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
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)}`);
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user