Files
Aether/frontend/src/pages/TrashPage.tsx
T
marcandClaude Opus 5 b5a83e4057 fix(upload): gezogenes Vorschaubild wurde erneut hochgeladen
Zieht man das angezeigte Bild ein Stueck zur Seite, legt Chrome es als
echte Datei in den Ziehvorgang. dataTransfer.types enthaelt dann
"Files" - nicht zu unterscheiden von einer Datei aus dem Dateimanager.
Die Ablageflaeche lud das eigene Bild daraufhin ein zweites Mal hoch.

Zwei Riegel: draggable={false} an allen Bildern, die kein eigenes
Ziehen brauchen (die Kachel bleibt ziehbar und verschiebt die Datei),
und useInternalDrag - was in dieser Seite losgezogen ist, wird nie als
Upload behandelt. dragstart feuert nur bei eigener Quelle.

Gemessen: vorher 1 Upload, nachher 0. Ein Zug von aussen laedt
unveraendert hoch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 17:24:22 +02:00

200 lines
7.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect } from 'react'
import { trashApi, filesApi, type TrashItem } from '../api/client'
import { formatBytes } from './FileBrowser'
import PageHeader from '../components/PageHeader'
import ConfirmDialog from '../components/ConfirmDialog'
interface Props {
/** Wiederherstellen verändert den belegten Speicher – Balken nachziehen. */
onUserChanged?: () => Promise<void>
}
export default function TrashPage({ onUserChanged }: Props) {
const [items, setItems] = useState<TrashItem[]>([])
const [loading, setLoading] = useState(true)
const [confirmDelete, setConfirmDelete] = useState<TrashItem | null>(null)
const [confirmEmpty, setConfirmEmpty] = useState(false)
const [emptying, setEmptying] = useState(false)
useEffect(() => {
trashApi.list()
.then(data => setItems(data ?? []))
.catch(() => {})
.finally(() => setLoading(false))
}, [])
async function restore(file: TrashItem) {
await trashApi.restore(file.id)
setItems(i => i.filter(x => x.id !== file.id))
await onUserChanged?.()
}
async function emptyAll() {
setEmptying(true)
try {
await trashApi.emptyAll()
setItems([])
await onUserChanged?.()
} catch { /* Liste bleibt stehen, damit der Fehlschlag sichtbar ist */ }
setEmptying(false)
setConfirmEmpty(false)
}
async function deletePermanently(file: TrashItem) {
await trashApi.deletePermanently(file.id)
setItems(i => i.filter(x => x.id !== file.id))
setConfirmDelete(null)
}
return (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<PageHeader
icon={
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6l-1 14H6L5 6M10 11v6M14 11v6M9 6V4h6v2" />
</svg>
}
title="Trash"
meta={items.length > 0 ? `${items.length} item${items.length !== 1 ? 's' : ''}` : undefined}
>
{items.length > 0 && (
<button
onClick={() => setConfirmEmpty(true)}
disabled={emptying}
style={{
padding: '6px 14px', borderRadius: 8, fontSize: 12, fontWeight: 500,
border: '1px solid var(--color-danger)', background: 'transparent',
color: 'var(--color-danger)', cursor: emptying ? 'default' : 'pointer',
}}
>
{emptying ? 'Emptying…' : 'Empty trash'}
</button>
)}
</PageHeader>
<div className="aether-content" style={{ flex: 1, overflowY: 'auto' }}>
{loading && (
<div style={{ display: 'flex', justifyContent: 'center', paddingTop: 80 }}>
<div style={{ width: 24, height: 24, border: '2px solid var(--color-primary)', borderTopColor: 'transparent', borderRadius: '50%', animation: 'spin 0.7s linear infinite' }} />
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
</div>
)}
{!loading && items.length === 0 && (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', paddingTop: 100, gap: 12 }}>
<div style={{ fontSize: 56, opacity: 0.25 }}>🗑</div>
<p style={{ color: 'var(--color-text-muted)', fontSize: 14, margin: 0 }}>Trash is empty</p>
</div>
)}
{!loading && items.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{items.map(file => (
<div key={file.id} style={{
display: 'flex', alignItems: 'center', gap: 14,
padding: '10px 14px',
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
borderRadius: 10,
}}>
{/* Thumbnail or icon */}
<div style={{
width: 44, height: 44, borderRadius: 8, flexShrink: 0,
overflow: 'hidden', background: 'var(--color-surface-2)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{file.has_thumbnail ? (
<img
draggable={false}
src={filesApi.thumbnail(file.id)}
alt=""
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
onError={e => { e.currentTarget.style.display = 'none' }}
/>
) : (
<span style={{ fontSize: 20 }}>{mimeEmoji(file.mime_type)}</span>
)}
</div>
{/* Info */}
<div style={{ flex: 1, minWidth: 0 }}>
<p style={{ margin: 0, fontSize: 13, fontWeight: 500, color: 'var(--color-text)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{file.name}
</p>
<p style={{ margin: '3px 0 0', fontSize: 11, color: 'var(--color-text-muted)', display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<span>{formatBytes(file.size)}</span>
<span style={{ display: 'flex', alignItems: 'center', gap: 3 }}>
<svg width="10" height="10" viewBox="0 0 24 24" fill="var(--color-warning)" stroke="none">
<path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2z" />
</svg>
{file.folder_path ? `Home / ${file.folder_path}` : 'Home'}
</span>
</p>
</div>
{/* Actions */}
<button
onClick={() => restore(file)}
style={{
padding: '5px 12px', borderRadius: 6,
border: '1px solid var(--color-border)',
background: 'transparent', color: 'var(--color-text)',
fontSize: 12, cursor: 'pointer', whiteSpace: 'nowrap', flexShrink: 0,
}}
>
Restore
</button>
<button
onClick={() => setConfirmDelete(file)}
style={{
padding: '5px 12px', borderRadius: 6,
border: '1px solid var(--color-danger)',
background: 'transparent', color: 'var(--color-danger)',
fontSize: 12, cursor: 'pointer', whiteSpace: 'nowrap', flexShrink: 0,
}}
>
Delete forever
</button>
</div>
))}
</div>
)}
</div>
{confirmDelete && (
<ConfirmDialog
title="Permanently delete"
message={`"${confirmDelete.name}" permanently delete? This cannot be undone.`}
confirmLabel="Delete forever"
danger
onConfirm={() => deletePermanently(confirmDelete)}
onCancel={() => setConfirmDelete(null)}
/>
)}
{confirmEmpty && (
<ConfirmDialog
title="Empty trash"
message={`${items.length} ${items.length === 1 ? 'file' : 'files'} will be deleted permanently, including all revisions. This cannot be undone.`}
confirmLabel="Delete forever"
danger
onConfirm={emptyAll}
onCancel={() => setConfirmEmpty(false)}
/>
)}
</div>
)
}
function mimeEmoji(mime: string): string {
if (mime.startsWith('image/')) return '🖼'
if (mime.startsWith('video/')) return '🎬'
if (mime.startsWith('audio/')) return '🎵'
if (mime.includes('pdf')) return '📄'
if (mime.includes('zip') || mime.includes('tar')) return '📦'
if (mime.includes('word') || mime.includes('document')) return '📝'
if (mime.includes('sheet') || mime.includes('excel')) return '📊'
return '📁'
}