added trash und fix seed data
This commit is contained in:
parent
78df40e598
commit
4106c63803
|
|
@ -1 +1 @@
|
|||
1.0.28
|
||||
1.0.34
|
||||
|
|
@ -10,8 +10,10 @@
|
|||
import type { AgendaContext, ContextType } from '@ka-note/shared';
|
||||
import { account, logout, login } from '$lib/auth/authStore.js';
|
||||
import { syncStatus, lastSyncAt, fullSync } from '$lib/sync/syncService';
|
||||
import { deletedItemCount } from '$lib/stores/agenda';
|
||||
|
||||
const isDev = import.meta.env.DEV;
|
||||
const trashCount$ = deletedItemCount();
|
||||
let appVersion = $state('…');
|
||||
fetch('/version.json').then(r => r.json()).then(d => appVersion = d.version).catch(() => appVersion = 'dev');
|
||||
|
||||
|
|
@ -287,6 +289,19 @@
|
|||
</ul>
|
||||
{/if}
|
||||
|
||||
<div class="mt-4">
|
||||
<button
|
||||
class="mb-1 w-full rounded px-2.5 py-2 text-left text-sm transition-colors flex items-center gap-2 text-[#ccc] hover:bg-white/5 hover:text-white"
|
||||
onclick={() => { goto('/trash'); onnavigate?.(); }}
|
||||
>
|
||||
<span>🗑</span>
|
||||
<span>Papierkorb</span>
|
||||
{#if ($trashCount$ ?? 0) > 0}
|
||||
<span class="ml-auto rounded-full bg-danger/70 px-1.5 py-0.5 text-xs text-white leading-none">{$trashCount$}</span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-auto border-t border-border pt-3">
|
||||
<div class="mb-2 flex items-center justify-between px-1">
|
||||
<div class="flex items-center gap-1.5 text-xs text-muted">
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { db } from './schema';
|
||||
import { newId, now, today } from './helpers';
|
||||
import { getAccessToken } from '$lib/auth/authStore';
|
||||
import type { AgendaContext, Topic, HistoryEntry, Rating, ContextType, TopicStatus, ProjectMeta, PersonMeta, CompanyMeta } from '@ka-note/shared';
|
||||
|
||||
// --- Contexts ---
|
||||
|
|
@ -328,3 +329,71 @@ export async function softDeleteRating(id: string): Promise<void> {
|
|||
await db.ratings.update(id, { deletedAt: now(), updatedAt: now(), version: rating.version + 1 });
|
||||
}
|
||||
}
|
||||
|
||||
// --- Trash ---
|
||||
|
||||
export type TrashEntityType = 'context' | 'topic' | 'history' | 'rating';
|
||||
|
||||
export interface TrashEntityRef {
|
||||
type: TrashEntityType;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export async function restoreEntities(entities: TrashEntityRef[]): Promise<void> {
|
||||
const timestamp = now();
|
||||
for (const { type, id } of entities) {
|
||||
if (type === 'context') {
|
||||
const item = await db.contexts.get(id);
|
||||
if (item) await db.contexts.update(id, { deletedAt: null, updatedAt: timestamp, version: item.version + 1 });
|
||||
} else if (type === 'topic') {
|
||||
const item = await db.topics.get(id);
|
||||
if (item) await db.topics.update(id, { deletedAt: null, updatedAt: timestamp, version: item.version + 1 });
|
||||
} else if (type === 'history') {
|
||||
const item = await db.historyEntries.get(id);
|
||||
if (item) await db.historyEntries.update(id, { deletedAt: null, updatedAt: timestamp, version: item.version + 1 });
|
||||
} else if (type === 'rating') {
|
||||
const item = await db.ratings.get(id);
|
||||
if (item) await db.ratings.update(id, { deletedAt: null, updatedAt: timestamp, version: item.version + 1 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveLocalCascade(entities: TrashEntityRef[]) {
|
||||
const contextIds = new Set(entities.filter(e => e.type === 'context').map(e => e.id));
|
||||
const topicIds = new Set(entities.filter(e => e.type === 'topic').map(e => e.id));
|
||||
const historyIds = new Set(entities.filter(e => e.type === 'history').map(e => e.id));
|
||||
const ratingIds = new Set(entities.filter(e => e.type === 'rating').map(e => e.id));
|
||||
|
||||
if (contextIds.size > 0) {
|
||||
const childTopics = await db.topics.where('contextId').anyOf([...contextIds]).toArray();
|
||||
for (const t of childTopics) topicIds.add(t.id);
|
||||
}
|
||||
if (topicIds.size > 0) {
|
||||
const childHistory = await db.historyEntries.where('topicId').anyOf([...topicIds]).toArray();
|
||||
for (const h of childHistory) historyIds.add(h.id);
|
||||
}
|
||||
if (historyIds.size > 0) {
|
||||
const childRatings = await db.ratings.where('historyEntryId').anyOf([...historyIds]).toArray();
|
||||
for (const r of childRatings) ratingIds.add(r.id);
|
||||
}
|
||||
|
||||
return { contextIds, topicIds, historyIds, ratingIds };
|
||||
}
|
||||
|
||||
export async function hardDeleteEntities(entities: TrashEntityRef[]): Promise<void> {
|
||||
const token = await getAccessToken();
|
||||
const res = await fetch('/api/trash', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
|
||||
body: JSON.stringify({ entities }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(`Server error ${res.status}: ${body}`);
|
||||
}
|
||||
const { contextIds, topicIds, historyIds, ratingIds } = await resolveLocalCascade(entities);
|
||||
await db.ratings.bulkDelete([...ratingIds]);
|
||||
await db.historyEntries.bulkDelete([...historyIds]);
|
||||
await db.topics.bulkDelete([...topicIds]);
|
||||
await db.contexts.bulkDelete([...contextIds]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,14 +6,32 @@ const today = new Date().toISOString().split('T')[0];
|
|||
const yesterday = new Date(Date.now() - 86400000).toISOString().split('T')[0];
|
||||
const lastWeek = new Date(Date.now() - 604800000).toISOString().split('T')[0];
|
||||
|
||||
/** Creates the mandatory daily-log context if missing. Safe to call in production. */
|
||||
export async function seedIfEmpty(): Promise<void> {
|
||||
const count = await db.contexts.count();
|
||||
if (count > 0) return;
|
||||
|
||||
const ts = now();
|
||||
const dailyLog: AgendaContext = {
|
||||
id: 'daily-log', name: 'Daily Log / Inbox', type: 'meeting', sortOrder: 0,
|
||||
meta: null, archivedAt: null, isFavorite: true, updatedAt: ts, deletedAt: null, version: 1
|
||||
};
|
||||
const journalTopic: Topic = {
|
||||
id: 'daily-log-journal', contextId: 'daily-log', title: '__journal__', status: 'active',
|
||||
snoozeUntil: null, sortOrder: -1, isNew: false, updatedAt: ts, deletedAt: null, version: 1
|
||||
};
|
||||
await db.transaction('rw', [db.contexts, db.topics], async () => {
|
||||
await db.contexts.put(dailyLog);
|
||||
await db.topics.put(journalTopic);
|
||||
});
|
||||
console.log('Bootstrap: daily-log created');
|
||||
}
|
||||
|
||||
/** Loads demo data. Only for development — never call in production. */
|
||||
export async function seedDevData(): Promise<void> {
|
||||
const ts = now();
|
||||
|
||||
const contexts: AgendaContext[] = [
|
||||
{ id: 'daily-log', name: 'Daily Log / Inbox', type: 'meeting', sortOrder: 0, meta: null, archivedAt: null, isFavorite: true, updatedAt: ts, deletedAt: null, version: 1 },
|
||||
{ id: 'jf-sysadmins', name: 'JF Team Sysadmins', type: 'meeting', sortOrder: 1, meta: null, archivedAt: null, isFavorite: true, updatedAt: ts, deletedAt: null, version: 1 },
|
||||
{ id: 'jf-devs', name: 'JF Developer', type: 'meeting', sortOrder: 2, meta: null, archivedAt: null, isFavorite: true, updatedAt: ts, deletedAt: null, version: 1 },
|
||||
{ id: 'p-tisax', name: 'Project TISAX', type: 'project', sortOrder: 0, meta: { status: '#stInArbeit', owner: 'STEFE', links: 'FileServer/Projects/TISAX' }, archivedAt: null, isFavorite: true, updatedAt: ts, deletedAt: null, version: 1 },
|
||||
|
|
@ -25,7 +43,6 @@ export async function seedIfEmpty(): Promise<void> {
|
|||
{ id: 'f-vendor-x', name: 'Firma VENDOR-X', type: 'company', sortOrder: 0, meta: { website: 'https://vendor-x.com', address: 'Musterstr. 1, 12345 Berlin' }, archivedAt: null, isFavorite: true, updatedAt: ts, deletedAt: null, version: 1 }
|
||||
];
|
||||
|
||||
// Topic IDs
|
||||
const t201 = newId(), t202 = newId(), t203 = newId(), t204 = newId();
|
||||
const t1 = newId(), t2 = newId(), t101 = newId();
|
||||
|
||||
|
|
@ -39,35 +56,23 @@ export async function seedIfEmpty(): Promise<void> {
|
|||
{ id: t101, contextId: 'jf-devs', title: 'API Refactoring', status: 'active', snoozeUntil: null, sortOrder: 0, isNew: false, updatedAt: ts, deletedAt: null, version: 1 }
|
||||
];
|
||||
|
||||
// Journal topic for daily-log
|
||||
const journalTopicId = 'daily-log-journal';
|
||||
const journalTopic: Topic = {
|
||||
id: journalTopicId, contextId: 'daily-log', title: '__journal__', status: 'active',
|
||||
snoozeUntil: null, sortOrder: -1, isNew: false, updatedAt: ts, deletedAt: null, version: 1
|
||||
};
|
||||
|
||||
const history: HistoryEntry[] = [
|
||||
{ id: newId(), topicId: t201, date: today, text: 'Hr. Müller angerufen. Lizenzserver ist down.\n- Ersatzticket erstellt: #INC-999\n- Eskalation an -> VENDOR-X\n- Info an Team -> STEFE\n- @VENDOR-X war sehr hilfsbereit bei der Umgehung.\n- Betrifft @F:VENDOR-X', sortOrder: 0, linkedContextId: null, doneAt: null, updatedAt: ts, deletedAt: null, version: 1 },
|
||||
{ id: newId(), topicId: t202, date: yesterday, text: 'Kosten laufen aus dem Ruder.\n- Idee: S3 Glacier Deep Archive nutzen?\n- Prüfen -> CHFI @P:CLOUD-MIGRATION\n- @CHFI hat super Ideen geliefert.', sortOrder: 0, linkedContextId: null, doneAt: null, updatedAt: ts, deletedAt: null, version: 1 },
|
||||
{ id: newId(), topicId: t203, date: today, text: 'Papierstau behoben. Toner bestellt.', sortOrder: 0, linkedContextId: null, doneAt: null, updatedAt: ts, deletedAt: null, version: 1 },
|
||||
{ id: newId(), topicId: t204, date: lastWeek, text: '- Ziele für Q1 prüfen\n- Schulungsbedarf klären\n- -> CHFI', sortOrder: 0, linkedContextId: null, doneAt: null, updatedAt: ts, deletedAt: null, version: 1 },
|
||||
{ id: newId(), topicId: t1, date: today, text: '- Entscheidung: Keine Ausnahmen mehr.\n- Umsetzung startet nächste Woche @P:TISAX', sortOrder: 1, linkedContextId: null, doneAt: null, updatedAt: ts, deletedAt: null, version: 1 },
|
||||
{ id: newId(), topicId: t1, date: lastWeek, text: '- Maßnahmen definiert:\n - Benutzergruppe für kein Internet -> STEFE @P:TISAX\n - VLAN „shared" nutzen -> PHILO\n - IP-Range prüfen', sortOrder: 0, linkedContextId: null, doneAt: null, updatedAt: ts, deletedAt: null, version: 1 },
|
||||
{ id: newId(), topicId: t2, date: yesterday, text: 'Priorisierung nötig -> ERAY @P:Security', sortOrder: 0, linkedContextId: null, doneAt: null, updatedAt: ts, deletedAt: null, version: 1 },
|
||||
{ id: newId(), topicId: t101, date: today, text: 'Neues Schema prüfen @P:TISAX\n- @ChrKl hat das verbockt.', sortOrder: 0, linkedContextId: null, doneAt: null, updatedAt: ts, deletedAt: null, version: 1 },
|
||||
// Journal entries
|
||||
{ id: newId(), topicId: journalTopicId, date: today, text: 'Standup: Alle Tickets im Sprint on track.', sortOrder: 0, linkedContextId: null, doneAt: null, updatedAt: ts, deletedAt: null, version: 1 },
|
||||
{ id: newId(), topicId: journalTopicId, date: today, text: 'Lizenzthema mit Vendor X besprochen, Eskalation läuft.', sortOrder: 1, linkedContextId: 'jf-sysadmins', doneAt: null, updatedAt: ts, deletedAt: null, version: 1 },
|
||||
{ id: newId(), topicId: journalTopicId, date: yesterday, text: 'Cloud-Migration Review vorbereitet.', sortOrder: 0, linkedContextId: null, doneAt: null, updatedAt: ts, deletedAt: null, version: 1 },
|
||||
{ id: newId(), topicId: t201, date: today, text: 'Hr. Müller angerufen. Lizenzserver ist down.\n- Ersatzticket erstellt: #INC-999\n- Eskalation an -> VENDOR-X\n- Info an Team -> STEFE\n- @VENDOR-X war sehr hilfsbereit bei der Umgehung.\n- Betrifft @F:VENDOR-X', sortOrder: 0, linkedContextId: null, doneAt: null, wiedervorlageDate: null, wiedervorlageResolvedAt: null, updatedAt: ts, deletedAt: null, version: 1 },
|
||||
{ id: newId(), topicId: t202, date: yesterday, text: 'Kosten laufen aus dem Ruder.\n- Idee: S3 Glacier Deep Archive nutzen?\n- Prüfen -> CHFI @P:CLOUD-MIGRATION\n- @CHFI hat super Ideen geliefert.', sortOrder: 0, linkedContextId: null, doneAt: null, wiedervorlageDate: null, wiedervorlageResolvedAt: null, updatedAt: ts, deletedAt: null, version: 1 },
|
||||
{ id: newId(), topicId: t203, date: today, text: 'Papierstau behoben. Toner bestellt.', sortOrder: 0, linkedContextId: null, doneAt: null, wiedervorlageDate: null, wiedervorlageResolvedAt: null, updatedAt: ts, deletedAt: null, version: 1 },
|
||||
{ id: newId(), topicId: t204, date: lastWeek, text: '- Ziele für Q1 prüfen\n- Schulungsbedarf klären\n- -> CHFI', sortOrder: 0, linkedContextId: null, doneAt: null, wiedervorlageDate: null, wiedervorlageResolvedAt: null, updatedAt: ts, deletedAt: null, version: 1 },
|
||||
{ id: newId(), topicId: t1, date: today, text: '- Entscheidung: Keine Ausnahmen mehr.\n- Umsetzung startet nächste Woche @P:TISAX', sortOrder: 1, linkedContextId: null, doneAt: null, wiedervorlageDate: null, wiedervorlageResolvedAt: null, updatedAt: ts, deletedAt: null, version: 1 },
|
||||
{ id: newId(), topicId: t1, date: lastWeek, text: '- Maßnahmen definiert:\n - Benutzergruppe für kein Internet -> STEFE @P:TISAX\n - VLAN „shared" nutzen -> PHILO\n - IP-Range prüfen', sortOrder: 0, linkedContextId: null, doneAt: null, wiedervorlageDate: null, wiedervorlageResolvedAt: null, updatedAt: ts, deletedAt: null, version: 1 },
|
||||
{ id: newId(), topicId: t2, date: yesterday, text: 'Priorisierung nötig -> ERAY @P:Security', sortOrder: 0, linkedContextId: null, doneAt: null, wiedervorlageDate: null, wiedervorlageResolvedAt: null, updatedAt: ts, deletedAt: null, version: 1 },
|
||||
{ id: newId(), topicId: t101, date: today, text: 'Neues Schema prüfen @P:TISAX\n- @ChrKl hat das verbockt.', sortOrder: 0, linkedContextId: null, doneAt: null, wiedervorlageDate: null, wiedervorlageResolvedAt: null, updatedAt: ts, deletedAt: null, version: 1 },
|
||||
];
|
||||
|
||||
await db.transaction('rw', [db.contexts, db.topics, db.historyEntries], async () => {
|
||||
await db.contexts.bulkPut(contexts);
|
||||
await db.topics.bulkPut([...topics, journalTopic]);
|
||||
await db.topics.bulkPut(topics);
|
||||
await db.historyEntries.bulkPut(history);
|
||||
});
|
||||
|
||||
console.log('Seed data loaded');
|
||||
console.log('Dev seed data loaded');
|
||||
}
|
||||
|
||||
export async function resetAndReseed(): Promise<void> {
|
||||
|
|
@ -79,4 +84,5 @@ export async function resetAndReseed(): Promise<void> {
|
|||
await db.syncMeta.clear();
|
||||
});
|
||||
await seedIfEmpty();
|
||||
await seedDevData();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -119,6 +119,27 @@ export function ratingsForPerson(personName: string) {
|
|||
);
|
||||
}
|
||||
|
||||
export function deletedItems() {
|
||||
return liveQuery(async () => ({
|
||||
contexts: await db.contexts.filter(c => !!c.deletedAt).toArray(),
|
||||
topics: await db.topics.filter(t => !!t.deletedAt).toArray(),
|
||||
history: await db.historyEntries.filter(h => !!h.deletedAt).toArray(),
|
||||
ratings: await db.ratings.filter(r => !!r.deletedAt).toArray(),
|
||||
}));
|
||||
}
|
||||
|
||||
export function deletedItemCount() {
|
||||
return liveQuery(async () => {
|
||||
const [c, t, h, r] = await Promise.all([
|
||||
db.contexts.filter(x => !!x.deletedAt).count(),
|
||||
db.topics.filter(x => !!x.deletedAt).count(),
|
||||
db.historyEntries.filter(x => !!x.deletedAt).count(),
|
||||
db.ratings.filter(x => !!x.deletedAt).count(),
|
||||
]);
|
||||
return c + t + h + r;
|
||||
});
|
||||
}
|
||||
|
||||
export function pendingWiedervorlage(date: string) {
|
||||
return liveQuery(() =>
|
||||
db.historyEntries
|
||||
|
|
|
|||
|
|
@ -0,0 +1,275 @@
|
|||
<script lang="ts">
|
||||
import { liveQuery } from 'dexie';
|
||||
import { deletedItems } from '$lib/stores/agenda';
|
||||
import { restoreEntities, hardDeleteEntities, type TrashEntityRef } from '$lib/db/repositories';
|
||||
import { sync } from '$lib/sync/syncService';
|
||||
import type { AgendaContext, Topic, HistoryEntry, Rating } from '@ka-note/shared';
|
||||
|
||||
type FilterType = 'all' | 'context' | 'topic' | 'history' | 'rating';
|
||||
|
||||
const items$ = deletedItems();
|
||||
|
||||
let filter = $state<FilterType>('all');
|
||||
let selected = $state(new Set<string>());
|
||||
let confirming = $state(false);
|
||||
let expandedKey = $state<string | null>(null);
|
||||
let deleteError = $state<string | null>(null);
|
||||
|
||||
interface DisplayItem {
|
||||
key: string;
|
||||
type: TrashEntityRef['type'];
|
||||
id: string;
|
||||
label: string;
|
||||
deletedAt: string;
|
||||
raw: AgendaContext | Topic | HistoryEntry | Rating;
|
||||
}
|
||||
|
||||
function buildItems(data: typeof $items$): DisplayItem[] {
|
||||
if (!data) return [];
|
||||
const result: DisplayItem[] = [];
|
||||
for (const c of data.contexts) {
|
||||
result.push({ key: `context:${c.id}`, type: 'context', id: c.id, label: c.name, deletedAt: c.deletedAt ?? '', raw: c });
|
||||
}
|
||||
for (const t of data.topics) {
|
||||
result.push({ key: `topic:${t.id}`, type: 'topic', id: t.id, label: t.title, deletedAt: t.deletedAt ?? '', raw: t });
|
||||
}
|
||||
for (const h of data.history) {
|
||||
result.push({ key: `history:${h.id}`, type: 'history', id: h.id, label: h.text.split('\n')[0].slice(0, 60) || h.id, deletedAt: h.deletedAt ?? '', raw: h });
|
||||
}
|
||||
for (const r of data.ratings) {
|
||||
result.push({ key: `rating:${r.id}`, type: 'rating', id: r.id, label: `Rating by ${r.personName}`, deletedAt: r.deletedAt ?? '', raw: r });
|
||||
}
|
||||
result.sort((a, b) => b.deletedAt.localeCompare(a.deletedAt));
|
||||
return result;
|
||||
}
|
||||
|
||||
function toggleExpand(key: string) {
|
||||
expandedKey = expandedKey === key ? null : key;
|
||||
}
|
||||
|
||||
const allItems = $derived(buildItems($items$));
|
||||
const filtered = $derived(filter === 'all' ? allItems : allItems.filter(i => i.type === filter));
|
||||
const allSelected = $derived(filtered.length > 0 && filtered.every(i => selected.has(i.key)));
|
||||
|
||||
function toggleAll() {
|
||||
if (allSelected) {
|
||||
selected = new Set();
|
||||
} else {
|
||||
selected = new Set(filtered.map(i => i.key));
|
||||
}
|
||||
}
|
||||
|
||||
function toggleItem(key: string) {
|
||||
const next = new Set(selected);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
selected = next;
|
||||
}
|
||||
|
||||
function selectedEntities(): TrashEntityRef[] {
|
||||
return [...selected].map(key => {
|
||||
const [type, id] = key.split(':') as [TrashEntityRef['type'], string];
|
||||
return { type, id };
|
||||
});
|
||||
}
|
||||
|
||||
async function restore() {
|
||||
const entities = selectedEntities();
|
||||
if (!entities.length) return;
|
||||
await restoreEntities(entities);
|
||||
selected = new Set();
|
||||
sync();
|
||||
}
|
||||
|
||||
async function hardDelete() {
|
||||
const entities = selectedEntities();
|
||||
if (!entities.length) return;
|
||||
deleteError = null;
|
||||
try {
|
||||
await hardDeleteEntities(entities);
|
||||
selected = new Set();
|
||||
confirming = false;
|
||||
sync();
|
||||
} catch (e) {
|
||||
deleteError = e instanceof Error ? e.message : String(e);
|
||||
confirming = false;
|
||||
}
|
||||
}
|
||||
|
||||
function typeLabel(type: TrashEntityRef['type']): string {
|
||||
if (type === 'context') return 'Context';
|
||||
if (type === 'topic') return 'Topic';
|
||||
if (type === 'history') return 'History';
|
||||
return 'Rating';
|
||||
}
|
||||
|
||||
function typeIcon(type: TrashEntityRef['type']): string {
|
||||
if (type === 'context') return '📋';
|
||||
if (type === 'topic') return '📝';
|
||||
if (type === 'history') return '📅';
|
||||
return '⭐';
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
if (!iso) return '';
|
||||
return iso.slice(0, 10);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-4">
|
||||
<h1 class="text-xl font-bold text-white">Papierkorb</h1>
|
||||
|
||||
<!-- Filter tabs -->
|
||||
<div class="flex gap-1 flex-wrap">
|
||||
{#each (['all', 'context', 'topic', 'history', 'rating'] as FilterType[]) as f}
|
||||
<button
|
||||
class="rounded px-3 py-1 text-sm transition-colors {filter === f ? 'bg-accent text-white font-semibold' : 'text-muted hover:bg-white/5 hover:text-white'}"
|
||||
onclick={() => { filter = f; selected = new Set(); }}
|
||||
>
|
||||
{f === 'all' ? 'Alle' : typeLabel(f as TrashEntityRef['type'])}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="flex items-center gap-3 border-b border-border pb-3">
|
||||
<label class="flex items-center gap-2 cursor-pointer text-sm text-muted">
|
||||
<input type="checkbox" checked={allSelected} onchange={toggleAll} class="accent-accent" />
|
||||
Alle
|
||||
</label>
|
||||
<button
|
||||
class="rounded px-3 py-1 text-sm bg-green-700/30 text-green-300 hover:bg-green-700/50 disabled:opacity-30 transition-colors"
|
||||
disabled={selected.size === 0}
|
||||
onclick={restore}
|
||||
>
|
||||
Wiederherstellen ({selected.size})
|
||||
</button>
|
||||
<button
|
||||
class="rounded px-3 py-1 text-sm bg-danger/20 text-danger hover:bg-danger/40 disabled:opacity-30 transition-colors"
|
||||
disabled={selected.size === 0}
|
||||
onclick={() => confirming = true}
|
||||
>
|
||||
Endgültig löschen ({selected.size})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Error banner -->
|
||||
{#if deleteError}
|
||||
<div class="rounded border border-danger/40 bg-danger/10 p-3 flex items-start gap-3">
|
||||
<span class="text-danger text-sm shrink-0">✗</span>
|
||||
<p class="text-sm text-danger flex-1">{deleteError}</p>
|
||||
<button class="text-muted hover:text-white text-xs shrink-0" onclick={() => deleteError = null}>×</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Confirm dialog -->
|
||||
{#if confirming}
|
||||
<div class="rounded border border-danger/40 bg-danger/10 p-4 space-y-3">
|
||||
<p class="text-sm text-white">{selected.size} Objekt(e) endgültig löschen? Diese Aktion kann nicht rückgängig gemacht werden.</p>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="rounded px-3 py-1 text-sm bg-danger text-white hover:bg-danger/80 transition-colors"
|
||||
onclick={hardDelete}
|
||||
>
|
||||
Ja, löschen
|
||||
</button>
|
||||
<button
|
||||
class="rounded px-3 py-1 text-sm text-muted hover:text-white transition-colors"
|
||||
onclick={() => confirming = false}
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- List -->
|
||||
{#if filtered.length === 0}
|
||||
<p class="text-muted text-sm">Keine gelöschten Objekte.</p>
|
||||
{:else}
|
||||
<ul class="space-y-0.5">
|
||||
{#each filtered as item (item.key)}
|
||||
{@const expanded = expandedKey === item.key}
|
||||
<li class="rounded overflow-hidden {expanded ? 'bg-white/5' : ''}">
|
||||
<!-- Row -->
|
||||
<div class="flex items-center gap-3 px-3 py-2 hover:bg-white/5 group cursor-pointer" onclick={() => toggleExpand(item.key)}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(item.key)}
|
||||
onchange={() => toggleItem(item.key)}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
class="accent-accent shrink-0"
|
||||
/>
|
||||
<span class="text-base shrink-0">{typeIcon(item.type)}</span>
|
||||
<span class="text-xs text-muted shrink-0 w-14">{typeLabel(item.type)}</span>
|
||||
<span class="flex-1 text-sm text-[#ccc] truncate" title={item.label}>{item.label}</span>
|
||||
<span class="text-xs text-muted shrink-0">{formatDate(item.deletedAt)}</span>
|
||||
<div class="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
|
||||
<button
|
||||
class="text-xs text-green-400 hover:text-green-300 px-2 py-0.5 rounded hover:bg-white/5"
|
||||
onclick={(e) => { e.stopPropagation(); restoreEntities([{ type: item.type, id: item.id }]).then(() => sync()); }}
|
||||
>
|
||||
Restore
|
||||
</button>
|
||||
<button
|
||||
class="text-xs text-danger hover:text-red-400 px-2 py-0.5 rounded hover:bg-white/5"
|
||||
onclick={(e) => { e.stopPropagation(); selected = new Set([item.key]); confirming = true; }}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
<span class="text-xs text-muted shrink-0 w-3">{expanded ? '▲' : '▼'}</span>
|
||||
</div>
|
||||
|
||||
<!-- Detail preview -->
|
||||
{#if expanded}
|
||||
<div class="px-4 pb-3 pt-1 border-t border-white/5">
|
||||
{#if item.type === 'context'}
|
||||
{@const ctx = item.raw as AgendaContext}
|
||||
<dl class="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-xs">
|
||||
<dt class="text-muted">ID</dt><dd class="text-[#aaa] font-mono">{ctx.id}</dd>
|
||||
<dt class="text-muted">Typ</dt><dd class="text-[#aaa]">{ctx.type}</dd>
|
||||
{#if ctx.archivedAt}<dt class="text-muted">Archiviert</dt><dd class="text-[#aaa]">{formatDate(ctx.archivedAt)}</dd>{/if}
|
||||
<dt class="text-muted">Version</dt><dd class="text-[#aaa]">{ctx.version}</dd>
|
||||
<dt class="text-muted">Geändert</dt><dd class="text-[#aaa]">{formatDate(ctx.updatedAt)}</dd>
|
||||
</dl>
|
||||
{:else if item.type === 'topic'}
|
||||
{@const t = item.raw as Topic}
|
||||
<dl class="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-xs">
|
||||
<dt class="text-muted">ID</dt><dd class="text-[#aaa] font-mono">{t.id}</dd>
|
||||
<dt class="text-muted">Context</dt><dd class="text-[#aaa] font-mono">{t.contextId}</dd>
|
||||
<dt class="text-muted">Status</dt><dd class="text-[#aaa]">{t.status}</dd>
|
||||
<dt class="text-muted">Version</dt><dd class="text-[#aaa]">{t.version}</dd>
|
||||
<dt class="text-muted">Geändert</dt><dd class="text-[#aaa]">{formatDate(t.updatedAt)}</dd>
|
||||
</dl>
|
||||
{:else if item.type === 'history'}
|
||||
{@const h = item.raw as HistoryEntry}
|
||||
<dl class="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-xs mb-2">
|
||||
<dt class="text-muted">ID</dt><dd class="text-[#aaa] font-mono">{h.id}</dd>
|
||||
<dt class="text-muted">Topic</dt><dd class="text-[#aaa] font-mono">{h.topicId}</dd>
|
||||
<dt class="text-muted">Datum</dt><dd class="text-[#aaa]">{h.date}</dd>
|
||||
{#if h.linkedContextId}<dt class="text-muted">Kontext</dt><dd class="text-[#aaa] font-mono">{h.linkedContextId}</dd>{/if}
|
||||
{#if h.wiedervorlageDate}<dt class="text-muted">Wiedervorlage</dt><dd class="text-[#aaa]">{h.wiedervorlageDate}</dd>{/if}
|
||||
<dt class="text-muted">Version</dt><dd class="text-[#aaa]">{h.version}</dd>
|
||||
</dl>
|
||||
{#if h.text}
|
||||
<pre class="whitespace-pre-wrap text-xs text-[#bbb] bg-black/20 rounded p-2 max-h-40 overflow-y-auto leading-relaxed">{h.text}</pre>
|
||||
{/if}
|
||||
{:else if item.type === 'rating'}
|
||||
{@const r = item.raw as Rating}
|
||||
<dl class="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-xs">
|
||||
<dt class="text-muted">ID</dt><dd class="text-[#aaa] font-mono">{r.id}</dd>
|
||||
<dt class="text-muted">Person</dt><dd class="text-[#aaa]">{r.personName}</dd>
|
||||
<dt class="text-muted">Wert</dt><dd class="text-[#aaa]">{'★'.repeat(r.value)}{'☆'.repeat(4 - r.value)}</dd>
|
||||
{#if r.comment}<dt class="text-muted">Kommentar</dt><dd class="text-[#aaa]">{r.comment}</dd>{/if}
|
||||
<dt class="text-muted">History</dt><dd class="text-[#aaa] font-mono">{r.historyEntryId}</dd>
|
||||
<dt class="text-muted">Version</dt><dd class="text-[#aaa]">{r.version}</dd>
|
||||
</dl>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -9,6 +9,7 @@ import { db } from './db/connection.js';
|
|||
import { authMiddleware } from './middleware/auth.js';
|
||||
import syncRoutes from './routes/sync.js';
|
||||
import aiRoutes from './routes/ai-export.js';
|
||||
import trashRoutes from './routes/trash.js';
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
|
|
@ -45,6 +46,9 @@ app.route('/api/sync', syncRoutes);
|
|||
app.use('/api/ai/*', authMiddleware);
|
||||
app.route('/api/ai', aiRoutes);
|
||||
|
||||
app.use('/api/trash/*', authMiddleware);
|
||||
app.route('/api/trash', trashRoutes);
|
||||
|
||||
// Static file serving in production
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
app.use('*', serveStatic({ root: './public' }));
|
||||
|
|
|
|||
|
|
@ -0,0 +1,80 @@
|
|||
import { Hono } from 'hono';
|
||||
import { db } from '../db/connection.js';
|
||||
import { contexts, topics, historyEntries, ratings } from '../db/schema.js';
|
||||
import { and, eq, inArray } from 'drizzle-orm';
|
||||
import type { AuthEnv } from '../middleware/auth.js';
|
||||
|
||||
const trash = new Hono<AuthEnv>();
|
||||
|
||||
interface EntityRef {
|
||||
type: 'context' | 'topic' | 'history' | 'rating';
|
||||
id: string;
|
||||
}
|
||||
|
||||
/** Resolves all dependent IDs that must be deleted alongside the given entities. */
|
||||
async function resolveCascade(entities: EntityRef[], userId: string) {
|
||||
const contextIds = new Set(entities.filter(e => e.type === 'context').map(e => e.id));
|
||||
const topicIds = new Set(entities.filter(e => e.type === 'topic').map(e => e.id));
|
||||
const historyIds = new Set(entities.filter(e => e.type === 'history').map(e => e.id));
|
||||
const ratingIds = new Set(entities.filter(e => e.type === 'rating').map(e => e.id));
|
||||
|
||||
// context → topics
|
||||
if (contextIds.size > 0) {
|
||||
const childTopics = await db
|
||||
.select({ id: topics.id })
|
||||
.from(topics)
|
||||
.where(and(eq(topics.userId, userId), inArray(topics.contextId, [...contextIds])));
|
||||
for (const t of childTopics) topicIds.add(t.id);
|
||||
}
|
||||
|
||||
// topics → history entries
|
||||
if (topicIds.size > 0) {
|
||||
const childHistory = await db
|
||||
.select({ id: historyEntries.id })
|
||||
.from(historyEntries)
|
||||
.where(and(eq(historyEntries.userId, userId), inArray(historyEntries.topicId, [...topicIds])));
|
||||
for (const h of childHistory) historyIds.add(h.id);
|
||||
}
|
||||
|
||||
// history entries → ratings
|
||||
if (historyIds.size > 0) {
|
||||
const childRatings = await db
|
||||
.select({ id: ratings.id })
|
||||
.from(ratings)
|
||||
.where(and(eq(ratings.userId, userId), inArray(ratings.historyEntryId, [...historyIds])));
|
||||
for (const r of childRatings) ratingIds.add(r.id);
|
||||
}
|
||||
|
||||
return { contextIds, topicIds, historyIds, ratingIds };
|
||||
}
|
||||
|
||||
trash.delete('/', async (c) => {
|
||||
const { userId } = c.get('auth');
|
||||
const body = await c.req.json<{ entities: EntityRef[] }>();
|
||||
const { entities } = body;
|
||||
|
||||
if (!Array.isArray(entities) || entities.length === 0) {
|
||||
return c.json({ error: 'No entities provided' }, 400);
|
||||
}
|
||||
|
||||
const { contextIds, topicIds, historyIds, ratingIds } = await resolveCascade(entities, userId);
|
||||
|
||||
// Delete in FK-safe order: ratings → history → topics → contexts
|
||||
if (ratingIds.size > 0) {
|
||||
await db.delete(ratings).where(and(eq(ratings.userId, userId), inArray(ratings.id, [...ratingIds])));
|
||||
}
|
||||
if (historyIds.size > 0) {
|
||||
await db.delete(historyEntries).where(and(eq(historyEntries.userId, userId), inArray(historyEntries.id, [...historyIds])));
|
||||
}
|
||||
if (topicIds.size > 0) {
|
||||
await db.delete(topics).where(and(eq(topics.userId, userId), inArray(topics.id, [...topicIds])));
|
||||
}
|
||||
if (contextIds.size > 0) {
|
||||
await db.delete(contexts).where(and(eq(contexts.userId, userId), inArray(contexts.id, [...contextIds])));
|
||||
}
|
||||
|
||||
const total = ratingIds.size + historyIds.size + topicIds.size + contextIds.size;
|
||||
return c.json({ deleted: total });
|
||||
});
|
||||
|
||||
export default trash;
|
||||
Loading…
Reference in New Issue