feat(lists): drag tasks and list entries onto another list; remember open list and view; live pill in lists
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m3s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m3s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6abcfa655b
commit
25bb787a8e
13 files changed
+298
-52
No files matched your search
+11
-3
@@ -6,7 +6,15 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
## [0.16.2] - 2026-10-31
|
## [0.17.0] - 2026-10-31
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Aufgaben und Kanban: Eine Aufgabe lässt sich auf den Reiter einer anderen Aufgabenliste ziehen und landet dort wie eine neue Aufgabe. Ihren Kanban-Status behält sie, wenn die Zielliste diese Spalte hat, sonst startet sie in der ersten Spalte. Nur lesbare Listen nehmen nichts an.
|
||||||
|
- Einkaufslisten: Einträge lassen sich genauso auf den Reiter einer anderen Liste ziehen; Kategorie, Menge und Haken bleiben erhalten. Neu dafür ist `POST /api/v1/lists/{id}/items/{itemId}/move` (Bearbeitungsrecht in beiden Listen nötig).
|
||||||
|
- Aufgaben und Einkaufslisten merken sich beim Wechsel zwischen den Reitern der App die offene Liste, Aufgaben außerdem Liste oder Kanban (pro Browser-Tab, wie der Workspace).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Einkaufslisten: Die Verbindungsanzeige oben rechts sieht jetzt aus wie bei den Notizen (Punkt und „Live“ bzw. „Offline“) statt des alten Funk-Symbols.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- Push-Benachrichtigungen am Handy: In der Statusleiste erschien statt des Gehirn-Symbols nur ein einfarbiges Quadrat. Android nutzt dort nur die Transparenz des Bildes; MindSift liefert dafür jetzt ein eigenes Symbol (weißes Gehirn auf transparentem Grund). Die App-Caches werden einmal erneuert.
|
- Push-Benachrichtigungen am Handy: In der Statusleiste erschien statt des Gehirn-Symbols nur ein einfarbiges Quadrat. Android nutzt dort nur die Transparenz des Bildes; MindSift liefert dafür jetzt ein eigenes Symbol (weißes Gehirn auf transparentem Grund). Die App-Caches werden einmal erneuert.
|
||||||
@@ -387,8 +395,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.16.2...dev
|
[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.17.0...dev
|
||||||
[0.16.2]: https://git.dsit.me/sandrodach/MindSift/compare/v0.16.1...v0.16.2
|
[0.17.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.16.1...v0.17.0
|
||||||
[0.16.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.16.0...v0.16.1
|
[0.16.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.16.0...v0.16.1
|
||||||
[0.16.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.15.1...v0.16.0
|
[0.16.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.15.1...v0.16.0
|
||||||
[0.15.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.15.0...v0.15.1
|
[0.15.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.15.0...v0.15.1
|
||||||
|
|||||||
@@ -190,6 +190,7 @@ Wiederholte Operationen (gleiche `opId`) werden nicht erneut angewendet; die Ant
|
|||||||
| PATCH | `/lists/{id}/items/{itemId}` | Abhaken (`isCompleted`) |
|
| PATCH | `/lists/{id}/items/{itemId}` | Abhaken (`isCompleted`) |
|
||||||
| PUT | `/lists/{id}/items/{itemId}` | Ändern (`content`, `category`, `quantity`) |
|
| PUT | `/lists/{id}/items/{itemId}` | Ändern (`content`, `category`, `quantity`) |
|
||||||
| DELETE | `/lists/{id}/items/{itemId}` | Löschen |
|
| DELETE | `/lists/{id}/items/{itemId}` | Löschen |
|
||||||
|
| POST | `/lists/{id}/items/{itemId}/move` | In eine andere Liste verschieben (`targetListId`); Bearbeitungsrecht in beiden Listen nötig, der Eintrag landet oben |
|
||||||
| POST/PUT | `/lists/{id}/items/reorder` | Reihenfolge/Kategorie (`items[{id, sortOrder, category, content}]`) |
|
| POST/PUT | `/lists/{id}/items/reorder` | Reihenfolge/Kategorie (`items[{id, sortOrder, category, content}]`) |
|
||||||
| POST | `/lists/{id}/clear-completed` | Erledigte entfernen |
|
| POST | `/lists/{id}/clear-completed` | Erledigte entfernen |
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@ Die meisten Notiz- und Aufgaben-Apps verlangen Struktur im falschen Moment: Wenn
|
|||||||
|
|
||||||
* Listen- und Kanban-Ansicht, Spalten pro Aufgabenliste frei konfigurierbar.
|
* Listen- und Kanban-Ansicht, Spalten pro Aufgabenliste frei konfigurierbar.
|
||||||
* Prioritäten, Fälligkeitsdatum, Hashtags, Unteraufgaben mit Fortschrittsbalken, Markdown-Notizen.
|
* Prioritäten, Fälligkeitsdatum, Hashtags, Unteraufgaben mit Fortschrittsbalken, Markdown-Notizen.
|
||||||
* Filter, Gruppierung und Sortierung; Drag & Drop in Liste und Board.
|
* Filter, Gruppierung und Sortierung; Drag & Drop in Liste und Board, auch auf den Reiter einer anderen Aufgabenliste.
|
||||||
* **Aufgabenlisten teilen** mit Benutzern oder Gruppen, wahlweise als **Bearbeiter** oder **nur lesend**.
|
* **Aufgabenlisten teilen** mit Benutzern oder Gruppen, wahlweise als **Bearbeiter** oder **nur lesend**.
|
||||||
* **Aufgaben beobachten:** Beliebige Aufgaben per Klick abonnieren, um über alle Änderungen informiert zu werden.
|
* **Aufgaben beobachten:** Beliebige Aufgaben per Klick abonnieren, um über alle Änderungen informiert zu werden.
|
||||||
* **Wiederholungen:** Wiederkehrende Aufgaben (täglich, wöchentlich, monatlich, jährlich) mit automatischer Folgeaufgabe beim Abhaken.
|
* **Wiederholungen:** Wiederkehrende Aufgaben (täglich, wöchentlich, monatlich, jährlich) mit automatischer Folgeaufgabe beim Abhaken.
|
||||||
@@ -97,7 +97,7 @@ Technik dahinter: [Doku/modules/collaboration.md](Doku/modules/collaboration.md)
|
|||||||
|
|
||||||
### 🛒 Geteilte Listen
|
### 🛒 Geteilte Listen
|
||||||
|
|
||||||
* Einkaufs-, Pack- und Checklisten mit Kategorien per Hashtag (`Hafermilch #Kühlregal`), Mengenangaben und Drag & Drop zwischen Kategorien.
|
* Einkaufs-, Pack- und Checklisten mit Kategorien per Hashtag (`Hafermilch #Kühlregal`), Mengenangaben und Drag & Drop zwischen Kategorien oder in eine andere Liste.
|
||||||
* Freigabe an Benutzer und Gruppen (Bearbeiter oder Leser); abgehakte Einträge erscheinen bei allen in Echtzeit.
|
* Freigabe an Benutzer und Gruppen (Bearbeiter oder Leser); abgehakte Einträge erscheinen bei allen in Echtzeit.
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "mindsift-frontend",
|
"name": "mindsift-frontend",
|
||||||
"version": "0.16.2",
|
"version": "0.17.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "mindsift-frontend",
|
"name": "mindsift-frontend",
|
||||||
"version": "0.16.2",
|
"version": "0.17.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.16.2",
|
"version": "0.17.0",
|
||||||
"license": "Unlicense",
|
"license": "Unlicense",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
Users,
|
Users,
|
||||||
Check,
|
Check,
|
||||||
Radio,
|
|
||||||
Sparkles,
|
Sparkles,
|
||||||
X,
|
X,
|
||||||
ShoppingCart,
|
ShoppingCart,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { ListItem, SharedList, Team, User, WSMessage } from '../../lib/types';
|
import { ListItem, SharedList, Team, User, WSMessage } from '../../lib/types';
|
||||||
import { api, triggerHaptic, createRealtimeConnection, RealtimeTransport } from '../../lib/api';
|
import { api, triggerHaptic, createRealtimeConnection, RealtimeTransport } from '../../lib/api';
|
||||||
|
import { readViewSession, writeViewSession } from '../../lib/viewSession';
|
||||||
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
||||||
import { AvatarStack, AvatarPerson } from '../../components/AvatarStack';
|
import { AvatarStack, AvatarPerson } from '../../components/AvatarStack';
|
||||||
import { CountBadge } from '../../components/CountBadge';
|
import { CountBadge } from '../../components/CountBadge';
|
||||||
@@ -83,7 +83,13 @@ export function deduplicateItems(items: ListItem[]): ListItem[] {
|
|||||||
export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGlobal, currentUser, initialListId }) => {
|
export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGlobal, currentUser, initialListId }) => {
|
||||||
const { setGroups, setPrimaryAction } = useMobileActions();
|
const { setGroups, setPrimaryAction } = useMobileActions();
|
||||||
const [lists, setLists] = useState<SharedList[]>([]);
|
const [lists, setLists] = useState<SharedList[]>([]);
|
||||||
const [activeListId, setActiveListId] = useState<string>(initialListId || '');
|
// The open list survives switching to other tabs (like the workspace's open note)
|
||||||
|
const [activeListId, setActiveListId] = useState<string>(
|
||||||
|
() => initialListId || readViewSession<{ listId: string }>('lists').listId || ''
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeListId) writeViewSession('lists', { listId: activeListId });
|
||||||
|
}, [activeListId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialListId) {
|
if (initialListId) {
|
||||||
@@ -116,6 +122,8 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
const [draggingItemId, setDraggingItemId] = useState<string | null>(null);
|
const [draggingItemId, setDraggingItemId] = useState<string | null>(null);
|
||||||
const [dragOverItemTarget, setDragOverItemTarget] = useState<{ itemId: string; position: 'before' | 'after' } | null>(null);
|
const [dragOverItemTarget, setDragOverItemTarget] = useState<{ itemId: string; position: 'before' | 'after' } | null>(null);
|
||||||
const [dragOverCategoryTarget, setDragOverCategoryTarget] = useState<string | null>(null);
|
const [dragOverCategoryTarget, setDragOverCategoryTarget] = useState<string | null>(null);
|
||||||
|
// List tab a dragged entry hovers over (dropping moves it into that list)
|
||||||
|
const [dragOverListTab, setDragOverListTab] = useState<string | null>(null);
|
||||||
|
|
||||||
const realtimeConnRef = useRef<any>(null);
|
const realtimeConnRef = useRef<any>(null);
|
||||||
|
|
||||||
@@ -378,6 +386,35 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
setDraggingItemId(item.id);
|
setDraggingItemId(item.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const canWriteList = (lst: SharedList) => lst.isOwner || lst.role !== 'viewer';
|
||||||
|
|
||||||
|
// An entry dragged onto another list's tab lands at the top of that list, like a new entry
|
||||||
|
const handleDropOnListTab = async (e: React.DragEvent, target: SharedList) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const itemId = e.dataTransfer.getData('application/mindsift-list-item-id') || draggingItemId;
|
||||||
|
setDraggingItemId(null);
|
||||||
|
setDragOverItemTarget(null);
|
||||||
|
setDragOverCategoryTarget(null);
|
||||||
|
setDragOverListTab(null);
|
||||||
|
const source = activeList;
|
||||||
|
const item = source?.items.find((i) => i.id === itemId);
|
||||||
|
if (!source || !item || target.id === source.id || !canWriteList(target)) return;
|
||||||
|
triggerHaptic(15);
|
||||||
|
setLists((prev) =>
|
||||||
|
prev.map((l) => {
|
||||||
|
if (l.id === source.id) return { ...l, items: l.items.filter((i) => i.id !== item.id) };
|
||||||
|
if (l.id === target.id) return { ...l, items: deduplicateItems([{ ...item, listId: target.id }, ...l.items]) };
|
||||||
|
return l;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await api.moveListItem(source.id, item.id, target.id);
|
||||||
|
} catch {
|
||||||
|
loadLists();
|
||||||
|
}
|
||||||
|
onRefreshGlobal?.();
|
||||||
|
};
|
||||||
|
|
||||||
const handleItemDragOver = (e: React.DragEvent, targetItemId: string) => {
|
const handleItemDragOver = (e: React.DragEvent, targetItemId: string) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -849,6 +886,23 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
]);
|
]);
|
||||||
}, [lists, activeList, completedItems.length, setGroups, setPrimaryAction]);
|
}, [lists, activeList, completedItems.length, setGroups, setPrimaryAction]);
|
||||||
|
|
||||||
|
// Connection pill in the same look as the notes' header
|
||||||
|
const livePill = (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1 h-7 px-2 rounded-btn text-[11px] bg-panel text-muted"
|
||||||
|
title={
|
||||||
|
!wsConnected
|
||||||
|
? 'Keine Live-Verbindung – Änderungen anderer erscheinen nach dem Neuladen'
|
||||||
|
: realtimeTransport === 'sse'
|
||||||
|
? 'Live-Verbindung über Server-Sent Events (WebSocket wird blockiert)'
|
||||||
|
: 'Live-Verbindung über WebSocket'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className={`w-1.5 h-1.5 rounded-full ${wsConnected ? 'bg-status-ok' : 'bg-status-warn'}`} />
|
||||||
|
{wsConnected ? 'Live' : 'Offline'}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full space-y-3">
|
<div className="w-full space-y-3">
|
||||||
{/* Top Header: List Tabs & Actions (Desktop) */}
|
{/* Top Header: List Tabs & Actions (Desktop) */}
|
||||||
@@ -861,9 +915,21 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={lst.id}
|
key={lst.id}
|
||||||
|
onDragOver={(e) => {
|
||||||
|
if (!draggingItemId || isSelected || !canWriteList(lst)) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = 'move';
|
||||||
|
if (dragOverListTab !== lst.id) setDragOverListTab(lst.id);
|
||||||
|
}}
|
||||||
|
onDragLeave={() => {
|
||||||
|
if (dragOverListTab === lst.id) setDragOverListTab(null);
|
||||||
|
}}
|
||||||
|
onDrop={(e) => handleDropOnListTab(e, lst)}
|
||||||
className={`relative group h-8 flex items-center border rounded-btn transition-colors ${
|
className={`relative group h-8 flex items-center border rounded-btn transition-colors ${
|
||||||
isSelected ? 'bg-panel border-accent text-main' : 'bg-canvas border-border text-muted hover:text-main'
|
isSelected ? 'bg-panel border-accent text-main' : 'bg-canvas border-border text-muted hover:text-main'
|
||||||
} ${isSelected && lists.length > 1 ? 'pr-1' : ''}`}
|
} ${isSelected && lists.length > 1 ? 'pr-1' : ''} ${
|
||||||
|
dragOverListTab === lst.id ? 'ring-2 ring-accent/60 bg-accent/10 text-main' : ''
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveListId(lst.id)}
|
onClick={() => setActiveListId(lst.id)}
|
||||||
@@ -951,25 +1017,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
{/* Live Sync Status */}
|
{livePill}
|
||||||
<div
|
|
||||||
className={`h-8 w-8 flex items-center justify-center rounded-btn border transition-colors ${
|
|
||||||
wsConnected
|
|
||||||
? realtimeTransport === 'sse'
|
|
||||||
? 'bg-teal-500/10 border-teal-500/30 text-teal-400'
|
|
||||||
: 'bg-emerald-500/10 border-emerald-500/30 text-emerald-500'
|
|
||||||
: 'bg-canvas border-border text-muted'
|
|
||||||
}`}
|
|
||||||
title={
|
|
||||||
wsConnected
|
|
||||||
? realtimeTransport === 'sse'
|
|
||||||
? 'Live synchronisiert (Server-Sent Events / Proxy-Stream)'
|
|
||||||
: 'Live synchronisiert (WebSocket online)'
|
|
||||||
: 'Offline'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Radio className={`w-3.5 h-3.5 ${wsConnected ? 'animate-pulse' : ''}`} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -996,24 +1044,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
<Users className="w-4 h-4 text-accent" />
|
<Users className="w-4 h-4 text-accent" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<div
|
{livePill}
|
||||||
className={`h-9 w-9 flex items-center justify-center rounded-btn border transition-colors ${
|
|
||||||
wsConnected
|
|
||||||
? realtimeTransport === 'sse'
|
|
||||||
? 'bg-teal-500/10 border-teal-500/30 text-teal-400'
|
|
||||||
: 'bg-emerald-500/10 border-emerald-500/30 text-emerald-500'
|
|
||||||
: 'bg-canvas border-border text-muted'
|
|
||||||
}`}
|
|
||||||
title={
|
|
||||||
wsConnected
|
|
||||||
? realtimeTransport === 'sse'
|
|
||||||
? 'Live synchronisiert (Server-Sent Events / Proxy-Stream)'
|
|
||||||
: 'Live synchronisiert (WebSocket online)'
|
|
||||||
: 'Offline'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Radio className={`w-3.5 h-3.5 ${wsConnected ? 'animate-pulse' : ''}`} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1195,6 +1226,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
setDraggingItemId(null);
|
setDraggingItemId(null);
|
||||||
setDragOverItemTarget(null);
|
setDragOverItemTarget(null);
|
||||||
setDragOverCategoryTarget(null);
|
setDragOverCategoryTarget(null);
|
||||||
|
setDragOverListTab(null);
|
||||||
}}
|
}}
|
||||||
onDragOver={(e) => handleItemDragOver(e, item.id)}
|
onDragOver={(e) => handleItemDragOver(e, item.id)}
|
||||||
onDragLeave={(e) => handleItemDragLeave(e, item.id)}
|
onDragLeave={(e) => handleItemDragLeave(e, item.id)}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { TaskList, Thought, SubTask, Team, User, TaskListMember, KanbanColumn, PriorityLevel, Recurrence, RECURRENCE_LABELS } from '../../lib/types';
|
import { TaskList, Thought, SubTask, Team, User, TaskListMember, KanbanColumn, PriorityLevel, Recurrence, RECURRENCE_LABELS } from '../../lib/types';
|
||||||
import { formatDue, isDueToday, isOverdue, localDay, splitDue } from '../../lib/due';
|
import { formatDue, isDueToday, isOverdue, localDay, splitDue } from '../../lib/due';
|
||||||
|
import { readViewSession, writeViewSession } from '../../lib/viewSession';
|
||||||
import { api, triggerHaptic } from '../../lib/api';
|
import { api, triggerHaptic } from '../../lib/api';
|
||||||
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
||||||
import { TaskDetailModal } from './TaskDetailModal';
|
import { TaskDetailModal } from './TaskDetailModal';
|
||||||
@@ -66,6 +67,8 @@ interface TasksViewProps {
|
|||||||
|
|
||||||
const KANBAN_WIDE_KEY = 'mindsift:kanban_wide';
|
const KANBAN_WIDE_KEY = 'mindsift:kanban_wide';
|
||||||
|
|
||||||
|
type TasksSession = { listId: string; viewMode: 'list' | 'kanban' };
|
||||||
|
|
||||||
export const TasksView: React.FC<TasksViewProps> = ({
|
export const TasksView: React.FC<TasksViewProps> = ({
|
||||||
currentUser,
|
currentUser,
|
||||||
onRefreshStats,
|
onRefreshStats,
|
||||||
@@ -75,7 +78,10 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
openTasksCount,
|
openTasksCount,
|
||||||
onWideChange,
|
onWideChange,
|
||||||
}) => {
|
}) => {
|
||||||
const [viewMode, setViewMode] = useState<'list' | 'kanban'>('list');
|
// Open list and view survive switching to other tabs (like the workspace's open note)
|
||||||
|
const [viewMode, setViewMode] = useState<'list' | 'kanban'>(() =>
|
||||||
|
readViewSession<TasksSession>('tasks').viewMode === 'kanban' ? 'kanban' : 'list'
|
||||||
|
);
|
||||||
// Wide Kanban: the board takes the whole window and shows every column in one row
|
// Wide Kanban: the board takes the whole window and shows every column in one row
|
||||||
const [kanbanWide, setKanbanWide] = useState(() => {
|
const [kanbanWide, setKanbanWide] = useState(() => {
|
||||||
try {
|
try {
|
||||||
@@ -101,7 +107,12 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
const [filter, setFilter] = useState<'open' | 'done' | 'discarded'>('open');
|
const [filter, setFilter] = useState<'open' | 'done' | 'discarded'>('open');
|
||||||
const [tasks, setTasks] = useState<Thought[]>([]);
|
const [tasks, setTasks] = useState<Thought[]>([]);
|
||||||
const [taskLists, setTaskLists] = useState<TaskList[]>([]);
|
const [taskLists, setTaskLists] = useState<TaskList[]>([]);
|
||||||
const [selectedListId, setSelectedListId] = useState<string>('all');
|
const [selectedListId, setSelectedListId] = useState<string>(
|
||||||
|
() => readViewSession<TasksSession>('tasks').listId || 'all'
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
writeViewSession<TasksSession>('tasks', { listId: selectedListId, viewMode });
|
||||||
|
}, [selectedListId, viewMode]);
|
||||||
const [teams, setTeams] = useState<Team[]>([]);
|
const [teams, setTeams] = useState<Team[]>([]);
|
||||||
const [collaborators, setCollaborators] = useState<User[]>([]);
|
const [collaborators, setCollaborators] = useState<User[]>([]);
|
||||||
const [discarded, setDiscarded] = useState<Thought[]>([]);
|
const [discarded, setDiscarded] = useState<Thought[]>([]);
|
||||||
@@ -111,6 +122,8 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
const [draggingTaskId, setDraggingTaskId] = useState<string | null>(null);
|
const [draggingTaskId, setDraggingTaskId] = useState<string | null>(null);
|
||||||
const [dragOverTaskTarget, setDragOverTaskTarget] = useState<{ taskId: string; position: 'before' | 'after' } | null>(null);
|
const [dragOverTaskTarget, setDragOverTaskTarget] = useState<{ taskId: string; position: 'before' | 'after' } | null>(null);
|
||||||
const [dragOverKanbanCol, setDragOverKanbanCol] = useState<string | null>(null);
|
const [dragOverKanbanCol, setDragOverKanbanCol] = useState<string | null>(null);
|
||||||
|
// List tab a dragged task hovers over (dropping moves it into that list)
|
||||||
|
const [dragOverListTab, setDragOverListTab] = useState<string | null>(null);
|
||||||
|
|
||||||
// Quick Task Add state
|
// Quick Task Add state
|
||||||
const [quickTitle, setQuickTitle] = useState('');
|
const [quickTitle, setQuickTitle] = useState('');
|
||||||
@@ -190,6 +203,10 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
loadedListRef.current = listId;
|
loadedListRef.current = listId;
|
||||||
setTasks(Array.isArray(allTasks) ? allTasks : []);
|
setTasks(Array.isArray(allTasks) ? allTasks : []);
|
||||||
setTaskLists(Array.isArray(allLists) ? allLists : []);
|
setTaskLists(Array.isArray(allLists) ? allLists : []);
|
||||||
|
// The remembered list was deleted or unshared meanwhile
|
||||||
|
if (listId !== 'all' && Array.isArray(allLists) && !allLists.some((l) => l.id === listId)) {
|
||||||
|
setSelectedListId('all');
|
||||||
|
}
|
||||||
setTeams(Array.isArray(allTeams) ? allTeams : []);
|
setTeams(Array.isArray(allTeams) ? allTeams : []);
|
||||||
setCollaborators(Array.isArray(allCollabs) ? allCollabs : []);
|
setCollaborators(Array.isArray(allCollabs) ? allCollabs : []);
|
||||||
setDiscarded(Array.isArray(allDiscarded) ? allDiscarded : []);
|
setDiscarded(Array.isArray(allDiscarded) ? allDiscarded : []);
|
||||||
@@ -535,6 +552,35 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
onRefreshStats();
|
onRefreshStats();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// A task dragged onto a list tab lands in that list like a new task: it keeps its Kanban status
|
||||||
|
// if the list has that column, otherwise it starts in the list's first column
|
||||||
|
const handleDropOnListTab = async (e: React.DragEvent, list: TaskList) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const sourceId = e.dataTransfer.getData('application/mindsift-task-id') || draggingTaskId;
|
||||||
|
setDraggingTaskId(null);
|
||||||
|
setDragOverTaskTarget(null);
|
||||||
|
setDragOverKanbanCol(null);
|
||||||
|
setDragOverListTab(null);
|
||||||
|
const task = tasks.find((t) => t.id === sourceId);
|
||||||
|
if (!task || task.taskListId === list.id || list.permission === 'viewer') return;
|
||||||
|
triggerHaptic(15);
|
||||||
|
const columns = Array.isArray(list.columns) && list.columns.length > 0 ? list.columns : globalColumns;
|
||||||
|
const keepStatus = !task.kanbanStatus || columns.some((c) => c.id === task.kanbanStatus);
|
||||||
|
const kanbanStatus = keepStatus ? task.kanbanStatus : (columns.find((c) => !c.isDone) || columns[0])?.id;
|
||||||
|
setTasks((prev) =>
|
||||||
|
selectedListId !== 'all'
|
||||||
|
? prev.filter((t) => t.id !== task.id)
|
||||||
|
: prev.map((t) => (t.id === task.id ? { ...t, taskListId: list.id, kanbanStatus } : t))
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await api.updateThought(task.id, { taskListId: list.id, ...(keepStatus ? {} : { kanbanStatus }) });
|
||||||
|
} catch {
|
||||||
|
loadData();
|
||||||
|
}
|
||||||
|
api.getTaskLists().then((lists) => setTaskLists(Array.isArray(lists) ? lists : [])).catch(() => {});
|
||||||
|
onRefreshStats();
|
||||||
|
};
|
||||||
|
|
||||||
const handleUpdateTaskNotes = async (task: Thought, newNotes: string) => {
|
const handleUpdateTaskNotes = async (task: Thought, newNotes: string) => {
|
||||||
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, notes: newNotes } : t)));
|
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, notes: newNotes } : t)));
|
||||||
await api.updateThought(task.id, { notes: newNotes });
|
await api.updateThought(task.id, { notes: newNotes });
|
||||||
@@ -1137,6 +1183,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
setDraggingTaskId(null);
|
setDraggingTaskId(null);
|
||||||
setDragOverTaskTarget(null);
|
setDragOverTaskTarget(null);
|
||||||
setDragOverKanbanCol(null);
|
setDragOverKanbanCol(null);
|
||||||
|
setDragOverListTab(null);
|
||||||
}}
|
}}
|
||||||
onDragOver={(e) => handleTaskDragOver(e, task.id)}
|
onDragOver={(e) => handleTaskDragOver(e, task.id)}
|
||||||
onDragLeave={(e) => handleTaskDragLeave(e, task.id)}
|
onDragLeave={(e) => handleTaskDragLeave(e, task.id)}
|
||||||
@@ -1385,9 +1432,20 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
{safeTaskLists.map((tl) => (
|
{safeTaskLists.map((tl) => (
|
||||||
<div
|
<div
|
||||||
key={tl.id}
|
key={tl.id}
|
||||||
|
onDragOver={(e) => {
|
||||||
|
const task = draggingTaskId ? tasks.find((t) => t.id === draggingTaskId) : null;
|
||||||
|
if (!task || task.taskListId === tl.id || tl.permission === 'viewer') return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = 'move';
|
||||||
|
if (dragOverListTab !== tl.id) setDragOverListTab(tl.id);
|
||||||
|
}}
|
||||||
|
onDragLeave={() => {
|
||||||
|
if (dragOverListTab === tl.id) setDragOverListTab(null);
|
||||||
|
}}
|
||||||
|
onDrop={(e) => handleDropOnListTab(e, tl)}
|
||||||
className={`relative group h-8 flex items-center border rounded-btn transition-colors ${
|
className={`relative group h-8 flex items-center border rounded-btn transition-colors ${
|
||||||
selectedListId === tl.id ? 'bg-panel border-accent text-main pr-1' : 'bg-canvas border-border text-muted hover:text-main'
|
selectedListId === tl.id ? 'bg-panel border-accent text-main pr-1' : 'bg-canvas border-border text-muted hover:text-main'
|
||||||
}`}
|
} ${dragOverListTab === tl.id ? 'ring-2 ring-accent/60 bg-accent/10 text-main' : ''}`}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onClick={() => setSelectedListId(tl.id)}
|
onClick={() => setSelectedListId(tl.id)}
|
||||||
@@ -2221,6 +2279,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
setDraggingTaskId(null);
|
setDraggingTaskId(null);
|
||||||
setDragOverTaskTarget(null);
|
setDragOverTaskTarget(null);
|
||||||
setDragOverKanbanCol(null);
|
setDragOverKanbanCol(null);
|
||||||
|
setDragOverListTab(null);
|
||||||
}}
|
}}
|
||||||
onDragOver={(e) => handleTaskDragOver(e, task.id)}
|
onDragOver={(e) => handleTaskDragOver(e, task.id)}
|
||||||
onDragLeave={(e) => handleTaskDragLeave(e, task.id)}
|
onDragLeave={(e) => handleTaskDragLeave(e, task.id)}
|
||||||
|
|||||||
@@ -994,6 +994,13 @@ export const api = {
|
|||||||
await request(`/api/v1/lists/${listId}/items/${itemId}`, { method: 'DELETE' });
|
await request(`/api/v1/lists/${listId}/items/${itemId}`, { method: 'DELETE' });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async moveListItem(listId: string, itemId: string, targetListId: string): Promise<ListItem> {
|
||||||
|
return request<ListItem>(`/api/v1/lists/${listId}/items/${itemId}/move`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ targetListId }),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
async clearCompletedItems(listId: string): Promise<{ removed: number }> {
|
async clearCompletedItems(listId: string): Promise<{ removed: number }> {
|
||||||
return request<{ removed: number }>(`/api/v1/lists/${listId}/clear-completed`, {
|
return request<{ removed: number }>(`/api/v1/lists/${listId}/clear-completed`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// View state for this browser tab that survives switching between the app's tabs (the views
|
||||||
|
// remount), like the workspace's open note: which list was open, list or Kanban view
|
||||||
|
const PREFIX = 'mindsift_view:';
|
||||||
|
|
||||||
|
export function readViewSession<T extends object>(view: string): Partial<T> {
|
||||||
|
try {
|
||||||
|
const raw = JSON.parse(sessionStorage.getItem(PREFIX + view) || '{}');
|
||||||
|
return raw && typeof raw === 'object' ? raw : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeViewSession<T extends object>(view: string, state: T) {
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(PREFIX + view, JSON.stringify(state));
|
||||||
|
} catch {
|
||||||
|
// storage blocked: the view simply starts fresh
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -245,6 +245,48 @@ func (s *Server) handleDeleteListItem(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleMoveListItem moves an item into another list; the user must be allowed to edit both lists
|
||||||
|
func (s *Server) handleMoveListItem(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := auth.GetUserID(r.Context())
|
||||||
|
listID := chi.URLParam(r, "id")
|
||||||
|
itemID := chi.URLParam(r, "itemId")
|
||||||
|
if !s.listItemsWriteAllowed(w, r, itemID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
TargetListID string `json:"targetListId"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.TargetListID == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "Zielliste fehlt")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.TargetListID == listID {
|
||||||
|
writeError(w, http.StatusBadRequest, "Eintrag ist bereits in dieser Liste")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.store.EnsureListItemsWritable(userID, req.TargetListID, ""); err != nil {
|
||||||
|
if errors.Is(err, storage.ErrListReadOnly) {
|
||||||
|
writeError(w, http.StatusForbidden, err.Error())
|
||||||
|
} else {
|
||||||
|
writeError(w, http.StatusNotFound, err.Error())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
item, err := s.store.MoveListItem(itemID, req.TargetListID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.wsHub.BroadcastListEvent(listID, "ITEM_DELETED", map[string]string{
|
||||||
|
"itemId": itemID,
|
||||||
|
"listId": listID,
|
||||||
|
})
|
||||||
|
s.wsHub.BroadcastListEvent(req.TargetListID, "ITEM_CREATED", item)
|
||||||
|
writeJSON(w, http.StatusOK, item)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleClearCompletedItems(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleClearCompletedItems(w http.ResponseWriter, r *http.Request) {
|
||||||
listID := chi.URLParam(r, "id")
|
listID := chi.URLParam(r, "id")
|
||||||
if !s.listItemsWriteAllowed(w, r, "") {
|
if !s.listItemsWriteAllowed(w, r, "") {
|
||||||
|
|||||||
@@ -263,6 +263,7 @@ func (s *Server) Router() http.Handler {
|
|||||||
r.Patch("/api/v1/lists/{id}/items/{itemId}", s.handleToggleListItem)
|
r.Patch("/api/v1/lists/{id}/items/{itemId}", s.handleToggleListItem)
|
||||||
r.Put("/api/v1/lists/{id}/items/{itemId}", s.handleUpdateListItem)
|
r.Put("/api/v1/lists/{id}/items/{itemId}", s.handleUpdateListItem)
|
||||||
r.Delete("/api/v1/lists/{id}/items/{itemId}", s.handleDeleteListItem)
|
r.Delete("/api/v1/lists/{id}/items/{itemId}", s.handleDeleteListItem)
|
||||||
|
r.Post("/api/v1/lists/{id}/items/{itemId}/move", s.handleMoveListItem)
|
||||||
r.Post("/api/v1/lists/{id}/items/reorder", s.handleReorderListItems)
|
r.Post("/api/v1/lists/{id}/items/reorder", s.handleReorderListItems)
|
||||||
r.Put("/api/v1/lists/{id}/items/reorder", s.handleReorderListItems)
|
r.Put("/api/v1/lists/{id}/items/reorder", s.handleReorderListItems)
|
||||||
r.Post("/api/v1/lists/{id}/clear-completed", s.handleClearCompletedItems)
|
r.Post("/api/v1/lists/{id}/clear-completed", s.handleClearCompletedItems)
|
||||||
|
|||||||
@@ -1560,3 +1560,50 @@ func testSession(t *testing.T, store *storage.Store, userID string) string {
|
|||||||
}
|
}
|
||||||
return sid
|
return sid
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// List entries move between lists only if the user may edit both
|
||||||
|
func TestMoveListItemBetweenLists(t *testing.T) {
|
||||||
|
srv, store, token := setupTestServer(t)
|
||||||
|
router := srv.Router()
|
||||||
|
claims, _ := auth.ValidateToken(testJWTSecret, token)
|
||||||
|
move := func(listID, itemID, target string) int {
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/lists/"+listID+"/items/"+itemID+"/move", strings.NewReader(`{"targetListId":"`+target+`"}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
return rec.Code
|
||||||
|
}
|
||||||
|
|
||||||
|
from, _ := store.CreateList(claims.UserID, "Einkauf", "", "shopping")
|
||||||
|
to, _ := store.CreateList(claims.UserID, "Baumarkt", "", "shopping")
|
||||||
|
item, _ := store.CreateListItem(from.ID, "Schrauben", "werkzeug", "20", 3, claims.UserID)
|
||||||
|
|
||||||
|
if code := move(from.ID, item.ID, to.ID); code != http.StatusOK {
|
||||||
|
t.Fatalf("move: %d", code)
|
||||||
|
}
|
||||||
|
if items, _ := store.GetListItems(from.ID); len(items) != 0 {
|
||||||
|
t.Fatalf("item still in the source list: %+v", items)
|
||||||
|
}
|
||||||
|
items, _ := store.GetListItems(to.ID)
|
||||||
|
if len(items) != 1 || items[0].Content != "Schrauben" || items[0].Category != "werkzeug" || items[0].Quantity != "20" {
|
||||||
|
t.Fatalf("item not moved intact: %+v", items)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read-only target and foreign lists are refused
|
||||||
|
other, _ := store.CreateUser("other@example.com", "hash", "Other", "member")
|
||||||
|
readOnly, _ := store.CreateList(other.ID, "Nur lesen", "", "shopping")
|
||||||
|
if _, err := store.AddListShare(other.ID, readOnly.ID, "user", claims.UserID, "viewer"); err != nil {
|
||||||
|
t.Fatalf("share: %v", err)
|
||||||
|
}
|
||||||
|
if code := move(to.ID, item.ID, readOnly.ID); code != http.StatusForbidden {
|
||||||
|
t.Fatalf("read-only target must be refused, got %d", code)
|
||||||
|
}
|
||||||
|
foreign, _ := store.CreateList(other.ID, "Fremd", "", "shopping")
|
||||||
|
if code := move(to.ID, item.ID, foreign.ID); code != http.StatusNotFound {
|
||||||
|
t.Fatalf("foreign target must be refused, got %d", code)
|
||||||
|
}
|
||||||
|
if code := move(from.ID, item.ID, to.ID); code != http.StatusNotFound {
|
||||||
|
t.Fatalf("item addressed through the wrong list must be refused, got %d", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -444,6 +444,35 @@ func (s *Store) UpdateListItem(itemID string, content *string, category *string,
|
|||||||
return &it, nil
|
return &it, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MoveListItem moves an item into another list, where it lands at the top like a new entry
|
||||||
|
// (content, category, quantity and checked state stay). Permissions are checked by the caller.
|
||||||
|
func (s *Store) MoveListItem(itemID, targetListID string) (*domain.ListItem, error) {
|
||||||
|
var sourceListID string
|
||||||
|
if err := s.db.QueryRow(`SELECT list_id FROM list_items WHERE id = ?`, itemID).Scan(&sourceListID); err != nil {
|
||||||
|
return nil, ErrListNotFound
|
||||||
|
}
|
||||||
|
now := domain.NowISO()
|
||||||
|
if _, err := s.db.Exec(`UPDATE list_items SET list_id = ?, sort_order = 0 WHERE id = ?`, targetListID, itemID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
_, _ = s.db.Exec(`UPDATE lists SET updated_at = ? WHERE id IN (?, ?)`, now, sourceListID, targetListID)
|
||||||
|
|
||||||
|
var it domain.ListItem
|
||||||
|
var dInt int
|
||||||
|
err := s.db.QueryRow(
|
||||||
|
`SELECT li.id, li.list_id, li.content, li.category, li.quantity, li.is_completed, li.completed_by, li.completed_by_name,
|
||||||
|
li.created_by, COALESCE(u.display_name, ''), li.sort_order, li.created_at, li.completed_at
|
||||||
|
FROM list_items li LEFT JOIN users u ON u.id = li.created_by AND li.created_by != ''
|
||||||
|
WHERE li.id = ?`,
|
||||||
|
itemID,
|
||||||
|
).Scan(&it.ID, &it.ListID, &it.Content, &it.Category, &it.Quantity, &dInt, &it.CompletedBy, &it.CompletedByName, &it.CreatedBy, &it.CreatedByName, &it.SortOrder, &it.CreatedAt, &it.CompletedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
it.IsCompleted = dInt != 0
|
||||||
|
return &it, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) DeleteListItem(itemID string) (string, error) {
|
func (s *Store) DeleteListItem(itemID string) (string, error) {
|
||||||
var listID string
|
var listID string
|
||||||
_ = s.db.QueryRow(`SELECT list_id FROM list_items WHERE id = ?`, itemID).Scan(&listID)
|
_ = s.db.QueryRow(`SELECT list_id FROM list_items WHERE id = ?`, itemID).Scan(&listID)
|
||||||
|
|||||||
Reference in new issue
Block a user