Compare commits

..

24 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
elpatron 8c6ab59d67 chore: release v0.1.1.23 2026-06-06 12:24:33 +02:00
elpatron a9c3e9ce3e Fix custom dialog coloring to support Light Theme via CSS variable mapping 2026-06-06 12:17:40 +02:00
elpatron 3eaf59e2b3 Implement AI consent gating, user preference settings, and Ko-fi hint 2026-06-06 12:08:46 +02:00
elpatron b1e17be7fd feat(analytics): add Plausible custom event VOICE_MEMO_TRANSCRIBED with status and mode properties 2026-06-06 11:51:07 +02:00
elpatron ac7e7c92d1 fix(asr): switch whisper model to whisper-large-v3-turbo 2026-06-06 11:43:09 +02:00
elpatron e10cef4b05 chore: remove parakeet service and configuration, switch completely to OpenRouter Whisper 2026-06-06 11:38:51 +02:00
elpatron 0ec5c51102 chore: configure parakeet to use 1 worker to significantly reduce memory footprint 2026-06-06 11:33:48 +02:00
elpatron 57b93b7ce7 fix: update transcribe route regex to support data URLs with codecs parameters 2026-06-06 11:14:24 +02:00
28 changed files with 1887 additions and 781 deletions
+2 -4
View File
@@ -6,10 +6,6 @@ OpenRouterAPIKey=
# Valid examples: anthropic/claude-3.5-haiku, anthropic/claude-3-haiku, anthropic/claude-haiku-4.5 # Valid examples: anthropic/claude-3.5-haiku, anthropic/claude-3-haiku, anthropic/claude-haiku-4.5
# OpenRouterModel=anthropic/claude-3.5-haiku # OpenRouterModel=anthropic/claude-3.5-haiku
# Speech-to-Text Transcription Service (local Parakeet container endpoint)
# Defaults to: http://localhost:5092/v1/audio/transcriptions (or http://parakeet:5092/v1/audio/transcriptions in Docker)
# PARAKEET_URL=http://localhost:5092/v1/audio/transcriptions
# DeepL API (for scripts/translate-locales.mjs and scripts/translate-flyer.mjs) # DeepL API (for scripts/translate-locales.mjs and scripts/translate-flyer.mjs)
# Free plan keys use api-free.deepl.com automatically (suffix :fx) # Free plan keys use api-free.deepl.com automatically (suffix :fx)
DeepLAPIKey= DeepLAPIKey=
@@ -38,6 +34,8 @@ ORIGIN=http://localhost:5173
# POSTGRES_USER=postgres # POSTGRES_USER=postgres
# POSTGRES_PASSWORD= # POSTGRES_PASSWORD=
# POSTGRES_DB=daagbox # 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) # 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 # CORS_ORIGINS=http://localhost:5173
+1 -1
View File
@@ -1 +1 @@
0.1.1.23 0.1.1.26
+298 -9
View File
@@ -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;
@@ -3237,9 +3312,9 @@ html.theme-cupertino .events-scroll-container {
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
background: rgba(11, 12, 16, 0.75); background: rgba(11, 12, 16, 0.45);
backdrop-filter: blur(10px); backdrop-filter: var(--app-backdrop);
-webkit-backdrop-filter: blur(10px); -webkit-backdrop-filter: var(--app-backdrop);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -3247,13 +3322,15 @@ html.theme-cupertino .events-scroll-container {
} }
.custom-dialog-card { .custom-dialog-card {
background: rgba(15, 23, 42, 0.85); background: var(--app-surface-hover, var(--app-surface));
border: 1px solid rgba(255, 255, 255, 0.08); backdrop-filter: var(--app-backdrop);
border-radius: 16px; -webkit-backdrop-filter: var(--app-backdrop);
border: 1px solid var(--app-border-subtle);
border-radius: var(--app-radius-card, 16px);
padding: 28px; padding: 28px;
width: 90%; width: 90%;
max-width: 420px; max-width: 420px;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.6); box-shadow: var(--app-shadow);
text-align: center; text-align: center;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -3263,7 +3340,7 @@ html.theme-cupertino .events-scroll-container {
.custom-dialog-title { .custom-dialog-title {
font-size: 19px; font-size: 19px;
font-weight: 700; font-weight: 700;
color: #fbbf24; color: var(--app-accent-light);
margin: 0 0 14px 0; margin: 0 0 14px 0;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.5px; letter-spacing: 0.5px;
@@ -3271,7 +3348,7 @@ html.theme-cupertino .events-scroll-container {
.custom-dialog-message { .custom-dialog-message {
font-size: 15px; font-size: 15px;
color: #e2e8f0; color: var(--app-text);
line-height: 1.5; line-height: 1.5;
margin: 0 0 24px 0; margin: 0 0 24px 0;
white-space: pre-line; white-space: pre-line;
@@ -4362,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,
@@ -6234,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);
}
+15 -3
View File
@@ -7,12 +7,22 @@ import {
type AdminTimeSeriesResponse, type AdminTimeSeriesResponse,
type AdminTimeBucket type AdminTimeBucket
} from '../services/adminApi.js' } 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 { function formatNumber(value: number): string {
return value.toLocaleString() 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({ function KpiCard({
icon, icon,
label, label,
@@ -20,14 +30,14 @@ function KpiCard({
}: { }: {
icon: ReactNode icon: ReactNode
label: string label: string
value: number value: number | string
}) { }) {
return ( return (
<div className="stats-kpi-card glass"> <div className="stats-kpi-card glass">
<div className="stats-kpi-icon">{icon}</div> <div className="stats-kpi-icon">{icon}</div>
<div className="stats-kpi-body"> <div className="stats-kpi-body">
<span className="stats-kpi-label">{label}</span> <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>
</div> </div>
) )
@@ -194,6 +204,7 @@ export default function AdminDashboard({ onBack }: AdminDashboardProps) {
label="Einträge mit AI-Zusammenfassung" label="Einträge mit AI-Zusammenfassung"
value={summary.aiSummaryEntries} value={summary.aiSummaryEntries}
/> />
<KpiCard icon={<Database size={20} />} label="Datenbankgröße" value={formatBytes(summary.dbSize)} />
</section> </section>
<section className="admin-controls"> <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 Benutzer" seriesKey="users_created" data={timeSeries} />
<TimeSeriesChart title="Neue Logbücher" seriesKey="logbooks_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="Foto-Aktivität" seriesKey="photos_updated" data={timeSeries} />
<TimeSeriesChart title="Datenbankgröße (MB)" seriesKey="database_size" data={timeSeries} />
</section> </section>
</main> </main>
</div> </div>
+69 -44
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' 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 type { EntryCrewFields, PersonSnapshot } from '../types/person.js'
import { loadPersonPool } from '../services/personPool.js' import { loadPersonPool } from '../services/personPool.js'
import { loadLogbookCrewSelection } from '../services/logbookCrewSelection.js' import { loadLogbookCrewSelection } from '../services/logbookCrewSelection.js'
@@ -24,6 +24,7 @@ export default function EntryCrewSection({
preloadedPool preloadedPool
}: EntryCrewSectionProps) { }: EntryCrewSectionProps) {
const { t } = useTranslation() const { t } = useTranslation()
const [collapsed, setCollapsed] = useState(true)
const [pool, setPool] = useState<Map<string, PersonData>>(preloadedPool ?? new Map()) const [pool, setPool] = useState<Map<string, PersonData>>(preloadedPool ?? new Map())
useEffect(() => { useEffect(() => {
@@ -90,54 +91,78 @@ export default function EntryCrewSection({
return ( return (
<div className="form-card" data-tour="entry-crew"> <div className="form-card" data-tour="entry-crew">
<div className="form-header"> <div
<Users size={22} className="form-icon" /> className="form-header accordion-header"
<h3>{t('entry_crew.title')}</h3> onClick={() => setCollapsed(!collapsed)}
</div> onKeyDown={(e) => {
<p className="help-text mb-3">{t('entry_crew.subtitle')}</p> if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
<div className="input-group mb-3"> setCollapsed(!collapsed)
<label>{t('entry_crew.day_skipper')}</label> }
{skippers.length === 0 ? ( }}
<p className="help-text">{t('entry_crew.no_skipper')}</p> 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"> <ChevronUp size={20} className="accordion-chevron" />
{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"> {!collapsed && (
<label>{t('entry_crew.day_crew')}</label> <>
{crewEntries.length === 0 ? ( <p className="help-text mb-3" style={{ marginTop: '16px' }}>{t('entry_crew.subtitle')}</p>
<p className="help-text">{t('entry_crew.no_crew')}</p>
) : ( <div className="input-group mb-3">
<div className="crew-selection-list"> <label>{t('entry_crew.day_skipper')}</label>
{crewEntries.map(([id, data]) => ( {skippers.length === 0 ? (
<label key={id} className="crew-selection-item"> <p className="help-text">{t('entry_crew.no_skipper')}</p>
<input ) : (
type="checkbox" <div className="crew-selection-list">
checked={value.selectedCrewIds.includes(id)} {skippers.map(([id, data]) => (
onChange={() => toggleCrew(id)} <label key={id} className="crew-selection-item">
disabled={readOnly} <input
/> type="radio"
<span>{data.name || t('logbook_crew.unnamed')}</span> name={`entry-skipper-${logbookId}`}
</label> 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> <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> </div>
) )
} }
@@ -7,6 +7,8 @@ import { formatEventSummary } from '../utils/formatEventSummary.js'
import VoiceMemoPlayer, { type PreloadedVoiceMemo } from './VoiceMemoPlayer.tsx' import VoiceMemoPlayer, { type PreloadedVoiceMemo } from './VoiceMemoPlayer.tsx'
import { useDialog } from './ModalDialog.tsx' import { useDialog } from './ModalDialog.tsx'
import { updateVoiceMemoTranscript } from '../services/voiceAttachments.js' import { updateVoiceMemoTranscript } from '../services/voiceAttachments.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { getAiAuthorized } from '../services/userPreferences.js'
interface EventRemarksCellProps { interface EventRemarksCellProps {
event: LogEventPayload event: LogEventPayload
@@ -44,6 +46,13 @@ export default function EventRemarksCell({
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
if (transcribing || !preloaded?.audio || !voiceId) return if (transcribing || !preloaded?.audio || !voiceId) return
if (!getAiAuthorized()) {
void showAlert(
t('profile.ai_unauthorized_alert_desc'),
t('profile.ai_unauthorized_alert_title')
)
return
}
setTranscribing(true) setTranscribing(true)
const controller = new AbortController() const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 15000) const timeoutId = setTimeout(() => controller.abort(), 15000)
@@ -66,9 +75,17 @@ export default function EventRemarksCell({
throw new Error('Transcription returned empty text') throw new Error('Transcription returned empty text')
} }
await updateVoiceMemoTranscript(logbookId, voiceId, text) await updateVoiceMemoTranscript(logbookId, voiceId, text)
trackPlausibleEvent(PlausibleEvents.VOICE_MEMO_TRANSCRIBED, {
status: 'success',
mode: 'manual'
})
} catch (err) { } catch (err) {
clearTimeout(timeoutId) clearTimeout(timeoutId)
console.error('[EventRemarksCell] Transcription failed:', err) console.error('[EventRemarksCell] Transcription failed:', err)
trackPlausibleEvent(PlausibleEvents.VOICE_MEMO_TRANSCRIBED, {
status: 'failed',
mode: 'manual'
})
void showAlert(t('logs.live_voice_transcribe_failed'), t('logs.live_voice_btn')) void showAlert(t('logs.live_voice_transcribe_failed'), t('logs.live_voice_btn'))
} finally { } finally {
setTranscribing(false) setTranscribing(false)
+36 -18
View File
@@ -22,6 +22,7 @@ import {
Zap Zap
} from 'lucide-react' } from 'lucide-react'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js' import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { getAiAuthorized } from '../services/userPreferences.js'
import { import {
appendQuickEvent as apiAppendQuickEvent, appendQuickEvent as apiAppendQuickEvent,
appendQuickEvents as apiAppendQuickEvents, appendQuickEvents as apiAppendQuickEvents,
@@ -834,28 +835,32 @@ export default function LiveLogView({
void (async () => { void (async () => {
try { try {
const audioDataUrl = await blobToAudioDataUrl(blob) const audioDataUrl = await blobToAudioDataUrl(blob)
const authorized = getAiAuthorized()
let transcriptionText = '' let transcriptionText = ''
let transcribed = true let transcribed = true
let transcriptionError = false let transcriptionError = false
try { if (authorized) {
const controller = new AbortController() try {
const timeoutId = setTimeout(() => controller.abort(), 4000) const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 4000)
const res = await fetch('/api/ai/transcribe', { const res = await fetch('/api/ai/transcribe', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ audioDataUrl }), body: JSON.stringify({ audioDataUrl }),
signal: controller.signal signal: controller.signal
}) })
clearTimeout(timeoutId) clearTimeout(timeoutId)
if (!res.ok) throw new Error(`Status ${res.status}`) if (!res.ok) throw new Error(`Status ${res.status}`)
const data = await res.json() const data = await res.json()
transcriptionText = (data.text || '').trim() transcriptionText = (data.text || '').trim()
} catch (err) { } catch (err) {
console.warn('[LiveLogView] Automatic transcription failed or timed out:', err) console.warn('[LiveLogView] Automatic transcription failed or timed out:', err)
transcriptionError = true transcriptionError = true
transcribed = false
}
} else {
transcribed = false transcribed = false
} }
@@ -885,9 +890,22 @@ export default function LiveLogView({
setVoiceCaption('') setVoiceCaption('')
showUndo('voice') showUndo('voice')
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_EVENT_LOGGED, { action: 'voice' }) trackPlausibleEvent(PlausibleEvents.LIVE_LOG_EVENT_LOGGED, { action: 'voice' })
if (transcriptionError) { if (transcriptionError) {
trackPlausibleEvent(PlausibleEvents.VOICE_MEMO_TRANSCRIBED, {
status: 'failed',
mode: 'auto'
})
void showAlert(t('logs.live_voice_transcribe_failed'), t('logs.live_voice_btn')) void showAlert(t('logs.live_voice_transcribe_failed'), t('logs.live_voice_btn'))
} else if (authorized) {
trackPlausibleEvent(PlausibleEvents.VOICE_MEMO_TRANSCRIBED, {
status: 'success',
mode: 'auto'
})
} else {
void showAlert(
t('profile.ai_unauthorized_alert_desc'),
t('profile.ai_unauthorized_alert_title')
)
} }
} catch (err: unknown) { } catch (err: unknown) {
console.error('Live log voice save failed:', err) console.error('Live log voice save failed:', err)
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 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,6 +1,6 @@
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 } from 'lucide-react' import { Compass, Palette, Save, Check, Cloud, Brain } from 'lucide-react'
import ThemedSelect from './ThemedSelect.tsx' import ThemedSelect from './ThemedSelect.tsx'
import PushNotificationSettings from './PushNotificationSettings.tsx' import PushNotificationSettings from './PushNotificationSettings.tsx'
import PwaInstallPrompt from './PwaInstallPrompt.tsx' import PwaInstallPrompt from './PwaInstallPrompt.tsx'
@@ -13,7 +13,9 @@ import {
getThemePreference, getThemePreference,
setColorSchemePreference, setColorSchemePreference,
setOwmApiKey, setOwmApiKey,
setThemePreference setThemePreference,
getAiAuthorized,
setAiAuthorized
} from '../services/userPreferences.js' } from '../services/userPreferences.js'
interface UserProfilePreferencesProps { interface UserProfilePreferencesProps {
@@ -28,12 +30,25 @@ export default function UserProfilePreferences({ userId }: UserProfilePreference
const [colorScheme, setColorScheme] = useState(() => getColorSchemePreference(userId)) const [colorScheme, setColorScheme] = useState(() => getColorSchemePreference(userId))
const [savingOwm, setSavingOwm] = useState(false) const [savingOwm, setSavingOwm] = useState(false)
const [owmSaved, setOwmSaved] = useState(false) 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) => { 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)
}) })
} }
@@ -58,6 +73,15 @@ export default function UserProfilePreferences({ userId }: UserProfilePreference
window.setTimeout(() => setOwmSaved(false), 3000) window.setTimeout(() => setOwmSaved(false), 3000)
} }
const handleAiToggle = (e: React.ChangeEvent<HTMLInputElement>) => {
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 ( return (
<> <>
<section className="member-editor-card glass"> <section className="member-editor-card glass">
@@ -152,6 +176,42 @@ export default function UserProfilePreferences({ userId }: UserProfilePreference
</form> </form>
</section> </section>
<section className="member-editor-card glass">
<div className="profile-section-header">
<Brain size={20} style={{ color: 'var(--app-accent-light)' }} />
<h3 style={{ margin: 0, color: 'var(--app-accent-light)', fontSize: '16px' }}>
{t('profile.ai_title')}
</h3>
</div>
<p className="text-muted" style={{ fontSize: '13.5px', lineHeight: '145%', margin: '0 0 12px 0' }}>
{t('profile.ai_desc')}
</p>
<p className="text-muted" style={{ fontSize: '13px', lineHeight: '145%', margin: '0 0 16px 0', whiteSpace: 'pre-line' }}>
{t('profile.ai_help')}
</p>
<label
className="switch-label"
style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
cursor: 'pointer',
fontSize: '14px',
color: '#f1f5f9'
}}
>
<input
id="profile-ai-authorize"
type="checkbox"
checked={aiAuthorized}
onChange={handleAiToggle}
style={{ width: '18px', height: '18px', cursor: 'pointer' }}
/>
<span>{t('profile.ai_enable_label')}</span>
</label>
</section>
<PushNotificationSettings /> <PushNotificationSettings />
<PwaInstallPrompt variant="inline" /> <PwaInstallPrompt variant="inline" />
</> </>
+13 -2
View File
@@ -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?",
@@ -672,6 +677,12 @@
"integrations_title": "Integrationer", "integrations_title": "Integrationer",
"owm_key": "OpenWeatherMap API-nøgle", "owm_key": "OpenWeatherMap API-nøgle",
"owm_help": "Valgfrit: egen OpenWeatherMap API-nøgle. Hvis der ikke er nogen indtastning, bruges nøglen på serversiden fra operatørkonfigurationen.", "owm_help": "Valgfrit: egen OpenWeatherMap API-nøgle. Hvis der ikke er nogen indtastning, bruges nøglen på serversiden fra operatørkonfigurationen.",
"ai_title": "AI-funktioner og privatliv",
"ai_desc": "Autoriser integrationer af kunstig intelligens for dine logbøger.",
"ai_help": "Aktivering af AI-funktioner giver appen mulighed for at opsummere dine rejsedage og transkribere optagede stemmememoer. For at behandle disse anmodninger sendes rå stemmedata og rejselogfiler sikkert løbende til OpenRouter. Der gemmes ingen data permanent af AI-modellen.\n\nDisse cloud-ressourcer koster penge at køre. Hvis du kan lide at bruge dem, bedes du overveje at støtte projektet frivilligt med en donation via Ko-fi-linket i footeren for at holde dem gratis og bæredygtige for alle.",
"ai_enable_label": "Aktiver transkribering og resuméer af rejsedage",
"ai_unauthorized_alert_title": "AI-funktioner er ikke autoriseret",
"ai_unauthorized_alert_desc": "For at bruge transkribering eller rejsedagsresuméer skal du autorisere dataoverførslen til OpenRouter i din brugerprofil under 'AI-funktioner og privatliv'.",
"prefs_save": "Gemme", "prefs_save": "Gemme",
"prefs_saving": "Vil blive reddet...", "prefs_saving": "Vil blive reddet...",
"prefs_saved": "Gemt", "prefs_saved": "Gemt",
+12 -1
View File
@@ -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?",
@@ -672,6 +677,12 @@
"integrations_title": "Integrationen", "integrations_title": "Integrationen",
"owm_key": "OpenWeatherMap API-Schlüssel", "owm_key": "OpenWeatherMap API-Schlüssel",
"owm_help": "Optional: eigener OpenWeatherMap-API-Schlüssel. Ohne Eintrag wird der serverseitige Schlüssel aus der Betreiber-Konfiguration verwendet.", "owm_help": "Optional: eigener OpenWeatherMap-API-Schlüssel. Ohne Eintrag wird der serverseitige Schlüssel aus der Betreiber-Konfiguration verwendet.",
"ai_title": "KI-Funktionen & Datenschutz",
"ai_desc": "Autorisiere die Nutzung von künstlicher Intelligenz (lokale/Cloud-Integrationen) für deine Logbücher.",
"ai_help": "Die Aktivierung ermöglicht es, Reiseberichte automatisch zusammenzufassen und Sprachnotizen zu transkribieren. Zur Verarbeitung werden Sprachaufnahmen und Logbucheinträge verschlüsselt an OpenRouter übertragen. Die Daten werden dort nicht dauerhaft gespeichert.\n\nDa der Betrieb dieser Cloud-Ressourcen Kosten verursacht, freuen wir uns über eine freiwillige Unterstützung über den Ko-fi-Spenden-Link im Footer, um diese Funktionen dauerhaft für alle kostenlos anbieten zu können.",
"ai_enable_label": "Transkribierung und Tageszusammenfassungen aktivieren",
"ai_unauthorized_alert_title": "KI-Funktionen nicht autorisiert",
"ai_unauthorized_alert_desc": "Um Sprachnotizen zu transkribieren oder Reiseberichte zusammenzufassen, musst du der Datenübermittlung an OpenRouter in deinem Benutzerprofil unter 'KI-Funktionen & Datenschutz' zustimmen.",
"prefs_save": "Speichern", "prefs_save": "Speichern",
"prefs_saving": "Wird gespeichert…", "prefs_saving": "Wird gespeichert…",
"prefs_saved": "Gespeichert", "prefs_saved": "Gespeichert",
+12 -1
View File
@@ -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?",
@@ -672,6 +677,12 @@
"integrations_title": "Integrations", "integrations_title": "Integrations",
"owm_key": "OpenWeatherMap API key", "owm_key": "OpenWeatherMap API key",
"owm_help": "Optional: your own OpenWeatherMap API key. If left empty, the operator-configured server key is used.", "owm_help": "Optional: your own OpenWeatherMap API key. If left empty, the operator-configured server key is used.",
"ai_title": "AI Features & Privacy",
"ai_desc": "Authorize artificial intelligence integrations for your logbooks.",
"ai_help": "Enabling AI features allows the app to summarize travel days and transcribe recorded voice memos. To process these requests, raw voice data and travel logs are sent securely on-the-fly to OpenRouter. No data is stored permanently by the AI model.\n\nThese cloud resources cost money to run; if you enjoy using them, please consider supporting the project voluntarily with a donation via the Ko-fi link in the footer to keep them free and sustainable for everyone.",
"ai_enable_label": "Enable transcription and travel day summaries",
"ai_unauthorized_alert_title": "AI Features Not Authorized",
"ai_unauthorized_alert_desc": "To use transcription or travel day summaries, please authorize the data transmission to OpenRouter in your User Profile under 'AI Features & Privacy'.",
"prefs_save": "Save", "prefs_save": "Save",
"prefs_saving": "Saving…", "prefs_saving": "Saving…",
"prefs_saved": "Saved", "prefs_saved": "Saved",
+12 -1
View File
@@ -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?",
@@ -672,6 +677,12 @@
"integrations_title": "Integrasjoner", "integrations_title": "Integrasjoner",
"owm_key": "OpenWeatherMap API-nøkkel", "owm_key": "OpenWeatherMap API-nøkkel",
"owm_help": "Valgfritt: egen OpenWeatherMap API-nøkkel. Hvis ingen oppføring er gjort, brukes serverside-nøkkelen fra operatørkonfigurasjonen.", "owm_help": "Valgfritt: egen OpenWeatherMap API-nøkkel. Hvis ingen oppføring er gjort, brukes serverside-nøkkelen fra operatørkonfigurasjonen.",
"ai_title": "KI-funksjoner og personvern",
"ai_desc": "Autoriser integrasjoner av kunstig intelligens for loggbøkene dine.",
"ai_help": "Aktivering av KI-funksjoner gjør det mulig for appen å oppsummere reisedagene dine og transkribere innspilte talememoer. For å behandle disse forespørslene sendes rå stemmedata og reiselogger sikkert løpende til OpenRouter. Ingen data lagres permanent av KI-modellen.\n\nDisse nettskyressursene koster penger å drifte. Hvis du har glede av å bruke dem, kan du vurdere å støtte prosjektet frivillig med en donasjon via Ko-fi-lenken i bunnteksten for å holde dem gratis og bærekraftige for alle.",
"ai_enable_label": "Aktiver transkribering og oppsummeringer av reisedager",
"ai_unauthorized_alert_title": "KI-funktionen er ikke autorisert",
"ai_unauthorized_alert_desc": "For å bruke transkribering eller reisedagsoppsummeringer, må du autorisere dataoverføringen til OpenRouter i brukerprofilen din under 'KI-funksjoner og personvern'.",
"prefs_save": "Spar", "prefs_save": "Spar",
"prefs_saving": "...vil bli reddet...", "prefs_saving": "...vil bli reddet...",
"prefs_saved": "Reddet", "prefs_saved": "Reddet",
+12 -1
View File
@@ -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?",
@@ -672,6 +677,12 @@
"integrations_title": "Integrationer", "integrations_title": "Integrationer",
"owm_key": "OpenWeatherMap API-nyckel", "owm_key": "OpenWeatherMap API-nyckel",
"owm_help": "Valfritt: egen OpenWeatherMap API-nyckel. Om inget anges används nyckeln på serversidan från operatörskonfigurationen.", "owm_help": "Valfritt: egen OpenWeatherMap API-nyckel. Om inget anges används nyckeln på serversidan från operatörskonfigurationen.",
"ai_title": "AI-funktioner och integritet",
"ai_desc": "Auktorisera integrationer av artificiell intelligens för dina loggböcker.",
"ai_help": "Genom at aktivera AI-funktioner kan appen sammanfatta dina rejsdagar och transkribera röstmemon. För att bearbeta dessa förfrågningar skickas röstdata och rejsloggar säkert och tillfälligt till OpenRouter. Inga data sparas permanent av AI-modellen.\n\nDessa molnresurser kostar pengar att driva. Om du gillar att använda dem, överväg att frivilligt stödja projektet med en donation via Ko-fi-länken i sidfoten för att hålla dem gratis och hållbara för alla.",
"ai_enable_label": "Aktivera transkribering och sammanfattningar av rejsdagar",
"ai_unauthorized_alert_title": "AI-funktioner är inte auktoriserade",
"ai_unauthorized_alert_desc": "För att använda transkribering eller rejsdagsöversikter måste du auktorisera dataöverföringen till OpenRouter i din användarprofil under 'AI-funktioner och integritet'.",
"prefs_save": "Spara", "prefs_save": "Spara",
"prefs_saving": "Kommer att sparas...", "prefs_saving": "Kommer att sparas...",
"prefs_saved": "Sparade", "prefs_saved": "Sparade",
+1
View File
@@ -16,6 +16,7 @@ export interface AdminSummary {
totalCollaborations: number totalCollaborations: number
totalInvitations: number totalInvitations: number
aiSummaryEntries: number aiSummaryEntries: number
dbSize: number
} }
export type AdminTimeBucket = 'day' | 'week' | 'month' export type AdminTimeBucket = 'day' | 'week' | 'month'
+1
View File
@@ -42,6 +42,7 @@ export const PlausibleEvents = {
LIVE_LOG_OPENED: 'Live Log Opened', LIVE_LOG_OPENED: 'Live Log Opened',
LIVE_LOG_EVENT_LOGGED: 'Live Log Event Logged', LIVE_LOG_EVENT_LOGGED: 'Live Log Event Logged',
VOICE_MEMO_UPLOADED: 'Voice Memo Uploaded', VOICE_MEMO_UPLOADED: 'Voice Memo Uploaded',
VOICE_MEMO_TRANSCRIBED: 'Voice Memo Transcribed',
OWM_WEATHER_FETCHED: 'OWM Weather Fetched', OWM_WEATHER_FETCHED: 'OWM Weather Fetched',
AI_SUMMARY_GENERATED: 'AI Summary Generated', AI_SUMMARY_GENERATED: 'AI Summary Generated',
PWA_BOOT_WATCHDOG_SOFT: 'PWA Boot Watchdog Soft', PWA_BOOT_WATCHDOG_SOFT: 'PWA Boot Watchdog Soft',
+8 -4
View File
@@ -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
}) })
+16 -5
View File
@@ -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)
+12 -1
View File
@@ -6,7 +6,9 @@ import {
getThemePreference, getThemePreference,
setColorSchemePreference, setColorSchemePreference,
setOwmApiKey, setOwmApiKey,
setThemePreference setThemePreference,
getAiAuthorized,
setAiAuthorized
} from './userPreferences.js' } from './userPreferences.js'
const USER_ID = 'test-user-123' const USER_ID = 'test-user-123'
@@ -58,4 +60,13 @@ describe('userPreferences', () => {
expect(getThemePreference(USER_ID)).toBe('ocean') expect(getThemePreference(USER_ID)).toBe('ocean')
expect(getColorSchemePreference(USER_ID)).toBe('light') expect(getColorSchemePreference(USER_ID)).toBe('light')
}) })
it('stores AI authorization preference per user', () => {
localStorage.setItem('active_userid', USER_ID)
expect(getAiAuthorized()).toBe(false)
setAiAuthorized(USER_ID, true)
expect(getAiAuthorized()).toBe(true)
expect(getAiAuthorized(USER_ID)).toBe(true)
expect(getAiAuthorized('other-user')).toBe(false)
})
}) })
+17
View File
@@ -89,3 +89,20 @@ export function setOwmApiKey(userId: string, value: string): void {
localStorage.removeItem(owmKey(userId)) localStorage.removeItem(owmKey(userId))
} }
} }
function aiAuthorizedKey(userId: string): string {
return `user_pref_ai_authorized_${userId}`
}
export function getAiAuthorized(userId?: string | null): boolean {
const id = resolveUserId(userId)
if (id) {
return localStorage.getItem(aiAuthorizedKey(id)) === 'true'
}
return false
}
export function setAiAuthorized(userId: string, value: boolean): void {
localStorage.setItem(aiAuthorizedKey(userId), String(value))
}
-8
View File
@@ -33,7 +33,6 @@ services:
OpenWeatherMapAPIKey: ${OpenWeatherMapAPIKey:-} OpenWeatherMapAPIKey: ${OpenWeatherMapAPIKey:-}
OpenRouterAPIKey: ${OpenRouterAPIKey:-} OpenRouterAPIKey: ${OpenRouterAPIKey:-}
OpenRouterModel: ${OpenRouterModel:-anthropic/claude-3.5-haiku} OpenRouterModel: ${OpenRouterModel:-anthropic/claude-3.5-haiku}
PARAKEET_URL: ${PARAKEET_URL:-http://parakeet:5092/v1/audio/transcriptions}
SESSION_SECRET: ${SESSION_SECRET:-} SESSION_SECRET: ${SESSION_SECRET:-}
ADMIN_USER_IDS: ${ADMIN_USER_IDS:-} ADMIN_USER_IDS: ${ADMIN_USER_IDS:-}
NTFY_SERVER: ${NTFY_SERVER:-https://ntfy.sh} NTFY_SERVER: ${NTFY_SERVER:-https://ntfy.sh}
@@ -67,13 +66,6 @@ services:
backend: backend:
condition: service_healthy condition: service_healthy
parakeet:
image: ghcr.io/achetronic/parakeet:latest
container_name: daagbox-staging-parakeet
restart: always
ports:
- "5092:5092"
volumes: volumes:
pgdata: pgdata:
name: daagbox-staging-pgdata name: daagbox-staging-pgdata
-8
View File
@@ -34,7 +34,6 @@ services:
OpenWeatherMapAPIKey: ${OpenWeatherMapAPIKey:-} OpenWeatherMapAPIKey: ${OpenWeatherMapAPIKey:-}
OpenRouterAPIKey: ${OpenRouterAPIKey:-} OpenRouterAPIKey: ${OpenRouterAPIKey:-}
OpenRouterModel: ${OpenRouterModel:-anthropic/claude-3.5-haiku} OpenRouterModel: ${OpenRouterModel:-anthropic/claude-3.5-haiku}
PARAKEET_URL: ${PARAKEET_URL:-http://parakeet:5092/v1/audio/transcriptions}
SESSION_SECRET: ${SESSION_SECRET:-} SESSION_SECRET: ${SESSION_SECRET:-}
ADMIN_USER_IDS: ${ADMIN_USER_IDS:-} ADMIN_USER_IDS: ${ADMIN_USER_IDS:-}
NTFY_SERVER: ${NTFY_SERVER:-https://ntfy.sh} NTFY_SERVER: ${NTFY_SERVER:-https://ntfy.sh}
@@ -68,13 +67,6 @@ services:
backend: backend:
condition: service_healthy condition: service_healthy
parakeet:
image: ghcr.io/achetronic/parakeet:latest
container_name: daagbox-prod-parakeet
restart: always
ports:
- "5092:5092"
volumes: volumes:
pgdata: pgdata:
name: daagbox-prod-pgdata name: daagbox-prod-pgdata
+2
View File
@@ -47,6 +47,7 @@ Das Script wird über `plausible-bootstrap.js` geladen; `data-domain` ist der ak
| CSV Shared | CSV über Web Share API geteilt (`LogEntriesList.tsx`) | — | | CSV Shared | CSV über Web Share API geteilt (`LogEntriesList.tsx`) | — |
| Photo Uploaded | Foto hochgeladen (`photoAttachments.ts`, `PhotoCapture.tsx`, `CrewForm.tsx`) | `context`: `logbook` \| `live_log` \| `crew`, bei Crew zusätzlich `role`: `skipper` \| `crew` | | Photo Uploaded | Foto hochgeladen (`photoAttachments.ts`, `PhotoCapture.tsx`, `CrewForm.tsx`) | `context`: `logbook` \| `live_log` \| `crew`, bei Crew zusätzlich `role`: `skipper` \| `crew` |
| Voice Memo Uploaded | Sprachnotiz gespeichert (`voiceAttachments.ts`) | `context`: `logbook` \| `live_log` | | Voice Memo Uploaded | Sprachnotiz gespeichert (`voiceAttachments.ts`) | `context`: `logbook` \| `live_log` |
| Voice Memo Transcribed | Sprachmemo transkribiert (`LiveLogView.tsx`, `EventRemarksCell.tsx`) | `status`: `success` \| `failed`, `mode`: `auto` (beim Speichern) \| `manual` (nachträglich) |
| OWM Weather Fetched | Erfolgreicher OpenWeatherMap-API-Abruf (`weather.ts`, zentral nach HTTP 200) | `source`: siehe [OWM-Quellen](#owm-quellen) | | OWM Weather Fetched | Erfolgreicher OpenWeatherMap-API-Abruf (`weather.ts`, zentral nach HTTP 200) | `source`: siehe [OWM-Quellen](#owm-quellen) |
| AI Summary Generated | Erfolgreiche KI-Zusammenfassung eines Reisetags (`aiSummary.ts`) | — | | AI Summary Generated | Erfolgreiche KI-Zusammenfassung eines Reisetags (`aiSummary.ts`) | — |
| Backup Exported | Backup-Datei heruntergeladen (`LogbookBackupPanel.tsx`, v2 ZIP) | `entries`, `photos`, `voiceMemos`, `bytes` (Anzahlen/Größe, keine Inhalte) | | Backup Exported | Backup-Datei heruntergeladen (`LogbookBackupPanel.tsx`, v2 ZIP) | `entries`, `photos`, `voiceMemos`, `bytes` (Anzahlen/Größe, keine Inhalte) |
@@ -161,6 +162,7 @@ trackPlausibleEvent(PlausibleEvents.LANGUAGE_CHANGED, { from: 'de', to: 'da' })
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_EVENT_LOGGED, { action: 'course' }) trackPlausibleEvent(PlausibleEvents.LIVE_LOG_EVENT_LOGGED, { action: 'course' })
trackPlausibleEvent(PlausibleEvents.PHOTO_UPLOADED, { context: 'live_log' }) trackPlausibleEvent(PlausibleEvents.PHOTO_UPLOADED, { context: 'live_log' })
trackPlausibleEvent(PlausibleEvents.VOICE_MEMO_UPLOADED, { context: 'live_log' }) trackPlausibleEvent(PlausibleEvents.VOICE_MEMO_UPLOADED, { context: 'live_log' })
trackPlausibleEvent(PlausibleEvents.VOICE_MEMO_TRANSCRIBED, { status: 'success', mode: 'auto' })
trackPlausibleEvent(PlausibleEvents.OWM_WEATHER_FETCHED, { source: 'live_log' }) trackPlausibleEvent(PlausibleEvents.OWM_WEATHER_FETCHED, { source: 'live_log' })
trackPlausibleEvent(PlausibleEvents.NMEA_UPLOADED, { lines: 1200, candidates: 8, duplicate: false, has_position: true }) trackPlausibleEvent(PlausibleEvents.NMEA_UPLOADED, { lines: 1200, candidates: 8, duplicate: false, has_position: true })
trackPlausibleEvent(PlausibleEvents.NMEA_IMPORTED, { mode: 'both', events: 6, track: true }) trackPlausibleEvent(PlausibleEvents.NMEA_IMPORTED, { mode: 'both', events: 6, track: true })
+5 -4
View File
@@ -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)
} }
+76 -3
View File
@@ -23,6 +23,11 @@ router.get('/summary', requireUser, requireAdmin, async (_req, res) => {
prisma.aiSummaryUsage.count() 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({ res.json({
totalUsers, totalUsers,
totalLogbooks, totalLogbooks,
@@ -31,7 +36,8 @@ router.get('/summary', requireUser, requireAdmin, async (_req, res) => {
totalGpsTracks, totalGpsTracks,
totalCollaborations, totalCollaborations,
totalInvitations, totalInvitations,
aiSummaryEntries aiSummaryEntries,
dbSize
}) })
} catch (error: unknown) { } catch (error: unknown) {
console.error('admin/summary error', error) console.error('admin/summary error', error)
@@ -91,7 +97,7 @@ async function buildTimeSeries(bucket: TimeBucket, windowDays: number): Promise<
const since = new Date() const since = new Date()
since.setUTCDate(since.getUTCDate() - windowDays) 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({ prisma.user.findMany({
where: { createdAt: { gte: since } }, where: { createdAt: { gte: since } },
select: { createdAt: true } select: { createdAt: true }
@@ -103,9 +109,72 @@ async function buildTimeSeries(bucket: TimeBucket, windowDays: number): Promise<
prisma.photoPayload.findMany({ prisma.photoPayload.findMany({
where: { updatedAt: { gte: since } }, where: { updatedAt: { gte: since } },
select: { updatedAt: true } 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 { function aggregate(dates: Date[], metric: string): TimeSeries {
const map = new Map<string, number>() const map = new Map<string, number>()
for (const d of dates) { for (const d of dates) {
@@ -130,7 +199,11 @@ async function buildTimeSeries(bucket: TimeBucket, windowDays: number): Promise<
aggregate( aggregate(
photos.map((p) => p.updatedAt), photos.map((p) => p.updatedAt),
'photos_updated' 'photos_updated'
) ),
{
metric: 'database_size',
points: dbSizePoints
}
] ]
} }
+29 -21
View File
@@ -3,8 +3,6 @@ import { prisma } from '../db.js'
import { requireUser } from '../middleware/auth.js' import { requireUser } from '../middleware/auth.js'
const router = Router() const router = Router()
const PARAKEET_URL = process.env.PARAKEET_URL || 'http://localhost:5092/v1/audio/transcriptions'
const MAX_ATTEMPTS_PER_ENTRY = 3 const MAX_ATTEMPTS_PER_ENTRY = 3
const DEFAULT_MODEL = 'anthropic/claude-3.5-haiku' const DEFAULT_MODEL = 'anthropic/claude-3.5-haiku'
const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions' const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions'
@@ -238,51 +236,61 @@ router.post('/transcribe', async (req: any, res) => {
return res.status(400).json({ error: 'audioDataUrl is required' }) return res.status(400).json({ error: 'audioDataUrl is required' })
} }
const match = audioDataUrl.match(/^data:([^;]+);base64,(.+)$/) const match = audioDataUrl.match(/^data:(.+);base64,(.+)$/)
if (!match) { if (!match) {
return res.status(400).json({ error: 'Invalid audio data URL format' }) return res.status(400).json({ error: 'Invalid audio data URL format' })
} }
const [, mimeType, base64Data] = match const [, fullMimeType, base64Data] = match
const buffer = Buffer.from(base64Data, 'base64') const mimeType = fullMimeType.split(';')[0]
let ext = 'webm' let ext = 'webm'
if (mimeType.includes('mp4')) ext = 'mp4' if (mimeType.includes('mp4')) ext = 'mp4'
else if (mimeType.includes('ogg')) ext = 'ogg' else if (mimeType.includes('ogg')) ext = 'ogg'
else if (mimeType.includes('wav')) ext = 'wav' else if (mimeType.includes('wav')) ext = 'wav'
const filename = `audio.${ext}` const apiKey = resolveOpenRouterApiKey()
const file = new File([buffer], filename, { type: mimeType }) if (!apiKey) {
console.warn('[server] OpenRouter API key not configured, transcription unavailable')
return res.status(503).json({ error: 'Transcription service not configured' })
}
const formData = new FormData() console.log(`[server] Forwarding ASR request to OpenRouter (${ext}, ${base64Data.length} chars)`)
formData.append('file', file)
console.log(`[server] Forwarding ASR request to ${PARAKEET_URL} (${filename}, ${buffer.length} bytes)`)
const controller = new AbortController() const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 15000) const timeoutId = setTimeout(() => controller.abort(), 30000)
try { try {
const parakeetRes = await fetch(PARAKEET_URL, { const openRouterRes = await fetch('https://openrouter.ai/api/v1/audio/transcriptions', {
method: 'POST', method: 'POST',
body: formData, headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: 'openai/whisper-large-v3-turbo',
input_audio: {
data: base64Data,
format: ext
}
}),
signal: controller.signal signal: controller.signal
}) })
if (!parakeetRes.ok) { if (!openRouterRes.ok) {
const errorText = await parakeetRes.text().catch(() => '') const errorText = await openRouterRes.text().catch(() => '')
console.error(`[server] Parakeet ASR error response (status=${parakeetRes.status}):`, errorText) console.error(`[server] OpenRouter ASR error response (status=${openRouterRes.status}):`, errorText)
throw new Error(`Parakeet returned status ${parakeetRes.status}`) throw new Error(`OpenRouter returned status ${openRouterRes.status}`)
} }
const data: any = await parakeetRes.json() const data: any = await openRouterRes.json()
const text = (data?.text || '').trim() const text = (data?.text || '').trim()
console.log(`[server] ASR completed successfully: "${text}"`) console.log(`[server] OpenRouter ASR completed successfully: "${text}"`)
return res.json({ text }) return res.json({ text })
} catch (error: unknown) { } catch (error: unknown) {
if (error instanceof Error && error.name === 'AbortError') { if (error instanceof Error && error.name === 'AbortError') {
console.error('[server] Parakeet ASR request timed out') console.error('[server] OpenRouter ASR request timed out')
return res.status(504).json({ error: 'Transcription request timed out' }) return res.status(504).json({ error: 'Transcription request timed out' })
} }
throw error throw error
+6
View File
@@ -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) {