feat(workspace): add hashtag indexing, tag pages and bullet group view
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m0s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m0s
This commit is contained in:
1 parent
c4b2a00348
commit
1310f8102e
8 files changed
+893
-11
No files matched your search
@@ -46,8 +46,9 @@ import {
|
||||
Edit3,
|
||||
Share,
|
||||
Share2,
|
||||
Hash,
|
||||
} from 'lucide-react';
|
||||
import { BlockType, Document, DocumentBlock, DocLockInfo, DocShare, User } from '../../lib/types';
|
||||
import { BlockType, Document, DocumentBlock, DocLockInfo, DocShare, TagDetails, TagSummary, User } from '../../lib/types';
|
||||
import { api, getToken, triggerHaptic } from '../../lib/api';
|
||||
|
||||
interface WorkspaceViewProps {
|
||||
@@ -91,8 +92,12 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
initialDocId,
|
||||
onStatsChange,
|
||||
}) => {
|
||||
// Top Workspace Tab Mode: 'editor' vs 'todos' (Global To-Do Rollup Dashboard)
|
||||
const [workspaceMode, setWorkspaceMode] = useState<'editor' | 'todos'>('editor');
|
||||
// Top Workspace Tab Mode: 'editor' vs 'todos' vs 'tag'
|
||||
const [workspaceMode, setWorkspaceMode] = useState<'editor' | 'todos' | 'tag'>('editor');
|
||||
const [selectedTag, setSelectedTag] = useState<string | null>(null);
|
||||
const [tagDetails, setTagDetails] = useState<TagDetails | null>(null);
|
||||
const [workspaceTags, setWorkspaceTags] = useState<TagSummary[]>([]);
|
||||
const [isLoadingTag, setIsLoadingTag] = useState(false);
|
||||
|
||||
const [docs, setDocs] = useState<Document[]>([]);
|
||||
const [selectedDoc, setSelectedDoc] = useState<Document | null>(null);
|
||||
@@ -281,6 +286,10 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
: prev
|
||||
);
|
||||
onStatsChange();
|
||||
api.getWorkspaceTags().then(setWorkspaceTags).catch(() => {});
|
||||
if (selectedTag) {
|
||||
api.getTagDetails(selectedTag).then(setTagDetails).catch(() => {});
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e.message && e.message.includes('423')) {
|
||||
api.getDocLock(docToSave.id).then((res) => {
|
||||
@@ -343,12 +352,14 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
|
||||
const loadDocs = async (selectId?: string | null) => {
|
||||
try {
|
||||
const [list, serverFolders] = await Promise.all([
|
||||
const [list, serverFolders, tags] = await Promise.all([
|
||||
api.getDocuments(),
|
||||
api.getFolders(),
|
||||
api.getWorkspaceTags().catch(() => []),
|
||||
]);
|
||||
setDocs(list);
|
||||
setCustomFolders(serverFolders);
|
||||
setWorkspaceTags(tags);
|
||||
if (selectId) {
|
||||
const found = list.find((d) => d.id === selectId);
|
||||
if (found) {
|
||||
@@ -367,6 +378,21 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const openTagPage = async (tag: string) => {
|
||||
const clean = tag.replace(/^#/, '').toLowerCase();
|
||||
setSelectedTag(clean);
|
||||
setWorkspaceMode('tag');
|
||||
setIsLoadingTag(true);
|
||||
try {
|
||||
const details = await api.getTagDetails(clean);
|
||||
setTagDetails(details);
|
||||
} catch (err) {
|
||||
console.error('Failed to load tag details', err);
|
||||
} finally {
|
||||
setIsLoadingTag(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadDocs(initialDocId);
|
||||
}, [initialDocId]);
|
||||
@@ -1397,9 +1423,9 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
// Helper to render text with clickable Wiki-Links
|
||||
const renderTextWithWikiLinks = (text: string) => {
|
||||
const parts = text.split(/(\[\[.*?\]\])/g);
|
||||
// Helper to render text with clickable Wiki-Links and Hashtags
|
||||
const renderFormattedText = (text: string) => {
|
||||
const parts = text.split(/(\[\[.*?\]\]|#[a-zA-Z0-9äöüÄÖÜß_-]+)/g);
|
||||
return parts.map((part, i) => {
|
||||
if (part.startsWith('[[') && part.endsWith(']]')) {
|
||||
const linkTitle = part.slice(2, -2).trim();
|
||||
@@ -1412,6 +1438,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
if (target) {
|
||||
setSelectedDoc(target);
|
||||
setZoomedBlockId(null);
|
||||
setWorkspaceMode('editor');
|
||||
}
|
||||
}}
|
||||
className={`inline-flex items-center gap-1 px-1.5 py-0.2 mx-0.5 border text-xs font-medium cursor-pointer rounded-btn transition-colors ${
|
||||
@@ -1427,6 +1454,23 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (part.startsWith('#') && part.length > 1) {
|
||||
const tagWord = part.slice(1);
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openTagPage(tagWord);
|
||||
}}
|
||||
className="inline-flex items-center gap-0.5 px-1.5 py-0.2 mx-0.5 bg-blue-500/10 border border-blue-500/30 text-blue-500 dark:text-blue-400 hover:bg-blue-500/20 text-xs font-medium cursor-pointer rounded-btn transition-colors"
|
||||
title={`Öffne Hashtag-Seite: #${tagWord}`}
|
||||
>
|
||||
<Hash className="w-2.5 h-2.5 opacity-80" />
|
||||
<span>{tagWord}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <span key={i}>{part}</span>;
|
||||
});
|
||||
};
|
||||
@@ -1496,6 +1540,29 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
setSelectedDoc(updatedDoc);
|
||||
}
|
||||
|
||||
// Also update tagDetails if currently viewing tag page
|
||||
setTagDetails((prev) => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
...prev,
|
||||
documents: prev.documents.map((dg) => {
|
||||
if (dg.docId !== docId) return dg;
|
||||
return {
|
||||
...dg,
|
||||
matchedBlocks: dg.matchedBlocks.map((mb) => ({
|
||||
rootBlock:
|
||||
mb.rootBlock.id === blockId
|
||||
? { ...mb.rootBlock, checked: !mb.rootBlock.checked }
|
||||
: mb.rootBlock,
|
||||
children: mb.children.map((c) =>
|
||||
c.id === blockId ? { ...c, checked: !c.checked } : c
|
||||
),
|
||||
})),
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
// Persist immediately to backend & disk
|
||||
await api.updateDocument(docId, {
|
||||
blocks: updatedBlocks,
|
||||
@@ -1823,6 +1890,26 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
{allWorkspaceTodos.filter((t) => !t.block.checked).length}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{selectedTag && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setWorkspaceMode('tag')}
|
||||
className={`h-8 px-3 text-xs font-medium border rounded-btn flex items-center gap-1.5 transition-colors ${
|
||||
workspaceMode === 'tag'
|
||||
? 'bg-panel border-accent text-main'
|
||||
: 'bg-canvas border-border text-muted hover:text-main'
|
||||
}`}
|
||||
>
|
||||
<Hash className="w-3.5 h-3.5 text-accent" />
|
||||
<span>#{selectedTag}</span>
|
||||
{tagDetails && (
|
||||
<span className="badge-neutral font-mono text-[10px]">
|
||||
{tagDetails.totalMatches}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-xs text-muted">
|
||||
@@ -2047,6 +2134,37 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
|
||||
{rootTree.map(renderFolder)}
|
||||
{rootDocs.map((doc) => renderDocItem(doc, 0))}
|
||||
|
||||
{/* Workspace Hashtags Section */}
|
||||
{workspaceTags.length > 0 && !searchQuery && (
|
||||
<div className="mt-3 pt-2.5 border-t border-border">
|
||||
<div className="px-2.5 py-1 text-[10px] font-bold text-muted uppercase tracking-wider flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5 text-accent">
|
||||
<Hash className="w-3 h-3" />
|
||||
<span>Themen & Hashtags ({workspaceTags.length})</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1 px-1.5 py-1">
|
||||
{workspaceTags.map((wt) => (
|
||||
<button
|
||||
key={wt.tag}
|
||||
type="button"
|
||||
onClick={() => openTagPage(wt.tag)}
|
||||
className={`px-2 py-0.5 rounded-pill text-[11px] font-medium border flex items-center gap-1 transition-colors ${
|
||||
selectedTag === wt.tag
|
||||
? 'bg-accent/20 border-accent text-accent font-semibold'
|
||||
: 'bg-canvas border-border text-muted hover:border-accent/40 hover:text-main'
|
||||
}`}
|
||||
title={`#${wt.tag} (${wt.count} Abschnitte)`}
|
||||
>
|
||||
<span>#{wt.tag}</span>
|
||||
<span className="text-[9px] opacity-60">({wt.count})</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredDocs.length === 0 && searchQuery && (
|
||||
<div className="p-4 text-center text-xs text-faint">
|
||||
Keine Dateien gefunden für "{searchQuery}"
|
||||
@@ -2430,6 +2548,11 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{(block.content.includes('[[') || block.content.includes('#')) && (
|
||||
<div className="text-xs text-muted flex flex-wrap items-center gap-1 pt-0.5 pl-6">
|
||||
{renderFormattedText(block.content)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Progress bar for child todos if any exist */}
|
||||
{(() => {
|
||||
@@ -2631,9 +2754,9 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
isReadOnly ? 'cursor-default' : ''
|
||||
}`}
|
||||
/>
|
||||
{block.content.includes('[[') && (
|
||||
<div className="text-xs text-muted">
|
||||
{renderTextWithWikiLinks(block.content)}
|
||||
{(block.content.includes('[[') || block.content.includes('#')) && (
|
||||
<div className="text-xs text-muted flex flex-wrap items-center gap-1 pt-0.5">
|
||||
{renderFormattedText(block.content)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -2898,6 +3021,217 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ===================================================================== */}
|
||||
{/* VIEW MODE 3: HASHTAG / THEMEN-SEITE (LOGSEQ STYLE) */}
|
||||
{/* ===================================================================== */}
|
||||
{workspaceMode === 'tag' && (
|
||||
<div className="space-y-4">
|
||||
{/* Tag Page Header */}
|
||||
<div className="p-4 panel-surface space-y-2 border border-border rounded-card">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setWorkspaceMode('editor')}
|
||||
className="btn-action h-8 px-2.5 text-xs flex items-center gap-1.5"
|
||||
title="Zurück zum Editor"
|
||||
>
|
||||
<ArrowLeft className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Notizen</span>
|
||||
</button>
|
||||
<div className="h-4 w-px bg-border" />
|
||||
<div className="flex items-center gap-2">
|
||||
<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">
|
||||
<Hash className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-bold text-main leading-tight flex items-center gap-1.5">
|
||||
<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>
|
||||
|
||||
{/* Other tags pills for fast hopping */}
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{workspaceTags
|
||||
.filter((t) => t.tag !== selectedTag)
|
||||
.slice(0, 6)
|
||||
.map((t) => (
|
||||
<button
|
||||
key={t.tag}
|
||||
type="button"
|
||||
onClick={() => openTagPage(t.tag)}
|
||||
className="px-2 py-0.5 rounded-pill text-[11px] badge-neutral hover:border-accent/40 hover:text-main transition-colors"
|
||||
>
|
||||
#{t.tag} ({t.count})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Matched Documents List */}
|
||||
{isLoadingTag ? (
|
||||
<div className="p-12 text-center text-muted text-xs panel-surface">
|
||||
<Sparkles className="w-6 h-6 animate-spin mx-auto mb-2 text-accent" />
|
||||
Hashtag-Verknüpfungen werden geladen…
|
||||
</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">
|
||||
{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">
|
||||
• {docGroup.matchedBlocks.length}{' '}
|
||||
{docGroup.matchedBlocks.length === 1 ? 'Treffer' : 'Treffer'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const found = docs.find((d) => d.id === docGroup.docId);
|
||||
if (found) {
|
||||
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>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</button>
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* ===================================================================== */}
|
||||
{/* DOCUMENT SHARING MODAL */}
|
||||
{/* ===================================================================== */}
|
||||
|
||||
Reference in new issue
Block a user