develop-updateui #1

Merged
lethdat merged 78 commits from develop-updateui into master 2026-04-02 05:59:23 +00:00
39 changed files with 429 additions and 273 deletions
Showing only changes of commit bd8b21955e - Show all commits

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']
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']

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';
}>(),
{
variant: 'secondary',
variant: 'primary',
size: 'md',
block: false,
disabled: false,

View File

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

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';
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);
}
}
};
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 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 startedAt = Date.now();
xhr.open('POST', 'https://tmpfiles.org/api/v1/upload');
activeXhrs.set(item.id, xhr);
xhr.open('PUT', uploadUrl);
if (item.file.type) {
xhr.setRequestHeader('Content-Type', item.file.type);
}
// 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 cleanup = () => {
if (activeXhrs.get(item.id) === xhr) {
activeXhrs.delete(item.id);
const unregister = () => activeXhrs.get(item.id)?.delete(xhr);
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
progressMap.set(index, e.loaded);
updateProgress();
}
};
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) {
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;
}
if (xhr.status >= 200 && xhr.status < 300) {
item.progress = 100;
item.uploaded = item.total;
item.speed = '0 MB/s';
resolve();
return;
} catch {
handleError();
}
reject(new Error(t('upload.errors.chunkUploadFailed', { index: 1 })));
};
xhr.onerror = () => {
cleanup();
reject(new Error(t('upload.errors.chunkUploadFailed', { index: 1 })));
}
handleError();
};
xhr.onabort = () => {
cleanup();
resolve();
unregister();
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) => {
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,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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