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:
@@ -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>
|
||||
@@ -11,7 +11,7 @@ const props = withDefaults(
|
||||
type?: 'button' | 'submit' | 'reset';
|
||||
}>(),
|
||||
{
|
||||
variant: 'secondary',
|
||||
variant: 'primary',
|
||||
size: 'md',
|
||||
block: false,
|
||||
disabled: false,
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import AppButton from '@/components/app/AppButton.vue';
|
||||
import AppDialog from '@/components/app/AppDialog.vue';
|
||||
import AppButton from '@/components/ui/AppButton.vue';
|
||||
import AppDialog from '@/components/ui/AppDialog.vue';
|
||||
import AlertTriangleIcon from '@/components/icons/AlertTriangleIcon.vue';
|
||||
import { useAppConfirm } from '@/composables/useAppConfirm';
|
||||
|
||||
55
src/components/ui/AppSwitch.vue
Normal file
55
src/components/ui/AppSwitch.vue
Normal 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>
|
||||
@@ -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<QueueItem[]>([]);
|
||||
|
||||
// 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<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 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<string, unknown>) => 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<number, number>(); // 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<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;
|
||||
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<void> => {
|
||||
const uploadChunk = (
|
||||
index: number,
|
||||
file: File,
|
||||
progressMap: Map<number, number>,
|
||||
updateProgress: () => void,
|
||||
item: QueueItem
|
||||
): Promise<void> => {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { client as rpcClient } from "@/api/rpcclient";
|
||||
import AppButton from "@/components/app/AppButton.vue";
|
||||
import AppDialog from "@/components/app/AppDialog.vue";
|
||||
import AppInput from "@/components/app/AppInput.vue";
|
||||
import BaseTable from "@/components/ui/table/BaseTable.vue";
|
||||
import AppButton from "@/components/ui/AppButton.vue";
|
||||
import AppDialog from "@/components/ui/AppDialog.vue";
|
||||
import AppInput from "@/components/ui/AppInput.vue";
|
||||
import BaseTable from "@/components/ui/BaseTable.vue";
|
||||
import SettingsSectionCard from "@/routes/settings/components/SettingsSectionCard.vue";
|
||||
import { type ColumnDef } from "@tanstack/vue-table";
|
||||
import { computed, h, onMounted, reactive, ref } from "vue";
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { client as rpcClient } from "@/api/rpcclient";
|
||||
import AppButton from "@/components/app/AppButton.vue";
|
||||
import AppDialog from "@/components/app/AppDialog.vue";
|
||||
import BaseTable from "@/components/ui/table/BaseTable.vue";
|
||||
import AppButton from "@/components/ui/AppButton.vue";
|
||||
import AppDialog from "@/components/ui/AppDialog.vue";
|
||||
import BaseTable from "@/components/ui/BaseTable.vue";
|
||||
import { useAdminRuntimeMqtt } from "@/composables/useAdminRuntimeMqtt";
|
||||
import SettingsSectionCard from "@/routes/settings/components/SettingsSectionCard.vue";
|
||||
import SettingsTableSkeleton from "@/routes/settings/components/SettingsTableSkeleton.vue";
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { client as rpcClient } from "@/api/rpcclient";
|
||||
import AppButton from "@/components/app/AppButton.vue";
|
||||
import AppDialog from "@/components/app/AppDialog.vue";
|
||||
import AppInput from "@/components/app/AppInput.vue";
|
||||
import BaseTable from "@/components/ui/table/BaseTable.vue";
|
||||
import AppButton from "@/components/ui/AppButton.vue";
|
||||
import AppDialog from "@/components/ui/AppDialog.vue";
|
||||
import AppInput from "@/components/ui/AppInput.vue";
|
||||
import BaseTable from "@/components/ui/BaseTable.vue";
|
||||
import { useAdminRuntimeMqtt } from "@/composables/useAdminRuntimeMqtt";
|
||||
import SettingsSectionCard from "@/routes/settings/components/SettingsSectionCard.vue";
|
||||
import { type ColumnDef } from "@tanstack/vue-table";
|
||||
|
||||
@@ -31,9 +31,9 @@ const menuSections = [
|
||||
},
|
||||
] as const;
|
||||
|
||||
const allSections = computed(() => menuSections.flatMap((section) => section.items));
|
||||
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(() => [
|
||||
@@ -105,11 +105,6 @@ const content = computed(() => ({
|
||||
<div class="max-w-7xl mx-auto pb-12">
|
||||
<div class="mt-6 flex flex-col gap-8 md:flex-row">
|
||||
<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">
|
||||
<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">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { client as rpcClient } from "@/api/rpcclient";
|
||||
import AppButton from "@/components/app/AppButton.vue";
|
||||
import AppInput from "@/components/app/AppInput.vue";
|
||||
import AppButton from "@/components/ui/AppButton.vue";
|
||||
import AppInput from "@/components/ui/AppInput.vue";
|
||||
import { useAdminRuntimeMqtt } from "@/composables/useAdminRuntimeMqtt";
|
||||
import SettingsSectionCard from "@/routes/settings/components/SettingsSectionCard.vue";
|
||||
import { computed, ref } from "vue";
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { client as rpcClient } from "@/api/rpcclient";
|
||||
import AppButton from "@/components/app/AppButton.vue";
|
||||
import AppDialog from "@/components/app/AppDialog.vue";
|
||||
import AppInput from "@/components/app/AppInput.vue";
|
||||
import BaseTable from "@/components/ui/table/BaseTable.vue";
|
||||
import AppButton from "@/components/ui/AppButton.vue";
|
||||
import AppDialog from "@/components/ui/AppDialog.vue";
|
||||
import AppInput from "@/components/ui/AppInput.vue";
|
||||
import BaseTable from "@/components/ui/BaseTable.vue";
|
||||
import SettingsSectionCard from "@/routes/settings/components/SettingsSectionCard.vue";
|
||||
import BillingPlansSection from "@/routes/settings/components/billing/BillingPlansSection.vue";
|
||||
import type { Plan as ModelPlan } from "@/server/gen/proto/app/v1/common";
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { client as rpcClient } from "@/api/rpcclient";
|
||||
import AppButton from "@/components/app/AppButton.vue";
|
||||
import AppDialog from "@/components/app/AppDialog.vue";
|
||||
import AppInput from "@/components/app/AppInput.vue";
|
||||
import AppButton from "@/components/ui/AppButton.vue";
|
||||
import AppDialog from "@/components/ui/AppDialog.vue";
|
||||
import AppInput from "@/components/ui/AppInput.vue";
|
||||
import SettingsSectionCard from "@/routes/settings/components/SettingsSectionCard.vue";
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import AdminSectionShell from "./components/AdminSectionShell.vue";
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { client, client as rpcClient } from "@/api/rpcclient";
|
||||
import AppButton from "@/components/app/AppButton.vue";
|
||||
import AppDialog from "@/components/app/AppDialog.vue";
|
||||
import AppInput from "@/components/app/AppInput.vue";
|
||||
import BaseTable from "@/components/ui/table/BaseTable.vue";
|
||||
import AppButton from "@/components/ui/AppButton.vue";
|
||||
import AppDialog from "@/components/ui/AppDialog.vue";
|
||||
import AppInput from "@/components/ui/AppInput.vue";
|
||||
import BaseTable from "@/components/ui/BaseTable.vue";
|
||||
import SettingsSectionCard from "@/routes/settings/components/SettingsSectionCard.vue";
|
||||
import { type ColumnDef } from "@tanstack/vue-table";
|
||||
import { computed, h, onMounted, reactive, ref, watch } from "vue";
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { client as rpcClient } from "@/api/rpcclient";
|
||||
import AppButton from "@/components/app/AppButton.vue";
|
||||
import AppDialog from "@/components/app/AppDialog.vue";
|
||||
import AppInput from "@/components/app/AppInput.vue";
|
||||
import BaseTable from "@/components/ui/table/BaseTable.vue";
|
||||
import AppButton from "@/components/ui/AppButton.vue";
|
||||
import AppDialog from "@/components/ui/AppDialog.vue";
|
||||
import AppInput from "@/components/ui/AppInput.vue";
|
||||
import BaseTable from "@/components/ui/BaseTable.vue";
|
||||
import SettingsSectionCard from "@/routes/settings/components/SettingsSectionCard.vue";
|
||||
import { type ColumnDef } from "@tanstack/vue-table";
|
||||
import { computed, h, onMounted, reactive, ref, watch } from "vue";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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 { computed, h } from 'vue';
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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 type { Video as ModelVideo } from '@/server/gen/proto/app/v1/common';
|
||||
import { formatDate, formatDuration } from '@/lib/utils';
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { client as rpcClient } from '@/api/rpcclient';
|
||||
import AppButton from '@/components/app/AppButton.vue';
|
||||
import AppDialog from '@/components/app/AppDialog.vue';
|
||||
import AppInput from '@/components/app/AppInput.vue';
|
||||
import AppSwitch from '@/components/app/AppSwitch.vue';
|
||||
import BaseTable from '@/components/ui/table/BaseTable.vue';
|
||||
import AppButton from '@/components/ui/AppButton.vue';
|
||||
import AppDialog from '@/components/ui/AppDialog.vue';
|
||||
import AppInput from '@/components/ui/AppInput.vue';
|
||||
import AppSwitch from '@/components/ui/AppSwitch.vue';
|
||||
import BaseTable from '@/components/ui/BaseTable.vue';
|
||||
import CheckIcon from '@/components/icons/CheckIcon.vue';
|
||||
import LinkIcon from '@/components/icons/LinkIcon.vue';
|
||||
import PencilIcon from '@/components/icons/PencilIcon.vue';
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { client as rpcClient } from '@/api/rpcclient';
|
||||
import AppButton from '@/components/app/AppButton.vue';
|
||||
import AppDialog from '@/components/app/AppDialog.vue';
|
||||
import AppInput from '@/components/app/AppInput.vue';
|
||||
import AppButton from '@/components/ui/AppButton.vue';
|
||||
import AppDialog from '@/components/ui/AppDialog.vue';
|
||||
import AppInput from '@/components/ui/AppInput.vue';
|
||||
import { useAppToast } from '@/composables/useAppToast';
|
||||
import { useUsageQuery } from '@/composables/useUsageQuery';
|
||||
import { formatBytes } from '@/lib/utils';
|
||||
import SettingsSectionCard from '@/routes/settings/components/SettingsSectionCard.vue';
|
||||
import BillingHistorySection from '@/routes/settings/components/billing/BillingHistorySection.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 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) => {
|
||||
if (!seconds) return t('settings.billing.durationMinutes', { minutes: 0 });
|
||||
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 () => {
|
||||
await Promise.allSettled([
|
||||
auth.fetchMe(),
|
||||
loadPaymentHistory(),
|
||||
refetchUsageSnapshot(),
|
||||
refetchUsage(),
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -403,7 +392,7 @@ const submitUpgrade = async () => {
|
||||
|
||||
try {
|
||||
const paymentMethod: UpgradePaymentMethod = selectedNeedsTopup.value ? selectedPaymentMethod.value : 'wallet';
|
||||
const payload: Record<string, any> = {
|
||||
const payload: Parameters<typeof rpcClient.createPayment>[0] = {
|
||||
planId: selectedPlan.value.id,
|
||||
termMonths: selectedTermMonths.value,
|
||||
paymentMethod: paymentMethod,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
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 SlidersIcon from '@/components/icons/SlidersIcon.vue';
|
||||
import TrashIcon from '@/components/icons/TrashIcon.vue';
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { client as rpcClient } from '@/api/rpcclient';
|
||||
import AppButton from '@/components/app/AppButton.vue';
|
||||
import AppDialog from '@/components/app/AppDialog.vue';
|
||||
import AppInput from '@/components/app/AppInput.vue';
|
||||
import BaseTable from '@/components/ui/table/BaseTable.vue';
|
||||
import AppButton from '@/components/ui/AppButton.vue';
|
||||
import AppDialog from '@/components/ui/AppDialog.vue';
|
||||
import AppInput from '@/components/ui/AppInput.vue';
|
||||
import BaseTable from '@/components/ui/BaseTable.vue';
|
||||
import CheckIcon from '@/components/icons/CheckIcon.vue';
|
||||
import LinkIcon from '@/components/icons/LinkIcon.vue';
|
||||
import PlusIcon from '@/components/icons/PlusIcon.vue';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { client as rpcClient } from '@/api/rpcclient';
|
||||
import AppButton from '@/components/app/AppButton.vue';
|
||||
import AppSwitch from '@/components/app/AppSwitch.vue';
|
||||
import AppButton from '@/components/ui/AppButton.vue';
|
||||
import AppSwitch from '@/components/ui/AppSwitch.vue';
|
||||
import BellIcon from '@/components/icons/BellIcon.vue';
|
||||
import CheckIcon from '@/components/icons/CheckIcon.vue';
|
||||
import MailIcon from '@/components/icons/MailIcon.vue';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { client as rpcClient } from '@/api/rpcclient';
|
||||
import AppButton from '@/components/app/AppButton.vue';
|
||||
import AppSwitch from '@/components/app/AppSwitch.vue';
|
||||
import AppButton from '@/components/ui/AppButton.vue';
|
||||
import AppSwitch from '@/components/ui/AppSwitch.vue';
|
||||
import CheckIcon from '@/components/icons/CheckIcon.vue';
|
||||
import {
|
||||
createPlayerSettingsDraft,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import AppButton from '@/components/app/AppButton.vue';
|
||||
import AppDialog from '@/components/app/AppDialog.vue';
|
||||
import AppInput from '@/components/app/AppInput.vue';
|
||||
import AppButton from '@/components/ui/AppButton.vue';
|
||||
import AppDialog from '@/components/ui/AppDialog.vue';
|
||||
import AppInput from '@/components/ui/AppInput.vue';
|
||||
import CheckIcon from '@/components/icons/CheckIcon.vue';
|
||||
import LockIcon from '@/components/icons/LockIcon.vue';
|
||||
import TelegramIcon from '@/components/icons/TelegramIcon.vue';
|
||||
|
||||
@@ -62,8 +62,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import AppConfirmHost from '@/components/app/AppConfirmHost.vue';
|
||||
import AppToastHost from '@/components/app/AppToastHost.vue';
|
||||
import AppConfirmHost from '@/components/ui/AppConfirmHost.vue';
|
||||
import AppToastHost from '@/components/ui/AppToastHost.vue';
|
||||
import ClientOnly from '@/components/ClientOnly';
|
||||
import PageHeader from '@/components/dashboard/PageHeader.vue';
|
||||
import AdvertisementIcon from '@/components/icons/AdvertisementIcon.vue';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import AppButton from '@/components/app/AppButton.vue';
|
||||
import AppDialog from '@/components/app/AppDialog.vue';
|
||||
import AppInput from '@/components/app/AppInput.vue';
|
||||
import AppButton from '@/components/ui/AppButton.vue';
|
||||
import AppDialog from '@/components/ui/AppDialog.vue';
|
||||
import AppInput from '@/components/ui/AppInput.vue';
|
||||
import CheckIcon from '@/components/icons/CheckIcon.vue';
|
||||
|
||||
defineProps<{
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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 PlusIcon from '@/components/icons/PlusIcon.vue';
|
||||
import SettingsRow from '../SettingsRow.vue';
|
||||
|
||||
@@ -5,8 +5,10 @@ import { useUploadQueue } from '@/composables/useUploadQueue';
|
||||
import { useUIState } from '@/stores/uiState';
|
||||
import RemoteUrlForm from './components/RemoteUrlForm.vue';
|
||||
import UploadDropzone from './components/UploadDropzone.vue';
|
||||
import { useAppToast } from '@/composables/useAppToast';
|
||||
|
||||
const uiState = useUIState();
|
||||
const toast = useAppToast();
|
||||
const mode = ref<'local' | 'remote'>('local');
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -15,7 +17,7 @@ const { addFiles, addRemoteUrls, pendingCount, startQueue, remainingSlots, maxIt
|
||||
const handleFilesSelected = (files: FileList) => {
|
||||
const result = addFiles(files);
|
||||
if (result.duplicates > 0) {
|
||||
uiState.toastQueue.push({
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: t('upload.dialog.duplicateFilesSummary'),
|
||||
detail: result.duplicates > 1
|
||||
@@ -30,7 +32,7 @@ const handleFilesSelected = (files: FileList) => {
|
||||
const handleRemoteUrls = (urls: string[]) => {
|
||||
const result = addRemoteUrls(urls);
|
||||
if (result.duplicates > 0) {
|
||||
uiState.toastQueue.push({
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: t('upload.dialog.duplicateUrlsSummary'),
|
||||
detail: result.duplicates > 1
|
||||
|
||||
@@ -64,7 +64,7 @@ const onDrop = (e: DragEvent) => {
|
||||
class="absolute inset-0 w-full h-full opacity-0 z-20 cursor-pointer" @change="handleFileChange">
|
||||
|
||||
<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
|
||||
? 'border-accent bg-accent/5 scale-[0.99]'
|
||||
: 'border-slate-200 group-hover:border-accent/60 group-hover:bg-accent/[0.03]'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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 PencilIcon from '@/components/icons/PencilIcon.vue';
|
||||
import TrashIcon from '@/components/icons/TrashIcon.vue';
|
||||
|
||||
Reference in New Issue
Block a user