feat: add camera/gallery choice for photos & sync AI profile pref to server

This commit is contained in:
2026-06-06 20:37:21 +02:00
parent 8c6ab59d67
commit 318f5e65da
11 changed files with 141 additions and 31 deletions
+66 -3
View File
@@ -8,7 +8,8 @@ import { saveEntryPhoto, deleteEntryPhoto } from '../services/photoAttachments.j
import { fileToCompressedJpegDataUrl } from '../utils/imageCompress.js' import { fileToCompressedJpegDataUrl } from '../utils/imageCompress.js'
import { useLiveQuery } from 'dexie-react-hooks' import { useLiveQuery } from 'dexie-react-hooks'
import { useDialog } from './ModalDialog.tsx' import { useDialog } from './ModalDialog.tsx'
import { Camera, Trash2 } from 'lucide-react' import { Camera, Image, Trash2 } from 'lucide-react'
import { probeCameraAvailability } from '../utils/cameraAvailability.js'
interface PhotoCaptureProps { interface PhotoCaptureProps {
entryId: string entryId: string
@@ -31,8 +32,22 @@ export default function PhotoCapture({ entryId, logbookId, readOnly = false, pre
const [uploading, setUploading] = useState(false) const [uploading, setUploading] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [decryptedPhotos, setDecryptedPhotos] = useState<DecryptedPhoto[]>([]) const [decryptedPhotos, setDecryptedPhotos] = useState<DecryptedPhoto[]>([])
const [hasCamera, setHasCamera] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
const cameraInputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
let cancelled = false
probeCameraAvailability().then((avail) => {
if (!cancelled) {
setHasCamera(avail === 'available')
}
})
return () => {
cancelled = true
}
}, [])
// Reactively query local photos database // Reactively query local photos database
const localPhotos = useLiveQuery( const localPhotos = useLiveQuery(
@@ -119,12 +134,18 @@ export default function PhotoCapture({ entryId, logbookId, readOnly = false, pre
} }
} }
const triggerSelect = () => { const triggerGallerySelect = () => {
if (fileInputRef.current) { if (fileInputRef.current) {
fileInputRef.current.click() fileInputRef.current.click()
} }
} }
const triggerCameraSelect = () => {
if (cameraInputRef.current) {
cameraInputRef.current.click()
}
}
return ( return (
<div className="form-card mt-6"> <div className="form-card mt-6">
<div className="form-header mb-4"> <div className="form-header mb-4">
@@ -159,10 +180,51 @@ export default function PhotoCapture({ entryId, logbookId, readOnly = false, pre
style={{ display: 'none' }} style={{ display: 'none' }}
/> />
<input
type="file"
accept="image/*"
capture="environment"
ref={cameraInputRef}
onChange={handleFileChange}
style={{ display: 'none' }}
/>
{hasCamera ? (
<>
<button <button
type="button" type="button"
className="btn primary" className="btn primary"
onClick={triggerSelect} onClick={triggerCameraSelect}
disabled={uploading}
style={{ width: 'auto', padding: '12px 20px', display: 'flex', gap: '8px', alignItems: 'center' }}
>
{uploading ? (
<span className="spin"></span>
) : (
<Camera size={16} />
)}
{uploading ? t('logs.photo_processing') : t('logs.photo_camera_btn')}
</button>
<button
type="button"
className="btn secondary"
onClick={triggerGallerySelect}
disabled={uploading}
style={{ width: 'auto', padding: '12px 20px', display: 'flex', gap: '8px', alignItems: 'center' }}
>
{uploading ? (
<span className="spin"></span>
) : (
<Image size={16} />
)}
{uploading ? t('logs.photo_processing') : t('logs.photo_gallery_btn')}
</button>
</>
) : (
<button
type="button"
className="btn primary"
onClick={triggerGallerySelect}
disabled={uploading} disabled={uploading}
style={{ width: 'auto', padding: '12px 24px', display: 'flex', gap: '8px', alignItems: 'center' }} style={{ width: 'auto', padding: '12px 24px', display: 'flex', gap: '8px', alignItems: 'center' }}
> >
@@ -173,6 +235,7 @@ export default function PhotoCapture({ entryId, logbookId, readOnly = false, pre
)} )}
{uploading ? t('logs.photo_processing') : t('logs.photo_btn')} {uploading ? t('logs.photo_processing') : t('logs.photo_btn')}
</button> </button>
)}
</div> </div>
</div> </div>
)} )}
@@ -1,4 +1,4 @@
import { useState } from 'react' import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Compass, Palette, Save, Check, Cloud, Brain } from 'lucide-react' import { Compass, Palette, Save, Check, Cloud, Brain } from 'lucide-react'
import ThemedSelect from './ThemedSelect.tsx' import ThemedSelect from './ThemedSelect.tsx'
@@ -32,11 +32,23 @@ export default function UserProfilePreferences({ userId }: UserProfilePreference
const [owmSaved, setOwmSaved] = useState(false) const [owmSaved, setOwmSaved] = useState(false)
const [aiAuthorized, setAiAuthorizedState] = useState(() => getAiAuthorized(userId)) const [aiAuthorized, setAiAuthorizedState] = useState(() => getAiAuthorized(userId))
useEffect(() => {
const handleChanged = () => {
setTheme(getThemePreference(userId))
setColorScheme(getColorSchemePreference(userId))
setAiAuthorizedState(getAiAuthorized(userId))
}
window.addEventListener('appearance-changed', handleChanged)
return () => {
window.removeEventListener('appearance-changed', handleChanged)
}
}, [userId])
const persistAppearance = (nextTheme: string, nextColorScheme: string) => { const persistAppearance = (nextTheme: string, nextColorScheme: string) => {
setThemePreference(userId, nextTheme) setThemePreference(userId, nextTheme)
setColorSchemePreference(userId, nextColorScheme) setColorSchemePreference(userId, nextColorScheme)
notifyAppearanceChanged() notifyAppearanceChanged()
void saveAppearancePrefsToServer(nextTheme, nextColorScheme).catch((err) => { void saveAppearancePrefsToServer(nextTheme, nextColorScheme, aiAuthorized, userId).catch((err) => {
console.warn('Failed to save appearance prefs to server:', err) console.warn('Failed to save appearance prefs to server:', err)
}) })
} }
@@ -65,6 +77,9 @@ export default function UserProfilePreferences({ userId }: UserProfilePreference
const nextVal = e.target.checked const nextVal = e.target.checked
setAiAuthorizedState(nextVal) setAiAuthorizedState(nextVal)
setAiAuthorized(userId, nextVal) setAiAuthorized(userId, nextVal)
void saveAppearancePrefsToServer(theme, colorScheme, nextVal, userId).catch((err) => {
console.warn('Failed to save ai preference to server:', err)
})
} }
return ( return (
+2
View File
@@ -443,6 +443,8 @@
"photo_caption_label": "Fotobeskrivelse/etiket (valgfrit)", "photo_caption_label": "Fotobeskrivelse/etiket (valgfrit)",
"photo_caption_placeholder": "f.eks. at sætte sejl nær indsejlingen til havnen", "photo_caption_placeholder": "f.eks. at sætte sejl nær indsejlingen til havnen",
"photo_btn": "Tag foto / upload", "photo_btn": "Tag foto / upload",
"photo_camera_btn": "Tag foto",
"photo_gallery_btn": "Vælg fra galleri",
"photo_processing": "Er ved at blive behandlet...", "photo_processing": "Er ved at blive behandlet...",
"no_photos": "Der er endnu ingen billeder knyttet til denne rejsedag.", "no_photos": "Der er endnu ingen billeder knyttet til denne rejsedag.",
"photo_delete_confirm": "Er du sikker på, at du vil slette dette foto permanent?", "photo_delete_confirm": "Er du sikker på, at du vil slette dette foto permanent?",
+2
View File
@@ -443,6 +443,8 @@
"photo_caption_label": "Foto-Beschreibung / Label (Optional)", "photo_caption_label": "Foto-Beschreibung / Label (Optional)",
"photo_caption_placeholder": "z.B. Segel setzen nahe Hafeneinfahrt", "photo_caption_placeholder": "z.B. Segel setzen nahe Hafeneinfahrt",
"photo_btn": "Foto aufnehmen / Hochladen", "photo_btn": "Foto aufnehmen / Hochladen",
"photo_camera_btn": "Foto aufnehmen",
"photo_gallery_btn": "Aus Galerie wählen",
"photo_processing": "Wird verarbeitet...", "photo_processing": "Wird verarbeitet...",
"no_photos": "Noch keine Fotos an diesen Reisetag angehängt.", "no_photos": "Noch keine Fotos an diesen Reisetag angehängt.",
"photo_delete_confirm": "Bist du sicher, dass du dieses Foto unwiderruflich löschen möchtest?", "photo_delete_confirm": "Bist du sicher, dass du dieses Foto unwiderruflich löschen möchtest?",
+2
View File
@@ -443,6 +443,8 @@
"photo_caption_label": "Photo Caption / Label (Optional)", "photo_caption_label": "Photo Caption / Label (Optional)",
"photo_caption_placeholder": "e.g. Setting sails near harbor entrance", "photo_caption_placeholder": "e.g. Setting sails near harbor entrance",
"photo_btn": "Take Photo / Upload", "photo_btn": "Take Photo / Upload",
"photo_camera_btn": "Take Photo",
"photo_gallery_btn": "Choose from Gallery",
"photo_processing": "Processing...", "photo_processing": "Processing...",
"no_photos": "No photos attached to this journal entry yet.", "no_photos": "No photos attached to this journal entry yet.",
"photo_delete_confirm": "Are you sure you want to permanently delete this photo?", "photo_delete_confirm": "Are you sure you want to permanently delete this photo?",
+2
View File
@@ -443,6 +443,8 @@
"photo_caption_label": "Fotobeskrivelse/etikett (valgfritt)", "photo_caption_label": "Fotobeskrivelse/etikett (valgfritt)",
"photo_caption_placeholder": "f.eks. å sette seil nær innseilingen til havnen", "photo_caption_placeholder": "f.eks. å sette seil nær innseilingen til havnen",
"photo_btn": "Ta bilde / last opp", "photo_btn": "Ta bilde / last opp",
"photo_camera_btn": "Ta bilde",
"photo_gallery_btn": "Velg fra galleri",
"photo_processing": "...blir behandlet...", "photo_processing": "...blir behandlet...",
"no_photos": "Ingen bilder knyttet til denne reisedagen ennå.", "no_photos": "Ingen bilder knyttet til denne reisedagen ennå.",
"photo_delete_confirm": "Er du sikker på at du vil slette dette bildet permanent?", "photo_delete_confirm": "Er du sikker på at du vil slette dette bildet permanent?",
+2
View File
@@ -443,6 +443,8 @@
"photo_caption_label": "Fotobeskrivning/etikett (valfritt)", "photo_caption_label": "Fotobeskrivning/etikett (valfritt)",
"photo_caption_placeholder": "t.ex. sätta segel nära hamninloppet", "photo_caption_placeholder": "t.ex. sätta segel nära hamninloppet",
"photo_btn": "Ta foto / ladda upp", "photo_btn": "Ta foto / ladda upp",
"photo_camera_btn": "Ta foto",
"photo_gallery_btn": "Välj från galleri",
"photo_processing": "Håller på att bearbetas...", "photo_processing": "Håller på att bearbetas...",
"no_photos": "Inga foton kopplade till denna resdag ännu.", "no_photos": "Inga foton kopplade till denna resdag ännu.",
"photo_delete_confirm": "Är du säker på att du vill radera det här fotot permanent?", "photo_delete_confirm": "Är du säker på att du vill radera det här fotot permanent?",
+8 -4
View File
@@ -26,6 +26,7 @@ describe('appearancePrefs', () => {
await expect(fetchAppearancePrefs()).resolves.toEqual({ await expect(fetchAppearancePrefs()).resolves.toEqual({
theme: 'auto', theme: 'auto',
colorScheme: 'auto', colorScheme: 'auto',
aiAuthorized: false,
persisted: false persisted: false
}) })
expect(mockedApiJson).not.toHaveBeenCalled() expect(mockedApiJson).not.toHaveBeenCalled()
@@ -36,6 +37,7 @@ describe('appearancePrefs', () => {
mockedApiJson.mockResolvedValueOnce({ mockedApiJson.mockResolvedValueOnce({
theme: 'ocean', theme: 'ocean',
colorScheme: 'dark', colorScheme: 'dark',
aiAuthorized: true,
persisted: true persisted: true
}) })
@@ -46,6 +48,7 @@ describe('appearancePrefs', () => {
expect(localStorage.getItem(`user_pref_theme_${USER_ID}`)).toBe('ocean') expect(localStorage.getItem(`user_pref_theme_${USER_ID}`)).toBe('ocean')
expect(localStorage.getItem(`user_pref_color_scheme_${USER_ID}`)).toBe('dark') expect(localStorage.getItem(`user_pref_color_scheme_${USER_ID}`)).toBe('dark')
expect(localStorage.getItem(`user_pref_ai_authorized_${USER_ID}`)).toBe('true')
expect(changed).toHaveBeenCalledTimes(1) expect(changed).toHaveBeenCalledTimes(1)
}) })
@@ -53,20 +56,20 @@ describe('appearancePrefs', () => {
localStorage.setItem('active_userid', USER_ID) localStorage.setItem('active_userid', USER_ID)
setThemePreference(USER_ID, 'material') setThemePreference(USER_ID, 'material')
mockedApiJson mockedApiJson
.mockResolvedValueOnce({ theme: 'auto', colorScheme: 'auto', persisted: false }) .mockResolvedValueOnce({ theme: 'auto', colorScheme: 'auto', aiAuthorized: false, persisted: false })
.mockResolvedValueOnce({ theme: 'material', colorScheme: 'auto', persisted: true }) .mockResolvedValueOnce({ theme: 'material', colorScheme: 'auto', aiAuthorized: false, persisted: true })
await syncAppearancePrefs(USER_ID) await syncAppearancePrefs(USER_ID)
expect(mockedApiJson).toHaveBeenCalledTimes(2) expect(mockedApiJson).toHaveBeenCalledTimes(2)
expect(mockedApiJson).toHaveBeenLastCalledWith('/api/auth/appearance-prefs', { expect(mockedApiJson).toHaveBeenLastCalledWith('/api/auth/appearance-prefs', {
method: 'PUT', method: 'PUT',
body: JSON.stringify({ theme: 'material', colorScheme: 'auto' }) body: JSON.stringify({ theme: 'material', colorScheme: 'auto', aiAuthorized: false })
}) })
}) })
it('saveAppearancePrefsToServer skips when not authenticated', async () => { it('saveAppearancePrefsToServer skips when not authenticated', async () => {
await saveAppearancePrefsToServer('ocean', 'light') await saveAppearancePrefsToServer('ocean', 'light', true)
expect(mockedApiJson).not.toHaveBeenCalled() expect(mockedApiJson).not.toHaveBeenCalled()
}) })
@@ -76,6 +79,7 @@ describe('appearancePrefs', () => {
mockedApiJson.mockResolvedValue({ mockedApiJson.mockResolvedValue({
theme: 'material', theme: 'material',
colorScheme: 'dark', colorScheme: 'dark',
aiAuthorized: false,
persisted: true persisted: true
}) })
+16 -5
View File
@@ -5,7 +5,9 @@ import {
getColorSchemePreference, getColorSchemePreference,
getThemePreference, getThemePreference,
setColorSchemePreference, setColorSchemePreference,
setThemePreference setThemePreference,
getAiAuthorized,
setAiAuthorized
} from './userPreferences.js' } from './userPreferences.js'
const API_BASE = '/api/auth/appearance-prefs' const API_BASE = '/api/auth/appearance-prefs'
@@ -13,13 +15,15 @@ const API_BASE = '/api/auth/appearance-prefs'
export interface AppearancePrefs { export interface AppearancePrefs {
theme: string theme: string
colorScheme: string colorScheme: string
aiAuthorized: boolean
persisted: boolean persisted: boolean
} }
function hasLocalAppearancePrefs(userId: string): boolean { function hasLocalAppearancePrefs(userId: string): boolean {
return ( return (
localStorage.getItem(`user_pref_theme_${userId}`) != null || localStorage.getItem(`user_pref_theme_${userId}`) != null ||
localStorage.getItem(`user_pref_color_scheme_${userId}`) != null localStorage.getItem(`user_pref_color_scheme_${userId}`) != null ||
localStorage.getItem(`user_pref_ai_authorized_${userId}`) != null
) )
} }
@@ -35,7 +39,7 @@ function resolveSyncedUserId(userId?: string | null): string | null {
export async function fetchAppearancePrefs(userId?: string | null): Promise<AppearancePrefs> { export async function fetchAppearancePrefs(userId?: string | null): Promise<AppearancePrefs> {
if (!resolveSyncedUserId(userId)) { if (!resolveSyncedUserId(userId)) {
return { theme: 'auto', colorScheme: 'auto', persisted: false } return { theme: 'auto', colorScheme: 'auto', aiAuthorized: false, persisted: false }
} }
return apiJson<AppearancePrefs>(API_BASE) return apiJson<AppearancePrefs>(API_BASE)
@@ -44,13 +48,14 @@ export async function fetchAppearancePrefs(userId?: string | null): Promise<Appe
export async function saveAppearancePrefsToServer( export async function saveAppearancePrefsToServer(
theme: string, theme: string,
colorScheme: string, colorScheme: string,
aiAuthorized: boolean,
userId?: string | null userId?: string | null
): Promise<void> { ): Promise<void> {
if (!resolveSyncedUserId(userId)) return if (!resolveSyncedUserId(userId)) return
await apiJson<AppearancePrefs>(API_BASE, { await apiJson<AppearancePrefs>(API_BASE, {
method: 'PUT', method: 'PUT',
body: JSON.stringify({ theme, colorScheme }) body: JSON.stringify({ theme, colorScheme, aiAuthorized })
}) })
} }
@@ -65,8 +70,14 @@ export async function syncAppearancePrefs(userId?: string | null): Promise<void>
if (server.persisted) { if (server.persisted) {
setThemePreference(id, server.theme) setThemePreference(id, server.theme)
setColorSchemePreference(id, server.colorScheme) setColorSchemePreference(id, server.colorScheme)
setAiAuthorized(id, server.aiAuthorized)
} else if (hasLocalAppearancePrefs(id)) { } else if (hasLocalAppearancePrefs(id)) {
await saveAppearancePrefsToServer(getThemePreference(id), getColorSchemePreference(id), id) await saveAppearancePrefsToServer(
getThemePreference(id),
getColorSchemePreference(id),
getAiAuthorized(id),
id
)
} }
} catch (err) { } catch (err) {
console.warn('Failed to sync appearance preferences:', err) console.warn('Failed to sync appearance preferences:', err)
+1
View File
@@ -55,6 +55,7 @@ model UserAppearancePrefs {
userId String @id userId String @id
theme String @default("auto") theme String @default("auto")
colorScheme String @default("auto") colorScheme String @default("auto")
aiAuthorized Boolean @default(false)
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+6
View File
@@ -63,6 +63,7 @@ function isMissingAppearancePrefsTable(error: unknown): boolean {
const DEFAULT_APPEARANCE_PREFS = { const DEFAULT_APPEARANCE_PREFS = {
theme: 'auto', theme: 'auto',
colorScheme: 'auto', colorScheme: 'auto',
aiAuthorized: false,
persisted: false persisted: false
} as const } as const
@@ -454,6 +455,7 @@ router.get('/appearance-prefs', requireUser, async (req: any, res) => {
return res.json({ return res.json({
theme: prefs?.theme ?? 'auto', theme: prefs?.theme ?? 'auto',
colorScheme: prefs?.colorScheme ?? 'auto', colorScheme: prefs?.colorScheme ?? 'auto',
aiAuthorized: prefs?.aiAuthorized ?? false,
persisted: prefs != null persisted: prefs != null
}) })
} catch (error: unknown) { } catch (error: unknown) {
@@ -469,6 +471,7 @@ router.put('/appearance-prefs', requireUser, async (req: any, res) => {
try { try {
const theme = parseThemePreference(req.body?.theme) const theme = parseThemePreference(req.body?.theme)
const colorScheme = parseColorSchemePreference(req.body?.colorScheme) const colorScheme = parseColorSchemePreference(req.body?.colorScheme)
const aiAuthorized = req.body?.aiAuthorized === true
if (!theme || !colorScheme) { if (!theme || !colorScheme) {
return res.status(400).json({ error: 'Invalid theme or colorScheme' }) return res.status(400).json({ error: 'Invalid theme or colorScheme' })
} }
@@ -479,11 +482,13 @@ router.put('/appearance-prefs', requireUser, async (req: any, res) => {
userId: req.userId, userId: req.userId,
theme, theme,
colorScheme, colorScheme,
aiAuthorized,
updatedAt: new Date() updatedAt: new Date()
}, },
update: { update: {
theme, theme,
colorScheme, colorScheme,
aiAuthorized,
updatedAt: new Date() updatedAt: new Date()
} }
}) })
@@ -491,6 +496,7 @@ router.put('/appearance-prefs', requireUser, async (req: any, res) => {
return res.json({ return res.json({
theme: prefs.theme, theme: prefs.theme,
colorScheme: prefs.colorScheme, colorScheme: prefs.colorScheme,
aiAuthorized: prefs.aiAuthorized,
persisted: true persisted: true
}) })
} catch (error: unknown) { } catch (error: unknown) {