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>
200 lines
7.9 KiB
TypeScript
200 lines
7.9 KiB
TypeScript
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 '📁'
|
||
}
|