From bd8b21955e965a9e6c51cff13aabf1a859c7786d Mon Sep 17 00:00:00 2001 From: lethdat Date: Wed, 18 Mar 2026 22:23:11 +0700 Subject: [PATCH] feat: add BaseTable component for improved table rendering - Introduced a new BaseTable component to enhance table functionality with sorting and loading states. - Updated upload queue logic to support chunk uploads and improved error handling. - Refactored various admin routes to utilize the new BaseTable component. - Adjusted import paths for UI components to maintain consistency. - Enhanced upload handling with better progress tracking and cancellation support. - Updated theme colors in uno.config.ts for a more cohesive design. --- .dockerignore | 68 ++++ Dockerfile | 39 +++ components.d.ts | 32 +- src/components/app/AppSwitch.vue | 46 --- src/components/{app => ui}/AppButton.vue | 2 +- src/components/{app => ui}/AppConfirmHost.vue | 4 +- src/components/{app => ui}/AppDialog.vue | 0 src/components/{app => ui}/AppInput.vue | 0 src/components/{app => ui}/AppProgressBar.vue | 0 src/components/ui/AppSwitch.vue | 55 ++++ src/components/{app => ui}/AppToastHost.vue | 0 src/components/ui/{table => }/BaseTable.vue | 0 src/composables/useUploadQueue.ts | 302 +++++++++++------- src/routes/admin/AdTemplates.vue | 8 +- src/routes/admin/Agents.vue | 6 +- src/routes/admin/Jobs.vue | 8 +- src/routes/admin/Layout.vue | 9 +- src/routes/admin/Logs.vue | 4 +- src/routes/admin/Payments.vue | 8 +- src/routes/admin/Plans.vue | 6 +- src/routes/admin/Users.vue | 8 +- src/routes/admin/Videos.vue | 8 +- .../components/AdminPlaceholderTable.vue | 2 +- .../overview/components/RecentVideos.vue | 2 +- src/routes/settings/AdsVast/AdsVast.vue | 10 +- src/routes/settings/Billing/Billing.vue | 23 +- src/routes/settings/DangerZone/DangerZone.vue | 2 +- src/routes/settings/DomainsDns/DomainsDns.vue | 8 +- .../NotificationSettings.vue | 4 +- .../PlayerSettings/PlayerSettings.vue | 4 +- .../SecurityNConnected/SecurityNConnected.vue | 6 +- src/routes/settings/Settings.vue | 4 +- .../components/SettingsTableSkeleton.vue | 2 +- .../components/billing/BillingTopupDialog.vue | 6 +- .../components/billing/BillingWalletRow.vue | 2 +- src/routes/upload/Upload.vue | 6 +- .../upload/components/UploadDropzone.vue | 2 +- src/routes/video/components/VideoTable.vue | 2 +- uno.config.ts | 4 +- 39 files changed, 429 insertions(+), 273 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile delete mode 100644 src/components/app/AppSwitch.vue rename src/components/{app => ui}/AppButton.vue (98%) rename src/components/{app => ui}/AppConfirmHost.vue (91%) rename src/components/{app => ui}/AppDialog.vue (100%) rename src/components/{app => ui}/AppInput.vue (100%) rename src/components/{app => ui}/AppProgressBar.vue (100%) create mode 100644 src/components/ui/AppSwitch.vue rename src/components/{app => ui}/AppToastHost.vue (100%) rename src/components/ui/{table => }/BaseTable.vue (100%) diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5f81cec --- /dev/null +++ b/.dockerignore @@ -0,0 +1,68 @@ +# Dependencies +node_modules +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# Build outputs +dist +build +.rsbuild +node_modules +/node_modules +# Environment files +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# IDE and editor files +.vscode +.idea +*.swp +*.swo +*~ + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Git +.git +.gitignore + +# Docker +Dockerfile +.dockerignore +docker-compose.yml + +# Documentation +README.md +*.md + +# Test files +coverage +.coverage +.nyc_output +test +tests +__tests__ +*.test.js +*.test.ts +*.spec.js +*.spec.ts + +# Linting +.eslintrc* +.prettierrc* +.stylelintrc* + +# Other +.husky \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..71ceaa0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,39 @@ +# ---------- Builder stage ---------- +FROM oven/bun:1.3.10-alpine AS builder + +WORKDIR /app + +# Copy lockfiles & package.json +COPY package*.json ./ +COPY bun.lockb* ./ +COPY yarn.lock* ./ +COPY pnpm-lock.yaml* ./ + +# Install dependencies (cached) +RUN --mount=type=cache,target=/root/.bun bun install + +# Copy source +COPY . . + +# Build app (RSBuild output -> dist) +RUN bun run build + + +# ---------- Production stage ---------- +FROM oven/bun:1.3.10-alpine AS production + +WORKDIR /app + +# Copy built files +COPY --from=builder /app/dist ./dist + +ENV NODE_ENV=production +# Expose port +EXPOSE 3000 + +# Optional health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget -qO- http://localhost:3000/ || exit 1 + +# Run Bun with fallback install (auto resolves missing deps) +CMD [ "bun", "--bun", "dist" ] \ No newline at end of file diff --git a/components.d.ts b/components.d.ts index 6f13e16..70611a9 100644 --- a/components.d.ts +++ b/components.d.ts @@ -17,18 +17,18 @@ declare module 'vue' { AdvertisementIcon: typeof import('./src/components/icons/AdvertisementIcon.vue')['default'] AlertTriangle: typeof import('./src/components/icons/AlertTriangle.vue')['default'] AlertTriangleIcon: typeof import('./src/components/icons/AlertTriangleIcon.vue')['default'] - AppButton: typeof import('./src/components/app/AppButton.vue')['default'] - AppConfirmHost: typeof import('./src/components/app/AppConfirmHost.vue')['default'] - AppDialog: typeof import('./src/components/app/AppDialog.vue')['default'] - AppInput: typeof import('./src/components/app/AppInput.vue')['default'] - AppProgressBar: typeof import('./src/components/app/AppProgressBar.vue')['default'] - AppSwitch: typeof import('./src/components/app/AppSwitch.vue')['default'] - AppToastHost: typeof import('./src/components/app/AppToastHost.vue')['default'] + AppButton: typeof import('./src/components/ui/AppButton.vue')['default'] + AppConfirmHost: typeof import('./src/components/ui/AppConfirmHost.vue')['default'] + AppDialog: typeof import('./src/components/ui/AppDialog.vue')['default'] + AppInput: typeof import('./src/components/ui/AppInput.vue')['default'] + AppProgressBar: typeof import('./src/components/ui/AppProgressBar.vue')['default'] + AppSwitch: typeof import('./src/components/ui/AppSwitch.vue')['default'] + AppToastHost: typeof import('./src/components/ui/AppToastHost.vue')['default'] AppTopLoadingBar: typeof import('./src/components/AppTopLoadingBar.vue')['default'] ArrowDownTray: typeof import('./src/components/icons/ArrowDownTray.vue')['default'] ArrowRightIcon: typeof import('./src/components/icons/ArrowRightIcon.vue')['default'] AsyncSelect: typeof import('./src/components/ui/AsyncSelect.vue')['default'] - BaseTable: typeof import('./src/components/ui/table/BaseTable.vue')['default'] + BaseTable: typeof import('./src/components/ui/BaseTable.vue')['default'] Bell: typeof import('./src/components/icons/Bell.vue')['default'] BellIcon: typeof import('./src/components/icons/BellIcon.vue')['default'] Chart: typeof import('./src/components/icons/Chart.vue')['default'] @@ -99,18 +99,18 @@ declare global { const AdvertisementIcon: typeof import('./src/components/icons/AdvertisementIcon.vue')['default'] const AlertTriangle: typeof import('./src/components/icons/AlertTriangle.vue')['default'] const AlertTriangleIcon: typeof import('./src/components/icons/AlertTriangleIcon.vue')['default'] - const AppButton: typeof import('./src/components/app/AppButton.vue')['default'] - const AppConfirmHost: typeof import('./src/components/app/AppConfirmHost.vue')['default'] - const AppDialog: typeof import('./src/components/app/AppDialog.vue')['default'] - const AppInput: typeof import('./src/components/app/AppInput.vue')['default'] - const AppProgressBar: typeof import('./src/components/app/AppProgressBar.vue')['default'] - const AppSwitch: typeof import('./src/components/app/AppSwitch.vue')['default'] - const AppToastHost: typeof import('./src/components/app/AppToastHost.vue')['default'] + const AppButton: typeof import('./src/components/ui/AppButton.vue')['default'] + const AppConfirmHost: typeof import('./src/components/ui/AppConfirmHost.vue')['default'] + const AppDialog: typeof import('./src/components/ui/AppDialog.vue')['default'] + const AppInput: typeof import('./src/components/ui/AppInput.vue')['default'] + const AppProgressBar: typeof import('./src/components/ui/AppProgressBar.vue')['default'] + const AppSwitch: typeof import('./src/components/ui/AppSwitch.vue')['default'] + const AppToastHost: typeof import('./src/components/ui/AppToastHost.vue')['default'] const AppTopLoadingBar: typeof import('./src/components/AppTopLoadingBar.vue')['default'] const ArrowDownTray: typeof import('./src/components/icons/ArrowDownTray.vue')['default'] const ArrowRightIcon: typeof import('./src/components/icons/ArrowRightIcon.vue')['default'] const AsyncSelect: typeof import('./src/components/ui/AsyncSelect.vue')['default'] - const BaseTable: typeof import('./src/components/ui/table/BaseTable.vue')['default'] + const BaseTable: typeof import('./src/components/ui/BaseTable.vue')['default'] const Bell: typeof import('./src/components/icons/Bell.vue')['default'] const BellIcon: typeof import('./src/components/icons/BellIcon.vue')['default'] const Chart: typeof import('./src/components/icons/Chart.vue')['default'] diff --git a/src/components/app/AppSwitch.vue b/src/components/app/AppSwitch.vue deleted file mode 100644 index 96ed048..0000000 --- a/src/components/app/AppSwitch.vue +++ /dev/null @@ -1,46 +0,0 @@ - - - diff --git a/src/components/app/AppButton.vue b/src/components/ui/AppButton.vue similarity index 98% rename from src/components/app/AppButton.vue rename to src/components/ui/AppButton.vue index 6de6dc7..76a74bd 100644 --- a/src/components/app/AppButton.vue +++ b/src/components/ui/AppButton.vue @@ -11,7 +11,7 @@ const props = withDefaults( type?: 'button' | 'submit' | 'reset'; }>(), { - variant: 'secondary', + variant: 'primary', size: 'md', block: false, disabled: false, diff --git a/src/components/app/AppConfirmHost.vue b/src/components/ui/AppConfirmHost.vue similarity index 91% rename from src/components/app/AppConfirmHost.vue rename to src/components/ui/AppConfirmHost.vue index 7469dbd..e0c5108 100644 --- a/src/components/app/AppConfirmHost.vue +++ b/src/components/ui/AppConfirmHost.vue @@ -1,6 +1,6 @@ + + \ No newline at end of file diff --git a/src/components/app/AppToastHost.vue b/src/components/ui/AppToastHost.vue similarity index 100% rename from src/components/app/AppToastHost.vue rename to src/components/ui/AppToastHost.vue diff --git a/src/components/ui/table/BaseTable.vue b/src/components/ui/BaseTable.vue similarity index 100% rename from src/components/ui/table/BaseTable.vue rename to src/components/ui/BaseTable.vue diff --git a/src/composables/useUploadQueue.ts b/src/composables/useUploadQueue.ts index 30a56d4..2dead24 100644 --- a/src/composables/useUploadQueue.ts +++ b/src/composables/useUploadQueue.ts @@ -1,4 +1,3 @@ -import { client as rpcClient } from '@/api/rpcclient'; import { computed, ref } from 'vue'; export interface QueueItem { @@ -11,30 +10,36 @@ export interface QueueItem { total?: string; speed?: string; thumbnail?: string; - file?: File; - url?: string; - playbackUrl?: string; - videoId?: string; - objectKey?: string; + file?: File; // Keep reference to file for local uploads + url?: string; // Keep reference to url for remote uploads + // Upload chunk tracking + activeChunks?: number; + uploadedUrls?: string[]; cancelled?: boolean; } const items = ref([]); + +// Upload limits const MAX_ITEMS = 5; + +// Chunk upload configuration +const CHUNK_SIZE = 90 * 1024 * 1024; // 90MB per chunk +const MAX_PARALLEL = 3; const MAX_RETRY = 3; -const activeXhrs = new Map(); +// Track active XHRs per item id so we can abort them on cancel +const activeXhrs = new Map>(); const abortItem = (id: string) => { - const xhr = activeXhrs.get(id); - if (xhr) { - xhr.abort(); + const xhrs = activeXhrs.get(id); + if (xhrs) { + xhrs.forEach(xhr => xhr.abort()); activeXhrs.delete(id); } }; export function useUploadQueue() { - const t = (key: string, params?: Record) => key; const remainingSlots = computed(() => Math.max(0, MAX_ITEMS - items.value.length)); @@ -60,9 +65,11 @@ export function useUploadQueue() { uploaded: '0 MB', total: formatSize(file.size), speed: '0 MB/s', - file, + file: file, thumbnail: undefined, - cancelled: false, + activeChunks: 0, + uploadedUrls: [], + cancelled: false })); items.value.push(...newItems); @@ -75,15 +82,17 @@ export function useUploadQueue() { const duplicateCount = allowed.length - fresh.length; const newItems: QueueItem[] = fresh.map((url) => ({ id: Math.random().toString(36).substring(2, 9), - name: url.split('/').pop() || t('upload.queueItem.remoteFileName'), + name: url.split('/').pop() || 'Remote File', type: 'remote', status: 'pending', progress: 0, uploaded: '0 MB', - total: t('upload.queueItem.unknownSize'), + total: 'Unknown', speed: '0 MB/s', - url, - cancelled: false, + url: url, + activeChunks: 0, + uploadedUrls: [], + cancelled: false })); items.value.push(...newItems); @@ -104,6 +113,7 @@ export function useUploadQueue() { if (item) { item.cancelled = true; item.status = 'error'; + item.activeChunks = 0; item.speed = '0 MB/s'; } }; @@ -112,7 +122,7 @@ export function useUploadQueue() { items.value.forEach(item => { if (item.status === 'pending') { if (item.type === 'local') { - startUpload(item.id); + startChunkUpload(item.id); } else { startMockRemoteFetch(item.id); } @@ -120,147 +130,185 @@ export function useUploadQueue() { }); }; - const startUpload = async (id: string) => { + // Real Chunk Upload Logic + const startChunkUpload = async (id: string) => { const item = items.value.find(i => i.id === id); if (!item || !item.file) return; item.status = 'uploading'; - item.progress = 0; - item.uploaded = '0 MB'; - item.speed = '0 MB/s'; + item.activeChunks = 0; + item.uploadedUrls = []; - try { - const response = await rpcClient.getUploadUrl({ filename: item.file.name }); - if (!response.uploadUrl || !response.key) { - throw new Error(t('upload.errors.mergeFailed')); + const file = item.file; + const totalChunks = Math.ceil(file.size / CHUNK_SIZE); + const progressMap = new Map(); // chunk index -> uploaded bytes + const queue: number[] = Array.from({ length: totalChunks }, (_, i) => i); + + const updateProgress = () => { + let totalUploaded = 0; + progressMap.forEach(value => { + totalUploaded += value; + }); + const percent = Math.min((totalUploaded / file.size) * 100, 100); + item.progress = parseFloat(percent.toFixed(1)); + item.uploaded = formatSize(totalUploaded); + + // Calculate speed (simplified) + const currentSpeed = item.activeChunks ? item.activeChunks * 2 * 1024 * 1024 : 0; + item.speed = formatSize(currentSpeed) + '/s'; + }; + + const processQueue = async () => { + if (item.cancelled) return; + + const activePromises: Promise[] = []; + + while ((item.activeChunks || 0) < MAX_PARALLEL && queue.length > 0) { + const index = queue.shift()!; + item.activeChunks = (item.activeChunks || 0) + 1; + + const promise = uploadChunk(index, file, progressMap, updateProgress, item) + .then(() => { + item.activeChunks = (item.activeChunks || 0) - 1; + }); + activePromises.push(promise); } - item.objectKey = response.key; - await uploadFileToPresignedUrl(item, response.uploadUrl); + if (activePromises.length > 0) { + await Promise.all(activePromises); + await processQueue(); + } + }; + + try { + await processQueue(); if (!item.cancelled) { item.status = 'processing'; await completeUpload(item); } } catch (error) { - if (!item.cancelled) { - item.status = 'error'; - console.error('Upload failed:', error); - } + item.status = 'error'; + console.error('Upload failed:', error); } }; - const uploadFileToPresignedUrl = async (item: QueueItem, uploadUrl: string) => { - if (!item.file) return; - - for (let attempt = 1; attempt <= MAX_RETRY; attempt++) { - try { - await sendFile(item, uploadUrl); - return; - } catch (error) { - if (item.cancelled) { - return; - } - if (attempt === MAX_RETRY) { - throw error; - } - } - } - }; - - const sendFile = (item: QueueItem, uploadUrl: string): Promise => { + const uploadChunk = ( + index: number, + file: File, + progressMap: Map, + updateProgress: () => void, + item: QueueItem + ): Promise => { return new Promise((resolve, reject) => { - if (!item.file) { - resolve(); - return; - } + let retry = 0; - const xhr = new XMLHttpRequest(); - const startedAt = Date.now(); + const attempt = () => { + if (item.cancelled) return resolve(); - activeXhrs.set(item.id, xhr); - xhr.open('PUT', uploadUrl); - if (item.file.type) { - xhr.setRequestHeader('Content-Type', item.file.type); - } + const start = index * CHUNK_SIZE; + const end = Math.min(start + CHUNK_SIZE, file.size); + const chunk = file.slice(start, end); - const cleanup = () => { - if (activeXhrs.get(item.id) === xhr) { - activeXhrs.delete(item.id); + const formData = new FormData(); + formData.append('file', chunk, file.name); + + const xhr = new XMLHttpRequest(); + xhr.open('POST', 'https://tmpfiles.org/api/v1/upload'); + + // Register this XHR so it can be aborted on cancel + if (!activeXhrs.has(item.id)) activeXhrs.set(item.id, new Set()); + activeXhrs.get(item.id)!.add(xhr); + + const unregister = () => activeXhrs.get(item.id)?.delete(xhr); + + xhr.upload.onprogress = (e) => { + if (e.lengthComputable) { + progressMap.set(index, e.loaded); + updateProgress(); + } + }; + + xhr.onload = function () { + unregister(); + if (item.cancelled) return resolve(); + if (xhr.status === 200) { + try { + const res = JSON.parse(xhr.responseText); + if (res.status === 'success') { + progressMap.set(index, chunk.size); + if (item.uploadedUrls) { + item.uploadedUrls[index] = res.data.url; + } + updateProgress(); + resolve(); + return; + } + } catch { + handleError(); + } + } + handleError(); + }; + + xhr.onabort = () => { + unregister(); + resolve(); // treat abort as graceful completion — processQueue will short-circuit via item.cancelled + }; + + xhr.onerror = () => { + unregister(); + handleError(); + }; + + function handleError() { + retry++; + if (retry <= MAX_RETRY) { + setTimeout(attempt, 2000); + } else { + item.status = 'error'; + reject(new Error(`Failed to upload chunk ${index + 1}`)); + } } + + xhr.send(formData); }; - xhr.upload.onprogress = (event) => { - if (!event.lengthComputable || !item.file) return; - - const uploadedBytes = event.loaded; - const percent = Math.min((uploadedBytes / item.file.size) * 100, 100); - const elapsedSeconds = Math.max((Date.now() - startedAt) / 1000, 0.001); - const speed = uploadedBytes / elapsedSeconds; - - item.progress = parseFloat(percent.toFixed(1)); - item.uploaded = formatSize(uploadedBytes); - item.total = formatSize(item.file.size); - item.speed = `${formatSize(speed)}/s`; - }; - - xhr.onload = () => { - cleanup(); - if (item.cancelled) { - resolve(); - return; - } - if (xhr.status >= 200 && xhr.status < 300) { - item.progress = 100; - item.uploaded = item.total; - item.speed = '0 MB/s'; - resolve(); - return; - } - reject(new Error(t('upload.errors.chunkUploadFailed', { index: 1 }))); - }; - - xhr.onerror = () => { - cleanup(); - reject(new Error(t('upload.errors.chunkUploadFailed', { index: 1 }))); - }; - - xhr.onabort = () => { - cleanup(); - resolve(); - }; - - xhr.send(item.file); + attempt(); }); }; const completeUpload = async (item: QueueItem) => { - if (!item.file || !item.objectKey) return; + if (!item.file || !item.uploadedUrls) return; try { - const createResponse = await rpcClient.createVideo({ - title: item.file.name.replace(/\.[^.]+$/, ''), - description: '', - url: item.objectKey, - size: item.file.size, - duration: 0, - format: item.file.type || 'video/mp4', + const response = await fetch('/merge', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + filename: item.file.name, + chunks: item.uploadedUrls, + size: item.file.size + }) }); - const createdVideo = createResponse.video; - item.videoId = createdVideo?.id; - item.playbackUrl = createdVideo?.url || item.objectKey; - item.url = createdVideo?.url || item.objectKey; + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || 'Merge failed'); + } + item.status = 'complete'; item.progress = 100; item.uploaded = item.total; item.speed = '0 MB/s'; } catch (error) { item.status = 'error'; - console.error('Create video failed:', error); + console.error('Merge failed:', error); } }; + // Mock Remote Fetch Logic const startMockRemoteFetch = (id: string) => { const item = items.value.find(i => i.id === id); if (!item) return; @@ -273,13 +321,13 @@ export function useUploadQueue() { }, 3000 + Math.random() * 3000); }; + const formatSize = (bytes: number): string => { if (bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); - const value = parseFloat((bytes / Math.pow(k, i)).toFixed(2)); - return `${value} ${sizes[i]}`; + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; }; const totalSize = computed(() => { @@ -297,11 +345,17 @@ export function useUploadQueue() { const pendingCount = computed(() => { return items.value.filter(i => i.status === 'pending').length; }); - function removeAll() { items.value = []; } - + // watch(items, (newItems) => { + // // console.log(newItems); + // if (newItems.length === 0) return; + // if (newItems.filter(i => i.status === 'pending' || i.status === 'uploading').length === 0) { + // // startQueue(); + // items.value = []; + // } + // }, { deep: true }); return { items, addFiles, @@ -316,4 +370,4 @@ export function useUploadQueue() { remainingSlots, maxItems: MAX_ITEMS, }; -} +} \ No newline at end of file diff --git a/src/routes/admin/AdTemplates.vue b/src/routes/admin/AdTemplates.vue index 1f8f8bb..efdfd43 100644 --- a/src/routes/admin/AdTemplates.vue +++ b/src/routes/admin/AdTemplates.vue @@ -1,9 +1,9 @@