This commit is contained in:
Louis Mazin 2026-10-14 18:51:52 +02:00
parent a5fb4ad3fa
commit 17afb70810
9 changed files with 229 additions and 18 deletions

View File

@ -0,0 +1,83 @@
"""USE_SQLITE=true python manage.py test drive.test_empty_trash"""
import tempfile
from pathlib import Path
from uuid import uuid4
from django.contrib.auth import get_user_model
from django.test import TestCase, override_settings
from django.utils import timezone
from rest_framework.test import APIClient
from .models import DriveItem, SpaceGrant
from .spaces import ensure_private_space, ensure_space
from .storage_layout import disk_path
class EmptyTrashTests(TestCase):
def setUp(self):
temp = tempfile.TemporaryDirectory()
self.addCleanup(temp.cleanup)
root = Path(temp.name)
config = override_settings(DRIVE_STORAGE_ROOT=root, FILE_UPLOAD_TEMP_DIR=str(root / '.tmp'))
config.enable()
self.addCleanup(config.disable)
self.admin = get_user_model().objects.create_superuser('admin-trash', 'admin@example.org', 'pass')
self.other = get_user_model().objects.create_user('private-owner', password='pass')
self.shared = ensure_space(self.admin)
SpaceGrant.objects.create(user=self.other, space=self.shared, permissions=['view', 'download'])
self.private = ensure_private_space(self.other)
self.client = APIClient()
self.client.force_authenticate(self.admin)
def file(self, space, owner, filename, relative_location):
location = '/'.join(p for p in (space.relative_path, relative_location) if p)
path = disk_path(location)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(filename.encode())
record = DriveItem.objects.create(
space=space, owner=owner, name=filename, mime_type='text/plain',
storage_key=str(uuid4()), storage_location=location,
size=len(filename), trashed_at=timezone.now(),
)
return record, path
def test_empty_trash_removes_accessible_files_but_not_other_private_files(self):
item1, path1 = self.file(self.shared, self.admin, 'duplicata.txt', '.gooddrive-trash/id1/duplicata.txt')
item2, path2 = self.file(self.shared, self.admin, 'duplicata.txt', '.gooddrive-trash/id2/duplicata.txt')
secret, secret_path = self.file(self.private, self.other, 'prive.txt', '.gooddrive-trash/private/prive.txt')
with self.captureOnCommitCallbacks(execute=True):
response = self.client.delete('/api/drive/trash/')
self.assertEqual(response.status_code, 200, response.data)
self.assertEqual(response.data['deleted'], 2)
self.assertFalse(DriveItem.objects.filter(pk=item1.pk).exists())
self.assertFalse(DriveItem.objects.filter(pk=item2.pk).exists())
self.assertTrue(DriveItem.objects.filter(pk=secret.pk).exists())
self.assertFalse(path1.exists())
self.assertFalse(path2.exists())
self.assertTrue(secret_path.exists())
def test_empty_trash_requires_authentication(self):
self.client.force_authenticate(user=None)
response = self.client.delete('/api/drive/trash/')
self.assertEqual(response.status_code, 401)
def test_empty_trash_never_deletes_active_files(self):
active, path = self.file(self.shared, self.admin, 'actif.txt', 'actif.txt')
DriveItem.objects.filter(pk=active.pk).update(trashed_at=None)
response = self.client.delete('/api/drive/trash/')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['deleted'], 0)
self.assertTrue(path.exists())
self.assertTrue(DriveItem.objects.filter(pk=active.pk).exists())
def test_reader_cannot_empty_trash(self):
reader = get_user_model().objects.create_user('reader', password='pass')
SpaceGrant.objects.create(user=reader, space=self.shared, permissions=['view', 'download'])
item, path = self.file(self.shared, self.admin, 'lecture.txt', '.gooddrive-trash/reader/lecture.txt')
self.client.force_authenticate(reader)
response = self.client.delete('/api/drive/trash/')
self.assertEqual(response.status_code, 200, response.data)
self.assertEqual(response.data['deleted'], 0)
self.assertEqual(response.data['skipped'], 1)
self.assertTrue(path.exists())
self.assertTrue(DriveItem.objects.filter(pk=item.pk).exists())

View File

@ -26,6 +26,7 @@ urlpatterns=[
path('invitations/',views_spaces.create_invitation), path('invitations/',views_spaces.create_invitation),
path('invitations/<uuid:invitation_id>/revoke/',views_spaces.revoke_invitation), path('invitations/<uuid:invitation_id>/revoke/',views_spaces.revoke_invitation),
path('items/',views.listing), path('items/',views.listing),
path('trash/',views.empty_trash),
path('stats/',views.stats), path('stats/',views.stats),
path('folders/',views.create_folder), path('folders/',views.create_folder),
path('uploads/',views.upload), path('uploads/',views.upload),

View File

@ -5,7 +5,7 @@ Implementations are grouped by responsibility without changing route names.
from .views_items import (me, listing, stats, create_folder, update_item, from .views_items import (me, listing, stats, create_folder, update_item,
trash_item, restore_item, delete_item) trash_item, restore_item, delete_item, empty_trash)
from .views_transfers import (upload, upload_start, upload_status, upload_chunk, from .views_transfers import (upload, upload_start, upload_status, upload_chunk,
upload_finish, upload_cancel, upload_pending) upload_finish, upload_cancel, upload_pending)
from .views_content import content_url, content from .views_content import content_url, content

View File

@ -11,7 +11,7 @@ from .permissions import has_space_right, item_rights
from .quotas import quota_state from .quotas import quota_state
from .services import error, item_info, get_item, chain, readable, writable, valid_name, target_parent, writable_destination from .services import error, item_info, get_item, chain, readable, writable, valid_name, target_parent, writable_destination
from .storage_layout import assert_available, prepare_parent, relocate_item, move_to_trash, NameConflict from .storage_layout import assert_available, prepare_parent, relocate_item, move_to_trash, NameConflict
from .trash_cleanup import delete_trashed_item from .trash_cleanup import delete_trashed_item, has_active_uploads_in_tree
@api_view(['GET']) @api_view(['GET'])
@ -224,3 +224,36 @@ def delete_item(request,item_id):
except (OSError,ValueError) as exc: except (OSError,ValueError) as exc:
return error(f'Suppression impossible : {exc}',409) return error(f'Suppression impossible : {exc}',409)
return Response(status=204) return Response(status=204)
@api_view(['DELETE'])
def empty_trash(request):
"""Permanently delete only trashed items for which the caller has Delete.
Traverse all spaces rather than only the first 500 results in the UI.
An admin never acquires access to another user's private space.
"""
from .spaces import can_read_space
allowed_spaces = [s.pk for s in DriveSpace.objects.all() if can_read_space(request.user, s)]
candidates = list(DriveItem.objects.filter(
space_id__in=allowed_spaces, trashed_at__isnull=False
).order_by('trashed_at').values_list('pk', flat=True))
deleted = skipped = 0
for pk in candidates:
# A parent already purged may have removed this item by cascade.
item = DriveItem.objects.select_related('owner', 'parent', 'space').filter(pk=pk).first()
if item is None:
continue
if not readable(item, request.user, allow_trashed=True) or not writable(item, request.user, 'delete'):
skipped += 1
continue
if has_active_uploads_in_tree(item):
skipped += 1
continue
try:
delete_trashed_item(item)
except (ValueError, OSError):
skipped += 1
else:
deleted += 1
return Response({'deleted': deleted, 'skipped': skipped})

View File

@ -82,6 +82,7 @@ export const drive={
folder:(name,parent,space)=>api.post('/drive/folders/',{name,parent,space}).then(r=>r.data), folder:(name,parent,space)=>api.post('/drive/folders/',{name,parent,space}).then(r=>r.data),
patch:(id,updates)=>api.patch(`/drive/items/${id}/`,updates).then(r=>r.data), patch:(id,updates)=>api.patch(`/drive/items/${id}/`,updates).then(r=>r.data),
trash:id=>api.post(`/drive/items/${id}/trash/`), trash:id=>api.post(`/drive/items/${id}/trash/`),
emptyTrash:()=>api.delete('/drive/trash/').then(r=>r.data),
restore:id=>api.post(`/drive/items/${id}/restore/`), restore:id=>api.post(`/drive/items/${id}/restore/`),
remove:id=>api.delete(`/drive/items/${id}/delete/`), remove:id=>api.delete(`/drive/items/${id}/delete/`),
link:(id,inline=false)=>api.post(`/drive/items/${id}/link/`,{inline}).then(r=>r.data.url), link:(id,inline=false)=>api.post(`/drive/items/${id}/link/`,{inline}).then(r=>r.data.url),

View File

@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useState } from 'react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { import {
ChevronRight, Clock3, FolderOpen, Grid2X2, List as ListIcon, ChevronRight, Clock3, FolderOpen, Grid2X2, List as ListIcon, Trash2,
LoaderCircle, Search, Upload, LoaderCircle, Search, Upload,
} from 'lucide-react' } from 'lucide-react'
import FileList from './FileList' import FileList from './FileList'
@ -10,15 +10,20 @@ import FileGrid from './FileGrid'
import Banner from './StatusBanner' import Banner from './StatusBanner'
import { headerNames } from '@/config/navigation' import { headerNames } from '@/config/navigation'
import ContextActions, {fileOptions} from './ContextActions' import ContextActions, {fileOptions} from './ContextActions'
import {
AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle,
AlertDialogDescription, AlertDialogFooter, AlertDialogCancel,
} from '@/components/ui/alert-dialog'
export default function DriveExplorer({ controller, user }) { export default function DriveExplorer({ controller, user }) {
const { const {
view, setParent, trail, items, stats, query, setQuery, search, loading, view, setParent, trail, items, stats, query, setQuery, search, loading,
error, setError, note, setNote, layout, setLayout, sort, setSort, error, setError, note, setNote, layout, setLayout, sort, setSort,
display, goRoot, openEntry, startAction, performItemAction, filesInput, isTrash, canWrite, activeSpace, display, goRoot, openEntry, startAction, performItemAction, filesInput, isTrash, canWrite, activeSpace, emptyTrash, emptyingTrash,
} = controller } = controller
const [context, setContext] = useState(null) const [context, setContext] = useState(null)
const [confirmEmptyTrash, setConfirmEmptyTrash] = useState(false)
const closeContext = useCallback(() => setContext(null), []) const closeContext = useCallback(() => setContext(null), [])
const openContext = (item,x,y) => { const openContext = (item,x,y) => {
const options=fileOptions(item,isTrash,view==='shared') const options=fileOptions(item,isTrash,view==='shared')
@ -75,6 +80,11 @@ export default function DriveExplorer({ controller, user }) {
</div> </div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
{isTrash && <Button
variant="destructive" size="sm"
disabled={loading || !items.length || emptyingTrash}
onClick={() => setConfirmEmptyTrash(true)}
><Trash2 className="size-4" /> {emptyingTrash ? 'Suppression…' : 'Vider la corbeille'}</Button>}
<span className="mr-1 hidden text-xs text-muted-foreground sm:inline">Trier :</span> <span className="mr-1 hidden text-xs text-muted-foreground sm:inline">Trier :</span>
<select value={sort} onChange={event => setSort(event.target.value)} className="h-9 rounded-lg border bg-card px-2 text-xs outline-none sm:text-sm"> <select value={sort} onChange={event => setSort(event.target.value)} className="h-9 rounded-lg border bg-card px-2 text-xs outline-none sm:text-sm">
<option value="name">Nom</option> <option value="name">Nom</option>
@ -127,6 +137,20 @@ export default function DriveExplorer({ controller, user }) {
)} )}
</div> </div>
<AlertDialog open={confirmEmptyTrash && isTrash} onOpenChange={setConfirmEmptyTrash}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Vider la corbeille ?</AlertDialogTitle>
<AlertDialogDescription>Les éléments que tu peux supprimer seront effacés définitivement. Cette action est irréversible.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={emptyingTrash}>Annuler</AlertDialogCancel>
<Button variant="destructive" disabled={emptyingTrash} onClick={() => { setConfirmEmptyTrash(false); emptyTrash() }}>
<Trash2 className="size-4"/> Supprimer définitivement
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<ContextActions menu={context && {...context, options:fileOptions(context.item,isTrash,view==='shared')}} onClose={closeContext} <ContextActions menu={context && {...context, options:fileOptions(context.item,isTrash,view==='shared')}} onClose={closeContext}
onChoose={type => performItemAction(context.item,type)} /> onChoose={type => performItemAction(context.item,type)} />
<footer className="flex justify-between gap-2 px-1 pt-3 text-[11px] text-muted-foreground"> <footer className="flex justify-between gap-2 px-1 pt-3 text-[11px] text-muted-foreground">

View File

@ -1,9 +1,16 @@
import { useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { LoaderCircle, FileText, Download, RefreshCcw, Maximize2, Minimize2 } from 'lucide-react' import { LoaderCircle, FileText, Download, RefreshCcw, Maximize2, Minimize2 } from 'lucide-react'
import { fmtSize } from '@/lib/drive-format' import { fmtSize } from '@/lib/drive-format'
function supportsViewerFullscreen(item) {
const mime = (item?.mime_type || '').toLowerCase()
const name = (item?.name || '').toLowerCase()
return !(mime.startsWith('audio/') || mime.startsWith('video/') ||
/\.(mp3|wav|ogg|flac|m4a|aac|mp4|webm|ogv|mov|m4v)$/.test(name))
}
function Preview({ item, url, onError, fullscreen }) { function Preview({ item, url, onError, fullscreen }) {
if (!url) return <LoaderCircle className="size-6 animate-spin" /> if (!url) return <LoaderCircle className="size-6 animate-spin" />
if (url === 'unsupported') return <div className="p-8 text-center"><FileText className="mx-auto mb-2 size-12 text-muted-foreground"/><p className="text-sm">Impossible de charger l'aperçu. Tu peux réessayer ou télécharger le fichier.</p></div> if (url === 'unsupported') return <div className="p-8 text-center"><FileText className="mx-auto mb-2 size-12 text-muted-foreground"/><p className="text-sm">Impossible de charger l'aperçu. Tu peux réessayer ou télécharger le fichier.</p></div>
@ -25,13 +32,44 @@ function Preview({ item, url, onError, fullscreen }) {
export default function PreviewDialog({ controller: c }) { export default function PreviewDialog({ controller: c }) {
const { preview, setPreview, previewUrl, setPreviewUrl, download, openEntry } = c const { preview, setPreview, previewUrl, setPreviewUrl, download, openEntry } = c
const dialogRef = useRef(null)
const [fullscreen, setFullscreen] = useState(false) const [fullscreen, setFullscreen] = useState(false)
// Browser fullscreen and fallback window-sized dialog use the same layout.
// Escape in native fullscreen restores the normal dialog automatically.
useEffect(() => {
const update = () => {
if (!document.fullscreenElement) setFullscreen(false)
else setFullscreen(document.fullscreenElement === dialogRef.current)
}
document.addEventListener('fullscreenchange', update)
return () => document.removeEventListener('fullscreenchange', update)
}, [])
const close = () => { const close = () => {
if (document.fullscreenElement === dialogRef.current) {
document.exitFullscreen?.().catch(() => {})
}
setFullscreen(false) setFullscreen(false)
setPreview(null) setPreview(null)
setPreviewUrl('') setPreviewUrl('')
} }
// A failed URL/media is the only case where retrying is relevant. const toggleFullscreen = async () => {
if (document.fullscreenElement === dialogRef.current) {
await document.exitFullscreen()
return
}
const el = dialogRef.current
if (el?.requestFullscreen) {
try {
await el.requestFullscreen()
setFullscreen(true)
return
} catch { /* Fallback for browsers that block fullscreen. */ }
}
setFullscreen(value => !value)
}
const previewFailed = previewUrl === 'unsupported' const previewFailed = previewUrl === 'unsupported'
const handleMediaError = () => { const handleMediaError = () => {
setPreviewUrl(current => current === previewUrl ? 'unsupported' : current) setPreviewUrl(current => current === previewUrl ? 'unsupported' : current)
@ -39,23 +77,29 @@ export default function PreviewDialog({ controller: c }) {
return <Dialog open={!!preview} onOpenChange={value => { if (!value) close() }}> return <Dialog open={!!preview} onOpenChange={value => { if (!value) close() }}>
<DialogContent <DialogContent
ref={dialogRef}
showCloseButton={false}
className={fullscreen className={fullscreen
? 'grid h-dvh w-screen max-w-none grid-rows-[auto_minmax(0,1fr)_auto] gap-3 overflow-hidden rounded-none p-4' ? 'grid grid-rows-[auto_minmax(0,1fr)_auto] gap-3 overflow-hidden border-0 bg-background p-4'
: 'w-[calc(100vw-1.5rem)] max-w-[min(96vw,1100px)]'} : 'grid max-h-[calc(100dvh-1rem)] w-[calc(100vw-1.5rem)] max-w-[min(96vw,1100px)] grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden'}
style={fullscreen ? { top: 0, left: 0, inset: 0, transform: 'none', width: '100vw', maxWidth: 'none', height: '100dvh', borderRadius: 0 } : undefined} style={fullscreen ? {
top: 0, left: 0, right: 0, bottom: 0, inset: 0,
transform: 'none', width: '100vw', maxWidth: 'none', maxHeight: 'none',
height: '100dvh', borderRadius: 0,
} : undefined}
> >
<DialogHeader> <DialogHeader className="min-w-0">
<DialogTitle className="max-w-[90%] break-all text-base">{preview?.name}</DialogTitle> <DialogTitle className="max-w-[calc(100%-1rem)] break-all text-base">{preview?.name}</DialogTitle>
<DialogDescription>{preview && !preview.is_folder ? fmtSize(preview.size) : ''}</DialogDescription> <DialogDescription>{preview && !preview.is_folder ? fmtSize(preview.size) : ''}</DialogDescription>
</DialogHeader> </DialogHeader>
<div className={fullscreen <div className={fullscreen
? 'flex min-h-0 h-full items-center justify-center overflow-auto rounded-xl bg-muted/60' ? 'flex min-h-0 h-full w-full items-center justify-center overflow-auto rounded-xl bg-muted/60'
: 'flex min-h-40 max-h-[72dvh] items-center justify-center overflow-auto rounded-xl bg-muted/60'}> : 'flex min-h-36 max-h-[72dvh] w-full items-center justify-center overflow-auto rounded-xl bg-muted/60'}>
{preview && <Preview item={preview} url={previewUrl} fullscreen={fullscreen} onError={handleMediaError}/>} {preview && <Preview item={preview} url={previewUrl} fullscreen={fullscreen} onError={handleMediaError}/>}
</div> </div>
<DialogFooter> <DialogFooter className="shrink-0">
{previewFailed && preview && <Button variant="outline" onClick={() => openEntry(preview)}><RefreshCcw/> Réessayer</Button>} {previewFailed && preview && <Button variant="outline" onClick={() => openEntry(preview)}><RefreshCcw/> Réessayer</Button>}
{preview && <Button variant="outline" onClick={() => setFullscreen(value => !value)} aria-label={fullscreen ? 'Quitter le plein écran' : 'Afficher en plein écran'}>{fullscreen ? <Minimize2/> : <Maximize2/>}{fullscreen ? 'Réduire' : 'Plein écran'}</Button>} {preview && supportsViewerFullscreen(preview) && <Button variant="outline" onClick={toggleFullscreen} aria-label={fullscreen ? 'Quitter le plein écran' : 'Afficher en plein écran'}>{fullscreen ? <Minimize2/> : <Maximize2/>}{fullscreen ? 'Réduire' : 'Plein écran'}</Button>}
<Button variant="outline" onClick={close}>Fermer</Button> <Button variant="outline" onClick={close}>Fermer</Button>
{preview?.permissions?.includes('download') && <Button onClick={() => download(preview)}><Download/> Télécharger</Button>} {preview?.permissions?.includes('download') && <Button onClick={() => download(preview)}><Download/> Télécharger</Button>}
</DialogFooter> </DialogFooter>

View File

@ -36,6 +36,7 @@ export function useDriveController(user) {
const [preview, setPreview] = useState(null) const [preview, setPreview] = useState(null)
const [previewUrl, setPreviewUrl] = useState('') const [previewUrl, setPreviewUrl] = useState('')
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const [emptyingTrash, setEmptyingTrash] = useState(false)
const [drag, setDrag] = useState(false) const [drag, setDrag] = useState(false)
const filesInput = useRef(null) const filesInput = useRef(null)
const previewSequence = useRef(0) const previewSequence = useRef(0)
@ -343,6 +344,26 @@ export function useDriveController(user) {
setModal(type) setModal(type)
} }
const emptyTrash = async () => {
if (view !== 'trash' || emptyingTrash) return
const sourceScope = feedbackScopeRef.current
setEmptyingTrash(true)
setError('')
try {
const result = await drive.emptyTrash()
if (feedbackScopeRef.current === sourceScope) {
const skipped = result.skipped || 0
setNote(`${result.deleted} élément${result.deleted === 1 ? '' : 's'} supprimé${result.deleted === 1 ? '' : 's'} définitivement.${skipped ? ` ${skipped} élément${skipped === 1 ? '' : 's'} conservé${skipped === 1 ? '' : 's'} (droits insuffisants, transfert actif ou erreur).` : ''}`, sourceScope)
await refresh()
}
reloadStats()
} catch (exception) {
setError(message(exception), sourceScope)
} finally {
setEmptyingTrash(false)
}
}
const isTrash = view === 'trash' const isTrash = view === 'trash'
return { return {
@ -353,7 +374,7 @@ export function useDriveController(user) {
error, setError, note, setNote, layout, setLayout, sort, setSort, dark, setDark, error, setError, note, setNote, layout, setLayout, sort, setSort, dark, setDark,
sidebar, setSidebar, modal, setModal, action, value, setValue, sidebar, setSidebar, modal, setModal, action, value, setValue,
moveTrail, moveItems, preview, setPreview, previewUrl, setPreviewUrl, moveTrail, moveItems, preview, setPreview, previewUrl, setPreviewUrl,
busy, uploads, drag, setDrag, filesInput, busy, emptyingTrash, emptyTrash, uploads, drag, setDrag, filesInput,
refresh, section, openEntry, download, uploadFiles, startAction, refresh, section, openEntry, download, uploadFiles, startAction,
display, goRoot, moveBrowse, actionSubmit, handleAction, performItemAction, isTrash, display, goRoot, moveBrowse, actionSubmit, handleAction, performItemAction, isTrash,
} }

View File

@ -42,6 +42,10 @@ export default function PublicSharePage({token}) {
document.addEventListener('fullscreenchange', update) document.addEventListener('fullscreenchange', update)
return ()=>document.removeEventListener('fullscreenchange', update) return ()=>document.removeEventListener('fullscreenchange', update)
},[]) },[])
const closeViewer=()=>{
if(document.fullscreenElement===viewerRef.current) document.exitFullscreen?.().catch(()=>{})
setSelected(null)
}
const toggleFullscreen=async()=>{ const toggleFullscreen=async()=>{
try { try {
if(document.fullscreenElement===viewerRef.current) await document.exitFullscreen() if(document.fullscreenElement===viewerRef.current) await document.exitFullscreen()
@ -90,7 +94,7 @@ export default function PublicSharePage({token}) {
</main> </main>
</div> </div>
{selected && <div ref={viewerRef} className="fixed inset-0 z-50 flex flex-col bg-black/95 p-3 text-white sm:p-6" role="dialog" aria-modal="true" aria-label={`Visualiser ${selected.name}`}> {selected && <div ref={viewerRef} className="fixed inset-0 z-50 flex flex-col bg-black/95 p-3 text-white sm:p-6" role="dialog" aria-modal="true" aria-label={`Visualiser ${selected.name}`}>
<div className="mb-4 flex items-center justify-between gap-2"><p className="min-w-0 truncate font-medium">{selected.name}</p><div className="flex items-center gap-2">{fullscreenSupported && <Button variant="secondary" size="icon" aria-label={fullscreen ? "Quitter le plein écran" : "Afficher en plein écran"} title={fullscreen ? "Réduire" : "Plein écran"} onClick={toggleFullscreen}>{fullscreen ? <Minimize2 className="size-5"/> : <Maximize2 className="size-5"/>}</Button>}<a className="inline-flex items-center gap-2 rounded-xl bg-white/15 px-3 py-2 text-sm hover:bg-white/25" href={publicFileUrl(token,selected.id)}><Download className="size-4"/> Télécharger</a><Button variant="secondary" size="icon" aria-label="Fermer l’aperçu" onClick={()=>setSelected(null)}><X className="size-5"/></Button></div></div> <div className="mb-4 flex items-center justify-between gap-2"><p className="min-w-0 truncate font-medium">{selected.name}</p><div className="flex items-center gap-2">{fullscreenSupported && !['video', 'audio'].includes(typeOf(selected)) && <Button variant="secondary" size="icon" aria-label={fullscreen ? "Quitter le plein écran" : "Afficher en plein écran"} title={fullscreen ? "Réduire" : "Plein écran"} onClick={toggleFullscreen}>{fullscreen ? <Minimize2 className="size-5"/> : <Maximize2 className="size-5"/>}</Button>}<a className="inline-flex items-center gap-2 rounded-xl bg-white/15 px-3 py-2 text-sm hover:bg-white/25" href={publicFileUrl(token,selected.id)}><Download className="size-4"/> Télécharger</a><Button variant="secondary" size="icon" aria-label="Fermer l’aperçu" onClick={closeViewer}><X className="size-5"/></Button></div></div>
<div className="flex min-h-0 flex-1 items-center justify-center overflow-auto"><Viewer item={selected} token={token} fullscreen={fullscreen}/></div> <div className="flex min-h-0 flex-1 items-center justify-center overflow-auto"><Viewer item={selected} token={token} fullscreen={fullscreen}/></div>
</div>} </div>}
</div> </div>