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
+79 -16
View File
@@ -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<string | null>(null)
const [decryptedPhotos, setDecryptedPhotos] = useState<DecryptedPhoto[]>([])
const [hasCamera, setHasCamera] = useState(false)
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
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 (
<div className="form-card mt-6">
<div className="form-header mb-4">
@@ -159,20 +180,62 @@ export default function PhotoCapture({ entryId, logbookId, readOnly = false, pre
style={{ display: 'none' }}
/>
<button
type="button"
className="btn primary"
onClick={triggerSelect}
disabled={uploading}
style={{ width: 'auto', padding: '12px 24px', display: 'flex', gap: '8px', alignItems: 'center' }}
>
{uploading ? (
<span className="spin"></span>
) : (
<Camera size={16} />
)}
{uploading ? t('logs.photo_processing') : t('logs.photo_btn')}
</button>
<input
type="file"
accept="image/*"
capture="environment"
ref={cameraInputRef}
onChange={handleFileChange}
style={{ display: 'none' }}
/>
{hasCamera ? (
<>
<button
type="button"
className="btn primary"
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}
style={{ width: 'auto', padding: '12px 24px', display: 'flex', gap: '8px', alignItems: 'center' }}
>
{uploading ? (
<span className="spin"></span>
) : (
<Camera size={16} />
)}
{uploading ? t('logs.photo_processing') : t('logs.photo_btn')}
</button>
)}
</div>
</div>
)}
@@ -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 (