Merge dev into main (v0.44.0)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 29s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 29s
This commit is contained in:
commit
ec481bdf0f
26 files changed
+208
-138
No files matched your search
+16
-1
@@ -6,6 +6,20 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.44.0] - 2026-11-01
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Neue Notizen bekommen 🗒️ als Standard-Symbol (vorher 📝 bzw. 📄).
|
||||||
|
- Aufgaben-Liste: „Gruppe“ und „Sortierung“ stehen direkt über der Liste neben den Reitern „Offen / Erledigt / Papierkorb“; „Erledigte in den Papierkorb“ und „Papierkorb leeren“ sind ein Papierkorb-Symbol rechts daneben.
|
||||||
|
- Kanban am PC: Das Board ist so hoch wie das Fenster; nur noch die Spalten scrollen, die Seite nicht mehr zusätzlich (wenn alle Spalten in eine Reihe passen).
|
||||||
|
- Quick Dump: Im aufgeklappten Bereich wählen Ziel und Liste nur aus, gespeichert wird mit Enter bzw. dem Knopf. Der Hinweis „(Klick speichert sofort …)“ entfällt.
|
||||||
|
- Spracheingabe über den Browser: Ein Tippen auf das Mikrofon hört über kurze Pausen hinweg zu und endet erst nach 5 Sekunden Stille.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Workspace: Shift+Klick ab einer angehefteten Notiz, die in ihrem Ordner angeklickt wurde, markierte alles ab der angehefteten Zeile ganz oben statt ab dem Ordner.
|
||||||
|
- Notizen: Ein Punkt mit Unterpunkten rückte durch den Aufklapp-Pfeil ein Stück nach rechts; jetzt stehen alle Punkte genau untereinander.
|
||||||
|
- Globale Suche: Das Eingabefeld springt beim Tippen nicht mehr hin und her (Lade-Symbol und Leeren-Knopf teilen sich einen festen Platz).
|
||||||
|
|
||||||
## [0.43.0] - 2026-11-01
|
## [0.43.0] - 2026-11-01
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -704,7 +718,8 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
|
|||||||
- Geteilte Listen (Einkaufs-, Pack- und Checklisten) mit `#Kategorie`-Gruppierung, Mengenangaben und Echtzeit-Abhaken.
|
- Geteilte Listen (Einkaufs-, Pack- und Checklisten) mit `#Kategorie`-Gruppierung, Mengenangaben und Echtzeit-Abhaken.
|
||||||
- Benutzer-, Gruppen- und Admin-Verwaltung (Ersteinrichtungs-Assistent, Einladungscodes, Profilbilder, persönliche API-Keys & Webhook-Ingest `/api/v1/webhooks/ingest`, JSON-Datenexport) sowie CSS-Theme-Engine mit 4 Themes (`Dark`, `Light`, `High Contrast`, `Soft`) und PWA-Unterstützung.
|
- Benutzer-, Gruppen- und Admin-Verwaltung (Ersteinrichtungs-Assistent, Einladungscodes, Profilbilder, persönliche API-Keys & Webhook-Ingest `/api/v1/webhooks/ingest`, JSON-Datenexport) sowie CSS-Theme-Engine mit 4 Themes (`Dark`, `Light`, `High Contrast`, `Soft`) und PWA-Unterstützung.
|
||||||
|
|
||||||
[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.43.0...dev
|
[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.44.0...dev
|
||||||
|
[0.44.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.43.0...v0.44.0
|
||||||
[0.43.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.42.0...v0.43.0
|
[0.43.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.42.0...v0.43.0
|
||||||
[0.42.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.41.0...v0.42.0
|
[0.42.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.41.0...v0.42.0
|
||||||
[0.41.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.40.1...v0.41.0
|
[0.41.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.40.1...v0.41.0
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
[](#)
|
[](#)
|
||||||
[](#-schnellstart)
|
[](#-schnellstart)
|
||||||
[](#-deine-daten-gehören-dir)
|
[](#-deine-daten-gehören-dir)
|
||||||
[](CHANGELOG.md)
|
[](CHANGELOG.md)
|
||||||
|
|
||||||
**MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen, die mehrere Personen gleichzeitig bearbeiten können, und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server.
|
**MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen, die mehrere Personen gleichzeitig bearbeiten können, und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server.
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "mindsift-frontend",
|
"name": "mindsift-frontend",
|
||||||
"version": "0.43.0",
|
"version": "0.44.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "mindsift-frontend",
|
"name": "mindsift-frontend",
|
||||||
"version": "0.43.0",
|
"version": "0.44.0",
|
||||||
"license": "Unlicense",
|
"license": "Unlicense",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/inter": "5.3.0",
|
"@fontsource-variable/inter": "5.3.0",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "mindsift-frontend",
|
"name": "mindsift-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.43.0",
|
"version": "0.44.0",
|
||||||
"license": "Unlicense",
|
"license": "Unlicense",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -204,21 +204,25 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({
|
|||||||
placeholder="Globale Suche in Notizen, Aufgaben und Listen…"
|
placeholder="Globale Suche in Notizen, Aufgaben und Listen…"
|
||||||
className="flex-1 bg-transparent border-0 outline-none text-main placeholder-muted text-base sm:text-sm focus:ring-0 p-0"
|
className="flex-1 bg-transparent border-0 outline-none text-main placeholder-muted text-base sm:text-sm focus:ring-0 p-0"
|
||||||
/>
|
/>
|
||||||
{loading && <Loader2 className="w-4 h-4 animate-spin text-accent shrink-0" />}
|
{/* One fixed slot for the spinner or the clear button, so the field keeps its width while typing */}
|
||||||
{query && !loading && (
|
<span className="w-6 h-6 shrink-0 flex items-center justify-center">
|
||||||
<button
|
{loading ? (
|
||||||
type="button"
|
<Loader2 className="w-4 h-4 animate-spin text-accent" />
|
||||||
onClick={() => {
|
) : query ? (
|
||||||
setQuery('');
|
<button
|
||||||
setHits([]);
|
type="button"
|
||||||
inputRef.current?.focus();
|
onClick={() => {
|
||||||
}}
|
setQuery('');
|
||||||
className="text-muted hover:text-main p-1"
|
setHits([]);
|
||||||
title="Eingabe leeren"
|
inputRef.current?.focus();
|
||||||
>
|
}}
|
||||||
<X className="w-4 h-4" />
|
className="w-6 h-6 flex items-center justify-center text-muted hover:text-main"
|
||||||
</button>
|
title="Eingabe leeren"
|
||||||
)}
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
@@ -269,7 +273,7 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({
|
|||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
<div className="mt-0.5 shrink-0 text-base leading-none">
|
<div className="mt-0.5 shrink-0 text-base leading-none">
|
||||||
{item.icon || (item.type === 'document' ? '📝' : item.type === 'thought' ? '✅' : '🛒')}
|
{item.icon || (item.type === 'document' ? '🗒️' : item.type === 'thought' ? '✅' : '🛒')}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 min-w-0 space-y-0.5">
|
<div className="flex-1 min-w-0 space-y-0.5">
|
||||||
|
|||||||
@@ -437,7 +437,7 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-1.5">
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted">
|
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted">
|
||||||
Wohin damit? {content.trim() ? '(Klick speichert sofort oder wählt Ziel)' : '(Ziel für Eingabe wählen)'}
|
Wohin damit?
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[11px] text-faint">
|
<span className="text-[11px] text-faint">
|
||||||
Standard: Quick Dump für späteres Swipen
|
Standard: Quick Dump für späteres Swipen
|
||||||
@@ -450,9 +450,6 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setTarget('inbox');
|
setTarget('inbox');
|
||||||
if (content.trim()) {
|
|
||||||
executeCapture('inbox');
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
className={`min-h-[36px] px-2.5 border rounded-btn flex items-center gap-1.5 text-left transition-colors cursor-pointer ${
|
className={`min-h-[36px] px-2.5 border rounded-btn flex items-center gap-1.5 text-left transition-colors cursor-pointer ${
|
||||||
target === 'inbox'
|
target === 'inbox'
|
||||||
@@ -469,10 +466,6 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setTarget('task');
|
setTarget('task');
|
||||||
// With task lists the row below picks one (and saves); otherwise save right away
|
|
||||||
if (content.trim() && writableTaskLists.length === 0) {
|
|
||||||
executeCapture('task');
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
className={`min-h-[36px] px-2.5 border rounded-btn flex items-center gap-1.5 text-left transition-colors cursor-pointer ${
|
className={`min-h-[36px] px-2.5 border rounded-btn flex items-center gap-1.5 text-left transition-colors cursor-pointer ${
|
||||||
target === 'task'
|
target === 'task'
|
||||||
@@ -508,9 +501,6 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setTarget('document');
|
setTarget('document');
|
||||||
if (content.trim()) {
|
|
||||||
executeCapture('document');
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
className={`min-h-[36px] px-2.5 border rounded-btn flex items-center gap-1.5 text-left transition-colors cursor-pointer ${
|
className={`min-h-[36px] px-2.5 border rounded-btn flex items-center gap-1.5 text-left transition-colors cursor-pointer ${
|
||||||
target === 'document'
|
target === 'document'
|
||||||
@@ -539,9 +529,6 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setTargetTaskListId(tl.id);
|
setTargetTaskListId(tl.id);
|
||||||
if (content.trim()) {
|
|
||||||
executeCapture('task', undefined, tl.id);
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
className={`min-h-[30px] px-2.5 border rounded-btn text-xs flex items-center gap-1.5 transition-colors cursor-pointer ${
|
className={`min-h-[30px] px-2.5 border rounded-btn text-xs flex items-center gap-1.5 transition-colors cursor-pointer ${
|
||||||
isSelected
|
isSelected
|
||||||
@@ -573,9 +560,6 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setTarget('list_item');
|
setTarget('list_item');
|
||||||
setTargetListId(lst.id);
|
setTargetListId(lst.id);
|
||||||
if (content.trim()) {
|
|
||||||
executeCapture('list_item', lst.id);
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
className={`min-h-[30px] px-2.5 border rounded-btn text-xs flex items-center gap-1.5 transition-colors cursor-pointer ${
|
className={`min-h-[30px] px-2.5 border rounded-btn text-xs flex items-center gap-1.5 transition-colors cursor-pointer ${
|
||||||
isSelected
|
isSelected
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ describe('InstantCaptureBar', () => {
|
|||||||
sessionStorage.clear();
|
sessionStorage.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows the list row that fits the target and saves into the chosen task list', async () => {
|
it('shows the list row that fits the target and saves into the chosen task list on send', async () => {
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
render(<InstantCaptureBar onCaptured={() => {}} lists={lists} defaultShowOptions />);
|
render(<InstantCaptureBar onCaptured={() => {}} lists={lists} defaultShowOptions />);
|
||||||
});
|
});
|
||||||
@@ -38,8 +38,11 @@ describe('InstantCaptureBar', () => {
|
|||||||
expect(captureThought).not.toHaveBeenCalled();
|
expect(captureThought).not.toHaveBeenCalled();
|
||||||
expect(screen.queryByText('Wocheneinkauf')).toBeNull();
|
expect(screen.queryByText('Wocheneinkauf')).toBeNull();
|
||||||
expect(screen.queryByText('Fremd')).toBeNull();
|
expect(screen.queryByText('Fremd')).toBeNull();
|
||||||
|
// Picking the list only selects it as well; saving takes the send button (or Enter)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Homelab/ }));
|
||||||
|
expect(captureThought).not.toHaveBeenCalled();
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.click(screen.getByRole('button', { name: /Homelab/ }));
|
fireEvent.submit(screen.getByRole('textbox').closest('form')!);
|
||||||
});
|
});
|
||||||
expect(captureThought).toHaveBeenCalledWith('Backup prüfen', 'normal', 'manual', '', {
|
expect(captureThought).toHaveBeenCalledWith('Backup prüfen', 'normal', 'manual', '', {
|
||||||
target: 'task',
|
target: 'task',
|
||||||
|
|||||||
@@ -428,6 +428,35 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
[ownColumnOf, boardIdOf]
|
[ownColumnOf, boardIdOf]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Desktop Kanban in one row: the columns fill the screen height and scroll on their own, so the page
|
||||||
|
// itself does not scroll as well (like the workspace). Several rows of columns keep the page scroll.
|
||||||
|
const kanbanBoardRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [kanbanColHeight, setKanbanColHeight] = useState<number | null>(null);
|
||||||
|
const kanbanOneRow = viewMode === 'kanban' && (isKanbanWide || boardColumns.length <= 4);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!kanbanOneRow) {
|
||||||
|
setKanbanColHeight(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fit = () => {
|
||||||
|
const el = kanbanBoardRef.current;
|
||||||
|
if (!el || window.innerWidth < 1024) {
|
||||||
|
setKanbanColHeight(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// From the board's top to the window's bottom; then take back what still overflows (footer, paddings)
|
||||||
|
const top = el.getBoundingClientRect().top + window.scrollY;
|
||||||
|
setKanbanColHeight(Math.max(320, window.innerHeight - top - 16));
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const over = document.documentElement.scrollHeight - window.innerHeight;
|
||||||
|
if (over > 0) setKanbanColHeight((h) => (h ? Math.max(320, h - over) : h));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
fit();
|
||||||
|
window.addEventListener('resize', fit);
|
||||||
|
return () => window.removeEventListener('resize', fit);
|
||||||
|
}, [kanbanOneRow, selectedListId, loading]);
|
||||||
|
|
||||||
const safeTasks = useMemo(() => {
|
const safeTasks = useMemo(() => {
|
||||||
if (!Array.isArray(tasks)) return [];
|
if (!Array.isArray(tasks)) return [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
@@ -1976,9 +2005,9 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Group By & Sort By Dropdowns & Manage Columns button */}
|
{/* Kanban: manage columns and width (the list view has grouping and sorting above the list) */}
|
||||||
<div className="flex items-center gap-2 flex-shrink-0 ml-auto">
|
<div className="flex items-center gap-2 flex-shrink-0 ml-auto">
|
||||||
{viewMode === 'kanban' ? (
|
{viewMode === 'kanban' && (
|
||||||
<>
|
<>
|
||||||
{!isSelectedListReadOnly && (
|
{!isSelectedListReadOnly && (
|
||||||
<button
|
<button
|
||||||
@@ -2004,40 +2033,6 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{/* Group By */}
|
|
||||||
<div className="flex items-center gap-1 text-xs text-muted">
|
|
||||||
<span className="text-[11px]">Gruppe:</span>
|
|
||||||
<select
|
|
||||||
value={groupBy}
|
|
||||||
onChange={(e) => setGroupBy(e.target.value as any)}
|
|
||||||
className="h-7 px-2 input-field text-xs bg-panel"
|
|
||||||
>
|
|
||||||
<option value="none">Keine</option>
|
|
||||||
<option value="tag">Nach Hashtag</option>
|
|
||||||
<option value="status">Nach Status</option>
|
|
||||||
<option value="priority">Nach Priorität</option>
|
|
||||||
<option value="due">Nach Fälligkeit</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sort By */}
|
|
||||||
<div className="flex items-center gap-1 text-xs text-muted">
|
|
||||||
<span className="text-[11px]">Sortierung:</span>
|
|
||||||
<select
|
|
||||||
value={sortBy}
|
|
||||||
onChange={(e) => setSortBy(e.target.value as any)}
|
|
||||||
className="h-7 px-2 input-field text-xs bg-panel"
|
|
||||||
>
|
|
||||||
<option value="custom">Manuell (Drag & Drop)</option>
|
|
||||||
<option value="created">Erstelldatum</option>
|
|
||||||
<option value="due">Fälligkeitsdatum</option>
|
|
||||||
<option value="priority">Priorität</option>
|
|
||||||
<option value="alpha">Titel (A-Z)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2464,6 +2459,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{kanbanTouch.ghost}
|
{kanbanTouch.ghost}
|
||||||
<div
|
<div
|
||||||
|
ref={kanbanBoardRef}
|
||||||
className={`grid grid-cols-1 md:grid-cols-2 gap-3 pt-1 lg:w-[var(--kanban-w)] lg:ml-[var(--kanban-ml)] transition-[width,margin] duration-300 ease-out ${
|
className={`grid grid-cols-1 md:grid-cols-2 gap-3 pt-1 lg:w-[var(--kanban-w)] lg:ml-[var(--kanban-ml)] transition-[width,margin] duration-300 ease-out ${
|
||||||
isKanbanWide ? 'lg:grid-cols-[repeat(var(--kanban-cols),minmax(0,1fr))]' : 'lg:grid-cols-4'
|
isKanbanWide ? 'lg:grid-cols-[repeat(var(--kanban-cols),minmax(0,1fr))]' : 'lg:grid-cols-4'
|
||||||
}`}
|
}`}
|
||||||
@@ -2486,9 +2482,10 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
onDragLeave={(e) => handleKanbanColDragLeave(e, col.id)}
|
onDragLeave={(e) => handleKanbanColDragLeave(e, col.id)}
|
||||||
onDrop={(e) => handleKanbanDrop(e, col.id, colTasks)}
|
onDrop={(e) => handleKanbanDrop(e, col.id, colTasks)}
|
||||||
data-kanban-col={col.id}
|
data-kanban-col={col.id}
|
||||||
className={`bg-canvas border border-border flex flex-col min-h-[420px] overflow-hidden transition-all ${
|
style={kanbanColHeight ? { height: kanbanColHeight } : undefined}
|
||||||
dragOverKanbanCol === col.id ? 'ring-2 ring-accent/60 bg-accent/5' : ''
|
className={`bg-canvas border border-border flex flex-col overflow-hidden transition-all ${
|
||||||
} ${blockedForDrag ? 'opacity-40' : ''}`}
|
kanbanColHeight ? '' : 'min-h-[420px]'
|
||||||
|
} ${dragOverKanbanCol === col.id ? 'ring-2 ring-accent/60 bg-accent/5' : ''} ${blockedForDrag ? 'opacity-40' : ''}`}
|
||||||
title={blockedForDrag ? 'Diese Spalte gibt es in der Liste der Aufgabe nicht' : undefined}
|
title={blockedForDrag ? 'Diese Spalte gibt es in der Liste der Aufgabe nicht' : undefined}
|
||||||
>
|
>
|
||||||
{/* Column Header */}
|
{/* Column Header */}
|
||||||
@@ -2501,7 +2498,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tasks List in Column */}
|
{/* Tasks List in Column */}
|
||||||
<div className="p-2 space-y-2 flex-1 overflow-y-auto max-h-[70vh]">
|
<div className={`p-2 space-y-2 flex-1 min-h-0 overflow-y-auto ${kanbanColHeight ? '' : 'max-h-[70vh]'}`}>
|
||||||
{colTasks.map((task) => {
|
{colTasks.map((task) => {
|
||||||
const isDragging = draggingTaskId === task.id;
|
const isDragging = draggingTaskId === task.id;
|
||||||
const readOnly = isTaskReadOnly(task);
|
const readOnly = isTaskReadOnly(task);
|
||||||
@@ -2748,16 +2745,56 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Grouping and sorting right above the list, then clearing as a plain trash icon (desktop; phones
|
||||||
|
use the menu) */}
|
||||||
|
<div className="hidden sm:flex items-center gap-2 ml-auto">
|
||||||
|
<div className="flex items-center gap-1 text-xs text-muted">
|
||||||
|
<span className="text-[11px]">Gruppe:</span>
|
||||||
|
<select
|
||||||
|
value={groupBy}
|
||||||
|
onChange={(e) => setGroupBy(e.target.value as any)}
|
||||||
|
className="h-8 px-2 input-field text-xs bg-panel"
|
||||||
|
>
|
||||||
|
<option value="none">Keine</option>
|
||||||
|
<option value="tag">Nach Hashtag</option>
|
||||||
|
<option value="status">Nach Status</option>
|
||||||
|
<option value="priority">Nach Priorität</option>
|
||||||
|
<option value="due">Nach Fälligkeit</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 text-xs text-muted">
|
||||||
|
<span className="text-[11px]">Sortierung:</span>
|
||||||
|
<select
|
||||||
|
value={sortBy}
|
||||||
|
onChange={(e) => setSortBy(e.target.value as any)}
|
||||||
|
className="h-8 px-2 input-field text-xs bg-panel"
|
||||||
|
>
|
||||||
|
<option value="custom">Manuell (Drag & Drop)</option>
|
||||||
|
<option value="created">Erstelldatum</option>
|
||||||
|
<option value="due">Fälligkeitsdatum</option>
|
||||||
|
<option value="priority">Priorität</option>
|
||||||
|
<option value="alpha">Titel (A-Z)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{filter === 'discarded' && taskTrash.some((t) => t.canEdit) && (
|
{filter === 'discarded' && taskTrash.some((t) => t.canEdit) && (
|
||||||
<button onClick={handleEmptyTaskTrash} className="btn-action min-h-[38px] sm:min-h-0 ml-auto">
|
<button
|
||||||
|
onClick={handleEmptyTaskTrash}
|
||||||
|
className="btn-action h-8 w-8 p-0 justify-center min-h-[38px] min-w-[38px] sm:min-h-0 sm:min-w-0 ml-auto sm:ml-0"
|
||||||
|
title="Papierkorb leeren"
|
||||||
|
aria-label="Papierkorb leeren"
|
||||||
|
>
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
Papierkorb leeren
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{filter === 'done' && doneTasks.length > 0 && !isSelectedListReadOnly && (
|
{filter === 'done' && doneTasks.length > 0 && !isSelectedListReadOnly && (
|
||||||
<button onClick={handleClearDone} className="btn-action min-h-[38px] sm:min-h-0 ml-auto">
|
<button
|
||||||
|
onClick={handleClearDone}
|
||||||
|
className="btn-action h-8 w-8 p-0 justify-center min-h-[38px] min-w-[38px] sm:min-h-0 sm:min-w-0 ml-auto sm:ml-0"
|
||||||
|
title="Erledigte in den Papierkorb"
|
||||||
|
aria-label="Erledigte in den Papierkorb"
|
||||||
|
>
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
Erledigte leeren
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ export const BacklinksSection: React.FC<BacklinksSectionProps> = ({ docId, title
|
|||||||
onClick={() => onOpen(l.docId)}
|
onClick={() => onOpen(l.docId)}
|
||||||
className="flex items-center gap-2 min-w-0 max-w-full text-left cursor-pointer group"
|
className="flex items-center gap-2 min-w-0 max-w-full text-left cursor-pointer group"
|
||||||
>
|
>
|
||||||
<span className="shrink-0">{l.icon || '📝'}</span>
|
<span className="shrink-0">{l.icon || '🗒️'}</span>
|
||||||
<span className="text-sm font-semibold text-main truncate group-hover:text-accent">
|
<span className="text-sm font-semibold text-main truncate group-hover:text-accent">
|
||||||
{l.title || 'Unbenannte Notiz'}
|
{l.title || 'Unbenannte Notiz'}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1290,7 +1290,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
{inJournal ? (
|
{inJournal ? (
|
||||||
<CalendarDays className="w-6 h-6 sm:w-7 sm:h-7 text-muted" strokeWidth={1.75} />
|
<CalendarDays className="w-6 h-6 sm:w-7 sm:h-7 text-muted" strokeWidth={1.75} />
|
||||||
) : (
|
) : (
|
||||||
doc?.icon || '📝'
|
doc?.icon || '🗒️'
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
{showEmojiPicker && canEdit && (
|
{showEmojiPicker && canEdit && (
|
||||||
|
|||||||
@@ -362,7 +362,7 @@ export const LinkModal: React.FC<LinkModalProps> = ({
|
|||||||
className="flex items-center justify-between p-2.5 bg-canvas border border-border rounded-card hover:border-accent/60 transition-colors group cursor-pointer"
|
className="flex items-center justify-between p-2.5 bg-canvas border border-border rounded-card hover:border-accent/60 transition-colors group cursor-pointer"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2.5 min-w-0">
|
<div className="flex items-center gap-2.5 min-w-0">
|
||||||
<span className="text-base shrink-0">{d.icon || '📝'}</span>
|
<span className="text-base shrink-0">{d.icon || '🗒️'}</span>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="text-xs font-semibold text-main truncate group-hover:text-accent">
|
<div className="text-xs font-semibold text-main truncate group-hover:text-accent">
|
||||||
{d.title || 'Unbenannte Notiz'}
|
{d.title || 'Unbenannte Notiz'}
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ export const MergeDialog: React.FC<MergeDialogProps> = ({ docs, sourceId, target
|
|||||||
onClick={() => setTarget(d.id)}
|
onClick={() => setTarget(d.id)}
|
||||||
className="w-full min-h-[44px] sm:min-h-0 px-3 py-2 flex items-center gap-2 text-left text-xs text-main hover:bg-panel-elevated"
|
className="w-full min-h-[44px] sm:min-h-0 px-3 py-2 flex items-center gap-2 text-left text-xs text-main hover:bg-panel-elevated"
|
||||||
>
|
>
|
||||||
<span className="shrink-0">{d.icon || '📝'}</span>
|
<span className="shrink-0">{d.icon || '🗒️'}</span>
|
||||||
<span className="truncate flex-1">{d.title || 'Unbenannte Notiz'}</span>
|
<span className="truncate flex-1">{d.title || 'Unbenannte Notiz'}</span>
|
||||||
{d.folderPath && <span className="text-[10px] text-faint truncate max-w-[40%]">{d.folderPath}</span>}
|
{d.folderPath && <span className="text-[10px] text-faint truncate max-w-[40%]">{d.folderPath}</span>}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1115,7 +1115,8 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
onClick={() =>
|
onClick={() =>
|
||||||
flushDebounce(localContent, { collapsed: !block.collapsed })
|
flushDebounce(localContent, { collapsed: !block.collapsed })
|
||||||
}
|
}
|
||||||
className="p-0.5 text-muted hover:text-accent transition-[color,opacity] opacity-0 group-hover:opacity-100 focus-visible:opacity-100"
|
// Same width as the spacer of rows without children (the padding is taken back), so bullets stay aligned
|
||||||
|
className="p-0.5 -m-0.5 text-muted hover:text-accent transition-[color,opacity] opacity-0 group-hover:opacity-100 focus-visible:opacity-100"
|
||||||
title={block.collapsed ? 'Unterpunkte ausklappen' : 'Unterpunkte einklappen'}
|
title={block.collapsed ? 'Unterpunkte ausklappen' : 'Unterpunkte einklappen'}
|
||||||
>
|
>
|
||||||
{block.collapsed ? (
|
{block.collapsed ? (
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export const TrashView: React.FC<TrashViewProps> = ({ items, onOpen, onRestore,
|
|||||||
className="flex items-center gap-2.5 flex-1 min-w-0 text-left cursor-pointer group"
|
className="flex items-center gap-2.5 flex-1 min-w-0 text-left cursor-pointer group"
|
||||||
title="Schreibgeschützt ansehen"
|
title="Schreibgeschützt ansehen"
|
||||||
>
|
>
|
||||||
<span className="text-lg shrink-0">{t.icon || '📝'}</span>
|
<span className="text-lg shrink-0">{t.icon || '🗒️'}</span>
|
||||||
<span className="min-w-0">
|
<span className="min-w-0">
|
||||||
<span className="block text-sm font-semibold text-main truncate group-hover:text-accent">
|
<span className="block text-sm font-semibold text-main truncate group-hover:text-accent">
|
||||||
{t.title || 'Unbenannte Notiz'}
|
{t.title || 'Unbenannte Notiz'}
|
||||||
|
|||||||
@@ -282,7 +282,7 @@ const FolderTreeItem: React.FC<FolderTreeItemProps> = ({
|
|||||||
: 'border-transparent text-main hover:bg-panel-elevated'
|
: 'border-transparent text-main hover:bg-panel-elevated'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="shrink-0">{d.icon || '📝'}</span>
|
<span className="shrink-0">{d.icon || '🗒️'}</span>
|
||||||
<span className="truncate flex-1">{d.title || 'Unbenannte Notiz'}</span>
|
<span className="truncate flex-1">{d.title || 'Unbenannte Notiz'}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -455,6 +455,8 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
const [draggingDocIds, setDraggingDocIds] = useState<string[]>([]);
|
const [draggingDocIds, setDraggingDocIds] = useState<string[]>([]);
|
||||||
const [trashDragOver, setTrashDragOver] = useState(false);
|
const [trashDragOver, setTrashDragOver] = useState(false);
|
||||||
const lastMarkedRef = useRef<string | null>(null);
|
const lastMarkedRef = useRef<string | null>(null);
|
||||||
|
// The row last clicked: a pinned note has two rows (pinned and in its folder), the range starts at this one
|
||||||
|
const lastMarkedRowRef = useRef<HTMLElement | null>(null);
|
||||||
const treeRef = useRef<HTMLDivElement>(null);
|
const treeRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Merging notes: dialog (picker/confirmation) and a toast with undo afterwards
|
// Merging notes: dialog (picker/confirmation) and a toast with undo afterwards
|
||||||
@@ -580,14 +582,14 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
}, [currentUser, reloadTrash]);
|
}, [currentUser, reloadTrash]);
|
||||||
|
|
||||||
const handleCreateDocument = useCallback(
|
const handleCreateDocument = useCallback(
|
||||||
async (folderPath = '', title = 'Neue Notiz', icon = '📝') => {
|
async (folderPath = '', title = 'Neue Notiz', icon = '🗒️') => {
|
||||||
if (isCreatingRef.current) return;
|
if (isCreatingRef.current) return;
|
||||||
isCreatingRef.current = true;
|
isCreatingRef.current = true;
|
||||||
triggerHaptic(10);
|
triggerHaptic(10);
|
||||||
try {
|
try {
|
||||||
const created = await api.createDocument({
|
const created = await api.createDocument({
|
||||||
title: title.trim() || 'Neue Notiz',
|
title: title.trim() || 'Neue Notiz',
|
||||||
icon: icon.trim() || '📝',
|
icon: icon.trim() || '🗒️',
|
||||||
folderPath,
|
folderPath,
|
||||||
blocks: [
|
blocks: [
|
||||||
{
|
{
|
||||||
@@ -624,7 +626,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
// The desktop keeps creating at once.
|
// The desktop keeps creating at once.
|
||||||
const [newNoteSheet, setNewNoteSheet] = useState<{ folderPath: string } | null>(null);
|
const [newNoteSheet, setNewNoteSheet] = useState<{ folderPath: string } | null>(null);
|
||||||
const [newNoteTitle, setNewNoteTitle] = useState('');
|
const [newNoteTitle, setNewNoteTitle] = useState('');
|
||||||
const [newNoteIcon, setNewNoteIcon] = useState('📝');
|
const [newNoteIcon, setNewNoteIcon] = useState('🗒️');
|
||||||
const openNewNote = useCallback(
|
const openNewNote = useCallback(
|
||||||
(folderPath = '') => {
|
(folderPath = '') => {
|
||||||
if (window.innerWidth >= 640) {
|
if (window.innerWidth >= 640) {
|
||||||
@@ -632,7 +634,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setNewNoteTitle('');
|
setNewNoteTitle('');
|
||||||
setNewNoteIcon('📝');
|
setNewNoteIcon('🗒️');
|
||||||
setNewNoteSheet({ folderPath });
|
setNewNoteSheet({ folderPath });
|
||||||
},
|
},
|
||||||
[handleCreateDocument]
|
[handleCreateDocument]
|
||||||
@@ -794,6 +796,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
// Ctrl/Cmd+click toggles a note, Shift+click marks the range in the order shown (from the last marked
|
// Ctrl/Cmd+click toggles a note, Shift+click marks the range in the order shown (from the last marked
|
||||||
// or the open note); a plain click clears the marks and opens the note
|
// or the open note); a plain click clears the marks and opens the note
|
||||||
const handleDocRowClick = (e: React.MouseEvent, id: string, open: () => void) => {
|
const handleDocRowClick = (e: React.MouseEvent, id: string, open: () => void) => {
|
||||||
|
const row = (e.currentTarget as HTMLElement).closest<HTMLElement>('[data-doc-row]');
|
||||||
if (e.metaKey || e.ctrlKey) {
|
if (e.metaKey || e.ctrlKey) {
|
||||||
setMarkedDocIds((prev) => {
|
setMarkedDocIds((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
@@ -803,23 +806,32 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
lastMarkedRef.current = id;
|
lastMarkedRef.current = id;
|
||||||
|
lastMarkedRowRef.current = row;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.shiftKey) {
|
if (e.shiftKey) {
|
||||||
const order: string[] = [];
|
// Rows in the order shown; a pinned note appears twice, so positions count, not IDs
|
||||||
treeRef.current?.querySelectorAll<HTMLElement>('[data-doc-row]').forEach((el) => {
|
const rows = Array.from(treeRef.current?.querySelectorAll<HTMLElement>('[data-doc-row]') || []);
|
||||||
const rowId = el.dataset.docRow;
|
const b = row ? rows.indexOf(row) : rows.findIndex((el) => el.dataset.docRow === id);
|
||||||
if (rowId && !order.includes(rowId)) order.push(rowId);
|
const anchorId = lastMarkedRef.current || selectedDocId || id;
|
||||||
});
|
let a = lastMarkedRowRef.current ? rows.indexOf(lastMarkedRowRef.current) : -1;
|
||||||
const anchor = lastMarkedRef.current || selectedDocId || id;
|
if (a < 0 || rows[a].dataset.docRow !== anchorId) {
|
||||||
const a = order.indexOf(anchor);
|
// The open note without a click here: its row nearest to the clicked one
|
||||||
const b = order.indexOf(id);
|
a = -1;
|
||||||
const range = a < 0 || b < 0 ? [id] : order.slice(Math.min(a, b), Math.max(a, b) + 1);
|
rows.forEach((el, i) => {
|
||||||
|
if (el.dataset.docRow === anchorId && (a < 0 || Math.abs(i - b) < Math.abs(a - b))) a = i;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const range =
|
||||||
|
a < 0 || b < 0
|
||||||
|
? [id]
|
||||||
|
: rows.slice(Math.min(a, b), Math.max(a, b) + 1).map((el) => el.dataset.docRow || '').filter(Boolean);
|
||||||
setMarkedDocIds((prev) => new Set([...prev, ...range]));
|
setMarkedDocIds((prev) => new Set([...prev, ...range]));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setMarkedDocIds(new Set());
|
setMarkedDocIds(new Set());
|
||||||
lastMarkedRef.current = id;
|
lastMarkedRef.current = id;
|
||||||
|
lastMarkedRowRef.current = row;
|
||||||
open();
|
open();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1408,7 +1420,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
map.set(d.id, {
|
map.set(d.id, {
|
||||||
docId: d.id,
|
docId: d.id,
|
||||||
title: d.title || 'Unbenannte Notiz',
|
title: d.title || 'Unbenannte Notiz',
|
||||||
icon: d.icon || '📝',
|
icon: d.icon || '🗒️',
|
||||||
folderPath: d.folderPath || '',
|
folderPath: d.folderPath || '',
|
||||||
matchedBlocks: matched,
|
matchedBlocks: matched,
|
||||||
});
|
});
|
||||||
@@ -1421,7 +1433,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
map.set(dg.docId, {
|
map.set(dg.docId, {
|
||||||
docId: dg.docId,
|
docId: dg.docId,
|
||||||
title: dg.title || 'Unbenannte Notiz',
|
title: dg.title || 'Unbenannte Notiz',
|
||||||
icon: dg.icon || '📝',
|
icon: dg.icon || '🗒️',
|
||||||
folderPath: dg.folderPath || '',
|
folderPath: dg.folderPath || '',
|
||||||
matchedBlocks: dg.matchedBlocks || [],
|
matchedBlocks: dg.matchedBlocks || [],
|
||||||
});
|
});
|
||||||
@@ -1978,7 +1990,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
: 'border-transparent text-main hover:bg-panel-elevated'
|
: 'border-transparent text-main hover:bg-panel-elevated'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="shrink-0">{d.icon || '📝'}</span>
|
<span className="shrink-0">{d.icon || '🗒️'}</span>
|
||||||
<span className="truncate flex-1">{d.title || 'Unbenannte Notiz'}</span>
|
<span className="truncate flex-1">{d.title || 'Unbenannte Notiz'}</span>
|
||||||
{d.sharedWith && d.sharedWith.length > 0 && (
|
{d.sharedWith && d.sharedWith.length > 0 && (
|
||||||
<Users className="w-3 h-3 text-status-ok shrink-0" />
|
<Users className="w-3 h-3 text-status-ok shrink-0" />
|
||||||
@@ -2049,7 +2061,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
: 'border-transparent text-main hover:bg-panel-elevated'
|
: 'border-transparent text-main hover:bg-panel-elevated'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="shrink-0">{d.icon || '📝'}</span>
|
<span className="shrink-0">{d.icon || '🗒️'}</span>
|
||||||
<span className="truncate flex-1">{d.title || 'Unbenannte Notiz'}</span>
|
<span className="truncate flex-1">{d.title || 'Unbenannte Notiz'}</span>
|
||||||
{d.sharedWith && d.sharedWith.length > 0 && (
|
{d.sharedWith && d.sharedWith.length > 0 && (
|
||||||
<Users className="w-3 h-3 text-status-ok shrink-0" />
|
<Users className="w-3 h-3 text-status-ok shrink-0" />
|
||||||
@@ -2351,7 +2363,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
className="inline-flex items-center gap-1.5 text-[11px] px-2 py-0.5 rounded-btn bg-panel-elevated text-main truncate max-w-full"
|
className="inline-flex items-center gap-1.5 text-[11px] px-2 py-0.5 rounded-btn bg-panel-elevated text-main truncate max-w-full"
|
||||||
title={meta.docs[0].title}
|
title={meta.docs[0].title}
|
||||||
>
|
>
|
||||||
<span>{meta.docs[0].icon || '📝'}</span>
|
<span>{meta.docs[0].icon || '🗒️'}</span>
|
||||||
<span className="truncate">{meta.docs[0].title || 'Unbenannte Notiz'}</span>
|
<span className="truncate">{meta.docs[0].title || 'Unbenannte Notiz'}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2452,7 +2464,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
onClick={() => handleJumpToDocBlock(docGroup.docId)}
|
onClick={() => handleJumpToDocBlock(docGroup.docId)}
|
||||||
className="flex items-center gap-2 text-xs font-semibold text-accent hover:underline cursor-pointer"
|
className="flex items-center gap-2 text-xs font-semibold text-accent hover:underline cursor-pointer"
|
||||||
>
|
>
|
||||||
<span>{docGroup.icon || '📝'}</span>
|
<span>{docGroup.icon || '🗒️'}</span>
|
||||||
<span>{docGroup.title}</span>
|
<span>{docGroup.title}</span>
|
||||||
{docGroup.folderPath && (
|
{docGroup.folderPath && (
|
||||||
<span className="badge-neutral text-[10px] font-normal">
|
<span className="badge-neutral text-[10px] font-normal">
|
||||||
@@ -2702,7 +2714,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-btn bg-main/[0.06] text-muted hover:text-main"
|
className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-btn bg-main/[0.06] text-muted hover:text-main"
|
||||||
title={item.doc.title}
|
title={item.doc.title}
|
||||||
>
|
>
|
||||||
<span>{item.doc.icon || '📝'}</span>
|
<span>{item.doc.icon || '🗒️'}</span>
|
||||||
<span className="truncate max-w-[120px]">{item.doc.title}</span>
|
<span className="truncate max-w-[120px]">{item.doc.title}</span>
|
||||||
{item.doc.folderPath && (
|
{item.doc.folderPath && (
|
||||||
<span className="text-faint text-[10px]">({item.doc.folderPath})</span>
|
<span className="text-faint text-[10px]">({item.doc.folderPath})</span>
|
||||||
@@ -2803,7 +2815,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
) : (
|
) : (
|
||||||
<ChevronDown className="w-3 h-3 text-accent" />
|
<ChevronDown className="w-3 h-3 text-accent" />
|
||||||
)}
|
)}
|
||||||
<span>{doc.icon || '📝'}</span>
|
<span>{doc.icon || '🗒️'}</span>
|
||||||
<span>{doc.title}</span>
|
<span>{doc.title}</span>
|
||||||
<span className="badge-neutral text-[9px] font-mono">
|
<span className="badge-neutral text-[9px] font-mono">
|
||||||
{docOpenCount} offen
|
{docOpenCount} offen
|
||||||
@@ -2955,7 +2967,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<EmojiIconButton value={newNoteIcon} onChange={setNewNoteIcon} fallback="📝" />
|
<EmojiIconButton value={newNoteIcon} onChange={setNewNoteIcon} fallback="🗒️" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
autoFocus
|
autoFocus
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ const LIVE_SILENCE_MS = 800;
|
|||||||
const LIVE_TICK_MS = 200;
|
const LIVE_TICK_MS = 200;
|
||||||
const LIVE_SPEECH_LEVEL = 0.02;
|
const LIVE_SPEECH_LEVEL = 0.02;
|
||||||
const MIN_RESTART_INTERVAL_MS = 300;
|
const MIN_RESTART_INTERVAL_MS = 300;
|
||||||
|
// Browser recognition on a tap: it stops only after this much silence, not at the first short pause
|
||||||
|
const BROWSER_SILENCE_STOP_MS = 5000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Microphone input for one button: start, stop (text arrives), cancel (nothing arrives). How the text
|
* Microphone input for one button: start, stop (text arrives), cancel (nothing arrives). How the text
|
||||||
@@ -38,6 +40,7 @@ export function useVoiceInput({ onText, onError, continuous = false, live = fals
|
|||||||
const timerRef = useRef<number | null>(null);
|
const timerRef = useRef<number | null>(null);
|
||||||
const segmentTimerRef = useRef<number | null>(null);
|
const segmentTimerRef = useRef<number | null>(null);
|
||||||
const restartTimerRef = useRef<number | null>(null);
|
const restartTimerRef = useRef<number | null>(null);
|
||||||
|
const silenceTimerRef = useRef<number | null>(null);
|
||||||
const cancelledRef = useRef(false);
|
const cancelledRef = useRef(false);
|
||||||
const stoppedRef = useRef(false);
|
const stoppedRef = useRef(false);
|
||||||
const transcriptRef = useRef('');
|
const transcriptRef = useRef('');
|
||||||
@@ -59,6 +62,8 @@ export function useVoiceInput({ onText, onError, continuous = false, live = fals
|
|||||||
segmentTimerRef.current = null;
|
segmentTimerRef.current = null;
|
||||||
if (restartTimerRef.current !== null) window.clearTimeout(restartTimerRef.current);
|
if (restartTimerRef.current !== null) window.clearTimeout(restartTimerRef.current);
|
||||||
restartTimerRef.current = null;
|
restartTimerRef.current = null;
|
||||||
|
if (silenceTimerRef.current !== null) window.clearTimeout(silenceTimerRef.current);
|
||||||
|
silenceTimerRef.current = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -240,8 +245,17 @@ export function useVoiceInput({ onText, onError, continuous = false, live = fals
|
|||||||
const recognition = new Recognition();
|
const recognition = new Recognition();
|
||||||
recognition.lang = 'de-DE';
|
recognition.lang = 'de-DE';
|
||||||
recognition.interimResults = true;
|
recognition.interimResults = true;
|
||||||
recognition.continuous = continuous || live;
|
// A tap (neither hold nor dictation) listens through short pauses and ends after 5 s of silence
|
||||||
|
const stopOnSilence = !continuous && !live;
|
||||||
|
recognition.continuous = continuous || live || stopOnSilence;
|
||||||
|
const armSilenceStop = () => {
|
||||||
|
if (!stopOnSilence) return;
|
||||||
|
if (silenceTimerRef.current !== null) window.clearTimeout(silenceTimerRef.current);
|
||||||
|
silenceTimerRef.current = window.setTimeout(() => void stop(), BROWSER_SILENCE_STOP_MS);
|
||||||
|
};
|
||||||
|
armSilenceStop();
|
||||||
recognition.onresult = (event: any) => {
|
recognition.onresult = (event: any) => {
|
||||||
|
armSilenceStop();
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
for (let i = 0; i < event.results.length; i++) parts.push(event.results[i][0].transcript);
|
for (let i = 0; i < event.results.length; i++) parts.push(event.results[i][0].transcript);
|
||||||
const sessionText = joinSpeechResults(parts);
|
const sessionText = joinSpeechResults(parts);
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ func newCollabFixture(t *testing.T) collabFixture {
|
|||||||
editor, editorTok := newTestUser(t, store, "editor@example.com")
|
editor, editorTok := newTestUser(t, store, "editor@example.com")
|
||||||
viewer, viewerTok := newTestUser(t, store, "viewer@example.com")
|
viewer, viewerTok := newTestUser(t, store, "viewer@example.com")
|
||||||
_, outsiderTok := newTestUser(t, store, "outsider2@example.com")
|
_, outsiderTok := newTestUser(t, store, "outsider2@example.com")
|
||||||
doc, err := store.CreateDocument(owner.ID, "", "Gemeinsam", "📄", "", []domain.DocumentBlock{{ID: "b1", Type: "bullet", Content: "Erster Punkt"}}, nil, false)
|
doc, err := store.CreateDocument(owner.ID, "", "Gemeinsam", "🗒️", "", []domain.DocumentBlock{{ID: "b1", Type: "bullet", Content: "Erster Punkt"}}, nil, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("create doc: %v", err)
|
t.Fatalf("create doc: %v", err)
|
||||||
}
|
}
|
||||||
@@ -199,7 +199,7 @@ func itoa(n int64) string {
|
|||||||
|
|
||||||
func TestDocSyncAfterCompaction(t *testing.T) {
|
func TestDocSyncAfterCompaction(t *testing.T) {
|
||||||
srv, store, ts, owner, ownerTok := startRealtimeServer(t)
|
srv, store, ts, owner, ownerTok := startRealtimeServer(t)
|
||||||
doc, err := store.CreateDocument(owner.ID, "", "Alt", "📄", "", []domain.DocumentBlock{
|
doc, err := store.CreateDocument(owner.ID, "", "Alt", "🗒️", "", []domain.DocumentBlock{
|
||||||
{ID: "b1", Type: "bullet", Content: "Bleibt"}, {ID: "b2", Type: "bullet", Content: "Weg"},
|
{ID: "b1", Type: "bullet", Content: "Bleibt"}, {ID: "b2", Type: "bullet", Content: "Weg"},
|
||||||
}, nil, false)
|
}, nil, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -310,7 +310,7 @@ func TestWebSocketHeartbeat(t *testing.T) {
|
|||||||
func TestDocChangesArePushedToTheNoteRoom(t *testing.T) {
|
func TestDocChangesArePushedToTheNoteRoom(t *testing.T) {
|
||||||
_, store, ts, owner, ownerToken := startRealtimeServer(t)
|
_, store, ts, owner, ownerToken := startRealtimeServer(t)
|
||||||
editor, editorToken := newTestUser(t, store, "push-editor@example.com")
|
editor, editorToken := newTestUser(t, store, "push-editor@example.com")
|
||||||
doc, err := store.CreateDocument(owner.ID, "", "Push", "📄", "", []domain.DocumentBlock{{ID: "push_b1", Type: "bullet", Content: "Start"}}, nil, false)
|
doc, err := store.CreateDocument(owner.ID, "", "Push", "🗒️", "", []domain.DocumentBlock{{ID: "push_b1", Type: "bullet", Content: "Start"}}, nil, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -315,7 +315,7 @@ func (w *simWorld) sync(c *simClient, reliable bool) {
|
|||||||
|
|
||||||
func runSimulation(t *testing.T, seed int64, steps int) {
|
func runSimulation(t *testing.T, seed int64, steps int) {
|
||||||
store, u := newSyncTestStore(t)
|
store, u := newSyncTestStore(t)
|
||||||
doc, err := store.CreateDocument(u.ID, "", "Sim", "📄", "", flatBlocks([3]interface{}{"root", 0, "Start"}), nil, false)
|
doc, err := store.CreateDocument(u.ID, "", "Sim", "🗒️", "", flatBlocks([3]interface{}{"root", 0, "Start"}), nil, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ func TestLegacySaveRoundTripsThroughTheLog(t *testing.T) {
|
|||||||
[3]interface{}{"d", 1, "Aufgabe 2"},
|
[3]interface{}{"d", 1, "Aufgabe 2"},
|
||||||
[3]interface{}{"e", 0, "Anderes"},
|
[3]interface{}{"e", 0, "Anderes"},
|
||||||
)
|
)
|
||||||
doc, err := s.CreateDocument(u.ID, "", "Notiz", "📄", "", initial, nil, false)
|
doc, err := s.CreateDocument(u.ID, "", "Notiz", "🗒️", "", initial, nil, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -111,7 +111,7 @@ func TestLegacySaveRoundTripsThroughTheLog(t *testing.T) {
|
|||||||
|
|
||||||
func TestApplyDocOpsIsIdempotent(t *testing.T) {
|
func TestApplyDocOpsIsIdempotent(t *testing.T) {
|
||||||
s, u := newSyncTestStore(t)
|
s, u := newSyncTestStore(t)
|
||||||
doc, _ := s.CreateDocument(u.ID, "", "Notiz", "📄", "", flatBlocks([3]interface{}{"a", 0, "Start"}), nil, false)
|
doc, _ := s.CreateDocument(u.ID, "", "Notiz", "🗒️", "", flatBlocks([3]interface{}{"a", 0, "Start"}), nil, false)
|
||||||
a := collab.Actor{UserID: u.ID, ClientID: "tab1", Name: "Owner"}
|
a := collab.Actor{UserID: u.ID, ClientID: "tab1", Name: "Owner"}
|
||||||
text := "Geändert"
|
text := "Geändert"
|
||||||
op := collab.Op{OpID: "op-1", Kind: collab.OpSetContent, BlockID: "a", BaseVersion: 1, Content: &text}
|
op := collab.Op{OpID: "op-1", Kind: collab.OpSetContent, BlockID: "a", BaseVersion: 1, Content: &text}
|
||||||
@@ -129,8 +129,8 @@ func TestApplyDocOpsIsIdempotent(t *testing.T) {
|
|||||||
|
|
||||||
func TestInsertCannotReuseBlockIDOfAnotherNote(t *testing.T) {
|
func TestInsertCannotReuseBlockIDOfAnotherNote(t *testing.T) {
|
||||||
s, u := newSyncTestStore(t)
|
s, u := newSyncTestStore(t)
|
||||||
other, _ := s.CreateDocument(u.ID, "", "Andere", "📄", "", flatBlocks([3]interface{}{"shared_id", 0, "Fremd"}), nil, false)
|
other, _ := s.CreateDocument(u.ID, "", "Andere", "🗒️", "", flatBlocks([3]interface{}{"shared_id", 0, "Fremd"}), nil, false)
|
||||||
doc, _ := s.CreateDocument(u.ID, "", "Notiz", "📄", "", nil, nil, false)
|
doc, _ := s.CreateDocument(u.ID, "", "Notiz", "🗒️", "", nil, nil, false)
|
||||||
a := collab.Actor{UserID: u.ID, ClientID: "tab1"}
|
a := collab.Actor{UserID: u.ID, ClientID: "tab1"}
|
||||||
content, root := "Übernahme", ""
|
content, root := "Übernahme", ""
|
||||||
res, _, err := s.ApplyDocOps(doc.ID, a, []collab.Op{{OpID: "x", Kind: collab.OpInsert, BlockID: "shared_id", ParentID: &root, OrderKey: "a0", Content: &content}}, nil)
|
res, _, err := s.ApplyDocOps(doc.ID, a, []collab.Op{{OpID: "x", Kind: collab.OpInsert, BlockID: "shared_id", ParentID: &root, OrderKey: "a0", Content: &content}}, nil)
|
||||||
@@ -148,7 +148,7 @@ func TestDocRole(t *testing.T) {
|
|||||||
reader, _ := s.CreateUser("reader@example.com", "hash", "Reader", "member")
|
reader, _ := s.CreateUser("reader@example.com", "hash", "Reader", "member")
|
||||||
writer, _ := s.CreateUser("writer@example.com", "hash", "Writer", "member")
|
writer, _ := s.CreateUser("writer@example.com", "hash", "Writer", "member")
|
||||||
stranger, _ := s.CreateUser("stranger@example.com", "hash", "Stranger", "member")
|
stranger, _ := s.CreateUser("stranger@example.com", "hash", "Stranger", "member")
|
||||||
doc, _ := s.CreateDocument(owner.ID, "", "Notiz", "📄", "", nil, nil, false)
|
doc, _ := s.CreateDocument(owner.ID, "", "Notiz", "🗒️", "", nil, nil, false)
|
||||||
|
|
||||||
now := domain.NowISO()
|
now := domain.NowISO()
|
||||||
if _, err := s.db.Exec(`INSERT INTO doc_shares (id, doc_id, user_id, permission, created_at, created_by) VALUES ('s1', ?, ?, 'read', ?, ?), ('s2', ?, ?, 'write', ?, ?)`,
|
if _, err := s.db.Exec(`INSERT INTO doc_shares (id, doc_id, user_id, permission, created_at, created_by) VALUES ('s1', ?, ?, 'read', ?, ?), ('s2', ?, ?, 'write', ?, ?)`,
|
||||||
@@ -167,7 +167,7 @@ func TestDocRole(t *testing.T) {
|
|||||||
|
|
||||||
func TestBackfillConvertsFlatBlocksToTree(t *testing.T) {
|
func TestBackfillConvertsFlatBlocksToTree(t *testing.T) {
|
||||||
s, u := newSyncTestStore(t)
|
s, u := newSyncTestStore(t)
|
||||||
doc, _ := s.CreateDocument(u.ID, "", "Alt", "📄", "", nil, nil, false)
|
doc, _ := s.CreateDocument(u.ID, "", "Alt", "🗒️", "", nil, nil, false)
|
||||||
if _, err := s.db.Exec(`DELETE FROM blocks WHERE document_id = ?`, doc.ID); err != nil {
|
if _, err := s.db.Exec(`DELETE FROM blocks WHERE document_id = ?`, doc.ID); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -186,7 +186,7 @@ func TestBackfillConvertsFlatBlocksToTree(t *testing.T) {
|
|||||||
|
|
||||||
func TestTaskLinksFollowTheirBlock(t *testing.T) {
|
func TestTaskLinksFollowTheirBlock(t *testing.T) {
|
||||||
s, u := newSyncTestStore(t)
|
s, u := newSyncTestStore(t)
|
||||||
doc, _ := s.CreateDocument(u.ID, "", "Notiz", "📄", "", []domain.DocumentBlock{{ID: "todo1", Type: "todo", Content: "Anrufen"}}, nil, false)
|
doc, _ := s.CreateDocument(u.ID, "", "Notiz", "🗒️", "", []domain.DocumentBlock{{ID: "todo1", Type: "todo", Content: "Anrufen"}}, nil, false)
|
||||||
task, err := s.CreateTaskFromDocBlock(u.ID, doc.ID, "todo1", "Anrufen", false)
|
task, err := s.CreateTaskFromDocBlock(u.ID, doc.ID, "todo1", "Anrufen", false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -225,8 +225,8 @@ func TestFixedBlockIDsWorkInSeveralNotes(t *testing.T) {
|
|||||||
s, u := newSyncTestStore(t)
|
s, u := newSyncTestStore(t)
|
||||||
other, _ := s.CreateUser("second@example.com", "hash", "Second", "member")
|
other, _ := s.CreateUser("second@example.com", "hash", "Second", "member")
|
||||||
blocks := flatBlocks([3]interface{}{"blk_1", 0, "Willkommen"}, [3]interface{}{"blk_2", 1, "Unterpunkt"})
|
blocks := flatBlocks([3]interface{}{"blk_1", 0, "Willkommen"}, [3]interface{}{"blk_2", 1, "Unterpunkt"})
|
||||||
first, _ := s.CreateDocument(u.ID, "", "Start", "📄", "", blocks, nil, false)
|
first, _ := s.CreateDocument(u.ID, "", "Start", "🗒️", "", blocks, nil, false)
|
||||||
second, _ := s.CreateDocument(other.ID, "", "Start", "📄", "", blocks, nil, false)
|
second, _ := s.CreateDocument(other.ID, "", "Start", "🗒️", "", blocks, nil, false)
|
||||||
|
|
||||||
a, _ := s.GetDocumentByID(u.ID, first.ID)
|
a, _ := s.GetDocumentByID(u.ID, first.ID)
|
||||||
b, _ := s.GetDocumentByID(other.ID, second.ID)
|
b, _ := s.GetDocumentByID(other.ID, second.ID)
|
||||||
@@ -254,7 +254,7 @@ func backdate(t *testing.T, s *Store, docID string, age time.Duration) {
|
|||||||
|
|
||||||
func TestCompactionKeepsStateAndForcesResync(t *testing.T) {
|
func TestCompactionKeepsStateAndForcesResync(t *testing.T) {
|
||||||
s, u := newSyncTestStore(t)
|
s, u := newSyncTestStore(t)
|
||||||
doc, _ := s.CreateDocument(u.ID, "", "Notiz", "📄", "", flatBlocks(
|
doc, _ := s.CreateDocument(u.ID, "", "Notiz", "🗒️", "", flatBlocks(
|
||||||
[3]interface{}{"a", 0, "Bleibt"},
|
[3]interface{}{"a", 0, "Bleibt"},
|
||||||
[3]interface{}{"b", 0, "Gelöscht"},
|
[3]interface{}{"b", 0, "Gelöscht"},
|
||||||
[3]interface{}{"c", 1, "Kind von b"},
|
[3]interface{}{"c", 1, "Kind von b"},
|
||||||
@@ -324,8 +324,8 @@ func TestCompactionKeepsStateAndForcesResync(t *testing.T) {
|
|||||||
func TestCompactionRetentionDependsOnSharing(t *testing.T) {
|
func TestCompactionRetentionDependsOnSharing(t *testing.T) {
|
||||||
s, u := newSyncTestStore(t)
|
s, u := newSyncTestStore(t)
|
||||||
other, _ := s.CreateUser("ben@example.com", "hash", "Ben", "member")
|
other, _ := s.CreateUser("ben@example.com", "hash", "Ben", "member")
|
||||||
private, _ := s.CreateDocument(u.ID, "", "Privat", "📄", "", flatBlocks([3]interface{}{"p", 0, "x"}), nil, false)
|
private, _ := s.CreateDocument(u.ID, "", "Privat", "🗒️", "", flatBlocks([3]interface{}{"p", 0, "x"}), nil, false)
|
||||||
shared, _ := s.CreateDocument(u.ID, "", "Geteilt", "📄", "", flatBlocks([3]interface{}{"s", 0, "x"}), nil, false)
|
shared, _ := s.CreateDocument(u.ID, "", "Geteilt", "🗒️", "", flatBlocks([3]interface{}{"s", 0, "x"}), nil, false)
|
||||||
if _, err := s.CreateDocShare(u.ID, shared.ID, other.Email, "write"); err != nil {
|
if _, err := s.CreateDocShare(u.ID, shared.ID, other.Email, "write"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -344,7 +344,7 @@ func TestCompactionRetentionDependsOnSharing(t *testing.T) {
|
|||||||
|
|
||||||
func TestDeletedAtFollowsDeleteAndRestore(t *testing.T) {
|
func TestDeletedAtFollowsDeleteAndRestore(t *testing.T) {
|
||||||
s, u := newSyncTestStore(t)
|
s, u := newSyncTestStore(t)
|
||||||
doc, _ := s.CreateDocument(u.ID, "", "Notiz", "📄", "", flatBlocks([3]interface{}{"a", 0, "x"}, [3]interface{}{"b", 0, "y"}), nil, false)
|
doc, _ := s.CreateDocument(u.ID, "", "Notiz", "🗒️", "", flatBlocks([3]interface{}{"a", 0, "x"}, [3]interface{}{"b", 0, "y"}), nil, false)
|
||||||
a := collab.Actor{UserID: u.ID, ClientID: "tab1"}
|
a := collab.Actor{UserID: u.ID, ClientID: "tab1"}
|
||||||
deletedAt := func() string {
|
deletedAt := func() string {
|
||||||
var v string
|
var v string
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ func (s *Store) CreateDocument(userID, spaceID, title, icon, folderPath string,
|
|||||||
title = "Unbenannte Notiz"
|
title = "Unbenannte Notiz"
|
||||||
}
|
}
|
||||||
if icon == "" {
|
if icon == "" {
|
||||||
icon = "📄"
|
icon = "🗒️"
|
||||||
}
|
}
|
||||||
if blocks == nil {
|
if blocks == nil {
|
||||||
blocks = []domain.DocumentBlock{
|
blocks = []domain.DocumentBlock{
|
||||||
|
|||||||
@@ -536,7 +536,7 @@ func (s *Store) ApplyImport(userID string, plan *ImportPlan) (ImportSummary, err
|
|||||||
}
|
}
|
||||||
icon := d.icon
|
icon := d.icon
|
||||||
if icon == "" {
|
if icon == "" {
|
||||||
icon = "📄"
|
icon = "🗒️"
|
||||||
}
|
}
|
||||||
blocks, files := linkImportMedia(d, plan.Media, nil)
|
blocks, files := linkImportMedia(d, plan.Media, nil)
|
||||||
doc, err := s.CreateDocument(userID, "", title, icon, d.folder, blocks, nil, false)
|
doc, err := s.CreateDocument(userID, "", title, icon, d.folder, blocks, nil, false)
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ func (s *Store) ExtractBranch(userID, docID, blockID string, a collab.Actor, lea
|
|||||||
|
|
||||||
base, tags := branchTitle(root.Content)
|
base, tags := branchTitle(root.Content)
|
||||||
title := s.uniqueTitle(userID, base)
|
title := s.uniqueTitle(userID, base)
|
||||||
created, err := s.CreateDocument(userID, "", title, "📄", source.FolderPath, children, nil, false)
|
created, err := s.CreateDocument(userID, "", title, "🗒️", source.FolderPath, children, nil, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ func TestStoreLifecycle(t *testing.T) {
|
|||||||
if !store.CanEditTaskList(user2.ID, taskList.ID) {
|
if !store.CanEditTaskList(user2.ID, taskList.ID) {
|
||||||
t.Fatalf("editor share should allow editing")
|
t.Fatalf("editor share should allow editing")
|
||||||
}
|
}
|
||||||
newDoc, err := store.CreateDocument(user.ID, "", "Test Note", "📄", "", []domain.DocumentBlock{
|
newDoc, err := store.CreateDocument(user.ID, "", "Test Note", "🗒️", "", []domain.DocumentBlock{
|
||||||
{ID: "blk_1", Type: "bullet", Content: "Geheim", Level: 0},
|
{ID: "blk_1", Type: "bullet", Content: "Geheim", Level: 0},
|
||||||
}, []string{"test"}, false)
|
}, []string{"test"}, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -393,7 +393,7 @@ func TestFolderManagementAndDeletion(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. Create document in subfolder
|
// 2. Create document in subfolder
|
||||||
doc1, err := store.CreateDocument(user.ID, "", "Doc in MindSift", "📄", "Projekte/MindSift", nil, nil, false)
|
doc1, err := store.CreateDocument(user.ID, "", "Doc in MindSift", "🗒️", "Projekte/MindSift", nil, nil, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateDocument failed: %v", err)
|
t.Fatalf("CreateDocument failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -383,7 +383,7 @@ func (s *Store) UpdateThoughtTriage(userID, thoughtID, action, convertTo, target
|
|||||||
Level: 0,
|
Level: 0,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
doc, err := s.CreateDocument(userID, "", title, "📄", "", blocks, thought.Tags, false)
|
doc, err := s.CreateDocument(userID, "", title, "🗒️", "", blocks, thought.Tags, false)
|
||||||
if err == nil && doc != nil {
|
if err == nil && doc != nil {
|
||||||
convertedRefID = doc.ID
|
convertedRefID = doc.ID
|
||||||
// A task stays in its list and becomes the note's first checkbox (same link as a task made
|
// A task stays in its list and becomes the note's first checkbox (same link as a task made
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ func TestVisibilityFollowsOrganizations(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Sharing: visible people only; an invisible e-mail looks like an unknown one
|
// Sharing: visible people only; an invisible e-mail looks like an unknown one
|
||||||
doc, _ := store.CreateDocument(anna.ID, "", "Einkauf", "📄", "", nil, nil, false)
|
doc, _ := store.CreateDocument(anna.ID, "", "Einkauf", "🗒️", "", nil, nil, false)
|
||||||
if _, err := store.CreateDocShare(anna.ID, doc.ID, ben.ID, "write"); err != nil {
|
if _, err := store.CreateDocShare(anna.ID, doc.ID, ben.ID, "write"); err != nil {
|
||||||
t.Fatalf("share with Ben failed: %v", err)
|
t.Fatalf("share with Ben failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user