Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fafefff29b | |||
| 4fd7f3c6cf | |||
| 262c48a01a | |||
| 9ad3c2cf38 | |||
| 6848390ffa | |||
| 65d2215a35 | |||
| f321e5bbd1 | |||
| d2961b050a | |||
| 6943fd2dc4 | |||
| f332eccf22 | |||
| 9d2a19dbf8 | |||
| e3cd89be5d | |||
| a86da72b04 | |||
| 7d6f381f55 | |||
| 878be33b7c | |||
| 318f5e65da |
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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 (
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface AdminSummary {
|
||||
totalCollaborations: number
|
||||
totalInvitations: number
|
||||
aiSummaryEntries: number
|
||||
dbSize: number
|
||||
}
|
||||
|
||||
export type AdminTimeBucket = 'day' | 'week' | 'month'
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user