Compare commits

..

2 Commits

11 changed files with 338 additions and 12 deletions
+45
View File
@@ -3336,6 +3336,51 @@ html.theme-cupertino .events-scroll-container {
word-break: break-word;
}
.photo-maximized-nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.2);
color: #f1f5f9;
border-radius: 50%;
width: 56px;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
z-index: 11005;
}
.photo-maximized-nav:hover {
background: rgba(255, 255, 255, 0.2);
border-color: #ffffff;
transform: translateY(-50%) scale(1.08);
}
.photo-maximized-prev {
left: 24px;
}
.photo-maximized-next {
right: 24px;
}
@media (max-width: 768px) {
.photo-maximized-nav {
width: 44px;
height: 44px;
}
.photo-maximized-prev {
left: 12px;
}
.photo-maximized-next {
right: 12px;
}
}
/* Custom Dialog Modals Styling */
.custom-dialog-overlay {
position: fixed;
+138 -2
View File
@@ -1,12 +1,13 @@
import React, { useState, useEffect, useCallback, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { db } from '../services/db.js'
import { getActiveMasterKey } from '../services/auth.js'
import { getActiveMasterKey, hasUnlockedLocalCrypto } from '../services/auth.js'
import { getLogbookKey } from '../services/logbookKeys.js'
import { encryptJson } from '../services/crypto.js'
import { encryptJson, decryptJson } from '../services/crypto.js'
import { syncLogbook } from '../services/sync.js'
import { downloadCsv, shareCsv } from '../services/csvExport.js'
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
import { buildZipArchive } from '../services/logbookBackup/zipArchive.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { getErrorMessage } from '../utils/errors.js'
import { findTodayEntryId, pruneEmptyTodayDuplicates, tryDecryptEntryPayload } from '../services/quickEventLog.js'
@@ -59,6 +60,42 @@ interface DecryptedEntryItem {
skipperSignStatus: SkipperSignStatus
}
// Helper to convert data URL to Uint8Array for zip packaging
function dataUrlToUint8Array(dataUrl: string): { data: Uint8Array; ext: string } {
const parts = dataUrl.split(',')
if (parts.length < 2) {
throw new Error('Invalid data URL')
}
const meta = parts[0]
const base64Data = parts[1]
let ext = 'jpg'
const mimeMatch = meta.match(/data:([^;]+)/)
if (mimeMatch) {
const mime = mimeMatch[1]
if (mime === 'image/png') ext = 'png'
else if (mime === 'image/gif') ext = 'gif'
else if (mime === 'image/webp') ext = 'webp'
else if (mime === 'image/heic') ext = 'heic'
else if (mime === 'image/heif') ext = 'heif'
}
const binaryString = atob(base64Data)
const bytes = new Uint8Array(binaryString.length)
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i)
}
return { data: bytes, ext }
}
function sanitizeFilename(str: string): string {
return str
.replace(/[^\w\s-]/gi, '')
.trim()
.replace(/\s+/g, '_')
.slice(0, 30)
}
export default function LogEntriesList({
logbookId,
readOnly = false,
@@ -257,6 +294,90 @@ export default function LogEntriesList({
}
}
const handleDownloadPhotosZip = async () => {
setExporting(true)
setError(null)
try {
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
// Fetch all photos for this logbook from IndexedDB
const localPhotos = await db.photos.where({ logbookId }).toArray()
if (localPhotos.length === 0) {
setError(t('logs.no_photos_to_download'))
return
}
// Build a map of entry ID to entry info for filename lookup
const entryMap = new Map<string, DecryptedEntryItem>()
entries.forEach((e) => entryMap.set(e.id, e))
const files: Record<string, Uint8Array> = {}
const usedNames = new Set<string>()
for (const photo of localPhotos) {
// Decrypt photo payload (contains base64 image data and caption)
const decrypted = await decryptJson(photo.encryptedData, photo.iv, photo.tag, masterKey)
if (!decrypted || !decrypted.image) continue
const { data, ext } = dataUrlToUint8Array(decrypted.image)
// Construct unique, friendly filename
let fileBase = `photo_${photo.payloadId}`
const entry = entryMap.get(photo.entryId)
if (entry) {
const dateStr = entry.date || 'unknown-date'
const travelDay = entry.dayOfTravel ? `day-${entry.dayOfTravel}` : ''
const sanitizedCaption = decrypted.caption ? sanitizeFilename(decrypted.caption) : ''
const parts = [dateStr]
if (travelDay) parts.push(travelDay)
if (sanitizedCaption) parts.push(sanitizedCaption)
fileBase = parts.join('_')
} else if (decrypted.caption) {
fileBase = `photo_${sanitizeFilename(decrypted.caption)}`
}
// De-duplicate name
let candidate = `${fileBase}.${ext}`
let counter = 1
while (usedNames.has(candidate.toLowerCase())) {
candidate = `${fileBase}_${counter}.${ext}`
counter++
}
usedNames.add(candidate.toLowerCase())
files[candidate] = data
}
if (Object.keys(files).length === 0) {
setError(t('logs.no_photos_to_download'))
return
}
const zipBytes = buildZipArchive(files)
const blob = new Blob([zipBytes as any], { type: 'application/zip' })
const url = URL.createObjectURL(blob)
const yachtName = preloadedYacht?.name || localStorage.getItem('active_logbook_title') || 'Logbook'
const safeTitle = yachtName.replace(/[^\w\s-]/g, '').trim().replace(/\s+/g, '-').slice(0, 40) || 'logbook'
const datePart = new Date().toISOString().slice(0, 10)
const filename = `${safeTitle}-photos-${datePart}.zip`
const anchor = document.createElement('a')
anchor.href = url
anchor.download = filename
anchor.click()
URL.revokeObjectURL(url)
} catch (err: any) {
console.error('Failed to download photos ZIP:', err)
setError(getErrorMessage(err, t('errors.export_failed')))
} finally {
setExporting(false)
}
}
const handleCreate = async () => {
if (readOnly) return
setError(null)
@@ -488,6 +609,21 @@ export default function LogEntriesList({
<span className="hide-mobile">{t('logs.share_csv')}</span>
</button>
{hasUnlockedLocalCrypto() && (
<button
className="btn secondary"
onClick={handleDownloadPhotosZip}
disabled={loading || exporting || entries.length === 0}
style={{ width: 'auto', padding: '8px 16px' }}
title={t('logs.export_photos_zip')}
>
<Download size={16} />
<span className="hide-mobile">
{exporting ? t('logs.exporting_photos_zip') : t('logs.export_photos_zip')}
</span>
</button>
)}
{!readOnly && (
<button className="btn primary" onClick={handleCreate} disabled={loading || exporting} style={{ width: 'auto', padding: '8px 16px' }} title={t('logs.new_entry')}>
<Plus size={16} />
+76 -2
View File
@@ -9,7 +9,7 @@ import { saveEntryPhoto, deleteEntryPhoto } from '../services/photoAttachments.j
import { fileToCompressedJpegDataUrl } from '../utils/imageCompress.js'
import { useLiveQuery } from 'dexie-react-hooks'
import { useDialog } from './ModalDialog.tsx'
import { Camera, Image, Trash2, X, ChevronDown, ChevronUp } from 'lucide-react'
import { Camera, Image, Trash2, X, ChevronDown, ChevronUp, ChevronLeft, ChevronRight } from 'lucide-react'
import { probeCameraAvailability } from '../utils/cameraAvailability.js'
interface PhotoCaptureProps {
@@ -39,6 +39,46 @@ export default function PhotoCapture({ entryId, logbookId, readOnly = false, pre
const fileInputRef = useRef<HTMLInputElement>(null)
const cameraInputRef = useRef<HTMLInputElement>(null)
const touchStartX = useRef<number>(0)
const touchEndX = useRef<number>(0)
const goToNext = () => {
if (!maximizedPhoto || decryptedPhotos.length <= 1) return
const currentIndex = decryptedPhotos.findIndex(p => p.payloadId === maximizedPhoto.payloadId)
if (currentIndex === -1) return
const nextIndex = (currentIndex + 1) % decryptedPhotos.length
setMaximizedPhoto(decryptedPhotos[nextIndex])
}
const goToPrev = () => {
if (!maximizedPhoto || decryptedPhotos.length <= 1) return
const currentIndex = decryptedPhotos.findIndex(p => p.payloadId === maximizedPhoto.payloadId)
if (currentIndex === -1) return
const prevIndex = (currentIndex - 1 + decryptedPhotos.length) % decryptedPhotos.length
setMaximizedPhoto(decryptedPhotos[prevIndex])
}
const handleTouchStart = (e: React.TouchEvent) => {
touchStartX.current = e.targetTouches[0].clientX
touchEndX.current = e.targetTouches[0].clientX
}
const handleTouchMove = (e: React.TouchEvent) => {
touchEndX.current = e.targetTouches[0].clientX
}
const handleTouchEnd = () => {
if (!touchStartX.current || !touchEndX.current) return
const diffX = touchStartX.current - touchEndX.current
const threshold = 50
if (diffX > threshold) {
goToNext()
} else if (diffX < -threshold) {
goToPrev()
}
touchStartX.current = 0
touchEndX.current = 0
}
useEffect(() => {
if (!maximizedPhoto) return
@@ -46,6 +86,10 @@ export default function PhotoCapture({ entryId, logbookId, readOnly = false, pre
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setMaximizedPhoto(null)
} else if (e.key === 'ArrowLeft' || e.key === 'Left') {
goToPrev()
} else if (e.key === 'ArrowRight' || e.key === 'Right') {
goToNext()
}
}
@@ -53,7 +97,7 @@ export default function PhotoCapture({ entryId, logbookId, readOnly = false, pre
return () => {
window.removeEventListener('keydown', handleKeyDown)
}
}, [maximizedPhoto])
}, [maximizedPhoto, decryptedPhotos])
useEffect(() => {
let cancelled = false
@@ -323,7 +367,37 @@ export default function PhotoCapture({ entryId, logbookId, readOnly = false, pre
<div
className="photo-maximized-overlay"
onClick={() => setMaximizedPhoto(null)}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
{decryptedPhotos.length > 1 && (
<>
<button
type="button"
className="photo-maximized-nav photo-maximized-prev"
onClick={(e) => {
e.stopPropagation()
goToPrev()
}}
aria-label={t('common.previous') || 'Previous'}
>
<ChevronLeft size={32} />
</button>
<button
type="button"
className="photo-maximized-nav photo-maximized-next"
onClick={(e) => {
e.stopPropagation()
goToNext()
}}
aria-label={t('common.next') || 'Next'}
>
<ChevronRight size={32} />
</button>
</>
)}
<div className="photo-maximized-container" onClick={(e) => e.stopPropagation()}>
<button
type="button"
+30 -1
View File
@@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { Settings as SettingsIcon, Check, Users, Trash2, Copy, Link as LinkIcon } from 'lucide-react'
import { Settings as SettingsIcon, Check, Users, Trash2, Copy, Link as LinkIcon, Share2 } from 'lucide-react'
import { ensureLogbookKey } from '../services/logbookKeys.js'
import LogbookBackupPanel from './LogbookBackupPanel.tsx'
import LinkQrCode from './LinkQrCode.tsx'
@@ -131,6 +131,24 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
}
}
const isShareSupported = typeof navigator !== 'undefined' && !!navigator.share
const handleShareLink = async () => {
if (shareLink) {
try {
await navigator.share({
title: t('seo.title') || 'Kapteins Daagbok',
text: t('settings.share_desc'),
url: shareLink
})
} catch (err: unknown) {
if (err instanceof Error && err.name !== 'AbortError') {
console.error('Sharing link failed:', err)
}
}
}
}
const loadCollaborators = async () => {
setLoadingCollabs(true)
setCollabError(null)
@@ -337,6 +355,17 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
>
{shareCopied ? <Check size={16} /> : <Copy size={16} />}
</button>
{isShareSupported && (
<button
type="button"
className="btn secondary"
onClick={() => void handleShareLink()}
style={{ width: 'auto', padding: '10px' }}
title={t('settings.share_btn')}
>
<Share2 size={16} />
</button>
)}
</div>
<LinkQrCode value={shareLink} />
</div>
+7 -1
View File
@@ -36,7 +36,9 @@
"unsaved_changes_stay": "Blive",
"unsaved_changes_save_leave": "Gem og afslut",
"unsaved_changes_discard": "Afvis",
"unsaved_changes_leave": "Forladt"
"unsaved_changes_leave": "Forladt",
"previous": "Forrige",
"next": "Næste"
},
"nav": {
"dashboard": "Dashboard",
@@ -445,6 +447,9 @@
"ai_summary_error_forbidden": "Kun skipperen må generere AI-opsummeringer.",
"ai_summary_offline": "AI-opsummeringen kræver en internetforbindelse. Du er i øjeblikket offline.",
"photos_title": "Fotobilag",
"export_photos_zip": "Download fotos (ZIP)",
"exporting_photos_zip": "Opretter ZIP...",
"no_photos_to_download": "Ingen fotos fundet i denne logbog.",
"photo_caption_label": "Billedbeskrivelse / Etiket (valgfrit)",
"photo_caption_placeholder": "f.eks. sætte sejl tæt på havneindsejlingen",
"photo_btn": "Tag/upload et billede",
@@ -820,6 +825,7 @@
"share_enable": "Aktivér offentligt link",
"share_copied": "Linket er kopieret!",
"share_copy_btn": "Kopier link",
"share_btn": "Del link",
"link_qr_hint": "QR-kode til scanning med en smartphone",
"link_qr_alt": "QR-kode til linket",
"danger_zone_title": "Farezone",
+7 -1
View File
@@ -36,7 +36,9 @@
"unsaved_changes_stay": "Bleiben",
"unsaved_changes_save_leave": "Speichern & verlassen",
"unsaved_changes_discard": "Verwerfen",
"unsaved_changes_leave": "Verlassen"
"unsaved_changes_leave": "Verlassen",
"previous": "Zurück",
"next": "Weiter"
},
"nav": {
"dashboard": "Dashboard",
@@ -445,6 +447,9 @@
"ai_summary_error_forbidden": "Nur der Skipper darf KI-Zusammenfassungen generieren.",
"ai_summary_offline": "Die KI-Zusammenfassung erfordert eine Internetverbindung. Du bist derzeit offline.",
"photos_title": "Foto-Anhänge",
"export_photos_zip": "Fotos herunterladen (ZIP)",
"exporting_photos_zip": "ZIP wird erstellt...",
"no_photos_to_download": "Keine Fotos in diesem Logbuch vorhanden.",
"photo_caption_label": "Foto-Beschreibung / Label (Optional)",
"photo_caption_placeholder": "z.B. Segel setzen nahe Hafeneinfahrt",
"photo_btn": "Foto aufnehmen / Hochladen",
@@ -820,6 +825,7 @@
"share_enable": "Öffentlichen Link aktivieren",
"share_copied": "Link kopiert!",
"share_copy_btn": "Link kopieren",
"share_btn": "Link teilen",
"link_qr_hint": "QR-Code zum Scannen mit dem Smartphone",
"link_qr_alt": "QR-Code für den Link",
"danger_zone_title": "Gefahrenzone",
+7 -1
View File
@@ -36,7 +36,9 @@
"unsaved_changes_stay": "Stay",
"unsaved_changes_save_leave": "Save & leave",
"unsaved_changes_discard": "Discard",
"unsaved_changes_leave": "Leave"
"unsaved_changes_leave": "Leave",
"previous": "Previous",
"next": "Next"
},
"nav": {
"dashboard": "Dashboard",
@@ -445,6 +447,9 @@
"ai_summary_error_forbidden": "Only the skipper may generate AI summaries.",
"ai_summary_offline": "AI summary generation requires an internet connection. You are currently offline.",
"photos_title": "Photo Attachments",
"export_photos_zip": "Download Photos (ZIP)",
"exporting_photos_zip": "Creating ZIP...",
"no_photos_to_download": "No photos found in this logbook.",
"photo_caption_label": "Photo Caption / Label (Optional)",
"photo_caption_placeholder": "e.g. Setting sails near harbor entrance",
"photo_btn": "Take Photo / Upload",
@@ -820,6 +825,7 @@
"share_enable": "Enable Public Link",
"share_copied": "Link copied!",
"share_copy_btn": "Copy Link",
"share_btn": "Share Link",
"link_qr_hint": "Scan this QR code with your phone",
"link_qr_alt": "QR code for the link",
"danger_zone_title": "Danger Zone",
+7 -1
View File
@@ -36,7 +36,9 @@
"unsaved_changes_stay": "Quedarse",
"unsaved_changes_save_leave": "Guardar y salir",
"unsaved_changes_discard": "Descartar",
"unsaved_changes_leave": "Abandonado"
"unsaved_changes_leave": "Abandonado",
"previous": "Anterior",
"next": "Siguiente"
},
"nav": {
"dashboard": "Panel de control",
@@ -445,6 +447,9 @@
"ai_summary_error_forbidden": "Solo el capitán puede generar resúmenes de IA.",
"ai_summary_offline": "El resumen generado por IA requiere una conexión a Internet. Actualmente no tienes conexión.",
"photos_title": "Archivos adjuntos con fotos",
"export_photos_zip": "Descargar fotos (ZIP)",
"exporting_photos_zip": "Creando archivo ZIP...",
"no_photos_to_download": "No hay fotos disponibles en este cuaderno de bitácora.",
"photo_caption_label": "Descripción de la foto / Etiqueta (opcional)",
"photo_caption_placeholder": "p. ej., izar las velas cerca de la entrada del puerto",
"photo_btn": "Hacer una foto / Subir una foto",
@@ -820,6 +825,7 @@
"share_enable": "Activar enlace público",
"share_copied": "¡Enlace copiado!",
"share_copy_btn": "Copiar enlace",
"share_btn": "Compartir enlace",
"link_qr_hint": "Código QR para escanear con el smartphone",
"link_qr_alt": "Código QR del enlace",
"danger_zone_title": "Zona de peligro",
+7 -1
View File
@@ -36,7 +36,9 @@
"unsaved_changes_stay": "Rester",
"unsaved_changes_save_leave": "Enregistrer et quitter",
"unsaved_changes_discard": "Rejeter",
"unsaved_changes_leave": "Quitter"
"unsaved_changes_leave": "Quitter",
"previous": "Précédent",
"next": "Suivant"
},
"nav": {
"dashboard": "Tableau de bord",
@@ -445,6 +447,9 @@
"ai_summary_error_forbidden": "Seul le skipper est autorisé à générer des résumés basés sur l'IA.",
"ai_summary_offline": "Le résumé généré par l'IA nécessite une connexion Internet. Tu es actuellement hors ligne.",
"photos_title": "Pièces jointes (photos)",
"export_photos_zip": "Télécharger les photos (ZIP)",
"exporting_photos_zip": "Création du fichier ZIP...",
"no_photos_to_download": "Aucune photo disponible dans ce journal.",
"photo_caption_label": "Description de la photo / Étiquette (facultatif)",
"photo_caption_placeholder": "par exemple, hisser les voiles près de l'entrée du port",
"photo_btn": "Prendre une photo / Télécharger une photo",
@@ -820,6 +825,7 @@
"share_enable": "Activer le lien public",
"share_copied": "Lien copié !",
"share_copy_btn": "Copier le lien",
"share_btn": "Partager le lien",
"link_qr_hint": "Code QR à scanner avec un smartphone",
"link_qr_alt": "Code QR pour le lien",
"danger_zone_title": "Zone dangereuse",
+7 -1
View File
@@ -36,7 +36,9 @@
"unsaved_changes_stay": "Bli",
"unsaved_changes_save_leave": "Lagre og avslutt",
"unsaved_changes_discard": "Avvis",
"unsaved_changes_leave": "Forlatt"
"unsaved_changes_leave": "Forlatt",
"previous": "Forrige",
"next": "Neste"
},
"nav": {
"dashboard": "Dashbord",
@@ -445,6 +447,9 @@
"ai_summary_error_forbidden": "Bare skipperen har lov til å generere AI-sammendrag.",
"ai_summary_offline": "AI-sammendraget krever en internettforbindelse. Du er for øyeblikket frakoblet.",
"photos_title": "Bildevedlegg",
"export_photos_zip": "Last ned bilder (ZIP)",
"exporting_photos_zip": "Oppretter ZIP...",
"no_photos_to_download": "Ingen bilder i denne loggboken.",
"photo_caption_label": "Bildetekst / Etikett (valgfritt)",
"photo_caption_placeholder": "f.eks. sette seil nær havneinnløpet",
"photo_btn": "Ta bilde / Last opp",
@@ -820,6 +825,7 @@
"share_enable": "Aktiver offentlig lenke",
"share_copied": "Koblingen er kopiert!",
"share_copy_btn": "Kopier lenken",
"share_btn": "Del lenke",
"link_qr_hint": "QR-kode som kan skannes med smarttelefonen",
"link_qr_alt": "QR-kode for lenken",
"danger_zone_title": "Fareområde",
+7 -1
View File
@@ -36,7 +36,9 @@
"unsaved_changes_stay": "Stanna kvar",
"unsaved_changes_save_leave": "Spara och avsluta",
"unsaved_changes_discard": "Avvisa",
"unsaved_changes_leave": "Lämna"
"unsaved_changes_leave": "Lämna",
"previous": "Föregående",
"next": "Nästa"
},
"nav": {
"dashboard": "Instrumentpanelen",
@@ -445,6 +447,9 @@
"ai_summary_error_forbidden": "Endast skepparen får skapa AI-sammanfattningar.",
"ai_summary_offline": "AI-sammanfattningen kräver en internetanslutning. Du är för närvarande offline.",
"photos_title": "Bilagor med bilder",
"export_photos_zip": "Ladda ner bilder (ZIP)",
"exporting_photos_zip": "Skapar ZIP...",
"no_photos_to_download": "Inga bilder i denna loggbok.",
"photo_caption_label": "Bildbeskrivning / Etikett (valfritt)",
"photo_caption_placeholder": "t.ex. sätta segel nära hamninloppet",
"photo_btn": "Ta en bild / Ladda upp",
@@ -820,6 +825,7 @@
"share_enable": "Aktivera offentlig länk",
"share_copied": "Länken har kopierats!",
"share_copy_btn": "Kopiera länken",
"share_btn": "Dela länk",
"link_qr_hint": "QR-kod att skanna med smarttelefonen",
"link_qr_alt": "QR-kod för länken",
"danger_zone_title": "Farlig zon",