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.
This commit is contained in:
2026-03-18 22:23:11 +07:00
parent 87c99e64cd
commit bd8b21955e
39 changed files with 429 additions and 273 deletions

68
.dockerignore Normal file
View File

@@ -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

39
Dockerfile Normal file
View File

@@ -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" ]

32
components.d.ts vendored
View File

@@ -17,18 +17,18 @@ declare module 'vue' {
AdvertisementIcon: typeof import('./src/components/icons/AdvertisementIcon.vue')['default'] AdvertisementIcon: typeof import('./src/components/icons/AdvertisementIcon.vue')['default']
AlertTriangle: typeof import('./src/components/icons/AlertTriangle.vue')['default'] AlertTriangle: typeof import('./src/components/icons/AlertTriangle.vue')['default']
AlertTriangleIcon: typeof import('./src/components/icons/AlertTriangleIcon.vue')['default'] AlertTriangleIcon: typeof import('./src/components/icons/AlertTriangleIcon.vue')['default']
AppButton: typeof import('./src/components/app/AppButton.vue')['default'] AppButton: typeof import('./src/components/ui/AppButton.vue')['default']
AppConfirmHost: typeof import('./src/components/app/AppConfirmHost.vue')['default'] AppConfirmHost: typeof import('./src/components/ui/AppConfirmHost.vue')['default']
AppDialog: typeof import('./src/components/app/AppDialog.vue')['default'] AppDialog: typeof import('./src/components/ui/AppDialog.vue')['default']
AppInput: typeof import('./src/components/app/AppInput.vue')['default'] AppInput: typeof import('./src/components/ui/AppInput.vue')['default']
AppProgressBar: typeof import('./src/components/app/AppProgressBar.vue')['default'] AppProgressBar: typeof import('./src/components/ui/AppProgressBar.vue')['default']
AppSwitch: typeof import('./src/components/app/AppSwitch.vue')['default'] AppSwitch: typeof import('./src/components/ui/AppSwitch.vue')['default']
AppToastHost: typeof import('./src/components/app/AppToastHost.vue')['default'] AppToastHost: typeof import('./src/components/ui/AppToastHost.vue')['default']
AppTopLoadingBar: typeof import('./src/components/AppTopLoadingBar.vue')['default'] AppTopLoadingBar: typeof import('./src/components/AppTopLoadingBar.vue')['default']
ArrowDownTray: typeof import('./src/components/icons/ArrowDownTray.vue')['default'] ArrowDownTray: typeof import('./src/components/icons/ArrowDownTray.vue')['default']
ArrowRightIcon: typeof import('./src/components/icons/ArrowRightIcon.vue')['default'] ArrowRightIcon: typeof import('./src/components/icons/ArrowRightIcon.vue')['default']
AsyncSelect: typeof import('./src/components/ui/AsyncSelect.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'] Bell: typeof import('./src/components/icons/Bell.vue')['default']
BellIcon: typeof import('./src/components/icons/BellIcon.vue')['default'] BellIcon: typeof import('./src/components/icons/BellIcon.vue')['default']
Chart: typeof import('./src/components/icons/Chart.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 AdvertisementIcon: typeof import('./src/components/icons/AdvertisementIcon.vue')['default']
const AlertTriangle: typeof import('./src/components/icons/AlertTriangle.vue')['default'] const AlertTriangle: typeof import('./src/components/icons/AlertTriangle.vue')['default']
const AlertTriangleIcon: typeof import('./src/components/icons/AlertTriangleIcon.vue')['default'] const AlertTriangleIcon: typeof import('./src/components/icons/AlertTriangleIcon.vue')['default']
const AppButton: typeof import('./src/components/app/AppButton.vue')['default'] const AppButton: typeof import('./src/components/ui/AppButton.vue')['default']
const AppConfirmHost: typeof import('./src/components/app/AppConfirmHost.vue')['default'] const AppConfirmHost: typeof import('./src/components/ui/AppConfirmHost.vue')['default']
const AppDialog: typeof import('./src/components/app/AppDialog.vue')['default'] const AppDialog: typeof import('./src/components/ui/AppDialog.vue')['default']
const AppInput: typeof import('./src/components/app/AppInput.vue')['default'] const AppInput: typeof import('./src/components/ui/AppInput.vue')['default']
const AppProgressBar: typeof import('./src/components/app/AppProgressBar.vue')['default'] const AppProgressBar: typeof import('./src/components/ui/AppProgressBar.vue')['default']
const AppSwitch: typeof import('./src/components/app/AppSwitch.vue')['default'] const AppSwitch: typeof import('./src/components/ui/AppSwitch.vue')['default']
const AppToastHost: typeof import('./src/components/app/AppToastHost.vue')['default'] const AppToastHost: typeof import('./src/components/ui/AppToastHost.vue')['default']
const AppTopLoadingBar: typeof import('./src/components/AppTopLoadingBar.vue')['default'] const AppTopLoadingBar: typeof import('./src/components/AppTopLoadingBar.vue')['default']
const ArrowDownTray: typeof import('./src/components/icons/ArrowDownTray.vue')['default'] const ArrowDownTray: typeof import('./src/components/icons/ArrowDownTray.vue')['default']
const ArrowRightIcon: typeof import('./src/components/icons/ArrowRightIcon.vue')['default'] const ArrowRightIcon: typeof import('./src/components/icons/ArrowRightIcon.vue')['default']
const AsyncSelect: typeof import('./src/components/ui/AsyncSelect.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 Bell: typeof import('./src/components/icons/Bell.vue')['default']
const BellIcon: typeof import('./src/components/icons/BellIcon.vue')['default'] const BellIcon: typeof import('./src/components/icons/BellIcon.vue')['default']
const Chart: typeof import('./src/components/icons/Chart.vue')['default'] const Chart: typeof import('./src/components/icons/Chart.vue')['default']

View File

@@ -1,46 +0,0 @@
<script setup lang="ts">
import { cn } from '@/lib/utils';
const props = withDefaults(defineProps<{
modelValue: boolean;
disabled?: boolean;
ariaLabel?: string;
}>(), {
disabled: false,
});
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void;
(e: 'change', value: boolean): void;
}>();
const toggle = () => {
if (props.disabled) return;
const next = !props.modelValue;
emit('update:modelValue', next);
emit('change', next);
};
</script>
<template>
<button
type="button"
role="switch"
:aria-checked="modelValue"
:aria-label="ariaLabel"
:disabled="disabled"
@click="toggle"
:class="cn(
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
disabled ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer',
modelValue ? 'bg-primary' : 'bg-border'
)"
>
<span
:class="cn(
'inline-block h-5 w-5 transform rounded-full bg-white shadow-sm transition-transform',
modelValue ? 'translate-x-5' : 'translate-x-1'
)"
/>
</button>
</template>

View File

@@ -11,7 +11,7 @@ const props = withDefaults(
type?: 'button' | 'submit' | 'reset'; type?: 'button' | 'submit' | 'reset';
}>(), }>(),
{ {
variant: 'secondary', variant: 'primary',
size: 'md', size: 'md',
block: false, block: false,
disabled: false, disabled: false,

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import AppButton from '@/components/app/AppButton.vue'; import AppButton from '@/components/ui/AppButton.vue';
import AppDialog from '@/components/app/AppDialog.vue'; import AppDialog from '@/components/ui/AppDialog.vue';
import AlertTriangleIcon from '@/components/icons/AlertTriangleIcon.vue'; import AlertTriangleIcon from '@/components/icons/AlertTriangleIcon.vue';
import { useAppConfirm } from '@/composables/useAppConfirm'; import { useAppConfirm } from '@/composables/useAppConfirm';

View File

@@ -0,0 +1,55 @@
<script setup lang="ts">
import { cn } from '@/lib/utils';
interface SwitchProps {
disabled?: boolean;
ariaLabel?: string;
class?: string; // Đổi từ className sang class cho chuẩn Vue
}
const props = withDefaults(defineProps<SwitchProps>(), {
disabled: false,
});
// Vue 3.4+ - Quản lý v-model cực gọn
const modelValue = defineModel<boolean>({ default: false });
const emit = defineEmits<{
(e: 'change', value: boolean): void;
}>();
const toggle = () => {
if (props.disabled) return;
modelValue.value = !modelValue.value;
emit('change', modelValue.value);
};
</script>
<template>
<button
type="button"
role="switch"
:aria-checked="modelValue"
:aria-label="ariaLabel"
:disabled="disabled"
@click="toggle"
:class="cn(
// Layout & Size
'relative inline-flex h-6 w-11 shrink-0 items-center rounded-full border-2 border-transparent transition-colors duration-200',
// Focus states (UnoCSS style)
'outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2',
// Status states
disabled ? 'op-50 cursor-not-allowed' : 'cursor-pointer',
modelValue ? 'bg-primary' : 'bg-gray-200 dark:bg-dark-300',
props.class
)"
>
<span
:class="cn(
// Toggle thumb
'pointer-events-none block h-5 w-5 rounded-full bg-white shadow-sm transition-transform duration-200',
modelValue ? 'translate-x-5' : 'translate-x-0'
)"
/>
</button>
</template>

View File

@@ -1,4 +1,3 @@
import { client as rpcClient } from '@/api/rpcclient';
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
export interface QueueItem { export interface QueueItem {
@@ -11,30 +10,36 @@ export interface QueueItem {
total?: string; total?: string;
speed?: string; speed?: string;
thumbnail?: string; thumbnail?: string;
file?: File; file?: File; // Keep reference to file for local uploads
url?: string; url?: string; // Keep reference to url for remote uploads
playbackUrl?: string; // Upload chunk tracking
videoId?: string; activeChunks?: number;
objectKey?: string; uploadedUrls?: string[];
cancelled?: boolean; cancelled?: boolean;
} }
const items = ref<QueueItem[]>([]); const items = ref<QueueItem[]>([]);
// Upload limits
const MAX_ITEMS = 5; 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 MAX_RETRY = 3;
const activeXhrs = new Map<string, XMLHttpRequest>(); // Track active XHRs per item id so we can abort them on cancel
const activeXhrs = new Map<string, Set<XMLHttpRequest>>();
const abortItem = (id: string) => { const abortItem = (id: string) => {
const xhr = activeXhrs.get(id); const xhrs = activeXhrs.get(id);
if (xhr) { if (xhrs) {
xhr.abort(); xhrs.forEach(xhr => xhr.abort());
activeXhrs.delete(id); activeXhrs.delete(id);
} }
}; };
export function useUploadQueue() { export function useUploadQueue() {
const t = (key: string, params?: Record<string, unknown>) => key;
const remainingSlots = computed(() => Math.max(0, MAX_ITEMS - items.value.length)); const remainingSlots = computed(() => Math.max(0, MAX_ITEMS - items.value.length));
@@ -60,9 +65,11 @@ export function useUploadQueue() {
uploaded: '0 MB', uploaded: '0 MB',
total: formatSize(file.size), total: formatSize(file.size),
speed: '0 MB/s', speed: '0 MB/s',
file, file: file,
thumbnail: undefined, thumbnail: undefined,
cancelled: false, activeChunks: 0,
uploadedUrls: [],
cancelled: false
})); }));
items.value.push(...newItems); items.value.push(...newItems);
@@ -75,15 +82,17 @@ export function useUploadQueue() {
const duplicateCount = allowed.length - fresh.length; const duplicateCount = allowed.length - fresh.length;
const newItems: QueueItem[] = fresh.map((url) => ({ const newItems: QueueItem[] = fresh.map((url) => ({
id: Math.random().toString(36).substring(2, 9), id: Math.random().toString(36).substring(2, 9),
name: url.split('/').pop() || t('upload.queueItem.remoteFileName'), name: url.split('/').pop() || 'Remote File',
type: 'remote', type: 'remote',
status: 'pending', status: 'pending',
progress: 0, progress: 0,
uploaded: '0 MB', uploaded: '0 MB',
total: t('upload.queueItem.unknownSize'), total: 'Unknown',
speed: '0 MB/s', speed: '0 MB/s',
url, url: url,
cancelled: false, activeChunks: 0,
uploadedUrls: [],
cancelled: false
})); }));
items.value.push(...newItems); items.value.push(...newItems);
@@ -104,6 +113,7 @@ export function useUploadQueue() {
if (item) { if (item) {
item.cancelled = true; item.cancelled = true;
item.status = 'error'; item.status = 'error';
item.activeChunks = 0;
item.speed = '0 MB/s'; item.speed = '0 MB/s';
} }
}; };
@@ -112,7 +122,7 @@ export function useUploadQueue() {
items.value.forEach(item => { items.value.forEach(item => {
if (item.status === 'pending') { if (item.status === 'pending') {
if (item.type === 'local') { if (item.type === 'local') {
startUpload(item.id); startChunkUpload(item.id);
} else { } else {
startMockRemoteFetch(item.id); 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); const item = items.value.find(i => i.id === id);
if (!item || !item.file) return; if (!item || !item.file) return;
item.status = 'uploading'; item.status = 'uploading';
item.progress = 0; item.activeChunks = 0;
item.uploaded = '0 MB'; item.uploadedUrls = [];
item.speed = '0 MB/s';
try { const file = item.file;
const response = await rpcClient.getUploadUrl({ filename: item.file.name }); const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
if (!response.uploadUrl || !response.key) { const progressMap = new Map<number, number>(); // chunk index -> uploaded bytes
throw new Error(t('upload.errors.mergeFailed')); 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<void>[] = [];
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; if (activePromises.length > 0) {
await uploadFileToPresignedUrl(item, response.uploadUrl); await Promise.all(activePromises);
await processQueue();
}
};
try {
await processQueue();
if (!item.cancelled) { if (!item.cancelled) {
item.status = 'processing'; item.status = 'processing';
await completeUpload(item); await completeUpload(item);
} }
} catch (error) { } catch (error) {
if (!item.cancelled) {
item.status = 'error'; item.status = 'error';
console.error('Upload failed:', error); console.error('Upload failed:', error);
} }
}
}; };
const uploadFileToPresignedUrl = async (item: QueueItem, uploadUrl: string) => { const uploadChunk = (
if (!item.file) return; index: number,
file: File,
for (let attempt = 1; attempt <= MAX_RETRY; attempt++) { progressMap: Map<number, number>,
try { updateProgress: () => void,
await sendFile(item, uploadUrl); item: QueueItem
return; ): Promise<void> => {
} catch (error) {
if (item.cancelled) {
return;
}
if (attempt === MAX_RETRY) {
throw error;
}
}
}
};
const sendFile = (item: QueueItem, uploadUrl: string): Promise<void> => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (!item.file) { let retry = 0;
resolve();
return; const attempt = () => {
} if (item.cancelled) return resolve();
const start = index * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, file.size);
const chunk = file.slice(start, end);
const formData = new FormData();
formData.append('file', chunk, file.name);
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
const startedAt = Date.now(); xhr.open('POST', 'https://tmpfiles.org/api/v1/upload');
activeXhrs.set(item.id, xhr); // Register this XHR so it can be aborted on cancel
xhr.open('PUT', uploadUrl); if (!activeXhrs.has(item.id)) activeXhrs.set(item.id, new Set());
if (item.file.type) { activeXhrs.get(item.id)!.add(xhr);
xhr.setRequestHeader('Content-Type', item.file.type);
}
const cleanup = () => { const unregister = () => activeXhrs.get(item.id)?.delete(xhr);
if (activeXhrs.get(item.id) === xhr) {
activeXhrs.delete(item.id); xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
progressMap.set(index, e.loaded);
updateProgress();
} }
}; };
xhr.upload.onprogress = (event) => { xhr.onload = function () {
if (!event.lengthComputable || !item.file) return; unregister();
if (item.cancelled) return resolve();
const uploadedBytes = event.loaded; if (xhr.status === 200) {
const percent = Math.min((uploadedBytes / item.file.size) * 100, 100); try {
const elapsedSeconds = Math.max((Date.now() - startedAt) / 1000, 0.001); const res = JSON.parse(xhr.responseText);
const speed = uploadedBytes / elapsedSeconds; if (res.status === 'success') {
progressMap.set(index, chunk.size);
item.progress = parseFloat(percent.toFixed(1)); if (item.uploadedUrls) {
item.uploaded = formatSize(uploadedBytes); item.uploadedUrls[index] = res.data.url;
item.total = formatSize(item.file.size); }
item.speed = `${formatSize(speed)}/s`; updateProgress();
};
xhr.onload = () => {
cleanup();
if (item.cancelled) {
resolve(); resolve();
return; return;
} }
if (xhr.status >= 200 && xhr.status < 300) { } catch {
item.progress = 100; handleError();
item.uploaded = item.total;
item.speed = '0 MB/s';
resolve();
return;
} }
reject(new Error(t('upload.errors.chunkUploadFailed', { index: 1 }))); }
}; handleError();
xhr.onerror = () => {
cleanup();
reject(new Error(t('upload.errors.chunkUploadFailed', { index: 1 })));
}; };
xhr.onabort = () => { xhr.onabort = () => {
cleanup(); unregister();
resolve(); resolve(); // treat abort as graceful completion — processQueue will short-circuit via item.cancelled
}; };
xhr.send(item.file); 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);
};
attempt();
}); });
}; };
const completeUpload = async (item: QueueItem) => { const completeUpload = async (item: QueueItem) => {
if (!item.file || !item.objectKey) return; if (!item.file || !item.uploadedUrls) return;
try { try {
const createResponse = await rpcClient.createVideo({ const response = await fetch('/merge', {
title: item.file.name.replace(/\.[^.]+$/, ''), method: 'POST',
description: '', headers: { 'Content-Type': 'application/json' },
url: item.objectKey, body: JSON.stringify({
size: item.file.size, filename: item.file.name,
duration: 0, chunks: item.uploadedUrls,
format: item.file.type || 'video/mp4', size: item.file.size
})
}); });
const createdVideo = createResponse.video; const data = await response.json();
item.videoId = createdVideo?.id;
item.playbackUrl = createdVideo?.url || item.objectKey; if (!response.ok) {
item.url = createdVideo?.url || item.objectKey; throw new Error(data.error || 'Merge failed');
}
item.status = 'complete'; item.status = 'complete';
item.progress = 100; item.progress = 100;
item.uploaded = item.total; item.uploaded = item.total;
item.speed = '0 MB/s'; item.speed = '0 MB/s';
} catch (error) { } catch (error) {
item.status = 'error'; item.status = 'error';
console.error('Create video failed:', error); console.error('Merge failed:', error);
} }
}; };
// Mock Remote Fetch Logic
const startMockRemoteFetch = (id: string) => { const startMockRemoteFetch = (id: string) => {
const item = items.value.find(i => i.id === id); const item = items.value.find(i => i.id === id);
if (!item) return; if (!item) return;
@@ -273,13 +321,13 @@ export function useUploadQueue() {
}, 3000 + Math.random() * 3000); }, 3000 + Math.random() * 3000);
}; };
const formatSize = (bytes: number): string => { const formatSize = (bytes: number): string => {
if (bytes === 0) return '0 B'; if (bytes === 0) return '0 B';
const k = 1024; const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k)); const i = Math.floor(Math.log(bytes) / Math.log(k));
const value = parseFloat((bytes / Math.pow(k, i)).toFixed(2)); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
return `${value} ${sizes[i]}`;
}; };
const totalSize = computed(() => { const totalSize = computed(() => {
@@ -297,11 +345,17 @@ export function useUploadQueue() {
const pendingCount = computed(() => { const pendingCount = computed(() => {
return items.value.filter(i => i.status === 'pending').length; return items.value.filter(i => i.status === 'pending').length;
}); });
function removeAll() { function removeAll() {
items.value = []; 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 { return {
items, items,
addFiles, addFiles,

View File

@@ -1,9 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { client as rpcClient } from "@/api/rpcclient"; import { client as rpcClient } from "@/api/rpcclient";
import AppButton from "@/components/app/AppButton.vue"; import AppButton from "@/components/ui/AppButton.vue";
import AppDialog from "@/components/app/AppDialog.vue"; import AppDialog from "@/components/ui/AppDialog.vue";
import AppInput from "@/components/app/AppInput.vue"; import AppInput from "@/components/ui/AppInput.vue";
import BaseTable from "@/components/ui/table/BaseTable.vue"; import BaseTable from "@/components/ui/BaseTable.vue";
import SettingsSectionCard from "@/routes/settings/components/SettingsSectionCard.vue"; import SettingsSectionCard from "@/routes/settings/components/SettingsSectionCard.vue";
import { type ColumnDef } from "@tanstack/vue-table"; import { type ColumnDef } from "@tanstack/vue-table";
import { computed, h, onMounted, reactive, ref } from "vue"; import { computed, h, onMounted, reactive, ref } from "vue";

View File

@@ -1,8 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { client as rpcClient } from "@/api/rpcclient"; import { client as rpcClient } from "@/api/rpcclient";
import AppButton from "@/components/app/AppButton.vue"; import AppButton from "@/components/ui/AppButton.vue";
import AppDialog from "@/components/app/AppDialog.vue"; import AppDialog from "@/components/ui/AppDialog.vue";
import BaseTable from "@/components/ui/table/BaseTable.vue"; import BaseTable from "@/components/ui/BaseTable.vue";
import { useAdminRuntimeMqtt } from "@/composables/useAdminRuntimeMqtt"; import { useAdminRuntimeMqtt } from "@/composables/useAdminRuntimeMqtt";
import SettingsSectionCard from "@/routes/settings/components/SettingsSectionCard.vue"; import SettingsSectionCard from "@/routes/settings/components/SettingsSectionCard.vue";
import SettingsTableSkeleton from "@/routes/settings/components/SettingsTableSkeleton.vue"; import SettingsTableSkeleton from "@/routes/settings/components/SettingsTableSkeleton.vue";

View File

@@ -1,9 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { client as rpcClient } from "@/api/rpcclient"; import { client as rpcClient } from "@/api/rpcclient";
import AppButton from "@/components/app/AppButton.vue"; import AppButton from "@/components/ui/AppButton.vue";
import AppDialog from "@/components/app/AppDialog.vue"; import AppDialog from "@/components/ui/AppDialog.vue";
import AppInput from "@/components/app/AppInput.vue"; import AppInput from "@/components/ui/AppInput.vue";
import BaseTable from "@/components/ui/table/BaseTable.vue"; import BaseTable from "@/components/ui/BaseTable.vue";
import { useAdminRuntimeMqtt } from "@/composables/useAdminRuntimeMqtt"; import { useAdminRuntimeMqtt } from "@/composables/useAdminRuntimeMqtt";
import SettingsSectionCard from "@/routes/settings/components/SettingsSectionCard.vue"; import SettingsSectionCard from "@/routes/settings/components/SettingsSectionCard.vue";
import { type ColumnDef } from "@tanstack/vue-table"; import { type ColumnDef } from "@tanstack/vue-table";

View File

@@ -31,9 +31,9 @@ const menuSections = [
}, },
] as const; ] as const;
const allSections = computed(() => menuSections.flatMap((section) => section.items));
const activeSection = computed(() => { const activeSection = computed(() => {
return allSections.value.find((section) => route.path === section.to || route.path.startsWith(`${section.to}/`)) ?? allSections.value[0]; const allSections = menuSections.map((section) => section.items).flat();
return allSections.find((section) => route.path === section.to || route.path.startsWith(`${section.to}/`)) ?? allSections[0];
}); });
const breadcrumbs = computed(() => [ const breadcrumbs = computed(() => [
@@ -105,11 +105,6 @@ const content = computed(() => ({
<div class="max-w-7xl mx-auto pb-12"> <div class="max-w-7xl mx-auto pb-12">
<div class="mt-6 flex flex-col gap-8 md:flex-row"> <div class="mt-6 flex flex-col gap-8 md:flex-row">
<aside class="md:w-56 shrink-0"> <aside class="md:w-56 shrink-0">
<div class="mb-8 rounded-lg border border-border bg-header px-4 py-4">
<div class="text-sm font-semibold text-foreground">{{ activeSection?.label }}</div>
<p class="mt-1 text-sm text-foreground/60">{{ activeSection?.description }}</p>
</div>
<nav class="space-y-6"> <nav class="space-y-6">
<div v-for="section in menuSections" :key="section.title"> <div v-for="section in menuSections" :key="section.title">
<h3 class="mb-2 pl-3 text-xs font-semibold uppercase tracking-wider text-foreground/50"> <h3 class="mb-2 pl-3 text-xs font-semibold uppercase tracking-wider text-foreground/50">

View File

@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { client as rpcClient } from "@/api/rpcclient"; import { client as rpcClient } from "@/api/rpcclient";
import AppButton from "@/components/app/AppButton.vue"; import AppButton from "@/components/ui/AppButton.vue";
import AppInput from "@/components/app/AppInput.vue"; import AppInput from "@/components/ui/AppInput.vue";
import { useAdminRuntimeMqtt } from "@/composables/useAdminRuntimeMqtt"; import { useAdminRuntimeMqtt } from "@/composables/useAdminRuntimeMqtt";
import SettingsSectionCard from "@/routes/settings/components/SettingsSectionCard.vue"; import SettingsSectionCard from "@/routes/settings/components/SettingsSectionCard.vue";
import { computed, ref } from "vue"; import { computed, ref } from "vue";

View File

@@ -1,9 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { client as rpcClient } from "@/api/rpcclient"; import { client as rpcClient } from "@/api/rpcclient";
import AppButton from "@/components/app/AppButton.vue"; import AppButton from "@/components/ui/AppButton.vue";
import AppDialog from "@/components/app/AppDialog.vue"; import AppDialog from "@/components/ui/AppDialog.vue";
import AppInput from "@/components/app/AppInput.vue"; import AppInput from "@/components/ui/AppInput.vue";
import BaseTable from "@/components/ui/table/BaseTable.vue"; import BaseTable from "@/components/ui/BaseTable.vue";
import SettingsSectionCard from "@/routes/settings/components/SettingsSectionCard.vue"; import SettingsSectionCard from "@/routes/settings/components/SettingsSectionCard.vue";
import BillingPlansSection from "@/routes/settings/components/billing/BillingPlansSection.vue"; import BillingPlansSection from "@/routes/settings/components/billing/BillingPlansSection.vue";
import type { Plan as ModelPlan } from "@/server/gen/proto/app/v1/common"; import type { Plan as ModelPlan } from "@/server/gen/proto/app/v1/common";

View File

@@ -1,8 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { client as rpcClient } from "@/api/rpcclient"; import { client as rpcClient } from "@/api/rpcclient";
import AppButton from "@/components/app/AppButton.vue"; import AppButton from "@/components/ui/AppButton.vue";
import AppDialog from "@/components/app/AppDialog.vue"; import AppDialog from "@/components/ui/AppDialog.vue";
import AppInput from "@/components/app/AppInput.vue"; import AppInput from "@/components/ui/AppInput.vue";
import SettingsSectionCard from "@/routes/settings/components/SettingsSectionCard.vue"; import SettingsSectionCard from "@/routes/settings/components/SettingsSectionCard.vue";
import { computed, onMounted, reactive, ref } from "vue"; import { computed, onMounted, reactive, ref } from "vue";
import AdminSectionShell from "./components/AdminSectionShell.vue"; import AdminSectionShell from "./components/AdminSectionShell.vue";

View File

@@ -1,9 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { client, client as rpcClient } from "@/api/rpcclient"; import { client, client as rpcClient } from "@/api/rpcclient";
import AppButton from "@/components/app/AppButton.vue"; import AppButton from "@/components/ui/AppButton.vue";
import AppDialog from "@/components/app/AppDialog.vue"; import AppDialog from "@/components/ui/AppDialog.vue";
import AppInput from "@/components/app/AppInput.vue"; import AppInput from "@/components/ui/AppInput.vue";
import BaseTable from "@/components/ui/table/BaseTable.vue"; import BaseTable from "@/components/ui/BaseTable.vue";
import SettingsSectionCard from "@/routes/settings/components/SettingsSectionCard.vue"; import SettingsSectionCard from "@/routes/settings/components/SettingsSectionCard.vue";
import { type ColumnDef } from "@tanstack/vue-table"; import { type ColumnDef } from "@tanstack/vue-table";
import { computed, h, onMounted, reactive, ref, watch } from "vue"; import { computed, h, onMounted, reactive, ref, watch } from "vue";

View File

@@ -1,9 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { client as rpcClient } from "@/api/rpcclient"; import { client as rpcClient } from "@/api/rpcclient";
import AppButton from "@/components/app/AppButton.vue"; import AppButton from "@/components/ui/AppButton.vue";
import AppDialog from "@/components/app/AppDialog.vue"; import AppDialog from "@/components/ui/AppDialog.vue";
import AppInput from "@/components/app/AppInput.vue"; import AppInput from "@/components/ui/AppInput.vue";
import BaseTable from "@/components/ui/table/BaseTable.vue"; import BaseTable from "@/components/ui/BaseTable.vue";
import SettingsSectionCard from "@/routes/settings/components/SettingsSectionCard.vue"; import SettingsSectionCard from "@/routes/settings/components/SettingsSectionCard.vue";
import { type ColumnDef } from "@tanstack/vue-table"; import { type ColumnDef } from "@tanstack/vue-table";
import { computed, h, onMounted, reactive, ref, watch } from "vue"; import { computed, h, onMounted, reactive, ref, watch } from "vue";

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import BaseTable from '@/components/ui/table/BaseTable.vue'; import BaseTable from '@/components/ui/BaseTable.vue';
import type { ColumnDef } from '@tanstack/vue-table'; import type { ColumnDef } from '@tanstack/vue-table';
import { computed, h } from 'vue'; import { computed, h } from 'vue';

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import BaseTable from '@/components/ui/table/BaseTable.vue'; import BaseTable from '@/components/ui/BaseTable.vue';
import EmptyState from '@/components/dashboard/EmptyState.vue'; import EmptyState from '@/components/dashboard/EmptyState.vue';
import type { Video as ModelVideo } from '@/server/gen/proto/app/v1/common'; import type { Video as ModelVideo } from '@/server/gen/proto/app/v1/common';
import { formatDate, formatDuration } from '@/lib/utils'; import { formatDate, formatDuration } from '@/lib/utils';

View File

@@ -1,10 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { client as rpcClient } from '@/api/rpcclient'; import { client as rpcClient } from '@/api/rpcclient';
import AppButton from '@/components/app/AppButton.vue'; import AppButton from '@/components/ui/AppButton.vue';
import AppDialog from '@/components/app/AppDialog.vue'; import AppDialog from '@/components/ui/AppDialog.vue';
import AppInput from '@/components/app/AppInput.vue'; import AppInput from '@/components/ui/AppInput.vue';
import AppSwitch from '@/components/app/AppSwitch.vue'; import AppSwitch from '@/components/ui/AppSwitch.vue';
import BaseTable from '@/components/ui/table/BaseTable.vue'; import BaseTable from '@/components/ui/BaseTable.vue';
import CheckIcon from '@/components/icons/CheckIcon.vue'; import CheckIcon from '@/components/icons/CheckIcon.vue';
import LinkIcon from '@/components/icons/LinkIcon.vue'; import LinkIcon from '@/components/icons/LinkIcon.vue';
import PencilIcon from '@/components/icons/PencilIcon.vue'; import PencilIcon from '@/components/icons/PencilIcon.vue';

View File

@@ -1,10 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { client as rpcClient } from '@/api/rpcclient'; import { client as rpcClient } from '@/api/rpcclient';
import AppButton from '@/components/app/AppButton.vue'; import AppButton from '@/components/ui/AppButton.vue';
import AppDialog from '@/components/app/AppDialog.vue'; import AppDialog from '@/components/ui/AppDialog.vue';
import AppInput from '@/components/app/AppInput.vue'; import AppInput from '@/components/ui/AppInput.vue';
import { useAppToast } from '@/composables/useAppToast'; import { useAppToast } from '@/composables/useAppToast';
import { useUsageQuery } from '@/composables/useUsageQuery'; import { useUsageQuery } from '@/composables/useUsageQuery';
import { formatBytes } from '@/lib/utils';
import SettingsSectionCard from '@/routes/settings/components/SettingsSectionCard.vue'; import SettingsSectionCard from '@/routes/settings/components/SettingsSectionCard.vue';
import BillingHistorySection from '@/routes/settings/components/billing/BillingHistorySection.vue'; import BillingHistorySection from '@/routes/settings/components/billing/BillingHistorySection.vue';
import BillingPlansSection from '@/routes/settings/components/billing/BillingPlansSection.vue'; import BillingPlansSection from '@/routes/settings/components/billing/BillingPlansSection.vue';
@@ -126,15 +127,6 @@ const upgradeSubmitLabel = computed(() => {
const formatMoney = (amount: number) => currencyFormatter.value.format(amount); const formatMoney = (amount: number) => currencyFormatter.value.format(amount);
const formatBytes = (bytes: number) => {
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 `${new Intl.NumberFormat(localeTag.value).format(value)} ${sizes[i]}`;
};
const formatDuration = (seconds?: number) => { const formatDuration = (seconds?: number) => {
if (!seconds) return t('settings.billing.durationMinutes', { minutes: 0 }); if (!seconds) return t('settings.billing.durationMinutes', { minutes: 0 });
if (seconds < 0) return t('settings.billing.durationMinutes', { minutes: -1 }).replace("-1", "∞") if (seconds < 0) return t('settings.billing.durationMinutes', { minutes: -1 }).replace("-1", "∞")
@@ -263,15 +255,12 @@ const loadPaymentHistory = async () => {
} }
}; };
const refetchUsageSnapshot = () => refetchUsage((fetchError) => {
throw fetchError;
});
const refreshBillingState = async () => { const refreshBillingState = async () => {
await Promise.allSettled([ await Promise.allSettled([
auth.fetchMe(), auth.fetchMe(),
loadPaymentHistory(), loadPaymentHistory(),
refetchUsageSnapshot(), refetchUsage(),
]); ]);
}; };
@@ -403,7 +392,7 @@ const submitUpgrade = async () => {
try { try {
const paymentMethod: UpgradePaymentMethod = selectedNeedsTopup.value ? selectedPaymentMethod.value : 'wallet'; const paymentMethod: UpgradePaymentMethod = selectedNeedsTopup.value ? selectedPaymentMethod.value : 'wallet';
const payload: Record<string, any> = { const payload: Parameters<typeof rpcClient.createPayment>[0] = {
planId: selectedPlan.value.id, planId: selectedPlan.value.id,
termMonths: selectedTermMonths.value, termMonths: selectedTermMonths.value,
paymentMethod: paymentMethod, paymentMethod: paymentMethod,

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { client as rpcClient } from '@/api/rpcclient'; import { client as rpcClient } from '@/api/rpcclient';
import AppButton from '@/components/app/AppButton.vue'; import AppButton from '@/components/ui/AppButton.vue';
import AlertTriangleIcon from '@/components/icons/AlertTriangle.vue'; import AlertTriangleIcon from '@/components/icons/AlertTriangle.vue';
import SlidersIcon from '@/components/icons/SlidersIcon.vue'; import SlidersIcon from '@/components/icons/SlidersIcon.vue';
import TrashIcon from '@/components/icons/TrashIcon.vue'; import TrashIcon from '@/components/icons/TrashIcon.vue';

View File

@@ -1,9 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { client as rpcClient } from '@/api/rpcclient'; import { client as rpcClient } from '@/api/rpcclient';
import AppButton from '@/components/app/AppButton.vue'; import AppButton from '@/components/ui/AppButton.vue';
import AppDialog from '@/components/app/AppDialog.vue'; import AppDialog from '@/components/ui/AppDialog.vue';
import AppInput from '@/components/app/AppInput.vue'; import AppInput from '@/components/ui/AppInput.vue';
import BaseTable from '@/components/ui/table/BaseTable.vue'; import BaseTable from '@/components/ui/BaseTable.vue';
import CheckIcon from '@/components/icons/CheckIcon.vue'; import CheckIcon from '@/components/icons/CheckIcon.vue';
import LinkIcon from '@/components/icons/LinkIcon.vue'; import LinkIcon from '@/components/icons/LinkIcon.vue';
import PlusIcon from '@/components/icons/PlusIcon.vue'; import PlusIcon from '@/components/icons/PlusIcon.vue';

View File

@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { client as rpcClient } from '@/api/rpcclient'; import { client as rpcClient } from '@/api/rpcclient';
import AppButton from '@/components/app/AppButton.vue'; import AppButton from '@/components/ui/AppButton.vue';
import AppSwitch from '@/components/app/AppSwitch.vue'; import AppSwitch from '@/components/ui/AppSwitch.vue';
import BellIcon from '@/components/icons/BellIcon.vue'; import BellIcon from '@/components/icons/BellIcon.vue';
import CheckIcon from '@/components/icons/CheckIcon.vue'; import CheckIcon from '@/components/icons/CheckIcon.vue';
import MailIcon from '@/components/icons/MailIcon.vue'; import MailIcon from '@/components/icons/MailIcon.vue';

View File

@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { client as rpcClient } from '@/api/rpcclient'; import { client as rpcClient } from '@/api/rpcclient';
import AppButton from '@/components/app/AppButton.vue'; import AppButton from '@/components/ui/AppButton.vue';
import AppSwitch from '@/components/app/AppSwitch.vue'; import AppSwitch from '@/components/ui/AppSwitch.vue';
import CheckIcon from '@/components/icons/CheckIcon.vue'; import CheckIcon from '@/components/icons/CheckIcon.vue';
import { import {
createPlayerSettingsDraft, createPlayerSettingsDraft,

View File

@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import AppButton from '@/components/app/AppButton.vue'; import AppButton from '@/components/ui/AppButton.vue';
import AppDialog from '@/components/app/AppDialog.vue'; import AppDialog from '@/components/ui/AppDialog.vue';
import AppInput from '@/components/app/AppInput.vue'; import AppInput from '@/components/ui/AppInput.vue';
import CheckIcon from '@/components/icons/CheckIcon.vue'; import CheckIcon from '@/components/icons/CheckIcon.vue';
import LockIcon from '@/components/icons/LockIcon.vue'; import LockIcon from '@/components/icons/LockIcon.vue';
import TelegramIcon from '@/components/icons/TelegramIcon.vue'; import TelegramIcon from '@/components/icons/TelegramIcon.vue';

View File

@@ -62,8 +62,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import AppConfirmHost from '@/components/app/AppConfirmHost.vue'; import AppConfirmHost from '@/components/ui/AppConfirmHost.vue';
import AppToastHost from '@/components/app/AppToastHost.vue'; import AppToastHost from '@/components/ui/AppToastHost.vue';
import ClientOnly from '@/components/ClientOnly'; import ClientOnly from '@/components/ClientOnly';
import PageHeader from '@/components/dashboard/PageHeader.vue'; import PageHeader from '@/components/dashboard/PageHeader.vue';
import AdvertisementIcon from '@/components/icons/AdvertisementIcon.vue'; import AdvertisementIcon from '@/components/icons/AdvertisementIcon.vue';

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import BaseTable from '@/components/ui/table/BaseTable.vue'; import BaseTable from '@/components/ui/BaseTable.vue';
import type { ColumnDef } from '@tanstack/vue-table'; import type { ColumnDef } from '@tanstack/vue-table';
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{

View File

@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import AppButton from '@/components/app/AppButton.vue'; import AppButton from '@/components/ui/AppButton.vue';
import AppDialog from '@/components/app/AppDialog.vue'; import AppDialog from '@/components/ui/AppDialog.vue';
import AppInput from '@/components/app/AppInput.vue'; import AppInput from '@/components/ui/AppInput.vue';
import CheckIcon from '@/components/icons/CheckIcon.vue'; import CheckIcon from '@/components/icons/CheckIcon.vue';
defineProps<{ defineProps<{

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import AppButton from '@/components/app/AppButton.vue'; import AppButton from '@/components/ui/AppButton.vue';
import CoinsIcon from '@/components/icons/CoinsIcon.vue'; import CoinsIcon from '@/components/icons/CoinsIcon.vue';
import PlusIcon from '@/components/icons/PlusIcon.vue'; import PlusIcon from '@/components/icons/PlusIcon.vue';
import SettingsRow from '../SettingsRow.vue'; import SettingsRow from '../SettingsRow.vue';

View File

@@ -5,8 +5,10 @@ import { useUploadQueue } from '@/composables/useUploadQueue';
import { useUIState } from '@/stores/uiState'; import { useUIState } from '@/stores/uiState';
import RemoteUrlForm from './components/RemoteUrlForm.vue'; import RemoteUrlForm from './components/RemoteUrlForm.vue';
import UploadDropzone from './components/UploadDropzone.vue'; import UploadDropzone from './components/UploadDropzone.vue';
import { useAppToast } from '@/composables/useAppToast';
const uiState = useUIState(); const uiState = useUIState();
const toast = useAppToast();
const mode = ref<'local' | 'remote'>('local'); const mode = ref<'local' | 'remote'>('local');
const { t } = useTranslation(); const { t } = useTranslation();
@@ -15,7 +17,7 @@ const { addFiles, addRemoteUrls, pendingCount, startQueue, remainingSlots, maxIt
const handleFilesSelected = (files: FileList) => { const handleFilesSelected = (files: FileList) => {
const result = addFiles(files); const result = addFiles(files);
if (result.duplicates > 0) { if (result.duplicates > 0) {
uiState.toastQueue.push({ toast.add({
severity: 'warn', severity: 'warn',
summary: t('upload.dialog.duplicateFilesSummary'), summary: t('upload.dialog.duplicateFilesSummary'),
detail: result.duplicates > 1 detail: result.duplicates > 1
@@ -30,7 +32,7 @@ const handleFilesSelected = (files: FileList) => {
const handleRemoteUrls = (urls: string[]) => { const handleRemoteUrls = (urls: string[]) => {
const result = addRemoteUrls(urls); const result = addRemoteUrls(urls);
if (result.duplicates > 0) { if (result.duplicates > 0) {
uiState.toastQueue.push({ toast.add({
severity: 'warn', severity: 'warn',
summary: t('upload.dialog.duplicateUrlsSummary'), summary: t('upload.dialog.duplicateUrlsSummary'),
detail: result.duplicates > 1 detail: result.duplicates > 1

View File

@@ -64,7 +64,7 @@ const onDrop = (e: DragEvent) => {
class="absolute inset-0 w-full h-full opacity-0 z-20 cursor-pointer" @change="handleFileChange"> class="absolute inset-0 w-full h-full opacity-0 z-20 cursor-pointer" @change="handleFileChange">
<div :class="[ <div :class="[
'flex-1 flex flex-col items-center justify-center gap-4 rounded-xl border-2 border-dashed transition-all duration-200 py-6 px-4 h-full', 'flex-1 flex flex-col items-center justify-center gap-4 rounded-xl border-2 border-dashed transition-all duration-200 py-6 px-4 h-full bg-white',
isDragOver isDragOver
? 'border-accent bg-accent/5 scale-[0.99]' ? 'border-accent bg-accent/5 scale-[0.99]'
: 'border-slate-200 group-hover:border-accent/60 group-hover:bg-accent/[0.03]' : 'border-slate-200 group-hover:border-accent/60 group-hover:bg-accent/[0.03]'

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import BaseTable from '@/components/ui/table/BaseTable.vue'; import BaseTable from '@/components/ui/BaseTable.vue';
import LinkIcon from '@/components/icons/LinkIcon.vue'; import LinkIcon from '@/components/icons/LinkIcon.vue';
import PencilIcon from '@/components/icons/PencilIcon.vue'; import PencilIcon from '@/components/icons/PencilIcon.vue';
import TrashIcon from '@/components/icons/TrashIcon.vue'; import TrashIcon from '@/components/icons/TrashIcon.vue';

View File

@@ -27,13 +27,13 @@ export default defineConfig({
theme: { theme: {
colors: { colors: {
primary: { primary: {
DEFAULT: "#4563ca", DEFAULT: "#1f883d",
50: "#effcf3", 50: "#effcf3",
100: "#dcf9e2", 100: "#dcf9e2",
200: "#bbf0c8", 200: "#bbf0c8",
300: "#86efac", 300: "#86efac",
400: "#4ade80", 400: "#4ade80",
500: "#4563ca", 500: "#1f883d",
600: "#16a34a", 600: "#16a34a",
700: "#15803d", 700: "#15803d",
800: "#166534", 800: "#166534",