From 375dda982b9fe70103dfdb91f05447f7045ab6cc Mon Sep 17 00:00:00 2001 From: Sandro Date: Fri, 30 Oct 2026 20:11:09 +0100 Subject: [PATCH 01/15] feat(workspace): journal panel toggle, view kept across tabs, sections nest under headings - the journal opens as a framed panel and closes on a second click, returning to the previous note; journal state and the last regular note survive tab switches, the fallback note is never a journal day - pasted and imported markdown nests content under its heading (H2 below H1), Enter on a heading creates a child, '- ' / '* ' / '+ ' start a bullet Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 ++ .../src/features/workspace/OutlinerBlock.tsx | 4 ++ .../src/features/workspace/OutlinerTree.tsx | 6 +- .../src/features/workspace/WorkspaceView.tsx | 70 +++++++++++++------ .../src/lib/__tests__/pasteOutline.test.ts | 24 +++++-- frontend/src/lib/journal.ts | 20 ++++++ frontend/src/lib/pasteOutline.ts | 16 +++-- internal/storage/import_markdown.go | 21 ++++-- internal/storage/import_markdown_test.go | 16 +++++ 9 files changed, 145 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 20f7969..2855b51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V ## [Unreleased] +### Changed +- Journal: Geöffnet ist es ein hinterlegtes Panel mit Rahmen, „Journal“ ist dessen Überschrift; ein zweiter Klick schließt es und zeigt wieder die vorherige Notiz. Ob das Journal offen war und welche Notiz offen war, bleibt beim Wechsel zwischen den Tabs erhalten (Journal-Tage öffnen sich nicht mehr als einzelne Notiz). +- Notizen: Beim Einfügen und beim Markdown-Import werden Absätze und Listen unter einer Überschrift eingerückt (H2 unter H1 eine Ebene tiefer), so lassen sich Abschnitte einklappen und fokussieren. Enter am Ende einer Überschrift legt den neuen Punkt als Unterpunkt an; `- `, `* ` oder `+ ` in einem leeren Punkt machen ihn zum normalen Aufzählungspunkt. + ## [0.10.0] - 2026-10-30 ### Added diff --git a/frontend/src/features/workspace/OutlinerBlock.tsx b/frontend/src/features/workspace/OutlinerBlock.tsx index a705828..699d99b 100644 --- a/frontend/src/features/workspace/OutlinerBlock.tsx +++ b/frontend/src/features/workspace/OutlinerBlock.tsx @@ -622,6 +622,10 @@ export const OutlinerBlock: React.FC = ({ val = val.replace(/^\[[xX]\]\s/, ''); typeOverride = 'todo'; checkedOverride = true; + } else if (val === '- ' || val === '* ' || val === '+ ') { + // Markdown list marker typed into an empty block: it already is a bullet, drop the marker + val = ''; + if (!['text', 'bullet', 'paragraph'].includes(block.type || 'text')) typeOverride = 'bullet'; } else if ((val === '1. ' || val === '1) ') && block.type !== 'numbered') { // "1. " typed into an empty bullet starts a numbered list val = ''; diff --git a/frontend/src/features/workspace/OutlinerTree.tsx b/frontend/src/features/workspace/OutlinerTree.tsx index 3b87473..93d3817 100644 --- a/frontend/src/features/workspace/OutlinerTree.tsx +++ b/frontend/src/features/workspace/OutlinerTree.tsx @@ -247,7 +247,11 @@ export const OutlinerTree = forwardRef(({ if (!canEdit) return; const idx = blocks.findIndex((b) => b.id === afterBlockId); const currentBlock = idx >= 0 ? blocks[idx] : null; - const level = currentBlock ? Math.max(0, currentBlock.level || 0) : 0; + const isHeading = + (currentBlock?.type === 'h1' || currentBlock?.type === 'h2' || currentBlock?.type === 'h3') && + !currentBlock.collapsed; + // Enter on an open heading starts its section: the new bullet goes below it as a child + const level = currentBlock ? Math.max(0, currentBlock.level || 0) + (isHeading ? 1 : 0) : 0; // Numbered lists continue on Enter (an empty item ends the list, see OutlinerBlock) const inheritedType: BlockType = currentBlock?.type === 'numbered' ? 'numbered' : 'text'; diff --git a/frontend/src/features/workspace/WorkspaceView.tsx b/frontend/src/features/workspace/WorkspaceView.tsx index a89a980..af60e1f 100644 --- a/frontend/src/features/workspace/WorkspaceView.tsx +++ b/frontend/src/features/workspace/WorkspaceView.tsx @@ -53,7 +53,7 @@ import { PresenceAvatars } from './PresenceAvatars'; import { TrashView } from './TrashView'; import { JournalView } from './JournalView'; import { JournalCalendar } from './JournalCalendar'; -import { isJournalEnabled, todayISO } from '../../lib/journal'; +import { isJournalEnabled, readWorkspaceSession, todayISO, writeWorkspaceSession } from '../../lib/journal'; import { MergeDialog } from './MergeDialog'; import { CountBadge } from '../../components/CountBadge'; import { extractHashtags } from '../../lib/hashtags'; @@ -319,10 +319,15 @@ export const WorkspaceView: React.FC = ({ return list.sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag)); }, [docs, serverTagsSummary]); - const [selectedDocId, setSelectedDocId] = useState(initialDocId || null); + const [selectedDocId, setSelectedDocId] = useState( + initialDocId || readWorkspaceSession().docId || null + ); const [targetBlockId, setTargetBlockId] = useState(initialBlockId || null); const [newlyCreatedDocId, setNewlyCreatedDocId] = useState(null); - const [subView, setSubView] = useState('editor'); + // Journal open and the last regular note survive switching to other tabs (the view remounts) + const [subView, setSubView] = useState(() => + !initialDocId && isJournalEnabled() && readWorkspaceSession().journal ? 'journal' : 'editor' + ); const [trash, setTrash] = useState([]); // Bumped after a restore so the open editor reloads the note without the trash banner const [editorNonce, setEditorNonce] = useState(0); @@ -398,7 +403,9 @@ export const WorkspaceView: React.FC = ({ } setShowNewFolderModal(true); }; - const [mobileShowDetail, setMobileShowDetail] = useState(Boolean(initialDocId)); + const [mobileShowDetail, setMobileShowDetail] = useState( + Boolean(initialDocId) || subView === 'journal' + ); const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() => { try { return localStorage.getItem('mindsift:workspace_sidebar_collapsed') === 'true'; @@ -473,12 +480,14 @@ export const WorkspaceView: React.FC = ({ const loadWorkspaceData = useCallback(async () => { try { - const [docsRes, foldersRes, sharedFoldersRes, tagsRes] = await Promise.all([ + const [docsRes, foldersRes, sharedFoldersRes, tagsRes, journalRes] = await Promise.all([ api.getDocuments(), api.getFolders(), api.getSharedFolders().catch(() => []), api.getWorkspaceTags().catch(() => []), + isJournalEnabled() ? api.getJournal().catch(() => [] as JournalDay[]) : Promise.resolve([] as JournalDay[]), ]); + const journalIds = new Set(journalRes.map((d) => d.docId)); setDocs(docsRes); setFolders(foldersRes); setSharedFolders(sharedFoldersRes); @@ -488,7 +497,9 @@ export const WorkspaceView: React.FC = ({ if (prevId && docsRes.some((d) => d.id === prevId)) { return prevId; } - return docsRes.length > 0 ? docsRes[0].id : null; + // Not a journal day: those open in the journal + const first = docsRes.find((d) => !(d.isOwner && journalIds.has(d.id))); + return first ? first.id : null; }); } catch { // ignore @@ -620,6 +631,16 @@ export const WorkspaceView: React.FC = ({ setMobileShowDetail(true); }, [reloadJournal]); + // A second click closes the journal and shows the regular note again + const toggleJournal = useCallback(() => { + if (subView === 'journal') { + setSubView('editor'); + setSelectedDocId((prev) => prev ?? treeDocsRef.current[0]?.id ?? null); + } else { + openJournal(); + } + }, [subView, openJournal]); + const openTrash = useCallback(() => { reloadTrash(); setSubView('trash'); @@ -1004,6 +1025,12 @@ export const WorkspaceView: React.FC = ({ () => (journalDocIds.size ? docs.filter((d) => !(d.isOwner && journalDocIds.has(d.id))) : docs), [docs, journalDocIds] ); + const treeDocsRef = useRef(treeDocs); + treeDocsRef.current = treeDocs; + + useEffect(() => { + writeWorkspaceSession({ journal: subView === 'journal', docId: selectedDocId }); + }, [subView, selectedDocId]); const allFolderPaths = useMemo(() => { const set = new Set(folders); @@ -1569,28 +1596,31 @@ export const WorkspaceView: React.FC = ({ {/* Scrollable Navigation Tree */}
- {/* Journal on top; while open, its calendar stays below it */} + {/* Journal on top; open, it becomes a panel with the calendar (click the heading to close) */} {journalEnabled && ( -
+
{subView === 'journal' && ( -
+
!d.empty).map((d) => d.date))} diff --git a/frontend/src/lib/__tests__/pasteOutline.test.ts b/frontend/src/lib/__tests__/pasteOutline.test.ts index b5eca0b..eb7611e 100644 --- a/frontend/src/lib/__tests__/pasteOutline.test.ts +++ b/frontend/src/lib/__tests__/pasteOutline.test.ts @@ -20,13 +20,27 @@ describe('parsePastedLines', () => { ]); }); - it('reads headings, todos, bullets and dividers', () => { - expect(parse('# Titel\nText danach\n- [x] erledigt\n- Punkt\n---')).toEqual([ + it('reads headings, todos, bullets and dividers and nests sections under headings', () => { + expect(parse('# Titel\nText danach\n- [x] erledigt\n* Punkt\n + tiefer\n---\nOhne Abschnitt')).toEqual([ { type: 'h1', content: 'Titel', indent: 0 }, - { type: 'text', content: 'Text danach', indent: 0 }, - { type: 'todo', checked: true, content: 'erledigt', indent: 0 }, - { type: 'text', content: 'Punkt', indent: 0 }, + { type: 'text', content: 'Text danach', indent: 1 }, + { type: 'todo', checked: true, content: 'erledigt', indent: 1 }, + { type: 'text', content: 'Punkt', indent: 1 }, + { type: 'text', content: 'tiefer', indent: 2 }, { type: 'divider', content: '---', indent: 0 }, + { type: 'text', content: 'Ohne Abschnitt', indent: 0 }, + ]); + }); + + it('nests H2 below H1 and closes sections at the same rank', () => { + expect(parse('# A\n## A1\n1. eins\n## A2\nText\n# B\n- b')).toEqual([ + { type: 'h1', content: 'A', indent: 0 }, + { type: 'h2', content: 'A1', indent: 1 }, + { type: 'numbered', content: 'eins', indent: 2 }, + { type: 'h2', content: 'A2', indent: 1 }, + { type: 'text', content: 'Text', indent: 2 }, + { type: 'h1', content: 'B', indent: 0 }, + { type: 'text', content: 'b', indent: 1 }, ]); }); diff --git a/frontend/src/lib/journal.ts b/frontend/src/lib/journal.ts index 3fc77e3..4ac90d7 100644 --- a/frontend/src/lib/journal.ts +++ b/frontend/src/lib/journal.ts @@ -60,3 +60,23 @@ export function monthGrid(year: number, month: number): (string | null)[][] { for (let i = 0; i < cells.length; i += 7) weeks.push(cells.slice(i, i + 7)); return weeks; } + +// Workspace state for this browser tab: is the journal open, which regular note was open last +const WORKSPACE_SESSION_KEY = 'mindsift_workspace_view'; + +export function readWorkspaceSession(): { journal: boolean; docId: string | null } { + try { + const raw = JSON.parse(sessionStorage.getItem(WORKSPACE_SESSION_KEY) || '{}'); + return { journal: raw.journal === true, docId: typeof raw.docId === 'string' ? raw.docId : null }; + } catch { + return { journal: false, docId: null }; + } +} + +export function writeWorkspaceSession(state: { journal: boolean; docId: string | null }) { + try { + sessionStorage.setItem(WORKSPACE_SESSION_KEY, JSON.stringify(state)); + } catch { + // storage blocked: the view simply starts fresh + } +} diff --git a/frontend/src/lib/pasteOutline.ts b/frontend/src/lib/pasteOutline.ts index 3a59900..8f7bc54 100644 --- a/frontend/src/lib/pasteOutline.ts +++ b/frontend/src/lib/pasteOutline.ts @@ -2,6 +2,7 @@ import type { BlockType } from './types'; // Turns pasted (Markdown) text into outline items: headings, todos, bullets, numbered items, dividers, // fenced code and paragraphs. A blank line ends a paragraph; indentation becomes the nesting level. +// Content below a heading is nested under it (H2 below H1 one level deeper), so sections fold and zoom. export interface PastedItem { type: BlockType; @@ -31,6 +32,8 @@ export function parsePastedLines(rawLines: string[]): PastedItem[] { let current: PastedItem | null = null; let inCode = false; let afterHeading = false; + const headings: { rank: number; level: number }[] = []; // open headings, innermost last + const sectionLevel = () => (headings.length ? headings[headings.length - 1].level + 1 : 0); const flush = () => { if (current) { @@ -52,13 +55,13 @@ export function parsePastedLines(rawLines: string[]): PastedItem[] { if (rawLine.trim().startsWith('```')) { inCode = true; flush(); - current = { type: 'text', content: rawLine, indent: 0 }; + current = { type: 'text', content: rawLine, indent: sectionLevel() }; afterHeading = false; continue; } const { width, rest } = leadingWidth(rawLine); - const indent = levelOf(width); + const indent = sectionLevel() + levelOf(width); const line = rest.trimEnd(); // A blank line ends the paragraph or list item @@ -71,13 +74,18 @@ export function parsePastedLines(rawLines: string[]): PastedItem[] { if (heading) { flush(); afterHeading = true; - items.push({ type: (['h1', 'h2', 'h3'] as const)[heading[1].length - 1], content: heading[2], indent }); + const rank = heading[1].length; + while (headings.length && headings[headings.length - 1].rank >= rank) headings.pop(); + const level = sectionLevel(); + headings.push({ rank, level }); + items.push({ type: (['h1', 'h2', 'h3'] as const)[rank - 1], content: heading[2], indent: level }); continue; } if (/^(?:-{3,}|\*{3,}|_{3,})$/.test(line)) { flush(); afterHeading = false; - items.push({ type: 'divider', content: '---', indent }); + headings.length = 0; // a divider closes the sections + items.push({ type: 'divider', content: '---', indent: 0 }); continue; } diff --git a/internal/storage/import_markdown.go b/internal/storage/import_markdown.go index 7095ac6..73601ce 100644 --- a/internal/storage/import_markdown.go +++ b/internal/storage/import_markdown.go @@ -740,9 +740,10 @@ func indentWidth(s string) int { func MarkdownToBlocks(md string) []domain.DocumentBlock { lines := strings.Split(md, "\n") var blocks []domain.DocumentBlock - base := 0 // 1 inside a heading section - var indents []int // indent widths of the open list levels - inList := false // the previous line belonged to a list item (for continuation lines) + base := 0 // level of content in the current heading section + var headings []int // ranks (1–3) of the open headings: H2 below H1 nests one level deeper + var indents []int // indent widths of the open list levels + inList := false // the previous line belonged to a list item (for continuation lines) listLevel := func(width int) int { for len(indents) > 0 && indents[len(indents)-1] > width { indents = indents[:len(indents)-1] @@ -789,15 +790,23 @@ func MarkdownToBlocks(md string) []domain.DocumentBlock { if typ == "" { typ = "h3" } - blocks = append(blocks, newBlock(typ, strings.TrimSpace(m[2]), 0)) - base, indents, inList = 1, nil, false + rank := len(m[1]) + if rank > 3 { + rank = 3 + } + for len(headings) > 0 && headings[len(headings)-1] >= rank { + headings = headings[:len(headings)-1] + } + blocks = append(blocks, newBlock(typ, strings.TrimSpace(m[2]), len(headings))) + headings = append(headings, rank) + base, indents, inList = len(headings), nil, false continue } if trimmed == "---" || trimmed == "***" || trimmed == "___" { if !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "\t") { blocks = append(blocks, newBlock("divider", "", 0)) - base, indents, inList = 0, nil, false + base, headings, indents, inList = 0, nil, nil, false continue } } diff --git a/internal/storage/import_markdown_test.go b/internal/storage/import_markdown_test.go index ba6482e..6eae56e 100644 --- a/internal/storage/import_markdown_test.go +++ b/internal/storage/import_markdown_test.go @@ -4,6 +4,7 @@ import ( "archive/zip" "bytes" "encoding/json" + "fmt" "path/filepath" "strings" "testing" @@ -219,3 +220,18 @@ func TestImportBackupZip(t *testing.T) { } checkRestored(t, store, ben) } + +func TestMarkdownHeadingsNestTheirSections(t *testing.T) { + blocks := MarkdownToBlocks("# Projekt\nEinleitung\n## Teil A\n- Punkt\n - Unterpunkt\n## Teil B\n1. Erstens\n# Anhang\nText") + var got []string + for _, b := range blocks { + got = append(got, fmt.Sprintf("%d:%s:%s", b.Level, b.Type, b.Content)) + } + want := []string{ + "0:h1:Projekt", "1:bullet:Einleitung", "1:h2:Teil A", "2:bullet:Punkt", "3:bullet:Unterpunkt", + "1:h2:Teil B", "2:numbered:Erstens", "0:h1:Anhang", "1:bullet:Text", + } + if strings.Join(got, "|") != strings.Join(want, "|") { + t.Fatalf("got %v\nwant %v", got, want) + } +} From c181ffea541eaf732cf402ea164f35443cc295b3 Mon Sep 17 00:00:00 2001 From: Sandro Date: Fri, 30 Oct 2026 20:19:02 +0100 Subject: [PATCH 02/15] feat(ui): add desktop footer, legal custom link and about dialog --- .env.example | 3 ++ CHANGELOG.md | 4 +++ Doku/architecture.md | 1 + docker-compose.yml | 3 ++ frontend/src/App.tsx | 32 ++++++++++++++++-- frontend/src/components/AppFooter.tsx | 45 ++++++++++++++++++++++++++ frontend/src/components/LegalLinks.tsx | 14 +++++++- frontend/src/lib/types.ts | 2 ++ internal/api/handlers_auth.go | 2 ++ internal/api/server_test.go | 11 +++++-- internal/config/config.go | 36 ++++++++++++++++----- internal/config/config_test.go | 39 ++++++++++++++++++++++ 12 files changed, 179 insertions(+), 13 deletions(-) create mode 100644 frontend/src/components/AppFooter.tsx diff --git a/.env.example b/.env.example index 098f01b..7c9067a 100644 --- a/.env.example +++ b/.env.example @@ -86,6 +86,9 @@ LEGAL_PRIVACY_CONTENT= # Pfad im Container, z. B. /legal/impressum.md LEGAL_IMPRINT_CONTENT_FILE= LEGAL_PRIVACY_CONTENT_FILE= +# Eigener Link im Footer (nur https://) +LEGAL_CUSTOM_LINK_LABEL= +LEGAL_CUSTOM_LINK_URL= # ─── Bilder und Audio in Notizen ─────────────────────────────────────────────────────────────────── # Dateien liegen in data/media/ und sind nur für Personen sichtbar, die die Notiz lesen dürfen. Nicht mehr diff --git a/CHANGELOG.md b/CHANGELOG.md index 20f7969..e79631b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V ## [Unreleased] +### Added +- Globaler Desktop-App-Footer mit Versionsnummer, „Über MindSift“, dynamischen Rechtstexten (Impressum, Datenschutz) sowie optional konfigurierbarem Custom-Link (`LEGAL_CUSTOM_LINK_LABEL`, `LEGAL_CUSTOM_LINK_URL`). +- „Über MindSift“-Dialog und Versionsanzeige im mobilen Profil-Menü (Avatar-Menü oben rechts). + ## [0.10.0] - 2026-10-30 ### Added diff --git a/Doku/architecture.md b/Doku/architecture.md index 46b60f2..54d6f6f 100644 --- a/Doku/architecture.md +++ b/Doku/architecture.md @@ -82,6 +82,7 @@ Das Frontend liegt per `go:embed` im Binary ([cmd/server/main.go](../cmd/server/ | `LEGAL_IMPRINT_URL`, `LEGAL_PRIVACY_URL` | leer | Impressum/Datenschutz als externer Link (nur `http(s)`) | | `LEGAL_IMPRINT_CONTENT`, `LEGAL_PRIVACY_CONTENT` | leer | Impressum/Datenschutz als Markdown, angezeigt in einem Lesefenster | | `LEGAL_IMPRINT_CONTENT_FILE`, `LEGAL_PRIVACY_CONTENT_FILE` | leer | Pfad zu einer Datei im Container mit Markdown-Inhalt für Impressum bzw. Datenschutz. Ohne URL und Text bleibt der Link unsichtbar. | +| `LEGAL_CUSTOM_LINK_LABEL`, `LEGAL_CUSTOM_LINK_URL` | leer | Eigener Link im globalen Desktop-Footer (nur `https://`, sonst ignoriert) | Hinweis: `DATABASE_URL` mit `postgres…` führt dazu, dass der Standardpfad in `DATA_DIR` genutzt wird; Postgres ist nicht implementiert. diff --git a/docker-compose.yml b/docker-compose.yml index e00922c..1674380 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -40,6 +40,9 @@ services: - LEGAL_PRIVACY_CONTENT=${LEGAL_PRIVACY_CONTENT:-} - LEGAL_IMPRINT_CONTENT_FILE=${LEGAL_IMPRINT_CONTENT_FILE:-} - LEGAL_PRIVACY_CONTENT_FILE=${LEGAL_PRIVACY_CONTENT_FILE:-} + # Eigener Link im Footer (nur https://) + # - LEGAL_CUSTOM_LINK_LABEL=${LEGAL_CUSTOM_LINK_LABEL:-} + # - LEGAL_CUSTOM_LINK_URL=${LEGAL_CUSTOM_LINK_URL:-} # Bilder und Audio in Notizen - MEDIA_MAX_IMAGE_MB=${MEDIA_MAX_IMAGE_MB:-15} - MEDIA_MAX_AUDIO_MB=${MEDIA_MAX_AUDIO_MB:-50} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5b36801..0a815c4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -65,6 +65,8 @@ import { useMobileActions } from './context/MobileActionContext'; import { MobileHeaderMenu } from './components/MobileHeaderMenu'; import { UserAvatar } from './components/UserAvatar'; import { CommandPalette } from './components/CommandPalette'; +import { AppFooter } from './components/AppFooter'; +import { AboutModal } from './components/AboutModal'; import { APP_VERSION } from './version'; type ActiveTab = @@ -170,6 +172,7 @@ export const App: React.FC = () => { // Profile Dropdown State const [showProfileMenu, setShowProfileMenu] = useState(false); + const [showAbout, setShowAbout] = useState(false); const dropdownRef = useRef(null); // Mobile Navigation & Quick-Action State @@ -1119,8 +1122,8 @@ export const App: React.FC = () => { {/* Main Container */} {/* The workspace fills the window exactly and scrolls only inside the note, never the page */}
setActiveTab('triage')}> @@ -1202,6 +1205,11 @@ export const App: React.FC = () => {
+ setShowAbout(true)} + /> + {/* MOBILE BOTTOM NAVIGATION BAR (5 equal columns -> + FAB is dead-center) */}
+ + {/* Version & About Hint on Mobile */} +
+ MindSift {APP_VERSION} +
)} @@ -1725,6 +1751,8 @@ export const App: React.FC = () => { onOpenTask={handleOpenTask} onOpenList={handleOpenList} /> + + {showAbout && setShowAbout(false)} />}
); }; diff --git a/frontend/src/components/AppFooter.tsx b/frontend/src/components/AppFooter.tsx new file mode 100644 index 0000000..024e194 --- /dev/null +++ b/frontend/src/components/AppFooter.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { AuthStatus } from '../lib/types'; +import { APP_VERSION } from '../version'; +import { LegalLinks } from './LegalLinks'; + +interface AppFooterProps { + legal?: AuthStatus['legal']; + onOpenAbout: () => void; +} + +export const AppFooter: React.FC = ({ legal, onOpenAbout }) => { + return ( +
+
+ + + · + +
+ +
+ +
+
+ ); +}; diff --git a/frontend/src/components/LegalLinks.tsx b/frontend/src/components/LegalLinks.tsx index a30a50d..5fdfc4e 100644 --- a/frontend/src/components/LegalLinks.tsx +++ b/frontend/src/components/LegalLinks.tsx @@ -28,7 +28,9 @@ export const LegalLinks: React.FC = ({ legal, className = '', c })) .filter((e) => e.url || e.hasContent); - if (entries.length === 0 && !children) return null; + const customHref = legal?.customLinkUrl && safeHref(legal.customLinkUrl); + + if (entries.length === 0 && !children && !customHref) return null; const openText = async (type: LegalType) => { setError(null); @@ -52,6 +54,16 @@ export const LegalLinks: React.FC = ({ legal, className = '', c ) )} + {customHref && ( + + {legal?.customLinkLabel || legal?.customLinkUrl} + + )} {children} {error && {error}} {open && ( diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index a15f238..e5ab70b 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -20,6 +20,8 @@ export interface AuthStatus { hasImprintContent?: boolean; privacyUrl?: string; hasPrivacyContent?: boolean; + customLinkLabel?: string; + customLinkUrl?: string; }; // Voice input: own Whisper server, the browser's recognition, or switched off (STT_* environment) voice?: { mode: VoiceMode; maxSeconds: number }; diff --git a/internal/api/handlers_auth.go b/internal/api/handlers_auth.go index 74d2d42..33d1d56 100644 --- a/internal/api/handlers_auth.go +++ b/internal/api/handlers_auth.go @@ -52,6 +52,8 @@ func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) { "hasImprintContent": s.cfg.Legal.ImprintContent != "", "privacyUrl": s.cfg.Legal.PrivacyURL, "hasPrivacyContent": s.cfg.Legal.PrivacyContent != "", + "customLinkLabel": s.cfg.Legal.CustomLinkLabel, + "customLinkUrl": s.cfg.Legal.CustomLinkURL, }, } // Countdown to the next demo reset; seconds as well, so client clocks do not matter diff --git a/internal/api/server_test.go b/internal/api/server_test.go index 822fa3a..3a27b3a 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -1388,7 +1388,12 @@ func TestLegalTextsArePublic(t *testing.T) { } defer store.Close() cfg := &config.Config{Port: "8080", DatabasePath: dbPath, JWTSecret: "test-jwt-secret-key", - Legal: config.LegalTexts{ImprintContent: "# Impressum\nMax Muster", PrivacyURL: "https://example.org/datenschutz"}} + Legal: config.LegalTexts{ + ImprintContent: "# Impressum\nMax Muster", + PrivacyURL: "https://example.org/datenschutz", + CustomLinkLabel: "Support", + CustomLinkURL: "https://support.example.org", + }} router := NewServer(cfg, store, ws.NewHub(), nil).Router() rec := httptest.NewRecorder() @@ -1399,10 +1404,12 @@ func TestLegalTextsArePublic(t *testing.T) { HasImprintContent bool `json:"hasImprintContent"` PrivacyURL string `json:"privacyUrl"` HasPrivacyContent bool `json:"hasPrivacyContent"` + CustomLinkLabel string `json:"customLinkLabel"` + CustomLinkURL string `json:"customLinkUrl"` } `json:"legal"` } _ = json.Unmarshal(rec.Body.Bytes(), &st) - if !st.Legal.HasImprintContent || st.Legal.HasPrivacyContent || st.Legal.PrivacyURL != "https://example.org/datenschutz" { + if !st.Legal.HasImprintContent || st.Legal.HasPrivacyContent || st.Legal.PrivacyURL != "https://example.org/datenschutz" || st.Legal.CustomLinkLabel != "Support" || st.Legal.CustomLinkURL != "https://support.example.org" { t.Fatalf("unexpected legal status: %+v", st.Legal) } diff --git a/internal/config/config.go b/internal/config/config.go index df0fff1..b559c86 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -94,8 +94,9 @@ func (s SpeechConfig) Mode(demo bool) string { } type LegalTexts struct { - ImprintURL, ImprintContent string - PrivacyURL, PrivacyContent string + ImprintURL, ImprintContent string + PrivacyURL, PrivacyContent string + CustomLinkLabel, CustomLinkURL string } // Publicly known JWT secrets (old code default and the placeholders from .env.example, compose and @@ -171,6 +172,20 @@ func legalURL(name string) string { return v } +// legalCustomLink accepts only https links for the custom footer link. +func legalCustomLink(labelVar, urlVar string) (string, string) { + u := strings.TrimSpace(os.Getenv(urlVar)) + if u == "" { + return "", "" + } + if !strings.HasPrefix(u, "https://") { + slog.Warn("Custom-Link-URL ignoriert: nur https erlaubt", "variable", urlVar, "url", u) + return "", "" + } + label := strings.TrimSpace(os.Getenv(labelVar)) + return label, u +} + func Load() *Config { port := getEnv("PORT", "8080") dataDir := getEnv("DATA_DIR", "./data") @@ -247,12 +262,17 @@ func Load() *Config { QuotaMB: envInt("MEDIA_QUOTA_MB", 0, 0, 10000000), DemoMaxMB: envInt("MEDIA_DEMO_MAX_MB", 3, 1, 100), }, - Legal: LegalTexts{ - ImprintURL: legalURL("LEGAL_IMPRINT_URL"), - ImprintContent: legalContent("LEGAL_IMPRINT_CONTENT"), - PrivacyURL: legalURL("LEGAL_PRIVACY_URL"), - PrivacyContent: legalContent("LEGAL_PRIVACY_CONTENT"), - }, + Legal: func() LegalTexts { + customLabel, customURL := legalCustomLink("LEGAL_CUSTOM_LINK_LABEL", "LEGAL_CUSTOM_LINK_URL") + return LegalTexts{ + ImprintURL: legalURL("LEGAL_IMPRINT_URL"), + ImprintContent: legalContent("LEGAL_IMPRINT_CONTENT"), + PrivacyURL: legalURL("LEGAL_PRIVACY_URL"), + PrivacyContent: legalContent("LEGAL_PRIVACY_CONTENT"), + CustomLinkLabel: customLabel, + CustomLinkURL: customURL, + } + }(), } } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index a5a3291..7bd1156 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -81,3 +81,42 @@ func TestJWTSecretNeverUsesAKnownDefault(t *testing.T) { t.Fatalf("a real JWT_SECRET must win, got %q", got) } } + +func TestCustomLinkValidation(t *testing.T) { + t.Setenv("DATA_DIR", t.TempDir()) + + // 1. Valid https URL and label + t.Setenv("LEGAL_CUSTOM_LINK_LABEL", "Unternehmensseite") + t.Setenv("LEGAL_CUSTOM_LINK_URL", "https://example.com/company") + cfg := Load() + if cfg.Legal.CustomLinkLabel != "Unternehmensseite" { + t.Errorf("expected label 'Unternehmensseite', got %q", cfg.Legal.CustomLinkLabel) + } + if cfg.Legal.CustomLinkURL != "https://example.com/company" { + t.Errorf("expected url 'https://example.com/company', got %q", cfg.Legal.CustomLinkURL) + } + + // 2. Insecure http URL -> ignored + t.Setenv("LEGAL_CUSTOM_LINK_LABEL", "Insecure Site") + t.Setenv("LEGAL_CUSTOM_LINK_URL", "http://insecure.example.com") + cfg = Load() + if cfg.Legal.CustomLinkURL != "" || cfg.Legal.CustomLinkLabel != "" { + t.Errorf("expected empty custom link for http, got url=%q label=%q", cfg.Legal.CustomLinkURL, cfg.Legal.CustomLinkLabel) + } + + // 3. Invalid scheme (javascript:) -> ignored + t.Setenv("LEGAL_CUSTOM_LINK_LABEL", "Exploit") + t.Setenv("LEGAL_CUSTOM_LINK_URL", "javascript:alert(1)") + cfg = Load() + if cfg.Legal.CustomLinkURL != "" || cfg.Legal.CustomLinkLabel != "" { + t.Errorf("expected empty custom link for javascript:, got url=%q label=%q", cfg.Legal.CustomLinkURL, cfg.Legal.CustomLinkLabel) + } + + // 4. Empty URL -> empty + t.Setenv("LEGAL_CUSTOM_LINK_LABEL", "Only Label") + t.Setenv("LEGAL_CUSTOM_LINK_URL", "") + cfg = Load() + if cfg.Legal.CustomLinkURL != "" || cfg.Legal.CustomLinkLabel != "" { + t.Errorf("expected empty custom link when url is empty, got url=%q label=%q", cfg.Legal.CustomLinkURL, cfg.Legal.CustomLinkLabel) + } +} From d408e7d307305f39b184531fd148c276be19a878 Mon Sep 17 00:00:00 2001 From: Sandro Date: Fri, 30 Oct 2026 20:22:39 +0100 Subject: [PATCH 03/15] feat(workspace): note actions in the workspace bar on desktop, one-row sidebar header On large screens the editor header is gone: undo/redo, share and the menu render into the workspace tab bar (portal) after avatars, live, shared and an icon-only save status; zoom breadcrumbs move above the title. Phones and tablets keep the editor header. The sidebar header drops its heading and puts search, new folder and new note in one row. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 + .../src/features/workspace/DocumentEditor.tsx | 369 ++++++++++-------- .../src/features/workspace/WorkspaceView.tsx | 97 ++--- 3 files changed, 252 insertions(+), 216 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2855b51..6e10368 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V ## [Unreleased] ### Changed +- Workspace am Desktop (ab 1024 px): Die eigene Kopfleiste der Notiz entfällt. Rückgängig, Wiederholen, Teilen und das Drei-Punkte-Menü stehen oben in der Workspace-Leiste, in fester Reihenfolge: Avatare, Live, Geteilt, Speicherstatus (nur noch als Symbol, Text als Tooltip), dann die Knöpfe. Beim Fokus-Modus stehen die Breadcrumbs über dem Titel. Am Handy und Tablet bleibt die Kopfleiste. +- Notizen-Seitenleiste: Überschrift „Notizen & Outliner“ entfernt, Suche und die Knöpfe für neuen Ordner und neue Notiz stehen in einer Zeile. - Journal: Geöffnet ist es ein hinterlegtes Panel mit Rahmen, „Journal“ ist dessen Überschrift; ein zweiter Klick schließt es und zeigt wieder die vorherige Notiz. Ob das Journal offen war und welche Notiz offen war, bleibt beim Wechsel zwischen den Tabs erhalten (Journal-Tage öffnen sich nicht mehr als einzelne Notiz). - Notizen: Beim Einfügen und beim Markdown-Import werden Absätze und Listen unter einer Überschrift eingerückt (H2 unter H1 eine Ebene tiefer), so lassen sich Abschnitte einklappen und fokussieren. Enter am Ende einer Überschrift legt den neuen Punkt als Unterpunkt an; `- `, `* ` oder `+ ` in einem leeren Punkt machen ihn zum normalen Aufzählungspunkt. diff --git a/frontend/src/features/workspace/DocumentEditor.tsx b/frontend/src/features/workspace/DocumentEditor.tsx index 24bcce9..0d57749 100644 --- a/frontend/src/features/workspace/DocumentEditor.tsx +++ b/frontend/src/features/workspace/DocumentEditor.tsx @@ -1,4 +1,5 @@ -import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'; +import React, { useState, useEffect, useRef, useCallback, useMemo, useSyncExternalStore } from 'react'; +import { createPortal } from 'react-dom'; import { Pin, Users, @@ -63,6 +64,20 @@ export interface DocumentEditorProps { onDocRemoved?: (docId: string) => void; /** 'journal': one day inside the journal list – no own scrolling, header or backlinks, fixed title */ variant?: 'page' | 'journal'; + /** Large screens: the action buttons render into this element (the workspace tab bar) */ + actionsTarget?: HTMLElement | null; +} + +// Large screens (lg) put the note's actions into the workspace tab bar and drop the editor header +const LARGE_QUERY = '(min-width: 1024px)'; +function subscribeLarge(onChange: () => void) { + if (typeof window === 'undefined' || !window.matchMedia) return () => {}; + const mq = window.matchMedia(LARGE_QUERY); + mq.addEventListener('change', onChange); + return () => mq.removeEventListener('change', onChange); +} +function isLargeScreen() { + return typeof window !== 'undefined' && !!window.matchMedia && window.matchMedia(LARGE_QUERY).matches; } import { EmojiPickerModal } from './EmojiPickerModal'; @@ -214,8 +229,11 @@ export const DocumentEditor: React.FC = ({ onDocCreated, onDocRemoved, variant = 'page', + actionsTarget, }) => { const inJournal = variant === 'journal'; + const large = useSyncExternalStore(subscribeLarge, isLargeScreen, () => false); + const desktopBar = !inJournal && large && Boolean(actionsTarget); const [doc, setDoc] = useState(initialDoc || null); const [title, setTitle] = useState(initialDoc?.title || ''); const [blocks, setBlocks] = useState(() => @@ -787,14 +805,189 @@ export const DocumentEditor: React.FC = ({ ); } + const breadcrumbNav = ( + + ); + + // Undo/redo, share and the menu; on large screens they sit in the workspace tab bar instead + const actionsBlock = ( +
+ {!inTrash && ( + <> + + + + )} + + {doc && !inTrash && ( + + )} + +
+ + + {showActionsMenu && ( +
+ {!inTrash && ( + + )} + + {onRequestMerge && doc?.isOwner && !inTrash && ( + + )} + + + + {canEdit && (doc?.isOwner || doc?.permission === 'owner') && ( + <> +
+ + + )} +
+ )} +
+
+ ); + return (
- {/* Top Document Action Bar (the journal has one for all days) */} - {!inJournal && ( + {desktopBar && actionsTarget && createPortal(actionsBlock, actionsTarget)} + + {/* Top Document Action Bar (the journal has one for all days; large screens use the tab bar) */} + {!inJournal && !desktopBar && (
{onBackMobile && ( @@ -810,48 +1003,7 @@ export const DocumentEditor: React.FC = ({ {/* Breadcrumbs Navigation (Fokus-Modus) or Doc Indicator */} {zoomedBlock && breadcrumbs.length > 0 ? ( - + breadcrumbNav ) : (
= ({ )}
- {/* Right Action Buttons */} -
- {!inTrash && ( - <> - - - - )} - - {doc && !inTrash && ( - - )} - -
- - - {showActionsMenu && ( -
- {!inTrash && ( - - )} - - {onRequestMerge && doc?.isOwner && !inTrash && ( - - )} - - - - {canEdit && (doc?.isOwner || doc?.permission === 'owner') && ( - <> -
- - - )} -
- )} -
-
+ {!desktopBar && actionsBlock}
)} @@ -1064,6 +1092,7 @@ export const DocumentEditor: React.FC = ({ inJournal ? 'py-3' : 'py-5' }`} > + {desktopBar && zoomedBlock && breadcrumbs.length > 0 &&
{breadcrumbNav}
} {zoomedBlock ? ( /* ========================================================================= FOKUS-MODUS & ZOOM-IN (HOISTED SUBTREE VIEW) diff --git a/frontend/src/features/workspace/WorkspaceView.tsx b/frontend/src/features/workspace/WorkspaceView.tsx index af60e1f..894fa54 100644 --- a/frontend/src/features/workspace/WorkspaceView.tsx +++ b/frontend/src/features/workspace/WorkspaceView.tsx @@ -427,6 +427,7 @@ export const WorkspaceView: React.FC = ({ const [docSaveStatus, setDocSaveStatus] = useState('saved'); const [docSaveError, setDocSaveError] = useState(null); const [docHeaderStatus, setDocHeaderStatus] = useState(null); + const [docActionsTarget, setDocActionsTarget] = useState(null); // Drag and Drop state @@ -1416,6 +1417,14 @@ export const WorkspaceView: React.FC = ({
{subView === 'editor' && selectedDocId ? ( <> + {/* Fixed order: people first (they come and go without moving the buttons), status, actions */} + {docHeaderStatus && ( + + )} + {/* Live connection pill */} {docHeaderStatus?.showLive && ( = ({ )} - {/* Who else has this note open */} - {docHeaderStatus && ( - - )} - {/* Share pill */} {selectedDocObj && !selectedDocObj.isArchived && @@ -1469,7 +1470,7 @@ export const WorkspaceView: React.FC = ({
@@ -1551,47 +1560,42 @@ export const WorkspaceView: React.FC = ({ : 'flex' } w-full lg:w-72 xl:w-80 shrink-0 flex-col bg-sidebar border-r border-border transition-all duration-200`} > - {/* Sidebar Header & Actions */} -
-
-

- Notizen & Outliner -

- {/* On phones these are in the menu at the top (and the + button), so not repeated here */} -
- - -
-
- + {/* Sidebar header: search and the create buttons in one row */} +
{/* Opens the global full-text search, which also finds the content of every bullet */} {onOpenSearch && ( )} + {/* On phones these are in the menu at the top (and the + button), so not repeated here */} +
+ + +
{/* Scrollable Navigation Tree */} @@ -1882,6 +1886,7 @@ export const WorkspaceView: React.FC = ({ setDocSaveError(err || null); }} onHeaderStatusChange={setDocHeaderStatus} + actionsTarget={docActionsTarget} /> ) : subView === 'tags' ? ( /* ========================================================================= From 86d2cd0138452020346a0c47ab43dd188418f65c Mon Sep 17 00:00:00 2001 From: Sandro Date: Fri, 30 Oct 2026 20:23:54 +0100 Subject: [PATCH 04/15] feat(pwa): add web share target for text and images with offline queue --- CHANGELOG.md | 1 + Doku/api/rest.md | 3 +- frontend/public/manifest.json | 16 +++ frontend/public/sw.js | 74 ++++++++++- frontend/src/App.tsx | 41 ++++++ .../src/lib/__tests__/shareTarget.test.ts | 87 +++++++++++++ frontend/src/lib/shareTarget.ts | 117 ++++++++++++++++++ internal/api/server.go | 5 + internal/api/server_test.go | 18 +++ 9 files changed, 360 insertions(+), 2 deletions(-) create mode 100644 frontend/src/lib/__tests__/shareTarget.test.ts create mode 100644 frontend/src/lib/shareTarget.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e79631b..6c006ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V ### Added - Globaler Desktop-App-Footer mit Versionsnummer, „Über MindSift“, dynamischen Rechtstexten (Impressum, Datenschutz) sowie optional konfigurierbarem Custom-Link (`LEGAL_CUSTOM_LINK_LABEL`, `LEGAL_CUSTOM_LINK_URL`). - „Über MindSift“-Dialog und Versionsanzeige im mobilen Profil-Menü (Avatar-Menü oben rechts). +- PWA Web Share Target: Text, Links und Bilder können über den nativen Teilen-Dialog des Smartphones direkt in den MindSift-Posteingang geteilt werden (`share_target` im Manifest, Abfangen im Service Worker via IndexedDB und Übernahme beim App-Start; Fallback-Route `POST /share-target` im Server). ## [0.10.0] - 2026-10-30 diff --git a/Doku/api/rest.md b/Doku/api/rest.md index 074558a..d5addb8 100644 --- a/Doku/api/rest.md +++ b/Doku/api/rest.md @@ -7,8 +7,9 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}` | Methode | Pfad | Zweck | |---|---|---| | GET | `/health` | Lebenszeichen | -| GET | `/auth/status` | `needsSetup`, `allowRegistration`, `instanceName`, `demoMode`, `sharingMode`, `legal` (`imprintUrl`, `hasImprintContent`, `privacyUrl`, `hasPrivacyContent`) | +| GET | `/auth/status` | `needsSetup`, `allowRegistration`, `instanceName`, `demoMode`, `sharingMode`, `legal` (`imprintUrl`, `hasImprintContent`, `privacyUrl`, `hasPrivacyContent`, `customLinkLabel`, `customLinkUrl`) | | GET | `/legal/{imprint\|privacy}` | Hinterlegter Markdown-Text (`content`), ohne Anmeldung; `404`, wenn keiner gesetzt ist | +| POST | `/share-target` | PWA Web Share Target Fallback: Weiterleitung auf `/` (`303 See Other`), falls Service Worker noch inaktiv | | POST | `/auth/setup` | Erstes Administratorkonto (`email`, `password`, `displayName`, `instanceName`, `allowRegistration`, `sharingMode`, `seedSampleData`); nur solange es keine Konten gibt | | POST | `/auth/register` | Registrierung (`email`, `password`, `displayName`, `inviteCode`); nur mit Code oder wenn freigegeben; `429` nach 20 Versuchen pro Adresse in 15 Minuten | | POST | `/auth/login` | Anmeldung (`login`/`email`/`username`, `password`) → `{token, user}` | diff --git a/frontend/public/manifest.json b/frontend/public/manifest.json index e81f1f8..31af9ac 100644 --- a/frontend/public/manifest.json +++ b/frontend/public/manifest.json @@ -47,6 +47,22 @@ "purpose": "maskable" } ], + "share_target": { + "action": "/share-target", + "method": "POST", + "enctype": "multipart/form-data", + "params": { + "title": "title", + "text": "text", + "url": "url", + "files": [ + { + "name": "media", + "accept": ["image/*"] + } + ] + } + }, "shortcuts": [ { "name": "Gedanke sofort erfassen", diff --git a/frontend/public/sw.js b/frontend/public/sw.js index 1328369..c96ccdd 100644 --- a/frontend/public/sw.js +++ b/frontend/public/sw.js @@ -5,10 +5,12 @@ // - Icons and manifest: from the cache, refreshed in the background. // - API requests are never touched; offline data comes from the app's own cache (lib/offline.ts). // Raise VERSION when this file changes in a way that needs fresh caches. -const VERSION = 'v2'; +const VERSION = 'v3'; const SHELL_CACHE = `mindsift-shell-${VERSION}`; const ASSET_CACHE = `mindsift-assets-${VERSION}`; const MAX_ASSETS = 80; +const SHARE_DB_NAME = 'mindsift-share-target'; +const SHARE_STORE_NAME = 'shared_items'; const SHELL_URLS = [ '/', '/index.html', @@ -80,8 +82,78 @@ async function staleWhileRevalidate(request) { return hit || refresh; } +function openShareDB() { + return new Promise((resolve, reject) => { + const req = indexedDB.open(SHARE_DB_NAME, 1); + req.onupgradeneeded = () => { + const db = req.result; + if (!db.objectStoreNames.contains(SHARE_STORE_NAME)) { + db.createObjectStore(SHARE_STORE_NAME, { autoIncrement: true, keyPath: 'id' }); + } + }; + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} + +async function savePendingShare(item) { + const db = await openShareDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction(SHARE_STORE_NAME, 'readwrite'); + tx.objectStore(SHARE_STORE_NAME).add(item); + tx.oncomplete = () => { + db.close(); + resolve(); + }; + tx.onerror = () => { + db.close(); + reject(tx.error); + }; + tx.onabort = () => { + db.close(); + reject(tx.error); + }; + }); +} + +async function handleShareTarget(event) { + try { + const formData = await event.request.formData(); + const title = formData.get('title'); + const text = formData.get('text'); + const url = formData.get('url'); + const media = formData.get('media'); + + let fileName = ''; + let hasMedia = false; + if (media && typeof media === 'object') { + fileName = media.name || 'image'; + hasMedia = true; + } + + await savePendingShare({ + title: typeof title === 'string' ? title.trim() : '', + text: typeof text === 'string' ? text.trim() : '', + url: typeof url === 'string' ? url.trim() : '', + fileName, + hasMedia, + createdAt: Date.now(), + }); + } catch (err) { + console.error('[SW] Share-Target Fehler:', err); + } + return Response.redirect('/?shared=pending', 303); +} + self.addEventListener('fetch', (event) => { const { request } = event; + if (request.method === 'POST') { + const url = new URL(request.url); + if (url.origin === self.location.origin && url.pathname === '/share-target') { + event.respondWith(handleShareTarget(event)); + } + return; + } if (request.method !== 'GET') return; const url = new URL(request.url); if (url.origin !== self.location.origin || url.pathname.startsWith('/api/')) return; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0a815c4..74fa278 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -67,6 +67,7 @@ import { UserAvatar } from './components/UserAvatar'; import { CommandPalette } from './components/CommandPalette'; import { AppFooter } from './components/AppFooter'; import { AboutModal } from './components/AboutModal'; +import { consumePendingShares, formatSharedThought } from './lib/shareTarget'; import { APP_VERSION } from './version'; type ActiveTab = @@ -555,6 +556,46 @@ export const App: React.FC = () => { ); }, [authStatus.demoMode, authStatus.demoResetInSeconds]); + // Web Share Target: import shared thoughts from external apps + useEffect(() => { + if (!user) return; + const params = new URLSearchParams(window.location.search); + const hasPendingShare = params.get('shared') === 'pending'; + + const processShares = async () => { + try { + const shares = await consumePendingShares(); + if (shares.length === 0) return; + + let importedCount = 0; + for (const share of shares) { + const content = formatSharedThought(share); + if (content) { + await api.captureThought(content, 'normal', 'manual', '', { target: 'inbox' }); + importedCount++; + } + } + + if (importedCount > 0) { + triggerHaptic(15); + setDemoNotice('Erfolgreich im Quick Dump abgelegt'); + setTimeout(() => setDemoNotice(null), 4000); + refreshAll(); + } + } catch (err) { + console.error('Fehler beim Import geteilter Daten:', err); + } finally { + if (hasPendingShare) { + const url = new URL(window.location.href); + url.searchParams.delete('shared'); + window.history.replaceState({}, '', url.pathname + (url.search ? url.search : '') + url.hash); + } + } + }; + + processShares(); + }, [user, refreshAll]); + // The demo database was reset: drop cached answers of the old data, remount the views (they load // fresh data), refresh counters and the countdown. Sessions stay valid (fixed demo account IDs). const handleDemoReset = useCallback(() => { diff --git a/frontend/src/lib/__tests__/shareTarget.test.ts b/frontend/src/lib/__tests__/shareTarget.test.ts new file mode 100644 index 0000000..09604e6 --- /dev/null +++ b/frontend/src/lib/__tests__/shareTarget.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect } from 'vitest'; +import { formatSharedThought } from '../shareTarget'; + +describe('formatSharedThought', () => { + it('formats pure text correctly', () => { + expect(formatSharedThought({ text: 'Spontaner Einfall' })).toBe('Spontaner Einfall'); + }); + + it('formats pure url correctly', () => { + expect(formatSharedThought({ url: 'https://example.com/article' })).toBe('https://example.com/article'); + }); + + it('combines title and url when text is empty', () => { + expect(formatSharedThought({ title: 'Toller Artikel', url: 'https://example.com/article' })) + .toBe('Toller Artikel\nhttps://example.com/article'); + }); + + it('does not duplicate URL when text already contains it', () => { + const input = { + title: 'Artikel', + text: 'Lies das hier: https://example.com/article unbedingt!', + url: 'https://example.com/article', + }; + expect(formatSharedThought(input)) + .toBe('Artikel\nLies das hier: https://example.com/article unbedingt!'); + }); + + it('does not duplicate URL when text is exactly the URL', () => { + const input = { + title: 'News', + text: 'https://news.example.com', + url: 'https://news.example.com', + }; + expect(formatSharedThought(input)) + .toBe('News\nhttps://news.example.com'); + }); + + it('does not duplicate title if text starts with title', () => { + const input = { + title: 'MindSift Update', + text: 'MindSift Update ist jetzt verfügbar.', + }; + expect(formatSharedThought(input)) + .toBe('MindSift Update ist jetzt verfügbar.'); + }); + + it('combines title, text and distinct URL', () => { + const input = { + title: 'Empfehlung', + text: 'Ein großartiges Tool für schnelles Erfassen.', + url: 'https://mindsift.app', + }; + expect(formatSharedThought(input)) + .toBe('Empfehlung\nEin großartiges Tool für schnelles Erfassen.\nhttps://mindsift.app'); + }); + + it('handles image with text', () => { + const input = { + title: 'Foto vom Ausflug', + fileName: 'berge.jpg', + hasMedia: true, + }; + expect(formatSharedThought(input)) + .toBe('Foto vom Ausflug\n(Bild geteilt – Bilder im Posteingang werden noch nicht unterstützt)'); + }); + + it('handles image only with filename', () => { + const input = { + fileName: 'screenshot.png', + hasMedia: true, + }; + expect(formatSharedThought(input)) + .toBe('[screenshot.png] (Bild geteilt – Bilder im Posteingang werden noch nicht unterstützt)'); + }); + + it('handles image only without filename', () => { + const input = { + hasMedia: true, + }; + expect(formatSharedThought(input)) + .toBe('(Bild geteilt – Bilder im Posteingang werden noch nicht unterstützt)'); + }); + + it('returns empty string if nothing is provided', () => { + expect(formatSharedThought({})).toBe(''); + }); +}); diff --git a/frontend/src/lib/shareTarget.ts b/frontend/src/lib/shareTarget.ts new file mode 100644 index 0000000..099fa51 --- /dev/null +++ b/frontend/src/lib/shareTarget.ts @@ -0,0 +1,117 @@ +export interface SharedData { + id?: number; + title?: string; + text?: string; + url?: string; + fileName?: string; + hasMedia?: boolean; + createdAt?: number; +} + +const SHARE_DB_NAME = 'mindsift-share-target'; +const SHARE_STORE_NAME = 'shared_items'; + +/** + * Combines title, text, url and media note into a single thought string. + * Ensures URLs are not duplicated if already present in the shared text or title. + */ +export function formatSharedThought(share: SharedData): string { + const title = (share.title || '').trim(); + const text = (share.text || '').trim(); + const url = (share.url || '').trim(); + const mediaNote = '(Bild geteilt – Bilder im Posteingang werden noch nicht unterstützt)'; + + let content = ''; + + // Check if url is already contained in text or title + const urlInText = url !== '' && text.includes(url); + const urlInTitle = url !== '' && title.includes(url); + + // Combine title and text + if (title && text) { + if (text === title) { + content = text; + } else if (text.startsWith(title)) { + content = text; + } else { + content = `${title}\n${text}`; + } + } else if (title) { + content = title; + } else if (text) { + content = text; + } + + // Append URL if not already included in text or title + if (url && !urlInText && !urlInTitle) { + if (content) { + content = `${content}\n${url}`; + } else { + content = url; + } + } + + // Media note handling + if (share.hasMedia || share.fileName) { + if (content) { + content = `${content}\n${mediaNote}`; + } else if (share.fileName) { + content = `[${share.fileName}] ${mediaNote}`; + } else { + content = mediaNote; + } + } + + return content.trim(); +} + +/** + * Retrieves and removes all pending shared items stored in IndexedDB by the Service Worker. + */ +export async function consumePendingShares(): Promise { + if (typeof indexedDB === 'undefined') return []; + + return new Promise((resolve) => { + try { + const req = indexedDB.open(SHARE_DB_NAME, 1); + req.onupgradeneeded = () => { + const db = req.result; + if (!db.objectStoreNames.contains(SHARE_STORE_NAME)) { + db.createObjectStore(SHARE_STORE_NAME, { autoIncrement: true, keyPath: 'id' }); + } + }; + req.onsuccess = () => { + const db = req.result; + if (!db.objectStoreNames.contains(SHARE_STORE_NAME)) { + db.close(); + return resolve([]); + } + + const tx = db.transaction(SHARE_STORE_NAME, 'readwrite'); + const store = tx.objectStore(SHARE_STORE_NAME); + const getAllReq = store.getAll(); + + getAllReq.onsuccess = () => { + const items: SharedData[] = getAllReq.result || []; + store.clear(); + tx.oncomplete = () => { + db.close(); + resolve(items); + }; + tx.onerror = () => { + db.close(); + resolve(items); + }; + }; + + getAllReq.onerror = () => { + db.close(); + resolve([]); + }; + }; + req.onerror = () => resolve([]); + } catch { + resolve([]); + } + }); +} diff --git a/internal/api/server.go b/internal/api/server.go index 743b21b..02b96f7 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -308,6 +308,11 @@ func (s *Server) Router() http.Handler { }) }) + // Web Share Target fallback when service worker is not active yet: redirect to / + r.Post("/share-target", func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/", http.StatusSeeOther) + }) + // Embedded Frontend SPA Handler if s.staticFS != nil { fileServer := http.FileServer(http.FS(s.staticFS)) diff --git a/internal/api/server_test.go b/internal/api/server_test.go index 3a27b3a..d4d5198 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -1533,6 +1533,24 @@ func TestPasswordResetFlow(t *testing.T) { } } +func TestShareTargetFallbackRedirects(t *testing.T) { + store, err := storage.NewStore(":memory:") + if err != nil { + t.Fatalf("NewStore: %v", err) + } + defer store.Close() + router := NewServer(&config.Config{Port: "8080", DatabasePath: ":memory:", JWTSecret: "test-jwt-secret-key"}, store, ws.NewHub(), nil).Router() + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/share-target", nil) + router.ServeHTTP(rec, req) + if rec.Code != http.StatusSeeOther { + t.Fatalf("expected 303 See Other, got %d", rec.Code) + } + if loc := rec.Header().Get("Location"); loc != "/" { + t.Fatalf("expected Location '/', got %q", loc) + } +} + // testSession starts a server-side session, as a login would, for tokens made directly in tests. func testSession(t *testing.T, store *storage.Store, userID string) string { t.Helper() From bcb3c702ff823fa3b8d0a346e5a553c81fc9b8df Mon Sep 17 00:00:00 2001 From: Sandro Date: Fri, 30 Oct 2026 20:28:19 +0100 Subject: [PATCH 05/15] ci: optimize pipeline with image retagging and conditional tests --- .gitea/scripts/build-plan.sh | 127 ++++++++++++++++++++++++ .gitea/scripts/test-build-plan.sh | 76 ++++++++++++++ .gitea/workflows/build-and-publish.yaml | 100 +++++++++---------- CHANGELOG.md | 5 + Dockerfile | 25 +++-- 5 files changed, 272 insertions(+), 61 deletions(-) create mode 100755 .gitea/scripts/build-plan.sh create mode 100755 .gitea/scripts/test-build-plan.sh diff --git a/.gitea/scripts/build-plan.sh b/.gitea/scripts/build-plan.sh new file mode 100755 index 0000000..9e87613 --- /dev/null +++ b/.gitea/scripts/build-plan.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash +set -euo pipefail + +# build-plan.sh +# Calculates whether to build from scratch, skip tests, or retag an existing image. +# +# Inputs (via env or arguments): +# REF: Git reference (e.g. refs/heads/dev, refs/heads/main, refs/tags/v0.10.0) +# COMMIT: Commit SHA or HEAD (defaults to HEAD) +# IMAGE: Docker image repository name (e.g. git.dsit.me/sandrodach/mindsift) +# PKG_VERSION: Package version from package.json (optional, auto-detected) +# CHECK_IMAGE_CMD: Command to check image existence in registry (default: "docker manifest inspect") + +REF="${REF:-${1:-}}" +COMMIT="${COMMIT:-${2:-HEAD}}" +IMAGE="${IMAGE:-${3:-}}" +PKG_VERSION="${PKG_VERSION:-${4:-}}" +CHECK_CMD="${CHECK_IMAGE_CMD:-docker manifest inspect}" + +if [ -z "${REF}" ]; then + REF="$(git symbolic-ref -q HEAD 2>/dev/null || git describe --tags --exact-match 2>/dev/null || echo "refs/heads/dev")" +fi + +if [ -z "${PKG_VERSION}" ] && [ -f "frontend/package.json" ]; then + PKG_VERSION="$(sed -nE 's/^ "version": "([^"]+)".*/\1/p' frontend/package.json | head -n1)" +fi + +SHORT_SHA="$(git rev-parse --short=8 "${COMMIT}^{commit}" 2>/dev/null || echo "unknown")" +SHORT_TREE="$(git rev-parse --short=12 "${COMMIT}^{tree}" 2>/dev/null || echo "unknown")" + +image_exists() { + local target="$1" + ${CHECK_CMD} "${target}" >/dev/null 2>&1 +} + +ACTION="build" +SKIP_TESTS="0" +APP_VERSION="" +TAGS="" +SOURCE_IMAGE="" +REASON="" + +case "${REF}" in + refs/heads/dev|dev) + ACTION="build" + SKIP_TESTS="0" + APP_VERSION="" + TAGS="dev tree-${SHORT_TREE}" + REASON="Dev branch push: running full frontend and backend tests, tagging :dev and :tree-${SHORT_TREE}" + ;; + + refs/heads/main|main) + ACTION="build" + APP_VERSION="v${PKG_VERSION}" + TAGS="latest sha-${SHORT_SHA} tree-${SHORT_TREE}" + + # Check if this exact tree was already successfully tested on dev + if [ -n "${IMAGE}" ] && image_exists "${IMAGE}:tree-${SHORT_TREE}"; then + SKIP_TESTS="1" + REASON="Main branch merge/push: tree :tree-${SHORT_TREE} was already tested on dev. Skipping tests (SKIP_TESTS=1)." + else + SKIP_TESTS="0" + REASON="Main branch merge/push: tree :tree-${SHORT_TREE} not found in registry. Running full tests (SKIP_TESTS=0)." + fi + ;; + + refs/tags/v*|v*) + TAG_NAME="${REF#refs/tags/}" + # If pre-release (contains hyphen like v1.0.0-rc.1), do not tag :stable + if [[ "${TAG_NAME}" == *-* ]]; then + TARGET_TAGS="${TAG_NAME}" + else + TARGET_TAGS="${TAG_NAME} stable" + fi + + # Check if a main image was already built for this exact commit + if [ -n "${IMAGE}" ] && image_exists "${IMAGE}:sha-${SHORT_SHA}"; then + ACTION="retag" + SOURCE_IMAGE="${IMAGE}:sha-${SHORT_SHA}" + SKIP_TESTS="1" + TAGS="${TARGET_TAGS}" + REASON="Release tag ${TAG_NAME} points to commit with existing image (${IMAGE}:sha-${SHORT_SHA}). Retagging without rebuild." + else + ACTION="build" + APP_VERSION="${TAG_NAME}" + TAGS="${TARGET_TAGS}" + if [ -n "${IMAGE}" ] && image_exists "${IMAGE}:tree-${SHORT_TREE}"; then + SKIP_TESTS="1" + REASON="Release tag ${TAG_NAME}: :sha-${SHORT_SHA} not found, but tree was tested on dev. Building with SKIP_TESTS=1." + else + SKIP_TESTS="0" + REASON="Release tag ${TAG_NAME}: :sha-${SHORT_SHA} not found and tree not verified. Building with full tests (SKIP_TESTS=0)." + fi + fi + ;; + + *) + REF_SLUG="$(echo "${REF#refs/heads/}" | sed 's#/#-#g' | tr '[:upper:]' '[:lower:]')" + ACTION="build" + SKIP_TESTS="0" + APP_VERSION="" + TAGS="${REF_SLUG}" + REASON="Feature/other branch (${REF}): building with full tests" + ;; +esac + +# Output to stdout +echo "ACTION=${ACTION}" +echo "SKIP_TESTS=${SKIP_TESTS}" +echo "APP_VERSION=${APP_VERSION}" +echo "TAGS=${TAGS}" +echo "SOURCE_IMAGE=${SOURCE_IMAGE}" +echo "SHORT_SHA=${SHORT_SHA}" +echo "SHORT_TREE=${SHORT_TREE}" +echo "REASON=${REASON}" + +# Output to GITHUB_OUTPUT if present +if [ -n "${GITHUB_OUTPUT:-}" ] && [ -w "${GITHUB_OUTPUT}" ]; then + echo "action=${ACTION}" >> "${GITHUB_OUTPUT}" + echo "skip_tests=${SKIP_TESTS}" >> "${GITHUB_OUTPUT}" + echo "app_version=${APP_VERSION}" >> "${GITHUB_OUTPUT}" + echo "tags=${TAGS}" >> "${GITHUB_OUTPUT}" + echo "source_image=${SOURCE_IMAGE}" >> "${GITHUB_OUTPUT}" + echo "short_sha=${SHORT_SHA}" >> "${GITHUB_OUTPUT}" + echo "short_tree=${SHORT_TREE}" >> "${GITHUB_OUTPUT}" + echo "reason=${REASON}" >> "${GITHUB_OUTPUT}" +fi diff --git a/.gitea/scripts/test-build-plan.sh b/.gitea/scripts/test-build-plan.sh new file mode 100755 index 0000000..c0a387d --- /dev/null +++ b/.gitea/scripts/test-build-plan.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PLAN_SCRIPT="${SCRIPT_DIR}/build-plan.sh" +IMAGE="git.dsit.me/sandrodach/mindsift" + +COMMIT_SHA="$(git rev-parse --short=8 HEAD)" +COMMIT_TREE="$(git rev-parse --short=12 HEAD^{tree})" + +# Helper mock check script +MOCK_BIN="${SCRIPT_DIR}/.mock_check.sh" +cat << 'EOF' > "${MOCK_BIN}" +#!/usr/bin/env bash +TARGET="$1" +if [ "${MOCK_MODE}" = "tree_only" ] && [[ "${TARGET}" == *":tree-"* ]]; then + exit 0 +fi +if [ "${MOCK_MODE}" = "sha_only" ] && [[ "${TARGET}" == *":sha-"* ]]; then + exit 0 +fi +if [ "${MOCK_MODE}" = "all" ]; then + exit 0 +fi +exit 1 +EOF +chmod +x "${MOCK_BIN}" +trap 'rm -f "${MOCK_BIN}"' EXIT + +echo "============================================================" +echo "TEST 1: Push to dev" +echo "============================================================" +export MOCK_MODE="none" +REF="refs/heads/dev" COMMIT="HEAD" IMAGE="${IMAGE}" CHECK_IMAGE_CMD="${MOCK_BIN}" "${PLAN_SCRIPT}" + +echo "" +echo "============================================================" +echo "TEST 2: Push/Merge to main (Tree ALREADY tested on dev)" +echo "============================================================" +export MOCK_MODE="tree_only" +REF="refs/heads/main" COMMIT="HEAD" IMAGE="${IMAGE}" CHECK_IMAGE_CMD="${MOCK_BIN}" "${PLAN_SCRIPT}" + +echo "" +echo "============================================================" +echo "TEST 3: Push to main (Tree NOT yet tested)" +echo "============================================================" +export MOCK_MODE="none" +REF="refs/heads/main" COMMIT="HEAD" IMAGE="${IMAGE}" CHECK_IMAGE_CMD="${MOCK_BIN}" "${PLAN_SCRIPT}" + +echo "" +echo "============================================================" +echo "TEST 4: Release Tag v0.10.0 (Main sha image ALREADY exists)" +echo "============================================================" +export MOCK_MODE="sha_only" +REF="refs/tags/v0.10.0" COMMIT="HEAD" IMAGE="${IMAGE}" CHECK_IMAGE_CMD="${MOCK_BIN}" "${PLAN_SCRIPT}" + +echo "" +echo "============================================================" +echo "TEST 5: Release Tag v0.10.0 (Sha NOT found, but Tree exists)" +echo "============================================================" +export MOCK_MODE="tree_only" +REF="refs/tags/v0.10.0" COMMIT="HEAD" IMAGE="${IMAGE}" CHECK_IMAGE_CMD="${MOCK_BIN}" "${PLAN_SCRIPT}" + +echo "" +echo "============================================================" +echo "TEST 6: Release Tag v0.10.0 (Sha NOT found, Tree NOT found)" +echo "============================================================" +export MOCK_MODE="none" +REF="refs/tags/v0.10.0" COMMIT="HEAD" IMAGE="${IMAGE}" CHECK_IMAGE_CMD="${MOCK_BIN}" "${PLAN_SCRIPT}" + +echo "" +echo "============================================================" +echo "TEST 7: Pre-release Tag v0.10.0-rc.1 (Sha image exists)" +echo "============================================================" +export MOCK_MODE="sha_only" +REF="refs/tags/v0.10.0-rc.1" COMMIT="HEAD" IMAGE="${IMAGE}" CHECK_IMAGE_CMD="${MOCK_BIN}" "${PLAN_SCRIPT}" diff --git a/.gitea/workflows/build-and-publish.yaml b/.gitea/workflows/build-and-publish.yaml index 0943af5..da67ce6 100644 --- a/.gitea/workflows/build-and-publish.yaml +++ b/.gitea/workflows/build-and-publish.yaml @@ -1,10 +1,5 @@ name: MindSift CI & Docker Release -# Image tags: -# push to dev -> :dev -# push/merge main -> :latest -# git tag v1.2.0 -> :v1.2.0 and :stable -# git tag v1.2.0-rc.1 (pre-release) -> :v1.2.0-rc.1 only, :stable stays on: push: branches: @@ -21,59 +16,54 @@ jobs: - name: Checkout Code uses: actions/checkout@v4 - - name: Determine Registry & Docker Tag - id: prep - run: | - SERVER_URL="${GITHUB_SERVER_URL:-https://git.dsit.me}" - REGISTRY_HOST=$(echo "${SERVER_URL}" | sed -E 's#^https?://##' | sed 's#/$##') - if [ -z "${REGISTRY_HOST}" ]; then - REGISTRY_HOST="git.dsit.me" - fi - - REPO_PATH="${GITHUB_REPOSITORY:-sandrodach/mindsift}" - IMAGE_NAME=$(echo "${REGISTRY_HOST}/${REPO_PATH}" | tr '[:upper:]' '[:lower:]') - REF_NAME="${GITHUB_REF_NAME:-latest}" - ACTOR_NAME="${GITHUB_ACTOR:-sandrodach}" - - # Ersetze evtl. Slashes in Branch-Namen (z.B. rc/1.1 -> rc-1.1) für valide Docker-Tags - DOCKER_TAG=$(echo "${REF_NAME}" | sed 's#/#-#g' | tr '[:upper:]' '[:lower:]') - - # All tags of this run (space separated) - case "${GITHUB_REF:-}" in - refs/tags/*-*) TAGS="${DOCKER_TAG}" ;; - refs/tags/*) TAGS="${DOCKER_TAG} stable" ;; - refs/heads/main) TAGS="latest" ;; - *) TAGS="${DOCKER_TAG}" ;; - esac - - echo "registry=${REGISTRY_HOST}" >> "$GITHUB_OUTPUT" - echo "image=${IMAGE_NAME}" >> "$GITHUB_OUTPUT" - echo "tag=${TAGS%% *}" >> "$GITHUB_OUTPUT" - echo "tags=${TAGS}" >> "$GITHUB_OUTPUT" - echo "actor=${ACTOR_NAME}" >> "$GITHUB_OUTPUT" - # 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})" - - name: Login to Gitea Container Registry uses: docker/login-action@v3 with: - registry: ${{ steps.prep.outputs.registry }} - username: ${{ steps.prep.outputs.actor }} + registry: ${{ vars.REGISTRY_HOST || 'git.dsit.me' }} + username: ${{ github.actor }} password: ${{ secrets.PUSH_TOKEN || secrets.GITEA_TOKEN || github.token }} - - name: Build & Run All Checks in Container, then Push + - name: Calculate Build Plan + id: plan run: | - IMAGE="${{ steps.prep.outputs.image }}" - TAG_ARGS="" - for t in ${{ steps.prep.outputs.tags }}; do TAG_ARGS="${TAG_ARGS} -t ${IMAGE}:${t}"; done - echo "🚀 Building container & executing Frontend/Backend checks inside image: ${IMAGE} (tags: ${{ steps.prep.outputs.tags }})" - DOCKER_BUILDKIT=1 docker build --progress=plain --build-arg APP_VERSION="${{ steps.prep.outputs.app_version }}" ${TAG_ARGS} . - echo "✅ All checks & tests passed inside container! Pushing..." - for t in ${{ steps.prep.outputs.tags }}; do docker push "${IMAGE}:${t}"; done + SERVER_URL="${GITHUB_SERVER_URL:-https://git.dsit.me}" + REGISTRY_HOST=$(echo "${SERVER_URL}" | sed -E 's#^https?://##' | sed 's#/$##') + [ -z "${REGISTRY_HOST}" ] && REGISTRY_HOST="git.dsit.me" + REPO_PATH="${GITHUB_REPOSITORY:-sandrodach/mindsift}" + IMAGE_NAME=$(echo "${REGISTRY_HOST}/${REPO_PATH}" | tr '[:upper:]' '[:lower:]') + + echo "registry=${REGISTRY_HOST}" >> "$GITHUB_OUTPUT" + echo "image=${IMAGE_NAME}" >> "$GITHUB_OUTPUT" + + REF="${GITHUB_REF:-}" COMMIT="${GITHUB_SHA:-HEAD}" IMAGE="${IMAGE_NAME}" .gitea/scripts/build-plan.sh + + - name: Execute Build or Retag & Push + run: | + IMAGE="${{ steps.plan.outputs.image }}" + ACTION="${{ steps.plan.outputs.action }}" + TAGS="${{ steps.plan.outputs.tags }}" + + if [ "${ACTION}" = "retag" ]; then + SOURCE="${{ steps.plan.outputs.source_image }}" + echo "🏷️ Re-tagging existing image ${SOURCE} without rebuilding (Reason: ${{ steps.plan.outputs.reason }})" + docker pull "${SOURCE}" + for t in ${TAGS}; do + docker tag "${SOURCE}" "${IMAGE}:${t}" + docker push "${IMAGE}:${t}" + done + else + SKIP_TESTS="${{ steps.plan.outputs.skip_tests }}" + APP_VERSION="${{ steps.plan.outputs.app_version }}" + TAG_ARGS="" + for t in ${TAGS}; do TAG_ARGS="${TAG_ARGS} -t ${IMAGE}:${t}"; done + + echo "🚀 Building container (SKIP_TESTS=${SKIP_TESTS}, APP_VERSION=${APP_VERSION}) for tags: ${TAGS}" + echo "ℹ️ Reason: ${{ steps.plan.outputs.reason }}" + DOCKER_BUILDKIT=1 docker build --progress=plain \ + --build-arg APP_VERSION="${APP_VERSION}" \ + --build-arg SKIP_TESTS="${SKIP_TESTS}" \ + ${TAG_ARGS} . + + echo "✅ Build complete! Pushing..." + for t in ${TAGS}; do docker push "${IMAGE}:${t}"; done + fi diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c006ca..1cd9887 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,11 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V - „Über MindSift“-Dialog und Versionsanzeige im mobilen Profil-Menü (Avatar-Menü oben rechts). - PWA Web Share Target: Text, Links und Bilder können über den nativen Teilen-Dialog des Smartphones direkt in den MindSift-Posteingang geteilt werden (`share_target` im Manifest, Abfangen im Service Worker via IndexedDB und Übernahme beim App-Start; Fallback-Route `POST /share-target` im Server). +### Changed +- CI/CD & Pipeline: Schlankere Builds ohne Dreifach-Kompilierung. Bei Release-Tags wird das bestehende `main`-Image (`:sha-`) per Umtaggen direkt als `:vX.Y.Z` und `:stable` weiterverwendet. +- CI/CD: Bedingte Testausführung über `ARG SKIP_TESTS=0` im Dockerfile; überspringt Frontend- und Backend-Tests auf `main`, wenn der Git-Tree (`:tree-`) bereits auf `dev` getestet wurde. +- Build-Caching: BuildKit-Caches für Go-Module (`/go/pkg/mod`, `/root/.cache/go-build`) und npm (`/root/.npm`) im Dockerfile aktiviert. + ## [0.10.0] - 2026-10-30 ### Added diff --git a/Dockerfile b/Dockerfile index 04f7a85..530d168 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,11 +4,17 @@ FROM node:22-alpine AS frontend-builder WORKDIR /app/frontend COPY frontend/package*.json ./ -RUN npm install --no-audit --no-fund +RUN --mount=type=cache,target=/root/.npm npm install --no-audit --no-fund COPY frontend/ ./ # Version shown in the header: the git tag for releases (set by CI), otherwise "-dev" ARG APP_VERSION="" -RUN npx tsc --noEmit && npm run lint && npm run test:run && APP_VERSION="${APP_VERSION}" npm run build +ARG SKIP_TESTS=0 +RUN if [ "$SKIP_TESTS" != "1" ]; then \ + npx tsc --noEmit && npm run lint && npm run test:run; \ + else \ + echo "Skipping frontend tests (SKIP_TESTS=1)"; \ + fi && \ + APP_VERSION="${APP_VERSION}" npm run build # ============================================================================== # Stage 2: Go Backend Lint, Unit Tests & Binary Build (Cached Go module & sqlite layer) @@ -19,7 +25,8 @@ RUN apk add --no-cache git ca-certificates # Cache Go modules & pre-compile heavy dependencies (modernc.org/sqlite) in a dedicated layer COPY go.mod ./ -RUN go get github.com/go-chi/chi/v5@v5.2.0 \ +RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build \ + go get github.com/go-chi/chi/v5@v5.2.0 \ github.com/go-chi/cors@v1.2.1 \ github.com/golang-jwt/jwt/v5@v5.2.1 \ github.com/gorilla/websocket@v1.5.3 \ @@ -38,10 +45,16 @@ COPY cmd/ ./cmd/ COPY internal/ ./internal/ COPY --from=frontend-builder /app/frontend/dist ./cmd/server/dist +ARG SKIP_TESTS=0 # Run go mod tidy, static analysis (go vet), unit tests (go test), and compile final binary -RUN go mod tidy && \ - CGO_ENABLED=0 go vet ./... && \ - CGO_ENABLED=0 go test -v ./... && \ +RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build \ + go mod tidy && \ + if [ "$SKIP_TESTS" != "1" ]; then \ + CGO_ENABLED=0 go vet ./... && \ + CGO_ENABLED=0 go test -v ./...; \ + else \ + echo "Skipping backend tests (SKIP_TESTS=1)"; \ + fi && \ CGO_ENABLED=0 go build -ldflags="-s -w" -o /app/mindsift ./cmd/server # ============================================================================== From 1a49e6b304a7c2938bb9b81ee8cb8c474d9c35ce Mon Sep 17 00:00:00 2001 From: Sandro Date: Fri, 30 Oct 2026 20:42:14 +0100 Subject: [PATCH 06/15] feat(workspace): links to single bullets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - copy a link to a bullet from its action bubble or pick a bullet in the link dialog; [[Note#^id|Note › excerpt]] opens the note, jumps to and highlights the bullet, or says it is gone - wiki links render inline with their label and are clickable; the duplicate chip below the text is gone test(e2e): run each suite with its own container, image tag and port, so parallel runs from another working copy no longer remove our server Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 + README.md | 2 +- e2e/run.sh | 34 +++-- .../src/features/workspace/DocumentEditor.tsx | 33 ++++- .../src/features/workspace/JournalView.tsx | 2 +- frontend/src/features/workspace/LinkModal.tsx | 117 ++++++++++++++++-- .../src/features/workspace/OutlinerBlock.tsx | 40 +++--- .../src/features/workspace/OutlinerTree.tsx | 5 +- .../src/features/workspace/WorkspaceView.tsx | 8 +- frontend/src/lib/__tests__/wikiLink.test.ts | 21 ++++ frontend/src/lib/markdown.tsx | 31 ++++- frontend/src/lib/wikiLink.ts | 33 +++++ 12 files changed, 284 insertions(+), 45 deletions(-) create mode 100644 frontend/src/lib/__tests__/wikiLink.test.ts create mode 100644 frontend/src/lib/wikiLink.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e10368..77f7efe 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] +### Added +- Links auf einzelne Punkte: „Link zu diesem Punkt kopieren“ in der Aktionsblase eines Punkts oder im Link-Dialog (`/link`) über „Punkt dieser Notiz wählen“. Format `[[Notiz#^id|Notiz › Auszug]]`; ein Klick öffnet die Notiz, springt zum Punkt und hebt ihn hervor. Gibt es den Punkt nicht mehr (verschoben, ausgelagert, gelöscht), öffnet sich die Notiz mit einem Hinweis. Wiki-Links stehen im Text jetzt mit ihrem Anzeigetext und sind direkt anklickbar. + ### Changed - Workspace am Desktop (ab 1024 px): Die eigene Kopfleiste der Notiz entfällt. Rückgängig, Wiederholen, Teilen und das Drei-Punkte-Menü stehen oben in der Workspace-Leiste, in fester Reihenfolge: Avatare, Live, Geteilt, Speicherstatus (nur noch als Symbol, Text als Tooltip), dann die Knöpfe. Beim Fokus-Modus stehen die Breadcrumbs über dem Titel. Am Handy und Tablet bleibt die Kopfleiste. - Notizen-Seitenleiste: Überschrift „Notizen & Outliner“ entfernt, Suche und die Knöpfe für neuen Ordner und neue Notiz stehen in einer Zeile. diff --git a/README.md b/README.md index 629ac9c..80c084c 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ Die meisten Notiz- und Aufgaben-Apps verlangen Struktur im falschen Moment: Wenn * **Bilder & Audio in Notizen:** `/image` (Datei, Kamera, Zwischenablage oder Link), `/audio` (Sprachnotiz oder Datei-Upload) und `/diktat` (Live-Transkription). * **Link-Vorschau:** Automatische Vorschaukarten über Punkten mit reiner Web-Adresse. * **Ast auslagern & Notizen zusammenführen:** Bulletpoints samt Unterbaum in eigenständige Notizen auslagern (`/extract`) oder Notizen per Drag & Drop bzw. Menü zusammenführen (jeweils mit vollständiger Rückgängig-Funktion). -* Wiki-Links `[[Notiz]]`, Links und `#Hashtags`; Tag-Seiten bündeln alle Fundstellen. +* Wiki-Links `[[Notiz]]`, auch auf **einzelne Punkte** (`[[Notiz#^id|Text]]`, kopierbar an jedem Punkt), Links und `#Hashtags`; Tag-Seiten bündeln alle Fundstellen. * Ordner, Favoriten, **To-Do-Rollup** über alle Notizen und Export einer Notiz als `.md`. * Einfügen von mehrzeiligem Markdown wird in Blöcke zerlegt; `Strg+Z` / `Strg+Umschalt+Z` nimmt eigene Änderungen zurück bzw. stellt sie wieder her. * **Teilen:** Notizen und ganze Ordner lassen sich Benutzern oder Gruppen als **Bearbeiter** oder **Leser** freigeben. Empfänger sortieren sie in ihren eigenen Baum ein. diff --git a/e2e/run.sh b/e2e/run.sh index 5152375..7a3fbb6 100755 --- a/e2e/run.sh +++ b/e2e/run.sh @@ -1,18 +1,36 @@ #!/usr/bin/env bash -# Builds MindSift, starts it in demo mode on port 18080 with a fresh database and runs the browser tests. +# Builds MindSift, starts it in demo mode with a fresh database and runs the browser tests. # Requires podman (or docker via ENGINE=docker). Usage: e2e/run.sh +# Every run uses its own container, image tag and port, so runs from several working copies at the +# same time cannot remove or test each other's server. set -euo pipefail ENGINE="${ENGINE:-podman}" ROOT="$(cd "$(dirname "$0")/.." && pwd)" WORK="$(mktemp -d)" -trap '"$ENGINE" rm -f mindsift-e2e >/dev/null 2>&1 || true; rm -rf "$WORK"' EXIT +RUN_ID="$$-$RANDOM" +NAME="mindsift-e2e-$RUN_ID" +IMAGE="mindsift-e2e-image:$RUN_ID" +# A free port above 18000 (E2E_PORT overrides) +PORT="${E2E_PORT:-}" +if [ -z "$PORT" ]; then + for p in $(shuf -i 18100-18999 -n 50); do + if ! (exec 3<>"/dev/tcp/127.0.0.1/$p") 2>/dev/null; then PORT="$p"; break; fi + done +fi +cleanup() { + "$ENGINE" rm -f "$NAME" >/dev/null 2>&1 || true + "$ENGINE" rmi "$IMAGE" >/dev/null 2>&1 || true + rm -rf "$WORK" +} +trap cleanup EXIT echo "Baue Image …" -"$ENGINE" build -q -t mindsift-e2e-image "$ROOT" >/dev/null -"$ENGINE" run -d --name mindsift-e2e --network host -e PORT=18080 -e MINDA_DEMO=true \ - -e JWT_SECRET=e2e-only-secret -v "$WORK":/data:Z mindsift-e2e-image >/dev/null -for _ in $(seq 1 60); do curl -s -m 1 http://localhost:18080/api/v1/health >/dev/null && break; sleep 0.5; done +"$ENGINE" build -q -t "$IMAGE" "$ROOT" >/dev/null +"$ENGINE" run -d --name "$NAME" --network host -e PORT="$PORT" -e MINDA_DEMO=true \ + -e JWT_SECRET=e2e-only-secret -v "$WORK":/data:Z "$IMAGE" >/dev/null +for _ in $(seq 1 60); do curl -s -m 1 "http://localhost:$PORT/api/v1/health" >/dev/null && break; sleep 0.5; done -echo "Starte Browser-Tests …" -"$ENGINE" run --rm --network host -v "$ROOT/e2e":/e2e:Z -w /e2e mcr.microsoft.com/playwright:v1.48.2-jammy \ +echo "Starte Browser-Tests (Port $PORT) …" +"$ENGINE" run --rm --network host -e MINDSIFT_URL="http://localhost:$PORT" -v "$ROOT/e2e":/e2e:Z -w /e2e \ + mcr.microsoft.com/playwright:v1.48.2-jammy \ sh -c 'npm install --no-audit --no-fund >/dev/null 2>&1 && node collab.e2e.mjs && node share.e2e.mjs && node lock.e2e.mjs && node channels.e2e.mjs' diff --git a/frontend/src/features/workspace/DocumentEditor.tsx b/frontend/src/features/workspace/DocumentEditor.tsx index 0d57749..d416af6 100644 --- a/frontend/src/features/workspace/DocumentEditor.tsx +++ b/frontend/src/features/workspace/DocumentEditor.tsx @@ -1,5 +1,6 @@ import React, { useState, useEffect, useRef, useCallback, useMemo, useSyncExternalStore } from 'react'; import { createPortal } from 'react-dom'; +import { buildBlockLink } from '../../lib/wikiLink'; import { Pin, Users, @@ -51,7 +52,7 @@ export interface DocumentEditorProps { onRestoreDoc?: (docId: string) => void; onOpenShareModal: (doc: Document) => void; onSelectTag: (tag: string) => void; - onWikiLinkClick?: (pageTitle: string) => void; + onWikiLinkClick?: (pageTitle: string, blockId?: string) => void; onOpenDocBlock?: (docId: string, blockId?: string) => void; onBackMobile?: () => void; onOpenTask?: (taskId: string) => void; @@ -495,6 +496,34 @@ export const DocumentEditor: React.FC = ({ }, actions.length ? 8000 : 3500); }, []); + // Link to a single bullet: [[Titel#^id|Titel › Auszug]] into the clipboard + const handleCopyBlockLink = useCallback( + (blockId: string) => { + const block = blocks.find((b) => b.id === blockId); + const link = buildBlockLink(title || doc?.title || 'Notiz', blockId, block?.content || ''); + const done = () => showNotice('Link zum Punkt kopiert – mit Strg+V in eine Notiz einfügen'); + if (navigator.clipboard?.writeText) { + navigator.clipboard.writeText(link).then(done, () => showNotice(`Kopieren nicht möglich: ${link}`)); + } else { + showNotice(`Kopieren nicht möglich: ${link}`); + } + }, + [blocks, title, doc?.title, showNotice] + ); + + // A link pointed to a bullet that is gone (moved, extracted, deleted): the note opens with a hint + const blocksForTargetRef = useRef(blocks); + blocksForTargetRef.current = blocks; + useEffect(() => { + if (!initialFocusedBlockId || !doc?.id) return; + const timer = window.setTimeout(() => { + if (!blocksForTargetRef.current.some((b) => b.id === initialFocusedBlockId)) { + showNotice('Der verlinkte Punkt ist in dieser Notiz nicht mehr vorhanden'); + } + }, 1500); + return () => window.clearTimeout(timer); + }, [initialFocusedBlockId, doc?.id, showNotice]); + // The last extraction, so undoing it (toast or Strg+Z) also puts the new note into the trash const lastExtractRef = useRef<{ newDocId: string; blockId: string; link: string } | null>(null); const finishUndoExtract = useCallback(() => { @@ -1188,6 +1217,7 @@ export const DocumentEditor: React.FC = ({ onOpenTask={onOpenTask} onTagClick={onSelectTag} onWikiLinkClick={onWikiLinkClick} + onCopyBlockLink={handleCopyBlockLink} initialFocusedBlockId={initialFocusedBlockId} onFocusedBlockChange={(id) => engineRef.current?.focusBlock(id)} lockedBlocks={lockedBlocks} @@ -1277,6 +1307,7 @@ export const DocumentEditor: React.FC = ({ onOpenTask={onOpenTask} onTagClick={onSelectTag} onWikiLinkClick={onWikiLinkClick} + onCopyBlockLink={handleCopyBlockLink} initialFocusedBlockId={initialFocusedBlockId} onFocusedBlockChange={(id) => engineRef.current?.focusBlock(id)} lockedBlocks={lockedBlocks} diff --git a/frontend/src/features/workspace/JournalView.tsx b/frontend/src/features/workspace/JournalView.tsx index 83bbd62..174bac7 100644 --- a/frontend/src/features/workspace/JournalView.tsx +++ b/frontend/src/features/workspace/JournalView.tsx @@ -25,7 +25,7 @@ interface JournalViewProps { onDocMetadataChange: (updated: Document) => void; onDeleteDoc: (docId: string) => void; onSelectTag: (tag: string) => void; - onWikiLinkClick?: (pageTitle: string) => void; + onWikiLinkClick?: (pageTitle: string, blockId?: string) => void; onOpenDocBlock?: (docId: string, blockId?: string) => void; onOpenTask?: (taskId: string) => void; onBackMobile: () => void; diff --git a/frontend/src/features/workspace/LinkModal.tsx b/frontend/src/features/workspace/LinkModal.tsx index 2f31dcf..c0f6c5b 100644 --- a/frontend/src/features/workspace/LinkModal.tsx +++ b/frontend/src/features/workspace/LinkModal.tsx @@ -8,9 +8,12 @@ import { ExternalLink, Loader2, Plus, + ArrowLeft, + ListTree, } from 'lucide-react'; import { api } from '../../lib/api'; -import { Document } from '../../lib/types'; +import { Document, DocumentBlock } from '../../lib/types'; +import { buildBlockLink } from '../../lib/wikiLink'; export interface LinkModalProps { isOpen: boolean; @@ -100,6 +103,37 @@ export const LinkModal: React.FC = ({ ); }, [docs, search]); + // Picking a single bullet of a note (link to that bullet) + const [pickDoc, setPickDoc] = useState(null); + const [pickBlocks, setPickBlocks] = useState(null); + const [blockSearch, setBlockSearch] = useState(''); + useEffect(() => { + if (!isOpen) { + setPickDoc(null); + setPickBlocks(null); + setBlockSearch(''); + } + }, [isOpen]); + const openBlockPicker = (doc: Document) => { + setPickDoc(doc); + setBlockSearch(''); + if (doc.blocks && doc.blocks.length > 0) { + setPickBlocks(doc.blocks); + return; + } + setPickBlocks(null); + api + .getDocument(doc.id) + .then((full) => setPickBlocks(full.blocks || [])) + .catch(() => setPickBlocks([])); + }; + const pickableBlocks = useMemo(() => { + const q = blockSearch.trim().toLowerCase(); + return (pickBlocks || []).filter( + (b) => b.content.trim() !== '' && b.type !== 'divider' && (!q || b.content.toLowerCase().includes(q)) + ); + }, [pickBlocks, blockSearch]); + const handleSelectNote = (doc: { title: string }) => { const noteTitle = doc.title.trim() || 'Notiz'; onInsertLink(`[[${noteTitle}]]`); @@ -244,8 +278,59 @@ export const LinkModal: React.FC = ({ )}
+ {/* Bullets of one note: link to a single bullet */} + {tab === 'note' && pickDoc && ( +
+
+ + + Punkt in „{pickDoc.title || 'Unbenannte Notiz'}“ wählen + +
+ setBlockSearch(e.target.value)} + placeholder="Punkte filtern…" + className="input-field w-full h-11 sm:h-8 px-2.5 text-xs" + aria-label="Punkte filtern" + /> +
+ {pickBlocks === null ? ( +
+ + Punkte werden geladen… +
+ ) : pickableBlocks.length === 0 ? ( +
Keine passenden Punkte.
+ ) : ( + pickableBlocks.map((b) => ( + + )) + )} +
+
+ )} + {/* Note List Section (Matching Active Shares list in UnifiedShareModal) */} - {tab === 'note' && ( + {tab === 'note' && !pickDoc && (
Verfügbare Notizen @@ -289,13 +374,27 @@ export const LinkModal: React.FC = ({ )}
- +
+ + +
)) )} diff --git a/frontend/src/features/workspace/OutlinerBlock.tsx b/frontend/src/features/workspace/OutlinerBlock.tsx index 699d99b..d17d65b 100644 --- a/frontend/src/features/workspace/OutlinerBlock.tsx +++ b/frontend/src/features/workspace/OutlinerBlock.tsx @@ -18,13 +18,13 @@ import { ExternalLink, Maximize2, Link as LinkIcon, - FileText, Lock, AlertTriangle, Image as ImageIcon, Mic, AudioLines, FileOutput, + Link2, Type, } from 'lucide-react'; import { DocumentBlock, BlockType } from '../../lib/types'; @@ -94,7 +94,9 @@ export interface OutlinerBlockProps { onBlurBlock?: (blockId: string) => void; onOpenTask?: (taskId: string) => void; onTagClick?: (tag: string) => void; - onWikiLinkClick?: (pageTitle: string) => void; + onWikiLinkClick?: (pageTitle: string, blockId?: string) => void; + /** Copies a link to this bullet ([[Notiz#^id|…]]) */ + onCopyBlockLink?: (blockId: string) => void; onDragStartBlock?: (index: number) => void; onDragOverBlock?: (e: React.DragEvent, index: number) => void; onDropBlock?: (index: number) => void; @@ -144,6 +146,7 @@ export const OutlinerBlock: React.FC = ({ onOpenTask, onTagClick, onWikiLinkClick, + onCopyBlockLink, onDragStartBlock, onDragOverBlock, onDropBlock, @@ -1065,9 +1068,9 @@ export const OutlinerBlock: React.FC = ({ }, [localContent]); // #Hashtags are rendered inline inside the text, so only links need the badge bar below + // Wiki links are readable and clickable in the text itself, so only web links get a chip const hasSpecialTokens = tokens.some( (t) => - (t.startsWith('[[') && t.endsWith(']]')) || /^\[(.*?)\]\((https?:\/\/.*?)\)$/.test(t) || (/^https?:\/\//.test(t) && t !== previewUrl) ); @@ -1384,7 +1387,7 @@ export const OutlinerBlock: React.FC = ({ > {/* When actively focused, ghost text is invisible so textarea renders text/caret directly */} - {localContent ? renderInlineMarkdown(localContent, { onTagClick }) : '\u200b'} + {localContent ? renderInlineMarkdown(localContent, { onTagClick, onWikiLinkClick }) : '\u200b'}
@@ -1504,21 +1507,7 @@ export const OutlinerBlock: React.FC = ({ {hasSpecialTokens && !isFocused && (
{tokens.map((part, idx) => { - if (part.startsWith('[[') && part.endsWith(']]')) { - const page = part.slice(2, -2).trim(); - return ( - - ); - } + if (part.startsWith('[[')) return null; const mdMatch = part.match(/^\[(.*?)\]\((https?:\/\/.*?)\)$/); if (mdMatch && safeHref(mdMatch[2])) { const label = mdMatch[1]; @@ -1727,6 +1716,19 @@ export const OutlinerBlock: React.FC = ({
)} + {/* Link to this bullet */} + {onCopyBlockLink && localContent.trim() !== '' && !mediaPicker && ( + + )} + {/* Branch into its own note */} {canEdit && onExtractBlock && localContent.trim() !== '' && !mediaPicker && ( +
+
+ {pushDevices.length > 0 && ( +

+ Aktiv auf {pushDevices.length} {pushDevices.length === 1 ? 'Gerät' : 'Geräten'}:{' '} + {pushDevices.map((d) => d.device || 'Gerät').join(', ')} +

+ )} + {pushMsg && ( +

{pushMsg.text}

+ )} +
+
)} + {onToggleFocusMode && ( + + )} + +
+ + + + )} {/* LEFT MASTER SIDEBAR */}