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:
68
.dockerignore
Normal file
68
.dockerignore
Normal 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
39
Dockerfile
Normal 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
32
components.d.ts
vendored
@@ -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']
|
||||||
|
|||||||
@@ -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';
|
type?: 'button' | 'submit' | 'reset';
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
variant: 'secondary',
|
variant: 'primary',
|
||||||
size: 'md',
|
size: 'md',
|
||||||
block: false,
|
block: false,
|
||||||
disabled: false,
|
disabled: false,
|
||||||
@@ -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';
|
||||||
|
|
||||||
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';
|
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,
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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<{
|
||||||
|
|||||||
@@ -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<{
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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]'
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user