inicontent 1.0.0
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/.github/pull_request.yml +18 -0
- package/CONTRIBUTING.md +67 -0
- package/LICENSE +21 -0
- package/README.md +142 -0
- package/app/app.vue +106 -0
- package/app/assets/main.css +974 -0
- package/app/components/Api/EndpointCard.vue +169 -0
- package/app/components/Api/SchemaDocsTable.vue +208 -0
- package/app/components/Asset/Card.vue +1363 -0
- package/app/components/Asset/Grid.vue +295 -0
- package/app/components/Asset/Icon.vue +38 -0
- package/app/components/Asset/Preview.vue +486 -0
- package/app/components/Asset/Scanner.vue +700 -0
- package/app/components/Asset/Thumb.vue +708 -0
- package/app/components/Auth/Reset.vue +99 -0
- package/app/components/Auth/index.vue +346 -0
- package/app/components/ChatInterface.vue +2326 -0
- package/app/components/ChatPagePreview.vue +34 -0
- package/app/components/Column/Array.vue +29 -0
- package/app/components/Column/Asset.vue +21 -0
- package/app/components/Column/Boolean.vue +12 -0
- package/app/components/Column/Color.vue +9 -0
- package/app/components/Column/Date.vue +57 -0
- package/app/components/Column/Edit.vue +215 -0
- package/app/components/Column/Email.vue +9 -0
- package/app/components/Column/Html.vue +27 -0
- package/app/components/Column/Id.vue +20 -0
- package/app/components/Column/Locale.vue +32 -0
- package/app/components/Column/Object.vue +18 -0
- package/app/components/Column/Password.vue +7 -0
- package/app/components/Column/Role.vue +15 -0
- package/app/components/Column/S.vue +14 -0
- package/app/components/Column/Table/Single.vue +49 -0
- package/app/components/Column/Table/index.vue +42 -0
- package/app/components/Column/Tags.vue +31 -0
- package/app/components/Column/Text.vue +9 -0
- package/app/components/Column/Url.vue +9 -0
- package/app/components/Column/index.vue +41 -0
- package/app/components/Dashboard/Editor.vue +221 -0
- package/app/components/Dashboard/Grid.vue +270 -0
- package/app/components/Dashboard/View.vue +131 -0
- package/app/components/Dashboard/Widget/BarChart.vue +73 -0
- package/app/components/Dashboard/Widget/Counter.vue +51 -0
- package/app/components/Dashboard/Widget/LineChart.vue +76 -0
- package/app/components/Dashboard/Widget/PieChart.vue +75 -0
- package/app/components/Dashboard/Widget/Table.vue +94 -0
- package/app/components/Dashboard/WidgetEditor.vue +274 -0
- package/app/components/Data/Array.vue +102 -0
- package/app/components/Data/Html.vue +9 -0
- package/app/components/Data/Icon.vue +9 -0
- package/app/components/Data/Object.vue +10 -0
- package/app/components/Data/S.vue +16 -0
- package/app/components/Data/Table.vue +53 -0
- package/app/components/Data/Value.vue +34 -0
- package/app/components/Data/index.vue +14 -0
- package/app/components/Field/Array.vue +324 -0
- package/app/components/Field/Asset/Actions.vue +97 -0
- package/app/components/Field/Asset/index.vue +639 -0
- package/app/components/Field/Boolean.vue +26 -0
- package/app/components/Field/Checkbox.vue +48 -0
- package/app/components/Field/Color.vue +27 -0
- package/app/components/Field/Date.vue +30 -0
- package/app/components/Field/Email.vue +51 -0
- package/app/components/Field/EmailTemplate.vue +420 -0
- package/app/components/Field/Html/ColorPicker.vue +127 -0
- package/app/components/Field/Html/Editor.vue +544 -0
- package/app/components/Field/Html/index.vue +25 -0
- package/app/components/Field/Icon.vue +114 -0
- package/app/components/Field/JSON.vue +113 -0
- package/app/components/Field/Mention.vue +36 -0
- package/app/components/Field/Number.vue +27 -0
- package/app/components/Field/Object.vue +92 -0
- package/app/components/Field/Password.vue +55 -0
- package/app/components/Field/Radio.vue +47 -0
- package/app/components/Field/S.vue +44 -0
- package/app/components/Field/Select.vue +49 -0
- package/app/components/Field/Table.vue +348 -0
- package/app/components/Field/Tags.vue +113 -0
- package/app/components/Field/Text.vue +31 -0
- package/app/components/Field/Textarea.vue +33 -0
- package/app/components/Field/Url.vue +40 -0
- package/app/components/Field/Wrapper.vue +128 -0
- package/app/components/Field/index.vue +68 -0
- package/app/components/FloatingChatbot.vue +73 -0
- package/app/components/Form/Card.vue +151 -0
- package/app/components/Form/Drawer.vue +195 -0
- package/app/components/Form/index.vue +621 -0
- package/app/components/Header.vue +328 -0
- package/app/components/Offline/ConflictModal.vue +200 -0
- package/app/components/Offline/SyncStatus.vue +249 -0
- package/app/components/PlatformFeatureLanding.vue +65 -0
- package/app/components/Table/Backups.vue +638 -0
- package/app/components/Table/Flows.vue +814 -0
- package/app/components/Table/Grid.vue +296 -0
- package/app/components/Table/Icon.vue +36 -0
- package/app/components/Table/Logs.vue +181 -0
- package/app/components/Table/Schedules.vue +722 -0
- package/app/components/Table/Search/Button.vue +256 -0
- package/app/components/Table/Search/Items.vue +286 -0
- package/app/components/Table/Search/index.vue +87 -0
- package/app/components/Table/Settings/Schema.vue +801 -0
- package/app/components/Table/Settings/index.vue +631 -0
- package/app/components/Table/TranslateDrawer.vue +526 -0
- package/app/components/Table/Views/Kanban.vue +289 -0
- package/app/components/Table/Views/Table.vue +1273 -0
- package/app/components/Table/index.vue +890 -0
- package/app/composables/Translation/define.ts +41 -0
- package/app/composables/Translation/fetch.ts +107 -0
- package/app/composables/Translation/languages.ts +1 -0
- package/app/composables/Translation/loadCoreTranslations.ts +38 -0
- package/app/composables/Translation/translate.ts +250 -0
- package/app/composables/databaseCookies.ts +79 -0
- package/app/composables/fieldValidator.ts +72 -0
- package/app/composables/fieldsList.ts +301 -0
- package/app/composables/formatDatabase.ts +101 -0
- package/app/composables/index.ts +444 -0
- package/app/composables/openDrawer.ts +118 -0
- package/app/composables/optimizeFile.ts +287 -0
- package/app/composables/renderLabel.ts +88 -0
- package/app/composables/schemaClipboard.ts +95 -0
- package/app/composables/search.ts +353 -0
- package/app/composables/setThemeConfig.ts +48 -0
- package/app/composables/translationValue.ts +169 -0
- package/app/composables/useAssetPreview.ts +84 -0
- package/app/composables/useAssetUploader.ts +56 -0
- package/app/composables/useDashboardData.ts +310 -0
- package/app/composables/useDocumentThumbnail.ts +351 -0
- package/app/composables/useEcharts.ts +33 -0
- package/app/composables/useLocalDatabaseConfig.ts +51 -0
- package/app/composables/useOcr.ts +300 -0
- package/app/composables/useOfflineFetch.ts +194 -0
- package/app/composables/useOfflineItem.ts +107 -0
- package/app/composables/useOfflineQueue.ts +302 -0
- package/app/composables/useOfflineSync.ts +235 -0
- package/app/composables/usePasskeyAuth.ts +156 -0
- package/app/composables/usePdfCompressor.ts +180 -0
- package/app/composables/usePlatformRedirect.ts +72 -0
- package/app/composables/useRealtimeSync.ts +233 -0
- package/app/composables/useReferencedItems.ts +180 -0
- package/app/composables/useScanner.ts +664 -0
- package/app/composables/useSubscription.ts +650 -0
- package/app/composables/useThumbnailCache.ts +136 -0
- package/app/composables/useVideoCompressor.ts +194 -0
- package/app/error.vue +112 -0
- package/app/layouts/api.vue +188 -0
- package/app/layouts/dashboard.vue +91 -0
- package/app/layouts/default.vue +228 -0
- package/app/layouts/table.vue +471 -0
- package/app/locales/ar.ts +996 -0
- package/app/locales/en.ts +655 -0
- package/app/locales/es.ts +996 -0
- package/app/locales/fr.ts +997 -0
- package/app/middleware/dashboard.ts +33 -0
- package/app/middleware/database.ts +83 -0
- package/app/middleware/global.ts +1 -0
- package/app/middleware/sid-ingest.global.ts +29 -0
- package/app/middleware/table.ts +54 -0
- package/app/middleware/user.ts +26 -0
- package/app/pages/[[database]]/admin/api/auth/index.vue +306 -0
- package/app/pages/[[database]]/admin/api/index.vue +18 -0
- package/app/pages/[[database]]/admin/api/tables/[table]/index.vue +497 -0
- package/app/pages/[[database]]/admin/api/tables/index.vue +87 -0
- package/app/pages/[[database]]/admin/billing/index.vue +16 -0
- package/app/pages/[[database]]/admin/dashboards/[id]/edit.vue +66 -0
- package/app/pages/[[database]]/admin/dashboards/[id]/index.vue +117 -0
- package/app/pages/[[database]]/admin/dashboards/index.vue +20 -0
- package/app/pages/[[database]]/admin/index.vue +28 -0
- package/app/pages/[[database]]/admin/settings.vue +526 -0
- package/app/pages/[[database]]/admin/tables/[table]/[id]/edit.vue +143 -0
- package/app/pages/[[database]]/admin/tables/[table]/[id]/index.vue +146 -0
- package/app/pages/[[database]]/admin/tables/[table]/flows.vue +10 -0
- package/app/pages/[[database]]/admin/tables/[table]/index.vue +10 -0
- package/app/pages/[[database]]/admin/tables/[table]/new.vue +12 -0
- package/app/pages/[[database]]/admin/tables/[table]/schedules.vue +10 -0
- package/app/pages/[[database]]/admin/tables/[table]/settings.vue +30 -0
- package/app/pages/[[database]]/admin/tables/assets/[...path].vue +24 -0
- package/app/pages/[[database]]/admin/tables/assets/flows.vue +10 -0
- package/app/pages/[[database]]/admin/tables/backups/index.vue +10 -0
- package/app/pages/[[database]]/admin/tables/blocks/index.vue +16 -0
- package/app/pages/[[database]]/admin/tables/index.vue +20 -0
- package/app/pages/[[database]]/admin/tables/pages/index.vue +16 -0
- package/app/pages/[[database]]/admin/tables/templates/index.vue +16 -0
- package/app/pages/[[database]]/api/index.vue +120 -0
- package/app/pages/[[database]]/auth/index.vue +12 -0
- package/app/pages/[[database]]/auth/reset.vue +10 -0
- package/app/pages/[[database]]/index.vue +10 -0
- package/app/plugins/offline-warmup.client.ts +53 -0
- package/app/plugins/template-globals.ts +9 -0
- package/app/spa-loading-template.html +93 -0
- package/biome.json +53 -0
- package/index.d.ts +380 -0
- package/modules/naiveui.ts +41 -0
- package/nuxt.config.ts +269 -0
- package/package.json +56 -0
- package/pnpm-workspace.yaml +5 -0
- package/public/apple-touch-icon.png +0 -0
- package/public/favicon-16x16.png +0 -0
- package/public/favicon-32x32.png +0 -0
- package/public/favicon.ico +0 -0
- package/public/pwa-192x192.png +0 -0
- package/public/pwa-512x512.png +0 -0
- package/public/pwa-maskable-192x192.png +0 -0
- package/public/pwa-maskable-512x512.png +0 -0
- package/public/vendor/opencv.js +48 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,1363 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div style="position: relative;" @dragenter.prevent="onDragEnter" @dragover.prevent="onDragOver"
|
|
3
|
+
@drop.prevent="onDrop" @dragleave="onDragLeave">
|
|
4
|
+
<input ref="folderInputRef" type="file" multiple directory="" webkitdirectory="" mozdirectory=""
|
|
5
|
+
style="display: none;" @change="onFolderInputChange" />
|
|
6
|
+
|
|
7
|
+
<LazyAssetScanner v-model:show="showScanner" :allowOCR="false"
|
|
8
|
+
@scanned="(blob) => uploadScannedDocument(blob)" />
|
|
9
|
+
<NModal v-model:show="showFolderInput" preset="card" :title="t('createFolder')"
|
|
10
|
+
style="width: min(420px, 95vw);"
|
|
11
|
+
@update:show="(show) => { if (!show) cancelFolderCreation(); }">
|
|
12
|
+
<NInput ref="folderInputElRef" v-model:value="folder" size="small"
|
|
13
|
+
@keydown="({ key }: KeyboardEvent) => { if (key === 'Enter') createFolder(); }"
|
|
14
|
+
:placeholder="t('folderName')">
|
|
15
|
+
<template #suffix>
|
|
16
|
+
<NIcon>
|
|
17
|
+
<Icon name="tabler:letter-case" />
|
|
18
|
+
</NIcon>
|
|
19
|
+
</template>
|
|
20
|
+
</NInput>
|
|
21
|
+
<template #footer>
|
|
22
|
+
<NFlex justify="end">
|
|
23
|
+
<NButton secondary @click="cancelFolderCreation">{{ t("cancel") }}</NButton>
|
|
24
|
+
<NButton type="primary" @click="createFolder">{{ t("create") }}</NButton>
|
|
25
|
+
</NFlex>
|
|
26
|
+
</template>
|
|
27
|
+
</NModal>
|
|
28
|
+
<Transition name="drop-fade">
|
|
29
|
+
<div v-if="isDragOver"
|
|
30
|
+
style="position: absolute; inset: 0; z-index: 100; background: rgba(24, 160, 88, 0.1); border: 2px dashed #18a058; border-radius: 3px; display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 12px; pointer-events: none;">
|
|
31
|
+
<NIcon size="56" color="#18a058">
|
|
32
|
+
<Icon name="tabler:cloud-upload" />
|
|
33
|
+
</NIcon>
|
|
34
|
+
<span style="color: #18a058; font-size: 16px; font-weight: 500;">{{ t("dropFilesHere") }}</span>
|
|
35
|
+
</div>
|
|
36
|
+
</Transition>
|
|
37
|
+
<NCard :header-style="{ paddingRight: 0, paddingLeft: 0 }" embedded
|
|
38
|
+
id="assetsContainer" style="height: fit-content;background-color: transparent;" :content-style="{ padding: 0 }" :bordered="false">
|
|
39
|
+
<template #header>
|
|
40
|
+
<span v-if="isAssetRoute">{{ t("assets") }}</span>
|
|
41
|
+
<NBreadcrumb v-else>
|
|
42
|
+
<NBreadcrumbItem @click="currentPath = ''">
|
|
43
|
+
{{ t("assets") }}
|
|
44
|
+
</NBreadcrumbItem>
|
|
45
|
+
<NBreadcrumbItem v-for="(singlePath, index) in currentPath?.split('/').slice(1)"
|
|
46
|
+
@click="currentPath = `${currentPath.split('/').slice(0, index + 2).join('/')}`">
|
|
47
|
+
{{ singlePath }}
|
|
48
|
+
</NBreadcrumbItem>
|
|
49
|
+
</NBreadcrumb>
|
|
50
|
+
</template>
|
|
51
|
+
<template #header-extra>
|
|
52
|
+
<NFlex wrap>
|
|
53
|
+
<NButtonGroup v-if="selectedAssetIds.length" round>
|
|
54
|
+
<NTooltip>
|
|
55
|
+
<template #trigger>
|
|
56
|
+
<NButton size="small" round secondary type="info"
|
|
57
|
+
:title="allSelectableSelected ? t('clearAll') : t('selectAll')"
|
|
58
|
+
@click="toggleSelectAllFromGroup">
|
|
59
|
+
<template #icon>
|
|
60
|
+
<NIcon>
|
|
61
|
+
<Icon
|
|
62
|
+
:name="allSelectableSelected ? 'tabler:square-minus' : 'tabler:square-check'" />
|
|
63
|
+
</NIcon>
|
|
64
|
+
</template>
|
|
65
|
+
</NButton>
|
|
66
|
+
</template>
|
|
67
|
+
{{ allSelectableSelected ? t("clearAll") : t("selectAll") }}
|
|
68
|
+
</NTooltip>
|
|
69
|
+
<NTooltip>
|
|
70
|
+
<template #trigger>
|
|
71
|
+
<NButton size="small" round secondary type="warning" :loading="bulkDownloadLoading"
|
|
72
|
+
:title="`${t('download')} ZIP${bulkDownloadLoading ? ` ${bulkZipProgress}%` : ''}`"
|
|
73
|
+
@click="downloadSelectedAsZip">
|
|
74
|
+
<template #icon>
|
|
75
|
+
<NIcon>
|
|
76
|
+
<Icon name="tabler:file-zip" />
|
|
77
|
+
</NIcon>
|
|
78
|
+
</template>
|
|
79
|
+
</NButton>
|
|
80
|
+
</template>
|
|
81
|
+
{{ bulkDownloadLoading ? `${t("download")} ZIP ${bulkZipProgress}%` : `${t("download")} ZIP`
|
|
82
|
+
}}
|
|
83
|
+
</NTooltip>
|
|
84
|
+
<NPopconfirm :show-icon="false" @positive-click="deleteSelectedAssets">
|
|
85
|
+
<template #trigger>
|
|
86
|
+
<NTooltip>
|
|
87
|
+
<template #trigger>
|
|
88
|
+
<NButton size="small" round secondary type="error"
|
|
89
|
+
:loading="bulkDeleteLoading" :title="t('delete')">
|
|
90
|
+
<template #icon>
|
|
91
|
+
<NIcon>
|
|
92
|
+
<Icon name="tabler:trash" />
|
|
93
|
+
</NIcon>
|
|
94
|
+
</template>
|
|
95
|
+
</NButton>
|
|
96
|
+
</template>
|
|
97
|
+
{{ t("delete") }}
|
|
98
|
+
</NTooltip>
|
|
99
|
+
</template>
|
|
100
|
+
{{ t("deleteSelectedAssetsConfirm", { count: selectedAssetIds.length }) }}
|
|
101
|
+
</NPopconfirm>
|
|
102
|
+
</NButtonGroup>
|
|
103
|
+
<NButtonGroup>
|
|
104
|
+
<LazyTableSearchButton v-model:string="searchString" v-model:array="searchArray" :schema
|
|
105
|
+
size="small" />
|
|
106
|
+
<NUpload v-if="table?.allowedMethods?.includes('c')" ref="uploadRef" multiple abstract
|
|
107
|
+
:action="`${config.public.apiBase}${database.slug}/assets${currentPath}?${database.slug}_sid=${sessionID}`"
|
|
108
|
+
@update:file-list="onUpdateFileList" :custom-request @remove="onRemoveUpload">
|
|
109
|
+
<NPopover trigger="manual" placement="top-end" :show="UploadProgress > 0" scrollable
|
|
110
|
+
style="max-height: 160px">
|
|
111
|
+
<template #trigger>
|
|
112
|
+
<NDropdown placement="bottom-end" trigger="click" size="small"
|
|
113
|
+
:options="uploadDropdownOptions" @select="onUploadDropdownSelect">
|
|
114
|
+
<NButton secondary type="primary" round size="small" :title="t('uploadFiles')">
|
|
115
|
+
<template #icon>
|
|
116
|
+
<NProgress v-if="compressionIndicator" type="circle" status="warning"
|
|
117
|
+
:percentage="compressionIndicator" :stroke-width="10">
|
|
118
|
+
<NTooltip v-model:show="showSkipCompressionTooltip" placement="top">
|
|
119
|
+
<template #trigger>
|
|
120
|
+
<Icon @click.stop="skipCompression" :size="10"
|
|
121
|
+
name="tabler:player-track-next-filled" />
|
|
122
|
+
</template>
|
|
123
|
+
{{ t("skipCompression") }}
|
|
124
|
+
</NTooltip>
|
|
125
|
+
</NProgress>
|
|
126
|
+
<NIcon v-else-if="!UploadProgress">
|
|
127
|
+
<Icon name="tabler:upload" />
|
|
128
|
+
</NIcon>
|
|
129
|
+
<NIcon v-else-if="UploadProgress === 10000">
|
|
130
|
+
<Icon name="tabler:check" />
|
|
131
|
+
</NIcon>
|
|
132
|
+
<NSpin v-else-if="UploadProgress === 1000 || UploadProgress === 1001"
|
|
133
|
+
:size="16" />
|
|
134
|
+
<NProgress v-else type="circle" :show-indicator="false"
|
|
135
|
+
:status="UploadProgress === 100 ? 'success' : 'warning'"
|
|
136
|
+
:percentage="UploadProgress" :stroke-width="20" />
|
|
137
|
+
</template>
|
|
138
|
+
</NButton>
|
|
139
|
+
</NDropdown>
|
|
140
|
+
</template>
|
|
141
|
+
<NFlex v-if="UploadProgress === 1001" vertical
|
|
142
|
+
style="gap: 6px; padding: 2px 0; min-width: 160px;">
|
|
143
|
+
<NText style="font-size: 12px;">
|
|
144
|
+
{{ folderUploadProgress.total > 0
|
|
145
|
+
? `${folderUploadProgress.current} / ${folderUploadProgress.total}`
|
|
146
|
+
: folderUploadProgress.current }}
|
|
147
|
+
</NText>
|
|
148
|
+
<NProgress v-if="folderUploadProgress.total > 0" type="line"
|
|
149
|
+
:percentage="Math.round((folderUploadProgress.current / folderUploadProgress.total) * 100)"
|
|
150
|
+
:show-indicator="false" />
|
|
151
|
+
</NFlex>
|
|
152
|
+
<NUploadFileList v-else />
|
|
153
|
+
</NPopover>
|
|
154
|
+
</NUpload>
|
|
155
|
+
</NButtonGroup>
|
|
156
|
+
</NFlex>
|
|
157
|
+
</template>
|
|
158
|
+
<NCard>
|
|
159
|
+
<NFlex vertical align="center">
|
|
160
|
+
<AssetGrid v-model="assets" :isAssetRoute :table :targetID="!targetID ? 'assetsContainer' : targetID"
|
|
161
|
+
:selected-asset-ids="selectedAssetIds" v-model:path="currentPath">
|
|
162
|
+
<template v-slot="slotProps">
|
|
163
|
+
<NCheckbox :checked="selectedAssetIdsSet.has(slotProps.asset.id)"
|
|
164
|
+
@update:checked="() => toggleAssetSelection(slotProps.asset.id)" />
|
|
165
|
+
<slot v-bind="slotProps"></slot>
|
|
166
|
+
</template>
|
|
167
|
+
</AssetGrid>
|
|
168
|
+
<NPagination v-if="pagination.itemCount && pagination.pageCount > 1" :simple="!!$device.isMobile"
|
|
169
|
+
:page-sizes="getResponsivePageSizes()" :show-size-picker="showSizePicker" style="margin-top: 25px;"
|
|
170
|
+
@update:page-size="onUpdatePageSize" @update:page="onUpdatePage" :page="pagination.page"
|
|
171
|
+
:page-size="pagination.pageSize" :item-count="pagination.itemCount" />
|
|
172
|
+
</NFlex>
|
|
173
|
+
</NCard>
|
|
174
|
+
</NCard>
|
|
175
|
+
</div>
|
|
176
|
+
</template>
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
<script lang="ts" setup>
|
|
180
|
+
import Inison from "inison";
|
|
181
|
+
import type {
|
|
182
|
+
InputInst,
|
|
183
|
+
UploadCustomRequestOptions,
|
|
184
|
+
UploadFileInfo,
|
|
185
|
+
UploadInst,
|
|
186
|
+
} from "naive-ui";
|
|
187
|
+
import { Icon, NIcon } from "#components";
|
|
188
|
+
import { getFileNameAndExtension } from "~/composables";
|
|
189
|
+
import { useOptimizeFile } from "~/composables/optimizeFile";
|
|
190
|
+
import { generateSearchArray } from "~/composables/search";
|
|
191
|
+
import { useAssetUploader } from "~/composables/useAssetUploader";
|
|
192
|
+
import { usePdfCompressor } from "~/composables/usePdfCompressor";
|
|
193
|
+
import { useVideoCompressor } from "~/composables/useVideoCompressor";
|
|
194
|
+
|
|
195
|
+
const { where, suffix, targetID } = defineProps<{
|
|
196
|
+
targetID?: string;
|
|
197
|
+
where?: any;
|
|
198
|
+
suffix?: string;
|
|
199
|
+
}>();
|
|
200
|
+
const config = useRuntimeConfig();
|
|
201
|
+
|
|
202
|
+
const getResponsivePageSize = () => {
|
|
203
|
+
if (typeof window === "undefined") return 15;
|
|
204
|
+
const width = window.innerWidth;
|
|
205
|
+
if (width < 640) return 10;
|
|
206
|
+
if (width < 1024) return 15;
|
|
207
|
+
if (width < 1440) return 20;
|
|
208
|
+
return 30;
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const getResponsivePageSizes = () => {
|
|
212
|
+
if (typeof window === "undefined") return [15, 30, 60, 100, 500];
|
|
213
|
+
if (window.innerWidth < 640) return [10, 20, 30, 50];
|
|
214
|
+
if (window.innerWidth < 1024) return [15, 30, 60, 100];
|
|
215
|
+
return [20, 30, 60, 100, 500];
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const LARGE_VIDEO_BYTES = 512 * 1024 * 1024;
|
|
219
|
+
const HUGE_VIDEO_BYTES = 2 * 1024 * 1024 * 1024;
|
|
220
|
+
const LARGE_PDF_BYTES = 50 * 1024 * 1024;
|
|
221
|
+
const HUGE_PDF_BYTES = 200 * 1024 * 1024;
|
|
222
|
+
|
|
223
|
+
const {
|
|
224
|
+
compressVideo,
|
|
225
|
+
loading: videoLoading,
|
|
226
|
+
progress: videoProgress,
|
|
227
|
+
abort: abortVideo,
|
|
228
|
+
resetSkip: resetVideoSkip,
|
|
229
|
+
} = useVideoCompressor();
|
|
230
|
+
const {
|
|
231
|
+
compressPdf,
|
|
232
|
+
loading: pdfLoading,
|
|
233
|
+
progress: pdfProgress,
|
|
234
|
+
abort: abortPdf,
|
|
235
|
+
resetSkip: resetPdfSkip,
|
|
236
|
+
} = usePdfCompressor();
|
|
237
|
+
|
|
238
|
+
const compressionIndicator = ref<number | null>(null);
|
|
239
|
+
const skipCompressionRequested = ref(false);
|
|
240
|
+
const processingFileId = ref<string | null>(null); // Track which file is being processed
|
|
241
|
+
|
|
242
|
+
const skipCompression = () => {
|
|
243
|
+
if (compressionIndicator.value === null) return;
|
|
244
|
+
skipCompressionRequested.value = true;
|
|
245
|
+
abortVideo();
|
|
246
|
+
abortPdf();
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const getIndicatorPercent = (progress: number) => {
|
|
250
|
+
const raw = Math.round(progress * 100);
|
|
251
|
+
if (!Number.isFinite(raw)) return 1;
|
|
252
|
+
return Math.min(99, Math.max(1, raw));
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
watchEffect(() => {
|
|
256
|
+
if (videoLoading.value) {
|
|
257
|
+
compressionIndicator.value = getIndicatorPercent(videoProgress.value);
|
|
258
|
+
} else if (pdfLoading.value) {
|
|
259
|
+
compressionIndicator.value = getIndicatorPercent(pdfProgress.value);
|
|
260
|
+
} else {
|
|
261
|
+
compressionIndicator.value = null;
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
const notifyVideoSize = (size: number) => {
|
|
266
|
+
if (!import.meta.client) return;
|
|
267
|
+
const messageApi = window?.$message;
|
|
268
|
+
if (!messageApi) return;
|
|
269
|
+
const formattedSize = humanFileSize(size);
|
|
270
|
+
if (size >= HUGE_VIDEO_BYTES)
|
|
271
|
+
messageApi.warning(t("compression.videoHuge", { size: formattedSize }));
|
|
272
|
+
else if (size >= LARGE_VIDEO_BYTES)
|
|
273
|
+
messageApi.info(t("compression.videoLarge", { size: formattedSize }));
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
const notifyPdfSize = (size: number) => {
|
|
277
|
+
if (!import.meta.client) return;
|
|
278
|
+
const messageApi = window?.$message;
|
|
279
|
+
if (!messageApi) return;
|
|
280
|
+
const formattedSize = humanFileSize(size);
|
|
281
|
+
if (size >= HUGE_PDF_BYTES)
|
|
282
|
+
messageApi.warning(t("compression.pdfHuge", { size: formattedSize }));
|
|
283
|
+
else if (size >= LARGE_PDF_BYTES)
|
|
284
|
+
messageApi.info(t("compression.pdfLarge", { size: formattedSize }));
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
const route = useRoute();
|
|
288
|
+
const router = useRouter();
|
|
289
|
+
const isAssetRoute = computed(() => route.path.includes("/tables/assets"));
|
|
290
|
+
|
|
291
|
+
const table = useState<Table>("table");
|
|
292
|
+
const currentItem = useState<Item>("currentItem");
|
|
293
|
+
const assetsTable = ref<Table>(table.value);
|
|
294
|
+
|
|
295
|
+
const currentPath = ref<string>(
|
|
296
|
+
`${suffix ? renderLabel({ ...assetsTable.value, label: suffix }, currentItem.value) : ""}${
|
|
297
|
+
route.params.path
|
|
298
|
+
? `/${([] as string[]).concat(route.params.path).join("/")}`
|
|
299
|
+
: ""
|
|
300
|
+
}`,
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
const database = useState<Database>("database");
|
|
304
|
+
|
|
305
|
+
const sessionID = useScopedCookie<string>("sid", database.value?.slug);
|
|
306
|
+
|
|
307
|
+
const Loading = useState<Record<string, boolean>>("Loading", () => ({}));
|
|
308
|
+
if (!assetsTable.value || assetsTable.value.slug !== "assets")
|
|
309
|
+
assetsTable.value = (
|
|
310
|
+
await $fetch<apiResponse<Table>>(
|
|
311
|
+
`${config.public.apiBase}inicontent/databases/${database.value.slug}/assets`,
|
|
312
|
+
{
|
|
313
|
+
credentials: "include",
|
|
314
|
+
params: {
|
|
315
|
+
[`${database.value.slug}_sid`]: sessionID.value,
|
|
316
|
+
},
|
|
317
|
+
},
|
|
318
|
+
)
|
|
319
|
+
).result;
|
|
320
|
+
|
|
321
|
+
const Language = useScopedCookie<LanguagesType>("language", database.value?.slug);
|
|
322
|
+
|
|
323
|
+
const assets = ref<Asset[]>();
|
|
324
|
+
const selectedAssetIds = ref<Asset["id"][]>([]);
|
|
325
|
+
const showScanner = ref(false);
|
|
326
|
+
const bulkDeleteLoading = ref(false);
|
|
327
|
+
const bulkDownloadLoading = ref(false);
|
|
328
|
+
const bulkZipProgress = ref(0);
|
|
329
|
+
const folderUploadProgress = reactive({ current: 0, total: 0 });
|
|
330
|
+
|
|
331
|
+
const selectedAssetIdsSet = computed(() => new Set(selectedAssetIds.value));
|
|
332
|
+
const selectedAssets = computed(() =>
|
|
333
|
+
(assets.value ?? []).filter((asset) =>
|
|
334
|
+
selectedAssetIdsSet.value.has(asset.id),
|
|
335
|
+
),
|
|
336
|
+
);
|
|
337
|
+
const allSelectableSelected = computed(
|
|
338
|
+
() =>
|
|
339
|
+
!!(assets.value ?? []).length &&
|
|
340
|
+
selectedAssetIds.value.length === (assets.value ?? []).length,
|
|
341
|
+
);
|
|
342
|
+
|
|
343
|
+
function toggleAssetSelection(id: Asset["id"]) {
|
|
344
|
+
if (!selectedAssetIdsSet.value.has(id))
|
|
345
|
+
selectedAssetIds.value = [...selectedAssetIds.value, id];
|
|
346
|
+
else
|
|
347
|
+
selectedAssetIds.value = selectedAssetIds.value.filter(
|
|
348
|
+
(selectedId) => selectedId !== id,
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function toggleSelectAllAssets(checked: boolean) {
|
|
353
|
+
if (!checked) {
|
|
354
|
+
selectedAssetIds.value = [];
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
selectedAssetIds.value = (assets.value ?? []).map((asset) => asset.id);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function toggleSelectAllFromGroup() {
|
|
361
|
+
if (allSelectableSelected.value) {
|
|
362
|
+
selectedAssetIds.value = [];
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
toggleSelectAllAssets(true);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
watch(assets, (value) => {
|
|
369
|
+
const currentIds = new Set((value ?? []).map((asset) => asset.id));
|
|
370
|
+
selectedAssetIds.value = selectedAssetIds.value.filter((id) =>
|
|
371
|
+
currentIds.has(id),
|
|
372
|
+
);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
async function runWithConcurrency<T>(
|
|
376
|
+
items: T[],
|
|
377
|
+
concurrency: number,
|
|
378
|
+
handler: (item: T, index: number) => Promise<void>,
|
|
379
|
+
) {
|
|
380
|
+
const queue = items.map((item, index) => ({ item, index }));
|
|
381
|
+
const workerCount = Math.max(1, Math.min(concurrency, queue.length));
|
|
382
|
+
|
|
383
|
+
await Promise.all(
|
|
384
|
+
Array.from({ length: workerCount }, async () => {
|
|
385
|
+
while (queue.length) {
|
|
386
|
+
const entry = queue.shift();
|
|
387
|
+
if (!entry) break;
|
|
388
|
+
await handler(entry.item, entry.index);
|
|
389
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
|
390
|
+
}
|
|
391
|
+
}),
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
async function deleteSelectedAssets() {
|
|
396
|
+
if (!selectedAssets.value.length || bulkDeleteLoading.value) return;
|
|
397
|
+
|
|
398
|
+
bulkDeleteLoading.value = true;
|
|
399
|
+
const deletedIds: Asset["id"][] = [];
|
|
400
|
+
let deletedSize = 0;
|
|
401
|
+
let failedCount = 0;
|
|
402
|
+
|
|
403
|
+
try {
|
|
404
|
+
await runWithConcurrency(selectedAssets.value, 4, async (asset) => {
|
|
405
|
+
try {
|
|
406
|
+
await $fetch<apiResponse>(
|
|
407
|
+
`${config.public.apiBase}${database.value.slug}/assets${currentPath.value}/${asset.type === "dir" ? asset.name : asset.id}`,
|
|
408
|
+
{
|
|
409
|
+
method: "DELETE",
|
|
410
|
+
params: {
|
|
411
|
+
locale: Language.value,
|
|
412
|
+
[`${database.value.slug}_sid`]: sessionID.value,
|
|
413
|
+
},
|
|
414
|
+
credentials: "include",
|
|
415
|
+
},
|
|
416
|
+
);
|
|
417
|
+
|
|
418
|
+
deletedIds.push(asset.id);
|
|
419
|
+
deletedSize += asset.size ?? 0;
|
|
420
|
+
} catch {
|
|
421
|
+
failedCount += 1;
|
|
422
|
+
}
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
if (deletedIds.length) {
|
|
426
|
+
const deletedSet = new Set(deletedIds);
|
|
427
|
+
assets.value = (assets.value ?? []).filter(
|
|
428
|
+
(asset) => !deletedSet.has(asset.id),
|
|
429
|
+
);
|
|
430
|
+
selectedAssetIds.value = selectedAssetIds.value.filter(
|
|
431
|
+
(id) => !deletedSet.has(id),
|
|
432
|
+
);
|
|
433
|
+
if (database.value.size)
|
|
434
|
+
database.value.size = Math.max(0, database.value.size - deletedSize);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
if (deletedIds.length)
|
|
438
|
+
window.$message.success(`${deletedIds.length} ${t("success")}`);
|
|
439
|
+
if (failedCount) window.$message.error(`${failedCount} ${t("error")}`);
|
|
440
|
+
} catch (error) {
|
|
441
|
+
console.error("Failed to delete selected assets", error);
|
|
442
|
+
window.$message.error(t("error"));
|
|
443
|
+
} finally {
|
|
444
|
+
bulkDeleteLoading.value = false;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function getZipSafeName(asset: Asset, usedNames: Map<string, number>) {
|
|
449
|
+
const extension = asset.extension ? `.${asset.extension}` : "";
|
|
450
|
+
const rawName = `${asset.name}${extension}`;
|
|
451
|
+
const sanitized =
|
|
452
|
+
rawName.replace(/[\\/:*?"<>|]+/g, "_").trim() ||
|
|
453
|
+
`asset-${asset.id}${extension}`;
|
|
454
|
+
const currentCount = usedNames.get(sanitized) ?? 0;
|
|
455
|
+
usedNames.set(sanitized, currentCount + 1);
|
|
456
|
+
if (!currentCount) return sanitized;
|
|
457
|
+
const nameWithoutExt = sanitized.slice(
|
|
458
|
+
0,
|
|
459
|
+
sanitized.length - extension.length,
|
|
460
|
+
);
|
|
461
|
+
return `${nameWithoutExt}-${currentCount + 1}${extension}`;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
async function fetchAllAssetsInPath(apiPath: string): Promise<Asset[]> {
|
|
465
|
+
const all: Asset[] = [];
|
|
466
|
+
let page = 1;
|
|
467
|
+
while (true) {
|
|
468
|
+
const data = await $fetch<apiResponse<Asset[]>>(
|
|
469
|
+
`${config.public.apiBase}${database.value.slug}/assets${apiPath}`,
|
|
470
|
+
{
|
|
471
|
+
credentials: "include",
|
|
472
|
+
params: {
|
|
473
|
+
options: Inison.stringify({ page, perPage: 500 }),
|
|
474
|
+
locale: Language.value,
|
|
475
|
+
[`${database.value.slug}_sid`]: sessionID.value,
|
|
476
|
+
},
|
|
477
|
+
},
|
|
478
|
+
);
|
|
479
|
+
all.push(...(data.result ?? []));
|
|
480
|
+
const totalPages = (data.options?.totalPages as number) ?? 0;
|
|
481
|
+
if (page >= totalPages) break;
|
|
482
|
+
page++;
|
|
483
|
+
}
|
|
484
|
+
return all;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
async function collectZipFiles(
|
|
488
|
+
assetsToProcess: Asset[],
|
|
489
|
+
apiBasePath: string,
|
|
490
|
+
zipBasePath: string,
|
|
491
|
+
): Promise<{ asset: Asset; zipPath: string }[]> {
|
|
492
|
+
const result: { asset: Asset; zipPath: string }[] = [];
|
|
493
|
+
const usedNames = new Map<string, number>();
|
|
494
|
+
for (const asset of assetsToProcess) {
|
|
495
|
+
if (asset.type === "dir") {
|
|
496
|
+
const subApiPath = `${apiBasePath}/${asset.name}`;
|
|
497
|
+
const subZipPath = zipBasePath
|
|
498
|
+
? `${zipBasePath}/${asset.name}`
|
|
499
|
+
: asset.name;
|
|
500
|
+
const subAssets = await fetchAllAssetsInPath(subApiPath);
|
|
501
|
+
result.push(
|
|
502
|
+
...(await collectZipFiles(subAssets, subApiPath, subZipPath)),
|
|
503
|
+
);
|
|
504
|
+
} else if (asset.publicURL) {
|
|
505
|
+
const fileName = getZipSafeName(asset, usedNames);
|
|
506
|
+
result.push({
|
|
507
|
+
asset,
|
|
508
|
+
zipPath: zipBasePath ? `${zipBasePath}/${fileName}` : fileName,
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
return result;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
async function downloadSelectedAsZip() {
|
|
516
|
+
if (
|
|
517
|
+
!import.meta.client ||
|
|
518
|
+
!selectedAssets.value.length ||
|
|
519
|
+
bulkDownloadLoading.value
|
|
520
|
+
)
|
|
521
|
+
return;
|
|
522
|
+
|
|
523
|
+
bulkDownloadLoading.value = true;
|
|
524
|
+
bulkZipProgress.value = 0;
|
|
525
|
+
|
|
526
|
+
try {
|
|
527
|
+
const JSZip = (await import("jszip")).default;
|
|
528
|
+
const zip = new JSZip();
|
|
529
|
+
const selected = [...selectedAssets.value];
|
|
530
|
+
|
|
531
|
+
const allFiles = await collectZipFiles(selected, currentPath.value, "");
|
|
532
|
+
|
|
533
|
+
if (!allFiles.length) throw new Error("No files could be added to zip");
|
|
534
|
+
|
|
535
|
+
let addedFiles = 0;
|
|
536
|
+
|
|
537
|
+
await runWithConcurrency(allFiles, 3, async ({ asset, zipPath }) => {
|
|
538
|
+
try {
|
|
539
|
+
const response = await fetch(asset.publicURL);
|
|
540
|
+
if (!response.ok) return;
|
|
541
|
+
const blob = await response.blob();
|
|
542
|
+
zip.file(zipPath, blob, { binary: true });
|
|
543
|
+
addedFiles += 1;
|
|
544
|
+
bulkZipProgress.value = Math.round((addedFiles / allFiles.length) * 80);
|
|
545
|
+
} catch {
|
|
546
|
+
// Ignore individual asset failures and continue with the rest.
|
|
547
|
+
}
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
if (!addedFiles) throw new Error("No files could be added to zip");
|
|
551
|
+
|
|
552
|
+
const zipBlob = await zip.generateAsync(
|
|
553
|
+
{
|
|
554
|
+
type: "blob",
|
|
555
|
+
compression: "STORE",
|
|
556
|
+
},
|
|
557
|
+
(metadata) => {
|
|
558
|
+
bulkZipProgress.value = Math.max(
|
|
559
|
+
80,
|
|
560
|
+
Math.round(80 + metadata.percent * 0.2),
|
|
561
|
+
);
|
|
562
|
+
},
|
|
563
|
+
);
|
|
564
|
+
|
|
565
|
+
const downloadURL = URL.createObjectURL(zipBlob);
|
|
566
|
+
const anchor = document.createElement("a");
|
|
567
|
+
const zipName = `assets-${new Date().toISOString().replace(/[:.]/g, "-")}.zip`;
|
|
568
|
+
anchor.href = downloadURL;
|
|
569
|
+
anchor.download = zipName;
|
|
570
|
+
anchor.click();
|
|
571
|
+
setTimeout(() => URL.revokeObjectURL(downloadURL), 1000);
|
|
572
|
+
bulkZipProgress.value = 100;
|
|
573
|
+
} catch (error) {
|
|
574
|
+
console.error("Failed to download selected assets as zip", error);
|
|
575
|
+
window.$message.error(t("error"));
|
|
576
|
+
} finally {
|
|
577
|
+
bulkDownloadLoading.value = false;
|
|
578
|
+
bulkZipProgress.value = 0;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const searchString = ref<string | undefined>(
|
|
583
|
+
(route.query.search as string | undefined) ?? "",
|
|
584
|
+
);
|
|
585
|
+
|
|
586
|
+
const searchArray = ref<searchType>(
|
|
587
|
+
route.query.search
|
|
588
|
+
? generateSearchArray(Inison.unstringify(route.query.search as string))
|
|
589
|
+
: { and: [[null, "=", null]] },
|
|
590
|
+
);
|
|
591
|
+
|
|
592
|
+
watch(Language, () => {
|
|
593
|
+
searchString.value = "";
|
|
594
|
+
searchArray.value = { and: [[null, "=", null]] };
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
watch(searchString, (v) => {
|
|
598
|
+
const { search, page, ...Query }: any = route.query;
|
|
599
|
+
|
|
600
|
+
router.push({
|
|
601
|
+
query: {
|
|
602
|
+
...Query,
|
|
603
|
+
search: v,
|
|
604
|
+
},
|
|
605
|
+
});
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
const schema: Schema = [
|
|
609
|
+
{
|
|
610
|
+
key: "name",
|
|
611
|
+
type: "string",
|
|
612
|
+
},
|
|
613
|
+
{
|
|
614
|
+
key: "extension",
|
|
615
|
+
type: "array",
|
|
616
|
+
children: "string",
|
|
617
|
+
subType: "select",
|
|
618
|
+
options: [
|
|
619
|
+
{
|
|
620
|
+
label: t("image"),
|
|
621
|
+
value: [
|
|
622
|
+
"png",
|
|
623
|
+
"jpg",
|
|
624
|
+
"jpeg",
|
|
625
|
+
"gif",
|
|
626
|
+
"webp",
|
|
627
|
+
"svg",
|
|
628
|
+
"bmp",
|
|
629
|
+
"tiff",
|
|
630
|
+
"heic",
|
|
631
|
+
"avif",
|
|
632
|
+
],
|
|
633
|
+
icon: "tabler:photo",
|
|
634
|
+
},
|
|
635
|
+
{
|
|
636
|
+
label: t("video"),
|
|
637
|
+
value: ["mp4", "mov", "wmv", "avi", "mkv", "flv", "webm", "rmvb"],
|
|
638
|
+
icon: "tabler:video",
|
|
639
|
+
},
|
|
640
|
+
{
|
|
641
|
+
label: t("audio"),
|
|
642
|
+
value: ["mp3", "wav", "aac", "flac", "ogg", "m4a"],
|
|
643
|
+
icon: "tabler:audio",
|
|
644
|
+
},
|
|
645
|
+
{
|
|
646
|
+
label: t("document"),
|
|
647
|
+
value: ["docx", "pdf", "txt", "pptx", "xlsx"],
|
|
648
|
+
icon: "tabler:file-text",
|
|
649
|
+
},
|
|
650
|
+
{
|
|
651
|
+
label: t("archive"),
|
|
652
|
+
value: ["zip", "rar", "tar", "gz"],
|
|
653
|
+
icon: "tabler:archive",
|
|
654
|
+
},
|
|
655
|
+
],
|
|
656
|
+
},
|
|
657
|
+
];
|
|
658
|
+
|
|
659
|
+
const syncPaginationPageSize = () => {
|
|
660
|
+
if (route.query.perPage) return;
|
|
661
|
+
const nextPageSize = getResponsivePageSize();
|
|
662
|
+
if (pagination.pageSize !== nextPageSize) {
|
|
663
|
+
pagination.pageSize = nextPageSize;
|
|
664
|
+
}
|
|
665
|
+
};
|
|
666
|
+
|
|
667
|
+
const pagination = reactive({
|
|
668
|
+
page: isAssetRoute && route.query.page ? Number(route.query.page) : 1,
|
|
669
|
+
pageCount: 1,
|
|
670
|
+
pageSize:
|
|
671
|
+
isAssetRoute && route.query.perPage
|
|
672
|
+
? Number(route.query.perPage)
|
|
673
|
+
: getResponsivePageSize(),
|
|
674
|
+
itemCount: 0,
|
|
675
|
+
async onUpdatePage(currentPage: number) {
|
|
676
|
+
pagination.page = currentPage;
|
|
677
|
+
if (!isAssetRoute) return;
|
|
678
|
+
let { page, ...Query }: any = route.query;
|
|
679
|
+
Query = {
|
|
680
|
+
...Query,
|
|
681
|
+
page: currentPage !== 1 ? currentPage : undefined,
|
|
682
|
+
};
|
|
683
|
+
router.push({ query: Query });
|
|
684
|
+
},
|
|
685
|
+
async onUpdatePageSize(pageSize: number) {
|
|
686
|
+
const OLD_pageSize = toRaw(pagination.pageSize);
|
|
687
|
+
pagination.pageSize = pageSize;
|
|
688
|
+
if (!isAssetRoute) return;
|
|
689
|
+
let { perPage, page, ...Query }: any = route.query;
|
|
690
|
+
const defaultPageSize = Number(
|
|
691
|
+
route.query.perPage ?? getResponsivePageSize(),
|
|
692
|
+
);
|
|
693
|
+
if (pageSize !== defaultPageSize) {
|
|
694
|
+
pagination.page = Math.round(
|
|
695
|
+
OLD_pageSize < pageSize
|
|
696
|
+
? page / (pageSize / OLD_pageSize)
|
|
697
|
+
: page * (pageSize / OLD_pageSize),
|
|
698
|
+
);
|
|
699
|
+
if (Number.isNaN(pagination.page)) pagination.page = 1;
|
|
700
|
+
Query = {
|
|
701
|
+
...Query,
|
|
702
|
+
perPage: pageSize,
|
|
703
|
+
page: pagination.page === 1 ? undefined : pagination.page,
|
|
704
|
+
};
|
|
705
|
+
router.push({
|
|
706
|
+
query: Query,
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
},
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
onMounted(() => {
|
|
713
|
+
syncPaginationPageSize();
|
|
714
|
+
window.addEventListener("resize", syncPaginationPageSize);
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
onBeforeUnmount(() => {
|
|
718
|
+
window.removeEventListener("resize", syncPaginationPageSize);
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
const showSizePicker = ref(false);
|
|
722
|
+
async function onUpdatePage(currentPage: number) {
|
|
723
|
+
pagination.page = currentPage;
|
|
724
|
+
let Query = route.query;
|
|
725
|
+
if (currentPage !== 1) Query = { ...Query, page: currentPage as any };
|
|
726
|
+
if (isAssetRoute)
|
|
727
|
+
router.push({ query: (({ page, ...rest }) => rest)(Query) });
|
|
728
|
+
return refresh();
|
|
729
|
+
}
|
|
730
|
+
async function onUpdatePageSize(currentPageSize: number) {
|
|
731
|
+
const OLD_pageSize = toRaw(pagination.pageSize);
|
|
732
|
+
pagination.pageSize = currentPageSize;
|
|
733
|
+
const newPage = Math.round(
|
|
734
|
+
OLD_pageSize < pagination.pageSize
|
|
735
|
+
? pagination.page / (pagination.pageSize / OLD_pageSize)
|
|
736
|
+
: pagination.page * (pagination.pageSize / OLD_pageSize),
|
|
737
|
+
);
|
|
738
|
+
pagination.page = Number.isNaN(newPage) ? 1 : newPage;
|
|
739
|
+
const Query = {
|
|
740
|
+
...route.query,
|
|
741
|
+
perPage: pagination.pageSize,
|
|
742
|
+
page: pagination.page,
|
|
743
|
+
};
|
|
744
|
+
if (isAssetRoute)
|
|
745
|
+
router.push({ query: Query });
|
|
746
|
+
return refresh();
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
const queryOptions = computed(() =>
|
|
750
|
+
Inison.stringify({
|
|
751
|
+
page: pagination.page,
|
|
752
|
+
perPage: pagination.pageSize,
|
|
753
|
+
}),
|
|
754
|
+
);
|
|
755
|
+
|
|
756
|
+
const { refresh } = await useLazyFetch<apiResponse<Asset[]>>(
|
|
757
|
+
() =>
|
|
758
|
+
`${config.public.apiBase}${database.value.slug}/assets${currentPath.value}`,
|
|
759
|
+
{
|
|
760
|
+
onRequest: () => {
|
|
761
|
+
Loading.value.AssetData = true;
|
|
762
|
+
},
|
|
763
|
+
onResponse: ({ response: { _data } }) => {
|
|
764
|
+
Loading.value.AssetData = false;
|
|
765
|
+
|
|
766
|
+
_data = _data as apiResponse<Asset[]>;
|
|
767
|
+
|
|
768
|
+
assets.value = _data.result;
|
|
769
|
+
|
|
770
|
+
showSizePicker.value = !!_data.options?.total;
|
|
771
|
+
|
|
772
|
+
pagination.pageCount = _data.options.totalPages ?? 0;
|
|
773
|
+
pagination.itemCount = _data.options.total ?? 0;
|
|
774
|
+
|
|
775
|
+
return _data.result;
|
|
776
|
+
},
|
|
777
|
+
query: {
|
|
778
|
+
options: queryOptions,
|
|
779
|
+
where: searchString,
|
|
780
|
+
locale: Language.value,
|
|
781
|
+
[`${database.value.slug}_sid`]: sessionID.value,
|
|
782
|
+
},
|
|
783
|
+
credentials: "include",
|
|
784
|
+
},
|
|
785
|
+
);
|
|
786
|
+
|
|
787
|
+
const UploadProgress = ref<number>(0);
|
|
788
|
+
let uploadCompleteTimer: ReturnType<typeof setTimeout> | undefined;
|
|
789
|
+
function onUpdateFileList(fileList: Required<UploadFileInfo>[]) {
|
|
790
|
+
if (!fileList.length) return;
|
|
791
|
+
|
|
792
|
+
const allFinished = fileList.every((f) => f.status === "finished");
|
|
793
|
+
|
|
794
|
+
if (allFinished) {
|
|
795
|
+
// Guard against multiple concurrent completion sequences
|
|
796
|
+
if (uploadCompleteTimer !== undefined) return;
|
|
797
|
+
uploadCompleteTimer = setTimeout(() => {
|
|
798
|
+
UploadProgress.value = 10000;
|
|
799
|
+
uploadCompleteTimer = setTimeout(() => {
|
|
800
|
+
UploadProgress.value = 0;
|
|
801
|
+
uploadCompleteTimer = undefined;
|
|
802
|
+
}, 5000);
|
|
803
|
+
}, 2000);
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// New uploads arrived – cancel any pending completion animation
|
|
808
|
+
if (uploadCompleteTimer !== undefined) {
|
|
809
|
+
clearTimeout(uploadCompleteTimer);
|
|
810
|
+
uploadCompleteTimer = undefined;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
const nonFinished = fileList.filter((f) => f.status !== "finished");
|
|
814
|
+
if (!nonFinished.length) {
|
|
815
|
+
UploadProgress.value = 1000;
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
// Cap at 99 so the circle never shows "100% success" before the check animation
|
|
820
|
+
const avg =
|
|
821
|
+
nonFinished.reduce((sum, f) => sum + (f.percentage ?? 0), 0) /
|
|
822
|
+
nonFinished.length;
|
|
823
|
+
UploadProgress.value = Math.max(1, Math.min(99, Math.round(avg)));
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
const { optimizeFile } = useOptimizeFile();
|
|
827
|
+
const { uploadAssetWithProgress } = useAssetUploader();
|
|
828
|
+
const showSkipCompressionTooltip = ref(false);
|
|
829
|
+
|
|
830
|
+
async function customRequest({
|
|
831
|
+
file,
|
|
832
|
+
headers,
|
|
833
|
+
action,
|
|
834
|
+
onFinish,
|
|
835
|
+
onError,
|
|
836
|
+
onProgress,
|
|
837
|
+
}: UploadCustomRequestOptions) {
|
|
838
|
+
// Track this file as being processed
|
|
839
|
+
processingFileId.value = file.id;
|
|
840
|
+
|
|
841
|
+
try {
|
|
842
|
+
const originalFile = file.file;
|
|
843
|
+
if (!originalFile) throw new Error("Missing file payload");
|
|
844
|
+
|
|
845
|
+
let fileToUpload: File = originalFile;
|
|
846
|
+
const isVideo = originalFile.type?.startsWith("video/") ?? false;
|
|
847
|
+
const isPdf =
|
|
848
|
+
originalFile.type === "application/pdf" ||
|
|
849
|
+
originalFile.name.toLowerCase().endsWith(".pdf");
|
|
850
|
+
|
|
851
|
+
if (isVideo || isPdf) {
|
|
852
|
+
showSkipCompressionTooltip.value = true;
|
|
853
|
+
setTimeout(() => {
|
|
854
|
+
showSkipCompressionTooltip.value = false;
|
|
855
|
+
}, 800);
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
if (isVideo) {
|
|
859
|
+
notifyVideoSize(originalFile.size);
|
|
860
|
+
try {
|
|
861
|
+
fileToUpload = await compressVideo(originalFile);
|
|
862
|
+
} catch (compressionError) {
|
|
863
|
+
// If compression was aborted/terminated, use original file
|
|
864
|
+
if (String(compressionError).includes("terminated")) {
|
|
865
|
+
fileToUpload = originalFile;
|
|
866
|
+
compressionIndicator.value = null;
|
|
867
|
+
} else throw compressionError;
|
|
868
|
+
}
|
|
869
|
+
} else if (isPdf) {
|
|
870
|
+
notifyPdfSize(originalFile.size);
|
|
871
|
+
try {
|
|
872
|
+
fileToUpload = await compressPdf(fileToUpload);
|
|
873
|
+
} catch (pdfError) {
|
|
874
|
+
// If compression was aborted, use original file
|
|
875
|
+
if (String(pdfError).toLowerCase().includes("abort")) {
|
|
876
|
+
fileToUpload = originalFile;
|
|
877
|
+
compressionIndicator.value = null;
|
|
878
|
+
} else throw pdfError;
|
|
879
|
+
}
|
|
880
|
+
} else if (originalFile.type?.startsWith("image/")) {
|
|
881
|
+
const optimizationResult = await optimizeFile(originalFile);
|
|
882
|
+
if (optimizationResult.optimized) fileToUpload = optimizationResult.file;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
// IMPORTANT: Completely replace the file object to prevent uploading both files
|
|
886
|
+
// Create a new File instance to ensure no reference to original
|
|
887
|
+
if (fileToUpload !== originalFile) {
|
|
888
|
+
fileToUpload = new File([fileToUpload], fileToUpload.name, {
|
|
889
|
+
type: fileToUpload.type,
|
|
890
|
+
lastModified: fileToUpload.lastModified,
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
// Update the file reference in the upload info
|
|
895
|
+
file.file = fileToUpload;
|
|
896
|
+
file.name = fileToUpload.name;
|
|
897
|
+
const mimeType = fileToUpload.type || file.type;
|
|
898
|
+
const { name, extension } = getFileNameAndExtension(
|
|
899
|
+
fileToUpload.name || file.name,
|
|
900
|
+
);
|
|
901
|
+
|
|
902
|
+
onProgress?.({ percent: 10 });
|
|
903
|
+
|
|
904
|
+
const { result } = await $fetch<apiResponse<Asset>>(action as string, {
|
|
905
|
+
method: "POST",
|
|
906
|
+
credentials: "include",
|
|
907
|
+
headers: headers as Record<string, string>,
|
|
908
|
+
body: { name, size: fileToUpload.size, type: mimeType, extension },
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
if (!result.uploadURL) {
|
|
912
|
+
if (result.id)
|
|
913
|
+
await $fetch<apiResponse<Asset>>(
|
|
914
|
+
`${action}/${result.id}?${database.value.slug}_sid=${sessionID.value}`,
|
|
915
|
+
{
|
|
916
|
+
method: "DELETE",
|
|
917
|
+
credentials: "include",
|
|
918
|
+
headers: headers as Record<string, string>,
|
|
919
|
+
},
|
|
920
|
+
);
|
|
921
|
+
throw new Error("Failed to get upload URL");
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
await uploadAssetWithProgress({
|
|
925
|
+
url: result.uploadURL as string,
|
|
926
|
+
method: result.uploadURL.includes("s3") ? "PUT" : "POST",
|
|
927
|
+
headers: { "Content-Type": mimeType as string },
|
|
928
|
+
file: fileToUpload,
|
|
929
|
+
onProgress,
|
|
930
|
+
});
|
|
931
|
+
compressionIndicator.value = null;
|
|
932
|
+
|
|
933
|
+
file.url = result.publicURL;
|
|
934
|
+
if (assets.value) assets.value.unshift(result);
|
|
935
|
+
else assets.value = [result];
|
|
936
|
+
if (!database.value.size) database.value.size = 0;
|
|
937
|
+
database.value.size += result.size ?? 0;
|
|
938
|
+
|
|
939
|
+
onFinish();
|
|
940
|
+
} catch (error) {
|
|
941
|
+
// Handle terminated/aborted compression
|
|
942
|
+
if (
|
|
943
|
+
String(error).includes("terminated") ||
|
|
944
|
+
String(error).includes("abort")
|
|
945
|
+
) {
|
|
946
|
+
console.log("Compression skipped for:", file.name);
|
|
947
|
+
// Current file was being compressed when skip was clicked
|
|
948
|
+
// Mark as error status instead of removed to prevent re-upload triggers
|
|
949
|
+
file.status = "error";
|
|
950
|
+
compressionIndicator.value = null;
|
|
951
|
+
onFinish(); // Call onFinish to let the upload component know we're done
|
|
952
|
+
return;
|
|
953
|
+
}
|
|
954
|
+
compressionIndicator.value = null;
|
|
955
|
+
console.error("Error in customRequest:", error);
|
|
956
|
+
onError();
|
|
957
|
+
} finally {
|
|
958
|
+
// Clean up
|
|
959
|
+
if (processingFileId.value === file.id) {
|
|
960
|
+
processingFileId.value = null;
|
|
961
|
+
}
|
|
962
|
+
// Reset skip flag only after current file is done
|
|
963
|
+
if (skipCompressionRequested.value) {
|
|
964
|
+
skipCompressionRequested.value = false;
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
async function onRemoveUpload({ file }: { file: Required<UploadFileInfo> }) {
|
|
970
|
+
if (file.status !== "finished") {
|
|
971
|
+
abortVideo();
|
|
972
|
+
abortPdf();
|
|
973
|
+
return false;
|
|
974
|
+
}
|
|
975
|
+
const data = await $fetch<apiResponse<Asset>>(
|
|
976
|
+
`${config.public.apiBase}${
|
|
977
|
+
database.value.slug
|
|
978
|
+
}/assets${currentPath.value}/${file.name}`,
|
|
979
|
+
{
|
|
980
|
+
method: "DELETE",
|
|
981
|
+
params: {
|
|
982
|
+
locale: Language.value,
|
|
983
|
+
[`${database.value.slug}_sid`]: sessionID.value,
|
|
984
|
+
},
|
|
985
|
+
credentials: "include",
|
|
986
|
+
},
|
|
987
|
+
),
|
|
988
|
+
singleAsset = assets.value?.find((asset) => asset.name === file.name);
|
|
989
|
+
if (data.result) {
|
|
990
|
+
if (assets.value)
|
|
991
|
+
assets.value = assets.value.filter((asset) => asset.name !== file.name);
|
|
992
|
+
window.$message.success(data?.message ?? t("success"));
|
|
993
|
+
if (database.value.size) database.value.size -= singleAsset?.size ?? 0;
|
|
994
|
+
return true;
|
|
995
|
+
}
|
|
996
|
+
window.$message.error(data?.message ?? t("error"));
|
|
997
|
+
return false;
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
async function uploadScannedDocument(blob: Blob) {
|
|
1001
|
+
const fileName = `scan-${new Date().toISOString().replace(/[:.]/g, "-")}.png`;
|
|
1002
|
+
const file = new File([blob], fileName, { type: "image/png" });
|
|
1003
|
+
|
|
1004
|
+
if (uploadCompleteTimer !== undefined) {
|
|
1005
|
+
clearTimeout(uploadCompleteTimer);
|
|
1006
|
+
uploadCompleteTimer = undefined;
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
try {
|
|
1010
|
+
await uploadFileToPath(file, currentPath.value);
|
|
1011
|
+
UploadProgress.value = 10000;
|
|
1012
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 3000));
|
|
1013
|
+
UploadProgress.value = 0;
|
|
1014
|
+
window.$message?.success(t("success"));
|
|
1015
|
+
} catch (err) {
|
|
1016
|
+
console.error("Scan upload failed:", err);
|
|
1017
|
+
window.$message?.error(t("error"));
|
|
1018
|
+
UploadProgress.value = 0;
|
|
1019
|
+
} finally {
|
|
1020
|
+
await refresh();
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
const folder = ref();
|
|
1025
|
+
const showFolderInput = ref(false);
|
|
1026
|
+
const folderInputElRef = ref<InputInst>();
|
|
1027
|
+
const uploadRef = ref<UploadInst>();
|
|
1028
|
+
|
|
1029
|
+
const uploadDropdownOptions = computed(() => [
|
|
1030
|
+
{
|
|
1031
|
+
label: t("uploadFiles"),
|
|
1032
|
+
key: "uploadFiles",
|
|
1033
|
+
icon: () => h(NIcon, () => h(Icon, { name: "tabler:upload" })),
|
|
1034
|
+
},
|
|
1035
|
+
{
|
|
1036
|
+
label: t("uploadFolder"),
|
|
1037
|
+
key: "uploadFolder",
|
|
1038
|
+
icon: () => h(NIcon, () => h(Icon, { name: "tabler:folder-up" })),
|
|
1039
|
+
},
|
|
1040
|
+
{
|
|
1041
|
+
label: t("createFolder"),
|
|
1042
|
+
key: "createFolder",
|
|
1043
|
+
icon: () => h(NIcon, () => h(Icon, { name: "tabler:folder-plus" })),
|
|
1044
|
+
},
|
|
1045
|
+
{
|
|
1046
|
+
label: t("scanDocument"),
|
|
1047
|
+
key: "scanDocument",
|
|
1048
|
+
icon: () => h(NIcon, () => h(Icon, { name: "tabler:scan" })),
|
|
1049
|
+
},
|
|
1050
|
+
]);
|
|
1051
|
+
|
|
1052
|
+
function onUploadDropdownSelect(key: string) {
|
|
1053
|
+
switch (key) {
|
|
1054
|
+
case "uploadFiles":
|
|
1055
|
+
uploadRef.value?.openOpenFileDialog();
|
|
1056
|
+
break;
|
|
1057
|
+
case "uploadFolder":
|
|
1058
|
+
folderInputRef.value?.click();
|
|
1059
|
+
break;
|
|
1060
|
+
case "createFolder":
|
|
1061
|
+
folder.value = "";
|
|
1062
|
+
showFolderInput.value = true;
|
|
1063
|
+
setTimeout(() => folderInputElRef.value?.focus(), 100);
|
|
1064
|
+
break;
|
|
1065
|
+
case "scanDocument":
|
|
1066
|
+
showScanner.value = true;
|
|
1067
|
+
break;
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
function cancelFolderCreation() {
|
|
1072
|
+
folder.value = "";
|
|
1073
|
+
showFolderInput.value = false;
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
async function createFolder() {
|
|
1077
|
+
if (folder.value) {
|
|
1078
|
+
currentPath.value += `/${folder.value}`;
|
|
1079
|
+
await $fetch<apiResponse>(
|
|
1080
|
+
`${config.public.apiBase}${database.value.slug}/assets${currentPath.value}`,
|
|
1081
|
+
{
|
|
1082
|
+
method: "POST",
|
|
1083
|
+
credentials: "include",
|
|
1084
|
+
params: {
|
|
1085
|
+
locale: Language.value,
|
|
1086
|
+
[`${database.value.slug}_sid`]: sessionID.value,
|
|
1087
|
+
},
|
|
1088
|
+
},
|
|
1089
|
+
);
|
|
1090
|
+
window.$message.success(t("folderCreatedSuccessfully"));
|
|
1091
|
+
await nextTick();
|
|
1092
|
+
if (isAssetRoute)
|
|
1093
|
+
await navigateTo(
|
|
1094
|
+
`${route.params.database ? `/${database.value.slug}` : ""}/admin/tables/assets${currentPath.value}`,
|
|
1095
|
+
);
|
|
1096
|
+
folder.value = "";
|
|
1097
|
+
showFolderInput.value = false;
|
|
1098
|
+
} else window.$message.error(t("folderNameRequired"));
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
watch(currentPath, () => {
|
|
1102
|
+
pagination.onUpdatePage(1);
|
|
1103
|
+
pagination.itemCount = 0;
|
|
1104
|
+
});
|
|
1105
|
+
|
|
1106
|
+
// --- Drag & Drop ---
|
|
1107
|
+
const isDragOver = ref(false);
|
|
1108
|
+
const folderInputRef = ref<HTMLInputElement>();
|
|
1109
|
+
const dragCounter = ref(0);
|
|
1110
|
+
|
|
1111
|
+
function onDragEnter(e: DragEvent) {
|
|
1112
|
+
if (!table.value?.allowedMethods?.includes("c")) return;
|
|
1113
|
+
const hasFiles = Array.from(e.dataTransfer?.items ?? []).some(
|
|
1114
|
+
(item) => item.kind === "file",
|
|
1115
|
+
);
|
|
1116
|
+
if (!hasFiles) return;
|
|
1117
|
+
dragCounter.value++;
|
|
1118
|
+
isDragOver.value = true;
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
function onDragLeave() {
|
|
1122
|
+
dragCounter.value--;
|
|
1123
|
+
if (dragCounter.value <= 0) {
|
|
1124
|
+
isDragOver.value = false;
|
|
1125
|
+
dragCounter.value = 0;
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
function onDragOver(e: DragEvent) {
|
|
1130
|
+
if (e.dataTransfer) e.dataTransfer.dropEffect = "copy";
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
async function readAllDirectoryEntries(
|
|
1134
|
+
reader: FileSystemDirectoryReader,
|
|
1135
|
+
): Promise<FileSystemEntry[]> {
|
|
1136
|
+
const all: FileSystemEntry[] = [];
|
|
1137
|
+
let batch: FileSystemEntry[];
|
|
1138
|
+
do {
|
|
1139
|
+
batch = await new Promise<FileSystemEntry[]>((resolve, reject) => {
|
|
1140
|
+
reader.readEntries(resolve, reject);
|
|
1141
|
+
});
|
|
1142
|
+
all.push(...batch);
|
|
1143
|
+
} while (batch.length > 0);
|
|
1144
|
+
return all;
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
async function uploadFileToPath(file: File, targetPath: string) {
|
|
1148
|
+
const isVideo = file.type?.startsWith("video/") ?? false;
|
|
1149
|
+
const isPdf =
|
|
1150
|
+
file.type === "application/pdf" || file.name.toLowerCase().endsWith(".pdf");
|
|
1151
|
+
|
|
1152
|
+
let fileToUpload: File = file;
|
|
1153
|
+
|
|
1154
|
+
if (isVideo) {
|
|
1155
|
+
notifyVideoSize(file.size);
|
|
1156
|
+
try {
|
|
1157
|
+
fileToUpload = await compressVideo(file);
|
|
1158
|
+
} catch {
|
|
1159
|
+
fileToUpload = file;
|
|
1160
|
+
compressionIndicator.value = null;
|
|
1161
|
+
}
|
|
1162
|
+
} else if (isPdf) {
|
|
1163
|
+
notifyPdfSize(file.size);
|
|
1164
|
+
try {
|
|
1165
|
+
fileToUpload = await compressPdf(file);
|
|
1166
|
+
} catch {
|
|
1167
|
+
fileToUpload = file;
|
|
1168
|
+
compressionIndicator.value = null;
|
|
1169
|
+
}
|
|
1170
|
+
} else if (file.type?.startsWith("image/")) {
|
|
1171
|
+
const optimizationResult = await optimizeFile(file);
|
|
1172
|
+
if (optimizationResult.optimized) fileToUpload = optimizationResult.file;
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
const { name, extension } = getFileNameAndExtension(fileToUpload.name);
|
|
1176
|
+
const mimeType = fileToUpload.type || file.type;
|
|
1177
|
+
const action = `${config.public.apiBase}${database.value.slug}/assets${targetPath}?${database.value.slug}_sid=${sessionID.value}`;
|
|
1178
|
+
|
|
1179
|
+
const { result } = await $fetch<apiResponse<Asset>>(action, {
|
|
1180
|
+
method: "POST",
|
|
1181
|
+
credentials: "include",
|
|
1182
|
+
body: { name, size: fileToUpload.size, type: mimeType, extension },
|
|
1183
|
+
});
|
|
1184
|
+
|
|
1185
|
+
if (!result.uploadURL) throw new Error("Failed to get upload URL");
|
|
1186
|
+
|
|
1187
|
+
await uploadAssetWithProgress({
|
|
1188
|
+
url: result.uploadURL as string,
|
|
1189
|
+
method: result.uploadURL.includes("s3") ? "PUT" : "POST",
|
|
1190
|
+
headers: { "Content-Type": mimeType as string },
|
|
1191
|
+
file: fileToUpload,
|
|
1192
|
+
});
|
|
1193
|
+
|
|
1194
|
+
compressionIndicator.value = null;
|
|
1195
|
+
|
|
1196
|
+
if (targetPath === currentPath.value) {
|
|
1197
|
+
if (assets.value) assets.value.unshift(result);
|
|
1198
|
+
else assets.value = [result];
|
|
1199
|
+
}
|
|
1200
|
+
if (!database.value.size) database.value.size = 0;
|
|
1201
|
+
database.value.size += result.size ?? 0;
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
async function ensureFolderExists(folderPath: string) {
|
|
1205
|
+
try {
|
|
1206
|
+
await $fetch<apiResponse>(
|
|
1207
|
+
`${config.public.apiBase}${database.value.slug}/assets${folderPath}`,
|
|
1208
|
+
{
|
|
1209
|
+
method: "POST",
|
|
1210
|
+
credentials: "include",
|
|
1211
|
+
params: {
|
|
1212
|
+
locale: Language.value,
|
|
1213
|
+
[`${database.value.slug}_sid`]: sessionID.value,
|
|
1214
|
+
},
|
|
1215
|
+
},
|
|
1216
|
+
);
|
|
1217
|
+
} catch {
|
|
1218
|
+
// Folder may already exist — ignore
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
async function processDroppedEntries(
|
|
1223
|
+
entries: FileSystemEntry[],
|
|
1224
|
+
basePath: string,
|
|
1225
|
+
) {
|
|
1226
|
+
for (const entry of entries) {
|
|
1227
|
+
if (entry.isFile) {
|
|
1228
|
+
const file = await new Promise<File>((resolve, reject) => {
|
|
1229
|
+
(entry as FileSystemFileEntry).file(resolve, reject);
|
|
1230
|
+
});
|
|
1231
|
+
try {
|
|
1232
|
+
await uploadFileToPath(file, basePath);
|
|
1233
|
+
} catch (err) {
|
|
1234
|
+
console.error("Drop upload failed for:", entry.name, err);
|
|
1235
|
+
} finally {
|
|
1236
|
+
folderUploadProgress.current++;
|
|
1237
|
+
}
|
|
1238
|
+
} else if (entry.isDirectory) {
|
|
1239
|
+
const dirPath = `${basePath}/${entry.name}`;
|
|
1240
|
+
await ensureFolderExists(dirPath);
|
|
1241
|
+
const reader = (entry as FileSystemDirectoryEntry).createReader();
|
|
1242
|
+
const subEntries = await readAllDirectoryEntries(reader);
|
|
1243
|
+
await processDroppedEntries(subEntries, dirPath);
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
async function onDrop(e: DragEvent) {
|
|
1249
|
+
isDragOver.value = false;
|
|
1250
|
+
dragCounter.value = 0;
|
|
1251
|
+
|
|
1252
|
+
if (!table.value?.allowedMethods?.includes("c")) return;
|
|
1253
|
+
|
|
1254
|
+
const items = e.dataTransfer?.items;
|
|
1255
|
+
if (!items?.length) return;
|
|
1256
|
+
|
|
1257
|
+
const entries: FileSystemEntry[] = [];
|
|
1258
|
+
for (let i = 0; i < items.length; i++) {
|
|
1259
|
+
const item = items[i];
|
|
1260
|
+
if (!item) continue;
|
|
1261
|
+
const entry = item.webkitGetAsEntry?.();
|
|
1262
|
+
if (entry) entries.push(entry);
|
|
1263
|
+
}
|
|
1264
|
+
if (!entries.length) return;
|
|
1265
|
+
|
|
1266
|
+
if (uploadCompleteTimer !== undefined) {
|
|
1267
|
+
clearTimeout(uploadCompleteTimer);
|
|
1268
|
+
uploadCompleteTimer = undefined;
|
|
1269
|
+
}
|
|
1270
|
+
folderUploadProgress.current = 0;
|
|
1271
|
+
folderUploadProgress.total = 0;
|
|
1272
|
+
UploadProgress.value = 1001;
|
|
1273
|
+
try {
|
|
1274
|
+
await processDroppedEntries(entries, currentPath.value);
|
|
1275
|
+
UploadProgress.value = 10000;
|
|
1276
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 3000));
|
|
1277
|
+
UploadProgress.value = 0;
|
|
1278
|
+
} catch (err) {
|
|
1279
|
+
console.error("Drop upload error:", err);
|
|
1280
|
+
UploadProgress.value = 0;
|
|
1281
|
+
} finally {
|
|
1282
|
+
await refresh();
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
async function onFolderInputChange(e: Event) {
|
|
1287
|
+
const input = e.target as HTMLInputElement;
|
|
1288
|
+
const files = Array.from(input.files ?? []);
|
|
1289
|
+
input.value = "";
|
|
1290
|
+
if (!files.length) return;
|
|
1291
|
+
|
|
1292
|
+
if (uploadCompleteTimer !== undefined) {
|
|
1293
|
+
clearTimeout(uploadCompleteTimer);
|
|
1294
|
+
uploadCompleteTimer = undefined;
|
|
1295
|
+
}
|
|
1296
|
+
UploadProgress.value = 1001;
|
|
1297
|
+
try {
|
|
1298
|
+
const dirsNeeded = new Set<string>();
|
|
1299
|
+
const uploads: { file: File; targetPath: string }[] = files.map((file) => {
|
|
1300
|
+
const relativePath = file.webkitRelativePath || file.name;
|
|
1301
|
+
const parts = relativePath.split("/");
|
|
1302
|
+
// parts[0] is the root folder name — create it and upload content inside it
|
|
1303
|
+
const rootPath = `${currentPath.value}/${parts[0]}`;
|
|
1304
|
+
dirsNeeded.add(rootPath);
|
|
1305
|
+
const folderParts = parts.slice(1, -1);
|
|
1306
|
+
|
|
1307
|
+
let pathAcc = rootPath;
|
|
1308
|
+
for (const part of folderParts) {
|
|
1309
|
+
pathAcc = `${pathAcc}/${part}`;
|
|
1310
|
+
dirsNeeded.add(pathAcc);
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
const targetPath =
|
|
1314
|
+
folderParts.length > 0
|
|
1315
|
+
? `${rootPath}/${folderParts.join("/")}`
|
|
1316
|
+
: rootPath;
|
|
1317
|
+
|
|
1318
|
+
return { file, targetPath };
|
|
1319
|
+
});
|
|
1320
|
+
|
|
1321
|
+
// Create directories shallowest-first
|
|
1322
|
+
const sortedDirs = Array.from(dirsNeeded).sort(
|
|
1323
|
+
(a, b) => a.split("/").length - b.split("/").length,
|
|
1324
|
+
);
|
|
1325
|
+
folderUploadProgress.current = 0;
|
|
1326
|
+
folderUploadProgress.total = uploads.length;
|
|
1327
|
+
for (const dir of sortedDirs) {
|
|
1328
|
+
await ensureFolderExists(dir);
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
for (const { file, targetPath } of uploads) {
|
|
1332
|
+
try {
|
|
1333
|
+
await uploadFileToPath(file, targetPath);
|
|
1334
|
+
} catch (err) {
|
|
1335
|
+
console.error("Folder upload failed for:", file.name, err);
|
|
1336
|
+
} finally {
|
|
1337
|
+
folderUploadProgress.current++;
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
UploadProgress.value = 10000;
|
|
1342
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 3000));
|
|
1343
|
+
UploadProgress.value = 0;
|
|
1344
|
+
} catch (err) {
|
|
1345
|
+
console.error("Folder upload error:", err);
|
|
1346
|
+
UploadProgress.value = 0;
|
|
1347
|
+
} finally {
|
|
1348
|
+
await refresh();
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
</script>
|
|
1352
|
+
|
|
1353
|
+
<style scoped>
|
|
1354
|
+
.drop-fade-enter-active,
|
|
1355
|
+
.drop-fade-leave-active {
|
|
1356
|
+
transition: opacity 0.15s ease;
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
.drop-fade-enter-from,
|
|
1360
|
+
.drop-fade-leave-to {
|
|
1361
|
+
opacity: 0;
|
|
1362
|
+
}
|
|
1363
|
+
</style>
|