diff --git a/client/src/App.css b/client/src/App.css index f1c8cca..41c8c5c 100644 --- a/client/src/App.css +++ b/client/src/App.css @@ -839,6 +839,42 @@ html.scheme-dark .themed-select-option.is-selected { background: var(--app-surface-hover); } +.logbook-card--shared { + border-left: 3px solid #38bdf8; +} + +.logbook-sections { + display: flex; + flex-direction: column; + gap: 28px; +} + +.logbook-section-header h3 { + margin: 0 0 6px; + font-size: 15px; + font-weight: 600; + color: var(--app-text-heading); +} + +.logbook-section-hint { + margin: 0 0 14px; + font-size: 13px; + line-height: 1.45; + color: var(--app-text-muted); + max-width: 52rem; +} + +.card-title-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + +.card-title-row h3 { + margin: 0; +} + .card-icon { background: var(--app-accent-bg); color: var(--app-accent-light); @@ -999,6 +1035,13 @@ html.scheme-dark .themed-select-option.is-selected { color: var(--app-text-heading); } +.app-title-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; +} + .app-title-area .app-subtitle { font-size: 12px; color: var(--app-text-muted); @@ -2975,6 +3018,36 @@ html.theme-cupertino .events-scroll-container { border: 1px solid rgba(251, 191, 36, 0.25); } +.role-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + border-radius: 999px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.01em; + white-space: nowrap; +} + +.role-badge--owner { + color: #86efac; + background: rgba(34, 197, 94, 0.12); + border: 1px solid rgba(34, 197, 94, 0.25); +} + +.role-badge--crew { + color: #7dd3fc; + background: rgba(56, 189, 248, 0.12); + border: 1px solid rgba(56, 189, 248, 0.28); +} + +.role-badge--read { + color: #cbd5e1; + background: rgba(148, 163, 184, 0.12); + border: 1px solid rgba(148, 163, 184, 0.25); +} + .app-tour-root { position: fixed; inset: 0; diff --git a/client/src/App.tsx b/client/src/App.tsx index 61f8b11..efc7cfc 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -26,7 +26,10 @@ import ReadOnlyViewer from './components/ReadOnlyViewer.tsx' import PwaInstallPrompt from './components/PwaInstallPrompt.tsx' import PwaUpdatePrompt from './components/PwaUpdatePrompt.tsx' import AppFooter from './components/AppFooter.tsx' +import LogbookRoleBadge from './components/LogbookRoleBadge.tsx' import { db } from './services/db.js' +import { getLogbookAccess } from './services/logbookAccess.js' +import type { LogbookAccessRole } from './services/logbook.js' import { useLiveQuery } from 'dexie-react-hooks' import { Ship, LogOut, ChevronLeft, Users, FileText, Settings, Wifi, WifiOff, Languages, BarChart2 } from 'lucide-react' import DisclaimerHeaderButton from './components/DisclaimerHeaderButton.tsx' @@ -59,6 +62,34 @@ function App() { [activeLogbookId] ) + const activeLogbookRecord = useLiveQuery( + () => (activeLogbookId ? db.logbooks.get(activeLogbookId) : undefined), + [activeLogbookId] + ) + + const [activeAccessRole, setActiveAccessRole] = useState('OWNER') + + useEffect(() => { + if (!activeLogbookId) { + setActiveAccessRole('OWNER') + return + } + + if (activeLogbookRecord?.isShared !== 1) { + setActiveAccessRole('OWNER') + return + } + + const cachedRole = activeLogbookRecord.collaborationRole + if (cachedRole) { + setActiveAccessRole(cachedRole) + } + + getLogbookAccess(activeLogbookId).then((access) => { + if (access) setActiveAccessRole(access.role) + }) + }, [activeLogbookId, activeLogbookRecord]) + useEffect(() => { const syncAppearance = () => { applyAppearanceToDocument(resolveAppTheme(), resolveColorScheme()) @@ -267,8 +298,17 @@ function App() { {t('nav.dashboard')}
-

{activeLogbookTitle}

-

{t('app.name')} / {activeLogbookId.substring(0, 8)}...

+
+

{activeLogbookTitle}

+ {activeAccessRole !== 'OWNER' && ( + + )} +
+

+ {activeAccessRole !== 'OWNER' + ? t('dashboard.section_shared_hint') + : `${t('app.name')} / ${activeLogbookId?.substring(0, 8)}...`} +

diff --git a/client/src/components/InvitationAcceptance.tsx b/client/src/components/InvitationAcceptance.tsx index b1416e6..a0c5063 100644 --- a/client/src/components/InvitationAcceptance.tsx +++ b/client/src/components/InvitationAcceptance.tsx @@ -182,6 +182,8 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio throw new Error(serverError.error || (isDe ? 'Beitritt auf dem Server fehlgeschlagen.' : 'Failed to join logbook on the server.')) } + const acceptResult = await res.json() + await saveLogbookKey(logbookId, logbookKey) if (rawEncryptedTitle) { @@ -190,7 +192,8 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio encryptedTitle: rawEncryptedTitle, updatedAt: new Date().toISOString(), isSynced: 1, - isShared: 1 + isShared: 1, + collaborationRole: acceptResult.role === 'READ' ? 'READ' : 'WRITE' }) } diff --git a/client/src/components/LogbookDashboard.tsx b/client/src/components/LogbookDashboard.tsx index 3e61980..2f95053 100644 --- a/client/src/components/LogbookDashboard.tsx +++ b/client/src/components/LogbookDashboard.tsx @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next' import { useLiveQuery } from 'dexie-react-hooks' import { db } from '../services/db.js' import { fetchLogbooks, createLogbook, deleteLogbook, type DecryptedLogbook } from '../services/logbook.js' +import LogbookRoleBadge from './LogbookRoleBadge.tsx' import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js' import { logoutUser } from '../services/auth.js' import { useDialog } from './ModalDialog.tsx' @@ -106,6 +107,68 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD i18n.changeLanguage(nextLang) } + const ownedLogbooks = logbooks.filter((lb) => !lb.isShared) + const sharedLogbooks = logbooks.filter((lb) => lb.isShared) + + const renderLogbookCard = (lb: DecryptedLogbook) => ( +
onSelectLogbook(lb.id, lb.title)} + > +
+ +
+ +
+
+

{lb.title}

+ +
+
+ + {lb.isSynced ? t('dashboard.status_synced') : t('dashboard.status_local')} + + {lb.isDemo && ( + {t('demo.badge')} + )} + + {new Date(lb.updatedAt).toLocaleDateString(i18n.language, { + year: 'numeric', + month: 'short', + day: 'numeric' + })} + +
+
+ + +
+ ) + + const renderLogbookSection = ( + title: string, + items: DecryptedLogbook[], + hint?: string + ) => ( +
+
+

{title}

+ {hint &&

{hint}

} +
+
+ {items.map(renderLogbookCard)} +
+
+ ) + return (
{/* Premium Dashboard Header */} @@ -201,42 +264,16 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD ) : logbooks.length === 0 ? (
{t('dashboard.no_logbooks')}
) : ( -
- {logbooks.map((lb) => ( -
onSelectLogbook(lb.id, lb.title)}> -
- -
- -
-

{lb.title}

-
- - {lb.isSynced ? t('dashboard.status_synced') : t('dashboard.status_local')} - - {lb.isDemo && ( - {t('demo.badge')} - )} - - {new Date(lb.updatedAt).toLocaleDateString(i18n.language, { - year: 'numeric', - month: 'short', - day: 'numeric' - })} - -
-
- - -
- ))} +
+ {ownedLogbooks.length > 0 && renderLogbookSection( + sharedLogbooks.length > 0 ? t('dashboard.section_owned') : t('dashboard.title'), + ownedLogbooks + )} + {sharedLogbooks.length > 0 && renderLogbookSection( + t('dashboard.section_shared'), + sharedLogbooks, + t('dashboard.section_shared_hint') + )}
)} diff --git a/client/src/components/LogbookRoleBadge.tsx b/client/src/components/LogbookRoleBadge.tsx new file mode 100644 index 0000000..734ef46 --- /dev/null +++ b/client/src/components/LogbookRoleBadge.tsx @@ -0,0 +1,37 @@ +import { useTranslation } from 'react-i18next' +import { Anchor, Eye, Users } from 'lucide-react' +import type { LogbookAccessRole } from '../services/logbook.js' + +interface LogbookRoleBadgeProps { + role: LogbookAccessRole + className?: string +} + +export default function LogbookRoleBadge({ role, className = '' }: LogbookRoleBadgeProps) { + const { t } = useTranslation() + + if (role === 'OWNER') { + return ( + + + ) + } + + if (role === 'READ') { + return ( + + + ) + } + + return ( + + + ) +} diff --git a/client/src/i18n/locales/de.json b/client/src/i18n/locales/de.json index 7aa1ffc..1586af8 100644 --- a/client/src/i18n/locales/de.json +++ b/client/src/i18n/locales/de.json @@ -236,7 +236,16 @@ "loading": "Logbücher werden geladen...", "status_synced": "Synchronisiert", "status_local": "Nur lokaler Cache", - "delete_btn": "Logbuch löschen" + "delete_btn": "Logbuch löschen", + "section_owned": "Meine Logbücher", + "section_shared": "Geteilte Logbücher", + "section_shared_hint": "Sie wurden als Crew-Mitglied eingeladen. Skipper-Profil und Einstellungen gehören dem Eigner.", + "role_owner": "Eigenes Logbuch", + "role_owner_hint": "Sie sind Eigner und Skipper dieses Logbuchs", + "role_crew": "Crew-Zugang", + "role_crew_hint": "Eingeladenes Logbuch — Sie können als Crew mitarbeiten und signieren", + "role_read": "Nur Lesen", + "role_read_hint": "Geteiltes Logbuch — nur Ansicht, keine Bearbeitung" }, "crew": { "title": "Skipper- & Crew-Profile", diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index 06a42b7..06f5b18 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -236,7 +236,16 @@ "loading": "Loading logbooks...", "status_synced": "Synced", "status_local": "Local Cache Only", - "delete_btn": "Delete logbook" + "delete_btn": "Delete logbook", + "section_owned": "My logbooks", + "section_shared": "Shared logbooks", + "section_shared_hint": "You were invited as crew. Skipper profile and settings belong to the owner.", + "role_owner": "Own logbook", + "role_owner_hint": "You own this logbook and act as skipper", + "role_crew": "Crew access", + "role_crew_hint": "Invited logbook — you can collaborate and sign as crew", + "role_read": "Read only", + "role_read_hint": "Shared logbook — view only, no editing" }, "crew": { "title": "Skipper & Crew Profiles", diff --git a/client/src/services/db.ts b/client/src/services/db.ts index e0a6e6c..46fb94f 100644 --- a/client/src/services/db.ts +++ b/client/src/services/db.ts @@ -7,6 +7,7 @@ export interface LocalLogbook { isSynced: number // 1 = yes, 0 = pending local modifications isShared?: number // 1 = collaborator copy, 0 or unset = owned isDemo?: number // 1 = demo logbook seeded at registration + collaborationRole?: 'READ' | 'WRITE' // set when isShared = 1 } export interface LocalYacht { diff --git a/client/src/services/logbook.ts b/client/src/services/logbook.ts index 2a59882..3ec80fe 100644 --- a/client/src/services/logbook.ts +++ b/client/src/services/logbook.ts @@ -6,12 +6,15 @@ import { PlausibleEvents, trackPlausibleEvent } from './analytics.js' const API_BASE = '/api/logbooks' +export type LogbookAccessRole = 'OWNER' | 'READ' | 'WRITE' + export interface DecryptedLogbook { id: string title: string updatedAt: string isSynced: boolean isShared: boolean + accessRole: LogbookAccessRole isDemo?: boolean } @@ -101,14 +104,18 @@ export async function fetchLogbooks(): Promise { // Update Dexie database cache const localById = new Map(localLogbooksArray.map((lb) => [lb.id, lb])) - const localLogbooks: LocalLogbook[] = serverLogbooks.map((lb: any) => ({ - id: lb.id, - encryptedTitle: lb.encryptedTitle, - updatedAt: lb.updatedAt || new Date().toISOString(), - isSynced: 1, - isShared: lb.userId !== userId ? 1 : 0, - isDemo: localById.get(lb.id)?.isDemo - })) + const localLogbooks: LocalLogbook[] = serverLogbooks.map((lb: any) => { + const isShared = lb.userId !== userId + return { + id: lb.id, + encryptedTitle: lb.encryptedTitle, + updatedAt: lb.updatedAt || new Date().toISOString(), + isSynced: 1, + isShared: isShared ? 1 : 0, + collaborationRole: isShared ? (lb.collaborators?.[0]?.role || 'WRITE') : undefined, + isDemo: localById.get(lb.id)?.isDemo + } + }) // Clear existing cache for this user and insert new ones await db.logbooks.bulkPut(localLogbooks) @@ -131,6 +138,7 @@ export async function fetchLogbooks(): Promise { updatedAt: lb.updatedAt, isSynced: lb.isSynced === 1, isShared: lb.isShared === 1, + accessRole: lb.isShared === 1 ? (lb.collaborationRole || 'WRITE') : 'OWNER', isDemo: lb.isDemo === 1 }) } @@ -207,7 +215,8 @@ export async function createLogbook(title: string): Promise { title, updatedAt: serverLb.updatedAt, isSynced: true, - isShared: false + isShared: false, + accessRole: 'OWNER' } } } catch (error) { @@ -238,7 +247,8 @@ export async function createLogbook(title: string): Promise { title, updatedAt: now, isSynced: false, - isShared: false + isShared: false, + accessRole: 'OWNER' } }