Compare commits

...

37 Commits

Author SHA1 Message Date
elpatron 78f1659db4 chore: release v0.1.1.15 2026-06-04 19:30:53 +02:00
elpatron 935c263648 style: link KnorrLabs to dashy with compass icon badge 2026-06-04 19:29:59 +02:00
elpatron 29ac96f892 chore: release v0.1.1.14 2026-06-04 19:27:12 +02:00
elpatron 4d3b7210b3 style: replace name with email address in footer mail link badge 2026-06-04 19:27:00 +02:00
elpatron 369bca2ef1 chore: release v0.1.1.13 2026-06-04 19:23:13 +02:00
elpatron 2fcc741f5e style: style email link in footer as icon badge similar to Ko-Fi badge 2026-06-04 19:22:43 +02:00
elpatron 27722186d1 AI Video updated 2026-06-04 19:18:10 +02:00
elpatron 5710c74706 feat: add square sticker/sharepic image 2026-06-04 19:09:27 +02:00
elpatron cd27dfa27d Add AI generated marketing video 2026-06-04 19:05:36 +02:00
elpatron c4c7d42de4 feat: add portrait sharepic and update generation script 2026-06-04 18:39:07 +02:00
elpatron 71025b3d61 style: add QR code to sharepic layout 2026-06-04 18:36:51 +02:00
elpatron f790a6adcc feat: add sharepic HTML template, generation script, and client package task 2026-06-04 18:36:12 +02:00
elpatron de5a46938b chore: release v0.1.1.12 2026-06-04 18:26:15 +02:00
elpatron 16944c1a26 chore: update contact email in footer to moin@kapteins-daagbok.eu 2026-06-04 18:26:02 +02:00
elpatron fae7b20f90 chore: release v0.1.1.11 2026-06-03 19:39:37 +02:00
elpatron 73e7613a1b feat(logbook): attribute log events to creator and show in exports 2026-06-03 19:39:15 +02:00
elpatron 6c8aa5af4c chore: release v0.1.1.10 2026-06-03 19:17:02 +02:00
elpatron 9554f4b66e style(client): center PWA update and install banners properly 2026-06-03 19:16:56 +02:00
elpatron 5c77bbfdc3 style(client): hide version footer on mobile when bottom navigation is active 2026-06-03 19:15:09 +02:00
elpatron 979b572136 chore: release v0.1.1.9 2026-06-03 19:11:29 +02:00
elpatron f189317dfc chore: remove visual debug logs panel from voice recording modal 2026-06-03 19:11:25 +02:00
elpatron c54f834311 chore: release v0.1.1.8 2026-06-03 19:07:07 +02:00
elpatron 9d05005bb7 fix: allow blob and data urls in Content-Security-Policy media-src directive 2026-06-03 19:07:03 +02:00
elpatron 40c4874156 chore: release v0.1.1.7 2026-06-03 18:56:39 +02:00
elpatron 2de0636608 fix: call load() to force mobile browsers to fetch blob URL metadata and fix player duration 2026-06-03 18:56:32 +02:00
elpatron 9e7c6f4397 chore: release v0.1.1.6 2026-06-03 18:51:14 +02:00
elpatron 6600ceafce debug: add verbose console logging and on-screen logs area to LiveVoiceCapture 2026-06-03 18:51:08 +02:00
elpatron d7a497a4a2 chore: release v0.1.1.5 2026-06-03 18:44:56 +02:00
elpatron 4c04086d63 fix: solve audio recording on iOS/Safari and fix Dockerfile health check 2026-06-03 18:44:51 +02:00
elpatron 79ce42bec6 chore: release v0.1.1.4 2026-06-03 18:33:39 +02:00
elpatron 72c956162c fix: resolve 0-second duration issue on WebM voice recordings in Chrome/Android 2026-06-03 18:33:35 +02:00
elpatron 3080b59dc8 chore: release v0.1.1.3 2026-06-03 18:27:00 +02:00
elpatron d054e42cc0 style: add sunset background image to login screen 2026-06-03 18:26:52 +02:00
elpatron d299fc1d93 chore: release v0.1.1.2 2026-06-03 18:23:23 +02:00
elpatron 6447e95d7d fix: defer stopping media stream tracks until media recorder finishes stopping 2026-06-03 18:22:30 +02:00
elpatron 7ec5a1eccc chore: release v0.1.1.1 2026-06-03 18:14:13 +02:00
elpatron 4cf70a3431 style: increase footer and Ko-Fi badge font-size 2026-06-03 18:14:07 +02:00
28 changed files with 1305 additions and 66 deletions
+1 -1
View File
@@ -1 +1 @@
0.1.1.1
0.1.1.16
+1 -1
View File
@@ -29,4 +29,4 @@ EXPOSE 80
# Health check to verify Nginx is actively running
HEALTHCHECK --interval=30s --timeout=5s --start-period=3s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:80/ || exit 1
CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:80/ || exit 1
+3 -3
View File
@@ -8,7 +8,7 @@ server {
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=(self)" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://plausible.elpatron.me; connect-src 'self' https://plausible.elpatron.me; img-src 'self' data: blob: https://*.tile.openstreetmap.org; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://plausible.elpatron.me; connect-src 'self' https://plausible.elpatron.me; img-src 'self' data: blob: https://*.tile.openstreetmap.org; media-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
# Service worker and app shell must revalidate so PWA updates are detected
location ~* ^/(sw\.js|workbox-.*\.js|manifest\.webmanifest|version\.json)$ {
@@ -18,7 +18,7 @@ server {
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=(self)" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://plausible.elpatron.me; connect-src 'self' https://plausible.elpatron.me; img-src 'self' data: blob: https://*.tile.openstreetmap.org; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://plausible.elpatron.me; connect-src 'self' https://plausible.elpatron.me; img-src 'self' data: blob: https://*.tile.openstreetmap.org; media-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
}
location = /index.html {
@@ -28,7 +28,7 @@ server {
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=(self)" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://plausible.elpatron.me; connect-src 'self' https://plausible.elpatron.me; img-src 'self' data: blob: https://*.tile.openstreetmap.org; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://plausible.elpatron.me; connect-src 'self' https://plausible.elpatron.me; img-src 'self' data: blob: https://*.tile.openstreetmap.org; media-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
}
location / {
+1
View File
@@ -13,6 +13,7 @@
"generate:flyer:png": "node ../scripts/generate-beta-flyer.mjs --png",
"generate:flyer:all": "node ../scripts/generate-beta-flyer.mjs --all",
"generate:flyer:setup": "playwright install chromium",
"generate:sharepic": "node ../scripts/generate-sharepic.mjs",
"translate:locales": "node ../scripts/translate-locales.mjs",
"translate:flyer": "node ../scripts/translate-flyer.mjs",
"validate:i18n": "node ../scripts/validate-i18n-keys.mjs"
Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

+60 -6
View File
@@ -36,6 +36,10 @@ code {
min-height: 100svh;
padding: 24px 16px calc(48px + env(safe-area-inset-bottom, 0px));
box-sizing: border-box;
background-image: linear-gradient(rgba(15, 23, 42, 0.3), rgba(15, 23, 42, 0.5)), url('/login-bg.jpg');
background-size: cover;
background-position: center;
background-repeat: no-repeat;
}
/* Glassmorphism Auth Card */
@@ -5293,8 +5297,9 @@ html.theme-cupertino .events-scroll-container {
/* PWA install prompt */
.pwa-install-banner {
position: fixed;
left: 16px;
right: 16px;
left: 0;
right: 0;
width: calc(100% - 32px);
bottom: calc(36px + env(safe-area-inset-bottom, 0px));
z-index: 1200;
display: grid;
@@ -5457,8 +5462,9 @@ html.theme-cupertino .events-scroll-container {
.pwa-update-banner {
position: fixed;
top: calc(12px + env(safe-area-inset-top, 0px));
left: 16px;
right: 16px;
left: 0;
right: 0;
width: calc(100% - 32px);
z-index: 1300;
display: grid;
grid-template-columns: auto 1fr auto;
@@ -5573,7 +5579,7 @@ html.theme-cupertino .events-scroll-container {
flex-wrap: wrap;
gap: 6px;
padding: 6px 12px calc(6px + env(safe-area-inset-bottom, 0px));
font-size: 11px;
font-size: 13px;
line-height: 1.4;
color: #64748b;
background: rgba(11, 12, 16, 0.72);
@@ -5581,6 +5587,12 @@ html.theme-cupertino .events-scroll-container {
pointer-events: none;
}
@media (max-width: 768px) {
body:has(.app-bottom-nav) .app-version-footer {
display: none;
}
}
.app-version-footer a,
.app-version-footer button {
pointer-events: auto;
@@ -5615,7 +5627,7 @@ html.theme-cupertino .events-scroll-container {
gap: 4px;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
font-size: 13px;
font-weight: 500;
color: #94a3b8;
text-decoration: none;
@@ -5630,6 +5642,48 @@ html.theme-cupertino .events-scroll-container {
border-color: rgba(255, 94, 91, 0.32);
}
.mail-footer-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 999px;
font-size: 13px;
font-weight: 500;
color: #94a3b8;
text-decoration: none;
background: rgba(56, 189, 248, 0.08);
border: 1px solid rgba(56, 189, 248, 0.18);
transition: color 0.15s ease, background 0.15s ease, border-color 0.15s ease;
}
.mail-footer-badge:hover {
color: #bae6fd;
background: rgba(56, 189, 248, 0.14);
border-color: rgba(56, 189, 248, 0.32);
}
.knorrlabs-footer-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 999px;
font-size: 13px;
font-weight: 500;
color: #94a3b8;
text-decoration: none;
background: rgba(139, 92, 246, 0.08);
border: 1px solid rgba(139, 92, 246, 0.18);
transition: color 0.15s ease, background 0.15s ease, border-color 0.15s ease;
}
.knorrlabs-footer-badge:hover {
color: #ddd6fe;
background: rgba(139, 92, 246, 0.14);
border-color: rgba(139, 92, 246, 0.32);
}
.demo-badge {
display: inline-flex;
align-items: center;
+27 -9
View File
@@ -1,4 +1,4 @@
import { Coffee } from 'lucide-react'
import { Coffee, Mail, Compass } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
@@ -15,17 +15,35 @@ export default function AppFooter() {
·
</span>
<span className="app-version-footer__copyright">
© 2026 KnorrLabs/
<a
href="mailto:elpatron+kd@mailbox.org"
onClick={() => trackPlausibleEvent(PlausibleEvents.FOOTER_LINK_CLICKED)}
>
Markus F.J. Busche
</a>
© 2026
</span>
<span className="app-version-footer__sep" aria-hidden="true">
·
</span>
<a
className="knorrlabs-footer-badge"
href="https://dashy.elpatron.me/"
target="_blank"
rel="noopener noreferrer"
onClick={() => trackPlausibleEvent(PlausibleEvents.FOOTER_LINK_CLICKED)}
>
<Compass size={14} aria-hidden="true" />
<span>KnorrLabs</span>
</a>
<span className="app-version-footer__sep" aria-hidden="true">
·
</span>
<a
className="mail-footer-badge"
href="mailto:moin@kapteins-daagbok.eu"
onClick={() => trackPlausibleEvent(PlausibleEvents.FOOTER_LINK_CLICKED)}
>
<Mail size={14} aria-hidden="true" />
<span>moin@kapteins-daagbok.eu</span>
</a>
<span className="app-version-footer__sep" aria-hidden="true">
·
</span>
<a
className="kofi-footer-badge"
href={KOFI_URL}
@@ -35,7 +53,7 @@ export default function AppFooter() {
aria-label={t('footer.kofi_title')}
onClick={() => trackPlausibleEvent(PlausibleEvents.KOFI_LINK_CLICKED)}
>
<Coffee size={12} aria-hidden="true" />
<Coffee size={14} aria-hidden="true" />
<span>{t('footer.kofi_label')}</span>
</a>
</footer>
+114
View File
@@ -0,0 +1,114 @@
import React from 'react'
interface PersonSnapshot {
name: string
photo?: string | null
role?: string
}
interface CreatorAvatarProps {
creatorId?: string
crewSnapshotsById?: Record<string, PersonSnapshot>
fallbackName?: string
size?: number
}
const colors = [
'#2563eb', // blue
'#059669', // emerald
'#d97706', // amber
'#dc2626', // red
'#7c3aed', // violet
'#db2777', // pink
'#0891b2', // cyan
'#4f46e5', // indigo
'#0f766e', // teal
'#9333ea', // purple
]
function getAvatarColor(name: string): string {
let hash = 0
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash)
}
const index = Math.abs(hash) % colors.length
return colors[index]
}
export default function CreatorAvatar({
creatorId,
crewSnapshotsById,
fallbackName,
size = 28
}: CreatorAvatarProps) {
let name = ''
let photo: string | null = null
let role = ''
if (creatorId && crewSnapshotsById && crewSnapshotsById[creatorId]) {
const snap = crewSnapshotsById[creatorId]
name = snap.name || ''
photo = snap.photo || null
role = snap.role || ''
}
// Fallback to active username if owner or no crew pool matches
if (!name) {
if (creatorId === 'skipper') {
name = fallbackName || localStorage.getItem('active_username') || 'Skipper'
role = 'skipper'
} else if (fallbackName) {
name = fallbackName
} else if (creatorId) {
// If creatorId is a username itself (fallback from LiveLogView)
name = creatorId
} else {
name = '?'
}
}
const initial = name ? name.trim().split(/\s+/)[0]?.charAt(0).toUpperCase() || '?' : '?'
const bgColor = name === '?' ? '#64748b' : getAvatarColor(name)
const style: React.CSSProperties = {
width: `${size}px`,
height: `${size}px`,
borderRadius: '50%',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: `${Math.round(size * 0.45)}px`,
fontWeight: 'bold',
color: '#ffffff',
backgroundColor: bgColor,
flexShrink: 0,
verticalAlign: 'middle',
overflow: 'hidden',
border: '1px solid rgba(255, 255, 255, 0.15)',
boxSizing: 'border-box'
}
const roleText = role ? (role === 'skipper' ? 'Skipper' : 'Crew') : ''
const tooltip = name + (roleText ? ` (${roleText})` : '')
if (photo) {
return (
<img
src={photo}
alt={name}
title={tooltip}
style={{
...style,
objectFit: 'cover',
backgroundColor: 'transparent'
}}
/>
)
}
return (
<div style={style} title={tooltip} className="creator-avatar-fallback">
{initial}
</div>
)
}
+76 -3
View File
@@ -23,13 +23,14 @@ import {
} from 'lucide-react'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import {
appendQuickEvent,
appendQuickEvents,
appendTankRefill,
appendQuickEvent as apiAppendQuickEvent,
appendQuickEvents as apiAppendQuickEvents,
appendTankRefill as apiAppendTankRefill,
findOrCreateTodayEntry,
loadEntry,
removeLastEvent
} from '../services/quickEventLog.js'
import CreatorAvatar from './CreatorAvatar.tsx'
import { formatEventSummary } from '../utils/formatEventSummary.js'
import {
getLastAutoPositionMs,
@@ -160,6 +161,24 @@ function gpsFailureAlertBody(
return `${t(geolocationErrorI18nKey(reason))}\n\n${t('logs.live_position_manual_hint')}`
}
function findActiveCreatorId(
activeUsername: string | null,
crewSnapshotsById: Record<string, any>,
selectedSkipperId: string | null
): string {
const username = (activeUsername || '').trim()
if (username) {
const matchEntry = Object.entries(crewSnapshotsById).find(
([_, snap]) => (snap?.name || '').trim().toLowerCase() === username.toLowerCase()
)
if (matchEntry) {
return matchEntry[0]
}
return username
}
return selectedSkipperId || 'skipper'
}
export default function LiveLogView({
logbookId,
onOpenEditor,
@@ -173,6 +192,8 @@ export default function LiveLogView({
const [dayOfTravel, setDayOfTravel] = useState('')
const [date, setDate] = useState('')
const [events, setEvents] = useState<LogEventPayload[]>([])
const [crewSnapshotsById, setCrewSnapshotsById] = useState<Record<string, any>>({})
const [selectedSkipperId, setSelectedSkipperId] = useState<string | null>(null)
const [yachtSails, setYachtSails] = useState<string[]>([])
const [loading, setLoading] = useState(true)
const [busy, setBusy] = useState(false)
@@ -214,6 +235,51 @@ export default function LiveLogView({
dateRef.current = date
busyRef.current = busy
const getActiveCreatorId = useCallback(() => {
const activeUsername = localStorage.getItem('active_username')
return findActiveCreatorId(activeUsername, crewSnapshotsById, selectedSkipperId)
}, [crewSnapshotsById, selectedSkipperId])
const appendQuickEvent = useCallback((
logbookId: string,
entryId: string,
partialEvent: Partial<LogEventPayload>,
headerPatch?: { departure?: string; destination?: string }
) => {
return apiAppendQuickEvent(
logbookId,
entryId,
{ creatorId: getActiveCreatorId(), ...partialEvent },
headerPatch
)
}, [getActiveCreatorId])
const appendQuickEvents = useCallback((
logbookId: string,
entryId: string,
partialEvents: Partial<LogEventPayload>[]
) => {
const creatorId = getActiveCreatorId()
const mapped = partialEvents.map((p) => ({ creatorId, ...p }))
return apiAppendQuickEvents(logbookId, entryId, mapped)
}, [getActiveCreatorId])
const appendTankRefill = useCallback((
logbookId: string,
entryId: string,
tank: 'fuel' | 'freshwater',
addLiters: number,
event: Partial<LogEventPayload>
) => {
return apiAppendTankRefill(
logbookId,
entryId,
tank,
addLiters,
{ creatorId: getActiveCreatorId(), ...event }
)
}, [getActiveCreatorId])
const defaultSails = useMemo(
() => (i18n.language === 'de'
? ['Großsegel', 'Genua', 'Fock', 'Spinnaker', 'Gennaker']
@@ -237,6 +303,8 @@ export default function LiveLogView({
setDayOfTravel(String(loaded.data.dayOfTravel || ''))
setDate(String(loaded.data.date || ''))
setEvents(sortLogEventsByTime(entryEvents.map((e) => ({ ...e }))))
setCrewSnapshotsById((loaded.data.crewSnapshotsById as Record<string, any>) || {})
setSelectedSkipperId(typeof loaded.data.selectedSkipperId === 'string' ? loaded.data.selectedSkipperId : null)
}, [])
const refreshEntry = useCallback(async (id: string) => {
@@ -1152,6 +1220,11 @@ export default function LiveLogView({
return (
<li key={`${event.time}-${index}`} className="live-log-entry">
<time className="live-log-time">{event.time}</time>
<CreatorAvatar
creatorId={event.creatorId}
crewSnapshotsById={crewSnapshotsById}
size={24}
/>
<div className="live-log-summary-block">
<span className="live-log-summary">{summary}</span>
{voiceId && (
+113 -18
View File
@@ -43,6 +43,53 @@ export default function LiveVoiceCapture({
const [previewMime, setPreviewMime] = useState('audio/webm')
const [previewDurationSec, setPreviewDurationSec] = useState(0)
const [saving, setSaving] = useState(false)
const log = useCallback((msg: string) => {
console.log(`[VoiceDebug] ${msg}`)
}, [])
const previewAudioRef = useRef<HTMLAudioElement | null>(null)
useEffect(() => {
const el = previewAudioRef.current
if (!el) {
log('previewAudioRef is null')
return
}
log('Preview audio player loaded. readyState=' + el.readyState + ', duration=' + el.duration + ', src=' + el.src)
const handleLoadedMetadata = () => {
log('loadedmetadata event fired. readyState=' + el.readyState + ', duration=' + el.duration)
if (el.duration === Infinity || isNaN(el.duration) || el.duration === 0) {
log('Duration correction hack triggered (duration=' + el.duration + '). Seeking to 1e10...')
el.currentTime = 1e10
const onTimeUpdate = () => {
log('timeupdate event. currentTime=' + el.currentTime + ', duration=' + el.duration)
el.currentTime = 0
el.removeEventListener('timeupdate', onTimeUpdate)
log('currentTime reset to 0. Final duration=' + el.duration)
}
el.addEventListener('timeupdate', onTimeUpdate)
} else {
log('Duration correction skipped (duration is valid)')
}
}
if (el.readyState >= 1) {
log('readyState >= 1. Executing hack immediately...')
handleLoadedMetadata()
} else {
log('readyState = 0. Adding loadedmetadata event listener...')
el.addEventListener('loadedmetadata', handleLoadedMetadata)
}
log('Calling el.load() to force loading of the media resource...')
el.load()
return () => {
el.removeEventListener('loadedmetadata', handleLoadedMetadata)
}
}, [previewUrl, log])
const stopStream = useCallback(() => {
for (const track of streamRef.current?.getTracks() ?? []) {
@@ -110,28 +157,51 @@ export default function LiveVoiceCapture({
if (!recorder || recorder.state !== 'recording') return
recorder.stop()
clearTimer()
stopStream()
}, [clearTimer, stopStream])
}, [clearTimer])
const startRecording = async () => {
setMicError(null)
chunksRef.current = []
log('startRecording flow triggered')
if (!navigator.mediaDevices?.getUserMedia) {
log('navigator.mediaDevices.getUserMedia is unavailable')
setMicError(t('logs.live_voice_mic_denied'))
return
}
try {
log('Requesting getUserMedia audio stream...')
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
streamRef.current = stream
log('Stream obtained successfully. active=' + stream.active)
stream.getTracks().forEach((track, i) => {
log(`Track ${i}: label="${track.label}" enabled=${track.enabled} readyState=${track.readyState} muted=${track.muted}`)
})
const mimeType = pickMediaRecorderMimeType()
log('MIME type candidates support check:')
const MIME_CANDIDATES = [
'audio/webm;codecs=opus',
'audio/webm',
'audio/mp4',
'audio/ogg;codecs=opus'
]
MIME_CANDIDATES.forEach(mime => {
log(` - ${mime}: ${MediaRecorder.isTypeSupported(mime) ? 'SUPPORTED' : 'UNSUPPORTED'}`)
})
log('Selected MIME from picker: ' + mimeType)
const recorder = mimeType
? new MediaRecorder(stream, { mimeType })
: new MediaRecorder(stream)
mediaRecorderRef.current = recorder
const resolvedMime = recorder.mimeType || mimeType || 'audio/webm'
log('MediaRecorder created. Resolved mime=' + resolvedMime)
recorder.ondataavailable = (ev) => {
if (ev.data.size > 0) chunksRef.current.push(ev.data)
log(`ondataavailable event: data size=${ev.data?.size} bytes`)
if (ev.data && ev.data.size > 0) {
chunksRef.current.push(ev.data)
}
}
recorder.onstop = () => {
@@ -139,44 +209,67 @@ export default function LiveVoiceCapture({
VOICE_MEMO_MAX_DURATION_SEC,
Math.max(1, Math.round((Date.now() - startedAtRef.current) / 1000))
)
const blob = new Blob(chunksRef.current, { type: resolvedMime })
chunksRef.current = []
try {
assertVoiceMemoBlobSize(blob)
finishRecording(blob, resolvedMime, durationSec)
} catch {
setMicError(t('logs.live_voice_too_large'))
setPhase('idle')
}
log(`onstop triggered. durationSec=${durationSec}. Wrapping in 50ms timeout...`)
setTimeout(() => {
log(`Creating Blob from ${chunksRef.current.length} chunks. Resolved mime=${resolvedMime}`)
const totalChunksSize = chunksRef.current.reduce((acc, chunk) => acc + chunk.size, 0)
log(`Total raw chunks size: ${totalChunksSize} bytes`)
const blob = new Blob(chunksRef.current, { type: resolvedMime })
chunksRef.current = []
stopStream()
log(`Blob finalized: size=${blob.size} bytes, type=${blob.type}`)
try {
assertVoiceMemoBlobSize(blob)
log('Blob size assertion passed. Calling finishRecording...')
finishRecording(blob, resolvedMime, durationSec)
} catch (err) {
log('Blob size assertion failed (too large)')
setMicError(t('logs.live_voice_too_large'))
setPhase('idle')
}
}, 50)
}
recorder.onerror = () => {
recorder.onerror = (ev) => {
log('MediaRecorder onerror triggered: ' + JSON.stringify(ev))
setMicError(t('logs.live_voice_record_failed'))
resetAll()
}
startedAtRef.current = Date.now()
recorder.start(200)
log('Calling recorder.start()...')
recorder.start()
log('recorder.start() called. State=' + recorder.state)
setPhase('recording')
setElapsedSec(0)
timerRef.current = window.setInterval(() => {
const sec = Math.floor((Date.now() - startedAtRef.current) / 1000)
setElapsedSec(sec)
if (sec >= VOICE_MEMO_MAX_DURATION_SEC) {
log('Max duration reached. Stopping recording...')
stopRecording()
}
}, 250)
} catch {
} catch (err: any) {
log('Error in startRecording try-catch block: ' + (err instanceof Error ? err.stack || err.message : String(err)))
setMicError(t('logs.live_voice_mic_denied'))
stopStream()
}
}
const handleSave = async () => {
if (!previewBlob || saving || busy) return
if (!previewBlob || saving || busy) {
log('handleSave ignored. previewBlob=' + (previewBlob ? 'PRESENT' : 'NULL') + ' saving=' + saving + ' busy=' + busy)
return
}
log('handleSave triggered. Saving blob size=' + previewBlob.size + ' mime=' + previewMime + ' duration=' + previewDurationSec)
setSaving(true)
try {
onSave(previewBlob, previewMime, previewDurationSec)
log('Invoking onSave callback...')
await onSave(previewBlob, previewMime, previewDurationSec)
log('onSave callback successfully finished!')
} catch (err: any) {
log('Error during onSave execution: ' + (err instanceof Error ? err.stack || err.message : String(err)))
} finally {
setSaving(false)
}
@@ -241,7 +334,7 @@ export default function LiveVoiceCapture({
{phase === 'preview' && previewUrl && (
<>
<audio className="voice-memo-player" controls src={previewUrl} preload="auto" />
<audio ref={previewAudioRef} className="voice-memo-player" controls src={previewUrl} preload="auto" />
{onCaptionChange && (
<label className="live-voice-caption-field">
<span>{t('logs.live_voice_caption_label')}</span>
@@ -278,6 +371,8 @@ export default function LiveVoiceCapture({
</div>
</>
)}
</div>
</div>
)
+41 -3
View File
@@ -11,6 +11,7 @@ import { downloadLogbookPagePdf } from '../services/pdfExport.js'
import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock, Download, Upload, Pencil, X, ChevronDown, ChevronUp, Sparkles } from 'lucide-react'
import PhotoCapture from './PhotoCapture.tsx'
import EventRemarksCell from './EventRemarksCell.tsx'
import CreatorAvatar from './CreatorAvatar.tsx'
import { useEntryVoiceMemos } from '../hooks/useEntryVoiceMemos.js'
import { parseLiveVoiceRemark } from '../utils/liveEventCodes.js'
import { deleteEntryVoiceMemo } from '../services/voiceAttachments.js'
@@ -173,6 +174,24 @@ function fingerprintFromStoredEntry(decrypted: Record<string, unknown>): string
})
}
function findActiveCreatorId(
activeUsername: string | null,
crewSnapshotsById: Record<string, any>,
selectedSkipperId: string | null
): string {
const username = (activeUsername || '').trim()
if (username) {
const matchEntry = Object.entries(crewSnapshotsById).find(
([_, snap]) => (snap?.name || '').trim().toLowerCase() === username.toLowerCase()
)
if (matchEntry) {
return matchEntry[0]
}
return username
}
return selectedSkipperId || 'skipper'
}
interface LogEntryEditorProps {
entryId: string
logbookId: string
@@ -418,8 +437,17 @@ export default function LogEntryEditor({
})
}, [buildPayloadForSigning, signSkipper, signCrew])
const buildEventFromForm = (): LogEvent =>
normalizeLogEvent({
const buildEventFromForm = (): LogEvent => {
let creatorId: string | undefined = undefined
if (editingEventIndex !== null && events[editingEventIndex]) {
creatorId = events[editingEventIndex].creatorId
}
if (!creatorId) {
const activeUsername = localStorage.getItem('active_username')
creatorId = findActiveCreatorId(activeUsername, entryCrew.crewSnapshotsById, entryCrew.selectedSkipperId)
}
return normalizeLogEvent({
time: evTime,
mgk: evMgk,
rwk: evRwk,
@@ -436,8 +464,10 @@ export default function LogEntryEditor({
distance: evDistance,
gpsLat: evGpsLat,
gpsLng: evGpsLng,
remarks: evRemarks
remarks: evRemarks,
creatorId
})
}
const applyEventFormToEvents = (eventData: LogEvent): LogEvent[] => {
if (editingEventIndex !== null) {
@@ -1815,6 +1845,7 @@ export default function LogEntryEditor({
<thead>
<tr>
<th>{t('logs.event_time')}</th>
<th>{t('logs.event_creator')}</th>
<th>{t('logs.event_mgk')}</th>
<th>{t('logs.event_rwk')}</th>
<th>{t('logs.event_wind_direction')}</th>
@@ -1831,6 +1862,13 @@ export default function LogEntryEditor({
{events.map((ev, idx) => (
<tr key={idx}>
<td className="font-mono">{ev.time}</td>
<td style={{ textAlign: 'center', width: '40px', verticalAlign: 'middle' }}>
<CreatorAvatar
creatorId={ev.creatorId}
crewSnapshotsById={entryCrew.crewSnapshotsById}
size={24}
/>
</td>
<td>{ev.mgk ? `${ev.mgk}°` : '—'}</td>
<td>{ev.rwk ? `${ev.rwk}°` : '—'}</td>
<td>{ev.windDirection || '—'}</td>
+34 -2
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useEffect, useState, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { db } from '../services/db.js'
import { getActiveMasterKey } from '../services/auth.js'
@@ -30,6 +30,38 @@ export default function VoiceMemoPlayer({
const [src, setSrc] = useState<string | null>(preloaded?.audio ?? null)
const [error, setError] = useState(false)
const audioRef = useRef<HTMLAudioElement | null>(null)
useEffect(() => {
const el = audioRef.current
if (!el) return
const handleLoadedMetadata = () => {
if (el.duration === Infinity || isNaN(el.duration) || el.duration === 0) {
el.currentTime = 1e10
const onTimeUpdate = () => {
el.currentTime = 0
el.removeEventListener('timeupdate', onTimeUpdate)
}
el.addEventListener('timeupdate', onTimeUpdate)
}
}
if (el.readyState >= 1) {
handleLoadedMetadata()
} else {
el.addEventListener('loadedmetadata', handleLoadedMetadata)
}
if (src) {
el.load()
}
return () => {
el.removeEventListener('loadedmetadata', handleLoadedMetadata)
}
}, [src])
useEffect(() => {
if (preloaded?.audio) {
setSrc(preloaded.audio)
@@ -75,7 +107,7 @@ export default function VoiceMemoPlayer({
return (
<div className="voice-memo-player-shell">
<audio className={playerClass} controls preload="none" src={src} />
<audio ref={audioRef} className={playerClass} controls preload="metadata" src={src} />
</div>
)
}
+1
View File
@@ -344,6 +344,7 @@
"carry_over_tanks_yes": "Tag over",
"carry_over_tanks_no": "Start med 0",
"event_title": "Kronologisk hændelseslog",
"event_creator": "Indtastet af",
"no_events": "Der er endnu ikke indtastet nogen begivenheder for denne rejsedag.",
"event_time": "Tidspunkt på dagen",
"event_mgk": "MgK-kursus",
+1
View File
@@ -344,6 +344,7 @@
"carry_over_tanks_yes": "Übernehmen",
"carry_over_tanks_no": "Mit 0 starten",
"event_title": "Chronologisches Ereignisprotokoll",
"event_creator": "Eingetragen von",
"no_events": "Noch keine Ereignisse für diesen Reisetag eingetragen.",
"event_time": "Uhrzeit",
"event_mgk": "MgK Kurs",
+1
View File
@@ -344,6 +344,7 @@
"carry_over_tanks_yes": "Carry over",
"carry_over_tanks_no": "Start at 0",
"event_title": "Chronological Event Logbook",
"event_creator": "Entered by",
"no_events": "No events logged for this travel day yet.",
"event_time": "Time",
"event_mgk": "MgK Course",
+1
View File
@@ -344,6 +344,7 @@
"carry_over_tanks_yes": "Ta over",
"carry_over_tanks_no": "Begynn med 0",
"event_title": "Kronologisk hendelseslogg",
"event_creator": "Registrert av",
"no_events": "Ingen arrangementer lagt inn for denne reisedagen ennå.",
"event_time": "Tid på døgnet",
"event_mgk": "MgK-kurs",
+1
View File
@@ -344,6 +344,7 @@
"carry_over_tanks_yes": "Ta över",
"carry_over_tanks_no": "Börja med 0",
"event_title": "Kronologisk händelselogg",
"event_creator": "Registrerad av",
"no_events": "Inga händelser inlagda för denna resdag ännu.",
"event_time": "Tid på dygnet",
"event_mgk": "MgK-kurs",
+14 -3
View File
@@ -77,7 +77,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
'Date', 'Day of Travel', 'Departure Port', 'Destination Port', 'AI Summary',
'Skipper Signature', 'Crew Signature',
'Track Distance (nm)', 'Track Max Speed (kn)', 'Track Avg Speed (kn)', 'Motor Hours (h)',
'Event Time', 'MgK Course', 'RwK Course',
'Event Time', 'Event Creator', 'MgK Course', 'RwK Course',
'Wind Dir', 'Wind Str', 'Barometer (hPa)', 'Sea State', 'Visibility',
'Current', 'Heel Angle', 'Sails/Motor', 'Log (nm)', 'Distance (nm)',
'Latitude', 'Longitude', 'Remarks',
@@ -122,6 +122,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
const greywaterLevel = entry.greywater?.level ?? '';
const aiSummary = entry.aiSummary ?? '';
const crewSnapshots = (entry.crewSnapshotsById as Record<string, any>) || {};
const eventsList = entry.events || [];
if (eventsList.length === 0) {
// Create one row even if there are no events for the day
@@ -129,7 +130,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
dateVal, travelDay, dep, dest, aiSummary,
signS, signC,
trackDist, trackMax, trackAvg, motorH,
'', '', '',
'', '', '', '',
'', '', '', '', '',
'', '', '', '', '',
'', '', '',
@@ -142,11 +143,21 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
// Sort events chronologically by time
const sortedEvents = sortLogEventsByTime(eventsList);
for (const ev of sortedEvents) {
const creatorSnap = ev.creatorId ? crewSnapshots[ev.creatorId] : null;
let creatorName = '';
if (creatorSnap) {
creatorName = creatorSnap.name || '';
} else if (ev.creatorId === 'skipper') {
creatorName = 'Skipper';
} else if (ev.creatorId) {
creatorName = ev.creatorId;
}
rows.push([
dateVal, travelDay, dep, dest, aiSummary,
signS, signC,
trackDist, trackMax, trackAvg, motorH,
ev.time || '', ev.mgk || '', ev.rwk || '',
ev.time || '', creatorName, ev.mgk || '', ev.rwk || '',
ev.windDirection || '', ev.windStrength || '', ev.windPressure || '', ev.seaState || '',
ev.visibility || '',
ev.current || '', ev.heel || '', ev.sailsOrMotor || '', ev.logReading || '', ev.distance || '',
+66 -12
View File
@@ -13,12 +13,13 @@ function formatPasskeySignDate(signedAt: string): string {
}
export async function generateLogbookPagePdf(logbookId: string, entryId: string, preloadedData?: { yacht: any; entry: any }): Promise<jsPDF> {
let yachtName = '', homePort = '', registration = '', callsign = '', atis = '', mmsi = '';
let yachtName = '', owner = '', homePort = '', registration = '', callsign = '', atis = '', mmsi = '';
let entry: any = null;
if (preloadedData) {
const yacht = preloadedData.yacht || {};
yachtName = yacht.name || '';
owner = yacht.owner || '';
homePort = yacht.port || '';
registration = yacht.registrationNumber || yacht.registration || '';
callsign = yacht.callSign || '';
@@ -35,6 +36,7 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string,
const yacht = await resolveVesselForLogbook(logbookId)
if (yacht) {
yachtName = yacht.name || ''
owner = yacht.owner || ''
homePort = yacht.homePort || ''
registration = yacht.registrationNumber || ''
callsign = yacht.callSign || ''
@@ -74,24 +76,56 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string,
doc.setFontSize(8.5);
doc.setFont('Helvetica', 'normal');
doc.text(`Yachtname: ${yachtName || '—'}`, 10, 21);
doc.text(`Heimathafen: ${homePort || '—'}`, 60, 21);
doc.text(`Kennzeichen: ${registration || '—'}`, 110, 21);
doc.text(`Rufzeichen: ${callsign || '—'}`, 160, 21);
doc.text(`ATIS: ${atis || '—'}`, 210, 21);
doc.text(`MMSI: ${mmsi || '—'}`, 250, 21);
doc.text(`Eigner: ${owner || '—'}`, 55, 21);
doc.text(`Heimathafen: ${homePort || '—'}`, 100, 21);
doc.text(`Kennzeichen: ${registration || '—'}`, 145, 21);
doc.text(`Rufzeichen: ${callsign || '—'}`, 190, 21);
doc.text(`ATIS: ${atis || '—'}`, 230, 21);
doc.text(`MMSI: ${mmsi || '—'}`, 260, 21);
doc.text(`Datum: ${entry.date || '—'}`, 10, 23);
doc.text(`Reisetag: ${entry.dayOfTravel || '—'}`, 60, 23);
doc.text(`Reise von (Departure): ${entry.departure || '—'}`, 110, 23);
doc.text(`nach (Destination): ${entry.destination || '—'}`, 200, 23);
doc.text(`Datum: ${entry.date || '—'}`, 10, 24);
doc.text(`Reisetag: ${entry.dayOfTravel || '—'}`, 60, 24);
doc.text(`Reise von (Departure): ${entry.departure || '—'}`, 110, 24);
doc.text(`nach (Destination): ${entry.destination || '—'}`, 200, 24);
// Format Crew names with initials
const crewSnapshots = (entry.crewSnapshotsById as Record<string, any>) || {}
const crewList: string[] = []
if (entry.selectedSkipperId && crewSnapshots[entry.selectedSkipperId]) {
const name = crewSnapshots[entry.selectedSkipperId].name || 'Skipper'
const initial = name.trim().split(/\s+/)[0]?.charAt(0).toUpperCase() || 'S'
crewList.push(`${name} [${initial}] (Skipper)`)
} else if (crewSnapshots['skipper']) {
const name = crewSnapshots['skipper'].name || 'Skipper'
crewList.push(`${name} [S] (Skipper)`)
}
if (Array.isArray(entry.selectedCrewIds)) {
for (const crewId of entry.selectedCrewIds) {
const snap = crewSnapshots[crewId]
if (snap) {
const name = snap.name || ''
const initial = name.trim().split(/\s+/)[0]?.charAt(0).toUpperCase() || '?'
crewList.push(`${name} [${initial}]`)
}
}
}
const crewText = crewList.length > 0 ? `Besatzung (Crew): ${crewList.join(', ')}` : ''
doc.setFont('Helvetica', 'normal');
if (entry.trackDistanceNm) {
doc.setFont('Helvetica', 'normal');
doc.text(
`GPS-Track: ${entry.trackDistanceNm} sm · max. ${entry.trackSpeedMaxKn ?? '—'} kn · Ø ${entry.trackSpeedAvgKn ?? '—'} kn`,
10,
27
);
if (crewText) {
doc.text(crewText, 140, 27);
}
} else if (crewText) {
doc.text(crewText, 10, 27);
}
// Divider line
@@ -175,8 +209,28 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string,
doc.text(gps, writeX + 1, y + 4.2);
writeX += colWidths[11];
const crewSnapshots = (entry.crewSnapshotsById as Record<string, any>) || {};
let initial = '';
if (ev.creatorId) {
const snap = crewSnapshots[ev.creatorId];
let name = '';
if (snap) {
name = snap.name || '';
} else if (ev.creatorId === 'skipper') {
name = 'Skipper';
} else {
name = ev.creatorId;
}
if (name) {
initial = name.trim().split(/\s+/)[0]?.charAt(0).toUpperCase() || '';
}
}
// Clip remarks to fit within the 94mm bounds
const remarks = ev.remarks || '';
let remarks = ev.remarks || '';
if (initial) {
remarks = `[${initial}] ${remarks}`;
}
const maxChars = 65;
const clippedRemarks = remarks.length > maxChars ? remarks.substring(0, maxChars) + '...' : remarks;
doc.text(clippedRemarks, writeX + 1, y + 4.2);
+10 -1
View File
@@ -97,6 +97,14 @@ function buildEncryptedPayload(
consumption: fuel.consumption ?? 0
}
const entryCrew = data.selectedSkipperId
? {
selectedSkipperId: String(data.selectedSkipperId),
selectedCrewIds: Array.isArray(data.selectedCrewIds) ? data.selectedCrewIds.map(String) : [],
crewSnapshotsById: (data.crewSnapshotsById as Record<string, any>) || {}
}
: undefined
const payload = buildLogEntryPayload({
date: String(data.date || ''),
dayOfTravel: String(data.dayOfTravel || ''),
@@ -121,7 +129,8 @@ function buildEncryptedPayload(
motorHoursRaw != null && motorHoursRaw !== ''
? parseFloat(String(motorHoursRaw))
: undefined,
events: options.events
events: options.events,
entryCrew
})
const clear = options.clearSignatures
+6 -4
View File
@@ -22,6 +22,7 @@ export interface LogEventPayload {
gpsLat: string
gpsLng: string
remarks: string
creatorId?: string
}
/** Calendar date YYYY-MM-DD in local timezone (matches logbook entry `date` field). */
@@ -85,7 +86,7 @@ export function joinTimeHHMM(hours: string, minutes: string): string {
const LOG_EVENT_FIELDS: (keyof LogEventPayload)[] = [
'time', 'mgk', 'rwk', 'windPressure', 'windDirection', 'windStrength', 'seaState',
'visibility', 'weatherIcon', 'current', 'heel', 'sailsOrMotor', 'logReading', 'distance',
'gpsLat', 'gpsLng', 'remarks'
'gpsLat', 'gpsLng', 'remarks', 'creatorId'
]
/** Normalize partial/legacy events so all fields are strings (safe for form + save). */
@@ -109,10 +110,11 @@ export function normalizeLogEvent(event: Partial<LogEventPayload> | Record<strin
distance: '',
gpsLat: '',
gpsLng: '',
remarks: ''
remarks: '',
creatorId: e.creatorId ? String(e.creatorId).trim() : undefined
}
for (const key of LOG_EVENT_FIELDS) {
if (key === 'time' || key === 'mgk' || key === 'rwk' || key === 'windDirection') continue
if (key === 'time' || key === 'mgk' || key === 'rwk' || key === 'windDirection' || key === 'creatorId') continue
normalized[key] = String(e[key] ?? '').trim()
}
return normalized
@@ -122,7 +124,7 @@ export function logEventsEqual(a: LogEventPayload, b: LogEventPayload): boolean
return LOG_EVENT_FIELDS.every((key) => a[key] === b[key])
}
const LOG_EVENT_CONTENT_FIELDS = LOG_EVENT_FIELDS.filter((key) => key !== 'time')
const LOG_EVENT_CONTENT_FIELDS = LOG_EVENT_FIELDS.filter((key) => key !== 'time' && key !== 'creatorId')
/** Draft with only a time (or empty fields) — not an unsaved log entry change. */
export function isLogEventDraftEmpty(event: LogEventPayload): boolean {
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 621 KiB

+318
View File
@@ -0,0 +1,318 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<title>Kapteins Daagbok — Sharepic (Portrait)</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
width: 1080px;
height: 1920px;
font-family: 'Plus Jakarta Sans', sans-serif;
color: #e2e8f0;
background: #0f172a;
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: center;
padding: 100px 80px;
background:
radial-gradient(circle at 50% 10%, rgba(56, 189, 248, 0.18) 0%, transparent 45%),
radial-gradient(circle at 50% 90%, rgba(134, 59, 255, 0.22) 0%, transparent 45%),
linear-gradient(180deg, #090d16 0%, #111827 50%, #090d16 100%);
position: relative;
}
/* Subtle background grid pattern */
body::after {
content: "";
position: absolute;
inset: 0;
background-image: linear-gradient(rgba(148, 163, 184, 0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(148, 163, 184, 0.03) 1px, transparent 1px);
background-size: 40px 40px;
pointer-events: none;
z-index: 0;
}
/* Outer border */
.outer-border {
position: absolute;
inset: 40px;
border: 1px solid rgba(148, 163, 184, 0.1);
border-radius: 30px;
pointer-events: none;
z-index: 1;
}
.brand {
display: flex;
flex-direction: column;
align-items: center;
gap: 30px;
z-index: 2;
text-align: center;
margin-top: 40px;
}
.logo {
width: 140px;
height: 140px;
object-fit: contain;
filter: drop-shadow(0 8px 24px rgba(56, 189, 248, 0.3));
}
.title-group h1 {
font-size: 64px;
font-weight: 800;
letter-spacing: -0.03em;
color: #ffffff;
line-height: 1.1;
background: linear-gradient(135deg, #ffffff 60%, #94a3b8 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
.title-group p {
font-size: 26px;
color: #38bdf8;
font-weight: 600;
margin-top: 8px;
letter-spacing: -0.01em;
}
.main-content {
width: 100%;
display: flex;
flex-direction: column;
gap: 50px;
z-index: 2;
}
.intro-text {
font-size: 26px;
line-height: 1.6;
color: #cbd5e1;
font-weight: 400;
text-align: center;
padding: 0 20px;
}
.intro-text strong {
color: #ffffff;
font-weight: 600;
}
.features-card {
background: rgba(30, 41, 59, 0.45);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 24px;
padding: 50px 60px;
box-shadow: 0 30px 60px rgba(0, 0, 0, 0.4);
position: relative;
}
.features-card::before {
content: "";
position: absolute;
inset: 0;
border-radius: 24px;
padding: 1px;
background: linear-gradient(to bottom, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.02));
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
}
.card-title {
font-size: 20px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #94a3b8;
margin-bottom: 35px;
display: flex;
align-items: center;
gap: 12px;
}
.card-title::after {
content: "";
flex: 1;
height: 1px;
background: rgba(148, 163, 184, 0.15);
}
.badge-premium {
background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%);
color: #1e293b;
font-size: 16px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.1em;
padding: 6px 14px;
border-radius: 8px;
}
.features-list {
display: flex;
flex-direction: column;
gap: 30px;
list-style: none;
}
.feature-item {
display: flex;
align-items: flex-start;
gap: 20px;
font-size: 24px;
line-height: 1.4;
color: #cbd5e1;
font-weight: 500;
}
.feature-icon {
color: #38bdf8;
font-size: 26px;
display: flex;
align-items: center;
justify-content: center;
margin-top: 3px;
text-shadow: 0 0 10px rgba(56, 189, 248, 0.6);
}
.bottom-section {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 50px;
z-index: 2;
margin-bottom: 40px;
}
.cta-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 30px;
}
.cta-badge {
background: linear-gradient(135deg, #38bdf8 0%, #0284c7 100%);
color: #0f172a;
font-size: 32px;
font-weight: 800;
padding: 20px 45px;
border-radius: 16px;
letter-spacing: -0.02em;
box-shadow: 0 10px 30px rgba(56, 189, 248, 0.25);
}
.qr-code {
width: 160px;
height: 160px;
background: #ffffff;
padding: 10px;
border-radius: 16px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
display: flex;
align-items: center;
justify-content: center;
}
.qr-code img {
width: 100%;
height: 100%;
object-fit: contain;
}
footer {
font-size: 18px;
color: #64748b;
text-align: center;
}
footer strong {
color: #94a3b8;
font-weight: 600;
}
</style>
</head>
<body>
<div class="outer-border"></div>
<div class="brand">
<img class="logo" src="../../client/public/logo.png" alt="Kapteins Daagbok" />
<div class="title-group">
<h1>Kapteins Daagbok</h1>
<p>Digitales Yacht-Logbuch</p>
</div>
</div>
<div class="main-content">
<p class="intro-text">
Führe dein Bordlogbuch modern & digital: Reisetage, GPS-Tracks, Crew- und Schiffsdaten —
<strong>Ende-zu-Ende-verschlüsselt</strong>, als App installierbar und <strong>auch offline</strong> auf See nutzbar.
</p>
<div class="features-card">
<div class="card-title">Top Features <span class="badge-premium">Kostenlos & Werbefrei</span></div>
<ul class="features-list">
<li class="feature-item">
<span class="feature-icon"></span>
<span>Nautisches Logbuch-Format & Streckenstatistik</span>
</li>
<li class="feature-item">
<span class="feature-icon"></span>
<span>Offline-first PWA — läuft auf allen Smartphones & Tablets</span>
</li>
<li class="feature-item">
<span class="feature-icon"></span>
<span>Ende-zu-Ende Verschlüsselung (Zero-Knowledge)</span>
</li>
<li class="feature-item">
<span class="feature-icon"></span>
<span>Einfache passwortlose Passkey-Anmeldung</span>
</li>
<li class="feature-item">
<span class="feature-icon"></span>
<span>GPS-Track-Upload & automatische NMEA-Erfassung</span>
</li>
<li class="feature-item">
<span class="feature-icon"></span>
<span>Crew-Einladung zur gemeinsamen Logbuch-Arbeit</span>
</li>
</ul>
</div>
</div>
<div class="bottom-section">
<div class="cta-container">
<div class="cta-badge">
kapteins-daagbok.eu
</div>
<div class="qr-code">
<img src="assets/qr-kapteins-daagbok.eu.png" alt="QR Code" />
</div>
</div>
<footer>
<strong>Kapteins Daagbok</strong> ist ein werbefreies, privates Hobbyprojekt.
</footer>
</div>
</body>
</html>
+320
View File
@@ -0,0 +1,320 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<title>Kapteins Daagbok — Sharepic</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
width: 1200px;
height: 630px;
font-family: 'Plus Jakarta Sans', sans-serif;
color: #e2e8f0;
background: #0f172a;
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 60px 80px;
background:
radial-gradient(circle at 90% 10%, rgba(56, 189, 248, 0.15) 0%, transparent 45%),
radial-gradient(circle at 10% 90%, rgba(134, 59, 255, 0.18) 0%, transparent 45%),
linear-gradient(165deg, #090d16 0%, #111827 50%, #090d16 100%);
position: relative;
}
/* Subtle background grid pattern */
body::after {
content: "";
position: absolute;
inset: 0;
background-image: linear-gradient(rgba(148, 163, 184, 0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(148, 163, 184, 0.03) 1px, transparent 1px);
background-size: 40px 40px;
pointer-events: none;
z-index: 0;
}
/* Outer border */
.outer-border {
position: absolute;
inset: 30px;
border: 1px solid rgba(148, 163, 184, 0.1);
border-radius: 20px;
pointer-events: none;
z-index: 1;
}
.content-wrapper {
display: flex;
justify-content: space-between;
align-items: center;
height: 100%;
width: 100%;
z-index: 2;
position: relative;
gap: 50px;
}
.left-col {
flex: 1.1;
display: flex;
flex-direction: column;
justify-content: center;
gap: 30px;
}
.brand {
display: flex;
align-items: center;
gap: 20px;
}
.logo {
width: 80px;
height: 80px;
object-fit: contain;
filter: drop-shadow(0 4px 12px rgba(56, 189, 248, 0.3));
}
.title-group h1 {
font-size: 44px;
font-weight: 800;
letter-spacing: -0.03em;
color: #ffffff;
line-height: 1.1;
background: linear-gradient(135deg, #ffffff 60%, #94a3b8 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
.title-group p {
font-size: 18px;
color: #38bdf8;
font-weight: 600;
margin-top: 4px;
letter-spacing: -0.01em;
}
.intro-text {
font-size: 20px;
line-height: 1.6;
color: #cbd5e1;
font-weight: 400;
}
.intro-text strong {
color: #ffffff;
font-weight: 600;
}
.cta-container {
display: flex;
align-items: center;
gap: 20px;
}
.cta-badge {
background: linear-gradient(135deg, #38bdf8 0%, #0284c7 100%);
color: #0f172a;
font-size: 22px;
font-weight: 800;
padding: 14px 28px;
border-radius: 12px;
letter-spacing: -0.02em;
box-shadow: 0 4px 20px rgba(56, 189, 248, 0.25);
}
.qr-code {
width: 60px;
height: 60px;
background: #ffffff;
padding: 4px;
border-radius: 8px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
display: flex;
align-items: center;
justify-content: center;
}
.qr-code img {
width: 100%;
height: 100%;
object-fit: contain;
}
.right-col {
flex: 0.9;
display: flex;
flex-direction: column;
justify-content: center;
}
.features-card {
background: rgba(30, 41, 59, 0.45);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 20px;
padding: 35px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
position: relative;
}
.features-card::before {
content: "";
position: absolute;
inset: 0;
border-radius: 20px;
padding: 1px;
background: linear-gradient(to bottom, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.02));
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
}
.features-list {
display: flex;
flex-direction: column;
gap: 18px;
list-style: none;
}
.feature-item {
display: flex;
align-items: flex-start;
gap: 14px;
font-size: 16px;
line-height: 1.4;
color: #cbd5e1;
font-weight: 500;
}
.feature-icon {
color: #38bdf8;
font-size: 18px;
display: flex;
align-items: center;
justify-content: center;
margin-top: 2px;
text-shadow: 0 0 8px rgba(56, 189, 248, 0.6);
}
.badge-premium {
background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%);
color: #1e293b;
font-size: 12px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.1em;
padding: 4px 10px;
border-radius: 6px;
margin-left: auto;
}
.card-title {
font-size: 14px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #94a3b8;
margin-bottom: 20px;
display: flex;
align-items: center;
gap: 8px;
}
.card-title::after {
content: "";
flex: 1;
height: 1px;
background: rgba(148, 163, 184, 0.15);
}
footer {
position: absolute;
bottom: 45px;
left: 80px;
font-size: 12px;
color: #64748b;
z-index: 2;
}
footer strong {
color: #94a3b8;
font-weight: 600;
}
</style>
</head>
<body>
<div class="outer-border"></div>
<div class="content-wrapper">
<div class="left-col">
<div class="brand">
<img class="logo" src="../../client/public/logo.png" alt="Kapteins Daagbok" />
<div class="title-group">
<h1>Kapteins Daagbok</h1>
<p>Digitales Yacht-Logbuch</p>
</div>
</div>
<p class="intro-text">
Führe dein Bordlogbuch modern & digital: Reisetage, GPS-Tracks, Crew- und Schiffsdaten —
<strong>Ende-zu-Ende-verschlüsselt</strong>, als App installierbar und <strong>auch offline</strong> auf See nutzbar.
</p>
<div class="cta-container">
<div class="cta-badge">
kapteins-daagbok.eu
</div>
<div class="qr-code">
<img src="assets/qr-kapteins-daagbok.eu.png" alt="QR Code" />
</div>
</div>
</div>
<div class="right-col">
<div class="features-card">
<div class="card-title">Top Features <span class="badge-premium">Kostenlos & Werbefrei</span></div>
<ul class="features-list">
<li class="feature-item">
<span class="feature-icon"></span>
<span>Nautisches Logbuch-Format & Streckenstatistik</span>
</li>
<li class="feature-item">
<span class="feature-icon"></span>
<span>Offline-first PWA — läuft auf allen Smartphones & Tablets</span>
</li>
<li class="feature-item">
<span class="feature-icon"></span>
<span>Ende-zu-Ende Verschlüsselung (Zero-Knowledge)</span>
</li>
<li class="feature-item">
<span class="feature-icon"></span>
<span>Einfache passwortlose Passkey-Anmeldung</span>
</li>
<li class="feature-item">
<span class="feature-icon"></span>
<span>GPS-Track-Upload & automatische NMEA-Erfassung</span>
</li>
<li class="feature-item">
<span class="feature-icon"></span>
<span>Crew-Einladung zur gemeinsamen Logbuch-Arbeit</span>
</li>
</ul>
</div>
</div>
</div>
<footer>
<strong>Kapteins Daagbok</strong> ist ein werbefreies, privates Hobbyprojekt.
</footer>
</body>
</html>
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env node
/**
* Generates the sharepic PNGs (landscape & portrait) from HTML files
*
* Usage:
* node scripts/generate-sharepic.mjs
*/
import { execSync } from 'node:child_process'
import { dirname, resolve } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { createRequire } from 'node:module'
const __dirname = dirname(fileURLToPath(import.meta.url))
const repoRoot = resolve(__dirname, '..')
const clientDir = resolve(repoRoot, 'client')
const marketingDir = resolve(repoRoot, 'docs/marketing')
const require = createRequire(resolve(clientDir, 'package.json'))
function isMissingBrowserError(err) {
const msg = err instanceof Error ? err.message : String(err)
return msg.includes("Executable doesn't exist") || msg.includes('browserType.launch')
}
async function ensurePlaywrightChromium(playwright) {
try {
const browser = await playwright.chromium.launch({ headless: true })
await browser.close()
return
} catch (err) {
if (!isMissingBrowserError(err)) throw err
}
console.log('Playwright Chromium fehlt — installiere Browser (einmalig)…')
execSync('npx playwright install chromium', {
cwd: clientDir,
stdio: 'inherit'
})
}
function loadPlaywright() {
try {
return require('playwright')
} catch {
console.error('Fehlende Abhängigkeit: "npm install -D playwright" in client/ ausführen.')
process.exit(1)
}
}
async function renderSharepic(browser, htmlName, pngName, width, height) {
const htmlPath = resolve(marketingDir, htmlName)
const pngPath = resolve(marketingDir, pngName)
console.log(`Generating sharepic (${width}x${height}) from ${htmlName}...`)
const context = await browser.newContext({
viewport: { width, height },
deviceScaleFactor: 2 // High-DPI for crisp text
})
const page = await context.newPage()
try {
await page.goto(pathToFileURL(htmlPath).href, { waitUntil: 'networkidle' })
await page.screenshot({
path: pngPath,
type: 'png'
})
console.log('Successfully wrote:', pngPath)
} finally {
await page.close()
}
}
async function main() {
const playwright = loadPlaywright()
await ensurePlaywrightChromium(playwright)
const browser = await playwright.chromium.launch({ headless: true })
try {
// Landscape 1200x630
await renderSharepic(browser, 'sharepic.html', 'kapteins-daagbok-sharepic.png', 1200, 630)
// Portrait 1080x1920
await renderSharepic(browser, 'sharepic-portrait.html', 'kapteins-daagbok-sharepic-portrait.png', 1080, 1920)
} finally {
await browser.close()
}
}
main().catch((err) => {
console.error('Error generating sharepics:', err)
process.exit(1)
})