create-nuxt-base 0.3.17 → 1.0.3
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/workflows/publish.yml +4 -2
- package/.oxfmtrc.jsonc +7 -0
- package/CHANGELOG.md +22 -8
- package/README.md +130 -3
- package/nuxt-base-template/.dockerignore +44 -0
- package/nuxt-base-template/.env.example +0 -2
- package/nuxt-base-template/.nuxtrc +1 -0
- package/nuxt-base-template/.oxfmtrc.jsonc +8 -0
- package/nuxt-base-template/Dockerfile.dev +23 -0
- package/nuxt-base-template/README.md +76 -29
- package/nuxt-base-template/app/components/Modal/ModalBackupCodes.vue +117 -0
- package/nuxt-base-template/app/components/Upload/TusFileUpload.vue +302 -0
- package/nuxt-base-template/app/composables/use-better-auth.ts +25 -0
- package/nuxt-base-template/app/composables/use-file.ts +39 -4
- package/nuxt-base-template/app/composables/use-share.ts +1 -1
- package/nuxt-base-template/app/composables/use-tus-upload.ts +278 -0
- package/nuxt-base-template/app/interfaces/upload.interface.ts +58 -0
- package/nuxt-base-template/app/interfaces/user.interface.ts +12 -0
- package/nuxt-base-template/app/lib/auth-client.ts +135 -0
- package/nuxt-base-template/app/middleware/admin.global.ts +23 -0
- package/nuxt-base-template/app/middleware/auth.global.ts +18 -0
- package/nuxt-base-template/app/middleware/guest.global.ts +18 -0
- package/nuxt-base-template/app/pages/app/settings/security.vue +409 -0
- package/nuxt-base-template/app/pages/auth/2fa.vue +120 -0
- package/nuxt-base-template/app/pages/auth/forgot-password.vue +72 -21
- package/nuxt-base-template/app/pages/auth/login.vue +75 -11
- package/nuxt-base-template/app/pages/auth/register.vue +184 -0
- package/nuxt-base-template/app/pages/auth/reset-password.vue +153 -0
- package/nuxt-base-template/app/utils/crypto.ts +13 -0
- package/nuxt-base-template/docker-entrypoint.sh +21 -0
- package/nuxt-base-template/nuxt.config.ts +4 -1
- package/nuxt-base-template/oxlint.json +14 -0
- package/nuxt-base-template/package-lock.json +11582 -10675
- package/nuxt-base-template/package.json +35 -32
- package/nuxt-base-template/tests/iam.spec.ts +247 -0
- package/package.json +14 -11
- package/.eslintignore +0 -14
- package/.eslintrc +0 -3
- package/.prettierignore +0 -5
- package/.prettierrc +0 -6
- package/nuxt-base-template/CLAUDE.md +0 -361
- package/nuxt-base-template/app/pages/auth/reset-password/[token].vue +0 -110
- package/nuxt-base-template/app/public/favicon.ico +0 -0
- package/nuxt-base-template/eslint.config.mjs +0 -4
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { UploadItem } from '~/interfaces/upload.interface';
|
|
3
|
+
|
|
4
|
+
// ============================================================================
|
|
5
|
+
// Props & Emits
|
|
6
|
+
// ============================================================================
|
|
7
|
+
interface Props {
|
|
8
|
+
/** Erlaubte Dateitypen (MIME types oder Extensions) */
|
|
9
|
+
accept?: string;
|
|
10
|
+
/** Upload automatisch starten */
|
|
11
|
+
autoStart?: boolean;
|
|
12
|
+
/** Chunk-Groesse fuer TUS */
|
|
13
|
+
chunkSize?: number;
|
|
14
|
+
/** Beschreibung fuer Dropzone */
|
|
15
|
+
description?: string;
|
|
16
|
+
/** Deaktiviert */
|
|
17
|
+
disabled?: boolean;
|
|
18
|
+
/** TUS Upload Endpoint */
|
|
19
|
+
endpoint?: string;
|
|
20
|
+
/** Label fuer Dropzone */
|
|
21
|
+
label?: string;
|
|
22
|
+
/** Layout der Dateiliste */
|
|
23
|
+
layout?: 'grid' | 'list';
|
|
24
|
+
/** Maximale Dateigroesse in Bytes */
|
|
25
|
+
maxSize?: number;
|
|
26
|
+
/** Zusaetzliche Metadaten */
|
|
27
|
+
metadata?: Record<string, string>;
|
|
28
|
+
/** Mehrere Dateien erlauben */
|
|
29
|
+
multiple?: boolean;
|
|
30
|
+
/** Parallele Uploads */
|
|
31
|
+
parallelUploads?: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
35
|
+
accept: '*/*',
|
|
36
|
+
autoStart: true,
|
|
37
|
+
chunkSize: 5 * 1024 * 1024,
|
|
38
|
+
description: 'Dateien werden automatisch hochgeladen',
|
|
39
|
+
label: 'Dateien hier ablegen',
|
|
40
|
+
layout: 'list',
|
|
41
|
+
maxSize: 100 * 1024 * 1024,
|
|
42
|
+
multiple: true,
|
|
43
|
+
parallelUploads: 3,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const emit = defineEmits<{
|
|
47
|
+
/** Alle Uploads abgeschlossen */
|
|
48
|
+
complete: [items: UploadItem[]];
|
|
49
|
+
/** Upload-Fehler */
|
|
50
|
+
error: [item: UploadItem, error: Error];
|
|
51
|
+
/** Ein Upload abgeschlossen */
|
|
52
|
+
success: [item: UploadItem];
|
|
53
|
+
/** Files geaendert */
|
|
54
|
+
'update:modelValue': [files: File[]];
|
|
55
|
+
}>();
|
|
56
|
+
|
|
57
|
+
// ============================================================================
|
|
58
|
+
// Composables
|
|
59
|
+
// ============================================================================
|
|
60
|
+
const toast = useToast();
|
|
61
|
+
const { formatDuration, formatFileSize } = useFile();
|
|
62
|
+
|
|
63
|
+
// ============================================================================
|
|
64
|
+
// TUS Upload Setup
|
|
65
|
+
// ============================================================================
|
|
66
|
+
const { addFiles, cancelAll, cancelUpload, clearCompleted, isUploading, pauseAll, pauseUpload, resumeAll, resumeUpload, retryUpload, totalProgress, uploads } = useTusUpload({
|
|
67
|
+
autoStart: props.autoStart,
|
|
68
|
+
chunkSize: props.chunkSize,
|
|
69
|
+
endpoint: props.endpoint,
|
|
70
|
+
metadata: props.metadata,
|
|
71
|
+
onError: (item, error) => {
|
|
72
|
+
toast.add({
|
|
73
|
+
color: 'error',
|
|
74
|
+
description: item.file.name,
|
|
75
|
+
title: 'Upload fehlgeschlagen',
|
|
76
|
+
});
|
|
77
|
+
emit('error', item, error);
|
|
78
|
+
},
|
|
79
|
+
onSuccess: (item) => {
|
|
80
|
+
toast.add({
|
|
81
|
+
color: 'success',
|
|
82
|
+
description: item.file.name,
|
|
83
|
+
title: 'Upload abgeschlossen',
|
|
84
|
+
});
|
|
85
|
+
emit('success', item);
|
|
86
|
+
},
|
|
87
|
+
parallelUploads: props.parallelUploads,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// ============================================================================
|
|
91
|
+
// State
|
|
92
|
+
// ============================================================================
|
|
93
|
+
const files = ref<File[]>([]);
|
|
94
|
+
|
|
95
|
+
// ============================================================================
|
|
96
|
+
// Watchers
|
|
97
|
+
// ============================================================================
|
|
98
|
+
// Neue Dateien zu TUS hinzufuegen
|
|
99
|
+
watch(
|
|
100
|
+
files,
|
|
101
|
+
(newFiles, oldFiles) => {
|
|
102
|
+
const addedFiles = newFiles.filter((f) => !oldFiles?.includes(f));
|
|
103
|
+
if (addedFiles.length > 0) {
|
|
104
|
+
// Validierung
|
|
105
|
+
const validFiles = addedFiles.filter((file) => {
|
|
106
|
+
if (file.size > props.maxSize) {
|
|
107
|
+
toast.add({
|
|
108
|
+
color: 'error',
|
|
109
|
+
description: `${file.name} ist zu gross (max. ${formatFileSize(props.maxSize)})`,
|
|
110
|
+
title: 'Datei zu gross',
|
|
111
|
+
});
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
return true;
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
if (validFiles.length > 0) {
|
|
118
|
+
addFiles(validFiles);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
emit('update:modelValue', newFiles);
|
|
122
|
+
},
|
|
123
|
+
{ deep: true },
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
// Emit complete wenn alle fertig
|
|
127
|
+
const completedUploads = computed(() => uploads.value.filter((u) => u.status === 'completed'));
|
|
128
|
+
watch([completedUploads, isUploading], ([completed, uploading]) => {
|
|
129
|
+
if (completed.length > 0 && !uploading && uploads.value.every((u) => u.status === 'completed' || u.status === 'error')) {
|
|
130
|
+
emit('complete', completed);
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
function getStatusColor(status: string): 'error' | 'neutral' | 'success' | 'warning' {
|
|
135
|
+
switch (status) {
|
|
136
|
+
case 'completed':
|
|
137
|
+
return 'success';
|
|
138
|
+
case 'error':
|
|
139
|
+
return 'error';
|
|
140
|
+
case 'paused':
|
|
141
|
+
return 'warning';
|
|
142
|
+
default:
|
|
143
|
+
return 'neutral';
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function getStatusLabel(status: string): string {
|
|
148
|
+
switch (status) {
|
|
149
|
+
case 'completed':
|
|
150
|
+
return 'Fertig';
|
|
151
|
+
case 'error':
|
|
152
|
+
return 'Fehler';
|
|
153
|
+
case 'paused':
|
|
154
|
+
return 'Pausiert';
|
|
155
|
+
case 'uploading':
|
|
156
|
+
return 'Hochladen';
|
|
157
|
+
default:
|
|
158
|
+
return 'Wartend';
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ============================================================================
|
|
163
|
+
// Helpers
|
|
164
|
+
// ============================================================================
|
|
165
|
+
function getUploadForFile(file: File, index: number): undefined | UploadItem {
|
|
166
|
+
// Versuche ueber Index zu matchen (funktioniert wenn Reihenfolge gleich)
|
|
167
|
+
const upload = uploads.value[index];
|
|
168
|
+
if (upload?.file.name === file.name && upload?.file.size === file.size) {
|
|
169
|
+
return upload;
|
|
170
|
+
}
|
|
171
|
+
// Fallback: Suche ueber Name und Groesse
|
|
172
|
+
return uploads.value.find((u) => u.file.name === file.name && u.file.size === file.size);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ============================================================================
|
|
176
|
+
// Expose fuer Parent-Zugriff
|
|
177
|
+
// ============================================================================
|
|
178
|
+
defineExpose({
|
|
179
|
+
cancelAll,
|
|
180
|
+
clearCompleted,
|
|
181
|
+
isUploading,
|
|
182
|
+
pauseAll,
|
|
183
|
+
resumeAll,
|
|
184
|
+
totalProgress,
|
|
185
|
+
uploads,
|
|
186
|
+
});
|
|
187
|
+
</script>
|
|
188
|
+
|
|
189
|
+
<template>
|
|
190
|
+
<div class="space-y-4">
|
|
191
|
+
<!-- Nuxt UI FileUpload -->
|
|
192
|
+
<UFileUpload
|
|
193
|
+
v-model="files"
|
|
194
|
+
:accept="accept"
|
|
195
|
+
:description="description"
|
|
196
|
+
:disabled="disabled || isUploading"
|
|
197
|
+
:dropzone="!disabled"
|
|
198
|
+
:label="label"
|
|
199
|
+
:layout="layout"
|
|
200
|
+
:multiple="multiple"
|
|
201
|
+
class="w-full"
|
|
202
|
+
>
|
|
203
|
+
<!-- Custom File Slot mit TUS Progress -->
|
|
204
|
+
<template #file="{ file, index }">
|
|
205
|
+
<div class="flex w-full items-center gap-3 rounded-lg border border-default bg-default p-3">
|
|
206
|
+
<UIcon name="i-lucide-file" class="size-6 shrink-0 text-muted" />
|
|
207
|
+
|
|
208
|
+
<div class="min-w-0 flex-1">
|
|
209
|
+
<p class="truncate text-sm font-medium">{{ file.name }}</p>
|
|
210
|
+
|
|
211
|
+
<template v-if="getUploadForFile(file, index)">
|
|
212
|
+
<div class="mt-0.5 flex items-center gap-2 text-xs text-muted">
|
|
213
|
+
<span>{{ formatFileSize(file.size) }}</span>
|
|
214
|
+
|
|
215
|
+
<template v-if="getUploadForFile(file, index)?.status === 'uploading'">
|
|
216
|
+
<span>-</span>
|
|
217
|
+
<span>{{ getUploadForFile(file, index)?.progress.percentage }}%</span>
|
|
218
|
+
<span>-</span>
|
|
219
|
+
<span>{{ formatFileSize(getUploadForFile(file, index)?.progress.speed || 0) }}/s</span>
|
|
220
|
+
<span>-</span>
|
|
221
|
+
<span>{{ formatDuration(getUploadForFile(file, index)?.progress.remainingTime || 0) }}</span>
|
|
222
|
+
</template>
|
|
223
|
+
|
|
224
|
+
<template v-else>
|
|
225
|
+
<span>-</span>
|
|
226
|
+
<UBadge :color="getStatusColor(getUploadForFile(file, index)?.status || 'idle')" size="xs" variant="subtle">
|
|
227
|
+
{{ getStatusLabel(getUploadForFile(file, index)?.status || 'idle') }}
|
|
228
|
+
</UBadge>
|
|
229
|
+
</template>
|
|
230
|
+
</div>
|
|
231
|
+
|
|
232
|
+
<UProgress
|
|
233
|
+
v-if="getUploadForFile(file, index)?.status === 'uploading' || getUploadForFile(file, index)?.status === 'paused'"
|
|
234
|
+
:color="getUploadForFile(file, index)?.status === 'paused' ? 'warning' : 'primary'"
|
|
235
|
+
:value="getUploadForFile(file, index)?.progress.percentage || 0"
|
|
236
|
+
class="mt-2"
|
|
237
|
+
size="xs"
|
|
238
|
+
/>
|
|
239
|
+
</template>
|
|
240
|
+
|
|
241
|
+
<template v-else>
|
|
242
|
+
<p class="text-xs text-muted">{{ formatFileSize(file.size) }}</p>
|
|
243
|
+
</template>
|
|
244
|
+
</div>
|
|
245
|
+
|
|
246
|
+
<!-- Actions -->
|
|
247
|
+
<div class="flex shrink-0 gap-0.5">
|
|
248
|
+
<UButton
|
|
249
|
+
v-if="getUploadForFile(file, index)?.status === 'uploading'"
|
|
250
|
+
color="neutral"
|
|
251
|
+
icon="i-lucide-pause"
|
|
252
|
+
size="xs"
|
|
253
|
+
variant="ghost"
|
|
254
|
+
@click.stop="pauseUpload(getUploadForFile(file, index)!.id)"
|
|
255
|
+
/>
|
|
256
|
+
<UButton
|
|
257
|
+
v-if="getUploadForFile(file, index)?.status === 'paused'"
|
|
258
|
+
color="primary"
|
|
259
|
+
icon="i-lucide-play"
|
|
260
|
+
size="xs"
|
|
261
|
+
variant="ghost"
|
|
262
|
+
@click.stop="resumeUpload(getUploadForFile(file, index)!.id)"
|
|
263
|
+
/>
|
|
264
|
+
<UButton
|
|
265
|
+
v-if="getUploadForFile(file, index)?.status === 'error'"
|
|
266
|
+
color="warning"
|
|
267
|
+
icon="i-lucide-refresh-cw"
|
|
268
|
+
size="xs"
|
|
269
|
+
variant="ghost"
|
|
270
|
+
@click.stop="retryUpload(getUploadForFile(file, index)!.id)"
|
|
271
|
+
/>
|
|
272
|
+
<UButton
|
|
273
|
+
v-if="getUploadForFile(file, index)?.status !== 'completed'"
|
|
274
|
+
color="error"
|
|
275
|
+
icon="i-lucide-x"
|
|
276
|
+
size="xs"
|
|
277
|
+
variant="ghost"
|
|
278
|
+
@click.stop="cancelUpload(getUploadForFile(file, index)!.id)"
|
|
279
|
+
/>
|
|
280
|
+
</div>
|
|
281
|
+
</div>
|
|
282
|
+
</template>
|
|
283
|
+
|
|
284
|
+
<!-- Actions Slot fuer Bulk-Actions -->
|
|
285
|
+
<template #files-bottom="{ files: fileList }">
|
|
286
|
+
<div v-if="fileList && Array.isArray(fileList) && fileList.length > 1 && uploads.length > 0" class="flex items-center justify-between border-t border-default pt-2">
|
|
287
|
+
<div class="text-xs text-muted">
|
|
288
|
+
<template v-if="isUploading"> {{ totalProgress.percentage }}% - {{ formatFileSize(totalProgress.speed) }}/s </template>
|
|
289
|
+
<template v-else> {{ uploads.filter((u) => u.status === 'completed').length }}/{{ uploads.length }} abgeschlossen </template>
|
|
290
|
+
</div>
|
|
291
|
+
|
|
292
|
+
<div class="flex gap-1">
|
|
293
|
+
<UButton v-if="isUploading" color="neutral" size="xs" variant="ghost" @click="pauseAll"> Alle pausieren </UButton>
|
|
294
|
+
<UButton v-if="uploads.some((u) => u.status === 'paused')" color="primary" size="xs" variant="ghost" @click="resumeAll"> Alle fortsetzen </UButton>
|
|
295
|
+
<UButton v-if="uploads.some((u) => u.status === 'completed')" color="neutral" size="xs" variant="ghost" @click="clearCompleted"> Fertige entfernen </UButton>
|
|
296
|
+
<UButton v-if="uploads.length > 0" color="error" size="xs" variant="ghost" @click="cancelAll"> Alle abbrechen </UButton>
|
|
297
|
+
</div>
|
|
298
|
+
</div>
|
|
299
|
+
</template>
|
|
300
|
+
</UFileUpload>
|
|
301
|
+
</div>
|
|
302
|
+
</template>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { authClient } from '~/lib/auth-client';
|
|
2
|
+
|
|
3
|
+
export function useBetterAuth() {
|
|
4
|
+
const session = authClient.useSession();
|
|
5
|
+
|
|
6
|
+
const user = computed<null | User>(() => (session.value.data?.user as User) ?? null);
|
|
7
|
+
const isAuthenticated = computed<boolean>(() => !!user.value);
|
|
8
|
+
const isAdmin = computed<boolean>(() => user.value?.role === 'admin');
|
|
9
|
+
const is2FAEnabled = computed<boolean>(() => user.value?.twoFactorEnabled ?? false);
|
|
10
|
+
const isLoading = computed<boolean>(() => session.value.isPending);
|
|
11
|
+
|
|
12
|
+
return {
|
|
13
|
+
is2FAEnabled,
|
|
14
|
+
isAdmin,
|
|
15
|
+
isAuthenticated,
|
|
16
|
+
isLoading,
|
|
17
|
+
passkey: authClient.passkey,
|
|
18
|
+
session,
|
|
19
|
+
signIn: authClient.signIn,
|
|
20
|
+
signOut: authClient.signOut,
|
|
21
|
+
signUp: authClient.signUp,
|
|
22
|
+
twoFactor: authClient.twoFactor,
|
|
23
|
+
user,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
@@ -8,11 +8,13 @@ interface FileInfo {
|
|
|
8
8
|
}
|
|
9
9
|
|
|
10
10
|
export function useFile() {
|
|
11
|
-
const
|
|
11
|
+
const config = useRuntimeConfig();
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
function isValidMongoID(id: string): boolean {
|
|
14
|
+
return /^[a-f\d]{24}$/i.test(id);
|
|
15
|
+
}
|
|
15
16
|
|
|
17
|
+
async function getFileInfo(id: string | undefined): Promise<FileInfo | null | string> {
|
|
16
18
|
if (!id) {
|
|
17
19
|
return null;
|
|
18
20
|
}
|
|
@@ -23,6 +25,7 @@ export function useFile() {
|
|
|
23
25
|
|
|
24
26
|
try {
|
|
25
27
|
const response = await $fetch<FileInfo>(config.public.host + '/files/info/' + id, {
|
|
28
|
+
credentials: 'include',
|
|
26
29
|
method: 'GET',
|
|
27
30
|
});
|
|
28
31
|
return response;
|
|
@@ -32,5 +35,37 @@ export function useFile() {
|
|
|
32
35
|
}
|
|
33
36
|
}
|
|
34
37
|
|
|
35
|
-
|
|
38
|
+
function getFileUrl(id: string): string {
|
|
39
|
+
return `${config.public.host}/files/${id}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function getDownloadUrl(id: string, filename?: string): string {
|
|
43
|
+
const base = `${config.public.host}/files/download/${id}`;
|
|
44
|
+
return filename ? `${base}?filename=${encodeURIComponent(filename)}` : base;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function formatFileSize(bytes: number): string {
|
|
48
|
+
if (bytes === 0) return '0 B';
|
|
49
|
+
const k = 1024;
|
|
50
|
+
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
51
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
52
|
+
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function formatDuration(seconds: number): string {
|
|
56
|
+
if (seconds < 60) return `${seconds}s`;
|
|
57
|
+
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
|
|
58
|
+
const hours = Math.floor(seconds / 3600);
|
|
59
|
+
const minutes = Math.floor((seconds % 3600) / 60);
|
|
60
|
+
return `${hours}h ${minutes}m`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
formatDuration,
|
|
65
|
+
formatFileSize,
|
|
66
|
+
getDownloadUrl,
|
|
67
|
+
getFileInfo,
|
|
68
|
+
getFileUrl,
|
|
69
|
+
isValidMongoID,
|
|
70
|
+
};
|
|
36
71
|
}
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import * as tus from 'tus-js-client';
|
|
2
|
+
|
|
3
|
+
import type { UploadItem, UploadOptions, UploadProgress, UseTusUploadReturn } from '~/interfaces/upload.interface';
|
|
4
|
+
|
|
5
|
+
export function useTusUpload(defaultOptions: UploadOptions = {}): UseTusUploadReturn {
|
|
6
|
+
const config = useRuntimeConfig();
|
|
7
|
+
|
|
8
|
+
// State
|
|
9
|
+
const uploadItems = ref<Map<string, UploadItem>>(new Map());
|
|
10
|
+
const tusUploads = ref<Map<string, tus.Upload>>(new Map());
|
|
11
|
+
|
|
12
|
+
// Default config
|
|
13
|
+
const defaultConfig: UploadOptions = {
|
|
14
|
+
autoStart: true,
|
|
15
|
+
chunkSize: 5 * 1024 * 1024, // 5MB chunks
|
|
16
|
+
endpoint: `${config.public.host}/files/upload`,
|
|
17
|
+
parallelUploads: 3,
|
|
18
|
+
retryDelays: [0, 1000, 3000, 5000, 10000],
|
|
19
|
+
...defaultOptions,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// Computed
|
|
23
|
+
const uploads = computed(() => Array.from(uploadItems.value.values()));
|
|
24
|
+
const isUploading = computed(() => uploads.value.some((u) => u.status === 'uploading'));
|
|
25
|
+
const totalProgress = computed<UploadProgress>(() => {
|
|
26
|
+
const items = uploads.value;
|
|
27
|
+
if (items.length === 0) {
|
|
28
|
+
return { bytesTotal: 0, bytesUploaded: 0, percentage: 0, remainingTime: 0, speed: 0 };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const bytesUploaded = items.reduce((acc, i) => acc + i.progress.bytesUploaded, 0);
|
|
32
|
+
const bytesTotal = items.reduce((acc, i) => acc + i.progress.bytesTotal, 0);
|
|
33
|
+
const speed = items.reduce((acc, i) => acc + i.progress.speed, 0);
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
bytesTotal,
|
|
37
|
+
bytesUploaded,
|
|
38
|
+
percentage: bytesTotal > 0 ? Math.round((bytesUploaded / bytesTotal) * 100) : 0,
|
|
39
|
+
remainingTime: speed > 0 ? Math.ceil((bytesTotal - bytesUploaded) / speed) : 0,
|
|
40
|
+
speed,
|
|
41
|
+
};
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// Helper: Generate unique ID
|
|
45
|
+
function generateId(): string {
|
|
46
|
+
return `upload_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Helper: Calculate speed with smoothing
|
|
50
|
+
function createSpeedTracker() {
|
|
51
|
+
let lastBytes = 0;
|
|
52
|
+
let lastTime = Date.now();
|
|
53
|
+
let smoothedSpeed = 0;
|
|
54
|
+
|
|
55
|
+
return (bytesUploaded: number): number => {
|
|
56
|
+
const now = Date.now();
|
|
57
|
+
const timeDiff = (now - lastTime) / 1000;
|
|
58
|
+
const bytesDiff = bytesUploaded - lastBytes;
|
|
59
|
+
|
|
60
|
+
if (timeDiff > 0) {
|
|
61
|
+
const currentSpeed = bytesDiff / timeDiff;
|
|
62
|
+
// Exponential moving average for smoother display
|
|
63
|
+
smoothedSpeed = smoothedSpeed === 0 ? currentSpeed : smoothedSpeed * 0.7 + currentSpeed * 0.3;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
lastBytes = bytesUploaded;
|
|
67
|
+
lastTime = now;
|
|
68
|
+
|
|
69
|
+
return Math.round(smoothedSpeed);
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Update item in map (triggers reactivity)
|
|
74
|
+
function updateItem(id: string, updates: Partial<UploadItem>): void {
|
|
75
|
+
const item = uploadItems.value.get(id);
|
|
76
|
+
if (item) {
|
|
77
|
+
const newMap = new Map(uploadItems.value);
|
|
78
|
+
newMap.set(id, { ...item, ...updates });
|
|
79
|
+
uploadItems.value = newMap;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Create TUS upload instance
|
|
84
|
+
function createTusUpload(item: UploadItem, options: UploadOptions): tus.Upload {
|
|
85
|
+
const speedTracker = createSpeedTracker();
|
|
86
|
+
|
|
87
|
+
return new tus.Upload(item.file, {
|
|
88
|
+
chunkSize: options.chunkSize || defaultConfig.chunkSize,
|
|
89
|
+
endpoint: options.endpoint || defaultConfig.endpoint,
|
|
90
|
+
headers: options.headers,
|
|
91
|
+
metadata: {
|
|
92
|
+
filename: item.file.name,
|
|
93
|
+
filetype: item.file.type,
|
|
94
|
+
...options.metadata,
|
|
95
|
+
...item.metadata,
|
|
96
|
+
},
|
|
97
|
+
onBeforeRequest: (req) => {
|
|
98
|
+
const xhr = req.getUnderlyingObject() as XMLHttpRequest;
|
|
99
|
+
xhr.withCredentials = true;
|
|
100
|
+
},
|
|
101
|
+
onError: (error) => {
|
|
102
|
+
updateItem(item.id, {
|
|
103
|
+
error: error.message,
|
|
104
|
+
status: 'error',
|
|
105
|
+
});
|
|
106
|
+
options.onError?.(uploadItems.value.get(item.id)!, error);
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
onProgress: (bytesUploaded, bytesTotal) => {
|
|
110
|
+
const speed = speedTracker(bytesUploaded);
|
|
111
|
+
const percentage = Math.round((bytesUploaded / bytesTotal) * 100);
|
|
112
|
+
const remainingTime = speed > 0 ? Math.ceil((bytesTotal - bytesUploaded) / speed) : 0;
|
|
113
|
+
|
|
114
|
+
updateItem(item.id, {
|
|
115
|
+
progress: { bytesTotal, bytesUploaded, percentage, remainingTime, speed },
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
options.onProgress?.(uploadItems.value.get(item.id)!);
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
onShouldRetry: (err) => {
|
|
122
|
+
const status = (err as { originalResponse?: { getStatus?: () => number } }).originalResponse?.getStatus?.();
|
|
123
|
+
// Don't retry on 4xx errors (except 429 Too Many Requests)
|
|
124
|
+
if (status && status >= 400 && status < 500 && status !== 429) {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
return true;
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
onSuccess: () => {
|
|
131
|
+
const tusUpload = tusUploads.value.get(item.id);
|
|
132
|
+
const currentItem = uploadItems.value.get(item.id);
|
|
133
|
+
updateItem(item.id, {
|
|
134
|
+
completedAt: new Date(),
|
|
135
|
+
progress: { ...currentItem!.progress, percentage: 100 },
|
|
136
|
+
status: 'completed',
|
|
137
|
+
url: tusUpload?.url ?? undefined,
|
|
138
|
+
});
|
|
139
|
+
options.onSuccess?.(uploadItems.value.get(item.id)!);
|
|
140
|
+
},
|
|
141
|
+
|
|
142
|
+
retryDelays: options.retryDelays || defaultConfig.retryDelays,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Actions
|
|
147
|
+
function addFiles(files: File | File[]): string[] {
|
|
148
|
+
const fileArray = Array.isArray(files) ? files : [files];
|
|
149
|
+
const ids: string[] = [];
|
|
150
|
+
|
|
151
|
+
for (const file of fileArray) {
|
|
152
|
+
const id = generateId();
|
|
153
|
+
const item: UploadItem = {
|
|
154
|
+
file,
|
|
155
|
+
id,
|
|
156
|
+
metadata: defaultConfig.metadata,
|
|
157
|
+
progress: { bytesTotal: file.size, bytesUploaded: 0, percentage: 0, remainingTime: 0, speed: 0 },
|
|
158
|
+
status: 'idle',
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const newMap = new Map(uploadItems.value);
|
|
162
|
+
newMap.set(id, item);
|
|
163
|
+
uploadItems.value = newMap;
|
|
164
|
+
|
|
165
|
+
const tusUpload = createTusUpload(item, defaultConfig);
|
|
166
|
+
tusUploads.value.set(id, tusUpload);
|
|
167
|
+
|
|
168
|
+
ids.push(id);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (defaultConfig.autoStart) {
|
|
172
|
+
startAll();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return ids;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function startUpload(id: string): void {
|
|
179
|
+
const item = uploadItems.value.get(id);
|
|
180
|
+
const tusUpload = tusUploads.value.get(id);
|
|
181
|
+
|
|
182
|
+
if (item && tusUpload && item.status !== 'uploading') {
|
|
183
|
+
updateItem(id, { startedAt: new Date(), status: 'uploading' });
|
|
184
|
+
|
|
185
|
+
// Check for previous uploads to resume
|
|
186
|
+
tusUpload.findPreviousUploads().then((previousUploads) => {
|
|
187
|
+
const previousUpload = previousUploads[0];
|
|
188
|
+
if (previousUpload) {
|
|
189
|
+
tusUpload.resumeFromPreviousUpload(previousUpload);
|
|
190
|
+
}
|
|
191
|
+
tusUpload.start();
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function startAll(): void {
|
|
197
|
+
const pending = uploads.value.filter((u) => u.status === 'idle' || u.status === 'paused');
|
|
198
|
+
const currentlyUploading = uploads.value.filter((u) => u.status === 'uploading').length;
|
|
199
|
+
const limit = (defaultConfig.parallelUploads || 3) - currentlyUploading;
|
|
200
|
+
|
|
201
|
+
pending.slice(0, Math.max(0, limit)).forEach((item) => startUpload(item.id));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function pauseUpload(id: string): void {
|
|
205
|
+
const tusUpload = tusUploads.value.get(id);
|
|
206
|
+
if (tusUpload) {
|
|
207
|
+
tusUpload.abort();
|
|
208
|
+
updateItem(id, { status: 'paused' });
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function pauseAll(): void {
|
|
213
|
+
uploads.value.filter((u) => u.status === 'uploading').forEach((item) => pauseUpload(item.id));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function resumeUpload(id: string): void {
|
|
217
|
+
startUpload(id);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function resumeAll(): void {
|
|
221
|
+
uploads.value.filter((u) => u.status === 'paused').forEach((item) => resumeUpload(item.id));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function cancelUpload(id: string): void {
|
|
225
|
+
const tusUpload = tusUploads.value.get(id);
|
|
226
|
+
if (tusUpload) {
|
|
227
|
+
tusUpload.abort();
|
|
228
|
+
}
|
|
229
|
+
tusUploads.value.delete(id);
|
|
230
|
+
|
|
231
|
+
const newMap = new Map(uploadItems.value);
|
|
232
|
+
newMap.delete(id);
|
|
233
|
+
uploadItems.value = newMap;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function cancelAll(): void {
|
|
237
|
+
uploads.value.forEach((item) => cancelUpload(item.id));
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function removeUpload(id: string): void {
|
|
241
|
+
cancelUpload(id);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function clearCompleted(): void {
|
|
245
|
+
uploads.value.filter((u) => u.status === 'completed').forEach((item) => removeUpload(item.id));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function retryUpload(id: string): void {
|
|
249
|
+
const item = uploadItems.value.get(id);
|
|
250
|
+
if (item && item.status === 'error') {
|
|
251
|
+
updateItem(id, { error: undefined, status: 'idle' });
|
|
252
|
+
startUpload(id);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function getUpload(id: string): undefined | UploadItem {
|
|
257
|
+
return uploadItems.value.get(id);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return {
|
|
261
|
+
addFiles,
|
|
262
|
+
cancelAll,
|
|
263
|
+
cancelUpload,
|
|
264
|
+
clearCompleted,
|
|
265
|
+
getUpload,
|
|
266
|
+
isUploading,
|
|
267
|
+
pauseAll,
|
|
268
|
+
pauseUpload,
|
|
269
|
+
removeUpload,
|
|
270
|
+
resumeAll,
|
|
271
|
+
resumeUpload,
|
|
272
|
+
retryUpload,
|
|
273
|
+
startAll,
|
|
274
|
+
startUpload,
|
|
275
|
+
totalProgress,
|
|
276
|
+
uploads,
|
|
277
|
+
};
|
|
278
|
+
}
|