perfect
This commit is contained in:
parent
1b2b2c414d
commit
3fea8a85c8
101
backend/drive/document_formats.py
Normal file
101
backend/drive/document_formats.py
Normal file
@ -0,0 +1,101 @@
|
||||
"""Small valid, editable blank ODF/DOCX documents and inert previews.
|
||||
|
||||
No cloud APIs, external requests, macros or document execution.
|
||||
"""
|
||||
from html import escape
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from zipfile import ZIP_DEFLATED, ZipFile, BadZipFile
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
ODF_TYPES = {
|
||||
'odt': ('application/vnd.oasis.opendocument.text', 'text'),
|
||||
'ods': ('application/vnd.oasis.opendocument.spreadsheet', 'spreadsheet'),
|
||||
'odp': ('application/vnd.oasis.opendocument.presentation', 'presentation'),
|
||||
}
|
||||
BLANK_TYPES = {'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
**{ext: mime for ext, (mime, _) in ODF_TYPES.items()},
|
||||
**{ext: 'text/plain; charset=utf-8' for ext in ('txt', 'md', 'py', 'js', 'jsx', 'ts', 'tsx', 'css', 'html', 'json', 'xml', 'csv')}}
|
||||
ODF_NS = 'urn:oasis:names:tc:opendocument:xmlns:'
|
||||
OFFICE = '{' + ODF_NS + 'office:1.0}'
|
||||
TEXT = '{' + ODF_NS + 'text:1.0}'
|
||||
TABLE = '{' + ODF_NS + 'table:1.0}'
|
||||
DRAW = '{' + ODF_NS + 'drawing:1.0}'
|
||||
MAX_DOC_BYTES = 35 * 1024 * 1024
|
||||
MAX_CONTENT_BYTES = 12 * 1024 * 1024
|
||||
|
||||
|
||||
def blank_file(extension):
|
||||
if extension not in BLANK_TYPES:
|
||||
raise ValueError('Format non autorisé.')
|
||||
if extension in ODF_TYPES:
|
||||
# Valid empty templates tested with LibreOffice, no server dependency.
|
||||
return (Path(__file__).with_name('templates') / f'blank.{extension}').read_bytes()
|
||||
if extension != 'docx':
|
||||
return b'' if extension != 'json' else b'{}\n'
|
||||
out = BytesIO()
|
||||
with ZipFile(out, 'w', ZIP_DEFLATED) as archive:
|
||||
archive.writestr('[Content_Types].xml', '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>''')
|
||||
archive.writestr('_rels/.rels', '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>''')
|
||||
archive.writestr('word/document.xml', '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p/><w:sectPr/></w:body></w:document>''')
|
||||
return out.getvalue()
|
||||
|
||||
|
||||
def render_odf_preview(path):
|
||||
"""Render plain text/cells/slides only; no untrusted HTML or external resources."""
|
||||
path = Path(path)
|
||||
ext = path.suffix.lower().lstrip('.')
|
||||
if ext not in ODF_TYPES or path.stat().st_size > MAX_DOC_BYTES:
|
||||
raise ValueError('Format ou taille de document incompatible.')
|
||||
try:
|
||||
with ZipFile(path) as archive:
|
||||
info = archive.getinfo('content.xml')
|
||||
if info.file_size > MAX_CONTENT_BYTES:
|
||||
raise ValueError('Document trop volumineux.')
|
||||
with archive.open(info) as stream:
|
||||
raw = stream.read(MAX_CONTENT_BYTES + 1)
|
||||
if len(raw) > MAX_CONTENT_BYTES or b'<!DOCTYPE' in raw.upper() or b'<!ENTITY' in raw.upper():
|
||||
raise ValueError('Document non autorisé.')
|
||||
root = ET.fromstring(raw)
|
||||
except (OSError, KeyError, BadZipFile, ET.ParseError, RuntimeError, EOFError) as exc:
|
||||
raise ValueError('Document LibreOffice illisible.') from exc
|
||||
# Cap rendered elements and text to keep the preview responsive and inert.
|
||||
count = 0
|
||||
def t(node):
|
||||
return escape(' '.join(''.join(node.itertext()).split())[:4000])
|
||||
sections = []
|
||||
if ext == 'odt':
|
||||
for el in root.iter():
|
||||
if el.tag in (TEXT + 'p', TEXT + 'h'):
|
||||
count += 1
|
||||
if count > 2500: break
|
||||
tag = 'h2' if el.tag == TEXT + 'h' else 'p'
|
||||
sections.append(f'<{tag}>' + (t(el) or ' ') + f'</{tag}>')
|
||||
elif ext == 'ods':
|
||||
for table in root.iter(TABLE + 'table'):
|
||||
if len(sections) >= 12: break
|
||||
sections.append('<h2>' + escape(table.attrib.get(TABLE + 'name', 'Feuille')) + '</h2><table>')
|
||||
for row in table.iter(TABLE + 'table-row'):
|
||||
count += 1
|
||||
if count > 1500: break
|
||||
cells = []
|
||||
for cell in row:
|
||||
if cell.tag in (TABLE + 'table-cell', TABLE + 'covered-table-cell'):
|
||||
cell_text = t(cell)
|
||||
repeat = min(int(cell.attrib.get(TABLE + 'number-columns-repeated', '1') or '1'), 100)
|
||||
cells.extend(['<td>' + cell_text + '</td>'] * min(repeat, max(0, 60 - len(cells))))
|
||||
if len(cells) >= 60: break
|
||||
if cells: sections.append('<tr>' + ''.join(cells) + '</tr>')
|
||||
sections.append('</table>')
|
||||
else:
|
||||
for page in root.iter(DRAW + 'page'):
|
||||
count += 1
|
||||
if count > 120: break
|
||||
name = escape(page.attrib.get(DRAW + 'name', f'Diapositive {count}'))
|
||||
paras = [t(p) for p in page.iter() if p.tag in (TEXT + 'p', TEXT + 'h')][:100]
|
||||
sections.append('<section><h2>' + name + '</h2>' + ''.join('<p>' + x + '</p>' for x in paras) + '</section>')
|
||||
content = ''.join(sections) or '<p>Document vide</p>'
|
||||
return ('''<!doctype html><html lang="fr"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><style>
|
||||
body{margin:0;background:#e9ecf1;color:#20242b;font:15px/1.6 Arial,sans-serif}main{box-sizing:border-box;max-width:1080px;margin:24px auto;padding:40px 48px;min-height:80vh;background:#fff;box-shadow:0 2px 14px #0001}
|
||||
p{white-space:pre-wrap;overflow-wrap:anywhere;margin:8px 0}h2{font-size:19px}table{border-collapse:collapse;width:max-content;max-width:100%;margin:14px 0}td{min-width:80px;border:1px solid #c9ced8;padding:6px 9px}section{margin:25px auto;padding:20px;border:1px solid #ddd;min-height:220px} @media(max-width:650px){main{margin:0;padding:18px}}</style></head><body><main>'''
|
||||
+ content + '</main></body></html>')
|
||||
BIN
backend/drive/templates/blank.odp
Normal file
BIN
backend/drive/templates/blank.odp
Normal file
Binary file not shown.
BIN
backend/drive/templates/blank.ods
Normal file
BIN
backend/drive/templates/blank.ods
Normal file
Binary file not shown.
BIN
backend/drive/templates/blank.odt
Normal file
BIN
backend/drive/templates/blank.odt
Normal file
Binary file not shown.
63
backend/drive/test_create_documents.py
Normal file
63
backend/drive/test_create_documents.py
Normal file
@ -0,0 +1,63 @@
|
||||
"""Regression tests for New > File and LibreOffice safe previews."""
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from zipfile import ZipFile
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import SimpleTestCase, TestCase, override_settings
|
||||
from rest_framework.test import APIClient
|
||||
from .document_formats import ODF_TYPES, blank_file, render_odf_preview
|
||||
from .models import DriveItem
|
||||
|
||||
|
||||
class DocumentFormatsTests(SimpleTestCase):
|
||||
def test_valid_blank_office_files_have_expected_structure(self):
|
||||
for extension in ('docx', 'odt', 'ods', 'odp'):
|
||||
with self.subTest(ext=extension):
|
||||
content = blank_file(extension)
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
p = Path(temp) / f'blank.{extension}'
|
||||
p.write_bytes(content)
|
||||
with ZipFile(p) as archive:
|
||||
self.assertIsNone(archive.testzip())
|
||||
self.assertIn('word/document.xml' if extension == 'docx' else 'content.xml', archive.namelist())
|
||||
if extension in ODF_TYPES:
|
||||
html = render_odf_preview(p)
|
||||
self.assertIn('<html', html)
|
||||
self.assertNotIn('<script', html)
|
||||
|
||||
def test_disallow_active_xml(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
p = Path(temp) / 'unsafe.odt'
|
||||
with ZipFile(p, 'w') as archive:
|
||||
archive.writestr('content.xml', b'<!DOCTYPE doc [<!ENTITY x SYSTEM "file:///etc/passwd">]><doc>&x;</doc>')
|
||||
with self.assertRaises(ValueError):
|
||||
render_odf_preview(p)
|
||||
|
||||
|
||||
class NewFileEndpointTests(TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.root = Path(self.temp.name)
|
||||
override = override_settings(DRIVE_STORAGE_ROOT=self.root, FILE_UPLOAD_TEMP_DIR=str(self.root / '.tmp'))
|
||||
override.enable()
|
||||
self.addCleanup(override.disable)
|
||||
self.user = get_user_model().objects.create_superuser('newfilesadmin', 'new@example.com', 'secret')
|
||||
self.client = APIClient()
|
||||
self.client.force_authenticate(user=self.user)
|
||||
self.space = self.client.get('/api/auth/me/').data['space_id']
|
||||
|
||||
def test_create_lo_file_and_reject_duplicate(self):
|
||||
payload = {'name': 'Feuille.ods', 'type': 'ods', 'space': self.space}
|
||||
response = self.client.post('/api/drive/files/create/', payload, format='json')
|
||||
self.assertEqual(response.status_code, 201, response.data)
|
||||
item = DriveItem.objects.get(pk=response.data['id'])
|
||||
self.assertEqual(item.size, (self.root / 'Feuille.ods').stat().st_size)
|
||||
self.assertTrue(item.storage_location.endswith('Feuille.ods'))
|
||||
self.assertEqual(self.client.post('/api/drive/files/create/', payload, format='json').status_code, 409)
|
||||
self.assertEqual(DriveItem.objects.filter(name='Feuille.ods').count(), 1)
|
||||
|
||||
def test_forbidden_extensions_and_empty_extensions(self):
|
||||
for payload in [{'name':'x.exe','type':'exe','space':self.space},
|
||||
{'name':'x.txt','type':'ods','space':self.space}]:
|
||||
self.assertEqual(self.client.post('/api/drive/files/create/', payload, format='json').status_code, 400)
|
||||
@ -29,6 +29,7 @@ urlpatterns=[
|
||||
path('trash/',views.empty_trash),
|
||||
path('stats/',views.stats),
|
||||
path('folders/',views.create_folder),
|
||||
path('files/create/',views.create_file),
|
||||
path('uploads/',views.upload),
|
||||
path('uploads/start/',views.upload_start),
|
||||
path('uploads/pending/',views.upload_pending),
|
||||
|
||||
@ -4,7 +4,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, create_file, update_item,
|
||||
trash_item, restore_item, delete_item, empty_trash)
|
||||
from .views_transfers import (upload, upload_start, upload_status, upload_chunk,
|
||||
upload_finish, upload_cancel, upload_pending)
|
||||
|
||||
@ -12,6 +12,7 @@ from .services import error, get_item, readable
|
||||
from .spaces import storage_path
|
||||
from .permissions import item_rights
|
||||
from .word_preview import render_word_preview
|
||||
from .document_formats import ODF_TYPES, render_odf_preview
|
||||
|
||||
IMAGE_TYPES = {'image/jpeg','image/png','image/gif','image/webp','image/avif','image/bmp'}
|
||||
VIDEO_TYPES = {'video/mp4','video/webm','video/ogg','video/quicktime','video/x-m4v'}
|
||||
@ -22,7 +23,7 @@ TEXT_TYPES = {'text/plain','text/csv','text/markdown','text/x-python','text/java
|
||||
|
||||
def safe_preview_type(item):
|
||||
mime = (mimetypes.guess_type(item.name)[0] or item.mime_type or '').lower()
|
||||
if item.name.lower().endswith('.docx'):
|
||||
if item.name.lower().endswith(('.docx', '.odt', '.ods', '.odp')):
|
||||
return 'text/html; charset=utf-8'
|
||||
if mime in IMAGE_TYPES | VIDEO_TYPES | AUDIO_TYPES | {'application/pdf'}:
|
||||
return mime
|
||||
@ -75,9 +76,9 @@ def content(request,item_id):
|
||||
if not path.is_file():raise Http404
|
||||
size=path.stat().st_size
|
||||
content_type=media if inline=='1' else 'application/octet-stream'
|
||||
if inline=='1' and item.name.lower().endswith('.docx'):
|
||||
if inline=='1' and item.name.lower().endswith(('.docx', '.odt', '.ods', '.odp')):
|
||||
try:
|
||||
html = render_word_preview(path)
|
||||
html = render_word_preview(path) if item.name.lower().endswith('.docx') else render_odf_preview(path)
|
||||
except ValueError:
|
||||
raise Http404
|
||||
response = HttpResponse(html if request.method=='GET' else '', content_type='text/html; charset=utf-8')
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
"""Space-bound navigation, folder management and trash endpoints."""
|
||||
import mimetypes
|
||||
import uuid
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import transaction
|
||||
from django.db.models import Sum, Count
|
||||
@ -8,10 +10,11 @@ from rest_framework.response import Response
|
||||
from .models import DriveItem, DriveSpace, ItemGrant
|
||||
from .spaces import ensure_space, ensure_private_space, is_admin, resolve_space, can_write_space, can_read_space
|
||||
from .permissions import has_space_right, item_rights
|
||||
from .quotas import quota_state
|
||||
from .quotas import quota_state, locked_quota_tree, assert_space_capacity, QuotaExceeded
|
||||
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, cleanup_empty_trash_container, NameConflict
|
||||
from .storage_layout import assert_available, prepare_parent, relocate_item, move_to_trash, cleanup_empty_trash_container, file_location, NameConflict
|
||||
from .trash_cleanup import delete_trashed_item, has_active_uploads_in_tree
|
||||
from .document_formats import BLANK_TYPES, blank_file
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@ -128,6 +131,54 @@ def create_folder(request):
|
||||
return Response(item_info(folder,request.user),status=201)
|
||||
|
||||
|
||||
@api_view(['POST'])
|
||||
def create_file(request):
|
||||
"""Create a real, valid blank local file; never accept arbitrary file contents."""
|
||||
try:
|
||||
extension = str(request.data.get('type', '')).lower().strip().lstrip('.')
|
||||
if extension not in BLANK_TYPES:
|
||||
return error('Type de fichier non autorisé.')
|
||||
name = valid_name(request.data.get('name'))
|
||||
if not name.lower().endswith('.' + extension):
|
||||
return error('L’extension ne correspond pas au type demandé.')
|
||||
space, parent = writable_destination(request.user, request.data.get('space'), request.data.get('parent'))
|
||||
content = blank_file(extension)
|
||||
except ValueError as exc:
|
||||
return error(str(exc))
|
||||
except PermissionError as exc:
|
||||
return error(str(exc), 403)
|
||||
destination = None
|
||||
created = False
|
||||
committed = False
|
||||
try:
|
||||
with transaction.atomic():
|
||||
with locked_quota_tree(space):
|
||||
assert_space_capacity(space, len(content))
|
||||
destination = assert_available(space, parent, name)
|
||||
prepare_parent(space, parent)
|
||||
with destination.open('xb') as outfile:
|
||||
created = True
|
||||
outfile.write(content)
|
||||
node = DriveItem(owner=request.user, space=space, parent=parent, name=name,
|
||||
is_folder=False, storage_key=str(uuid.uuid4()),
|
||||
storage_location=file_location(space, parent, name),
|
||||
size=len(content), mime_type=BLANK_TYPES[extension])
|
||||
node.full_clean()
|
||||
node.save()
|
||||
committed = True
|
||||
return Response(item_info(node, request.user), status=201)
|
||||
except (NameConflict, FileExistsError) as exc:
|
||||
return error(str(exc), 409)
|
||||
except QuotaExceeded as exc:
|
||||
return error(str(exc), 413)
|
||||
except (OSError, ValidationError) as exc:
|
||||
return error(f'Création impossible : {exc}', 409)
|
||||
finally:
|
||||
# Do not leave an orphan file if a DB transaction rolls back.
|
||||
if created and not committed and destination is not None:
|
||||
destination.unlink(missing_ok=True)
|
||||
|
||||
|
||||
@api_view(['PATCH'])
|
||||
def update_item(request,item_id):
|
||||
item=get_item(item_id)
|
||||
|
||||
@ -18,6 +18,7 @@ from .models import DriveItem, ItemGrant, PublicItemLink
|
||||
from .services import error, get_item, readable, writable, chain, item_info
|
||||
from .spaces import storage_path
|
||||
from .word_preview import render_word_preview
|
||||
from .document_formats import render_odf_preview
|
||||
from .permissions import item_rights, assert_item_delegation, available_item_share_rights, PRESETS
|
||||
from .views_content import safe_preview_type, read_bytes
|
||||
|
||||
@ -177,9 +178,9 @@ def public_file(request,token,item_id):
|
||||
if inline and not preview:return error('Aperçu indisponible pour ce format.',415)
|
||||
content_type=preview if inline else 'application/octet-stream'
|
||||
size=path.stat().st_size
|
||||
if inline and item.name.lower().endswith('.docx'):
|
||||
if inline and item.name.lower().endswith(('.docx', '.odt', '.ods', '.odp')):
|
||||
try:
|
||||
html = render_word_preview(path)
|
||||
html = render_word_preview(path) if item.name.lower().endswith('.docx') else render_odf_preview(path)
|
||||
except ValueError:
|
||||
raise Http404
|
||||
response = HttpResponse(html if request.method=='GET' else '', content_type='text/html; charset=utf-8')
|
||||
|
||||
@ -80,6 +80,7 @@ export const drive={
|
||||
updateUserAccess:(id,kind,spaceId,permissions)=>api.patch(`/drive/admin/users/${id}/access/${kind}/${spaceId}/`,{permissions}).then(r=>r.data),
|
||||
stats:params=>api.get('/drive/stats/',{params}).then(r=>r.data),
|
||||
folder:(name,parent,space)=>api.post('/drive/folders/',{name,parent,space}).then(r=>r.data),
|
||||
createFile:(name,type,parent,space)=>api.post('/drive/files/create/',{name,type,parent,space}).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/`),
|
||||
emptyTrash:()=>api.delete('/drive/trash/').then(r=>r.data),
|
||||
|
||||
@ -2,12 +2,14 @@ import { useCallback, useEffect, useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import ContextActions, {spaceOptions, mobileDots} from './ContextActions'
|
||||
import { ChevronDown, ChevronRight, MoreHorizontal, Plus } from 'lucide-react'
|
||||
import { Database, FolderPlus, LogOut, FolderLock, FolderOpen, X, Users, Share2 } from 'lucide-react'
|
||||
import { Database, FolderPlus, FilePlus2, FileText, ExternalLink, LogOut, FolderLock, FolderOpen, X, Users, Share2 } from 'lucide-react'
|
||||
import { localFileGroups, googleFileTypes } from '@/config/creatable-files'
|
||||
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent, DropdownMenuLabel } from '@/components/ui/dropdown-menu'
|
||||
import { sections } from '@/config/navigation'
|
||||
import { fmtSize } from '@/lib/drive-format'
|
||||
|
||||
export default function DriveSidebar({ controller, user, onLogout }) {
|
||||
const { sidebar, view, search, setValue, setModal, setSidebar, section, stats, spaces, activeSpaceId, activeSpace, switchSpace, canWrite,
|
||||
const { sidebar, view, search, setValue, setModal, setSidebar, section, stats, spaces, activeSpaceId, activeSpace, switchSpace, canWrite, setNewFileType,
|
||||
pendingUploads, cancelPendingUpload, uploads, setSpaceDialog, setManagedSpaceId } = controller
|
||||
|
||||
const [spaceMenu,setSpaceMenu] = useState(null)
|
||||
@ -55,6 +57,12 @@ export default function DriveSidebar({ controller, user, onLogout }) {
|
||||
setSidebar(false)
|
||||
}
|
||||
|
||||
const newFile = type => {
|
||||
setNewFileType(type.extension)
|
||||
setValue(`${type.suggestedName}.${type.extension}`)
|
||||
setModal('new-file')
|
||||
setSidebar(false)
|
||||
}
|
||||
return (
|
||||
<aside className="drive-sidebar flex flex-col" data-open={sidebar}>
|
||||
<div className="flex h-20 items-center gap-3 px-5">
|
||||
@ -72,9 +80,31 @@ export default function DriveSidebar({ controller, user, onLogout }) {
|
||||
</div>
|
||||
|
||||
<div className="px-4 pb-6 pt-3">
|
||||
<Button onClick={newFolder} disabled={!canWrite || view==='trash'} className="h-11 w-full justify-start gap-2 rounded-xl shadow-sm">
|
||||
<FolderPlus className="size-5" /> Nouveau dossier
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger render={<Button disabled={!canWrite || view !== 'files' && view !== 'shared'} className="h-11 w-full justify-start gap-2 rounded-xl shadow-sm"/>}>
|
||||
<FilePlus2 className="size-5"/> Nouveau <ChevronDown className="ml-auto size-4"/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="right" align="start" className="min-w-52">
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger><FileText className="size-4"/> Fichier</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="min-w-52">
|
||||
{localFileGroups.map(group => <DropdownMenuSub key={group.label}>
|
||||
<DropdownMenuSubTrigger>{group.label}</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="min-w-52">
|
||||
{group.types.map(type => <DropdownMenuItem key={type.extension} onClick={() => newFile(type)}>{type.label} <span className="ml-auto text-xs text-muted-foreground">.{type.extension}</span></DropdownMenuItem>)}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>)}
|
||||
<DropdownMenuSeparator/>
|
||||
<DropdownMenuLabel>Google Drive · création externe</DropdownMenuLabel>
|
||||
{googleFileTypes.map(type => <DropdownMenuItem key={type.url} onClick={() => window.open(type.url, '_blank', 'noopener,noreferrer')}>
|
||||
{type.label} <ExternalLink className="ml-auto size-3.5"/>
|
||||
</DropdownMenuItem>)}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuSeparator/>
|
||||
<DropdownMenuItem onClick={newFolder}><FolderPlus className="size-4"/> Dossier</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 overflow-y-auto px-3 pb-4">
|
||||
|
||||
@ -6,6 +6,6 @@ import { HardDrive, ChevronRight, Folder } from 'lucide-react'
|
||||
export default function ItemFormDialog({controller: c}) {
|
||||
const {modal, setModal, action, value, setValue, busy, moveBrowse, moveTrail, moveItems, actionSubmit} = c
|
||||
return (
|
||||
<Dialog open={modal==='folder'||modal==='rename'||modal==='move'} onOpenChange={v=>{if(!v)setModal(null)}}><DialogContent className="sm:max-w-md"><DialogHeader><DialogTitle>{modal==='folder'?'Créer un dossier':modal==='rename'?'Renommer':'Déplacer un élément'}</DialogTitle><DialogDescription className={modal==='move' ? undefined : 'sr-only'}>{modal==='move' ? 'Choisis le dossier de destination.' : modal==='folder' ? 'Nom du nouveau dossier' : 'Nouveau nom'}</DialogDescription></DialogHeader><form onSubmit={actionSubmit} className="space-y-4">{modal==='move'?<div className="max-h-64 space-y-2 overflow-auto rounded-xl border p-3"><button type="button" className="flex items-center gap-2 text-sm text-primary" onClick={()=>moveBrowse(null)}><HardDrive className="size-4"/> Racine</button>{moveTrail.map((x,i)=><div key={x.id} className="flex items-center gap-1 text-xs text-muted-foreground"><ChevronRight className="size-3"/><button type="button" onClick={()=>moveBrowse(x.id)}>{x.name}</button></div>)}<p className="border-b pb-2 text-xs text-muted-foreground">Destination : {moveTrail.at(-1)?.name||'Racine'}</p>{moveItems.filter(x=>x.id!==action?.id && x.permissions?.includes('add')).map(x=><button type="button" key={x.id} onClick={()=>moveBrowse(x.id)} className="flex w-full items-center gap-2 rounded-lg px-2 py-2 text-left text-sm hover:bg-muted"><Folder className="size-4 text-amber-500"/>{x.name}<ChevronRight className="ml-auto size-4"/></button>)}</div>:<Input maxLength={255} required autoFocus value={value} onChange={e=>setValue(e.target.value)} placeholder="Nom"/>}<DialogFooter><Button variant="outline" type="button" onClick={()=>setModal(null)}>Annuler</Button><Button disabled={busy} type="submit">{busy?'En cours…':'Enregistrer'}</Button></DialogFooter></form></DialogContent></Dialog>
|
||||
<Dialog open={modal==='folder'||modal==='new-file'||modal==='rename'||modal==='move'} onOpenChange={v=>{if(!v)setModal(null)}}><DialogContent className="sm:max-w-md"><DialogHeader><DialogTitle>{modal==='folder'?'Créer un dossier':modal==='new-file'?'Créer un fichier':modal==='rename'?'Renommer':'Déplacer un élément'}</DialogTitle><DialogDescription className={modal==='move' ? undefined : 'sr-only'}>{modal==='move' ? 'Choisis le dossier de destination.' : modal==='folder' ? 'Nom du nouveau dossier' : modal==='new-file' ? 'Nom du nouveau fichier' : 'Nouveau nom'}</DialogDescription></DialogHeader><form onSubmit={actionSubmit} className="space-y-4">{modal==='move'?<div className="max-h-64 space-y-2 overflow-auto rounded-xl border p-3"><button type="button" className="flex items-center gap-2 text-sm text-primary" onClick={()=>moveBrowse(null)}><HardDrive className="size-4"/> Racine</button>{moveTrail.map((x,i)=><div key={x.id} className="flex items-center gap-1 text-xs text-muted-foreground"><ChevronRight className="size-3"/><button type="button" onClick={()=>moveBrowse(x.id)}>{x.name}</button></div>)}<p className="border-b pb-2 text-xs text-muted-foreground">Destination : {moveTrail.at(-1)?.name||'Racine'}</p>{moveItems.filter(x=>x.id!==action?.id && x.permissions?.includes('add')).map(x=><button type="button" key={x.id} onClick={()=>moveBrowse(x.id)} className="flex w-full items-center gap-2 rounded-lg px-2 py-2 text-left text-sm hover:bg-muted"><Folder className="size-4 text-amber-500"/>{x.name}<ChevronRight className="ml-auto size-4"/></button>)}</div>:<Input maxLength={255} required autoFocus value={value} onChange={e=>setValue(e.target.value)} placeholder="Nom"/>}<DialogFooter><Button variant="outline" type="button" onClick={()=>setModal(null)}>Annuler</Button><Button disabled={busy} type="submit">{busy?'En cours…':'Enregistrer'}</Button></DialogFooter></form></DialogContent></Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { LoaderCircle, FileText, Download, RefreshCcw, Maximize2, Minimize2 } from 'lucide-react'
|
||||
@ -18,57 +18,32 @@ function Preview({ item, url, onError, fullscreen }) {
|
||||
const name = item?.name?.toLowerCase() || ''
|
||||
const mime = (item?.mime_type || '').toLowerCase()
|
||||
const mediaHeight = fullscreen ? 'max-h-full' : 'max-h-[70dvh]'
|
||||
const frameHeight = fullscreen ? 'h-full min-h-0' : 'h-[70dvh]'
|
||||
const frameHeight = fullscreen ? 'h-full min-h-0' : 'h-[min(70dvh,760px)]'
|
||||
const textHeight = fullscreen ? 'h-full min-h-0' : 'h-[65dvh]'
|
||||
|
||||
if ((mime.startsWith('image/') && mime !== 'image/svg+xml') || /\.(jpe?g|png|gif|webp|avif|bmp)$/.test(name)) return <img src={url} onError={onError} alt={item.name} className={`${mediaHeight} max-w-full object-contain`} />
|
||||
if (name.endsWith('.docx')) return <iframe title={item.name} src={url} onError={onError} sandbox="" className={`${frameHeight} w-full rounded-md bg-white`} />
|
||||
if (mime === 'application/pdf' || name.endsWith('.pdf')) return <iframe title={item.name} src={url} onError={onError} className={`${frameHeight} w-full rounded-md bg-white`} />
|
||||
if (/\.(docx|odt|ods|odp)$/.test(name)) return <iframe title={item.name} src={url} onError={onError} sandbox="" className={`${frameHeight} min-w-0 w-full flex-1 rounded-md bg-white`} />
|
||||
if (mime === 'application/pdf' || name.endsWith('.pdf')) return <iframe title={item.name} src={url} onError={onError} className={`block ${frameHeight} min-w-0 w-full flex-1 rounded-md bg-white`} />
|
||||
if (mime.startsWith('video/') || /\.(mp4|webm|ogv|mov|m4v)$/.test(name)) return <video key={url} onError={onError} controls playsInline preload="metadata" className={`${mediaHeight} w-full`} src={url}>Vidéo non supportée par le navigateur.</video>
|
||||
if (mime.startsWith('audio/') || /\.(mp3|wav|ogg|flac|m4a|aac)$/.test(name)) return <audio key={url} onError={onError} controls preload="metadata" className="mx-5 w-full" src={url}>Audio non supporté.</audio>
|
||||
if (mime.startsWith('text/') || mime === 'application/json' || mime === 'application/xml' || /\.(txt|md|csv|json|xml|log|py|js|jsx|ts|tsx|css|html|svg)$/.test(name)) return <iframe title={item.name} src={url} onError={onError} className={`${textHeight} w-full rounded-md bg-white`} sandbox="" />
|
||||
if (mime.startsWith('text/') || mime === 'application/json' || mime === 'application/xml' || /\.(txt|md|csv|json|xml|log|py|js|jsx|ts|tsx|css|html|svg)$/.test(name)) return <iframe title={item.name} src={url} onError={onError} className={`${textHeight} min-w-0 w-full flex-1 rounded-md bg-white`} sandbox="" />
|
||||
return <p className="p-8 text-sm text-muted-foreground">Aperçu non disponible pour ce type de fichier.</p>
|
||||
}
|
||||
|
||||
export default function PreviewDialog({ controller: c }) {
|
||||
const { preview, setPreview, previewUrl, setPreviewUrl, download, openEntry } = c
|
||||
const dialogRef = useRef(null)
|
||||
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.
|
||||
// Maximize the *card* only. Never call the browser Fullscreen API.
|
||||
useEffect(() => {
|
||||
const update = () => {
|
||||
if (!document.fullscreenElement) setFullscreen(false)
|
||||
else setFullscreen(document.fullscreenElement === dialogRef.current)
|
||||
}
|
||||
document.addEventListener('fullscreenchange', update)
|
||||
return () => document.removeEventListener('fullscreenchange', update)
|
||||
}, [])
|
||||
|
||||
if (!preview) setFullscreen(false)
|
||||
}, [preview])
|
||||
const close = () => {
|
||||
if (document.fullscreenElement === dialogRef.current) {
|
||||
document.exitFullscreen?.().catch(() => {})
|
||||
}
|
||||
setFullscreen(false)
|
||||
setPreview(null)
|
||||
setPreviewUrl('')
|
||||
}
|
||||
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 toggleFullscreen = () => setFullscreen(value => !value)
|
||||
|
||||
const previewFailed = previewUrl === 'unsupported'
|
||||
const handleMediaError = () => {
|
||||
@ -77,19 +52,16 @@ export default function PreviewDialog({ controller: c }) {
|
||||
|
||||
return <Dialog open={!!preview} onOpenChange={value => { if (!value) close() }}>
|
||||
<DialogContent
|
||||
ref={dialogRef}
|
||||
showCloseButton={false}
|
||||
data-preview-fullscreen={fullscreen ? 'true' : undefined}
|
||||
className={fullscreen
|
||||
? 'gooddrive-preview-dialog grid grid-rows-[auto_minmax(0,1fr)_auto] gap-3 overflow-hidden border-0 bg-background p-4'
|
||||
: 'gooddrive-preview-dialog 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'}
|
||||
// The dialog's usual centering uses the CSS `translate` property (Tailwind v4),
|
||||
// not `transform`. Reset both or the fullscreen popup stays offset.
|
||||
? 'gooddrive-preview-dialog grid grid-rows-[auto_minmax(0,1fr)_auto] gap-3 overflow-hidden rounded-xl bg-background p-4'
|
||||
: 'gooddrive-preview-dialog grid max-h-[calc(100dvh-1rem)] w-[calc(100vw-1.5rem)] max-w-[min(96vw,1100px)] sm:max-w-[min(96vw,1100px)] grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden'}
|
||||
style={fullscreen ? {
|
||||
position: 'fixed', inset: 0, top: 0, left: 0, right: 0, bottom: 0,
|
||||
position: 'fixed', top: '12px', left: '12px', right: '12px', bottom: '12px',
|
||||
translate: 'none', transform: 'none', margin: 0,
|
||||
boxSizing: 'border-box', width: '100vw', height: '100dvh',
|
||||
maxWidth: 'none', maxHeight: 'none', borderRadius: 0,
|
||||
width: 'calc(100vw - 24px)', maxWidth: 'none',
|
||||
height: 'calc(100dvh - 24px)', maxHeight: 'none', boxSizing: 'border-box',
|
||||
} : undefined}
|
||||
>
|
||||
<DialogHeader className="min-w-0">
|
||||
@ -97,7 +69,7 @@ export default function PreviewDialog({ controller: c }) {
|
||||
<DialogDescription>{preview && !preview.is_folder ? fmtSize(preview.size) : ''}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className={fullscreen
|
||||
? 'flex min-h-0 h-full w-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-hidden 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}/>}
|
||||
</div>
|
||||
|
||||
34
frontend/src/config/creatable-files.js
Normal file
34
frontend/src/config/creatable-files.js
Normal file
@ -0,0 +1,34 @@
|
||||
/** Actual locally stored formats. Extend together with backend/document_formats.py
|
||||
* and the preview dispatchers whenever supporting another document type. */
|
||||
export const localFileGroups = [
|
||||
{ label: 'Documents', types: [
|
||||
{ label: 'Word', extension: 'docx', suggestedName: 'Document' },
|
||||
{ label: 'Texte', extension: 'txt', suggestedName: 'Texte' },
|
||||
{ label: 'Markdown', extension: 'md', suggestedName: 'Notes' },
|
||||
] },
|
||||
{ label: 'Code', types: [
|
||||
{ label: 'Python', extension: 'py', suggestedName: 'script' },
|
||||
{ label: 'JavaScript', extension: 'js', suggestedName: 'script' },
|
||||
{ label: 'React JSX', extension: 'jsx', suggestedName: 'Composant' },
|
||||
{ label: 'TypeScript', extension: 'ts', suggestedName: 'script' },
|
||||
{ label: 'React TSX', extension: 'tsx', suggestedName: 'Composant' },
|
||||
{ label: 'CSS', extension: 'css', suggestedName: 'styles' },
|
||||
{ label: 'HTML', extension: 'html', suggestedName: 'index' },
|
||||
{ label: 'JSON', extension: 'json', suggestedName: 'data' },
|
||||
{ label: 'XML', extension: 'xml', suggestedName: 'document' },
|
||||
{ label: 'CSV', extension: 'csv', suggestedName: 'tableau' },
|
||||
] },
|
||||
{ label: 'LibreOffice', types: [
|
||||
{ label: 'Writer', extension: 'odt', suggestedName: 'Document' },
|
||||
{ label: 'Calc', extension: 'ods', suggestedName: 'Classeur' },
|
||||
{ label: 'Impress', extension: 'odp', suggestedName: 'Présentation' },
|
||||
] },
|
||||
]
|
||||
|
||||
// Google-native Docs/Sheets/Slides are cloud objects, not blank local files.
|
||||
// These links create them on Google's service; they are not saved to GoodDrive.
|
||||
export const googleFileTypes = [
|
||||
{ label: 'Google Docs (externe)', url: 'https://docs.google.com/document/u/0/create' },
|
||||
{ label: 'Google Sheets (externe)', url: 'https://docs.google.com/spreadsheets/u/0/create' },
|
||||
{ label: 'Google Slides (externe)', url: 'https://docs.google.com/presentation/u/0/create' },
|
||||
]
|
||||
@ -28,6 +28,7 @@ export function useDriveController(user) {
|
||||
const [modal, setModal] = useState(null)
|
||||
const [action, setAction] = useState(null)
|
||||
const [value, setValue] = useState('')
|
||||
const [newFileType, setNewFileType] = useState('txt')
|
||||
const [moveFolder, setMoveFolder] = useState(null)
|
||||
const [moveTrail, setMoveTrail] = useState([])
|
||||
const [moveItems, setMoveItems] = useState([])
|
||||
@ -320,6 +321,7 @@ export function useDriveController(user) {
|
||||
const actionSubmit = event => {
|
||||
event.preventDefault()
|
||||
if (modal === 'folder') return run(() => drive.folder(value,parent,view==='shared'?sharedFolderAccess.space_id:activeSpaceId))
|
||||
if (modal === 'new-file') return run(() => drive.createFile(value,newFileType,parent,view==='shared'?sharedFolderAccess.space_id:activeSpaceId))
|
||||
if (modal === 'rename') return run(() => drive.patch(action.id, { name: value }))
|
||||
if (modal === 'move') return run(() => drive.patch(action.id, { parent: moveFolder }))
|
||||
}
|
||||
@ -372,7 +374,7 @@ export function useDriveController(user) {
|
||||
managedSpaceId, setManagedSpaceId, spaceDialog, setSpaceDialog,
|
||||
pendingUploads, cancelPendingUpload,
|
||||
error, setError, note, setNote, layout, setLayout, sort, setSort, dark, setDark,
|
||||
sidebar, setSidebar, modal, setModal, action, value, setValue,
|
||||
sidebar, setSidebar, modal, setModal, action, value, setValue, newFileType, setNewFileType,
|
||||
moveTrail, moveItems, preview, setPreview, previewUrl, setPreviewUrl,
|
||||
busy, emptyingTrash, emptyTrash, uploads, drag, setDrag, filesInput,
|
||||
refresh, section, openEntry, download, uploadFiles, startAction,
|
||||
|
||||
@ -42,21 +42,8 @@
|
||||
.drive-file:focus-within .row-action{opacity:1}
|
||||
@media(max-width:767px){.drive-sidebar{position:fixed;z-index:35;inset:0 auto 0 0;transform:translateX(-110%);transition:transform .2s;box-shadow:0 15px 30px #0002}.drive-sidebar[data-open="true"]{transform:translateX(0)}}
|
||||
|
||||
/* Le dialogue d'aperçu garde normalement une translation de centrage.
|
||||
En plein écran, cette translation doit être supprimée, y compris
|
||||
avant le rendu React qui suit l'événement fullscreenchange. */
|
||||
.gooddrive-preview-dialog:fullscreen,
|
||||
/* Le mode agrandi change uniquement les dimensions de la card.
|
||||
Il ne doit pas être forcé en plein écran de navigateur. */
|
||||
.gooddrive-preview-dialog[data-preview-fullscreen="true"] {
|
||||
position: fixed !important;
|
||||
inset: 0 !important;
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
width: 100vw !important;
|
||||
height: 100dvh !important;
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
margin: 0 !important;
|
||||
translate: none !important;
|
||||
transform: none !important;
|
||||
border-radius: 0 !important;
|
||||
transition: width 120ms ease, height 120ms ease;
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { ArrowLeft, Download, File, Folder, FolderOpen, LoaderCircle, Eye, X, Image, FileText, ShieldCheck, Maximize2, Minimize2 } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { drive, message, publicFileUrl } from '@/api/client'
|
||||
@ -7,7 +7,7 @@ import { fmtSize } from '@/lib/drive-format'
|
||||
function typeOf(item) {
|
||||
const n = (item.name || '').toLowerCase(), m=(item.mime_type || '').toLowerCase()
|
||||
if(m.startsWith('image/') && m!=='image/svg+xml' || /\.(png|jpe?g|gif|webp|bmp|avif)$/.test(n)) return 'image'
|
||||
if(n.endsWith('.docx'))return 'word'
|
||||
if(/\.(docx|odt|ods|odp)$/.test(n))return 'word'
|
||||
if(m==='application/pdf' || n.endsWith('.pdf'))return 'pdf'
|
||||
if(m.startsWith('video/') || /\.(mp4|webm|mov|m4v|ogv)$/.test(n))return 'video'
|
||||
if(m.startsWith('audio/') || /\.(mp3|ogg|wav|m4a|flac|aac)$/.test(n))return 'audio'
|
||||
@ -17,14 +17,14 @@ function typeOf(item) {
|
||||
function Viewer({item,token,fullscreen}) {
|
||||
const kind=typeOf(item)
|
||||
const src=publicFileUrl(token,item.id,true)
|
||||
const mediaHeight=fullscreen?'max-h-[calc(100dvh-5rem)]':'max-h-[72vh]'
|
||||
const frameHeight=fullscreen?'h-[calc(100dvh-5rem)]':'h-[70vh]'
|
||||
const mediaHeight=fullscreen?'max-h-full':'max-h-full'
|
||||
const frameHeight='h-full min-h-0'
|
||||
if(kind==='image')return <img className={`mx-auto ${mediaHeight} max-w-full rounded-lg object-contain`} alt={item.name} src={src}/>
|
||||
if(kind==='word')return <iframe title={item.name} src={src} sandbox="" className={`${frameHeight} w-full rounded-xl bg-white`}/>
|
||||
if(kind==='pdf')return <iframe title={item.name} src={src} className={`${frameHeight} w-full rounded-xl bg-white`}/>
|
||||
if(kind==='word')return <iframe title={item.name} src={src} sandbox="" className={`${frameHeight} min-w-0 w-full flex-1 rounded-xl bg-white`}/>
|
||||
if(kind==='pdf')return <iframe title={item.name} src={src} className={`${frameHeight} min-w-0 w-full flex-1 rounded-xl bg-white`}/>
|
||||
if(kind==='video')return <video controls playsInline preload="metadata" src={src} className={`${mediaHeight} w-full rounded-xl`}/>
|
||||
if(kind==='audio')return <audio controls src={src} className="w-full"/>
|
||||
if(kind==='text')return <iframe sandbox="" title={item.name} src={src} className={`${frameHeight} w-full rounded-xl bg-white`}/>
|
||||
if(kind==='text')return <iframe sandbox="" title={item.name} src={src} className={`${frameHeight} min-w-0 w-full flex-1 rounded-xl bg-white`}/>
|
||||
return <div className="p-10 text-center text-muted-foreground">Aperçu indisponible pour ce format.</div>
|
||||
}
|
||||
export default function PublicSharePage({token}) {
|
||||
@ -33,27 +33,10 @@ export default function PublicSharePage({token}) {
|
||||
const [selected,setSelected]=useState(null)
|
||||
const [error,setError]=useState('')
|
||||
const [loading,setLoading]=useState(true)
|
||||
const viewerRef=useRef(null)
|
||||
const [fullscreen,setFullscreen]=useState(false)
|
||||
const [fullscreenSupported,setFullscreenSupported]=useState(false)
|
||||
useEffect(()=>{
|
||||
setFullscreenSupported(Boolean(document.fullscreenEnabled))
|
||||
const update=()=>setFullscreen(document.fullscreenElement===viewerRef.current)
|
||||
document.addEventListener('fullscreenchange', update)
|
||||
return ()=>document.removeEventListener('fullscreenchange', update)
|
||||
},[])
|
||||
const closeViewer=()=>{
|
||||
if(document.fullscreenElement===viewerRef.current) document.exitFullscreen?.().catch(()=>{})
|
||||
setSelected(null)
|
||||
}
|
||||
const toggleFullscreen=async()=>{
|
||||
try {
|
||||
if(document.fullscreenElement===viewerRef.current) await document.exitFullscreen()
|
||||
else await viewerRef.current?.requestFullscreen()
|
||||
} catch {
|
||||
// Browsers may deny fullscreen; leave the normal viewer usable.
|
||||
}
|
||||
}
|
||||
const closeViewer=()=>{setSelected(null);setFullscreen(false)}
|
||||
const toggleFullscreen=()=>setFullscreen(value=>!value)
|
||||
useEffect(()=>{if(!selected)setFullscreen(false)},[selected])
|
||||
const load=useCallback(async()=>{
|
||||
setLoading(true)
|
||||
try { setData(await drive.publicBrowse(token,parent));setError('') }
|
||||
@ -93,9 +76,11 @@ export default function PublicSharePage({token}) {
|
||||
</>}
|
||||
</main>
|
||||
</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}`}>
|
||||
<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>
|
||||
{selected && <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/65 p-3 sm:p-6" role="presentation" onMouseDown={event=>{if(event.target===event.currentTarget)closeViewer()}}>
|
||||
<div role="dialog" aria-modal="true" aria-label={`Visualiser ${selected.name}`} className={`flex min-h-0 w-full flex-col overflow-hidden rounded-2xl bg-card p-3 text-foreground shadow-2xl sm:p-4 ${fullscreen?'h-full max-w-none':'h-[min(82dvh,850px)] max-w-5xl'}`}>
|
||||
<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">{!['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 w-full flex-1 items-center justify-center overflow-hidden rounded-xl bg-muted"><Viewer item={selected} token={token} fullscreen={fullscreen}/></div>
|
||||
</div>
|
||||
</div>}
|
||||
</div>
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user