Compare commits
13 Commits
a9c3e9ce3e
...
v0.1.1.24
| Author | SHA1 | Date | |
|---|---|---|---|
| 6848390ffa | |||
| 65d2215a35 | |||
| f321e5bbd1 | |||
| d2961b050a | |||
| 6943fd2dc4 | |||
| f332eccf22 | |||
| 9d2a19dbf8 | |||
| e3cd89be5d | |||
| a86da72b04 | |||
| 7d6f381f55 | |||
| 878be33b7c | |||
| 318f5e65da | |||
| 8c6ab59d67 |
@@ -2090,6 +2090,7 @@ html.scheme-dark .themed-select-option.is-selected {
|
|||||||
cursor: text;
|
cursor: text;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
transition: background-color 0.15s ease;
|
transition: background-color 0.15s ease;
|
||||||
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logbook-title-editable:hover {
|
.logbook-title-editable:hover {
|
||||||
@@ -2105,6 +2106,7 @@ html.scheme-dark .themed-select-option.is-selected {
|
|||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-icon {
|
.card-icon {
|
||||||
@@ -3184,6 +3186,7 @@ html.theme-cupertino .events-scroll-container {
|
|||||||
aspect-ratio: 16 / 9;
|
aspect-ratio: 16 / 9;
|
||||||
background: #0b0c10;
|
background: #0b0c10;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.photo-container img {
|
.photo-container img {
|
||||||
@@ -3230,6 +3233,78 @@ html.theme-cupertino .events-scroll-container {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Photo Maximized Overlay */
|
||||||
|
.photo-maximized-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(11, 12, 16, 0.9);
|
||||||
|
backdrop-filter: blur(15px);
|
||||||
|
-webkit-backdrop-filter: blur(15px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 11000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-maximized-container {
|
||||||
|
position: relative;
|
||||||
|
max-width: 90vw;
|
||||||
|
max-height: 90vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-maximized-img {
|
||||||
|
max-width: 90vw;
|
||||||
|
max-height: 80vh;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.6);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-maximized-close {
|
||||||
|
position: absolute;
|
||||||
|
top: -48px;
|
||||||
|
right: 0;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
color: #f1f5f9;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-maximized-close:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
border-color: #ffffff;
|
||||||
|
transform: scale(1.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-maximized-caption {
|
||||||
|
font-size: 15px;
|
||||||
|
color: #f1f5f9;
|
||||||
|
background: rgba(15, 23, 42, 0.75);
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 20px;
|
||||||
|
max-width: 80%;
|
||||||
|
text-align: center;
|
||||||
|
backdrop-filter: blur(5px);
|
||||||
|
-webkit-backdrop-filter: blur(5px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
/* Custom Dialog Modals Styling */
|
/* Custom Dialog Modals Styling */
|
||||||
.custom-dialog-overlay {
|
.custom-dialog-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -4364,6 +4439,7 @@ html.theme-cupertino .events-scroll-container {
|
|||||||
.consumption-grid .input-group .input-text {
|
.consumption-grid .input-group .input-text {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
-moz-appearance: textfield;
|
-moz-appearance: textfield;
|
||||||
|
appearance: textfield;
|
||||||
}
|
}
|
||||||
|
|
||||||
.consumption-grid .input-text::-webkit-outer-spin-button,
|
.consumption-grid .input-text::-webkit-outer-spin-button,
|
||||||
@@ -6236,3 +6312,214 @@ body.app-tour-active .feedback-modal-overlay--tour .disclaimer-modal-panel {
|
|||||||
.crew-selection-item input {
|
.crew-selection-item input {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Responsive Event Cards */
|
||||||
|
.events-desktop-only {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.events-mobile-only {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.events-desktop-only {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.events-mobile-only {
|
||||||
|
display: flex !important;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-mobile-card {
|
||||||
|
background: var(--app-surface-alt);
|
||||||
|
border: 1px solid var(--app-border-subtle);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-mobile-card:hover {
|
||||||
|
border-color: var(--app-border);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card-time {
|
||||||
|
color: #fbbf24;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 15px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--app-border-subtle);
|
||||||
|
margin: 0;
|
||||||
|
border: none;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card-chip {
|
||||||
|
background: var(--app-surface-hover, rgba(255, 255, 255, 0.03));
|
||||||
|
border: 1px solid var(--app-border-muted);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #cbd5e1;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card-chip svg {
|
||||||
|
color: var(--app-text-muted, #94a3b8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card-weather-img {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card-remarks {
|
||||||
|
background: var(--app-surface-inset, rgba(11, 12, 16, 0.2));
|
||||||
|
border-left: 3px solid var(--app-accent, #fbbf24);
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 0 8px 8px 0;
|
||||||
|
font-size: 13.5px;
|
||||||
|
color: #e2e8f0;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Accordion Styling */
|
||||||
|
.accordion-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
padding: 8px 12px;
|
||||||
|
margin: -8px -12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: background-color 0.2s ease, color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-header:hover {
|
||||||
|
background-color: var(--app-surface-hover, rgba(255, 255, 255, 0.03));
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-header-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-chevron {
|
||||||
|
color: var(--app-text-muted, #94a3b8);
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Specific styling for nested member-editor-card header */
|
||||||
|
.member-editor-card .accordion-header {
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(255, 255, 255, 0.01);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-editor-card .accordion-header:hover {
|
||||||
|
background: var(--app-surface-hover, rgba(255, 255, 255, 0.03));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Column Selector / Customizer Popover */
|
||||||
|
.column-selector-popover {
|
||||||
|
position: absolute;
|
||||||
|
top: 40px;
|
||||||
|
right: 0;
|
||||||
|
width: 240px;
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--app-surface-alt, rgba(18, 20, 26, 0.98));
|
||||||
|
border: 1px solid var(--app-border, rgba(255, 255, 255, 0.1));
|
||||||
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 100;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-selector-title {
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--app-accent, #fbbf24);
|
||||||
|
margin: 0;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
border-bottom: 1px solid var(--app-border-subtle, rgba(255, 255, 255, 0.06));
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-selector-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-selector-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--app-text-muted, #cbd5e1);
|
||||||
|
padding: 4px 6px;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: background-color 0.15s ease, color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-selector-item:hover {
|
||||||
|
background: var(--app-surface-hover, rgba(255, 255, 255, 0.04));
|
||||||
|
color: var(--app-text, #ffffff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-selector-item input[type="checkbox"] {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
accent-color: var(--app-accent, #fbbf24);
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react'
|
import React, { useState, useEffect, useRef } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { db } from '../services/db.js'
|
import { db } from '../services/db.js'
|
||||||
import { getActiveMasterKey } from '../services/auth.js'
|
import { getActiveMasterKey } from '../services/auth.js'
|
||||||
@@ -8,7 +9,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, X } from 'lucide-react'
|
||||||
|
import { probeCameraAvailability } from '../utils/cameraAvailability.js'
|
||||||
|
|
||||||
interface PhotoCaptureProps {
|
interface PhotoCaptureProps {
|
||||||
entryId: string
|
entryId: string
|
||||||
@@ -31,8 +33,38 @@ 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 [maximizedPhoto, setMaximizedPhoto] = useState<DecryptedPhoto | null>(null)
|
||||||
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const cameraInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!maximizedPhoto) return
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setMaximizedPhoto(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}
|
||||||
|
}, [maximizedPhoto])
|
||||||
|
|
||||||
|
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 +151,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,20 +197,62 @@ export default function PhotoCapture({ entryId, logbookId, readOnly = false, pre
|
|||||||
style={{ display: 'none' }}
|
style={{ display: 'none' }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<button
|
<input
|
||||||
type="button"
|
type="file"
|
||||||
className="btn primary"
|
accept="image/*"
|
||||||
onClick={triggerSelect}
|
capture="environment"
|
||||||
disabled={uploading}
|
ref={cameraInputRef}
|
||||||
style={{ width: 'auto', padding: '12px 24px', display: 'flex', gap: '8px', alignItems: 'center' }}
|
onChange={handleFileChange}
|
||||||
>
|
style={{ display: 'none' }}
|
||||||
{uploading ? (
|
/>
|
||||||
<span className="spin">⏳</span>
|
|
||||||
) : (
|
{hasCamera ? (
|
||||||
<Camera size={16} />
|
<>
|
||||||
)}
|
<button
|
||||||
{uploading ? t('logs.photo_processing') : t('logs.photo_btn')}
|
type="button"
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -183,14 +263,22 @@ export default function PhotoCapture({ entryId, logbookId, readOnly = false, pre
|
|||||||
) : (
|
) : (
|
||||||
<div className="photo-attachments-grid">
|
<div className="photo-attachments-grid">
|
||||||
{decryptedPhotos.map((photo) => (
|
{decryptedPhotos.map((photo) => (
|
||||||
<div key={photo.payloadId} className="photo-card glass">
|
<div
|
||||||
|
key={photo.payloadId}
|
||||||
|
className="photo-card glass"
|
||||||
|
onClick={() => setMaximizedPhoto(photo)}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
>
|
||||||
<div className="photo-container">
|
<div className="photo-container">
|
||||||
<img src={photo.image} alt={photo.caption || 'Attachment'} loading="lazy" />
|
<img src={photo.image} alt={photo.caption || 'Attachment'} loading="lazy" />
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="photo-btn-delete"
|
className="photo-btn-delete"
|
||||||
onClick={() => handleDelete(photo.payloadId)}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleDelete(photo.payloadId)
|
||||||
|
}}
|
||||||
title="Remove photo"
|
title="Remove photo"
|
||||||
>
|
>
|
||||||
<Trash2 size={16} />
|
<Trash2 size={16} />
|
||||||
@@ -206,6 +294,35 @@ export default function PhotoCapture({ entryId, logbookId, readOnly = false, pre
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{maximizedPhoto && createPortal(
|
||||||
|
<div
|
||||||
|
className="photo-maximized-overlay"
|
||||||
|
onClick={() => setMaximizedPhoto(null)}
|
||||||
|
>
|
||||||
|
<div className="photo-maximized-container" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="photo-maximized-close"
|
||||||
|
onClick={() => setMaximizedPhoto(null)}
|
||||||
|
aria-label={t('common.close') || 'Close'}
|
||||||
|
>
|
||||||
|
<X size={24} />
|
||||||
|
</button>
|
||||||
|
<img
|
||||||
|
src={maximizedPhoto.image}
|
||||||
|
alt={maximizedPhoto.caption || 'Maximized Attachment'}
|
||||||
|
className="photo-maximized-img"
|
||||||
|
/>
|
||||||
|
{maximizedPhoto.caption && (
|
||||||
|
<div className="photo-maximized-caption">
|
||||||
|
{maximizedPhoto.caption}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
</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 (
|
||||||
|
|||||||
@@ -185,7 +185,10 @@
|
|||||||
"travel_day_number": "Rejsedag {{number}}",
|
"travel_day_number": "Rejsedag {{number}}",
|
||||||
"departure": "Starthavn (rejse fra)",
|
"departure": "Starthavn (rejse fra)",
|
||||||
"destination": "Destinationsport (til)",
|
"destination": "Destinationsport (til)",
|
||||||
"route": "Rejse fra/til",
|
"route": "Reje fra/til",
|
||||||
|
"tanks": "Tanke",
|
||||||
|
"customize_columns": "Tilpas kolonner",
|
||||||
|
"column_selector_title": "Kolonner at vise",
|
||||||
"freshwater": "Ferskvand (liter)",
|
"freshwater": "Ferskvand (liter)",
|
||||||
"fuel": "Treibstoff / Brændstof (liter)",
|
"fuel": "Treibstoff / Brændstof (liter)",
|
||||||
"greywater": "Gråt vand (liter)",
|
"greywater": "Gråt vand (liter)",
|
||||||
@@ -439,10 +442,12 @@
|
|||||||
"ai_summary_error_rate_limited": "Maksimalt antal genereringer nået for denne rejsedag.",
|
"ai_summary_error_rate_limited": "Maksimalt antal genereringer nået for denne rejsedag.",
|
||||||
"ai_summary_error_forbidden": "Kun skipperen må generere AI-resuméer.",
|
"ai_summary_error_forbidden": "Kun skipperen må generere AI-resuméer.",
|
||||||
"ai_summary_offline": "AI-resumé kræver internetforbindelse. Du er offline lige nu.",
|
"ai_summary_offline": "AI-resumé kræver internetforbindelse. Du er offline lige nu.",
|
||||||
"photos_title": "Vedhæftede billeder (E2E-krypteret)",
|
"photos_title": "Vedhæftede billeder",
|
||||||
"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?",
|
||||||
|
|||||||
@@ -186,6 +186,9 @@
|
|||||||
"departure": "Start-Hafen (Reise von)",
|
"departure": "Start-Hafen (Reise von)",
|
||||||
"destination": "Ziel-Hafen (nach)",
|
"destination": "Ziel-Hafen (nach)",
|
||||||
"route": "Reise von/nach",
|
"route": "Reise von/nach",
|
||||||
|
"tanks": "Tanks",
|
||||||
|
"customize_columns": "Spalten anpassen",
|
||||||
|
"column_selector_title": "Anzuzeigende Spalten",
|
||||||
"freshwater": "Frischwasser (Liter)",
|
"freshwater": "Frischwasser (Liter)",
|
||||||
"fuel": "Treibstoff / Fuel (Liter)",
|
"fuel": "Treibstoff / Fuel (Liter)",
|
||||||
"greywater": "Grauwasser (Liter)",
|
"greywater": "Grauwasser (Liter)",
|
||||||
@@ -439,10 +442,12 @@
|
|||||||
"ai_summary_error_rate_limited": "Maximale Anzahl an Generierungen für diesen Reisetag erreicht.",
|
"ai_summary_error_rate_limited": "Maximale Anzahl an Generierungen für diesen Reisetag erreicht.",
|
||||||
"ai_summary_error_forbidden": "Nur der Skipper darf KI-Zusammenfassungen generieren.",
|
"ai_summary_error_forbidden": "Nur der Skipper darf KI-Zusammenfassungen generieren.",
|
||||||
"ai_summary_offline": "Die KI-Zusammenfassung erfordert eine Internetverbindung. Du bist derzeit offline.",
|
"ai_summary_offline": "Die KI-Zusammenfassung erfordert eine Internetverbindung. Du bist derzeit offline.",
|
||||||
"photos_title": "Foto-Anhänge (E2E-verschlüsselt)",
|
"photos_title": "Foto-Anhänge",
|
||||||
"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?",
|
||||||
|
|||||||
@@ -186,6 +186,9 @@
|
|||||||
"departure": "Departure Port (von)",
|
"departure": "Departure Port (von)",
|
||||||
"destination": "Destination Port (nach)",
|
"destination": "Destination Port (nach)",
|
||||||
"route": "Route / Journey",
|
"route": "Route / Journey",
|
||||||
|
"tanks": "Tanks",
|
||||||
|
"customize_columns": "Customize columns",
|
||||||
|
"column_selector_title": "Columns to Show",
|
||||||
"freshwater": "Freshwater (Liters)",
|
"freshwater": "Freshwater (Liters)",
|
||||||
"fuel": "Fuel (Liters)",
|
"fuel": "Fuel (Liters)",
|
||||||
"greywater": "Greywater (Liters)",
|
"greywater": "Greywater (Liters)",
|
||||||
@@ -439,10 +442,12 @@
|
|||||||
"ai_summary_error_rate_limited": "Maximum number of generations reached for this travel day.",
|
"ai_summary_error_rate_limited": "Maximum number of generations reached for this travel day.",
|
||||||
"ai_summary_error_forbidden": "Only the skipper may generate AI summaries.",
|
"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.",
|
"ai_summary_offline": "AI summary generation requires an internet connection. You are currently offline.",
|
||||||
"photos_title": "Photo Attachments (E2E Encrypted)",
|
"photos_title": "Photo Attachments",
|
||||||
"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?",
|
||||||
|
|||||||
@@ -186,6 +186,9 @@
|
|||||||
"departure": "Starthavn (reise fra)",
|
"departure": "Starthavn (reise fra)",
|
||||||
"destination": "Destinasjonsport (til)",
|
"destination": "Destinasjonsport (til)",
|
||||||
"route": "Reise fra/til",
|
"route": "Reise fra/til",
|
||||||
|
"tanks": "Tanker",
|
||||||
|
"customize_columns": "Tilpass kolonner",
|
||||||
|
"column_selector_title": "Kolonner å vise",
|
||||||
"freshwater": "Ferskvann (liter)",
|
"freshwater": "Ferskvann (liter)",
|
||||||
"fuel": "Drivstoff / Drivstoff (liter)",
|
"fuel": "Drivstoff / Drivstoff (liter)",
|
||||||
"greywater": "Gråvann (liter)",
|
"greywater": "Gråvann (liter)",
|
||||||
@@ -439,10 +442,12 @@
|
|||||||
"ai_summary_error_rate_limited": "Maksimalt antall genereringer nådd for denne reisedagen.",
|
"ai_summary_error_rate_limited": "Maksimalt antall genereringer nådd for denne reisedagen.",
|
||||||
"ai_summary_error_forbidden": "Kun skipperen kan generere AI-sammendrag.",
|
"ai_summary_error_forbidden": "Kun skipperen kan generere AI-sammendrag.",
|
||||||
"ai_summary_offline": "AI-sammendrag krever internettforbindelse. Du er frakoblet.",
|
"ai_summary_offline": "AI-sammendrag krever internettforbindelse. Du er frakoblet.",
|
||||||
"photos_title": "Bildevedlegg (E2E-kryptert)",
|
"photos_title": "Bildevedlegg",
|
||||||
"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?",
|
||||||
|
|||||||
@@ -186,6 +186,9 @@
|
|||||||
"departure": "Starthamn (resa från)",
|
"departure": "Starthamn (resa från)",
|
||||||
"destination": "Destinationsport (till)",
|
"destination": "Destinationsport (till)",
|
||||||
"route": "Resa från/till",
|
"route": "Resa från/till",
|
||||||
|
"tanks": "Tankar",
|
||||||
|
"customize_columns": "Anpassa kolumner",
|
||||||
|
"column_selector_title": "Kolumner att visa",
|
||||||
"freshwater": "Färskvatten (liter)",
|
"freshwater": "Färskvatten (liter)",
|
||||||
"fuel": "Treibstoff / Bränsle (liter)",
|
"fuel": "Treibstoff / Bränsle (liter)",
|
||||||
"greywater": "Gråvatten (liter)",
|
"greywater": "Gråvatten (liter)",
|
||||||
@@ -439,10 +442,12 @@
|
|||||||
"ai_summary_error_rate_limited": "Maximalt antal genereringar nått för denna resedag.",
|
"ai_summary_error_rate_limited": "Maximalt antal genereringar nått för denna resedag.",
|
||||||
"ai_summary_error_forbidden": "Endast skepparen får generera AI-sammanfattningar.",
|
"ai_summary_error_forbidden": "Endast skepparen får generera AI-sammanfattningar.",
|
||||||
"ai_summary_offline": "AI-sammanfattning kräver internetanslutning. Du är offline.",
|
"ai_summary_offline": "AI-sammanfattning kräver internetanslutning. Du är offline.",
|
||||||
"photos_title": "Fotobilagor (E2E-krypterade)",
|
"photos_title": "Fotobilagor",
|
||||||
"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?",
|
||||||
|
|||||||
@@ -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
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -52,10 +52,11 @@ model UserNotificationPrefs {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model UserAppearancePrefs {
|
model UserAppearancePrefs {
|
||||||
userId String @id
|
userId String @id
|
||||||
theme String @default("auto")
|
theme String @default("auto")
|
||||||
colorScheme String @default("auto")
|
colorScheme String @default("auto")
|
||||||
updatedAt DateTime @updatedAt
|
aiAuthorized Boolean @default(false)
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user