super
This commit is contained in:
parent
a5fb4ad3fa
commit
17afb70810
83
backend/drive/test_empty_trash.py
Normal file
83
backend/drive/test_empty_trash.py
Normal 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())
|
||||||
@ -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),
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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})
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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,7 +344,27 @@ export function useDriveController(user) {
|
|||||||
setModal(type)
|
setModal(type)
|
||||||
}
|
}
|
||||||
|
|
||||||
const isTrash = view === 'trash'
|
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'
|
||||||
|
|
||||||
return {
|
return {
|
||||||
view, parent, setParent, trail, items, stats, query, setQuery, search, loading,
|
view, parent, setParent, trail, items, stats, query, setQuery, search, loading,
|
||||||
@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user