Compare commits

..

16 Commits

Author SHA1 Message Date
elpatron fafefff29b chore: release v0.1.1.25 2026-06-06 22:02:25 +02:00
elpatron 4fd7f3c6cf feat(journal): wrap Crew an diesem Reisetag card inside a collapsible accordion defaulting to collapsed 2026-06-06 21:59:25 +02:00
elpatron 262c48a01a chore: document COMPOSE_FILE in .env.example to lock environment compose stack configurations 2026-06-06 21:53:43 +02:00
elpatron 9ad3c2cf38 Add Database Size single metric and time series history chart to Admin Dashboard 2026-06-06 21:45:19 +02:00
elpatron 6848390ffa chore: release v0.1.1.24 2026-06-06 21:38:12 +02:00
elpatron 65d2215a35 Render maximized photo overlay via React Portal to resolve CSS stacking context issue 2026-06-06 21:33:47 +02:00
elpatron f321e5bbd1 Simplify photos_title localization across all languages by removing E2E encryption label 2026-06-06 21:32:01 +02:00
elpatron d2961b050a Rearrange journal cards layout according to user request order 2026-06-06 21:30:00 +02:00
elpatron 6943fd2dc4 Implement column selector customizer popover for chronological events logbook 2026-06-06 21:17:50 +02:00
elpatron f332eccf22 fix: restore click events for editing logbook title in dashboard 2026-06-06 21:11:29 +02:00
elpatron 9d2a19dbf8 feat: group freshwater, fuel, and greywater cards in collapsible Tanks section 2026-06-06 21:07:51 +02:00
elpatron e3cd89be5d feat: separate chronological events list and add event form into separate cards 2026-06-06 21:04:25 +02:00
elpatron a86da72b04 feat: implement collapsible accordions for event protocol list and form 2026-06-06 21:02:35 +02:00
elpatron 7d6f381f55 feat: implement responsive event cards for mobile viewports 2026-06-06 20:58:04 +02:00
elpatron 878be33b7c feat: add fullscreen photo viewer overlay on click & resolve appearance compat warnings 2026-06-06 20:40:13 +02:00
elpatron 318f5e65da feat: add camera/gallery choice for photos & sync AI profile pref to server 2026-06-06 20:37:21 +02:00
19 changed files with 1677 additions and 710 deletions
+2
View File
@@ -34,6 +34,8 @@ ORIGIN=http://localhost:5173
# POSTGRES_USER=postgres
# POSTGRES_PASSWORD=
# POSTGRES_DB=daagbox
# Optional: lock Docker Compose to a specific configuration file (e.g. staging or production) on the server:
# COMPOSE_FILE=docker-compose.staging.yml
# Optional: comma-separated CORS origins (defaults to ORIGIN; 127.0.0.1 may be allowed for CORS but not for login)
# CORS_ORIGINS=http://localhost:5173
+1 -1
View File
@@ -1 +1 @@
0.1.1.24
0.1.1.26
+287
View File
@@ -2090,6 +2090,7 @@ html.scheme-dark .themed-select-option.is-selected {
cursor: text;
border-radius: 4px;
transition: background-color 0.15s ease;
pointer-events: auto;
}
.logbook-title-editable:hover {
@@ -2105,6 +2106,7 @@ html.scheme-dark .themed-select-option.is-selected {
font-size: 16px;
font-weight: 600;
line-height: 1.4;
pointer-events: auto;
}
.card-icon {
@@ -3184,6 +3186,7 @@ html.theme-cupertino .events-scroll-container {
aspect-ratio: 16 / 9;
background: #0b0c10;
overflow: hidden;
cursor: pointer;
}
.photo-container img {
@@ -3230,6 +3233,78 @@ html.theme-cupertino .events-scroll-container {
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-overlay {
position: fixed;
@@ -4364,6 +4439,7 @@ html.theme-cupertino .events-scroll-container {
.consumption-grid .input-group .input-text {
flex-shrink: 0;
-moz-appearance: textfield;
appearance: textfield;
}
.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 {
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);
}
+15 -3
View File
@@ -7,12 +7,22 @@ import {
type AdminTimeSeriesResponse,
type AdminTimeBucket
} from '../services/adminApi.js'
import { BarChart2, Bookmark, ChevronLeft, Image, MapPin, Mic, Users } from 'lucide-react'
import { BarChart2, Bookmark, ChevronLeft, Database, Image, MapPin, Mic, Users } from 'lucide-react'
function formatNumber(value: number): string {
return value.toLocaleString()
}
function formatBytes(bytes: number | undefined): string {
if (bytes === undefined || bytes === null) return '—'
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
const num = bytes / Math.pow(k, i)
return `${num.toFixed(1)} ${sizes[i]}`
}
function KpiCard({
icon,
label,
@@ -20,14 +30,14 @@ function KpiCard({
}: {
icon: ReactNode
label: string
value: number
value: number | string
}) {
return (
<div className="stats-kpi-card glass">
<div className="stats-kpi-icon">{icon}</div>
<div className="stats-kpi-body">
<span className="stats-kpi-label">{label}</span>
<span className="stats-kpi-value">{formatNumber(value)}</span>
<span className="stats-kpi-value">{typeof value === 'number' ? formatNumber(value) : value}</span>
</div>
</div>
)
@@ -194,6 +204,7 @@ export default function AdminDashboard({ onBack }: AdminDashboardProps) {
label="Einträge mit AI-Zusammenfassung"
value={summary.aiSummaryEntries}
/>
<KpiCard icon={<Database size={20} />} label="Datenbankgröße" value={formatBytes(summary.dbSize)} />
</section>
<section className="admin-controls">
@@ -233,6 +244,7 @@ export default function AdminDashboard({ onBack }: AdminDashboardProps) {
<TimeSeriesChart title="Neue Benutzer" seriesKey="users_created" data={timeSeries} />
<TimeSeriesChart title="Neue Logbücher" seriesKey="logbooks_created" data={timeSeries} />
<TimeSeriesChart title="Foto-Aktivität" seriesKey="photos_updated" data={timeSeries} />
<TimeSeriesChart title="Datenbankgröße (MB)" seriesKey="database_size" data={timeSeries} />
</section>
</main>
</div>
+69 -44
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Users } from 'lucide-react'
import { Users, ChevronDown, ChevronUp } from 'lucide-react'
import type { EntryCrewFields, PersonSnapshot } from '../types/person.js'
import { loadPersonPool } from '../services/personPool.js'
import { loadLogbookCrewSelection } from '../services/logbookCrewSelection.js'
@@ -24,6 +24,7 @@ export default function EntryCrewSection({
preloadedPool
}: EntryCrewSectionProps) {
const { t } = useTranslation()
const [collapsed, setCollapsed] = useState(true)
const [pool, setPool] = useState<Map<string, PersonData>>(preloadedPool ?? new Map())
useEffect(() => {
@@ -90,54 +91,78 @@ export default function EntryCrewSection({
return (
<div className="form-card" data-tour="entry-crew">
<div className="form-header">
<Users size={22} className="form-icon" />
<h3>{t('entry_crew.title')}</h3>
</div>
<p className="help-text mb-3">{t('entry_crew.subtitle')}</p>
<div className="input-group mb-3">
<label>{t('entry_crew.day_skipper')}</label>
{skippers.length === 0 ? (
<p className="help-text">{t('entry_crew.no_skipper')}</p>
<div
className="form-header accordion-header"
onClick={() => setCollapsed(!collapsed)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
setCollapsed(!collapsed)
}
}}
role="button"
aria-expanded={!collapsed}
tabIndex={0}
>
<div className="accordion-header-title">
<Users size={22} className="form-icon" />
<h3>{t('entry_crew.title')}</h3>
</div>
{collapsed ? (
<ChevronDown size={20} className="accordion-chevron" />
) : (
<div className="crew-selection-list">
{skippers.map(([id, data]) => (
<label key={id} className="crew-selection-item">
<input
type="radio"
name={`entry-skipper-${logbookId}`}
checked={value.selectedSkipperId === id}
onChange={() => !readOnly && applyChange(id, value.selectedCrewIds)}
disabled={readOnly}
/>
<span>{data.name || t('logbook_crew.unnamed')}</span>
</label>
))}
</div>
<ChevronUp size={20} className="accordion-chevron" />
)}
</div>
<div className="input-group">
<label>{t('entry_crew.day_crew')}</label>
{crewEntries.length === 0 ? (
<p className="help-text">{t('entry_crew.no_crew')}</p>
) : (
<div className="crew-selection-list">
{crewEntries.map(([id, data]) => (
<label key={id} className="crew-selection-item">
<input
type="checkbox"
checked={value.selectedCrewIds.includes(id)}
onChange={() => toggleCrew(id)}
disabled={readOnly}
/>
<span>{data.name || t('logbook_crew.unnamed')}</span>
</label>
))}
{!collapsed && (
<>
<p className="help-text mb-3" style={{ marginTop: '16px' }}>{t('entry_crew.subtitle')}</p>
<div className="input-group mb-3">
<label>{t('entry_crew.day_skipper')}</label>
{skippers.length === 0 ? (
<p className="help-text">{t('entry_crew.no_skipper')}</p>
) : (
<div className="crew-selection-list">
{skippers.map(([id, data]) => (
<label key={id} className="crew-selection-item">
<input
type="radio"
name={`entry-skipper-${logbookId}`}
checked={value.selectedSkipperId === id}
onChange={() => !readOnly && applyChange(id, value.selectedCrewIds)}
disabled={readOnly}
/>
<span>{data.name || t('logbook_crew.unnamed')}</span>
</label>
))}
</div>
)}
</div>
)}
</div>
<div className="input-group">
<label>{t('entry_crew.day_crew')}</label>
{crewEntries.length === 0 ? (
<p className="help-text">{t('entry_crew.no_crew')}</p>
) : (
<div className="crew-selection-list">
{crewEntries.map(([id, data]) => (
<label key={id} className="crew-selection-item">
<input
type="checkbox"
checked={value.selectedCrewIds.includes(id)}
onChange={() => toggleCrew(id)}
disabled={readOnly}
/>
<span>{data.name || t('logbook_crew.unnamed')}</span>
</label>
))}
</div>
)}
</div>
</>
)}
</div>
)
}
File diff suppressed because it is too large Load Diff
+135 -18
View File
@@ -1,4 +1,5 @@
import React, { useState, useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next'
import { db } from '../services/db.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 { useLiveQuery } from 'dexie-react-hooks'
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 {
entryId: string
@@ -31,8 +33,38 @@ 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 [maximizedPhoto, setMaximizedPhoto] = useState<DecryptedPhoto | null>(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
const localPhotos = useLiveQuery(
@@ -119,12 +151,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 +197,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>
)}
@@ -183,14 +263,22 @@ export default function PhotoCapture({ entryId, logbookId, readOnly = false, pre
) : (
<div className="photo-attachments-grid">
{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">
<img src={photo.image} alt={photo.caption || 'Attachment'} loading="lazy" />
{!readOnly && (
<button
type="button"
className="photo-btn-delete"
onClick={() => handleDelete(photo.payloadId)}
onClick={(e) => {
e.stopPropagation()
handleDelete(photo.payloadId)
}}
title="Remove photo"
>
<Trash2 size={16} />
@@ -206,6 +294,35 @@ export default function PhotoCapture({ entryId, logbookId, readOnly = false, pre
))}
</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>
)
}
@@ -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 (
+7 -2
View File
@@ -185,7 +185,10 @@
"travel_day_number": "Rejsedag {{number}}",
"departure": "Starthavn (rejse fra)",
"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)",
"fuel": "Treibstoff / Brændstof (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_forbidden": "Kun skipperen må generere AI-resuméer.",
"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_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?",
+6 -1
View File
@@ -186,6 +186,9 @@
"departure": "Start-Hafen (Reise von)",
"destination": "Ziel-Hafen (nach)",
"route": "Reise von/nach",
"tanks": "Tanks",
"customize_columns": "Spalten anpassen",
"column_selector_title": "Anzuzeigende Spalten",
"freshwater": "Frischwasser (Liter)",
"fuel": "Treibstoff / Fuel (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_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 (E2E-verschlüsselt)",
"photos_title": "Foto-Anhänge",
"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?",
+6 -1
View File
@@ -186,6 +186,9 @@
"departure": "Departure Port (von)",
"destination": "Destination Port (nach)",
"route": "Route / Journey",
"tanks": "Tanks",
"customize_columns": "Customize columns",
"column_selector_title": "Columns to Show",
"freshwater": "Freshwater (Liters)",
"fuel": "Fuel (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_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 (E2E Encrypted)",
"photos_title": "Photo Attachments",
"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?",
+6 -1
View File
@@ -186,6 +186,9 @@
"departure": "Starthavn (reise fra)",
"destination": "Destinasjonsport (til)",
"route": "Reise fra/til",
"tanks": "Tanker",
"customize_columns": "Tilpass kolonner",
"column_selector_title": "Kolonner å vise",
"freshwater": "Ferskvann (liter)",
"fuel": "Drivstoff / Drivstoff (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_forbidden": "Kun skipperen kan generere AI-sammendrag.",
"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_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?",
+6 -1
View File
@@ -186,6 +186,9 @@
"departure": "Starthamn (resa från)",
"destination": "Destinationsport (till)",
"route": "Resa från/till",
"tanks": "Tankar",
"customize_columns": "Anpassa kolumner",
"column_selector_title": "Kolumner att visa",
"freshwater": "Färskvatten (liter)",
"fuel": "Treibstoff / Bränsle (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_forbidden": "Endast skepparen får generera AI-sammanfattningar.",
"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_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?",
+1
View File
@@ -16,6 +16,7 @@ export interface AdminSummary {
totalCollaborations: number
totalInvitations: number
aiSummaryEntries: number
dbSize: number
}
export type AdminTimeBucket = 'day' | 'week' | 'month'
+8 -4
View File
@@ -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
})
+16 -5
View File
@@ -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<AppearancePrefs> {
if (!resolveSyncedUserId(userId)) {
return { theme: 'auto', colorScheme: 'auto', persisted: false }
return { theme: 'auto', colorScheme: 'auto', aiAuthorized: false, persisted: false }
}
return apiJson<AppearancePrefs>(API_BASE)
@@ -44,13 +48,14 @@ export async function fetchAppearancePrefs(userId?: string | null): Promise<Appe
export async function saveAppearancePrefsToServer(
theme: string,
colorScheme: string,
aiAuthorized: boolean,
userId?: string | null
): Promise<void> {
if (!resolveSyncedUserId(userId)) return
await apiJson<AppearancePrefs>(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<void>
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)
+5 -4
View File
@@ -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)
}
+76 -3
View File
@@ -23,6 +23,11 @@ router.get('/summary', requireUser, requireAdmin, async (_req, res) => {
prisma.aiSummaryUsage.count()
])
const rawDbSize = await prisma.$queryRaw<[{ size: string }]>`
SELECT pg_database_size(current_database())::text as size
`
const dbSize = Number(rawDbSize[0]?.size || '0')
res.json({
totalUsers,
totalLogbooks,
@@ -31,7 +36,8 @@ router.get('/summary', requireUser, requireAdmin, async (_req, res) => {
totalGpsTracks,
totalCollaborations,
totalInvitations,
aiSummaryEntries
aiSummaryEntries,
dbSize
})
} catch (error: unknown) {
console.error('admin/summary error', error)
@@ -91,7 +97,7 @@ async function buildTimeSeries(bucket: TimeBucket, windowDays: number): Promise<
const since = new Date()
since.setUTCDate(since.getUTCDate() - windowDays)
const [users, logbooks, photos] = await Promise.all([
const [users, logbooks, photos, dbSizeRaw, photosSize, voiceSize, tracksSize, entriesSize] = await Promise.all([
prisma.user.findMany({
where: { createdAt: { gte: since } },
select: { createdAt: true }
@@ -103,9 +109,72 @@ async function buildTimeSeries(bucket: TimeBucket, windowDays: number): Promise<
prisma.photoPayload.findMany({
where: { updatedAt: { gte: since } },
select: { updatedAt: true }
}),
prisma.$queryRaw<[{ size: string }]>`
SELECT pg_database_size(current_database())::text as size
`,
prisma.photoPayload.findMany({
select: { updatedAt: true, encryptedData: true }
}),
prisma.voiceMemoPayload.findMany({
select: { updatedAt: true, encryptedData: true }
}),
prisma.gpsTrackPayload.findMany({
select: { updatedAt: true, encryptedData: true }
}),
prisma.entryPayload.findMany({
select: { updatedAt: true, encryptedData: true }
})
])
const dbSizeVal = Number(dbSizeRaw[0]?.size || '0')
const payloads: { date: Date; size: number }[] = []
for (const p of photosSize) {
payloads.push({ date: p.updatedAt, size: p.encryptedData.length })
}
for (const v of voiceSize) {
payloads.push({ date: v.updatedAt, size: v.encryptedData.length })
}
for (const g of tracksSize) {
payloads.push({ date: g.updatedAt, size: g.encryptedData.length })
}
for (const e of entriesSize) {
payloads.push({ date: e.updatedAt, size: e.encryptedData.length })
}
const totalPayloadsSize = payloads.reduce((acc, p) => acc + p.size, 0)
const baseDbSize = Math.max(0, dbSizeVal - totalPayloadsSize)
payloads.sort((a, b) => a.date.getTime() - b.date.getTime())
// Generate complete list of date keys for the window
const dateKeys: string[] = []
const current = new Date(since)
const todayStr = bucketDate(new Date(), bucket)
while (true) {
const key = bucketDate(current, bucket)
if (!dateKeys.includes(key)) {
dateKeys.push(key)
}
if (key >= todayStr) break
current.setUTCDate(current.getUTCDate() + 1)
}
const dbSizePoints = dateKeys.map((key) => {
let sizeSum = 0
for (const p of payloads) {
if (bucketDate(p.date, bucket) <= key) {
sizeSum += p.size
} else {
break
}
}
const totalBytes = baseDbSize + sizeSum
const sizeInMb = Math.round((totalBytes / (1024 * 1024)) * 10) / 10
return { date: key, count: sizeInMb }
})
function aggregate(dates: Date[], metric: string): TimeSeries {
const map = new Map<string, number>()
for (const d of dates) {
@@ -130,7 +199,11 @@ async function buildTimeSeries(bucket: TimeBucket, windowDays: number): Promise<
aggregate(
photos.map((p) => p.updatedAt),
'photos_updated'
)
),
{
metric: 'database_size',
points: dbSizePoints
}
]
}
+6
View File
@@ -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) {