@alfakit/vue 0.1.1 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/dist/components/actions/ActionButton.js.map +1 -1
- package/dist/components/actions/ActionButton.vue.d.ts.map +1 -1
- package/dist/components/actions/ActionButton.vue_vue_type_script_setup_true_lang.js +160 -159
- package/dist/components/actions/ActionButton.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components/actions/ActionModal.js.map +1 -1
- package/dist/components/actions/ActionModal.vue.d.ts.map +1 -1
- package/dist/components/actions/ActionModal.vue_vue_type_script_setup_true_lang.js +214 -214
- package/dist/components/actions/ActionModal.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components/fields/ColorPickerField.js.map +1 -1
- package/dist/components/fields/ColorPickerField.vue.d.ts.map +1 -1
- package/dist/components/fields/ColorPickerField.vue_vue_type_script_setup_true_lang.js +1 -0
- package/dist/components/fields/ColorPickerField.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components/fields/ComboboxSelect.js.map +1 -1
- package/dist/components/fields/ComboboxSelect.vue.d.ts.map +1 -1
- package/dist/components/fields/ComboboxSelect.vue_vue_type_script_setup_true_lang.js +1 -1
- package/dist/components/fields/ComboboxSelect.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components/fields/FileUploadField.js.map +1 -1
- package/dist/components/fields/FileUploadField.vue.d.ts.map +1 -1
- package/dist/components/fields/FileUploadField.vue_vue_type_script_setup_true_lang.js +183 -183
- package/dist/components/fields/FileUploadField.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components/fields/OneTimeCodeInputField.js.map +1 -1
- package/dist/components/fields/OneTimeCodeInputField.vue_vue_type_script_setup_true_lang.js +1 -2
- package/dist/components/fields/OneTimeCodeInputField.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components/fields/SelectField.js.map +1 -1
- package/dist/components/fields/SelectField.vue.d.ts.map +1 -1
- package/dist/components/fields/SelectField.vue_vue_type_script_setup_true_lang.js +2 -2
- package/dist/components/fields/SelectField.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components/fields/TableColumnHeaders.js.map +1 -1
- package/dist/components/fields/TableColumnHeaders.vue.d.ts.map +1 -1
- package/dist/components/fields/TableColumnHeaders.vue_vue_type_script_setup_true_lang.js +6 -3
- package/dist/components/fields/TableColumnHeaders.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components/file-manager/FileManagerView.js.map +1 -1
- package/dist/components/file-manager/FileManagerView.vue_vue_type_script_setup_true_lang.js +1 -1
- package/dist/components/file-manager/FileManagerView.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components/form/FormBlock.js.map +1 -1
- package/dist/components/form/FormBlock.vue.d.ts.map +1 -1
- package/dist/components/form/FormBlock.vue_vue_type_script_setup_true_lang.js +81 -77
- package/dist/components/form/FormBlock.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components/form/form-context.js +25 -25
- package/dist/components/form/form-context.js.map +1 -1
- package/dist/components/layout/FusedGroupComponent.js.map +1 -1
- package/dist/components/layout/FusedGroupComponent.vue.d.ts.map +1 -1
- package/dist/components/layout/FusedGroupComponent.vue_vue_type_script_setup_true_lang.js +1 -0
- package/dist/components/layout/FusedGroupComponent.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components/layout/TabsComponent.js.map +1 -1
- package/dist/components/layout/TabsComponent.vue.d.ts.map +1 -1
- package/dist/components/layout/TabsComponent.vue_vue_type_script_setup_true_lang.js +76 -55
- package/dist/components/layout/TabsComponent.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components/notifications/NotificationsMenu.js.map +1 -1
- package/dist/components/notifications/NotificationsMenu.vue.d.ts.map +1 -1
- package/dist/components/notifications/NotificationsMenu.vue_vue_type_script_setup_true_lang.js +44 -42
- package/dist/components/notifications/NotificationsMenu.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components/panel/GlobalSearch.js.map +1 -1
- package/dist/components/panel/GlobalSearch.vue.d.ts.map +1 -1
- package/dist/components/panel/GlobalSearch.vue_vue_type_script_setup_true_lang.js +1 -0
- package/dist/components/panel/GlobalSearch.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components/panel/ShortcutKeys.js.map +1 -1
- package/dist/components/panel/ShortcutKeys.vue.d.ts.map +1 -1
- package/dist/components/panel/ShortcutKeys.vue_vue_type_script_setup_true_lang.js +4 -1
- package/dist/components/panel/ShortcutKeys.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components/panel/TenantMenu.js.map +1 -1
- package/dist/components/panel/TenantMenu.vue_vue_type_script_setup_true_lang.js +2 -2
- package/dist/components/panel/TenantMenu.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components/table/GroupHeaderRow.js.map +1 -1
- package/dist/components/table/GroupHeaderRow.vue.d.ts.map +1 -1
- package/dist/components/table/GroupHeaderRow.vue_vue_type_script_setup_true_lang.js +55 -34
- package/dist/components/table/GroupHeaderRow.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components/table/SelectCell.js.map +1 -1
- package/dist/components/table/SelectCell.vue.d.ts.map +1 -1
- package/dist/components/table/SelectCell.vue_vue_type_script_setup_true_lang.js +24 -19
- package/dist/components/table/SelectCell.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components/table/TextInputCell.js.map +1 -1
- package/dist/components/table/TextInputCell.vue.d.ts.map +1 -1
- package/dist/components/table/TextInputCell.vue_vue_type_script_setup_true_lang.js +38 -33
- package/dist/components/table/TextInputCell.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components.css +347 -0
- package/dist/eject-manifest.json +2 -2
- package/dist/standalone/AlfakitToaster.js.map +1 -1
- package/dist/standalone/AlfakitToaster.vue.d.ts.map +1 -1
- package/dist/standalone/AlfakitToaster.vue_vue_type_script_setup_true_lang.js +23 -15
- package/dist/standalone/AlfakitToaster.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/standalone.css +47 -0
- package/dist/starter-kit/widgets/SearchTrigger.js.map +1 -1
- package/dist/starter-kit/widgets/SearchTrigger.vue.d.ts.map +1 -1
- package/dist/starter-kit/widgets/SearchTrigger.vue_vue_type_script_setup_true_lang.js +1 -0
- package/dist/starter-kit/widgets/SearchTrigger.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/starter-kit/widgets/TenantChooser.js.map +1 -1
- package/dist/starter-kit/widgets/TenantChooser.vue_vue_type_script_setup_true_lang.js +1 -1
- package/dist/starter-kit/widgets/TenantChooser.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/themes.css +2 -334
- package/package.json +3 -3
- package/src/components/actions/ActionButton.vue +14 -6
- package/src/components/actions/ActionModal.vue +3 -1
- package/src/components/fields/ColorPickerField.vue +1 -0
- package/src/components/fields/ComboboxSelect.vue +1 -1
- package/src/components/fields/FileUploadField.vue +14 -11
- package/src/components/fields/OneTimeCodeInputField.vue +1 -1
- package/src/components/fields/SelectField.vue +4 -2
- package/src/components/fields/TableColumnHeaders.vue +6 -1
- package/src/components/file-manager/FileManagerView.vue +1 -1
- package/src/components/form/FormBlock.vue +24 -0
- package/src/components/form/form-context.ts +2 -2
- package/src/components/layout/FusedGroupComponent.vue +1 -0
- package/src/components/layout/TabsComponent.vue +61 -5
- package/src/components/notifications/NotificationsMenu.vue +11 -0
- package/src/components/panel/GlobalSearch.vue +1 -0
- package/src/components/panel/ShortcutKeys.vue +1 -0
- package/src/components/panel/TenantMenu.vue +2 -2
- package/src/components/table/GroupHeaderRow.vue +27 -0
- package/src/components/table/SelectCell.vue +5 -2
- package/src/components/table/TextInputCell.vue +11 -5
- package/src/standalone/AlfakitToaster.vue +12 -2
- package/src/starter-kit/widgets/SearchTrigger.vue +1 -0
- package/src/starter-kit/widgets/TenantChooser.vue +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"FileManagerView.js","names":["_sfc_main","FileManagerView_vue_vue_type_script_setup_true_lang_default","FileManagerView_default"],"sources":["../../../src/components/file-manager/FileManagerView.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport type {\n FileManagerEntrySchema,\n FileManagerEnvironment,\n FileManagerSchema,\n FileManagerSortField,\n} from '@alfakit/core'\nimport {\n FileManagerController,\n clientErrorNotification,\n fetchEditableImage,\n fileManagerAcceptAttribute,\n fileManagerExpiry,\n fileManagerSize,\n} from '@alfakit/core'\nimport {\n ArrowDownIcon,\n ArrowUpIcon,\n CheckIcon,\n ChevronLeftIcon,\n ChevronRightIcon,\n CopyIcon,\n DownloadIcon,\n FolderInputIcon,\n FolderOpenIcon,\n FolderPlusIcon,\n FolderTreeIcon,\n InfoIcon,\n LayoutGridIcon,\n LinkIcon,\n ListIcon,\n PencilIcon,\n RefreshCwIcon,\n RotateCcwIcon,\n SearchIcon,\n Share2Icon,\n SquarePenIcon,\n Trash2Icon,\n UploadIcon,\n XIcon,\n} from '@lucide/vue'\nimport { computed, nextTick, onBeforeUnmount, onMounted, ref, toRaw, useId, watch } from 'vue'\nimport {\n useActionRunner,\n useChoice,\n useDirection,\n useFormatter,\n usePanel,\n useT,\n} from '../../context'\nimport { useIsMobile } from '../../composables/use-mobile'\nimport { useStore } from '../../composables/use-store'\nimport { cn } from '../../lib/utils'\nimport { usePageActionContext } from '../actions/action-context'\nimport ImageEditorDialog, { type ImageEditRequest } from '../fields/ImageEditorDialog.vue'\nimport { showToast } from '../notifications/flash-toasts'\nimport Button from '../ui/Button.vue'\nimport Card from '../ui/Card.vue'\nimport Checkbox from '../ui/Checkbox.vue'\nimport Dialog from '../ui/Dialog.vue'\nimport DialogContent from '../ui/DialogContent.vue'\nimport DialogDescription from '../ui/DialogDescription.vue'\nimport DialogFooter from '../ui/DialogFooter.vue'\nimport DialogHeader from '../ui/DialogHeader.vue'\nimport DialogTitle from '../ui/DialogTitle.vue'\nimport Input from '../ui/Input.vue'\nimport Sheet from '../ui/Sheet.vue'\nimport SheetContent from '../ui/SheetContent.vue'\nimport SheetHeader from '../ui/SheetHeader.vue'\nimport SheetTitle from '../ui/SheetTitle.vue'\nimport Spinner from '../ui/Spinner.vue'\nimport FileManagerDetails from './FileManagerDetails.vue'\nimport FileManagerIcon from './FileManagerIcon.vue'\nimport FileManagerTree from './FileManagerTree.vue'\n\n/**\n * The file manager of alfasix/alfakit-file-manager: a folder tree, breadcrumbs, grid and\n * list views, sorting, search, multi-selection with mouse and keyboard, drag and drop\n * (uploads and moves), uploads with progress, previews, details, sharing and the trash.\n * Everything is listed, checked and changed by the server.\n */\nconst props = defineProps<{\n schema: FileManagerSchema\n /** Picker: called with the chosen files. */\n onChoose?: (entries: FileManagerEntrySchema[]) => void\n onCancel?: () => void\n}>()\n\nconst SELECT =\n 'border-input focus-visible:border-ring focus-visible:ring-ring/50 h-9 min-w-0 rounded-md border bg-transparent px-2 text-sm shadow-xs outline-none focus-visible:ring-[3px]'\nconst SORT_FIELDS: FileManagerSortField[] = ['name', 'size', 'modified', 'type']\nconst IMAGE_EDITOR = { aspectRatios: [null], circle: false }\n\nconst runner = useActionRunner()\nconst page = usePageActionContext()\nconst panel = usePanel()\nconst t = useT()\nconst choice = useChoice()\nconst direction = useDirection()\nconst formatter = useFormatter()\nconst mobile = useIsMobile()\nconst labelId = useId()\nconst hintId = useId()\nconst nameId = useId()\nconst errorId = useId()\nconst tt = (key: string, replacements: Record<string, string | number> = {}, fallback?: string) =>\n t(`file-manager.${key}`, replacements, fallback)\n\nconst environment = (): FileManagerEnvironment => ({\n runner,\n token: page.token,\n uploadEndpoint: panel.value.endpoints.upload,\n uploadChunkEndpoint: panel.value.endpoints.uploadChunks,\n uploadChunkSize: panel.value.uploadChunkSize,\n t,\n choice,\n formatter: formatter.value,\n notify: (notifications) => notifications.forEach((notification) => showToast(notification)),\n error: (message) => showToast(clientErrorNotification('file-manager', message)),\n})\n\nconst controller = new FileManagerController(\n toRaw(props.schema),\n environment(),\n formatter.value.locale,\n)\nconst state = useStore(controller.store)\n\nwatch(\n () => props.schema,\n (schema) => controller.setSchema(toRaw(schema)),\n)\nwatch([formatter, direction], () => controller.setEnvironment(environment()))\n\nonMounted(() => {\n if (props.schema.mode === 'picker') void controller.ensureLoaded()\n})\nonBeforeUnmount(() => controller.dispose())\n\nconst picker = computed(() => props.schema.mode === 'picker')\nconst entries = computed(() => {\n void state.value\n\n return controller.visibleEntries()\n})\nconst selected = computed(() => {\n void state.value\n\n return controller.selectedEntries()\n})\nconst chosen = computed(() => {\n void state.value\n\n return controller.chosen()\n})\nconst single = computed(() => (selected.value.length === 1 ? selected.value[0] : undefined))\nconst canWrite = computed(() => !picker.value && !state.value.trash)\nconst focusPath = computed(() => state.value.focus ?? entries.value[0]?.path ?? null)\nconst folderLabel = computed(() =>\n state.value.trash\n ? tt('trash', {}, 'Trash')\n : (state.value.breadcrumbs[state.value.breadcrumbs.length - 1]?.name ??\n tt('root', {}, 'Files')),\n)\nconst can = (ability: Parameters<FileManagerController['can']>[0]) => controller.can(ability)\n\nconst listRef = ref<HTMLElement | null>(null)\nconst items = new Map<string, HTMLElement>()\nconst uploadInput = ref<HTMLInputElement | null>(null)\nconst foldersOpen = ref(false)\nconst pointer = ref('mouse')\nconst broken = ref(new Set<string>())\n\nconst setItem = (path: string) => (element: unknown) => {\n if (element instanceof HTMLElement) items.set(path, element)\n else items.delete(path)\n}\n\nconst focusItem = (path: string | undefined) => {\n if (path) void nextTick(() => items.get(path)?.focus())\n}\n\nconst columns = () => {\n const element = listRef.value\n\n if (!element || state.value.view !== 'grid' || typeof getComputedStyle === 'undefined') return 1\n\n return Math.max(\n 1,\n getComputedStyle(element).gridTemplateColumns.split(' ').filter(Boolean).length,\n )\n}\n\nconst onItemKeyDown = (event: KeyboardEvent) => {\n const result = controller.handleKey(event, columns(), direction.value)\n\n if (result.handled) {\n event.preventDefault()\n focusItem(result.focus)\n }\n}\n\nconst onItemClick = (entry: FileManagerEntrySchema, event: MouseEvent) =>\n controller.click(entry.path, {\n shift: event.shiftKey,\n toggle: event.metaKey || event.ctrlKey,\n touch: pointer.value === 'touch',\n })\n\nconst onItemDoubleClick = (entry: FileManagerEntrySchema) => {\n if (picker.value && entry.type === 'file') {\n if (entry.selectable !== false && entry.value) props.onChoose?.([entry])\n\n return\n }\n\n controller.openEntry(entry)\n}\n\nconst copying = (event: DragEvent) => event.ctrlKey || event.altKey || event.metaKey\n\nconst dropHandlers = (target: string) => ({\n dragover: (event: DragEvent) => {\n if (event.dataTransfer && controller.dragOver(target, event.dataTransfer, copying(event))) {\n event.preventDefault()\n event.stopPropagation()\n }\n },\n dragleave: (event: DragEvent) => {\n const current = event.currentTarget as HTMLElement | null\n\n if (!current?.contains(event.relatedTarget as Node | null)) controller.dragLeave(target)\n },\n drop: (event: DragEvent) => {\n event.preventDefault()\n event.stopPropagation()\n\n if (event.dataTransfer) void controller.drop(target, event.dataTransfer, copying(event))\n },\n})\n\nconst onDragStart = (entry: FileManagerEntrySchema, event: DragEvent) => {\n if (event.dataTransfer) controller.dragStart(entry.path, event.dataTransfer)\n}\n\nconst openTreeFolder = (path: string) => {\n foldersOpen.value = false\n void controller.open(path)\n}\n\nconst onUpload = (event: Event) => {\n const input = event.target as HTMLInputElement\n\n if (input.files) void controller.upload(Array.from(input.files))\n input.value = ''\n}\n\nconst sizeOf = (entry: FileManagerEntrySchema) =>\n entry.type === 'file' ? fileManagerSize(entry.size, formatter.value) : ''\nconst locationOf = (entry: FileManagerEntrySchema) =>\n state.value.results !== null && entry.path.includes('/')\n ? entry.path.slice(0, entry.path.lastIndexOf('/'))\n : null\nconst thumbnailOf = (entry: FileManagerEntrySchema) =>\n entry.thumbnail && !broken.value.has(entry.path) ? entry.thumbnail : null\nconst markBroken = (path: string) => {\n broken.value = new Set([...broken.value, path])\n}\n\n// ---------------------------------------------------------------- Uploads\n\nconst uploadsDone = computed(\n () => state.value.uploads.filter((upload) => upload.status === 'done').length,\n)\nconst uploadsFinished = computed(() =>\n state.value.uploads.every((upload) => upload.status === 'done' || upload.status === 'error'),\n)\n\n// ---------------------------------------------------------------- Dialogs\n\nconst dialog = computed(() => state.value.dialog)\nconst name = ref('')\nconst expiry = ref('')\nconst copied = ref(false)\nconst share = computed(() => props.schema.share)\n\nwatch(\n () =>\n [\n dialog.value?.type,\n dialog.value && 'entry' in dialog.value ? dialog.value.entry.path : null,\n ] as const,\n () => {\n const current = dialog.value\n\n if (current?.type === 'rename') name.value = current.entry.name\n if (current?.type === 'createFolder') name.value = ''\n if (current?.type === 'share') {\n expiry.value = String(share.value?.expiries[0] ?? '')\n copied.value = false\n }\n },\n)\n\nconst close = () => controller.closeDialog()\nconst onDialogOpen = (open: boolean) => {\n if (!open) close()\n}\n\nconst submitName = () => {\n const current = dialog.value\n\n if (current?.type === 'rename') void controller.rename(current.entry.path, name.value)\n else void controller.createFolder(name.value)\n}\n\nconst submitTransfer = () => {\n const current = dialog.value\n\n if (current?.type === 'move' || current?.type === 'copy')\n void controller.transfer(current.type, current.paths, current.destination)\n}\n\nconst chooseDestination = (path: string) => {\n const current = dialog.value\n\n if (current?.type === 'move' || current?.type === 'copy')\n controller.openDialog({ ...current, destination: path })\n}\n\nconst submitDelete = () => {\n const current = dialog.value\n\n if (current?.type === 'delete') void controller.remove(current.paths)\n else void controller.emptyTrash()\n}\n\nconst expiryLabel = (minutes: number) => {\n const label = fileManagerExpiry(minutes)\n\n return choice(`file-manager.${label.key}`, label.count, {}, ':count')\n}\n\nconst copy = async (url: string) => {\n try {\n await navigator.clipboard.writeText(url)\n copied.value = true\n } catch {\n copied.value = false\n }\n}\n\n// ---------------------------------------------------------------- Preview\n\nconst preview = computed(() => state.value.preview)\nconst previewData = computed(() => preview.value?.data ?? null)\nconst previous = computed(() => {\n void state.value.preview\n\n return controller.previewSibling(-1)\n})\nconst next = computed(() => {\n void state.value.preview\n\n return controller.previewSibling(1)\n})\nconst editing = ref<ImageEditRequest | null>(null)\n\nconst editImage = async () => {\n const data = previewData.value\n const current = preview.value\n\n if (!data?.url || !current) return\n\n const source = await fetchEditableImage(data.url, current.entry.name)\n\n if (!source) return\n\n editing.value = {\n file: source,\n resolve: (edited) => {\n editing.value = null\n if (edited && edited !== source) void controller.saveEditedImage(edited)\n },\n }\n}\n\nconst onPreviewKeyDown = (event: KeyboardEvent) => {\n const back = direction.value === 'rtl' ? 'ArrowRight' : 'ArrowLeft'\n const forward = direction.value === 'rtl' ? 'ArrowLeft' : 'ArrowRight'\n\n if (\n event.target instanceof HTMLElement &&\n ['VIDEO', 'AUDIO', 'INPUT'].includes(event.target.tagName)\n )\n return\n\n if (event.key === back && previous.value) void controller.openPreview(previous.value)\n if (event.key === forward && next.value) void controller.openPreview(next.value)\n}\n</script>\n\n<template>\n <Card\n class=\"relative gap-0 overflow-hidden py-0\"\n data-slot=\"file-manager\"\n :data-view=\"state.view\"\n :data-mode=\"schema.mode\"\n :aria-labelledby=\"labelId\"\n >\n <span :id=\"labelId\" class=\"sr-only\">{{ tt('label', {}, 'File manager') }}</span>\n <div class=\"sr-only\" role=\"status\" aria-live=\"polite\">{{ state.announcement }}</div>\n\n <div class=\"flex flex-wrap items-center gap-2 border-b p-3\" data-slot=\"file-manager-toolbar\">\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"icon-sm\"\n class=\"md:hidden\"\n :aria-label=\"tt('toggle_folders', {}, 'Show folders')\"\n @click=\"foldersOpen = true\"\n >\n <FolderTreeIcon aria-hidden=\"true\" />\n </Button>\n <label v-if=\"schema.disks.length > 1\" class=\"flex items-center\">\n <span class=\"sr-only\">{{ tt('disk', {}, 'Storage') }}</span>\n <select\n :class=\"SELECT\"\n :value=\"state.disk\"\n @change=\"controller.setDisk(($event.target as HTMLSelectElement).value)\"\n >\n <option v-for=\"disk in schema.disks\" :key=\"disk.name\" :value=\"disk.name\">\n {{ disk.label }}\n </option>\n </select>\n </label>\n <nav\n :aria-label=\"tt('breadcrumb', {}, 'Folder path')\"\n class=\"min-w-0 flex-1\"\n data-slot=\"file-manager-breadcrumb\"\n >\n <ol class=\"flex min-w-0 flex-wrap items-center gap-1 text-sm\">\n <li class=\"flex items-center\">\n <button\n type=\"button\"\n class=\"hover:bg-accent rounded px-1.5 py-1 font-medium\"\n :aria-current=\"!state.trash && state.path === '' ? 'page' : undefined\"\n @click=\"state.trash ? controller.showTrash(false) : controller.open('')\"\n >\n {{ tt('root', {}, 'Files') }}\n </button>\n </li>\n <li v-if=\"state.trash\" class=\"flex min-w-0 items-center gap-1\">\n <ChevronRightIcon\n class=\"text-muted-foreground size-3.5 shrink-0 rtl:rotate-180\"\n aria-hidden=\"true\"\n />\n <span class=\"truncate px-1.5 py-1 font-medium\" aria-current=\"page\">{{\n tt('trash', {}, 'Trash')\n }}</span>\n </li>\n <template v-else>\n <li\n v-for=\"(crumb, index) in state.breadcrumbs\"\n :key=\"crumb.path\"\n class=\"flex min-w-0 items-center gap-1\"\n >\n <ChevronRightIcon\n class=\"text-muted-foreground size-3.5 shrink-0 rtl:rotate-180\"\n aria-hidden=\"true\"\n />\n <span\n v-if=\"index === state.breadcrumbs.length - 1\"\n class=\"truncate px-1.5 py-1 font-medium\"\n aria-current=\"page\"\n >\n {{ crumb.name }}\n </span>\n <button\n v-else\n type=\"button\"\n class=\"hover:bg-accent truncate rounded px-1.5 py-1\"\n @click=\"controller.open(crumb.path)\"\n >\n {{ crumb.name }}\n </button>\n </li>\n </template>\n </ol>\n </nav>\n <div class=\"flex w-full flex-wrap items-center gap-2 sm:w-auto\">\n <div class=\"relative min-w-40 flex-1 sm:flex-none\">\n <SearchIcon\n class=\"text-muted-foreground pointer-events-none absolute start-2.5 top-1/2 size-4 -translate-y-1/2\"\n aria-hidden=\"true\"\n />\n <Input\n type=\"search\"\n :model-value=\"state.query\"\n :placeholder=\"tt('search_placeholder', {}, 'Search…')\"\n :aria-label=\"tt('search', {}, 'Search in this folder')\"\n class=\"h-9 ps-8 sm:w-52\"\n :disabled=\"state.trash\"\n @update:model-value=\"controller.setQuery(String($event))\"\n />\n </div>\n <label\n v-if=\"schema.search.recursive && !state.trash\"\n class=\"flex items-center gap-2 text-sm\"\n >\n <Checkbox\n :model-value=\"state.recursive\"\n @update:model-value=\"controller.setRecursive($event === true)\"\n />\n {{ tt('search_everywhere', {}, 'Include subfolders') }}\n </label>\n <label class=\"flex items-center gap-1\">\n <span class=\"sr-only\">{{ tt('sort', {}, 'Sort by') }}</span>\n <select\n :class=\"SELECT\"\n :value=\"state.sort.field\"\n @change=\"\n controller.setSort(\n ($event.target as HTMLSelectElement).value as FileManagerSortField,\n state.sort.direction,\n )\n \"\n >\n <option v-for=\"field in SORT_FIELDS\" :key=\"field\" :value=\"field\">\n {{ tt(`sort_${field}`, {}, field) }}\n </option>\n </select>\n </label>\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"icon-sm\"\n :aria-label=\"\n state.sort.direction === 'asc'\n ? tt('ascending', {}, 'Ascending')\n : tt('descending', {}, 'Descending')\n \"\n @click=\"\n controller.setSort(state.sort.field, state.sort.direction === 'asc' ? 'desc' : 'asc')\n \"\n >\n <ArrowUpIcon v-if=\"state.sort.direction === 'asc'\" aria-hidden=\"true\" />\n <ArrowDownIcon v-else aria-hidden=\"true\" />\n </Button>\n <div class=\"flex rounded-md border\" role=\"group\" :aria-label=\"tt('view', {}, 'View')\">\n <Button\n type=\"button\"\n :variant=\"state.view === 'grid' ? 'secondary' : 'ghost'\"\n size=\"icon-sm\"\n :aria-pressed=\"state.view === 'grid'\"\n :aria-label=\"tt('view_grid', {}, 'Grid')\"\n @click=\"controller.setView('grid')\"\n >\n <LayoutGridIcon aria-hidden=\"true\" />\n </Button>\n <Button\n type=\"button\"\n :variant=\"state.view === 'list' ? 'secondary' : 'ghost'\"\n size=\"icon-sm\"\n :aria-pressed=\"state.view === 'list'\"\n :aria-label=\"tt('view_list', {}, 'List')\"\n @click=\"controller.setView('list')\"\n >\n <ListIcon aria-hidden=\"true\" />\n </Button>\n </div>\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon-sm\"\n :aria-label=\"tt('refresh', {}, 'Refresh')\"\n @click=\"controller.refresh()\"\n >\n <RefreshCwIcon aria-hidden=\"true\" :class=\"cn(state.loading && 'animate-spin')\" />\n </Button>\n <Button\n v-if=\"!state.trash && can('createFolder')\"\n type=\"button\"\n variant=\"outline\"\n size=\"sm\"\n @click=\"controller.openDialog({ type: 'createFolder' })\"\n >\n <FolderPlusIcon aria-hidden=\"true\" />\n <span class=\"max-sm:sr-only\">{{ tt('new_folder', {}, 'New folder') }}</span>\n </Button>\n <template v-if=\"!state.trash && can('upload') && schema.upload\">\n <Button type=\"button\" size=\"sm\" @click=\"uploadInput?.click()\">\n <UploadIcon aria-hidden=\"true\" />\n <span class=\"max-sm:sr-only\">{{ tt('upload', {}, 'Upload') }}</span>\n </Button>\n <input\n ref=\"uploadInput\"\n type=\"file\"\n multiple\n hidden\n :accept=\"fileManagerAcceptAttribute(schema.upload.accept)\"\n data-slot=\"file-manager-upload-input\"\n @change=\"onUpload\"\n />\n </template>\n <Button\n v-if=\"schema.trash && !picker\"\n type=\"button\"\n :variant=\"state.trash ? 'secondary' : 'ghost'\"\n size=\"sm\"\n :aria-pressed=\"state.trash\"\n @click=\"controller.showTrash(!state.trash)\"\n >\n <Trash2Icon aria-hidden=\"true\" />\n <span class=\"max-sm:sr-only\">{{\n state.trash ? tt('back_to_files', {}, 'Back to files') : tt('trash', {}, 'Trash')\n }}</span>\n </Button>\n </div>\n </div>\n\n <div\n v-if=\"selected.length > 0 && !picker\"\n class=\"bg-muted/50 flex flex-wrap items-center gap-1 border-b px-3 py-2 text-sm\"\n data-slot=\"file-manager-selection\"\n >\n <span class=\"me-2 font-medium\">{{\n tt('selected', { count: selected.length }, ':count selected')\n }}</span>\n <template v-if=\"state.trash\">\n <Button\n v-if=\"can('restore')\"\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n @click=\"controller.restore()\"\n >\n <RotateCcwIcon aria-hidden=\"true\" />\n <span class=\"max-md:sr-only\">{{ tt('restore', {}, 'Restore') }}</span>\n </Button>\n <Button\n v-if=\"can('delete')\"\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n class=\"text-destructive hover:text-destructive\"\n @click=\"controller.confirmDelete()\"\n >\n <Trash2Icon aria-hidden=\"true\" />\n <span class=\"max-md:sr-only\">{{\n tt('delete_permanently', {}, 'Delete permanently')\n }}</span>\n </Button>\n </template>\n <template v-else>\n <Button\n v-if=\"single\"\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n @click=\"controller.openEntry(single)\"\n >\n <FolderOpenIcon v-if=\"single.type === 'folder'\" aria-hidden=\"true\" />\n <SearchIcon v-else aria-hidden=\"true\" />\n <span class=\"max-md:sr-only\">{{\n single.type === 'folder' ? tt('open', {}, 'Open') : tt('preview', {}, 'Preview')\n }}</span>\n </Button>\n <Button\n v-if=\"can('download') && (single?.type === 'file' || can('zip'))\"\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n @click=\"controller.download()\"\n >\n <DownloadIcon aria-hidden=\"true\" />\n <span class=\"max-md:sr-only\">{{\n single?.type === 'file'\n ? tt('download', {}, 'Download')\n : tt('download_zip', {}, 'Download as zip')\n }}</span>\n </Button>\n <Button\n v-if=\"single && can('rename')\"\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n @click=\"controller.openDialog({ type: 'rename', entry: single })\"\n >\n <PencilIcon aria-hidden=\"true\" />\n <span class=\"max-md:sr-only\">{{ tt('rename', {}, 'Rename') }}</span>\n </Button>\n <Button\n v-if=\"can('move')\"\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n @click=\"\n controller.openDialog({ type: 'move', paths: state.selection, destination: state.path })\n \"\n >\n <FolderInputIcon aria-hidden=\"true\" />\n <span class=\"max-md:sr-only\">{{ tt('move', {}, 'Move') }}</span>\n </Button>\n <Button\n v-if=\"can('copy')\"\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n @click=\"\n controller.openDialog({ type: 'copy', paths: state.selection, destination: state.path })\n \"\n >\n <CopyIcon aria-hidden=\"true\" />\n <span class=\"max-md:sr-only\">{{ tt('copy', {}, 'Copy') }}</span>\n </Button>\n <Button\n v-if=\"single?.type === 'file' && can('share') && schema.share\"\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n @click=\"controller.openShare(single)\"\n >\n <Share2Icon aria-hidden=\"true\" />\n <span class=\"max-md:sr-only\">{{ tt('share', {}, 'Share') }}</span>\n </Button>\n <Button type=\"button\" variant=\"ghost\" size=\"sm\" @click=\"controller.showDetails(single)\">\n <InfoIcon aria-hidden=\"true\" />\n <span class=\"max-md:sr-only\">{{ tt('details', {}, 'Details') }}</span>\n </Button>\n <Button\n v-if=\"can('delete')\"\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n class=\"text-destructive hover:text-destructive\"\n @click=\"controller.confirmDelete()\"\n >\n <Trash2Icon aria-hidden=\"true\" />\n <span class=\"max-md:sr-only\">{{ tt('delete', {}, 'Delete') }}</span>\n </Button>\n </template>\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon-sm\"\n class=\"ms-auto\"\n :aria-label=\"tt('clear_selection', {}, 'Clear selection')\"\n @click=\"controller.clearSelection()\"\n >\n <XIcon aria-hidden=\"true\" />\n </Button>\n </div>\n\n <div :class=\"cn('flex', picker ? 'h-[60vh]' : 'min-h-[28rem]')\">\n <aside\n class=\"hidden w-60 shrink-0 overflow-y-auto border-e p-2 md:block\"\n data-slot=\"file-manager-sidebar\"\n >\n <FileManagerTree\n :controller=\"controller\"\n :active=\"state.trash ? null : state.path\"\n :drop-handlers=\"canWrite ? dropHandlers : undefined\"\n @open=\"openTreeFolder\"\n />\n </aside>\n\n <div\n class=\"relative min-w-0 flex-1 overflow-y-auto\"\n data-slot=\"file-manager-content\"\n :data-drop-target=\"state.dropTarget === '' ? '' : undefined\"\n v-on=\"canWrite ? dropHandlers('') : {}\"\n >\n <p\n v-if=\"state.trash && state.retentionDays\"\n class=\"text-muted-foreground border-b px-4 py-2 text-xs\"\n >\n {{\n tt(\n 'trash_hint',\n { days: state.retentionDays },\n 'Items in the trash are deleted for good after :days days.',\n )\n }}\n </p>\n <p\n v-if=\"state.truncated || state.resultsTruncated\"\n class=\"text-muted-foreground border-b px-4 py-2 text-xs\"\n role=\"note\"\n >\n {{\n state.resultsTruncated\n ? tt('search_truncated', {}, 'Only the first results are shown. Refine your search.')\n : tt('truncated', {}, 'This folder has more items than can be shown here.')\n }}\n </p>\n <p :id=\"hintId\" class=\"sr-only\">\n {{\n tt('keyboard', {}, 'Arrow keys move between items, Shift selects a range, Enter opens.')\n }}\n </p>\n\n <div\n v-if=\"(state.loading || state.searching) && entries.length === 0\"\n class=\"flex justify-center py-16\"\n >\n <Spinner />\n <span class=\"sr-only\">{{ tt('loading', {}, 'Loading files') }}</span>\n </div>\n <div\n v-else-if=\"entries.length === 0\"\n class=\"text-muted-foreground flex flex-col items-center gap-2 px-6 py-16 text-center text-sm\"\n data-slot=\"file-manager-empty\"\n >\n <FolderOpenIcon class=\"size-10 opacity-40\" aria-hidden=\"true\" />\n <p class=\"text-foreground font-medium\">\n {{\n state.query\n ? tt('no_results', {}, 'Nothing matches your search')\n : state.trash\n ? tt('empty_trash_state', {}, 'The trash is empty')\n : tt('empty', {}, 'This folder is empty')\n }}\n </p>\n <p v-if=\"!state.query && canWrite && can('upload')\">\n {{ tt('empty_hint', {}, 'Drop files here or choose Upload.') }}\n </p>\n </div>\n <div\n v-else\n ref=\"listRef\"\n role=\"listbox\"\n :aria-multiselectable=\"!picker || schema.multiple === true\"\n :aria-labelledby=\"labelId\"\n :aria-describedby=\"hintId\"\n :aria-busy=\"state.loading || undefined\"\n data-slot=\"file-manager-items\"\n :class=\"\n cn(\n state.view === 'grid'\n ? 'grid grid-cols-2 gap-3 p-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6'\n : 'flex flex-col p-1',\n state.loading && 'opacity-60',\n )\n \"\n >\n <div\n v-if=\"state.view === 'list'\"\n class=\"text-muted-foreground hidden grid-cols-[1fr_7rem_10rem] gap-3 px-3 py-1.5 text-xs font-medium sm:grid\"\n aria-hidden=\"true\"\n >\n <span>{{ tt('name', {}, 'Name') }}</span>\n <span class=\"text-end\">{{ tt('size', {}, 'Size') }}</span>\n <span>{{ tt('modified', {}, 'Modified') }}</span>\n </div>\n <div\n v-for=\"entry in entries\"\n :key=\"entry.path\"\n :ref=\"setItem(entry.path)\"\n role=\"option\"\n :aria-label=\"entry.name\"\n :aria-selected=\"state.selection.includes(entry.path)\"\n :aria-disabled=\"\n (picker && entry.type === 'file' && entry.selectable === false) || undefined\n \"\n :tabindex=\"entry.path === focusPath ? 0 : -1\"\n :draggable=\"canWrite && (can('move') || can('copy'))\"\n data-slot=\"file-manager-item\"\n :data-type=\"entry.type\"\n :data-kind=\"entry.kind\"\n :data-selected=\"state.selection.includes(entry.path) ? '' : undefined\"\n :data-drop-target=\"state.dropTarget === entry.path ? '' : undefined\"\n :title=\"entry.name\"\n :class=\"\n cn(\n 'group focus-visible:ring-ring/50 relative cursor-default rounded-lg outline-none select-none focus-visible:ring-[3px]',\n state.view === 'grid'\n ? 'hover:bg-accent/50 flex flex-col gap-2 border p-2'\n : 'hover:bg-accent/50 grid grid-cols-[auto_1fr] items-center gap-3 px-3 py-2 sm:grid-cols-[auto_1fr_7rem_10rem]',\n state.selection.includes(entry.path) &&\n 'bg-primary/10 border-primary/40 hover:bg-primary/15',\n state.dropTarget === entry.path && 'ring-primary ring-2',\n picker && entry.type === 'file' && entry.selectable === false && 'opacity-50',\n )\n \"\n v-on=\"entry.type === 'folder' && canWrite ? dropHandlers(entry.path) : {}\"\n @pointerdown=\"pointer = $event.pointerType\"\n @click=\"onItemClick(entry, $event)\"\n @dblclick=\"onItemDoubleClick(entry)\"\n @keydown=\"onItemKeyDown\"\n @focus=\"controller.focus(entry.path)\"\n @dragstart=\"onDragStart(entry, $event)\"\n @dragend=\"controller.dragEnd()\"\n >\n <template v-if=\"state.view === 'grid'\">\n <div\n class=\"bg-muted/60 flex aspect-[4/3] items-center justify-center overflow-hidden rounded-md\"\n >\n <img\n v-if=\"thumbnailOf(entry)\"\n :src=\"thumbnailOf(entry) ?? undefined\"\n alt=\"\"\n loading=\"lazy\"\n :draggable=\"false\"\n class=\"size-full object-cover\"\n @error=\"markBroken(entry.path)\"\n />\n <FileManagerIcon v-else :entry=\"entry\" class=\"size-10\" />\n </div>\n <div class=\"min-w-0 px-0.5\">\n <p class=\"truncate text-sm font-medium\">{{ entry.name }}</p>\n <p class=\"text-muted-foreground truncate text-xs\">\n {{\n locationOf(entry) ??\n (entry.type === 'folder' ? tt('kinds.folder', {}, 'Folder') : sizeOf(entry))\n }}\n </p>\n </div>\n </template>\n <template v-else>\n <img\n v-if=\"thumbnailOf(entry)\"\n :src=\"thumbnailOf(entry) ?? undefined\"\n alt=\"\"\n loading=\"lazy\"\n :draggable=\"false\"\n class=\"size-8 rounded object-cover\"\n @error=\"markBroken(entry.path)\"\n />\n <FileManagerIcon v-else :entry=\"entry\" class=\"size-5\" />\n <div class=\"min-w-0\">\n <p class=\"truncate text-sm font-medium\">{{ entry.name }}</p>\n <p v-if=\"locationOf(entry)\" class=\"text-muted-foreground truncate text-xs\">\n {{ locationOf(entry) }}\n </p>\n </div>\n <span class=\"text-muted-foreground hidden text-end text-sm tabular-nums sm:block\">{{\n sizeOf(entry)\n }}</span>\n <span class=\"text-muted-foreground hidden truncate text-sm sm:block\">\n {{ entry.modified ? formatter.dateTime(entry.modified) : '' }}\n </span>\n </template>\n <CheckIcon\n v-if=\"state.selection.includes(entry.path)\"\n class=\"bg-primary text-primary-foreground absolute end-1.5 top-1.5 size-4 rounded-full p-0.5\"\n aria-hidden=\"true\"\n />\n </div>\n </div>\n\n <div\n v-if=\"state.dropTarget === ''\"\n class=\"border-primary bg-primary/5 text-primary pointer-events-none absolute inset-2 flex items-center justify-center rounded-lg border-2 border-dashed text-sm font-medium\"\n data-slot=\"file-manager-drop\"\n >\n {{ tt('drop_here', { folder: folderLabel }, 'Drop files to upload them to :folder') }}\n </div>\n </div>\n\n <aside\n v-if=\"state.detailsOpen\"\n class=\"hidden w-72 shrink-0 overflow-y-auto border-s md:block\"\n data-slot=\"file-manager-details\"\n >\n <FileManagerDetails :controller=\"controller\" />\n </aside>\n </div>\n\n <div\n v-if=\"state.trash && entries.length > 0 && can('delete')\"\n class=\"flex justify-end border-t p-3\"\n >\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"sm\"\n @click=\"controller.openDialog({ type: 'emptyTrash' })\"\n >\n <Trash2Icon aria-hidden=\"true\" />\n {{ tt('empty_trash', {}, 'Empty trash') }}\n </Button>\n </div>\n\n <div\n v-if=\"picker\"\n class=\"flex flex-wrap items-center justify-end gap-2 border-t p-3\"\n data-slot=\"file-manager-picker-footer\"\n >\n <span class=\"text-muted-foreground me-auto text-sm\">\n {{ selected.length > 0 ? tt('selected', { count: chosen.length }, ':count selected') : '' }}\n </span>\n <Button type=\"button\" variant=\"outline\" @click=\"onCancel?.()\">{{\n tt('cancel', {}, 'Cancel')\n }}</Button>\n <Button type=\"button\" :disabled=\"chosen.length === 0\" @click=\"onChoose?.(chosen)\">\n <CheckIcon aria-hidden=\"true\" />\n {{ tt('picker_choose', {}, 'Choose') }}\n </Button>\n </div>\n\n <Sheet :open=\"foldersOpen\" @update:open=\"foldersOpen = $event\">\n <SheetContent :side=\"direction === 'rtl' ? 'right' : 'left'\" class=\"p-4\">\n <SheetHeader class=\"p-0\">\n <SheetTitle>{{ tt('folders', {}, 'Folders') }}</SheetTitle>\n </SheetHeader>\n <FileManagerTree\n :controller=\"controller\"\n :active=\"state.trash ? null : state.path\"\n :drop-handlers=\"canWrite ? dropHandlers : undefined\"\n @open=\"openTreeFolder\"\n />\n </SheetContent>\n </Sheet>\n\n <Sheet\n :open=\"state.detailsOpen && mobile\"\n @update:open=\"(open) => !open && controller.closeDetails()\"\n >\n <SheetContent :side=\"direction === 'rtl' ? 'left' : 'right'\" class=\"p-0\">\n <SheetHeader>\n <SheetTitle>{{ tt('details', {}, 'Details') }}</SheetTitle>\n </SheetHeader>\n <FileManagerDetails :controller=\"controller\" hide-heading />\n </SheetContent>\n </Sheet>\n\n <section\n v-if=\"state.uploads.length > 0\"\n class=\"bg-popover fixed end-4 bottom-4 z-40 flex max-h-80 w-[min(22rem,calc(100vw-2rem))] flex-col overflow-hidden rounded-lg border shadow-lg\"\n :aria-label=\"tt('uploads', {}, 'Uploads')\"\n data-slot=\"file-manager-uploads\"\n >\n <header class=\"flex items-center gap-2 border-b px-3 py-2 text-sm font-medium\">\n <UploadIcon class=\"size-4\" aria-hidden=\"true\" />\n <span class=\"flex-1\" role=\"status\">\n {{\n tt(\n 'uploads_summary',\n { done: uploadsDone, total: state.uploads.length },\n ':done of :total uploaded',\n )\n }}\n </span>\n <Button\n v-if=\"uploadsFinished\"\n type=\"button\"\n variant=\"ghost\"\n size=\"icon-xs\"\n :aria-label=\"tt('dismiss', {}, 'Dismiss')\"\n @click=\"controller.clearUploads()\"\n >\n <XIcon aria-hidden=\"true\" />\n </Button>\n </header>\n <ul class=\"flex flex-col gap-2 overflow-y-auto p-3\">\n <li\n v-for=\"upload in state.uploads\"\n :key=\"upload.id\"\n class=\"flex flex-col gap-1 text-sm\"\n :data-status=\"upload.status\"\n >\n <div class=\"flex items-center gap-2\">\n <span class=\"min-w-0 flex-1 truncate\">{{ upload.name }}</span>\n <CheckIcon\n v-if=\"upload.status === 'done'\"\n class=\"size-4 text-emerald-600\"\n :aria-label=\"tt('upload_done', {}, 'Uploaded')\"\n />\n <Button\n v-else-if=\"upload.status === 'error'\"\n type=\"button\"\n variant=\"ghost\"\n size=\"icon-xs\"\n :aria-label=\"tt('dismiss', {}, 'Dismiss')\"\n @click=\"controller.dismissUpload(upload.id)\"\n >\n <XIcon aria-hidden=\"true\" />\n </Button>\n <span v-else class=\"text-muted-foreground text-xs tabular-nums\">{{\n formatter.percent(upload.progress)\n }}</span>\n </div>\n <p v-if=\"upload.status === 'error'\" class=\"text-destructive text-xs\" role=\"alert\">\n {{ upload.error ?? tt('upload_failed', {}, 'Failed') }}\n </p>\n <div\n v-else-if=\"upload.status !== 'done'\"\n role=\"progressbar\"\n :aria-valuemin=\"0\"\n :aria-valuemax=\"100\"\n :aria-valuenow=\"Math.round(upload.progress * 100)\"\n :aria-label=\"\n tt(\n 'upload_progress',\n { name: upload.name, percent: formatter.percent(upload.progress) },\n ':name, :percent uploaded',\n )\n \"\n class=\"bg-muted h-1.5 overflow-hidden rounded-full\"\n >\n <div\n class=\"bg-primary h-full rounded-full transition-[width]\"\n :style=\"{ width: `${upload.progress * 100}%` }\"\n />\n </div>\n </li>\n </ul>\n </section>\n\n <Dialog\n :open=\"dialog?.type === 'createFolder' || dialog?.type === 'rename'\"\n @update:open=\"onDialogOpen\"\n >\n <DialogContent data-slot=\"file-manager-dialog\">\n <form class=\"flex flex-col gap-4\" @submit.prevent=\"submitName\">\n <DialogHeader>\n <DialogTitle>\n {{\n dialog?.type === 'rename'\n ? tt('rename_heading', { name: dialog.entry.name }, 'Rename “:name”')\n : tt('new_folder', {}, 'New folder')\n }}\n </DialogTitle>\n </DialogHeader>\n <div class=\"flex flex-col gap-2\">\n <label :for=\"nameId\" class=\"text-sm font-medium\">\n {{\n dialog?.type === 'rename'\n ? tt('name', {}, 'Name')\n : tt('folder_name', {}, 'Folder name')\n }}\n </label>\n <Input\n :id=\"nameId\"\n v-model=\"name\"\n autofocus\n :aria-invalid=\"state.error ? true : undefined\"\n :aria-describedby=\"state.error ? errorId : undefined\"\n />\n <p v-if=\"state.error\" :id=\"errorId\" class=\"text-destructive text-sm\" role=\"alert\">\n {{ state.error }}\n </p>\n </div>\n <DialogFooter>\n <Button type=\"button\" variant=\"outline\" @click=\"close\">{{\n tt('cancel', {}, 'Cancel')\n }}</Button>\n <Button type=\"submit\" :disabled=\"state.busy || name.trim() === ''\">\n <Spinner v-if=\"state.busy\" />\n {{ dialog?.type === 'rename' ? tt('save', {}, 'Save') : tt('create', {}, 'Create') }}\n </Button>\n </DialogFooter>\n </form>\n </DialogContent>\n </Dialog>\n\n <Dialog :open=\"dialog?.type === 'move' || dialog?.type === 'copy'\" @update:open=\"onDialogOpen\">\n <DialogContent data-slot=\"file-manager-dialog\">\n <DialogHeader>\n <DialogTitle>\n {{\n dialog?.type === 'move' || dialog?.type === 'copy'\n ? choice(\n `file-manager.${dialog.type}_heading`,\n dialog.paths.length,\n {},\n dialog.type === 'move' ? 'Move :count items' : 'Copy :count items',\n )\n : ''\n }}\n </DialogTitle>\n <DialogDescription>{{ tt('destination', {}, 'Destination folder') }}</DialogDescription>\n </DialogHeader>\n <div class=\"max-h-72 overflow-y-auto rounded-md border p-2\">\n <FileManagerTree\n v-if=\"dialog?.type === 'move' || dialog?.type === 'copy'\"\n :controller=\"controller\"\n :active=\"dialog.destination\"\n @open=\"chooseDestination\"\n />\n </div>\n <p v-if=\"state.error\" class=\"text-destructive text-sm\" role=\"alert\">{{ state.error }}</p>\n <DialogFooter>\n <Button type=\"button\" variant=\"outline\" @click=\"close\">{{\n tt('cancel', {}, 'Cancel')\n }}</Button>\n <Button type=\"button\" :disabled=\"state.busy\" @click=\"submitTransfer\">\n <Spinner v-if=\"state.busy\" />\n {{\n dialog?.type === 'copy'\n ? tt('copy_here', {}, 'Copy here')\n : tt('move_here', {}, 'Move here')\n }}\n </Button>\n </DialogFooter>\n </DialogContent>\n </Dialog>\n\n <Dialog\n :open=\"dialog?.type === 'delete' || dialog?.type === 'emptyTrash'\"\n @update:open=\"onDialogOpen\"\n >\n <DialogContent data-slot=\"file-manager-dialog\">\n <DialogHeader>\n <DialogTitle>\n {{\n dialog?.type === 'delete'\n ? choice(\n 'file-manager.delete_heading',\n dialog.paths.length,\n {},\n 'Delete :count items?',\n )\n : tt('empty_trash_heading', {}, 'Empty the trash?')\n }}\n </DialogTitle>\n <DialogDescription>\n {{\n dialog?.type === 'delete' && !dialog.permanent\n ? tt(\n 'delete_description_trash',\n {},\n 'They go to the trash, where you can restore them.',\n )\n : dialog?.type === 'emptyTrash'\n ? tt(\n 'empty_trash_description',\n {},\n 'Everything in the trash is deleted for good.',\n )\n : tt(\n 'delete_description',\n {},\n 'They are deleted for good. This cannot be undone.',\n )\n }}\n </DialogDescription>\n </DialogHeader>\n <p v-if=\"state.error\" class=\"text-destructive text-sm\" role=\"alert\">{{ state.error }}</p>\n <DialogFooter>\n <Button type=\"button\" variant=\"outline\" @click=\"close\">{{\n tt('cancel', {}, 'Cancel')\n }}</Button>\n <Button type=\"button\" variant=\"destructive\" :disabled=\"state.busy\" @click=\"submitDelete\">\n <Spinner v-if=\"state.busy\" />\n {{\n dialog?.type === 'emptyTrash'\n ? tt('empty_trash', {}, 'Empty trash')\n : dialog?.type === 'delete' && dialog.permanent\n ? tt('delete_permanently', {}, 'Delete permanently')\n : tt('delete', {}, 'Delete')\n }}\n </Button>\n </DialogFooter>\n </DialogContent>\n </Dialog>\n\n <Dialog :open=\"dialog?.type === 'share'\" @update:open=\"onDialogOpen\">\n <DialogContent data-slot=\"file-manager-dialog\">\n <template v-if=\"dialog?.type === 'share'\">\n <DialogHeader>\n <DialogTitle>{{\n tt('share_heading', { name: dialog.entry.name }, 'Share “:name”')\n }}</DialogTitle>\n <DialogDescription>{{\n tt('share_hint', {}, 'Anyone with the link can open this file, without signing in.')\n }}</DialogDescription>\n </DialogHeader>\n <div class=\"flex flex-wrap items-end gap-2\">\n <label class=\"flex min-w-40 flex-1 flex-col gap-1 text-sm\">\n <span class=\"font-medium\">{{ tt('share_expiry', {}, 'Link expires after') }}</span>\n <select v-model=\"expiry\" :class=\"SELECT\">\n <option\n v-for=\"minutes in share?.expiries ?? []\"\n :key=\"minutes\"\n :value=\"String(minutes)\"\n >\n {{ expiryLabel(minutes) }}\n </option>\n <option v-if=\"share?.publicLinks\" value=\"never\">\n {{ tt('expiry_never', {}, 'Never') }}\n </option>\n </select>\n </label>\n <Button\n type=\"button\"\n :disabled=\"state.busy\"\n @click=\"controller.createLink(expiry === 'never' ? null : Number(expiry))\"\n >\n <Spinner v-if=\"state.busy\" />\n <LinkIcon v-else aria-hidden=\"true\" />\n {{ tt('create_link', {}, 'Create link') }}\n </Button>\n </div>\n <p v-if=\"state.error\" class=\"text-destructive text-sm\" role=\"alert\">{{ state.error }}</p>\n <div v-if=\"dialog.url\" class=\"flex flex-col gap-1\">\n <span class=\"text-sm font-medium\">{{ tt('link', {}, 'Shareable link') }}</span>\n <div class=\"flex gap-2\">\n <Input\n readonly\n :model-value=\"dialog.url\"\n dir=\"ltr\"\n :aria-label=\"tt('link', {}, 'Shareable link')\"\n @focus=\"($event.target as HTMLInputElement).select()\"\n />\n <Button type=\"button\" variant=\"outline\" @click=\"copy(dialog.url ?? '')\">\n <CheckIcon v-if=\"copied\" aria-hidden=\"true\" />\n <CopyIcon v-else aria-hidden=\"true\" />\n {{\n copied ? tt('link_copied', {}, 'Link copied') : tt('copy_link', {}, 'Copy link')\n }}\n </Button>\n </div>\n <span class=\"text-muted-foreground text-xs\">\n {{\n dialog.expiresAt\n ? tt(\n 'link_expires',\n { date: formatter.dateTime(dialog.expiresAt) },\n 'Expires :date',\n )\n : tt('link_never_expires', {}, 'Never expires')\n }}\n </span>\n </div>\n <div v-if=\"share?.publicLinks\" class=\"flex flex-col gap-2\" data-slot=\"file-manager-links\">\n <h4 class=\"text-sm font-medium\">{{ tt('links', {}, 'Links') }}</h4>\n <Spinner v-if=\"dialog.links === null\" />\n <p v-else-if=\"dialog.links.length === 0\" class=\"text-muted-foreground text-sm\">\n {{ tt('no_links', {}, 'No links yet') }}\n </p>\n <ul v-else class=\"flex max-h-48 flex-col divide-y overflow-y-auto rounded-md border\">\n <li\n v-for=\"link in dialog.links\"\n :key=\"link.id\"\n class=\"flex items-center gap-2 px-3 py-2 text-sm\"\n >\n <div class=\"min-w-0 flex-1\">\n <p\n :class=\"cn('truncate', link.expired && 'text-muted-foreground line-through')\"\n dir=\"ltr\"\n >\n {{ link.url }}\n </p>\n <p class=\"text-muted-foreground text-xs\">\n {{\n link.expired\n ? tt('link_expired', {}, 'Expired')\n : link.expiresAt\n ? tt(\n 'link_expires',\n { date: formatter.dateTime(link.expiresAt) },\n 'Expires :date',\n )\n : tt('link_never_expires', {}, 'Never expires')\n }}\n ·\n {{\n choice('file-manager.link_downloads', link.downloads, {}, ':count downloads')\n }}\n </p>\n </div>\n <Button\n v-if=\"link.url && !link.expired\"\n type=\"button\"\n variant=\"ghost\"\n size=\"icon-xs\"\n :aria-label=\"tt('copy_link', {}, 'Copy link')\"\n @click=\"copy(link.url ?? '')\"\n >\n <CopyIcon aria-hidden=\"true\" />\n </Button>\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n class=\"text-destructive\"\n @click=\"controller.revokeLink(link.id)\"\n >\n {{ tt('revoke', {}, 'Revoke') }}\n </Button>\n </li>\n </ul>\n </div>\n <DialogFooter>\n <Button type=\"button\" variant=\"outline\" @click=\"close\">{{\n tt('close', {}, 'Close')\n }}</Button>\n </DialogFooter>\n </template>\n </DialogContent>\n </Dialog>\n\n <Dialog :open=\"preview !== null\" @update:open=\"(open) => !open && controller.closePreview()\">\n <DialogContent\n class=\"flex max-h-[90vh] flex-col sm:max-w-4xl\"\n data-slot=\"file-manager-preview\"\n @keydown=\"onPreviewKeyDown\"\n >\n <DialogHeader>\n <DialogTitle class=\"truncate pe-8\">{{ preview?.entry.name }}</DialogTitle>\n </DialogHeader>\n <div\n class=\"bg-muted/40 flex min-h-64 flex-1 items-center justify-center overflow-auto rounded-md\"\n >\n <Spinner v-if=\"!previewData\" />\n <div\n v-else-if=\"!previewData.previewable\"\n class=\"text-muted-foreground flex flex-col items-center gap-2 p-8 text-center text-sm\"\n >\n <FileManagerIcon v-if=\"preview\" :entry=\"preview.entry\" class=\"size-12\" />\n <p>{{ tt('preview_unavailable', {}, 'There is no preview for this type of file.') }}</p>\n </div>\n <img\n v-else-if=\"previewData.kind === 'image'\"\n :src=\"previewData.url\"\n :alt=\"preview?.entry.name ?? ''\"\n class=\"max-h-[70vh] max-w-full object-contain\"\n />\n <iframe\n v-else-if=\"previewData.kind === 'pdf'\"\n :src=\"previewData.url\"\n :title=\"preview?.entry.name ?? ''\"\n class=\"h-[70vh] w-full rounded-md bg-white\"\n />\n <video\n v-else-if=\"previewData.kind === 'video'\"\n :src=\"previewData.url\"\n controls\n class=\"max-h-[70vh] max-w-full\"\n >\n <track kind=\"captions\" />\n </video>\n <audio\n v-else-if=\"previewData.kind === 'audio'\"\n :src=\"previewData.url\"\n controls\n class=\"w-full max-w-md\"\n />\n <pre\n v-else\n class=\"h-full max-h-[70vh] w-full overflow-auto p-4 font-mono text-xs whitespace-pre-wrap\"\n dir=\"auto\"\n tabindex=\"0\"\n >{{ previewData.text }}</pre>\n </div>\n <p v-if=\"previewData?.truncated\" class=\"text-muted-foreground text-xs\">\n {{ tt('preview_truncated', {}, 'Only the beginning of this file is shown.') }}\n </p>\n <DialogFooter class=\"flex-row flex-wrap items-center sm:justify-between\">\n <div class=\"flex gap-1\">\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"icon-sm\"\n :disabled=\"!previous\"\n :aria-label=\"tt('previous', {}, 'Previous file')\"\n @click=\"previous && controller.openPreview(previous)\"\n >\n <ChevronLeftIcon class=\"rtl:rotate-180\" aria-hidden=\"true\" />\n </Button>\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"icon-sm\"\n :disabled=\"!next\"\n :aria-label=\"tt('next', {}, 'Next file')\"\n @click=\"next && controller.openPreview(next)\"\n >\n <ChevronRightIcon class=\"rtl:rotate-180\" aria-hidden=\"true\" />\n </Button>\n </div>\n <div class=\"flex gap-2\">\n <Button\n v-if=\"previewData?.editable && previewData.url\"\n type=\"button\"\n variant=\"outline\"\n size=\"sm\"\n @click=\"editImage\"\n >\n <SquarePenIcon aria-hidden=\"true\" />\n {{ tt('edit_image', {}, 'Edit image') }}\n </Button>\n <Button v-if=\"previewData?.downloadUrl\" type=\"button\" size=\"sm\" as-child>\n <a :href=\"previewData.downloadUrl\">\n <DownloadIcon aria-hidden=\"true\" />\n {{ tt('download', {}, 'Download') }}\n </a>\n </Button>\n </div>\n </DialogFooter>\n </DialogContent>\n </Dialog>\n <ImageEditorDialog :request=\"editing\" :options=\"IMAGE_EDITOR\" />\n </Card>\n</template>\n"],"mappings":""}
|
|
1
|
+
{"version":3,"file":"FileManagerView.js","names":["_sfc_main","FileManagerView_vue_vue_type_script_setup_true_lang_default","FileManagerView_default"],"sources":["../../../src/components/file-manager/FileManagerView.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport type {\n FileManagerEntrySchema,\n FileManagerEnvironment,\n FileManagerSchema,\n FileManagerSortField,\n} from '@alfakit/core'\nimport {\n FileManagerController,\n clientErrorNotification,\n fetchEditableImage,\n fileManagerAcceptAttribute,\n fileManagerExpiry,\n fileManagerSize,\n} from '@alfakit/core'\nimport {\n ArrowDownIcon,\n ArrowUpIcon,\n CheckIcon,\n ChevronLeftIcon,\n ChevronRightIcon,\n CopyIcon,\n DownloadIcon,\n FolderInputIcon,\n FolderOpenIcon,\n FolderPlusIcon,\n FolderTreeIcon,\n InfoIcon,\n LayoutGridIcon,\n LinkIcon,\n ListIcon,\n PencilIcon,\n RefreshCwIcon,\n RotateCcwIcon,\n SearchIcon,\n Share2Icon,\n SquarePenIcon,\n Trash2Icon,\n UploadIcon,\n XIcon,\n} from '@lucide/vue'\nimport { computed, nextTick, onBeforeUnmount, onMounted, ref, toRaw, useId, watch } from 'vue'\nimport {\n useActionRunner,\n useChoice,\n useDirection,\n useFormatter,\n usePanel,\n useT,\n} from '../../context'\nimport { useIsMobile } from '../../composables/use-mobile'\nimport { useStore } from '../../composables/use-store'\nimport { cn } from '../../lib/utils'\nimport { usePageActionContext } from '../actions/action-context'\nimport ImageEditorDialog, { type ImageEditRequest } from '../fields/ImageEditorDialog.vue'\nimport { showToast } from '../notifications/flash-toasts'\nimport Button from '../ui/Button.vue'\nimport Card from '../ui/Card.vue'\nimport Checkbox from '../ui/Checkbox.vue'\nimport Dialog from '../ui/Dialog.vue'\nimport DialogContent from '../ui/DialogContent.vue'\nimport DialogDescription from '../ui/DialogDescription.vue'\nimport DialogFooter from '../ui/DialogFooter.vue'\nimport DialogHeader from '../ui/DialogHeader.vue'\nimport DialogTitle from '../ui/DialogTitle.vue'\nimport Input from '../ui/Input.vue'\nimport Sheet from '../ui/Sheet.vue'\nimport SheetContent from '../ui/SheetContent.vue'\nimport SheetHeader from '../ui/SheetHeader.vue'\nimport SheetTitle from '../ui/SheetTitle.vue'\nimport Spinner from '../ui/Spinner.vue'\nimport FileManagerDetails from './FileManagerDetails.vue'\nimport FileManagerIcon from './FileManagerIcon.vue'\nimport FileManagerTree from './FileManagerTree.vue'\n\n/**\n * The file manager of alfasix/alfakit-file-manager: a folder tree, breadcrumbs, grid and\n * list views, sorting, search, multi-selection with mouse and keyboard, drag and drop\n * (uploads and moves), uploads with progress, previews, details, sharing and the trash.\n * Everything is listed, checked and changed by the server.\n */\nconst props = defineProps<{\n schema: FileManagerSchema\n /** Picker: called with the chosen files. */\n onChoose?: (entries: FileManagerEntrySchema[]) => void\n onCancel?: () => void\n}>()\n\nconst SELECT =\n 'border-input focus-visible:border-ring focus-visible:ring-ring/50 h-9 min-w-0 rounded-md border bg-transparent px-2 text-sm shadow-xs outline-none focus-visible:ring-[3px]'\nconst SORT_FIELDS: FileManagerSortField[] = ['name', 'size', 'modified', 'type']\nconst IMAGE_EDITOR = { aspectRatios: [null], circle: false }\n\nconst runner = useActionRunner()\nconst page = usePageActionContext()\nconst panel = usePanel()\nconst t = useT()\nconst choice = useChoice()\nconst direction = useDirection()\nconst formatter = useFormatter()\nconst mobile = useIsMobile()\nconst labelId = useId()\nconst hintId = useId()\nconst nameId = useId()\nconst errorId = useId()\nconst tt = (key: string, replacements: Record<string, string | number> = {}, fallback?: string) =>\n t(`file-manager.${key}`, replacements, fallback)\n\nconst environment = (): FileManagerEnvironment => ({\n runner,\n token: page.token,\n uploadEndpoint: panel.value.endpoints.upload,\n uploadChunkEndpoint: panel.value.endpoints.uploadChunks,\n uploadChunkSize: panel.value.uploadChunkSize,\n t,\n choice,\n formatter: formatter.value,\n notify: (notifications) => notifications.forEach((notification) => showToast(notification)),\n error: (message) => showToast(clientErrorNotification('file-manager', message)),\n})\n\nconst controller = new FileManagerController(\n toRaw(props.schema),\n environment(),\n formatter.value.locale,\n)\nconst state = useStore(controller.store)\n\nwatch(\n () => props.schema,\n (schema) => controller.setSchema(toRaw(schema)),\n)\nwatch([formatter, direction], () => controller.setEnvironment(environment()))\n\nonMounted(() => {\n if (props.schema.mode === 'picker') void controller.ensureLoaded()\n})\nonBeforeUnmount(() => controller.dispose())\n\nconst picker = computed(() => props.schema.mode === 'picker')\nconst entries = computed(() => {\n void state.value\n\n return controller.visibleEntries()\n})\nconst selected = computed(() => {\n void state.value\n\n return controller.selectedEntries()\n})\nconst chosen = computed(() => {\n void state.value\n\n return controller.chosen()\n})\nconst single = computed(() => (selected.value.length === 1 ? selected.value[0] : undefined))\nconst canWrite = computed(() => !picker.value && !state.value.trash)\nconst focusPath = computed(() => state.value.focus ?? entries.value[0]?.path ?? null)\nconst folderLabel = computed(() =>\n state.value.trash\n ? tt('trash', {}, 'Trash')\n : (state.value.breadcrumbs[state.value.breadcrumbs.length - 1]?.name ??\n tt('root', {}, 'Files')),\n)\nconst can = (ability: Parameters<FileManagerController['can']>[0]) => controller.can(ability)\n\nconst listRef = ref<HTMLElement | null>(null)\nconst items = new Map<string, HTMLElement>()\nconst uploadInput = ref<HTMLInputElement | null>(null)\nconst foldersOpen = ref(false)\nconst pointer = ref('mouse')\nconst broken = ref(new Set<string>())\n\nconst setItem = (path: string) => (element: unknown) => {\n if (element instanceof HTMLElement) items.set(path, element)\n else items.delete(path)\n}\n\nconst focusItem = (path: string | undefined) => {\n if (path) void nextTick(() => items.get(path)?.focus())\n}\n\nconst columns = () => {\n const element = listRef.value\n\n if (!element || state.value.view !== 'grid' || typeof getComputedStyle === 'undefined') return 1\n\n return Math.max(\n 1,\n getComputedStyle(element).gridTemplateColumns.split(' ').filter(Boolean).length,\n )\n}\n\nconst onItemKeyDown = (event: KeyboardEvent) => {\n const result = controller.handleKey(event, columns(), direction.value)\n\n if (result.handled) {\n event.preventDefault()\n focusItem(result.focus)\n }\n}\n\nconst onItemClick = (entry: FileManagerEntrySchema, event: MouseEvent) =>\n controller.click(entry.path, {\n shift: event.shiftKey,\n toggle: event.metaKey || event.ctrlKey,\n touch: pointer.value === 'touch',\n })\n\nconst onItemDoubleClick = (entry: FileManagerEntrySchema) => {\n if (picker.value && entry.type === 'file') {\n if (entry.selectable !== false && entry.value) props.onChoose?.([entry])\n\n return\n }\n\n controller.openEntry(entry)\n}\n\nconst copying = (event: DragEvent) => event.ctrlKey || event.altKey || event.metaKey\n\nconst dropHandlers = (target: string) => ({\n dragover: (event: DragEvent) => {\n if (event.dataTransfer && controller.dragOver(target, event.dataTransfer, copying(event))) {\n event.preventDefault()\n event.stopPropagation()\n }\n },\n dragleave: (event: DragEvent) => {\n const current = event.currentTarget as HTMLElement | null\n\n if (!current?.contains(event.relatedTarget as Node | null)) controller.dragLeave(target)\n },\n drop: (event: DragEvent) => {\n event.preventDefault()\n event.stopPropagation()\n\n if (event.dataTransfer) void controller.drop(target, event.dataTransfer, copying(event))\n },\n})\n\nconst onDragStart = (entry: FileManagerEntrySchema, event: DragEvent) => {\n if (event.dataTransfer) controller.dragStart(entry.path, event.dataTransfer)\n}\n\nconst openTreeFolder = (path: string) => {\n foldersOpen.value = false\n void controller.open(path)\n}\n\nconst onUpload = (event: Event) => {\n const input = event.target as HTMLInputElement\n\n if (input.files) void controller.upload(Array.from(input.files))\n input.value = ''\n}\n\nconst sizeOf = (entry: FileManagerEntrySchema) =>\n entry.type === 'file' ? fileManagerSize(entry.size, formatter.value) : ''\nconst locationOf = (entry: FileManagerEntrySchema) =>\n state.value.results !== null && entry.path.includes('/')\n ? entry.path.slice(0, entry.path.lastIndexOf('/'))\n : null\nconst thumbnailOf = (entry: FileManagerEntrySchema) =>\n entry.thumbnail && !broken.value.has(entry.path) ? entry.thumbnail : null\nconst markBroken = (path: string) => {\n broken.value = new Set([...broken.value, path])\n}\n\n// ---------------------------------------------------------------- Uploads\n\nconst uploadsDone = computed(\n () => state.value.uploads.filter((upload) => upload.status === 'done').length,\n)\nconst uploadsFinished = computed(() =>\n state.value.uploads.every((upload) => upload.status === 'done' || upload.status === 'error'),\n)\n\n// ---------------------------------------------------------------- Dialogs\n\nconst dialog = computed(() => state.value.dialog)\nconst name = ref('')\nconst expiry = ref('')\nconst copied = ref(false)\nconst share = computed(() => props.schema.share)\n\nwatch(\n () =>\n [\n dialog.value?.type,\n dialog.value && 'entry' in dialog.value ? dialog.value.entry.path : null,\n ] as const,\n () => {\n const current = dialog.value\n\n if (current?.type === 'rename') name.value = current.entry.name\n if (current?.type === 'createFolder') name.value = ''\n if (current?.type === 'share') {\n expiry.value = String(share.value?.expiries[0] ?? '')\n copied.value = false\n }\n },\n)\n\nconst close = () => controller.closeDialog()\nconst onDialogOpen = (open: boolean) => {\n if (!open) close()\n}\n\nconst submitName = () => {\n const current = dialog.value\n\n if (current?.type === 'rename') void controller.rename(current.entry.path, name.value)\n else void controller.createFolder(name.value)\n}\n\nconst submitTransfer = () => {\n const current = dialog.value\n\n if (current?.type === 'move' || current?.type === 'copy')\n void controller.transfer(current.type, current.paths, current.destination)\n}\n\nconst chooseDestination = (path: string) => {\n const current = dialog.value\n\n if (current?.type === 'move' || current?.type === 'copy')\n controller.openDialog({ ...current, destination: path })\n}\n\nconst submitDelete = () => {\n const current = dialog.value\n\n if (current?.type === 'delete') void controller.remove(current.paths)\n else void controller.emptyTrash()\n}\n\nconst expiryLabel = (minutes: number) => {\n const label = fileManagerExpiry(minutes)\n\n return choice(`file-manager.${label.key}`, label.count, {}, ':count')\n}\n\nconst copy = async (url: string) => {\n try {\n await navigator.clipboard.writeText(url)\n copied.value = true\n } catch {\n copied.value = false\n }\n}\n\n// ---------------------------------------------------------------- Preview\n\nconst preview = computed(() => state.value.preview)\nconst previewData = computed(() => preview.value?.data ?? null)\nconst previous = computed(() => {\n void state.value.preview\n\n return controller.previewSibling(-1)\n})\nconst next = computed(() => {\n void state.value.preview\n\n return controller.previewSibling(1)\n})\nconst editing = ref<ImageEditRequest | null>(null)\n\nconst editImage = async () => {\n const data = previewData.value\n const current = preview.value\n\n if (!data?.url || !current) return\n\n const source = await fetchEditableImage(data.url, current.entry.name)\n\n if (!source) return\n\n editing.value = {\n file: source,\n resolve: (edited) => {\n editing.value = null\n if (edited && edited !== source) void controller.saveEditedImage(edited)\n },\n }\n}\n\nconst onPreviewKeyDown = (event: KeyboardEvent) => {\n const back = direction.value === 'rtl' ? 'ArrowRight' : 'ArrowLeft'\n const forward = direction.value === 'rtl' ? 'ArrowLeft' : 'ArrowRight'\n\n if (\n event.target instanceof HTMLElement &&\n ['VIDEO', 'AUDIO', 'INPUT'].includes(event.target.tagName)\n )\n return\n\n if (event.key === back && previous.value) void controller.openPreview(previous.value)\n if (event.key === forward && next.value) void controller.openPreview(next.value)\n}\n</script>\n\n<template>\n <Card\n class=\"relative gap-0 overflow-hidden py-0\"\n data-slot=\"file-manager\"\n :data-view=\"state.view\"\n :data-mode=\"schema.mode\"\n :aria-labelledby=\"labelId\"\n >\n <span :id=\"labelId\" class=\"sr-only\">{{ tt('label', {}, 'File manager') }}</span>\n <div class=\"sr-only\" role=\"status\" aria-live=\"polite\">{{ state.announcement }}</div>\n\n <div class=\"flex flex-wrap items-center gap-2 border-b p-3\" data-slot=\"file-manager-toolbar\">\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"icon-sm\"\n class=\"md:hidden\"\n :aria-label=\"tt('toggle_folders', {}, 'Show folders')\"\n @click=\"foldersOpen = true\"\n >\n <FolderTreeIcon aria-hidden=\"true\" />\n </Button>\n <label v-if=\"schema.disks.length > 1\" class=\"flex items-center\">\n <span class=\"sr-only\">{{ tt('disk', {}, 'Storage') }}</span>\n <select\n :class=\"SELECT\"\n :value=\"state.disk\"\n @change=\"controller.setDisk(($event.target as HTMLSelectElement).value)\"\n >\n <option v-for=\"disk in schema.disks\" :key=\"disk.name\" :value=\"disk.name\">\n {{ disk.label }}\n </option>\n </select>\n </label>\n <nav\n :aria-label=\"tt('breadcrumb', {}, 'Folder path')\"\n class=\"min-w-0 flex-[1_1_12rem]\"\n data-slot=\"file-manager-breadcrumb\"\n >\n <ol class=\"flex min-w-0 flex-wrap items-center gap-1 text-sm\">\n <li class=\"flex items-center\">\n <button\n type=\"button\"\n class=\"hover:bg-accent rounded px-1.5 py-1 font-medium\"\n :aria-current=\"!state.trash && state.path === '' ? 'page' : undefined\"\n @click=\"state.trash ? controller.showTrash(false) : controller.open('')\"\n >\n {{ tt('root', {}, 'Files') }}\n </button>\n </li>\n <li v-if=\"state.trash\" class=\"flex min-w-0 items-center gap-1\">\n <ChevronRightIcon\n class=\"text-muted-foreground size-3.5 shrink-0 rtl:rotate-180\"\n aria-hidden=\"true\"\n />\n <span class=\"truncate px-1.5 py-1 font-medium\" aria-current=\"page\">{{\n tt('trash', {}, 'Trash')\n }}</span>\n </li>\n <template v-else>\n <li\n v-for=\"(crumb, index) in state.breadcrumbs\"\n :key=\"crumb.path\"\n class=\"flex min-w-0 items-center gap-1\"\n >\n <ChevronRightIcon\n class=\"text-muted-foreground size-3.5 shrink-0 rtl:rotate-180\"\n aria-hidden=\"true\"\n />\n <span\n v-if=\"index === state.breadcrumbs.length - 1\"\n class=\"truncate px-1.5 py-1 font-medium\"\n aria-current=\"page\"\n >\n {{ crumb.name }}\n </span>\n <button\n v-else\n type=\"button\"\n class=\"hover:bg-accent truncate rounded px-1.5 py-1\"\n @click=\"controller.open(crumb.path)\"\n >\n {{ crumb.name }}\n </button>\n </li>\n </template>\n </ol>\n </nav>\n <div class=\"flex w-full flex-wrap items-center gap-2 sm:w-auto\">\n <div class=\"relative min-w-40 flex-1 sm:flex-none\">\n <SearchIcon\n class=\"text-muted-foreground pointer-events-none absolute start-2.5 top-1/2 size-4 -translate-y-1/2\"\n aria-hidden=\"true\"\n />\n <Input\n type=\"search\"\n :model-value=\"state.query\"\n :placeholder=\"tt('search_placeholder', {}, 'Search…')\"\n :aria-label=\"tt('search', {}, 'Search in this folder')\"\n class=\"h-9 ps-8 sm:w-52\"\n :disabled=\"state.trash\"\n @update:model-value=\"controller.setQuery(String($event))\"\n />\n </div>\n <label\n v-if=\"schema.search.recursive && !state.trash\"\n class=\"flex items-center gap-2 text-sm\"\n >\n <Checkbox\n :model-value=\"state.recursive\"\n @update:model-value=\"controller.setRecursive($event === true)\"\n />\n {{ tt('search_everywhere', {}, 'Include subfolders') }}\n </label>\n <label class=\"flex items-center gap-1\">\n <span class=\"sr-only\">{{ tt('sort', {}, 'Sort by') }}</span>\n <select\n :class=\"SELECT\"\n :value=\"state.sort.field\"\n @change=\"\n controller.setSort(\n ($event.target as HTMLSelectElement).value as FileManagerSortField,\n state.sort.direction,\n )\n \"\n >\n <option v-for=\"field in SORT_FIELDS\" :key=\"field\" :value=\"field\">\n {{ tt(`sort_${field}`, {}, field) }}\n </option>\n </select>\n </label>\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"icon-sm\"\n :aria-label=\"\n state.sort.direction === 'asc'\n ? tt('ascending', {}, 'Ascending')\n : tt('descending', {}, 'Descending')\n \"\n @click=\"\n controller.setSort(state.sort.field, state.sort.direction === 'asc' ? 'desc' : 'asc')\n \"\n >\n <ArrowUpIcon v-if=\"state.sort.direction === 'asc'\" aria-hidden=\"true\" />\n <ArrowDownIcon v-else aria-hidden=\"true\" />\n </Button>\n <div class=\"flex rounded-md border\" role=\"group\" :aria-label=\"tt('view', {}, 'View')\">\n <Button\n type=\"button\"\n :variant=\"state.view === 'grid' ? 'secondary' : 'ghost'\"\n size=\"icon-sm\"\n :aria-pressed=\"state.view === 'grid'\"\n :aria-label=\"tt('view_grid', {}, 'Grid')\"\n @click=\"controller.setView('grid')\"\n >\n <LayoutGridIcon aria-hidden=\"true\" />\n </Button>\n <Button\n type=\"button\"\n :variant=\"state.view === 'list' ? 'secondary' : 'ghost'\"\n size=\"icon-sm\"\n :aria-pressed=\"state.view === 'list'\"\n :aria-label=\"tt('view_list', {}, 'List')\"\n @click=\"controller.setView('list')\"\n >\n <ListIcon aria-hidden=\"true\" />\n </Button>\n </div>\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon-sm\"\n :aria-label=\"tt('refresh', {}, 'Refresh')\"\n @click=\"controller.refresh()\"\n >\n <RefreshCwIcon aria-hidden=\"true\" :class=\"cn(state.loading && 'animate-spin')\" />\n </Button>\n <Button\n v-if=\"!state.trash && can('createFolder')\"\n type=\"button\"\n variant=\"outline\"\n size=\"sm\"\n @click=\"controller.openDialog({ type: 'createFolder' })\"\n >\n <FolderPlusIcon aria-hidden=\"true\" />\n <span class=\"max-sm:sr-only\">{{ tt('new_folder', {}, 'New folder') }}</span>\n </Button>\n <template v-if=\"!state.trash && can('upload') && schema.upload\">\n <Button type=\"button\" size=\"sm\" @click=\"uploadInput?.click()\">\n <UploadIcon aria-hidden=\"true\" />\n <span class=\"max-sm:sr-only\">{{ tt('upload', {}, 'Upload') }}</span>\n </Button>\n <input\n ref=\"uploadInput\"\n type=\"file\"\n multiple\n hidden\n :accept=\"fileManagerAcceptAttribute(schema.upload.accept)\"\n data-slot=\"file-manager-upload-input\"\n @change=\"onUpload\"\n />\n </template>\n <Button\n v-if=\"schema.trash && !picker\"\n type=\"button\"\n :variant=\"state.trash ? 'secondary' : 'ghost'\"\n size=\"sm\"\n :aria-pressed=\"state.trash\"\n @click=\"controller.showTrash(!state.trash)\"\n >\n <Trash2Icon aria-hidden=\"true\" />\n <span class=\"max-sm:sr-only\">{{\n state.trash ? tt('back_to_files', {}, 'Back to files') : tt('trash', {}, 'Trash')\n }}</span>\n </Button>\n </div>\n </div>\n\n <div\n v-if=\"selected.length > 0 && !picker\"\n class=\"bg-muted/50 flex flex-wrap items-center gap-1 border-b px-3 py-2 text-sm\"\n data-slot=\"file-manager-selection\"\n >\n <span class=\"me-2 font-medium\">{{\n tt('selected', { count: selected.length }, ':count selected')\n }}</span>\n <template v-if=\"state.trash\">\n <Button\n v-if=\"can('restore')\"\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n @click=\"controller.restore()\"\n >\n <RotateCcwIcon aria-hidden=\"true\" />\n <span class=\"max-md:sr-only\">{{ tt('restore', {}, 'Restore') }}</span>\n </Button>\n <Button\n v-if=\"can('delete')\"\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n class=\"text-destructive hover:text-destructive\"\n @click=\"controller.confirmDelete()\"\n >\n <Trash2Icon aria-hidden=\"true\" />\n <span class=\"max-md:sr-only\">{{\n tt('delete_permanently', {}, 'Delete permanently')\n }}</span>\n </Button>\n </template>\n <template v-else>\n <Button\n v-if=\"single\"\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n @click=\"controller.openEntry(single)\"\n >\n <FolderOpenIcon v-if=\"single.type === 'folder'\" aria-hidden=\"true\" />\n <SearchIcon v-else aria-hidden=\"true\" />\n <span class=\"max-md:sr-only\">{{\n single.type === 'folder' ? tt('open', {}, 'Open') : tt('preview', {}, 'Preview')\n }}</span>\n </Button>\n <Button\n v-if=\"can('download') && (single?.type === 'file' || can('zip'))\"\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n @click=\"controller.download()\"\n >\n <DownloadIcon aria-hidden=\"true\" />\n <span class=\"max-md:sr-only\">{{\n single?.type === 'file'\n ? tt('download', {}, 'Download')\n : tt('download_zip', {}, 'Download as zip')\n }}</span>\n </Button>\n <Button\n v-if=\"single && can('rename')\"\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n @click=\"controller.openDialog({ type: 'rename', entry: single })\"\n >\n <PencilIcon aria-hidden=\"true\" />\n <span class=\"max-md:sr-only\">{{ tt('rename', {}, 'Rename') }}</span>\n </Button>\n <Button\n v-if=\"can('move')\"\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n @click=\"\n controller.openDialog({ type: 'move', paths: state.selection, destination: state.path })\n \"\n >\n <FolderInputIcon aria-hidden=\"true\" />\n <span class=\"max-md:sr-only\">{{ tt('move', {}, 'Move') }}</span>\n </Button>\n <Button\n v-if=\"can('copy')\"\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n @click=\"\n controller.openDialog({ type: 'copy', paths: state.selection, destination: state.path })\n \"\n >\n <CopyIcon aria-hidden=\"true\" />\n <span class=\"max-md:sr-only\">{{ tt('copy', {}, 'Copy') }}</span>\n </Button>\n <Button\n v-if=\"single?.type === 'file' && can('share') && schema.share\"\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n @click=\"controller.openShare(single)\"\n >\n <Share2Icon aria-hidden=\"true\" />\n <span class=\"max-md:sr-only\">{{ tt('share', {}, 'Share') }}</span>\n </Button>\n <Button type=\"button\" variant=\"ghost\" size=\"sm\" @click=\"controller.showDetails(single)\">\n <InfoIcon aria-hidden=\"true\" />\n <span class=\"max-md:sr-only\">{{ tt('details', {}, 'Details') }}</span>\n </Button>\n <Button\n v-if=\"can('delete')\"\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n class=\"text-destructive hover:text-destructive\"\n @click=\"controller.confirmDelete()\"\n >\n <Trash2Icon aria-hidden=\"true\" />\n <span class=\"max-md:sr-only\">{{ tt('delete', {}, 'Delete') }}</span>\n </Button>\n </template>\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon-sm\"\n class=\"ms-auto\"\n :aria-label=\"tt('clear_selection', {}, 'Clear selection')\"\n @click=\"controller.clearSelection()\"\n >\n <XIcon aria-hidden=\"true\" />\n </Button>\n </div>\n\n <div :class=\"cn('flex', picker ? 'h-[60vh]' : 'min-h-[28rem]')\">\n <aside\n class=\"hidden w-60 shrink-0 overflow-y-auto border-e p-2 md:block\"\n data-slot=\"file-manager-sidebar\"\n >\n <FileManagerTree\n :controller=\"controller\"\n :active=\"state.trash ? null : state.path\"\n :drop-handlers=\"canWrite ? dropHandlers : undefined\"\n @open=\"openTreeFolder\"\n />\n </aside>\n\n <div\n class=\"relative min-w-0 flex-1 overflow-y-auto\"\n data-slot=\"file-manager-content\"\n :data-drop-target=\"state.dropTarget === '' ? '' : undefined\"\n v-on=\"canWrite ? dropHandlers('') : {}\"\n >\n <p\n v-if=\"state.trash && state.retentionDays\"\n class=\"text-muted-foreground border-b px-4 py-2 text-xs\"\n >\n {{\n tt(\n 'trash_hint',\n { days: state.retentionDays },\n 'Items in the trash are deleted for good after :days days.',\n )\n }}\n </p>\n <p\n v-if=\"state.truncated || state.resultsTruncated\"\n class=\"text-muted-foreground border-b px-4 py-2 text-xs\"\n role=\"note\"\n >\n {{\n state.resultsTruncated\n ? tt('search_truncated', {}, 'Only the first results are shown. Refine your search.')\n : tt('truncated', {}, 'This folder has more items than can be shown here.')\n }}\n </p>\n <p :id=\"hintId\" class=\"sr-only\">\n {{\n tt('keyboard', {}, 'Arrow keys move between items, Shift selects a range, Enter opens.')\n }}\n </p>\n\n <div\n v-if=\"(state.loading || state.searching) && entries.length === 0\"\n class=\"flex justify-center py-16\"\n >\n <Spinner />\n <span class=\"sr-only\">{{ tt('loading', {}, 'Loading files') }}</span>\n </div>\n <div\n v-else-if=\"entries.length === 0\"\n class=\"text-muted-foreground flex flex-col items-center gap-2 px-6 py-16 text-center text-sm\"\n data-slot=\"file-manager-empty\"\n >\n <FolderOpenIcon class=\"size-10 opacity-40\" aria-hidden=\"true\" />\n <p class=\"text-foreground font-medium\">\n {{\n state.query\n ? tt('no_results', {}, 'Nothing matches your search')\n : state.trash\n ? tt('empty_trash_state', {}, 'The trash is empty')\n : tt('empty', {}, 'This folder is empty')\n }}\n </p>\n <p v-if=\"!state.query && canWrite && can('upload')\">\n {{ tt('empty_hint', {}, 'Drop files here or choose Upload.') }}\n </p>\n </div>\n <div\n v-else\n ref=\"listRef\"\n role=\"listbox\"\n :aria-multiselectable=\"!picker || schema.multiple === true\"\n :aria-labelledby=\"labelId\"\n :aria-describedby=\"hintId\"\n :aria-busy=\"state.loading || undefined\"\n data-slot=\"file-manager-items\"\n :class=\"\n cn(\n state.view === 'grid'\n ? 'grid grid-cols-2 gap-3 p-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6'\n : 'flex flex-col p-1',\n state.loading && 'opacity-60',\n )\n \"\n >\n <div\n v-if=\"state.view === 'list'\"\n class=\"text-muted-foreground hidden grid-cols-[1fr_7rem_10rem] gap-3 px-3 py-1.5 text-xs font-medium sm:grid\"\n aria-hidden=\"true\"\n >\n <span>{{ tt('name', {}, 'Name') }}</span>\n <span class=\"text-end\">{{ tt('size', {}, 'Size') }}</span>\n <span>{{ tt('modified', {}, 'Modified') }}</span>\n </div>\n <div\n v-for=\"entry in entries\"\n :key=\"entry.path\"\n :ref=\"setItem(entry.path)\"\n role=\"option\"\n :aria-label=\"entry.name\"\n :aria-selected=\"state.selection.includes(entry.path)\"\n :aria-disabled=\"\n (picker && entry.type === 'file' && entry.selectable === false) || undefined\n \"\n :tabindex=\"entry.path === focusPath ? 0 : -1\"\n :draggable=\"canWrite && (can('move') || can('copy'))\"\n data-slot=\"file-manager-item\"\n :data-type=\"entry.type\"\n :data-kind=\"entry.kind\"\n :data-selected=\"state.selection.includes(entry.path) ? '' : undefined\"\n :data-drop-target=\"state.dropTarget === entry.path ? '' : undefined\"\n :title=\"entry.name\"\n :class=\"\n cn(\n 'group focus-visible:ring-ring/50 relative cursor-default rounded-lg outline-none select-none focus-visible:ring-[3px]',\n state.view === 'grid'\n ? 'hover:bg-accent/50 flex flex-col gap-2 border p-2'\n : 'hover:bg-accent/50 grid grid-cols-[auto_1fr] items-center gap-3 px-3 py-2 sm:grid-cols-[auto_1fr_7rem_10rem]',\n state.selection.includes(entry.path) &&\n 'bg-primary/10 border-primary/40 hover:bg-primary/15',\n state.dropTarget === entry.path && 'ring-primary ring-2',\n picker && entry.type === 'file' && entry.selectable === false && 'opacity-50',\n )\n \"\n v-on=\"entry.type === 'folder' && canWrite ? dropHandlers(entry.path) : {}\"\n @pointerdown=\"pointer = $event.pointerType\"\n @click=\"onItemClick(entry, $event)\"\n @dblclick=\"onItemDoubleClick(entry)\"\n @keydown=\"onItemKeyDown\"\n @focus=\"controller.focus(entry.path)\"\n @dragstart=\"onDragStart(entry, $event)\"\n @dragend=\"controller.dragEnd()\"\n >\n <template v-if=\"state.view === 'grid'\">\n <div\n class=\"bg-muted/60 flex aspect-[4/3] items-center justify-center overflow-hidden rounded-md\"\n >\n <img\n v-if=\"thumbnailOf(entry)\"\n :src=\"thumbnailOf(entry) ?? undefined\"\n alt=\"\"\n loading=\"lazy\"\n :draggable=\"false\"\n class=\"size-full object-cover\"\n @error=\"markBroken(entry.path)\"\n />\n <FileManagerIcon v-else :entry=\"entry\" class=\"size-10\" />\n </div>\n <div class=\"min-w-0 px-0.5\">\n <p class=\"truncate text-sm font-medium\">{{ entry.name }}</p>\n <p class=\"text-muted-foreground truncate text-xs\">\n {{\n locationOf(entry) ??\n (entry.type === 'folder' ? tt('kinds.folder', {}, 'Folder') : sizeOf(entry))\n }}\n </p>\n </div>\n </template>\n <template v-else>\n <img\n v-if=\"thumbnailOf(entry)\"\n :src=\"thumbnailOf(entry) ?? undefined\"\n alt=\"\"\n loading=\"lazy\"\n :draggable=\"false\"\n class=\"size-8 rounded object-cover\"\n @error=\"markBroken(entry.path)\"\n />\n <FileManagerIcon v-else :entry=\"entry\" class=\"size-5\" />\n <div class=\"min-w-0\">\n <p class=\"truncate text-sm font-medium\">{{ entry.name }}</p>\n <p v-if=\"locationOf(entry)\" class=\"text-muted-foreground truncate text-xs\">\n {{ locationOf(entry) }}\n </p>\n </div>\n <span class=\"text-muted-foreground hidden text-end text-sm tabular-nums sm:block\">{{\n sizeOf(entry)\n }}</span>\n <span class=\"text-muted-foreground hidden truncate text-sm sm:block\">\n {{ entry.modified ? formatter.dateTime(entry.modified) : '' }}\n </span>\n </template>\n <CheckIcon\n v-if=\"state.selection.includes(entry.path)\"\n class=\"bg-primary text-primary-foreground absolute end-1.5 top-1.5 size-4 rounded-full p-0.5\"\n aria-hidden=\"true\"\n />\n </div>\n </div>\n\n <div\n v-if=\"state.dropTarget === ''\"\n class=\"border-primary bg-primary/5 text-primary pointer-events-none absolute inset-2 flex items-center justify-center rounded-lg border-2 border-dashed text-sm font-medium\"\n data-slot=\"file-manager-drop\"\n >\n {{ tt('drop_here', { folder: folderLabel }, 'Drop files to upload them to :folder') }}\n </div>\n </div>\n\n <aside\n v-if=\"state.detailsOpen\"\n class=\"hidden w-72 shrink-0 overflow-y-auto border-s md:block\"\n data-slot=\"file-manager-details\"\n >\n <FileManagerDetails :controller=\"controller\" />\n </aside>\n </div>\n\n <div\n v-if=\"state.trash && entries.length > 0 && can('delete')\"\n class=\"flex justify-end border-t p-3\"\n >\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"sm\"\n @click=\"controller.openDialog({ type: 'emptyTrash' })\"\n >\n <Trash2Icon aria-hidden=\"true\" />\n {{ tt('empty_trash', {}, 'Empty trash') }}\n </Button>\n </div>\n\n <div\n v-if=\"picker\"\n class=\"flex flex-wrap items-center justify-end gap-2 border-t p-3\"\n data-slot=\"file-manager-picker-footer\"\n >\n <span class=\"text-muted-foreground me-auto text-sm\">\n {{ selected.length > 0 ? tt('selected', { count: chosen.length }, ':count selected') : '' }}\n </span>\n <Button type=\"button\" variant=\"outline\" @click=\"onCancel?.()\">{{\n tt('cancel', {}, 'Cancel')\n }}</Button>\n <Button type=\"button\" :disabled=\"chosen.length === 0\" @click=\"onChoose?.(chosen)\">\n <CheckIcon aria-hidden=\"true\" />\n {{ tt('picker_choose', {}, 'Choose') }}\n </Button>\n </div>\n\n <Sheet :open=\"foldersOpen\" @update:open=\"foldersOpen = $event\">\n <SheetContent :side=\"direction === 'rtl' ? 'right' : 'left'\" class=\"p-4\">\n <SheetHeader class=\"p-0\">\n <SheetTitle>{{ tt('folders', {}, 'Folders') }}</SheetTitle>\n </SheetHeader>\n <FileManagerTree\n :controller=\"controller\"\n :active=\"state.trash ? null : state.path\"\n :drop-handlers=\"canWrite ? dropHandlers : undefined\"\n @open=\"openTreeFolder\"\n />\n </SheetContent>\n </Sheet>\n\n <Sheet\n :open=\"state.detailsOpen && mobile\"\n @update:open=\"(open) => !open && controller.closeDetails()\"\n >\n <SheetContent :side=\"direction === 'rtl' ? 'left' : 'right'\" class=\"p-0\">\n <SheetHeader>\n <SheetTitle>{{ tt('details', {}, 'Details') }}</SheetTitle>\n </SheetHeader>\n <FileManagerDetails :controller=\"controller\" hide-heading />\n </SheetContent>\n </Sheet>\n\n <section\n v-if=\"state.uploads.length > 0\"\n class=\"bg-popover fixed end-4 bottom-4 z-40 flex max-h-80 w-[min(22rem,calc(100vw-2rem))] flex-col overflow-hidden rounded-lg border shadow-lg\"\n :aria-label=\"tt('uploads', {}, 'Uploads')\"\n data-slot=\"file-manager-uploads\"\n >\n <header class=\"flex items-center gap-2 border-b px-3 py-2 text-sm font-medium\">\n <UploadIcon class=\"size-4\" aria-hidden=\"true\" />\n <span class=\"flex-1\" role=\"status\">\n {{\n tt(\n 'uploads_summary',\n { done: uploadsDone, total: state.uploads.length },\n ':done of :total uploaded',\n )\n }}\n </span>\n <Button\n v-if=\"uploadsFinished\"\n type=\"button\"\n variant=\"ghost\"\n size=\"icon-xs\"\n :aria-label=\"tt('dismiss', {}, 'Dismiss')\"\n @click=\"controller.clearUploads()\"\n >\n <XIcon aria-hidden=\"true\" />\n </Button>\n </header>\n <ul class=\"flex flex-col gap-2 overflow-y-auto p-3\">\n <li\n v-for=\"upload in state.uploads\"\n :key=\"upload.id\"\n class=\"flex flex-col gap-1 text-sm\"\n :data-status=\"upload.status\"\n >\n <div class=\"flex items-center gap-2\">\n <span class=\"min-w-0 flex-1 truncate\">{{ upload.name }}</span>\n <CheckIcon\n v-if=\"upload.status === 'done'\"\n class=\"size-4 text-emerald-600\"\n :aria-label=\"tt('upload_done', {}, 'Uploaded')\"\n />\n <Button\n v-else-if=\"upload.status === 'error'\"\n type=\"button\"\n variant=\"ghost\"\n size=\"icon-xs\"\n :aria-label=\"tt('dismiss', {}, 'Dismiss')\"\n @click=\"controller.dismissUpload(upload.id)\"\n >\n <XIcon aria-hidden=\"true\" />\n </Button>\n <span v-else class=\"text-muted-foreground text-xs tabular-nums\">{{\n formatter.percent(upload.progress)\n }}</span>\n </div>\n <p v-if=\"upload.status === 'error'\" class=\"text-destructive text-xs\" role=\"alert\">\n {{ upload.error ?? tt('upload_failed', {}, 'Failed') }}\n </p>\n <div\n v-else-if=\"upload.status !== 'done'\"\n role=\"progressbar\"\n :aria-valuemin=\"0\"\n :aria-valuemax=\"100\"\n :aria-valuenow=\"Math.round(upload.progress * 100)\"\n :aria-label=\"\n tt(\n 'upload_progress',\n { name: upload.name, percent: formatter.percent(upload.progress) },\n ':name, :percent uploaded',\n )\n \"\n class=\"bg-muted h-1.5 overflow-hidden rounded-full\"\n >\n <div\n class=\"bg-primary h-full rounded-full transition-[width]\"\n :style=\"{ width: `${upload.progress * 100}%` }\"\n />\n </div>\n </li>\n </ul>\n </section>\n\n <Dialog\n :open=\"dialog?.type === 'createFolder' || dialog?.type === 'rename'\"\n @update:open=\"onDialogOpen\"\n >\n <DialogContent data-slot=\"file-manager-dialog\">\n <form class=\"flex flex-col gap-4\" @submit.prevent=\"submitName\">\n <DialogHeader>\n <DialogTitle>\n {{\n dialog?.type === 'rename'\n ? tt('rename_heading', { name: dialog.entry.name }, 'Rename “:name”')\n : tt('new_folder', {}, 'New folder')\n }}\n </DialogTitle>\n </DialogHeader>\n <div class=\"flex flex-col gap-2\">\n <label :for=\"nameId\" class=\"text-sm font-medium\">\n {{\n dialog?.type === 'rename'\n ? tt('name', {}, 'Name')\n : tt('folder_name', {}, 'Folder name')\n }}\n </label>\n <Input\n :id=\"nameId\"\n v-model=\"name\"\n autofocus\n :aria-invalid=\"state.error ? true : undefined\"\n :aria-describedby=\"state.error ? errorId : undefined\"\n />\n <p v-if=\"state.error\" :id=\"errorId\" class=\"text-destructive text-sm\" role=\"alert\">\n {{ state.error }}\n </p>\n </div>\n <DialogFooter>\n <Button type=\"button\" variant=\"outline\" @click=\"close\">{{\n tt('cancel', {}, 'Cancel')\n }}</Button>\n <Button type=\"submit\" :disabled=\"state.busy || name.trim() === ''\">\n <Spinner v-if=\"state.busy\" />\n {{ dialog?.type === 'rename' ? tt('save', {}, 'Save') : tt('create', {}, 'Create') }}\n </Button>\n </DialogFooter>\n </form>\n </DialogContent>\n </Dialog>\n\n <Dialog :open=\"dialog?.type === 'move' || dialog?.type === 'copy'\" @update:open=\"onDialogOpen\">\n <DialogContent data-slot=\"file-manager-dialog\">\n <DialogHeader>\n <DialogTitle>\n {{\n dialog?.type === 'move' || dialog?.type === 'copy'\n ? choice(\n `file-manager.${dialog.type}_heading`,\n dialog.paths.length,\n {},\n dialog.type === 'move' ? 'Move :count items' : 'Copy :count items',\n )\n : ''\n }}\n </DialogTitle>\n <DialogDescription>{{ tt('destination', {}, 'Destination folder') }}</DialogDescription>\n </DialogHeader>\n <div class=\"max-h-72 overflow-y-auto rounded-md border p-2\">\n <FileManagerTree\n v-if=\"dialog?.type === 'move' || dialog?.type === 'copy'\"\n :controller=\"controller\"\n :active=\"dialog.destination\"\n @open=\"chooseDestination\"\n />\n </div>\n <p v-if=\"state.error\" class=\"text-destructive text-sm\" role=\"alert\">{{ state.error }}</p>\n <DialogFooter>\n <Button type=\"button\" variant=\"outline\" @click=\"close\">{{\n tt('cancel', {}, 'Cancel')\n }}</Button>\n <Button type=\"button\" :disabled=\"state.busy\" @click=\"submitTransfer\">\n <Spinner v-if=\"state.busy\" />\n {{\n dialog?.type === 'copy'\n ? tt('copy_here', {}, 'Copy here')\n : tt('move_here', {}, 'Move here')\n }}\n </Button>\n </DialogFooter>\n </DialogContent>\n </Dialog>\n\n <Dialog\n :open=\"dialog?.type === 'delete' || dialog?.type === 'emptyTrash'\"\n @update:open=\"onDialogOpen\"\n >\n <DialogContent data-slot=\"file-manager-dialog\">\n <DialogHeader>\n <DialogTitle>\n {{\n dialog?.type === 'delete'\n ? choice(\n 'file-manager.delete_heading',\n dialog.paths.length,\n {},\n 'Delete :count items?',\n )\n : tt('empty_trash_heading', {}, 'Empty the trash?')\n }}\n </DialogTitle>\n <DialogDescription>\n {{\n dialog?.type === 'delete' && !dialog.permanent\n ? tt(\n 'delete_description_trash',\n {},\n 'They go to the trash, where you can restore them.',\n )\n : dialog?.type === 'emptyTrash'\n ? tt(\n 'empty_trash_description',\n {},\n 'Everything in the trash is deleted for good.',\n )\n : tt(\n 'delete_description',\n {},\n 'They are deleted for good. This cannot be undone.',\n )\n }}\n </DialogDescription>\n </DialogHeader>\n <p v-if=\"state.error\" class=\"text-destructive text-sm\" role=\"alert\">{{ state.error }}</p>\n <DialogFooter>\n <Button type=\"button\" variant=\"outline\" @click=\"close\">{{\n tt('cancel', {}, 'Cancel')\n }}</Button>\n <Button type=\"button\" variant=\"destructive\" :disabled=\"state.busy\" @click=\"submitDelete\">\n <Spinner v-if=\"state.busy\" />\n {{\n dialog?.type === 'emptyTrash'\n ? tt('empty_trash', {}, 'Empty trash')\n : dialog?.type === 'delete' && dialog.permanent\n ? tt('delete_permanently', {}, 'Delete permanently')\n : tt('delete', {}, 'Delete')\n }}\n </Button>\n </DialogFooter>\n </DialogContent>\n </Dialog>\n\n <Dialog :open=\"dialog?.type === 'share'\" @update:open=\"onDialogOpen\">\n <DialogContent data-slot=\"file-manager-dialog\">\n <template v-if=\"dialog?.type === 'share'\">\n <DialogHeader>\n <DialogTitle>{{\n tt('share_heading', { name: dialog.entry.name }, 'Share “:name”')\n }}</DialogTitle>\n <DialogDescription>{{\n tt('share_hint', {}, 'Anyone with the link can open this file, without signing in.')\n }}</DialogDescription>\n </DialogHeader>\n <div class=\"flex flex-wrap items-end gap-2\">\n <label class=\"flex min-w-40 flex-1 flex-col gap-1 text-sm\">\n <span class=\"font-medium\">{{ tt('share_expiry', {}, 'Link expires after') }}</span>\n <select v-model=\"expiry\" :class=\"SELECT\">\n <option\n v-for=\"minutes in share?.expiries ?? []\"\n :key=\"minutes\"\n :value=\"String(minutes)\"\n >\n {{ expiryLabel(minutes) }}\n </option>\n <option v-if=\"share?.publicLinks\" value=\"never\">\n {{ tt('expiry_never', {}, 'Never') }}\n </option>\n </select>\n </label>\n <Button\n type=\"button\"\n :disabled=\"state.busy\"\n @click=\"controller.createLink(expiry === 'never' ? null : Number(expiry))\"\n >\n <Spinner v-if=\"state.busy\" />\n <LinkIcon v-else aria-hidden=\"true\" />\n {{ tt('create_link', {}, 'Create link') }}\n </Button>\n </div>\n <p v-if=\"state.error\" class=\"text-destructive text-sm\" role=\"alert\">{{ state.error }}</p>\n <div v-if=\"dialog.url\" class=\"flex flex-col gap-1\">\n <span class=\"text-sm font-medium\">{{ tt('link', {}, 'Shareable link') }}</span>\n <div class=\"flex gap-2\">\n <Input\n readonly\n :model-value=\"dialog.url\"\n dir=\"ltr\"\n :aria-label=\"tt('link', {}, 'Shareable link')\"\n @focus=\"($event.target as HTMLInputElement).select()\"\n />\n <Button type=\"button\" variant=\"outline\" @click=\"copy(dialog.url ?? '')\">\n <CheckIcon v-if=\"copied\" aria-hidden=\"true\" />\n <CopyIcon v-else aria-hidden=\"true\" />\n {{\n copied ? tt('link_copied', {}, 'Link copied') : tt('copy_link', {}, 'Copy link')\n }}\n </Button>\n </div>\n <span class=\"text-muted-foreground text-xs\">\n {{\n dialog.expiresAt\n ? tt(\n 'link_expires',\n { date: formatter.dateTime(dialog.expiresAt) },\n 'Expires :date',\n )\n : tt('link_never_expires', {}, 'Never expires')\n }}\n </span>\n </div>\n <div v-if=\"share?.publicLinks\" class=\"flex flex-col gap-2\" data-slot=\"file-manager-links\">\n <h4 class=\"text-sm font-medium\">{{ tt('links', {}, 'Links') }}</h4>\n <Spinner v-if=\"dialog.links === null\" />\n <p v-else-if=\"dialog.links.length === 0\" class=\"text-muted-foreground text-sm\">\n {{ tt('no_links', {}, 'No links yet') }}\n </p>\n <ul v-else class=\"flex max-h-48 flex-col divide-y overflow-y-auto rounded-md border\">\n <li\n v-for=\"link in dialog.links\"\n :key=\"link.id\"\n class=\"flex items-center gap-2 px-3 py-2 text-sm\"\n >\n <div class=\"min-w-0 flex-1\">\n <p\n :class=\"cn('truncate', link.expired && 'text-muted-foreground line-through')\"\n dir=\"ltr\"\n >\n {{ link.url }}\n </p>\n <p class=\"text-muted-foreground text-xs\">\n {{\n link.expired\n ? tt('link_expired', {}, 'Expired')\n : link.expiresAt\n ? tt(\n 'link_expires',\n { date: formatter.dateTime(link.expiresAt) },\n 'Expires :date',\n )\n : tt('link_never_expires', {}, 'Never expires')\n }}\n ·\n {{\n choice('file-manager.link_downloads', link.downloads, {}, ':count downloads')\n }}\n </p>\n </div>\n <Button\n v-if=\"link.url && !link.expired\"\n type=\"button\"\n variant=\"ghost\"\n size=\"icon-xs\"\n :aria-label=\"tt('copy_link', {}, 'Copy link')\"\n @click=\"copy(link.url ?? '')\"\n >\n <CopyIcon aria-hidden=\"true\" />\n </Button>\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n class=\"text-destructive\"\n @click=\"controller.revokeLink(link.id)\"\n >\n {{ tt('revoke', {}, 'Revoke') }}\n </Button>\n </li>\n </ul>\n </div>\n <DialogFooter>\n <Button type=\"button\" variant=\"outline\" @click=\"close\">{{\n tt('close', {}, 'Close')\n }}</Button>\n </DialogFooter>\n </template>\n </DialogContent>\n </Dialog>\n\n <Dialog :open=\"preview !== null\" @update:open=\"(open) => !open && controller.closePreview()\">\n <DialogContent\n class=\"flex max-h-[90vh] flex-col sm:max-w-4xl\"\n data-slot=\"file-manager-preview\"\n @keydown=\"onPreviewKeyDown\"\n >\n <DialogHeader>\n <DialogTitle class=\"truncate pe-8\">{{ preview?.entry.name }}</DialogTitle>\n </DialogHeader>\n <div\n class=\"bg-muted/40 flex min-h-64 flex-1 items-center justify-center overflow-auto rounded-md\"\n >\n <Spinner v-if=\"!previewData\" />\n <div\n v-else-if=\"!previewData.previewable\"\n class=\"text-muted-foreground flex flex-col items-center gap-2 p-8 text-center text-sm\"\n >\n <FileManagerIcon v-if=\"preview\" :entry=\"preview.entry\" class=\"size-12\" />\n <p>{{ tt('preview_unavailable', {}, 'There is no preview for this type of file.') }}</p>\n </div>\n <img\n v-else-if=\"previewData.kind === 'image'\"\n :src=\"previewData.url\"\n :alt=\"preview?.entry.name ?? ''\"\n class=\"max-h-[70vh] max-w-full object-contain\"\n />\n <iframe\n v-else-if=\"previewData.kind === 'pdf'\"\n :src=\"previewData.url\"\n :title=\"preview?.entry.name ?? ''\"\n class=\"h-[70vh] w-full rounded-md bg-white\"\n />\n <video\n v-else-if=\"previewData.kind === 'video'\"\n :src=\"previewData.url\"\n controls\n class=\"max-h-[70vh] max-w-full\"\n >\n <track kind=\"captions\" />\n </video>\n <audio\n v-else-if=\"previewData.kind === 'audio'\"\n :src=\"previewData.url\"\n controls\n class=\"w-full max-w-md\"\n />\n <pre\n v-else\n class=\"h-full max-h-[70vh] w-full overflow-auto p-4 font-mono text-xs whitespace-pre-wrap\"\n dir=\"auto\"\n tabindex=\"0\"\n >{{ previewData.text }}</pre>\n </div>\n <p v-if=\"previewData?.truncated\" class=\"text-muted-foreground text-xs\">\n {{ tt('preview_truncated', {}, 'Only the beginning of this file is shown.') }}\n </p>\n <DialogFooter class=\"flex-row flex-wrap items-center sm:justify-between\">\n <div class=\"flex gap-1\">\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"icon-sm\"\n :disabled=\"!previous\"\n :aria-label=\"tt('previous', {}, 'Previous file')\"\n @click=\"previous && controller.openPreview(previous)\"\n >\n <ChevronLeftIcon class=\"rtl:rotate-180\" aria-hidden=\"true\" />\n </Button>\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"icon-sm\"\n :disabled=\"!next\"\n :aria-label=\"tt('next', {}, 'Next file')\"\n @click=\"next && controller.openPreview(next)\"\n >\n <ChevronRightIcon class=\"rtl:rotate-180\" aria-hidden=\"true\" />\n </Button>\n </div>\n <div class=\"flex gap-2\">\n <Button\n v-if=\"previewData?.editable && previewData.url\"\n type=\"button\"\n variant=\"outline\"\n size=\"sm\"\n @click=\"editImage\"\n >\n <SquarePenIcon aria-hidden=\"true\" />\n {{ tt('edit_image', {}, 'Edit image') }}\n </Button>\n <Button v-if=\"previewData?.downloadUrl\" type=\"button\" size=\"sm\" as-child>\n <a :href=\"previewData.downloadUrl\">\n <DownloadIcon aria-hidden=\"true\" />\n {{ tt('download', {}, 'Download') }}\n </a>\n </Button>\n </div>\n </DialogFooter>\n </DialogContent>\n </Dialog>\n <ImageEditorDialog :request=\"editing\" :options=\"IMAGE_EDITOR\" />\n </Card>\n</template>\n"],"mappings":""}
|
|
@@ -316,7 +316,7 @@ var it = ["id"], at = {
|
|
|
316
316
|
}, w(e.label), 9, ut))), 128))], 40, lt)])) : m("", !0),
|
|
317
317
|
g("nav", {
|
|
318
318
|
"aria-label": P("breadcrumb", {}, "Folder path"),
|
|
319
|
-
class: "min-w-0 flex-
|
|
319
|
+
class: "min-w-0 flex-[1_1_12rem]",
|
|
320
320
|
"data-slot": "file-manager-breadcrumb"
|
|
321
321
|
}, [g("ol", ft, [g("li", pt, [g("button", {
|
|
322
322
|
type: "button",
|