feat: Kanban touch drag, admins delete backups, bigger share message
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m5s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m5s
- Kanban on phones/tablets: hold a card, drag it onto a card or column, release; the page scrolls at the edges
- Admin settings: database backups can be deleted (DELETE /api/v1/admin/backups/{name})
- Share message: large Liste/Aufgabe buttons, close button, tap outside closes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
da258fdce2
commit
f76b0f34ff
14 files changed
+371
-57
No files matched your search
+11
-1
@@ -6,6 +6,15 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.22.0] - 2026-10-31
|
||||
|
||||
### Added
|
||||
- Kanban am Handy und Tablet: Karten lassen sich mit dem Finger verschieben – kurz gedrückt halten, bis sie sich anhebt, dann auf eine andere Karte oder Spalte ziehen und loslassen. Am Rand scrollt die Seite mit (am Handy stehen die Spalten untereinander). Ein kurzer Tipp öffnet die Aufgabe wie bisher, Wischen scrollt.
|
||||
- Admin-Einstellungen: Datenbank-Backups lassen sich neben „Download“ auch löschen (mit Rückfrage). Nur für Admins; Benutzer-Backups laufen weiter über die Aufbewahrungsdauer ab. Neu dafür ist `DELETE /api/v1/admin/backups/{name}`.
|
||||
|
||||
### Changed
|
||||
- Meldung nach dem Teilen: größer und mit dem Daumen gut erreichbar – Text oben, darunter zwei große Knöpfe **Liste** und **Aufgabe** wie in der Triage, ein kleines ✕ in der Ecke; ein Tipp daneben schließt sie.
|
||||
|
||||
## [0.21.0] - 2026-10-31
|
||||
|
||||
### Added
|
||||
@@ -450,7 +459,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.
|
||||
- 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.21.0...dev
|
||||
[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.22.0...dev
|
||||
[0.22.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.21.0...v0.22.0
|
||||
[0.21.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.20.0...v0.21.0
|
||||
[0.20.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.19.0...v0.20.0
|
||||
[0.19.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.18.1...v0.19.0
|
||||
|
||||
@@ -221,6 +221,7 @@ Wiederholte Operationen (gleiche `opId`) werden nicht erneut angewendet; die Ant
|
||||
| GET | `/admin/voice` | Aktive Spracherkennung (`mode`: `server`/`browser`/`off`), Einstellungen aus `STT_*` ohne Schlüssel, Grenzen und im Server-Modus ein frischer Zustand (`status.reachable`, `status.modelReady`) |
|
||||
| GET/POST | `/admin/backups` | Datenbank-Abzüge auflisten bzw. sofort sichern (Abzug + alle Benutzer-Backups) |
|
||||
| GET | `/admin/backups/{name}` | Datenbank-Abzug herunterladen |
|
||||
| DELETE | `/admin/backups/{name}` | Datenbank-Abzug löschen (gibt die verbleibende Liste zurück; nicht in der Demo) |
|
||||
|
||||
## Beispiele
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
[](#)
|
||||
[](#-schnellstart)
|
||||
[](#-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.
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mindsift-frontend",
|
||||
"version": "0.21.0",
|
||||
"version": "0.22.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mindsift-frontend",
|
||||
"version": "0.21.0",
|
||||
"version": "0.22.0",
|
||||
"license": "Unlicense",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "5.3.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "mindsift-frontend",
|
||||
"private": true,
|
||||
"version": "0.21.0",
|
||||
"version": "0.22.0",
|
||||
"license": "Unlicense",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -49,52 +49,68 @@ export const SharedImportToast: React.FC<SharedImportToastProps> = ({ thoughtIds
|
||||
else setChoosingList(true);
|
||||
};
|
||||
|
||||
const chip =
|
||||
'h-9 px-3 inline-flex items-center gap-1.5 rounded-btn border border-border bg-canvas text-main text-xs font-medium active:bg-panel-elevated disabled:opacity-50 touch-manipulation';
|
||||
const big =
|
||||
'min-h-[52px] px-3 py-2 rounded-xl text-sm font-semibold flex items-center justify-center gap-2 touch-manipulation cursor-pointer active:scale-98 disabled:opacity-50';
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
className="fixed left-1/2 -translate-x-1/2 bottom-[calc(5.5rem+env(safe-area-inset-bottom))] sm:bottom-4 z-[60] w-[calc(100vw-1.5rem)] max-w-sm panel-surface shadow-panel overflow-hidden p-3 space-y-2.5 animate-in fade-in slide-in-from-bottom-2 duration-200"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-sm text-main">
|
||||
<Inbox className="w-4 h-4 text-accent shrink-0" />
|
||||
<span className="flex-1 font-medium">
|
||||
{count === 1 ? 'Im Quick Dump abgelegt' : `${count} Einträge im Quick Dump abgelegt`}
|
||||
</span>
|
||||
<button type="button" onClick={onClose} aria-label="Schließen" className="h-7 w-7 -mr-1 flex items-center justify-center text-faint">
|
||||
<>
|
||||
{/* Tapping anywhere else closes it (transparent, the page stays visible) */}
|
||||
<div className="fixed inset-0 z-[59]" onClick={onClose} aria-hidden />
|
||||
<div
|
||||
role="status"
|
||||
className="fixed left-1/2 -translate-x-1/2 bottom-[calc(5.5rem+env(safe-area-inset-bottom))] sm:bottom-4 z-[60] w-[calc(100vw-1.5rem)] max-w-md panel-surface shadow-panel overflow-hidden p-4 space-y-3 animate-in fade-in slide-in-from-bottom-2 duration-200"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Schließen"
|
||||
className="absolute top-2 right-2 h-8 w-8 flex items-center justify-center rounded-btn text-faint hover:text-main"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
<div className="pr-8">
|
||||
<p className="flex items-center gap-2 text-base font-semibold text-main">
|
||||
<Inbox className="w-5 h-5 text-accent shrink-0" />
|
||||
{count === 1 ? 'Im Quick Dump abgelegt' : `${count} Einträge im Quick Dump abgelegt`}
|
||||
</p>
|
||||
<p className="text-xs text-muted mt-1">
|
||||
{choosingList ? 'Auf welche Liste?' : 'Oder gleich woanders hin:'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{failed && <p className="text-xs text-status-err">Umwandeln hat nicht geklappt – der Eintrag bleibt im Quick Dump.</p>}
|
||||
|
||||
{!choosingList ? (
|
||||
<div className={`grid gap-2 ${writableLists.length > 0 ? 'grid-cols-2' : 'grid-cols-1'}`}>
|
||||
{writableLists.length > 0 && (
|
||||
<button type="button" disabled={busy} onClick={toList} className={`${big} bg-canvas border border-border text-main`}>
|
||||
<ShoppingCart className="w-4 h-4 text-accent" />
|
||||
<span>Liste</span>
|
||||
</button>
|
||||
)}
|
||||
<button type="button" disabled={busy} onClick={() => convert('task')} className={`${big} bg-accent text-on-accent`}>
|
||||
<CheckSquare className="w-4 h-4" />
|
||||
<span>Aufgabe</span>
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{writableLists.map((l) => (
|
||||
<button
|
||||
key={l.id}
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => convert('list_item', l)}
|
||||
className={`${big} bg-canvas border border-border text-main`}
|
||||
>
|
||||
<span>{l.icon}</span>
|
||||
<span className="truncate">{l.title}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{failed && <p className="text-xs text-status-err">Umwandeln hat nicht geklappt – der Eintrag bleibt im Quick Dump.</p>}
|
||||
|
||||
{!choosingList ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted">Stattdessen</span>
|
||||
<button type="button" className={chip} disabled={busy} onClick={() => convert('task')}>
|
||||
<ArrowRight className="w-3.5 h-3.5 text-faint" />
|
||||
<CheckSquare className="w-3.5 h-3.5 text-accent" />
|
||||
Aufgabe
|
||||
</button>
|
||||
{writableLists.length > 0 && (
|
||||
<button type="button" className={chip} disabled={busy} onClick={toList}>
|
||||
<ArrowRight className="w-3.5 h-3.5 text-faint" />
|
||||
<ShoppingCart className="w-3.5 h-3.5 text-accent" />
|
||||
Liste
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{writableLists.map((l) => (
|
||||
<button key={l.id} type="button" className={chip} disabled={busy} onClick={() => convert('list_item', l)}>
|
||||
<span>{l.icon}</span>
|
||||
{l.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Archive, Download, Loader2 } from 'lucide-react';
|
||||
import { Archive, Download, Loader2, Trash2 } from 'lucide-react';
|
||||
import { BackupFile, SystemSettings } from '../../lib/types';
|
||||
import { api } from '../../lib/api';
|
||||
import { ToggleSwitch } from '../../components/ToggleSwitch';
|
||||
@@ -41,6 +41,16 @@ export const BackupAdminCard: React.FC<BackupAdminCardProps> = ({ settings, onSa
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (name: string) => {
|
||||
if (!window.confirm(`Backup ${name} endgültig löschen?`)) return;
|
||||
setError(null);
|
||||
try {
|
||||
setFiles(await api.adminDeleteBackup(name));
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Löschen fehlgeschlagen');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 bg-canvas border border-border rounded-card space-y-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
@@ -109,14 +119,25 @@ export const BackupAdminCard: React.FC<BackupAdminCardProps> = ({ settings, onSa
|
||||
{new Date(f.createdAt).toLocaleString('de-DE')} · {formatBytes(f.size)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => download(f.name)}
|
||||
className="btn-action h-11 sm:h-7 px-2.5 text-[11px] inline-flex items-center gap-1 shrink-0"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
Download
|
||||
</button>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => download(f.name)}
|
||||
className="btn-action h-11 sm:h-7 px-2.5 text-[11px] inline-flex items-center gap-1"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
Download
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove(f.name)}
|
||||
title="Backup löschen"
|
||||
aria-label={`Backup ${f.name} löschen`}
|
||||
className="btn-action h-11 w-11 sm:h-7 sm:w-7 inline-flex items-center justify-center text-faint hover:text-status-err"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
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 { readViewSession, writeViewSession } from '../../lib/viewSession';
|
||||
import { useKanbanTouchDrag } from './useKanbanTouchDrag';
|
||||
import { api, triggerHaptic } from '../../lib/api';
|
||||
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
||||
import { TaskDetailModal } from './TaskDetailModal';
|
||||
@@ -807,7 +808,11 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
setDraggingTaskId(null);
|
||||
setDragOverTaskTarget(null);
|
||||
setDragOverKanbanCol(null);
|
||||
await moveTaskNextTo(sourceId, targetTask, position, newKanbanCol);
|
||||
};
|
||||
|
||||
// Shared by mouse drag & drop and the touch drag on the Kanban board
|
||||
const moveTaskNextTo = async (sourceId: string | null, targetTask: Thought, position: 'before' | 'after', newKanbanCol?: string) => {
|
||||
if (!sourceId || sourceId === targetTask.id) return;
|
||||
triggerHaptic(15);
|
||||
|
||||
@@ -864,7 +869,10 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
setDraggingTaskId(null);
|
||||
setDragOverTaskTarget(null);
|
||||
setDragOverKanbanCol(null);
|
||||
await moveTaskToColumn(sourceId, colId);
|
||||
};
|
||||
|
||||
const moveTaskToColumn = async (sourceId: string | null, colId: string) => {
|
||||
if (!sourceId) return;
|
||||
triggerHaptic(15);
|
||||
|
||||
@@ -905,6 +913,28 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
// Kanban with a finger: hold a card, drag it, let go (mouse and pen use HTML5 drag & drop)
|
||||
const clearDragState = () => {
|
||||
setDraggingTaskId(null);
|
||||
setDragOverTaskTarget(null);
|
||||
setDragOverKanbanCol(null);
|
||||
};
|
||||
const kanbanTouch = useKanbanTouchDrag({
|
||||
enabled: !canDragTasks && viewMode === 'kanban',
|
||||
onStart: (taskId) => setDraggingTaskId(taskId),
|
||||
onHover: (colId, target) => {
|
||||
setDragOverKanbanCol(colId);
|
||||
setDragOverTaskTarget(target);
|
||||
},
|
||||
onDrop: (taskId, colId, target) => {
|
||||
clearDragState();
|
||||
const targetTask = target ? tasks.find((t) => t.id === target.taskId) : undefined;
|
||||
if (targetTask && target) void moveTaskNextTo(taskId, targetTask, target.position, colId ?? undefined);
|
||||
else if (colId) void moveTaskToColumn(taskId, colId);
|
||||
},
|
||||
onCancel: clearDragState,
|
||||
});
|
||||
|
||||
const handleCreateTaskList = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const title = newListTitle.trim();
|
||||
@@ -2224,6 +2254,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
{viewMode === 'kanban' ? (
|
||||
/* DYNAMIC KANBAN BOARD */
|
||||
<div className="space-y-2">
|
||||
{kanbanTouch.ghost}
|
||||
<div
|
||||
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'
|
||||
@@ -2251,6 +2282,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
if (dragOverKanbanCol === col.id) setDragOverKanbanCol(null);
|
||||
}}
|
||||
onDrop={(e) => handleDropOnKanbanCol(e, col.id)}
|
||||
data-kanban-col={col.id}
|
||||
className={`bg-canvas border border-border flex flex-col min-h-[420px] overflow-hidden transition-all ${
|
||||
dragOverKanbanCol === col.id ? 'ring-2 ring-accent/60 bg-accent/5' : ''
|
||||
}`}
|
||||
@@ -2295,11 +2327,14 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
onDragOver={(e) => handleTaskDragOver(e, task.id)}
|
||||
onDragLeave={(e) => handleTaskDragLeave(e, task.id)}
|
||||
onDrop={(e) => handleDropOnTask(e, task, col.id)}
|
||||
data-kanban-task={task.id}
|
||||
{...kanbanTouch.cardProps(task.id, task.content, !readOnly)}
|
||||
onClick={() => {
|
||||
if (kanbanTouch.consumeClick()) return;
|
||||
setDetailTaskId(task.id);
|
||||
setShowDetailModal(true);
|
||||
}}
|
||||
className={`panel-surface p-2.5 space-y-2 hover:border-strong transition-all shadow-sm group relative ${
|
||||
className={`panel-surface p-2.5 space-y-2 hover:border-strong transition-all shadow-sm group relative select-none ${
|
||||
readOnly ? 'cursor-pointer' : 'cursor-grab active:cursor-grabbing'
|
||||
} ${
|
||||
isDragging ? 'opacity-30 border border-dashed border-accent/40' : ''
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
// Drag & drop on the Kanban board with a finger (browsers offer HTML5 drag & drop only for mouse and
|
||||
// pen): hold a card for a moment, it lifts, drag it onto another card or column, let go. A short tap
|
||||
// still opens the task and a swipe still scrolls, because the drag starts only after the hold.
|
||||
// Cards carry data-kanban-task="<id>", columns data-kanban-col="<id>".
|
||||
|
||||
const HOLD_MS = 350;
|
||||
const MOVE_TOLERANCE = 8; // px a finger may wander during the hold before it counts as scrolling
|
||||
const EDGE = 90; // px near the top/bottom edge that scroll the page while dragging
|
||||
|
||||
export type KanbanDropTarget = { taskId: string; position: 'before' | 'after' } | null;
|
||||
|
||||
interface Options {
|
||||
enabled: boolean;
|
||||
onStart: (taskId: string) => void;
|
||||
onHover: (colId: string | null, target: KanbanDropTarget) => void;
|
||||
onDrop: (taskId: string, colId: string | null, target: KanbanDropTarget) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
interface Drag {
|
||||
taskId: string;
|
||||
label: string;
|
||||
x: number;
|
||||
y: number;
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
width: number;
|
||||
}
|
||||
|
||||
export function useKanbanTouchDrag({ enabled, onStart, onHover, onDrop, onCancel }: Options) {
|
||||
const [drag, setDrag] = useState<Drag | null>(null);
|
||||
const holdRef = useRef<{ timer: number; x: number; y: number } | null>(null);
|
||||
const dragRef = useRef<Drag | null>(null);
|
||||
const hoverRef = useRef<{ col: string | null; target: KanbanDropTarget }>({ col: null, target: null });
|
||||
const suppressClickRef = useRef(false);
|
||||
const callbacks = useRef({ onStart, onHover, onDrop, onCancel });
|
||||
callbacks.current = { onStart, onHover, onDrop, onCancel };
|
||||
|
||||
const cancelHold = () => {
|
||||
if (holdRef.current) window.clearTimeout(holdRef.current.timer);
|
||||
holdRef.current = null;
|
||||
};
|
||||
|
||||
const hoverAt = (x: number, y: number) => {
|
||||
const el = document.elementFromPoint(x, y) as HTMLElement | null;
|
||||
const card = el?.closest('[data-kanban-task]') as HTMLElement | null;
|
||||
const col = (el?.closest('[data-kanban-col]') as HTMLElement | null)?.dataset.kanbanCol ?? null;
|
||||
let target: KanbanDropTarget = null;
|
||||
const cardId = card?.dataset.kanbanTask;
|
||||
if (card && cardId && cardId !== dragRef.current?.taskId) {
|
||||
const r = card.getBoundingClientRect();
|
||||
target = { taskId: cardId, position: y - r.top < r.height / 2 ? 'before' : 'after' };
|
||||
}
|
||||
const prev = hoverRef.current;
|
||||
if (prev.col !== col || prev.target?.taskId !== target?.taskId || prev.target?.position !== target?.position) {
|
||||
hoverRef.current = { col, target };
|
||||
callbacks.current.onHover(col, target);
|
||||
}
|
||||
};
|
||||
|
||||
// While dragging: the page must not scroll under the finger (non-passive listener), it scrolls
|
||||
// only near the edges
|
||||
useEffect(() => {
|
||||
if (!drag) return;
|
||||
let frame = 0;
|
||||
const scroll = () => {
|
||||
const d = dragRef.current;
|
||||
if (d) {
|
||||
if (d.y < EDGE) window.scrollBy(0, -Math.ceil((EDGE - d.y) / 6));
|
||||
else if (d.y > window.innerHeight - EDGE) window.scrollBy(0, Math.ceil((d.y - (window.innerHeight - EDGE)) / 6));
|
||||
hoverAt(d.x, d.y);
|
||||
}
|
||||
frame = requestAnimationFrame(scroll);
|
||||
};
|
||||
frame = requestAnimationFrame(scroll);
|
||||
|
||||
const move = (e: TouchEvent) => {
|
||||
e.preventDefault();
|
||||
const t = e.touches[0];
|
||||
if (!t || !dragRef.current) return;
|
||||
dragRef.current = { ...dragRef.current, x: t.clientX, y: t.clientY };
|
||||
setDrag(dragRef.current);
|
||||
};
|
||||
const end = (e: TouchEvent) => {
|
||||
const d = dragRef.current;
|
||||
dragRef.current = null;
|
||||
setDrag(null);
|
||||
suppressClickRef.current = true;
|
||||
window.setTimeout(() => (suppressClickRef.current = false), 400);
|
||||
if (!d) return;
|
||||
if (e.type === 'touchcancel') callbacks.current.onCancel();
|
||||
else callbacks.current.onDrop(d.taskId, hoverRef.current.col, hoverRef.current.target);
|
||||
hoverRef.current = { col: null, target: null };
|
||||
};
|
||||
document.addEventListener('touchmove', move, { passive: false });
|
||||
document.addEventListener('touchend', end);
|
||||
document.addEventListener('touchcancel', end);
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
document.removeEventListener('touchmove', move);
|
||||
document.removeEventListener('touchend', end);
|
||||
document.removeEventListener('touchcancel', end);
|
||||
};
|
||||
// Listeners live as long as one drag; positions are read from refs
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [Boolean(drag)]);
|
||||
|
||||
/** Props for a card; label is what the lifted card shows */
|
||||
const cardProps = useCallback(
|
||||
(taskId: string, label: string, draggable: boolean) => {
|
||||
if (!enabled || !draggable) return {};
|
||||
return {
|
||||
onTouchStart: (e: React.TouchEvent<HTMLElement>) => {
|
||||
if (e.touches.length !== 1) return;
|
||||
const t = e.touches[0];
|
||||
const el = e.currentTarget;
|
||||
cancelHold();
|
||||
holdRef.current = {
|
||||
x: t.clientX,
|
||||
y: t.clientY,
|
||||
timer: window.setTimeout(() => {
|
||||
const hold = holdRef.current;
|
||||
holdRef.current = null;
|
||||
if (!hold) return;
|
||||
const r = el.getBoundingClientRect();
|
||||
navigator.vibrate?.(15);
|
||||
const d: Drag = { taskId, label, x: hold.x, y: hold.y, offsetX: hold.x - r.left, offsetY: hold.y - r.top, width: r.width };
|
||||
dragRef.current = d;
|
||||
hoverRef.current = { col: null, target: null };
|
||||
setDrag(d);
|
||||
callbacks.current.onStart(taskId);
|
||||
}, HOLD_MS),
|
||||
};
|
||||
},
|
||||
onTouchMove: (e: React.TouchEvent<HTMLElement>) => {
|
||||
const h = holdRef.current;
|
||||
const t = e.touches[0];
|
||||
if (h && t && Math.hypot(t.clientX - h.x, t.clientY - h.y) > MOVE_TOLERANCE) cancelHold(); // scrolling
|
||||
},
|
||||
onTouchEnd: cancelHold,
|
||||
onTouchCancel: cancelHold,
|
||||
// The long press must not open the browser's context menu or select text
|
||||
onContextMenu: (e: React.MouseEvent) => e.preventDefault(),
|
||||
style: { WebkitTouchCallout: 'none' } as React.CSSProperties,
|
||||
};
|
||||
},
|
||||
[enabled]
|
||||
);
|
||||
|
||||
/** A tap right after a drag must not open the task */
|
||||
const consumeClick = () => suppressClickRef.current;
|
||||
|
||||
const ghost = drag
|
||||
? createPortal(
|
||||
<div
|
||||
className="fixed z-[80] pointer-events-none panel-surface shadow-panel px-3 py-2.5 text-xs font-medium text-main rotate-1 opacity-95 border-accent"
|
||||
style={{ left: drag.x - drag.offsetX, top: drag.y - drag.offsetY, width: drag.width }}
|
||||
>
|
||||
{drag.label}
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
: null;
|
||||
|
||||
return { cardProps, ghost, consumeClick, dragging: Boolean(drag) };
|
||||
}
|
||||
@@ -1305,6 +1305,10 @@ export const api = {
|
||||
return ensureArray(await request<BackupFile[]>('/api/v1/admin/backups', { method: 'POST' }));
|
||||
},
|
||||
|
||||
async adminDeleteBackup(name: string): Promise<BackupFile[]> {
|
||||
return ensureArray(await request<BackupFile[]>(`/api/v1/admin/backups/${encodeURIComponent(name)}`, { method: 'DELETE' }));
|
||||
},
|
||||
|
||||
async adminDownloadBackup(name: string): Promise<Blob> {
|
||||
return downloadBlob(`/api/v1/admin/backups/${encodeURIComponent(name)}`);
|
||||
},
|
||||
|
||||
@@ -110,6 +110,16 @@ func (s *Server) handleAdminRunBackup(w http.ResponseWriter, r *http.Request) {
|
||||
s.handleAdminListBackups(w, r)
|
||||
}
|
||||
|
||||
// handleAdminDeleteBackup removes one database snapshot (admins only; users cannot delete their own
|
||||
// backups, those expire with the retention period).
|
||||
func (s *Server) handleAdminDeleteBackup(w http.ResponseWriter, r *http.Request) {
|
||||
if err := storage.DeleteBackup(filepath.Join(s.backupRoot(), "system"), chi.URLParam(r, "name")); err != nil {
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
s.handleAdminListBackups(w, r)
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminDownloadBackup(w http.ResponseWriter, r *http.Request) {
|
||||
serveBackupFile(w, r, filepath.Join(s.backupRoot(), "system"), chi.URLParam(r, "name"), "application/gzip")
|
||||
}
|
||||
|
||||
@@ -89,3 +89,41 @@ func mustTime(day string) (t time.Time) {
|
||||
t, _ = time.Parse("2006-01-02", day)
|
||||
return t
|
||||
}
|
||||
|
||||
// Admins can delete database snapshots; members cannot, and only real backup names are accepted
|
||||
func TestAdminDeletesBackups(t *testing.T) {
|
||||
srv, store, token := setupTestServer(t)
|
||||
router := srv.Router()
|
||||
call := func(method, path, tok string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(method, path, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+tok)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
var files []storage.BackupFile
|
||||
_ = json.Unmarshal(call("POST", "/api/v1/admin/backups", token).Body.Bytes(), &files)
|
||||
if len(files) != 1 {
|
||||
t.Fatalf("expected one snapshot, got %+v", files)
|
||||
}
|
||||
name := files[0].Name
|
||||
|
||||
_, memberToken := newTestUser(t, store, "member@example.com")
|
||||
if rec := call("DELETE", "/api/v1/admin/backups/"+name, memberToken); rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("members must not delete backups, got %d", rec.Code)
|
||||
}
|
||||
if rec := call("DELETE", "/api/v1/admin/backups/..%2F..%2Fmindsift.db", token); rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("path tricks must be refused, got %d", rec.Code)
|
||||
}
|
||||
rec := call("DELETE", "/api/v1/admin/backups/"+name, token)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("delete: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &files)
|
||||
if len(files) != 0 {
|
||||
t.Fatalf("snapshot should be gone, got %+v", files)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(srv.backupRoot(), "system", name)); !os.IsNotExist(err) {
|
||||
t.Fatalf("file must be removed from disk")
|
||||
}
|
||||
}
|
||||
@@ -303,6 +303,7 @@ func (s *Server) Router() http.Handler {
|
||||
r.Get("/api/v1/admin/backups", s.handleAdminListBackups)
|
||||
r.Post("/api/v1/admin/backups", s.notInDemo(s.handleAdminRunBackup))
|
||||
r.Get("/api/v1/admin/backups/{name}", s.handleAdminDownloadBackup)
|
||||
r.Delete("/api/v1/admin/backups/{name}", s.notInDemo(s.handleAdminDeleteBackup))
|
||||
r.Put("/api/v1/admin/settings", s.notInDemo(s.handleAdminUpdateSettings))
|
||||
r.Get("/api/v1/admin/settings/sharing-impact", s.handleAdminSharingImpact)
|
||||
r.Get("/api/v1/admin/voice", s.handleAdminVoiceStatus)
|
||||
|
||||
@@ -54,6 +54,15 @@ func BackupPath(dir, name string) (string, error) {
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// DeleteBackup removes one backup in dir; like BackupPath it only accepts our own file names.
|
||||
func DeleteBackup(dir, name string) error {
|
||||
p, err := BackupPath(dir, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Remove(p)
|
||||
}
|
||||
|
||||
// ListBackups lists the backups in dir, newest first.
|
||||
func ListBackups(dir string) ([]BackupFile, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
|
||||
Reference in new issue
Block a user