Dev #3
No files matched your search
@@ -106,6 +106,9 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
const [workspaceTags, setWorkspaceTags] = useState<TagSummary[]>([]);
|
const [workspaceTags, setWorkspaceTags] = useState<TagSummary[]>([]);
|
||||||
const [isLoadingTag, setIsLoadingTag] = useState(false);
|
const [isLoadingTag, setIsLoadingTag] = useState(false);
|
||||||
const [tagSearchQuery, setTagSearchQuery] = useState('');
|
const [tagSearchQuery, setTagSearchQuery] = useState('');
|
||||||
|
const [tagNoteContent, setTagNoteContent] = useState('');
|
||||||
|
const [tagNoteSaveState, setTagNoteSaveState] = useState<'saved' | 'saving'>('saved');
|
||||||
|
const tagNoteSaveTimerRef = useRef<any>(null);
|
||||||
|
|
||||||
// Collapsible sidebar sections
|
// Collapsible sidebar sections
|
||||||
const [isPinnedCollapsed, setIsPinnedCollapsed] = useState(false);
|
const [isPinnedCollapsed, setIsPinnedCollapsed] = useState(false);
|
||||||
@@ -412,12 +415,18 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
|
|
||||||
const openTagPage = async (tag: string) => {
|
const openTagPage = async (tag: string) => {
|
||||||
const clean = tag.replace(/^#/, '').toLowerCase();
|
const clean = tag.replace(/^#/, '').toLowerCase();
|
||||||
|
if (tagNoteSaveTimerRef.current) {
|
||||||
|
clearTimeout(tagNoteSaveTimerRef.current);
|
||||||
|
tagNoteSaveTimerRef.current = null;
|
||||||
|
}
|
||||||
setSelectedTag(clean);
|
setSelectedTag(clean);
|
||||||
setWorkspaceMode('tags');
|
setWorkspaceMode('tags');
|
||||||
setIsLoadingTag(true);
|
setIsLoadingTag(true);
|
||||||
try {
|
try {
|
||||||
const details = await api.getTagDetails(clean);
|
const details = await api.getTagDetails(clean);
|
||||||
setTagDetails(details);
|
setTagDetails(details);
|
||||||
|
setTagNoteContent(details.noteContent || '');
|
||||||
|
setTagNoteSaveState('saved');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load tag details', err);
|
console.error('Failed to load tag details', err);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -425,6 +434,40 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleTagNoteChange = (newContent: string) => {
|
||||||
|
setTagNoteContent(newContent);
|
||||||
|
if (!selectedTag) return;
|
||||||
|
const targetTag = selectedTag;
|
||||||
|
setTagNoteSaveState('saving');
|
||||||
|
if (tagNoteSaveTimerRef.current) {
|
||||||
|
clearTimeout(tagNoteSaveTimerRef.current);
|
||||||
|
}
|
||||||
|
tagNoteSaveTimerRef.current = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
await api.updateTagPageNote(targetTag, newContent);
|
||||||
|
setTagDetails((prev) =>
|
||||||
|
prev && prev.tag === targetTag ? { ...prev, noteContent: newContent } : prev
|
||||||
|
);
|
||||||
|
setTagNoteSaveState('saved');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to save tag note', err);
|
||||||
|
setTagNoteSaveState('saved');
|
||||||
|
}
|
||||||
|
}, 450);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExportTagMarkdown = () => {
|
||||||
|
if (!selectedTag) return;
|
||||||
|
const md = `# #${selectedTag}\n\n${tagNoteContent || ''}\n`;
|
||||||
|
const blob = new Blob([md], { type: 'text/markdown;charset=utf-8' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `tag-${selectedTag}.md`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadDocs(initialDocId);
|
loadDocs(initialDocId);
|
||||||
}, [initialDocId]);
|
}, [initialDocId]);
|
||||||
@@ -3605,43 +3648,45 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Header Toolbar */}
|
{/* Header Toolbar */}
|
||||||
<div className="p-4 panel-surface space-y-3 border border-border rounded-card">
|
<div className="p-4 panel-surface space-y-3 border border-border rounded-card">
|
||||||
|
{selectedTag && (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedTag(null)}
|
||||||
|
className="inline-flex items-center gap-1 text-[11px] font-medium text-muted hover:text-accent transition-colors cursor-pointer"
|
||||||
|
title="Zurück zur Themen-Übersicht"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-3 h-3 text-accent" />
|
||||||
|
<span>Alle Themen</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2.5">
|
||||||
{selectedTag ? (
|
{selectedTag ? (
|
||||||
<>
|
<>
|
||||||
<button
|
<div className="w-9 h-9 rounded-lg bg-blue-500/10 border border-blue-500/30 flex items-center justify-center text-blue-500 dark:text-blue-400 font-bold flex-shrink-0">
|
||||||
type="button"
|
<Hash className="w-5 h-5" />
|
||||||
onClick={() => setSelectedTag(null)}
|
</div>
|
||||||
className="btn-action h-8 px-2.5 text-xs flex items-center gap-1.5"
|
<div>
|
||||||
title="Zurück zur Themen-Übersicht"
|
<h2 className="text-base font-bold text-main leading-tight flex items-center gap-1.5">
|
||||||
>
|
<span>#{selectedTag}</span>
|
||||||
<ArrowLeft className="w-3.5 h-3.5 text-accent" />
|
</h2>
|
||||||
<span>Alle Themen</span>
|
<p className="text-[11px] text-muted">
|
||||||
</button>
|
{isLoadingTag
|
||||||
<div className="h-4 w-px bg-border" />
|
? 'Wird geladen…'
|
||||||
<div className="flex items-center gap-2">
|
: `${tagDetails?.totalMatches || 0} ${
|
||||||
<div className="w-8 h-8 rounded-lg bg-blue-500/10 border border-blue-500/30 flex items-center justify-center text-blue-500 dark:text-blue-400 font-bold">
|
tagDetails?.totalMatches === 1 ? 'Abschnitt' : 'Abschnitte'
|
||||||
<Hash className="w-4 h-4" />
|
} in ${tagDetails?.documents.length || 0} ${
|
||||||
</div>
|
tagDetails?.documents.length === 1 ? 'Notiz' : 'Notizen'
|
||||||
<div>
|
}`}
|
||||||
<h2 className="text-base font-bold text-main leading-tight flex items-center gap-1.5">
|
</p>
|
||||||
<span>#{selectedTag}</span>
|
|
||||||
</h2>
|
|
||||||
<p className="text-[11px] text-muted">
|
|
||||||
{isLoadingTag
|
|
||||||
? 'Wird geladen…'
|
|
||||||
: `${tagDetails?.totalMatches || 0} ${
|
|
||||||
tagDetails?.totalMatches === 1 ? 'Abschnitt' : 'Abschnitte'
|
|
||||||
} in ${tagDetails?.documents.length || 0} ${
|
|
||||||
tagDetails?.documents.length === 1 ? 'Notiz' : 'Notizen'
|
|
||||||
}`}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-2.5">
|
<>
|
||||||
<div className="w-9 h-9 rounded-lg bg-accent/10 border border-accent/30 flex items-center justify-center text-accent">
|
<div className="w-9 h-9 rounded-lg bg-accent/10 border border-accent/30 flex items-center justify-center text-accent flex-shrink-0">
|
||||||
<Hash className="w-5 h-5" />
|
<Hash className="w-5 h-5" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -3650,7 +3695,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
{workspaceTags.length} {workspaceTags.length === 1 ? 'Thema' : 'Themen'} im gesamten Workspace
|
{workspaceTags.length} {workspaceTags.length === 1 ? 'Thema' : 'Themen'} im gesamten Workspace
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -3760,147 +3805,203 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
<Sparkles className="w-6 h-6 animate-spin mx-auto mb-2 text-accent" />
|
<Sparkles className="w-6 h-6 animate-spin mx-auto mb-2 text-accent" />
|
||||||
Hashtag-Verknüpfungen werden geladen…
|
Hashtag-Verknüpfungen werden geladen…
|
||||||
</div>
|
</div>
|
||||||
) : !tagDetails || tagDetails.documents.length === 0 ? (
|
|
||||||
<div className="p-12 text-center text-muted text-xs panel-surface space-y-2">
|
|
||||||
<Hash className="w-8 h-8 mx-auto text-faint" />
|
|
||||||
<p className="font-medium text-main">Keine Bullet Points mit #{selectedTag} gefunden</p>
|
|
||||||
<p className="text-xs text-faint">
|
|
||||||
Füge einfach in irgendeiner Notiz <code className="text-accent">#{selectedTag}</code> in einen Bullet Point ein.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{tagDetails.documents.map((docGroup) => (
|
{/* Dedicated Logseq-Style Hashtag Markdown Note Card (#tag.md) */}
|
||||||
<div
|
<div className="panel-surface p-4 space-y-2.5 border border-border rounded-card">
|
||||||
key={docGroup.docId}
|
<div className="flex flex-wrap items-center justify-between gap-2 pb-2 border-b border-border">
|
||||||
className="panel-surface p-4 space-y-3 border border-border rounded-card hover:border-border/80 transition-colors"
|
<div className="flex items-center gap-2">
|
||||||
>
|
<FileText className="w-3.5 h-3.5 text-accent" />
|
||||||
{/* Document Header */}
|
<span className="text-xs font-bold text-main">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2 pb-2 border-b border-border">
|
Eigene Notiz zu #{selectedTag}
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
</span>
|
||||||
<span className="text-base flex-shrink-0">{docGroup.icon || '📄'}</span>
|
<span className="badge-neutral text-[10px] font-mono">
|
||||||
<span className="font-semibold text-xs text-main truncate">{docGroup.title || 'Unbenannt'}</span>
|
#{selectedTag}.md
|
||||||
{docGroup.folderPath && (
|
</span>
|
||||||
<span className="badge-neutral text-[10px] flex items-center gap-1 font-mono">
|
</div>
|
||||||
<Folder className="w-2.5 h-2.5" />
|
<div className="flex items-center gap-2">
|
||||||
<span>{docGroup.folderPath}</span>
|
<span className="text-[10px] text-faint font-mono">
|
||||||
</span>
|
{tagNoteSaveState === 'saving' ? 'Speichert…' : 'Gespeichert'}
|
||||||
)}
|
</span>
|
||||||
<span className="text-[10px] text-faint">
|
|
||||||
• {docGroup.matchedBlocks.length}{' '}
|
|
||||||
{docGroup.matchedBlocks.length === 1 ? 'Treffer' : 'Treffer'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={handleExportTagMarkdown}
|
||||||
const found = docs.find((d) => d.id === docGroup.docId);
|
className="btn-action h-6 px-2 text-[11px] flex items-center gap-1"
|
||||||
if (found) {
|
title={`#${selectedTag}.md herunterladen`}
|
||||||
setSelectedDoc(found);
|
|
||||||
setZoomedBlockId(null);
|
|
||||||
setWorkspaceMode('editor');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="px-2.5 py-1 text-xs font-medium text-accent hover:text-main bg-accent/10 hover:bg-accent/20 rounded-btn flex items-center gap-1 transition-colors"
|
|
||||||
>
|
>
|
||||||
<span>Notiz öffnen</span>
|
<Download className="w-3 h-3" />
|
||||||
<ExternalLink className="w-3 h-3" />
|
<span>.md</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Matched Bullet Points and their children */}
|
<textarea
|
||||||
<div className="space-y-3 pl-1">
|
value={tagNoteContent}
|
||||||
{docGroup.matchedBlocks.map((mb, idx) => (
|
onChange={(e) => handleTagNoteChange(e.target.value)}
|
||||||
<div
|
onKeyDown={(e) => {
|
||||||
key={mb.rootBlock.id || idx}
|
if (e.key === 'Tab') {
|
||||||
className="space-y-1.5 bg-canvas/40 p-3 rounded-card border border-border/50"
|
e.preventDefault();
|
||||||
>
|
const target = e.currentTarget;
|
||||||
{/* Root Bullet Point */}
|
const start = target.selectionStart;
|
||||||
<div className="flex items-start gap-2 group">
|
const end = target.selectionEnd;
|
||||||
{mb.rootBlock.type === 'todo' ? (
|
const nextVal =
|
||||||
<input
|
tagNoteContent.slice(0, start) + ' ' + tagNoteContent.slice(end);
|
||||||
type="checkbox"
|
handleTagNoteChange(nextVal);
|
||||||
checked={!!mb.rootBlock.checked}
|
requestAnimationFrame(() => {
|
||||||
onChange={() => handleToggleRollupTodo(docGroup.docId, mb.rootBlock.id)}
|
target.setSelectionRange(start + 2, start + 2);
|
||||||
className="w-4 h-4 mt-0.5 checkbox-square cursor-pointer flex-shrink-0"
|
});
|
||||||
/>
|
}
|
||||||
) : (
|
}}
|
||||||
<div className="w-3.5 h-3.5 mt-1 rounded-full bg-accent/20 border border-accent flex items-center justify-center flex-shrink-0">
|
rows={Math.max(4, tagNoteContent.split('\n').length + 1)}
|
||||||
<div className="w-1.5 h-1.5 rounded-full bg-accent" />
|
placeholder={`Eigene Gedanken, Zusammenfassungen oder Markdown-Notizen direkt zum Thema #${selectedTag} festhalten (unabhängig von den anderen Workspace-Notizen)…`}
|
||||||
</div>
|
className="w-full bg-canvas/50 border border-border/60 rounded-card p-3 text-xs text-main leading-relaxed focus:outline-none focus:border-accent resize-y placeholder:text-faint"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Linked References / Verknüpfte Bullet Points from Workspace Notes */}
|
||||||
|
{!tagDetails || tagDetails.documents.length === 0 ? (
|
||||||
|
<div className="p-10 text-center text-muted text-xs panel-surface space-y-2 border border-border rounded-card">
|
||||||
|
<Hash className="w-7 h-7 mx-auto text-faint" />
|
||||||
|
<p className="font-medium text-main">Keine verknüpften Bullet Points mit #{selectedTag} in anderen Notizen</p>
|
||||||
|
<p className="text-xs text-faint">
|
||||||
|
Füge einfach in irgendeiner Notiz <code className="text-accent">#{selectedTag}</code> in einen Bullet Point ein, damit er hier automatisch verknüpft wird.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{tagDetails.documents.map((docGroup) => (
|
||||||
|
<div
|
||||||
|
key={docGroup.docId}
|
||||||
|
className="panel-surface p-4 space-y-3 border border-border rounded-card hover:border-border/80 transition-colors"
|
||||||
|
>
|
||||||
|
{/* Document Header */}
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2 pb-2 border-b border-border">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<span className="text-base flex-shrink-0">{docGroup.icon || '📄'}</span>
|
||||||
|
<span className="font-semibold text-xs text-main truncate">{docGroup.title || 'Unbenannt'}</span>
|
||||||
|
{docGroup.folderPath && (
|
||||||
|
<span className="badge-neutral text-[10px] flex items-center gap-1 font-mono">
|
||||||
|
<Folder className="w-2.5 h-2.5" />
|
||||||
|
<span>{docGroup.folderPath}</span>
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
|
<span className="text-[10px] text-faint">
|
||||||
<div
|
• {docGroup.matchedBlocks.length}{' '}
|
||||||
onClick={() => {
|
{docGroup.matchedBlocks.length === 1 ? 'Treffer' : 'Treffer'}
|
||||||
const found = docs.find((d) => d.id === docGroup.docId);
|
</span>
|
||||||
if (found) {
|
|
||||||
setSelectedDoc(found);
|
|
||||||
setZoomedBlockId(mb.rootBlock.id);
|
|
||||||
setWorkspaceMode('editor');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className={`flex-1 text-xs leading-relaxed cursor-pointer hover:text-accent transition-colors ${
|
|
||||||
mb.rootBlock.checked ? 'line-through text-faint' : 'text-main'
|
|
||||||
}`}
|
|
||||||
title="Klick: Zu diesem Bullet Point in der Notiz zoomen"
|
|
||||||
>
|
|
||||||
{renderFormattedText(mb.rootBlock.content || '(Leerer Bullet Point)')}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Children / Sub-bullets */}
|
<button
|
||||||
{mb.children.length > 0 && (
|
type="button"
|
||||||
<div className="space-y-1 mt-1 pl-4 border-l-2 border-border/60 ml-2">
|
onClick={() => {
|
||||||
{mb.children.map((child) => {
|
const found = docs.find((d) => d.id === docGroup.docId);
|
||||||
const indentLevel = Math.max(
|
if (found) {
|
||||||
0,
|
setSelectedDoc(found);
|
||||||
(child.level || 0) - (mb.rootBlock.level || 0) - 1
|
setZoomedBlockId(null);
|
||||||
);
|
setWorkspaceMode('editor');
|
||||||
return (
|
}
|
||||||
<div
|
}}
|
||||||
key={child.id}
|
className="px-2.5 py-1 text-xs font-medium text-accent hover:text-main bg-accent/10 hover:bg-accent/20 rounded-btn flex items-center gap-1 transition-colors"
|
||||||
style={{ paddingLeft: `${indentLevel * 14}px` }}
|
>
|
||||||
className="flex items-start gap-2 py-0.5 text-xs text-muted"
|
<span>Notiz öffnen</span>
|
||||||
>
|
<ExternalLink className="w-3 h-3" />
|
||||||
{child.type === 'todo' ? (
|
</button>
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={!!child.checked}
|
|
||||||
onChange={() =>
|
|
||||||
handleToggleRollupTodo(docGroup.docId, child.id)
|
|
||||||
}
|
|
||||||
className="w-3.5 h-3.5 mt-0.5 checkbox-square cursor-pointer flex-shrink-0"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span className="w-1.5 h-1.5 mt-1.5 rounded-full bg-muted/60 flex-shrink-0" />
|
|
||||||
)}
|
|
||||||
<div
|
|
||||||
onClick={() => {
|
|
||||||
const found = docs.find((d) => d.id === docGroup.docId);
|
|
||||||
if (found) {
|
|
||||||
setSelectedDoc(found);
|
|
||||||
setZoomedBlockId(child.id);
|
|
||||||
setWorkspaceMode('editor');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className={`flex-1 text-xs leading-relaxed cursor-pointer hover:text-accent transition-colors ${
|
|
||||||
child.checked ? 'line-through text-faint' : 'text-muted'
|
|
||||||
}`}
|
|
||||||
title="Klick: In Notiz fokussieren"
|
|
||||||
>
|
|
||||||
{renderFormattedText(child.content || '')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
</div>
|
{/* Matched Bullet Points and their children */}
|
||||||
|
<div className="space-y-3 pl-1">
|
||||||
|
{docGroup.matchedBlocks.map((mb, idx) => (
|
||||||
|
<div
|
||||||
|
key={mb.rootBlock.id || idx}
|
||||||
|
className="space-y-1.5 bg-canvas/40 p-3 rounded-card border border-border/50"
|
||||||
|
>
|
||||||
|
{/* Root Bullet Point */}
|
||||||
|
<div className="flex items-start gap-2 group">
|
||||||
|
{mb.rootBlock.type === 'todo' ? (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={!!mb.rootBlock.checked}
|
||||||
|
onChange={() => handleToggleRollupTodo(docGroup.docId, mb.rootBlock.id)}
|
||||||
|
className="w-4 h-4 mt-0.5 checkbox-square cursor-pointer flex-shrink-0"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-3.5 h-3.5 mt-1 rounded-full bg-accent/20 border border-accent flex items-center justify-center flex-shrink-0">
|
||||||
|
<div className="w-1.5 h-1.5 rounded-full bg-accent" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
onClick={() => {
|
||||||
|
const found = docs.find((d) => d.id === docGroup.docId);
|
||||||
|
if (found) {
|
||||||
|
setSelectedDoc(found);
|
||||||
|
setZoomedBlockId(mb.rootBlock.id);
|
||||||
|
setWorkspaceMode('editor');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={`flex-1 text-xs leading-relaxed cursor-pointer hover:text-accent transition-colors ${
|
||||||
|
mb.rootBlock.checked ? 'line-through text-faint' : 'text-main'
|
||||||
|
}`}
|
||||||
|
title="Klick: Zu diesem Bullet Point in der Notiz zoomen"
|
||||||
|
>
|
||||||
|
{renderFormattedText(mb.rootBlock.content || '(Leerer Bullet Point)')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Children / Sub-bullets */}
|
||||||
|
{mb.children.length > 0 && (
|
||||||
|
<div className="space-y-1 mt-1 pl-4 border-l-2 border-border/60 ml-2">
|
||||||
|
{mb.children.map((child) => {
|
||||||
|
const indentLevel = Math.max(
|
||||||
|
0,
|
||||||
|
(child.level || 0) - (mb.rootBlock.level || 0) - 1
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={child.id}
|
||||||
|
style={{ paddingLeft: `${indentLevel * 14}px` }}
|
||||||
|
className="flex items-start gap-2 py-0.5 text-xs text-muted"
|
||||||
|
>
|
||||||
|
{child.type === 'todo' ? (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={!!child.checked}
|
||||||
|
onChange={() =>
|
||||||
|
handleToggleRollupTodo(docGroup.docId, child.id)
|
||||||
|
}
|
||||||
|
className="w-3.5 h-3.5 mt-0.5 checkbox-square cursor-pointer flex-shrink-0"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="w-1.5 h-1.5 mt-1.5 rounded-full bg-muted/60 flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
onClick={() => {
|
||||||
|
const found = docs.find((d) => d.id === docGroup.docId);
|
||||||
|
if (found) {
|
||||||
|
setSelectedDoc(found);
|
||||||
|
setZoomedBlockId(child.id);
|
||||||
|
setWorkspaceMode('editor');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={`flex-1 text-xs leading-relaxed cursor-pointer hover:text-accent transition-colors ${
|
||||||
|
child.checked ? 'line-through text-faint' : 'text-muted'
|
||||||
|
}`}
|
||||||
|
title="Klick: In Notiz fokussieren"
|
||||||
|
>
|
||||||
|
{renderFormattedText(child.content || '')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -442,6 +442,20 @@ export const api = {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async updateTagPageNote(
|
||||||
|
tag: string,
|
||||||
|
content: string
|
||||||
|
): Promise<{ tag: string; content: string; updatedAt: string }> {
|
||||||
|
const clean = encodeURIComponent(tag.replace(/^#/, ''));
|
||||||
|
return request<{ tag: string; content: string; updatedAt: string }>(
|
||||||
|
`/api/v1/workspace/tags/${clean}/note`,
|
||||||
|
{
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ content }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
// --- WORKSPACE DOCUMENTS ---
|
// --- WORKSPACE DOCUMENTS ---
|
||||||
async getDocuments(): Promise<Document[]> {
|
async getDocuments(): Promise<Document[]> {
|
||||||
const res = await request<Document[]>('/api/v1/documents');
|
const res = await request<Document[]>('/api/v1/documents');
|
||||||
|
|||||||
@@ -223,6 +223,8 @@ export interface TagDocumentGroup {
|
|||||||
|
|
||||||
export interface TagDetails {
|
export interface TagDetails {
|
||||||
tag: string;
|
tag: string;
|
||||||
|
noteContent?: string;
|
||||||
|
noteUpdatedAt?: string;
|
||||||
documents: TagDocumentGroup[];
|
documents: TagDocumentGroup[];
|
||||||
totalMatches: number;
|
totalMatches: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ func (s *Server) Router() http.Handler {
|
|||||||
// Workspace Tags & Pages
|
// Workspace Tags & Pages
|
||||||
r.Get("/api/v1/workspace/tags", s.handleGetWorkspaceTags)
|
r.Get("/api/v1/workspace/tags", s.handleGetWorkspaceTags)
|
||||||
r.Get("/api/v1/workspace/tags/{tag}", s.handleGetTagDetails)
|
r.Get("/api/v1/workspace/tags/{tag}", s.handleGetTagDetails)
|
||||||
|
r.Put("/api/v1/workspace/tags/{tag}/note", s.handleUpdateTagPageNote)
|
||||||
|
|
||||||
r.Get("/api/v1/documents", s.handleGetDocuments)
|
r.Get("/api/v1/documents", s.handleGetDocuments)
|
||||||
r.Post("/api/v1/documents", s.handleCreateDocument)
|
r.Post("/api/v1/documents", s.handleCreateDocument)
|
||||||
@@ -903,6 +904,28 @@ func (s *Server) handleGetTagDetails(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, details)
|
writeJSON(w, http.StatusOK, details)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleUpdateTagPageNote(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := auth.GetUserID(r.Context())
|
||||||
|
tag := chi.URLParam(r, "tag")
|
||||||
|
var req struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updatedAt, err := s.store.UpdateTagPageNote(userID, tag, req.Content)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{
|
||||||
|
"tag": tag,
|
||||||
|
"content": req.Content,
|
||||||
|
"updatedAt": updatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// --- DOCUMENT SHARING & COLLABORATION ---
|
// --- DOCUMENT SHARING & COLLABORATION ---
|
||||||
|
|
||||||
func (s *Server) handleGetDocShares(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleGetDocShares(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -314,9 +314,11 @@ type TagDocumentGroup struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type TagDetails struct {
|
type TagDetails struct {
|
||||||
Tag string `json:"tag"`
|
Tag string `json:"tag"`
|
||||||
Documents []TagDocumentGroup `json:"documents"`
|
NoteContent string `json:"noteContent"`
|
||||||
TotalMatches int `json:"totalMatches"`
|
NoteUpdatedAt string `json:"noteUpdatedAt,omitempty"`
|
||||||
|
Documents []TagDocumentGroup `json:"documents"`
|
||||||
|
TotalMatches int `json:"totalMatches"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SpaceMember struct {
|
type SpaceMember struct {
|
||||||
|
|||||||
@@ -249,6 +249,14 @@ func (s *Store) migrate() error {
|
|||||||
CREATE INDEX IF NOT EXISTS idx_doc_block_tags_user_tag ON doc_block_tags(user_id, tag);
|
CREATE INDEX IF NOT EXISTS idx_doc_block_tags_user_tag ON doc_block_tags(user_id, tag);
|
||||||
CREATE INDEX IF NOT EXISTS idx_doc_block_tags_doc ON doc_block_tags(doc_id);
|
CREATE INDEX IF NOT EXISTS idx_doc_block_tags_doc ON doc_block_tags(doc_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS tag_pages (
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
tag TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL DEFAULT '',
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY(user_id, tag)
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS lists (
|
CREATE TABLE IF NOT EXISTS lists (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
space_id TEXT NOT NULL,
|
space_id TEXT NOT NULL,
|
||||||
@@ -1577,6 +1585,41 @@ func (s *Store) indexDocBlockTags(docID, userID string, blocks []domain.Document
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) saveTagPageToDisk(userID, tag, content string) {
|
||||||
|
if s.docDir == "" || tag == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tagDir := filepath.Join(filepath.Dir(s.docDir), "tag_pages", userID)
|
||||||
|
_ = os.MkdirAll(tagDir, 0755)
|
||||||
|
safeTag := regexp.MustCompile(`[^a-zA-Z0-9äöüÄÖÜß_-]+`).ReplaceAllString(tag, "_")
|
||||||
|
targetPath := filepath.Join(tagDir, safeTag+".md")
|
||||||
|
if strings.TrimSpace(content) == "" {
|
||||||
|
_ = os.Remove(targetPath)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
md := fmt.Sprintf("# #%s\n\n%s\n", tag, content)
|
||||||
|
_ = os.WriteFile(targetPath, []byte(md), 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) UpdateTagPageNote(userID, tag, content string) (string, error) {
|
||||||
|
cleanTag := strings.ToLower(strings.TrimPrefix(strings.TrimSpace(tag), "#"))
|
||||||
|
if cleanTag == "" {
|
||||||
|
return "", errors.New("tag name required")
|
||||||
|
}
|
||||||
|
now := domain.NowISO()
|
||||||
|
_, err := s.db.Exec(
|
||||||
|
`INSERT INTO tag_pages (user_id, tag, content, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
ON CONFLICT(user_id, tag) DO UPDATE SET content = excluded.content, updated_at = excluded.updated_at`,
|
||||||
|
userID, cleanTag, content, now,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
s.saveTagPageToDisk(userID, cleanTag, content)
|
||||||
|
return now, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) GetWorkspaceTags(userID string) ([]domain.TagSummary, error) {
|
func (s *Store) GetWorkspaceTags(userID string) ([]domain.TagSummary, error) {
|
||||||
rows, err := s.db.Query(
|
rows, err := s.db.Query(
|
||||||
`SELECT t.tag, COUNT(DISTINCT t.doc_id || ':' || t.block_id) as count
|
`SELECT t.tag, COUNT(DISTINCT t.doc_id || ':' || t.block_id) as count
|
||||||
@@ -1595,12 +1638,32 @@ func (s *Store) GetWorkspaceTags(userID string) ([]domain.TagSummary, error) {
|
|||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
results := make([]domain.TagSummary, 0)
|
results := make([]domain.TagSummary, 0)
|
||||||
|
seen := make(map[string]bool)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var ts domain.TagSummary
|
var ts domain.TagSummary
|
||||||
if err := rows.Scan(&ts.Tag, &ts.Count); err == nil {
|
if err := rows.Scan(&ts.Tag, &ts.Count); err == nil {
|
||||||
results = append(results, ts)
|
results = append(results, ts)
|
||||||
|
seen[ts.Tag] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
_ = rows.Close()
|
||||||
|
|
||||||
|
// Also include tags that have a non-empty tag_pages note even if not currently used in a block
|
||||||
|
pageRows, err := s.db.Query(
|
||||||
|
`SELECT tag FROM tag_pages WHERE user_id = ? AND TRIM(content) != '' ORDER BY tag ASC`,
|
||||||
|
userID,
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
for pageRows.Next() {
|
||||||
|
var t string
|
||||||
|
if err := pageRows.Scan(&t); err == nil && t != "" && !seen[t] {
|
||||||
|
results = append(results, domain.TagSummary{Tag: t, Count: 0})
|
||||||
|
seen[t] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = pageRows.Close()
|
||||||
|
}
|
||||||
|
|
||||||
return results, nil
|
return results, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1610,6 +1673,12 @@ func (s *Store) GetTagDetails(userID, tag string) (*domain.TagDetails, error) {
|
|||||||
return &domain.TagDetails{Tag: "", Documents: []domain.TagDocumentGroup{}, TotalMatches: 0}, nil
|
return &domain.TagDetails{Tag: "", Documents: []domain.TagDocumentGroup{}, TotalMatches: 0}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var noteContent, noteUpdatedAt string
|
||||||
|
_ = s.db.QueryRow(
|
||||||
|
`SELECT content, updated_at FROM tag_pages WHERE user_id = ? AND tag = ?`,
|
||||||
|
userID, cleanTag,
|
||||||
|
).Scan(¬eContent, ¬eUpdatedAt)
|
||||||
|
|
||||||
rows, err := s.db.Query(
|
rows, err := s.db.Query(
|
||||||
`SELECT t.block_id, d.id, d.title, d.icon, d.folder_path, d.blocks_json, d.updated_at, d.user_id,
|
`SELECT t.block_id, d.id, d.title, d.icon, d.folder_path, d.blocks_json, d.updated_at, d.user_id,
|
||||||
COALESCE(u.display_name, '') as owner_name,
|
COALESCE(u.display_name, '') as owner_name,
|
||||||
@@ -1734,9 +1803,11 @@ func (s *Store) GetTagDetails(userID, tag string) (*domain.TagDetails, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &domain.TagDetails{
|
return &domain.TagDetails{
|
||||||
Tag: cleanTag,
|
Tag: cleanTag,
|
||||||
Documents: docGroups,
|
NoteContent: noteContent,
|
||||||
TotalMatches: totalMatches,
|
NoteUpdatedAt: noteUpdatedAt,
|
||||||
|
Documents: docGroups,
|
||||||
|
TotalMatches: totalMatches,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user