From 318f5e65da5ae707464f80a33c9cf3dac0264ae7 Mon Sep 17 00:00:00 2001 From: elpatron Date: Sat, 6 Jun 2026 20:37:21 +0200 Subject: [PATCH] feat: add camera/gallery choice for photos & sync AI profile pref to server --- client/src/components/PhotoCapture.tsx | 95 +++++++++++++++---- .../src/components/UserProfilePreferences.tsx | 19 +++- client/src/i18n/locales/da.json | 2 + client/src/i18n/locales/de.json | 2 + client/src/i18n/locales/en.json | 2 + client/src/i18n/locales/nb.json | 2 + client/src/i18n/locales/sv.json | 2 + client/src/services/appearancePrefs.test.ts | 12 ++- client/src/services/appearancePrefs.ts | 21 +++- server/prisma/schema.prisma | 9 +- server/src/routes/auth.ts | 6 ++ 11 files changed, 141 insertions(+), 31 deletions(-) diff --git a/client/src/components/PhotoCapture.tsx b/client/src/components/PhotoCapture.tsx index 4e456ef..bfba2af 100644 --- a/client/src/components/PhotoCapture.tsx +++ b/client/src/components/PhotoCapture.tsx @@ -8,7 +8,8 @@ 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, Trash2 } from 'lucide-react' +import { Camera, Image, Trash2 } from 'lucide-react' +import { probeCameraAvailability } from '../utils/cameraAvailability.js' interface PhotoCaptureProps { entryId: string @@ -31,8 +32,22 @@ export default function PhotoCapture({ entryId, logbookId, readOnly = false, pre const [uploading, setUploading] = useState(false) const [error, setError] = useState(null) const [decryptedPhotos, setDecryptedPhotos] = useState([]) + const [hasCamera, setHasCamera] = useState(false) const fileInputRef = useRef(null) + const cameraInputRef = useRef(null) + + useEffect(() => { + let cancelled = false + probeCameraAvailability().then((avail) => { + if (!cancelled) { + setHasCamera(avail === 'available') + } + }) + return () => { + cancelled = true + } + }, []) // Reactively query local photos database const localPhotos = useLiveQuery( @@ -119,12 +134,18 @@ export default function PhotoCapture({ entryId, logbookId, readOnly = false, pre } } - const triggerSelect = () => { + const triggerGallerySelect = () => { if (fileInputRef.current) { fileInputRef.current.click() } } + const triggerCameraSelect = () => { + if (cameraInputRef.current) { + cameraInputRef.current.click() + } + } + return (
@@ -159,20 +180,62 @@ export default function PhotoCapture({ entryId, logbookId, readOnly = false, pre style={{ display: 'none' }} /> - + + + {hasCamera ? ( + <> + + + + ) : ( + + )}
)} diff --git a/client/src/components/UserProfilePreferences.tsx b/client/src/components/UserProfilePreferences.tsx index 21f6aca..87897b4 100644 --- a/client/src/components/UserProfilePreferences.tsx +++ b/client/src/components/UserProfilePreferences.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useState, useEffect } from 'react' import { useTranslation } from 'react-i18next' import { Compass, Palette, Save, Check, Cloud, Brain } from 'lucide-react' import ThemedSelect from './ThemedSelect.tsx' @@ -32,11 +32,23 @@ export default function UserProfilePreferences({ userId }: UserProfilePreference const [owmSaved, setOwmSaved] = useState(false) 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) => { setThemePreference(userId, nextTheme) setColorSchemePreference(userId, nextColorScheme) 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) }) } @@ -65,6 +77,9 @@ export default function UserProfilePreferences({ userId }: UserProfilePreference const nextVal = e.target.checked setAiAuthorizedState(nextVal) setAiAuthorized(userId, nextVal) + void saveAppearancePrefsToServer(theme, colorScheme, nextVal, userId).catch((err) => { + console.warn('Failed to save ai preference to server:', err) + }) } return ( diff --git a/client/src/i18n/locales/da.json b/client/src/i18n/locales/da.json index 069b855..33f63a6 100644 --- a/client/src/i18n/locales/da.json +++ b/client/src/i18n/locales/da.json @@ -443,6 +443,8 @@ "photo_caption_label": "Fotobeskrivelse/etiket (valgfrit)", "photo_caption_placeholder": "f.eks. at sætte sejl nær indsejlingen til havnen", "photo_btn": "Tag foto / upload", + "photo_camera_btn": "Tag foto", + "photo_gallery_btn": "Vælg fra galleri", "photo_processing": "Er ved at blive behandlet...", "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?", diff --git a/client/src/i18n/locales/de.json b/client/src/i18n/locales/de.json index df8c73a..c0bd62a 100644 --- a/client/src/i18n/locales/de.json +++ b/client/src/i18n/locales/de.json @@ -443,6 +443,8 @@ "photo_caption_label": "Foto-Beschreibung / Label (Optional)", "photo_caption_placeholder": "z.B. Segel setzen nahe Hafeneinfahrt", "photo_btn": "Foto aufnehmen / Hochladen", + "photo_camera_btn": "Foto aufnehmen", + "photo_gallery_btn": "Aus Galerie wählen", "photo_processing": "Wird verarbeitet...", "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?", diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index b898fd6..0f6acad 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -443,6 +443,8 @@ "photo_caption_label": "Photo Caption / Label (Optional)", "photo_caption_placeholder": "e.g. Setting sails near harbor entrance", "photo_btn": "Take Photo / Upload", + "photo_camera_btn": "Take Photo", + "photo_gallery_btn": "Choose from Gallery", "photo_processing": "Processing...", "no_photos": "No photos attached to this journal entry yet.", "photo_delete_confirm": "Are you sure you want to permanently delete this photo?", diff --git a/client/src/i18n/locales/nb.json b/client/src/i18n/locales/nb.json index 2c1bfa6..7d72a44 100644 --- a/client/src/i18n/locales/nb.json +++ b/client/src/i18n/locales/nb.json @@ -443,6 +443,8 @@ "photo_caption_label": "Fotobeskrivelse/etikett (valgfritt)", "photo_caption_placeholder": "f.eks. å sette seil nær innseilingen til havnen", "photo_btn": "Ta bilde / last opp", + "photo_camera_btn": "Ta bilde", + "photo_gallery_btn": "Velg fra galleri", "photo_processing": "...blir behandlet...", "no_photos": "Ingen bilder knyttet til denne reisedagen ennå.", "photo_delete_confirm": "Er du sikker på at du vil slette dette bildet permanent?", diff --git a/client/src/i18n/locales/sv.json b/client/src/i18n/locales/sv.json index 4afbaac..3088929 100644 --- a/client/src/i18n/locales/sv.json +++ b/client/src/i18n/locales/sv.json @@ -443,6 +443,8 @@ "photo_caption_label": "Fotobeskrivning/etikett (valfritt)", "photo_caption_placeholder": "t.ex. sätta segel nära hamninloppet", "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...", "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?", diff --git a/client/src/services/appearancePrefs.test.ts b/client/src/services/appearancePrefs.test.ts index ba95856..9ebce31 100644 --- a/client/src/services/appearancePrefs.test.ts +++ b/client/src/services/appearancePrefs.test.ts @@ -26,6 +26,7 @@ describe('appearancePrefs', () => { await expect(fetchAppearancePrefs()).resolves.toEqual({ theme: 'auto', colorScheme: 'auto', + aiAuthorized: false, persisted: false }) expect(mockedApiJson).not.toHaveBeenCalled() @@ -36,6 +37,7 @@ describe('appearancePrefs', () => { mockedApiJson.mockResolvedValueOnce({ theme: 'ocean', colorScheme: 'dark', + aiAuthorized: true, persisted: true }) @@ -46,6 +48,7 @@ describe('appearancePrefs', () => { 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_ai_authorized_${USER_ID}`)).toBe('true') expect(changed).toHaveBeenCalledTimes(1) }) @@ -53,20 +56,20 @@ describe('appearancePrefs', () => { localStorage.setItem('active_userid', USER_ID) setThemePreference(USER_ID, 'material') mockedApiJson - .mockResolvedValueOnce({ theme: 'auto', colorScheme: 'auto', persisted: false }) - .mockResolvedValueOnce({ theme: 'material', colorScheme: 'auto', persisted: true }) + .mockResolvedValueOnce({ theme: 'auto', colorScheme: 'auto', aiAuthorized: false, persisted: false }) + .mockResolvedValueOnce({ theme: 'material', colorScheme: 'auto', aiAuthorized: false, persisted: true }) await syncAppearancePrefs(USER_ID) expect(mockedApiJson).toHaveBeenCalledTimes(2) expect(mockedApiJson).toHaveBeenLastCalledWith('/api/auth/appearance-prefs', { 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 () => { - await saveAppearancePrefsToServer('ocean', 'light') + await saveAppearancePrefsToServer('ocean', 'light', true) expect(mockedApiJson).not.toHaveBeenCalled() }) @@ -76,6 +79,7 @@ describe('appearancePrefs', () => { mockedApiJson.mockResolvedValue({ theme: 'material', colorScheme: 'dark', + aiAuthorized: false, persisted: true }) diff --git a/client/src/services/appearancePrefs.ts b/client/src/services/appearancePrefs.ts index e55f87f..ca42a23 100644 --- a/client/src/services/appearancePrefs.ts +++ b/client/src/services/appearancePrefs.ts @@ -5,7 +5,9 @@ import { getColorSchemePreference, getThemePreference, setColorSchemePreference, - setThemePreference + setThemePreference, + getAiAuthorized, + setAiAuthorized } from './userPreferences.js' const API_BASE = '/api/auth/appearance-prefs' @@ -13,13 +15,15 @@ const API_BASE = '/api/auth/appearance-prefs' export interface AppearancePrefs { theme: string colorScheme: string + aiAuthorized: boolean persisted: boolean } function hasLocalAppearancePrefs(userId: string): boolean { return ( 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 { if (!resolveSyncedUserId(userId)) { - return { theme: 'auto', colorScheme: 'auto', persisted: false } + return { theme: 'auto', colorScheme: 'auto', aiAuthorized: false, persisted: false } } return apiJson(API_BASE) @@ -44,13 +48,14 @@ export async function fetchAppearancePrefs(userId?: string | null): Promise { if (!resolveSyncedUserId(userId)) return await apiJson(API_BASE, { 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 if (server.persisted) { setThemePreference(id, server.theme) setColorSchemePreference(id, server.colorScheme) + setAiAuthorized(id, server.aiAuthorized) } else if (hasLocalAppearancePrefs(id)) { - await saveAppearancePrefsToServer(getThemePreference(id), getColorSchemePreference(id), id) + await saveAppearancePrefsToServer( + getThemePreference(id), + getColorSchemePreference(id), + getAiAuthorized(id), + id + ) } } catch (err) { console.warn('Failed to sync appearance preferences:', err) diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 578df03..27da7fa 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -52,10 +52,11 @@ model UserNotificationPrefs { } model UserAppearancePrefs { - userId String @id - theme String @default("auto") - colorScheme String @default("auto") - updatedAt DateTime @updatedAt + userId String @id + theme String @default("auto") + colorScheme String @default("auto") + aiAuthorized Boolean @default(false) + updatedAt DateTime @updatedAt user User @relation(fields: [userId], references: [id], onDelete: Cascade) } diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index 7de83b3..3bd80fa 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -63,6 +63,7 @@ function isMissingAppearancePrefsTable(error: unknown): boolean { const DEFAULT_APPEARANCE_PREFS = { theme: 'auto', colorScheme: 'auto', + aiAuthorized: false, persisted: false } as const @@ -454,6 +455,7 @@ router.get('/appearance-prefs', requireUser, async (req: any, res) => { return res.json({ theme: prefs?.theme ?? 'auto', colorScheme: prefs?.colorScheme ?? 'auto', + aiAuthorized: prefs?.aiAuthorized ?? false, persisted: prefs != null }) } catch (error: unknown) { @@ -469,6 +471,7 @@ router.put('/appearance-prefs', requireUser, async (req: any, res) => { try { const theme = parseThemePreference(req.body?.theme) const colorScheme = parseColorSchemePreference(req.body?.colorScheme) + const aiAuthorized = req.body?.aiAuthorized === true if (!theme || !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, theme, colorScheme, + aiAuthorized, updatedAt: new Date() }, update: { theme, colorScheme, + aiAuthorized, updatedAt: new Date() } }) @@ -491,6 +496,7 @@ router.put('/appearance-prefs', requireUser, async (req: any, res) => { return res.json({ theme: prefs.theme, colorScheme: prefs.colorScheme, + aiAuthorized: prefs.aiAuthorized, persisted: true }) } catch (error: unknown) {