From 2542e0e7b840110812894e2bfb42d1e0622ab220 Mon Sep 17 00:00:00 2001 From: Sandro Date: Fri, 30 Oct 2026 18:03:46 +0100 Subject: [PATCH 1/9] fix(themes): white editor and grey sidebar in light theme Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 +++ frontend/src/features/workspace/DocumentEditor.tsx | 4 ++-- frontend/src/features/workspace/WorkspaceView.tsx | 8 ++++---- frontend/src/index.css | 7 +++++++ frontend/src/themes/theme-light.css | 2 ++ frontend/src/themes/theme-template.css | 1 + frontend/tailwind.config.js | 2 ++ 7 files changed, 21 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 37cc979..881f22a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V ## [Unreleased] +### Fixed +- Light-Theme: Notizen stehen auf einem weißen Blatt, die Ordnerleiste ist dezent grau abgesetzt (bisher umgekehrt). Themes können das über die neuen Variablen `--bg-editor` und `--bg-sidebar` selbst festlegen. + ## [0.9.0] - 2026-10-30 ### Added diff --git a/frontend/src/features/workspace/DocumentEditor.tsx b/frontend/src/features/workspace/DocumentEditor.tsx index ef1d52d..54398cf 100644 --- a/frontend/src/features/workspace/DocumentEditor.tsx +++ b/frontend/src/features/workspace/DocumentEditor.tsx @@ -763,9 +763,9 @@ export const DocumentEditor: React.FC = ({ } return ( -
+
{/* Top Document Action Bar */} -
+
{onBackMobile && (
{/* Main Workspace Split Pane */} -
+
{/* LEFT MASTER SIDEBAR */}
) : ( -
+

Keine Notiz ausgewählt diff --git a/frontend/src/index.css b/frontend/src/index.css index 75839b8..9e4555d 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -550,6 +550,13 @@ fieldset:disabled :is(button, input, select, textarea) { cursor: not-allowed; } +/* Workspace surfaces: the note editor and the folder sidebar. Themes may override them (the light theme + uses a white sheet next to a grey sidebar); by default they follow the page and panel colours. */ +:root { + --bg-editor: var(--bg-app); + --bg-sidebar: var(--bg-panel); +} + /* Outliner: indent per level and the width a row keeps for its text. Phones indent less and let very deep rows run wider than the screen (the note then scrolls sideways) instead of squeezing the text. */ :root { diff --git a/frontend/src/themes/theme-light.css b/frontend/src/themes/theme-light.css index a1539aa..4f6f641 100644 --- a/frontend/src/themes/theme-light.css +++ b/frontend/src/themes/theme-light.css @@ -19,6 +19,8 @@ --bg-input: #ffffff; --bg-overlay: rgba(15, 23, 42, 0.45); --bg-code: #e2e8f0; + --bg-editor: #ffffff; + --bg-sidebar: #f3f4f6; /* --- 3. TEXT & KONTRASTE --- */ --text-main: #111827; diff --git a/frontend/src/themes/theme-template.css b/frontend/src/themes/theme-template.css index 6830ea1..1b2e884 100644 --- a/frontend/src/themes/theme-template.css +++ b/frontend/src/themes/theme-template.css @@ -21,6 +21,7 @@ --bg-input: #121316; /* Textfelder, Selects, Textareas */ --bg-overlay: rgba(0, 0, 0, 0.72); /* Hintergrund bei Modals/Dialogen */ --bg-code: #0d0e11; /* Code-Blöcke & Terminal-Boxen */ + /* Optional: --bg-editor (Notiz-Editor, Standard --bg-app) und --bg-sidebar (Ordnerleiste, Standard --bg-panel) */ /* --- 3. TEXT & KONTRASTE --- */ --text-main: #f4f4f5; /* Haupttext & Überschriften */ diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index e34d886..754c556 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -14,6 +14,8 @@ export default { extend: { colors: { canvas: v('--bg-app'), + editor: v('--bg-editor'), + sidebar: v('--bg-sidebar'), panel: { DEFAULT: v('--bg-panel'), elevated: v('--bg-panel-elevated'), From ad4829796ce9b00e29d9b0922a2861fbd94a00d3 Mon Sep 17 00:00:00 2001 From: Sandro Date: Fri, 30 Oct 2026 18:36:50 +0100 Subject: [PATCH 2/9] fix(settings): let the profile section use the full width Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + frontend/src/features/settings/UserSettingsView.tsx | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 881f22a..96c2ae3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V ## [Unreleased] ### Fixed +- Kontoeinstellungen: Der Bereich „Profil & Anmeldedaten“ nutzt die volle Breite wie die übrigen Bereiche. - Light-Theme: Notizen stehen auf einem weißen Blatt, die Ordnerleiste ist dezent grau abgesetzt (bisher umgekehrt). Themes können das über die neuen Variablen `--bg-editor` und `--bg-sidebar` selbst festlegen. ## [0.9.0] - 2026-10-30 diff --git a/frontend/src/features/settings/UserSettingsView.tsx b/frontend/src/features/settings/UserSettingsView.tsx index c11cae9..19665d5 100644 --- a/frontend/src/features/settings/UserSettingsView.tsx +++ b/frontend/src/features/settings/UserSettingsView.tsx @@ -581,7 +581,7 @@ export const UserSettingsView: React.FC = ({ {/* SECTION 1: PROFIL & SICHERHEIT */} {/* ================================================================= */} {section === 'profile' && ( -
+

Profil & Anmeldedaten

From 6c154af2516114eb17019313f032676413ef3f41 Mon Sep 17 00:00:00 2001 From: Sandro Date: Fri, 30 Oct 2026 18:58:55 +0100 Subject: [PATCH 3/9] fix(workspace): single scroll area, drag cleanup, thin note titles with bundled Inter - the workspace fills the window; only the note scrolls (also on phones, where the sideways scroll area used to scroll on its own) - clear drag state on dragend, calmer sidebar toggle when collapsed - ship Inter as variable font, light and larger note titles; header shows the title only after the big title scrolled away and stays one row on phones - main builds show the package version without -dev Co-Authored-By: Claude Opus 5.5 --- .gitea/workflows/build-and-publish.yaml | 4 +- CHANGELOG.md | 8 ++++ frontend/package-lock.json | 10 +++++ frontend/package.json | 1 + frontend/src/App.tsx | 15 +++++-- .../src/features/workspace/DocumentEditor.tsx | 43 +++++++++++++++---- .../src/features/workspace/WorkspaceView.tsx | 19 ++++++-- frontend/src/index.css | 2 +- frontend/src/main.tsx | 2 + frontend/tailwind.config.js | 1 + frontend/vite.config.ts | 2 +- 11 files changed, 88 insertions(+), 19 deletions(-) diff --git a/.gitea/workflows/build-and-publish.yaml b/.gitea/workflows/build-and-publish.yaml index d62768a..0943af5 100644 --- a/.gitea/workflows/build-and-publish.yaml +++ b/.gitea/workflows/build-and-publish.yaml @@ -51,10 +51,12 @@ jobs: echo "tag=${TAGS%% *}" >> "$GITHUB_OUTPUT" echo "tags=${TAGS}" >> "$GITHUB_OUTPUT" echo "actor=${ACTOR_NAME}" >> "$GITHUB_OUTPUT" - # Releases (tags) show the tag as version in the app header + # Version in the app header: tags show the tag, main the package version, everything else + # (dev, feature branches) keeps the "-dev" suffix from vite.config.ts APP_VERSION="" case "${GITHUB_REF:-}" in refs/tags/*) APP_VERSION="${REF_NAME}" ;; + refs/heads/main) APP_VERSION="v$(sed -nE 's/^ "version": "([^"]+)".*/\1/p' frontend/package.json | head -n1)" ;; esac echo "app_version=${APP_VERSION}" >> "$GITHUB_OUTPUT" echo "Target Docker image: ${IMAGE_NAME} tags: ${TAGS} (Registry: ${REGISTRY_HOST}, Actor: ${ACTOR_NAME})" diff --git a/CHANGELOG.md b/CHANGELOG.md index 96c2ae3..6b8dbf4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,15 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V ## [Unreleased] +### Changed +- Schrift: MindSift liefert die Schrift Inter jetzt selbst aus (ohne externen Dienst), damit die Oberfläche auf allen Geräten gleich aussieht. Notiztitel stehen in einer feinen, dünnen Schrift und etwas größer. +- Notizen: Der Titel in der Kopfleiste erscheint erst, wenn der große Titel aus dem Bild gescrollt ist; am Handy passt die Kopfleiste in eine Zeile. +- Das Bild für `main` (`latest`) zeigt die Versionsnummer ohne `-dev`; nur `dev` und andere Zweige behalten den Zusatz. + ### Fixed +- Workspace: kein doppelter Scrollbalken mehr – die Seite selbst scrollt nicht, nur die Notiz; am Handy scrollt die Notiz nicht mehr getrennt von der Seite. +- Workspace: Ein abgebrochenes Ziehen in der Seitenleiste lässt „Auf Hauptebene verschieben“ nicht mehr stehen. +- Workspace: Der Knopf zum Ein-/Ausklappen der Ordnerleiste ist im eingeklappten Zustand dezent (nur das Symbol in Akzentfarbe). - Kontoeinstellungen: Der Bereich „Profil & Anmeldedaten“ nutzt die volle Breite wie die übrigen Bereiche. - Light-Theme: Notizen stehen auf einem weißen Blatt, die Ordnerleiste ist dezent grau abgesetzt (bisher umgekehrt). Themes können das über die neuen Variablen `--bg-editor` und `--bg-sidebar` selbst festlegen. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 50b1f35..44a9660 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,7 @@ "version": "0.9.0", "license": "Unlicense", "dependencies": { + "@fontsource-variable/inter": "5.3.0", "@types/prismjs": "^1.26.6", "emoji-picker-react": "4.12.0", "framer-motion": "^11.15.0", @@ -1024,6 +1025,15 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@fontsource-variable/inter": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.3.0.tgz", + "integrity": "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index d2277c8..76e2ec3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,6 +13,7 @@ "test:run": "vitest run" }, "dependencies": { + "@fontsource-variable/inter": "5.3.0", "@types/prismjs": "^1.26.6", "emoji-picker-react": "4.12.0", "framer-motion": "^11.15.0", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 28a7672..5b36801 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -750,7 +750,11 @@ export const App: React.FC = () => { }; return ( -

+
{/* Top Header Bar: Floating on mobile, solid panel on desktop */}
{/* Mobile Header: Floating Bar with Logo Action Dropdown Left & Profile Avatar Right */} @@ -1113,9 +1117,14 @@ export const App: React.FC = () => { )} {/* Main Container */} -
+ {/* The workspace fills the window exactly and scrolls only inside the note, never the page */} +
setActiveTab('triage')}> -
+
{activeTab === 'triage' && ( = ({ const titleInputRef = useRef(null); + const scrollRef = useRef(null); + // The header repeats the title only once the big title has scrolled out of view + const [titleScrolledAway, setTitleScrolledAway] = useState(false); const outlinerTreeRef = useRef(null); const docRef = useRef(doc); docRef.current = doc; @@ -239,6 +242,22 @@ export const DocumentEditor: React.FC = ({ const currentBlocksRef = useRef(blocks); currentBlocksRef.current = blocks; + useEffect(() => { + const root = scrollRef.current; + const el = titleInputRef.current; + if (!root || !el || typeof IntersectionObserver === 'undefined') { + setTitleScrolledAway(false); + return; + } + const observer = new IntersectionObserver( + ([entry]) => setTitleScrolledAway(!entry.isIntersecting), + // the sticky header covers the top of the scroll area + { root, rootMargin: '-56px 0px 0px 0px' } + ); + observer.observe(el); + return () => observer.disconnect(); + }, [zoomedBlockId, doc?.id]); + useEffect(() => { if (!loading && (autoFocusTitle || (doc?.title === 'Neue Notiz' && (!doc?.blocks || doc?.blocks.length <= 1)))) { const focusTitle = () => { @@ -763,15 +782,15 @@ export const DocumentEditor: React.FC = ({ } return ( -
+
{/* Top Document Action Bar */} -
-
+
+
{onBackMobile && (
+
+
+

Journal anzeigen

+

+ Eine Notiz pro Tag, untereinander mit Kalender – ganz oben in der Notizen-Seitenleiste. Ausschalten + blendet es nur aus, die Tage bleiben als Notizen erhalten. Gilt für dieses Gerät. +

+
+ { + setJournalEnabled(v); + setJournalOn(v); + }} + title="Journal anzeigen" + /> +
+
{themes.map((t) => { const isSelected = activeTheme === t.id; diff --git a/frontend/src/features/workspace/DocumentEditor.tsx b/frontend/src/features/workspace/DocumentEditor.tsx index f17ec21..5821d2b 100644 --- a/frontend/src/features/workspace/DocumentEditor.tsx +++ b/frontend/src/features/workspace/DocumentEditor.tsx @@ -61,6 +61,8 @@ export interface DocumentEditorProps { /** A note was created from this one (extract) or removed again (undo); keeps the sidebar current */ onDocCreated?: (doc: Document) => void; onDocRemoved?: (docId: string) => void; + /** 'journal': one day inside the journal list – no own scrolling, header or backlinks, fixed title */ + variant?: 'page' | 'journal'; } import { EmojiPickerModal } from './EmojiPickerModal'; @@ -211,7 +213,9 @@ export const DocumentEditor: React.FC = ({ onRequestMerge, onDocCreated, onDocRemoved, + variant = 'page', }) => { + const inJournal = variant === 'journal'; const [doc, setDoc] = useState(initialDoc || null); const [title, setTitle] = useState(initialDoc?.title || ''); const [blocks, setBlocks] = useState(() => @@ -245,7 +249,7 @@ export const DocumentEditor: React.FC = ({ useEffect(() => { const root = scrollRef.current; const el = titleInputRef.current; - if (!root || !el || typeof IntersectionObserver === 'undefined') { + if (inJournal || !root || !el || typeof IntersectionObserver === 'undefined') { setTitleScrolledAway(false); return; } @@ -256,7 +260,7 @@ export const DocumentEditor: React.FC = ({ ); observer.observe(el); return () => observer.disconnect(); - }, [zoomedBlockId, doc?.id]); + }, [zoomedBlockId, doc?.id, inJournal]); useEffect(() => { if (!loading && (autoFocusTitle || (doc?.title === 'Neue Notiz' && (!doc?.blocks || doc?.blocks.length <= 1)))) { @@ -782,8 +786,13 @@ export const DocumentEditor: React.FC = ({ } return ( -
- {/* Top Document Action Bar */} +
+ {/* Top Document Action Bar (the journal has one for all days) */} + {!inJournal && (
{onBackMobile && ( @@ -1017,6 +1026,7 @@ export const DocumentEditor: React.FC = ({
+ )} {/* In the trash: read-only with a way back */} {inTrash && ( @@ -1047,7 +1057,11 @@ export const DocumentEditor: React.FC = ({ {/* Main Content Area */} {/* Phones: almost no side margin; very deep outlines scroll sideways (rows keep a minimum width) */} {/* shrink-0: the sideways scroll area must keep its full height, so only the editor scrolls vertically */} -
+
{zoomedBlock ? ( /* ========================================================================= FOKUS-MODUS & ZOOM-IN (HOISTED SUBTREE VIEW) @@ -1127,6 +1141,7 @@ export const DocumentEditor: React.FC = ({
) : ( = ({
diff --git a/frontend/src/features/workspace/JournalCalendar.tsx b/frontend/src/features/workspace/JournalCalendar.tsx new file mode 100644 index 0000000..8529b3c --- /dev/null +++ b/frontend/src/features/workspace/JournalCalendar.tsx @@ -0,0 +1,123 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { ChevronLeft, ChevronRight } from 'lucide-react'; +import { MONTHS, monthGrid, parseISODate, todayISO } from '../../lib/journal'; + +interface JournalCalendarProps { + /** Day currently in view; the calendar follows its month */ + activeDate: string; + /** Days with written entries (get a dot) */ + entryDates: Set; + onSelect: (date: string) => void; +} + +const WEEKDAYS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']; + +export const JournalCalendar: React.FC = ({ activeDate, entryDates, onSelect }) => { + const active = parseISODate(activeDate); + const [view, setView] = useState({ year: active.getFullYear(), month: active.getMonth() }); + useEffect(() => { + const d = parseISODate(activeDate); + setView({ year: d.getFullYear(), month: d.getMonth() }); + }, [activeDate]); + + const today = todayISO(); + const weeks = useMemo(() => monthGrid(view.year, view.month), [view]); + const years = useMemo(() => { + const now = new Date().getFullYear(); + let first = now - 5; + entryDates.forEach((d) => { + first = Math.min(first, Number(d.slice(0, 4))); + }); + const list: number[] = []; + for (let y = now + 1; y >= first; y--) list.push(y); + return list; + }, [entryDates]); + + const shiftMonth = (delta: number) => + setView((v) => { + const d = new Date(v.year, v.month + delta, 1); + return { year: d.getFullYear(), month: d.getMonth() }; + }); + + return ( +
+
+ + + + +
+ +
+ {WEEKDAYS.map((w) => ( + {w} + ))} +
+
+ {weeks.flat().map((date, i) => + date ? ( + + ) : ( + + ) + )} +
+
+ ); +}; diff --git a/frontend/src/features/workspace/JournalView.tsx b/frontend/src/features/workspace/JournalView.tsx new file mode 100644 index 0000000..83bbd62 --- /dev/null +++ b/frontend/src/features/workspace/JournalView.tsx @@ -0,0 +1,246 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { ArrowLeft, CalendarDays, ChevronDown, Users } from 'lucide-react'; +import { Document, JournalDay, User } from '../../lib/types'; +import { shortDateLabel, todayISO } from '../../lib/journal'; +import { DocumentEditor } from './DocumentEditor'; +import { JournalCalendar } from './JournalCalendar'; + +const PAGE = 7; + +interface JournalViewProps { + days: JournalDay[]; + /** Days jumped to over the calendar (shown even while still empty) */ + extraDates: string[]; + activeDate: string; + onActiveDateChange: (date: string) => void; + /** Set by the calendar; the view scrolls there (nonce: the same day again) */ + jumpRequest: { date: string; nonce: number } | null; + /** Opens (creates on first use) the note of a day */ + onOpenDay: (date: string) => Promise; + onJump: (date: string) => void; + getDoc: (id: string) => Document | undefined; + currentUser?: User | null; + availableFolders: string[]; + onOpenShareModal: (doc: Document) => void; + onDocMetadataChange: (updated: Document) => void; + onDeleteDoc: (docId: string) => void; + onSelectTag: (tag: string) => void; + onWikiLinkClick?: (pageTitle: string) => void; + onOpenDocBlock?: (docId: string, blockId?: string) => void; + onOpenTask?: (taskId: string) => void; + onBackMobile: () => void; + /** A day was saved (its calendar dot may change) */ + onDaySaved: () => void; +} + +// The journal: one note per day below each other, today on top, older days load while scrolling. +export const JournalView: React.FC = ({ + days, + extraDates, + activeDate, + onActiveDateChange, + jumpRequest, + onOpenDay, + onJump, + getDoc, + currentUser, + availableFolders, + onOpenShareModal, + onDocMetadataChange, + onDeleteDoc, + onSelectTag, + onWikiLinkClick, + onOpenDocBlock, + onOpenTask, + onBackMobile, + onDaySaved, +}) => { + const today = todayISO(); + const docIdByDate = useMemo(() => new Map(days.map((d) => [d.date, d.docId])), [days]); + const entryDates = useMemo(() => new Set(days.filter((d) => !d.empty).map((d) => d.date)), [days]); + + // Today, every day with text and the days jumped to – newest first + const dates = useMemo(() => { + const set = new Set([today, ...extraDates]); + days.forEach((d) => { + if (!d.empty) set.add(d.date); + }); + return Array.from(set).sort((a, b) => b.localeCompare(a)); + }, [days, extraDates, today]); + + const [limit, setLimit] = useState(PAGE); + const shown = dates.slice(0, limit); + const [showCalendar, setShowCalendar] = useState(false); + + // Days on screen without a note yet (today, a day jumped to) get one + const opening = useRef(new Set()); + useEffect(() => { + shown.forEach((date) => { + if (!docIdByDate.has(date) && !opening.current.has(date)) { + opening.current.add(date); + void onOpenDay(date).finally(() => opening.current.delete(date)); + } + }); + }, [shown, docIdByDate, onOpenDay]); + + const scrollRef = useRef(null); + const sectionRefs = useRef(new Map()); + + // Jump: make sure the day is rendered, then scroll to it once its note is there + const pendingJump = useRef(null); + const handledJump = useRef(0); + useEffect(() => { + if (!jumpRequest || handledJump.current === jumpRequest.nonce) return; + const idx = dates.indexOf(jumpRequest.date); + if (idx === -1) return; // the parent adds the day first + handledJump.current = jumpRequest.nonce; + if (idx >= limit) setLimit(idx + PAGE); + pendingJump.current = jumpRequest.date; + }, [jumpRequest, dates, limit]); + useEffect(() => { + const date = pendingJump.current; + if (!date) return; + const el = sectionRefs.current.get(date); + if (el && docIdByDate.has(date)) { + pendingJump.current = null; + el.scrollIntoView({ block: 'start' }); + } + }); + + // The day at the top of the scroll area is the active one (calendar, share button) + const onScroll = useCallback(() => { + const root = scrollRef.current; + if (!root) return; + const top = root.getBoundingClientRect().top + 48; + let current = shown[0]; + for (const date of shown) { + const el = sectionRefs.current.get(date); + if (!el) continue; + if (el.getBoundingClientRect().top <= top) current = date; + else break; + } + if (current && current !== activeDate) onActiveDateChange(current); + }, [shown, activeDate, onActiveDateChange]); + + // Load older days when the end comes into view + const sentinelRef = useRef(null); + useEffect(() => { + const el = sentinelRef.current; + const root = scrollRef.current; + if (!el || !root || typeof IntersectionObserver === 'undefined' || limit >= dates.length) return; + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) setLimit((l) => l + PAGE); + }, + { root, rootMargin: '400px 0px' } + ); + observer.observe(el); + return () => observer.disconnect(); + }, [limit, dates.length]); + + const activeDocId = docIdByDate.get(activeDate); + const activeDoc = activeDocId ? getDoc(activeDocId) : undefined; + const sharedCount = activeDoc?.sharedWith?.length ?? 0; + + return ( +
+ {/* One header for all days: the day in view and its sharing */} +
+ + + {activeDate !== today && ( + + )} + +
+ + {showCalendar && ( +
+ { + setShowCalendar(false); + onJump(date); + }} + /> +
+ )} + +
+ {shown.map((date) => { + const docId = docIdByDate.get(date); + return ( +
{ + if (el) sectionRefs.current.set(date, el); + else sectionRefs.current.delete(date); + }} + className="border-b border-border last:border-b-0 min-h-[8rem]" + > + {docId ? ( + { + if (status === 'saved') onDaySaved(); + }} + /> + ) : ( +
{shortDateLabel(date)} wird geöffnet…
+ )} +
+ ); + })} + {limit < dates.length &&
} + {/* Room below the oldest day, so every day can be scrolled to the top */} +
+
+
+ ); +}; diff --git a/frontend/src/features/workspace/OutlinerTree.tsx b/frontend/src/features/workspace/OutlinerTree.tsx index 9547957..1bdc613 100644 --- a/frontend/src/features/workspace/OutlinerTree.tsx +++ b/frontend/src/features/workspace/OutlinerTree.tsx @@ -9,6 +9,8 @@ export interface OutlinerTreeHandle { export interface OutlinerTreeProps { docId: string; + /** Smaller click area below the last bullet (journal days) */ + compact?: boolean; blocks: DocumentBlock[]; canEdit: boolean; levelOffset?: number; @@ -34,6 +36,7 @@ export interface OutlinerTreeProps { export const OutlinerTree = forwardRef(({ docId, + compact = false, blocks, canEdit, levelOffset = 0, @@ -810,7 +813,7 @@ export const OutlinerTree = forwardRef(({ {canEdit && (
{ diff --git a/frontend/src/features/workspace/WorkspaceView.tsx b/frontend/src/features/workspace/WorkspaceView.tsx index c7d8808..a89a980 100644 --- a/frontend/src/features/workspace/WorkspaceView.tsx +++ b/frontend/src/features/workspace/WorkspaceView.tsx @@ -23,6 +23,7 @@ import { Clock, AlertTriangle, Loader2, + CalendarDays, } from 'lucide-react'; import { Document, @@ -36,6 +37,7 @@ import { TrashedDocument, UnifiedShare, User, + JournalDay, } from '../../lib/types'; import { api, @@ -49,6 +51,9 @@ import { useMobileActions } from '../../context/MobileActionContext'; import { DocumentEditor, DocSaveStatus, DocHeaderStatus } from './DocumentEditor'; import { PresenceAvatars } from './PresenceAvatars'; import { TrashView } from './TrashView'; +import { JournalView } from './JournalView'; +import { JournalCalendar } from './JournalCalendar'; +import { isJournalEnabled, todayISO } from '../../lib/journal'; import { MergeDialog } from './MergeDialog'; import { CountBadge } from '../../components/CountBadge'; import { extractHashtags } from '../../lib/hashtags'; @@ -63,7 +68,7 @@ interface WorkspaceViewProps { onOpenSearch?: () => void; } -type WorkspaceSubView = 'editor' | 'tag_detail' | 'todos' | 'tags' | 'trash'; +type WorkspaceSubView = 'editor' | 'tag_detail' | 'todos' | 'tags' | 'trash' | 'journal'; interface FolderTreeNode { name: string; @@ -327,6 +332,33 @@ export const WorkspaceView: React.FC = ({ useEffect(() => { reloadTrash(); }, [reloadTrash]); + + // Journal: one note per day, listed separately from the folder tree (can be switched off per device) + const [journalEnabled] = useState(isJournalEnabled); + const [journalDays, setJournalDays] = useState([]); + const [journalExtra, setJournalExtra] = useState([]); + const [journalActive, setJournalActive] = useState(todayISO); + const [journalJump, setJournalJump] = useState<{ date: string; nonce: number } | null>(null); + const reloadJournal = useCallback(() => { + if (!journalEnabled) return; + api.getJournal().then(setJournalDays).catch(() => {}); + }, [journalEnabled]); + useEffect(() => { + reloadJournal(); + }, [reloadJournal]); + // After typing, refresh which days have text (calendar dots) – at most every few seconds + const journalReloadTimer = useRef(null); + const reloadJournalSoon = useCallback(() => { + if (journalReloadTimer.current !== null) return; + journalReloadTimer.current = window.setTimeout(() => { + journalReloadTimer.current = null; + reloadJournal(); + }, 3000); + }, [reloadJournal]); + useEffect(() => () => { + if (journalReloadTimer.current !== null) window.clearTimeout(journalReloadTimer.current); + }, []); + const journalDocIds = useMemo(() => new Set(journalDays.map((d) => d.docId)), [journalDays]); const [selectedTag, setSelectedTag] = useState(null); const [tagDetails, setTagDetails] = useState(null); const [tagNoteDraft, setTagNoteDraft] = useState(''); @@ -560,6 +592,34 @@ export const WorkspaceView: React.FC = ({ setMobileShowDetail(true); }, []); + const openJournalDay = useCallback(async (date: string): Promise => { + try { + const doc = await api.openJournalDay(date); + setDocs((prev) => (prev.some((d) => d.id === doc.id) ? prev : [doc, ...prev])); + setJournalDays((prev) => + [ + ...prev.filter((d) => d.date !== date), + { date, docId: doc.id, empty: !(doc.content || '').trim() }, + ].sort((a, b) => b.date.localeCompare(a.date)) + ); + return doc; + } catch { + return null; + } + }, []); + + const jumpToJournalDay = useCallback((date: string) => { + setJournalExtra((prev) => (prev.includes(date) ? prev : [...prev, date])); + setJournalActive(date); + setJournalJump({ date, nonce: Date.now() }); + }, []); + + const openJournal = useCallback(() => { + reloadJournal(); + setSubView('journal'); + setMobileShowDetail(true); + }, [reloadJournal]); + const openTrash = useCallback(() => { reloadTrash(); setSubView('trash'); @@ -939,6 +999,12 @@ export const WorkspaceView: React.FC = ({ } }; + // Own journal notes appear under "Journal", not in the folder tree + const treeDocs = useMemo( + () => (journalDocIds.size ? docs.filter((d) => !(d.isOwner && journalDocIds.has(d.id))) : docs), + [docs, journalDocIds] + ); + const allFolderPaths = useMemo(() => { const set = new Set(folders); for (const d of docs) { @@ -977,7 +1043,7 @@ export const WorkspaceView: React.FC = ({ const sharedFolderObj = sharedFolders.find( (sf) => sf.mountPath === p || sf.name === p ); - const folderDocs = docs.filter((d) => d.folderPath === p); + const folderDocs = treeDocs.filter((d) => d.folderPath === p); const node: FolderTreeNode = { name: leafName, @@ -1002,16 +1068,16 @@ export const WorkspaceView: React.FC = ({ } return rootNodes; - }, [allFolderPaths, docs, sharedFolders]); + }, [allFolderPaths, treeDocs, sharedFolders]); const pinnedDocs = useMemo( - () => docs.filter((d) => d.isPinned), - [docs] + () => treeDocs.filter((d) => d.isPinned), + [treeDocs] ); const rootDocs = useMemo( - () => docs.filter((d) => !d.folderPath && !d.isPinned), - [docs] + () => treeDocs.filter((d) => !d.folderPath && !d.isPinned), + [treeDocs] ); // Global To-Dos rollup collections @@ -1451,7 +1517,7 @@ export const WorkspaceView: React.FC = ({ {/* LEFT MASTER SIDEBAR */}