done ui
This commit is contained in:
71
src/routes/auth/google-finalize.vue
Normal file
71
src/routes/auth/google-finalize.vue
Normal file
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-center gap-3 py-6 text-center">
|
||||
<div class="i-svg-spinners-90-ring-with-bg h-10 w-10 text-blue-600"></div>
|
||||
<p class="text-sm text-gray-600">{{ message }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAppToast } from '@/composables/useAppToast';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { computed, onMounted } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
const auth = useAuthStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const toast = useAppToast();
|
||||
|
||||
const status = computed(() => String(route.query.status ?? 'error'));
|
||||
const reason = computed(() => String(route.query.reason ?? 'google_login_failed'));
|
||||
|
||||
const reasonMessages: Record<string, string> = {
|
||||
missing_state: 'Google login session is invalid. Please try again.',
|
||||
invalid_state: 'Google login session has expired. Please try again.',
|
||||
missing_code: 'Google did not return an authorization code.',
|
||||
access_denied: 'Google login was cancelled.',
|
||||
exchange_failed: 'Failed to sign in with Google.',
|
||||
userinfo_failed: 'Failed to load your Google account information.',
|
||||
userinfo_parse_failed: 'Failed to read your Google account information.',
|
||||
missing_email: 'Your Google account did not provide an email address.',
|
||||
create_user_failed: 'Failed to create your account.',
|
||||
update_user_failed: 'Failed to update your account.',
|
||||
reload_user_failed: 'Failed to finish signing you in.',
|
||||
session_failed: 'Failed to create your sign-in session.',
|
||||
fetch_me_failed: 'Signed in with Google, but failed to load your account.',
|
||||
google_login_failed: 'Google login failed. Please try again.',
|
||||
};
|
||||
|
||||
const errorMessage = computed(() => reasonMessages[reason.value] ?? reasonMessages.google_login_failed);
|
||||
const message = computed(() => status.value === 'success' ? 'Signing you in with Google...' : errorMessage.value);
|
||||
|
||||
onMounted(async () => {
|
||||
if (status.value !== 'success') {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Google login failed',
|
||||
detail: errorMessage.value,
|
||||
life: 5000,
|
||||
});
|
||||
await router.replace({ name: 'login', query: { reason: reason.value } });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await auth.fetchMe();
|
||||
if (!user) {
|
||||
throw new Error('missing_user');
|
||||
}
|
||||
|
||||
await router.replace({ name: 'overview' });
|
||||
} catch {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Google login failed',
|
||||
detail: 'Signed in with Google, but failed to load your account.',
|
||||
life: 5000,
|
||||
});
|
||||
await router.replace({ name: 'login', query: { reason: 'fetch_me_failed' } });
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -45,6 +45,11 @@ const content = computed(() => ({
|
||||
title: t('auth.layout.forgot.title'),
|
||||
subtitle: t('auth.layout.forgot.subtitle'),
|
||||
headTitle: t('auth.layout.forgot.headTitle')
|
||||
},
|
||||
'google-auth-finalize': {
|
||||
title: 'Google sign in',
|
||||
subtitle: 'Completing your Google sign in.',
|
||||
headTitle: 'Google sign in - Holistream'
|
||||
}
|
||||
}));
|
||||
</script>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useRouteLoading } from "@/composables/useRouteLoading";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { headSymbol, type ReactiveHead, type ResolvableValue } from "@unhead/vue";
|
||||
import { inject } from "vue";
|
||||
@@ -68,6 +69,11 @@ const routes: RouteData[] = [
|
||||
name: "forgot",
|
||||
component: () => import("./auth/forgot.vue"),
|
||||
},
|
||||
{
|
||||
path: "auth/google/finalize",
|
||||
name: "google-auth-finalize",
|
||||
component: () => import("./auth/google-finalize.vue"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -85,16 +91,6 @@ const routes: RouteData[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
// {
|
||||
// path: "upload",
|
||||
// name: "upload",
|
||||
// component: () => import("./upload/Upload.vue"),
|
||||
// meta: {
|
||||
// head: {
|
||||
// title: "Upload - Holistream",
|
||||
// },
|
||||
// },
|
||||
// },
|
||||
{
|
||||
path: "videos",
|
||||
children: [
|
||||
@@ -114,16 +110,6 @@ const routes: RouteData[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
// {
|
||||
// path: ":id",
|
||||
// name: "video-detail",
|
||||
// component: () => import("./video/DetailVideo.vue"),
|
||||
// meta: {
|
||||
// head: {
|
||||
// title: "Edit Video - Holistream",
|
||||
// },
|
||||
// },
|
||||
// },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -255,16 +241,27 @@ const createAppRouter = () => {
|
||||
},
|
||||
});
|
||||
|
||||
const loading = useRouteLoading()
|
||||
router.beforeEach((to, from) => {
|
||||
const auth = useAuthStore();
|
||||
const head = inject(headSymbol);
|
||||
(head as any).push(to.meta.head || {});
|
||||
(head as any).push(to.meta.head || {});
|
||||
if (to.fullPath !== from.fullPath && !import.meta.env.SSR) {
|
||||
loading.start()
|
||||
}
|
||||
if (to.matched.some((record) => record.meta.requiresAuth)) {
|
||||
if (!auth.user) {
|
||||
return { name: "login" };
|
||||
}
|
||||
}
|
||||
});
|
||||
router.afterEach(() => {
|
||||
loading.finish()
|
||||
})
|
||||
|
||||
router.onError(() => {
|
||||
loading.fail()
|
||||
})
|
||||
return router;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,117 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
import PageHeader from '@/components/dashboard/PageHeader.vue';
|
||||
import NotificationActions from './components/NotificationActions.vue';
|
||||
import NotificationList from './components/NotificationList.vue';
|
||||
import NotificationTabs from './components/NotificationTabs.vue';
|
||||
import { useNotifications } from '@/composables/useNotifications';
|
||||
|
||||
type NotificationType = 'info' | 'success' | 'warning' | 'error' | 'video' | 'payment' | 'system';
|
||||
|
||||
interface Notification {
|
||||
id: string;
|
||||
type: NotificationType;
|
||||
title: string;
|
||||
message: string;
|
||||
time: string;
|
||||
read: boolean;
|
||||
actionUrl?: string;
|
||||
actionLabel?: string;
|
||||
}
|
||||
|
||||
const loading = ref(false);
|
||||
const activeTab = ref('all');
|
||||
const { t } = useTranslation();
|
||||
const notificationStore = useNotifications();
|
||||
|
||||
const notifications = ref<Notification[]>([
|
||||
{
|
||||
id: '1',
|
||||
type: 'video',
|
||||
title: t('notification.mocks.videoProcessed.title'),
|
||||
message: t('notification.mocks.videoProcessed.message'),
|
||||
time: t('notification.time.minutesAgo', { count: 2 }),
|
||||
read: false,
|
||||
actionUrl: '/video',
|
||||
actionLabel: t('notification.actions.viewVideo')
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'payment',
|
||||
title: t('notification.mocks.paymentSuccess.title'),
|
||||
message: t('notification.mocks.paymentSuccess.message'),
|
||||
time: t('notification.time.hoursAgo', { count: 1 }),
|
||||
read: false,
|
||||
actionUrl: '/payments-and-plans',
|
||||
actionLabel: t('notification.actions.viewReceipt')
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
type: 'warning',
|
||||
title: t('notification.mocks.storageWarning.title'),
|
||||
message: t('notification.mocks.storageWarning.message'),
|
||||
time: t('notification.time.hoursAgo', { count: 3 }),
|
||||
read: false,
|
||||
actionUrl: '/payments-and-plans',
|
||||
actionLabel: t('notification.actions.upgradePlan')
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
type: 'success',
|
||||
title: t('notification.mocks.uploadSuccess.title'),
|
||||
message: t('notification.mocks.uploadSuccess.message'),
|
||||
time: t('notification.time.daysAgo', { count: 1 }),
|
||||
read: true
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
type: 'system',
|
||||
title: t('notification.mocks.maintenance.title'),
|
||||
message: t('notification.mocks.maintenance.message'),
|
||||
time: t('notification.time.daysAgo', { count: 2 }),
|
||||
read: true
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
type: 'info',
|
||||
title: t('notification.mocks.newFeature.title'),
|
||||
message: t('notification.mocks.newFeature.message'),
|
||||
time: t('notification.time.daysAgo', { count: 3 }),
|
||||
read: true,
|
||||
actionUrl: '/video',
|
||||
actionLabel: t('notification.actions.tryNow')
|
||||
}
|
||||
]);
|
||||
onMounted(() => {
|
||||
void notificationStore.fetchNotifications();
|
||||
});
|
||||
|
||||
const unreadCount = computed(() => notifications.value.filter(n => !n.read).length);
|
||||
const unreadCount = computed(() => notificationStore.unreadCount.value);
|
||||
|
||||
const tabs = computed(() => [
|
||||
{ key: 'all', label: t('notification.tabs.all'), icon: 'i-lucide-inbox', count: notifications.value.length },
|
||||
{ key: 'all', label: t('notification.tabs.all'), icon: 'i-lucide-inbox', count: notificationStore.notifications.value.length },
|
||||
{ key: 'unread', label: t('notification.tabs.unread'), icon: 'i-lucide-bell-dot', count: unreadCount.value },
|
||||
{ key: 'video', label: t('notification.tabs.videos'), icon: 'i-lucide-video', count: notifications.value.filter(n => n.type === 'video').length },
|
||||
{ key: 'payment', label: t('notification.tabs.payments'), icon: 'i-lucide-credit-card', count: notifications.value.filter(n => n.type === 'payment').length }
|
||||
{ key: 'video', label: t('notification.tabs.videos'), icon: 'i-lucide-video', count: notificationStore.notifications.value.filter(n => n.type === 'video').length },
|
||||
{ key: 'payment', label: t('notification.tabs.payments'), icon: 'i-lucide-credit-card', count: notificationStore.notifications.value.filter(n => n.type === 'payment').length },
|
||||
]);
|
||||
|
||||
const filteredNotifications = computed(() => {
|
||||
if (activeTab.value === 'all') return notifications.value;
|
||||
if (activeTab.value === 'unread') return notifications.value.filter(n => !n.read);
|
||||
return notifications.value.filter(n => n.type === activeTab.value);
|
||||
if (activeTab.value === 'all') return notificationStore.notifications.value;
|
||||
if (activeTab.value === 'unread') return notificationStore.notifications.value.filter(n => !n.read);
|
||||
return notificationStore.notifications.value.filter(n => n.type === activeTab.value);
|
||||
});
|
||||
|
||||
const handleMarkRead = (id: string) => {
|
||||
const notification = notifications.value.find(n => n.id === id);
|
||||
if (notification) notification.read = true;
|
||||
const handleMarkRead = async (id: string) => {
|
||||
await notificationStore.markRead(id);
|
||||
};
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
notifications.value = notifications.value.filter(n => n.id !== id);
|
||||
const handleDelete = async (id: string) => {
|
||||
await notificationStore.deleteNotification(id);
|
||||
};
|
||||
|
||||
const handleMarkAllRead = () => {
|
||||
notifications.value.forEach(n => n.read = true);
|
||||
const handleMarkAllRead = async () => {
|
||||
await notificationStore.markAllRead();
|
||||
};
|
||||
|
||||
const handleClearAll = () => {
|
||||
notifications.value = [];
|
||||
const handleClearAll = async () => {
|
||||
await notificationStore.clearAll();
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -128,8 +60,8 @@ const handleClearAll = () => {
|
||||
<div class="w-full max-w-4xl mx-auto mt-6">
|
||||
<div class="notification-container bg-white rounded-2xl border border-gray-200 p-6 shadow-sm">
|
||||
<NotificationActions
|
||||
:loading="loading"
|
||||
:total-count="notifications.length"
|
||||
:loading="notificationStore.loading.value"
|
||||
:total-count="notificationStore.notifications.value.length"
|
||||
:unread-count="unreadCount"
|
||||
@mark-all-read="handleMarkAllRead"
|
||||
@clear-all="handleClearAll"
|
||||
@@ -143,7 +75,7 @@ const handleClearAll = () => {
|
||||
|
||||
<NotificationList
|
||||
:notifications="filteredNotifications"
|
||||
:loading="loading"
|
||||
:loading="notificationStore.loading.value"
|
||||
@mark-read="handleMarkRead"
|
||||
@delete="handleDelete"
|
||||
/>
|
||||
|
||||
@@ -1,48 +1,42 @@
|
||||
<script setup lang="tsx">
|
||||
import { client, type ModelVideo } from '@/api/client';
|
||||
import { useUsageQuery } from '@/composables/useUsageQuery';
|
||||
import PageHeader from '@/components/dashboard/PageHeader.vue';
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import NameGradient from './components/NameGradient.vue';
|
||||
import QuickActions from './components/QuickActions.vue';
|
||||
import RecentVideos from './components/RecentVideos.vue';
|
||||
import StatsOverview from './components/StatsOverview.vue';
|
||||
|
||||
const loading = ref(true);
|
||||
const recentVideosLoading = ref(true);
|
||||
const recentVideos = ref<ModelVideo[]>([]);
|
||||
const { data: usageSnapshot, isPending: isUsagePending } = useUsageQuery();
|
||||
|
||||
const stats = ref({
|
||||
totalVideos: 0,
|
||||
totalViews: 0,
|
||||
storageUsed: 0,
|
||||
const stats = computed(() => ({
|
||||
totalVideos: usageSnapshot.value?.totalVideos ?? 0,
|
||||
totalViews: recentVideos.value.reduce((sum, v: any) => sum + (v.views || 0), 0),
|
||||
storageUsed: usageSnapshot.value?.totalStorage ?? 0,
|
||||
storageLimit: 10737418240,
|
||||
uploadsThisMonth: 0
|
||||
});
|
||||
}));
|
||||
const statsLoading = computed(() => recentVideosLoading.value || (isUsagePending.value && !usageSnapshot.value));
|
||||
|
||||
const fetchDashboardData = async () => {
|
||||
loading.value = true;
|
||||
recentVideosLoading.value = true;
|
||||
try {
|
||||
const response = await client.videos.videosList({ page: 1, limit: 5 });
|
||||
const response = await client.videos.videosList({ page: 1, limit: 5 }, { baseUrl: '/r' });
|
||||
const body = response.data as any;
|
||||
|
||||
if (body.data && Array.isArray(body.data)) {
|
||||
recentVideos.value = body.data;
|
||||
stats.value.totalVideos = body.data.length;
|
||||
} else if (Array.isArray(body)) {
|
||||
recentVideos.value = body;
|
||||
stats.value.totalVideos = body.length;
|
||||
}
|
||||
const videos = Array.isArray(body?.data?.videos)
|
||||
? body.data.videos
|
||||
: Array.isArray(body?.videos)
|
||||
? body.videos
|
||||
: [];
|
||||
|
||||
stats.value.totalViews = recentVideos.value.reduce((sum, v: any) => sum + (v.views || 0), 0);
|
||||
stats.value.storageUsed = recentVideos.value.reduce((sum, v) => sum + (v.size || 0), 0);
|
||||
stats.value.uploadsThisMonth = recentVideos.value.filter(v => {
|
||||
const uploadDate = new Date(v.created_at || '');
|
||||
const now = new Date();
|
||||
return uploadDate.getMonth() === now.getMonth() && uploadDate.getFullYear() === now.getFullYear();
|
||||
}).length;
|
||||
recentVideos.value = videos;
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch dashboard data:', err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
recentVideosLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -57,11 +51,11 @@ onMounted(() => {
|
||||
{ label: $t('pageHeader.dashboard') }
|
||||
]" />
|
||||
|
||||
<StatsOverview :loading="loading" :stats="stats" />
|
||||
<StatsOverview :loading="statsLoading" :stats="stats" />
|
||||
|
||||
<QuickActions :loading="loading" />
|
||||
<QuickActions :loading="recentVideosLoading" />
|
||||
|
||||
<RecentVideos :loading="loading" :videos="recentVideos" />
|
||||
<RecentVideos :loading="recentVideosLoading" :videos="recentVideos" />
|
||||
|
||||
<!-- <StorageUsage :loading="loading" :stats="stats" /> -->
|
||||
</div>
|
||||
|
||||
@@ -30,7 +30,7 @@ const quickActions = computed(() => [
|
||||
title: t('overview.quickActions.videoLibrary.title'),
|
||||
description: t('overview.quickActions.videoLibrary.description'),
|
||||
icon: Video,
|
||||
onClick: () => router.push('/video')
|
||||
onClick: () => router.push('/videos')
|
||||
},
|
||||
{
|
||||
title: t('overview.quickActions.analytics.title'),
|
||||
@@ -42,7 +42,7 @@ const quickActions = computed(() => [
|
||||
title: t('overview.quickActions.managePlan.title'),
|
||||
description: t('overview.quickActions.managePlan.description'),
|
||||
icon: Credit,
|
||||
onClick: () => router.push('/payments-and-plans')
|
||||
onClick: () => router.push('/settings/billing')
|
||||
},
|
||||
]);
|
||||
</script>
|
||||
|
||||
@@ -4,6 +4,7 @@ import EmptyState from '@/components/dashboard/EmptyState.vue';
|
||||
import { formatDate, formatDuration } from '@/lib/utils';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useUIState } from '@/stores/uiState';
|
||||
|
||||
interface Props {
|
||||
loading: boolean;
|
||||
@@ -13,6 +14,7 @@ interface Props {
|
||||
defineProps<Props>();
|
||||
|
||||
const router = useRouter();
|
||||
const uiState = useUIState();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const getStatusClass = (status?: string) => {
|
||||
@@ -48,7 +50,7 @@ const getStatusClass = (status?: string) => {
|
||||
<div v-else>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-xl font-semibold">{{ t('overview.recentVideos.title') }}</h2>
|
||||
<router-link to="/video"
|
||||
<router-link to="/videos"
|
||||
class="text-sm text-primary hover:underline font-medium flex items-center gap-1">
|
||||
{{ t('overview.recentVideos.viewAll') }}
|
||||
<span class="i-heroicons-arrow-right w-4 h-4" />
|
||||
@@ -58,7 +60,7 @@ const getStatusClass = (status?: string) => {
|
||||
<EmptyState v-if="videos.length === 0" :title="t('overview.recentVideos.emptyTitle')"
|
||||
:description="t('overview.recentVideos.emptyDescription')"
|
||||
imageUrl="https://cdn-icons-png.flaticon.com/512/7486/7486747.png" :actionLabel="t('overview.recentVideos.emptyAction')"
|
||||
:onAction="() => router.push('/upload')" />
|
||||
:onAction="() => uiState.toggleUploadDialog()" />
|
||||
|
||||
<div v-else class="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
|
||||
@@ -11,7 +11,6 @@ interface Props {
|
||||
totalViews: number;
|
||||
storageUsed: number;
|
||||
storageLimit: number;
|
||||
uploadsThisMonth: number;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -21,8 +20,8 @@ const localeTag = computed(() => i18next.resolvedLanguage === 'vi' ? 'vi-VN' : '
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="loading" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
<div v-for="i in 4" :key="i" class="bg-surface rounded-xl border border-gray-200 p-6">
|
||||
<div v-if="loading" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
|
||||
<div v-for="i in 3" :key="i" class="bg-surface rounded-xl border border-gray-200 p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="space-y-2">
|
||||
<div class="w-20 h-4 bg-gray-200 rounded animate-pulse mb-2" />
|
||||
@@ -33,7 +32,7 @@ const localeTag = computed(() => i18next.resolvedLanguage === 'vi' ? 'vi-VN' : '
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
|
||||
<StatsCard :title="t('overview.stats.totalVideos')" :value="stats.totalVideos" :trend="{ value: 12, isPositive: true }" />
|
||||
|
||||
<StatsCard :title="t('overview.stats.totalViews')" :value="stats.totalViews.toLocaleString(localeTag)"
|
||||
@@ -41,8 +40,5 @@ const localeTag = computed(() => i18next.resolvedLanguage === 'vi' ? 'vi-VN' : '
|
||||
|
||||
<StatsCard :title="t('overview.stats.storageUsed')"
|
||||
:value="`${formatBytes(stats.storageUsed)} / ${formatBytes(stats.storageLimit)}`" color="warning" />
|
||||
|
||||
<StatsCard :title="t('overview.stats.uploadsThisMonth')" :value="stats.uploadsThisMonth" color="success"
|
||||
:trend="{ value: 25, isPositive: true }" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { client } from '@/api/client';
|
||||
import AppButton from '@/components/app/AppButton.vue';
|
||||
import AppDialog from '@/components/app/AppDialog.vue';
|
||||
import AppInput from '@/components/app/AppInput.vue';
|
||||
@@ -12,11 +13,15 @@ import { useAppConfirm } from '@/composables/useAppConfirm';
|
||||
import { useAppToast } from '@/composables/useAppToast';
|
||||
import SettingsNotice from '@/routes/settings/components/SettingsNotice.vue';
|
||||
import SettingsSectionCard from '@/routes/settings/components/SettingsSectionCard.vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import SettingsTableSkeleton from '@/routes/settings/components/SettingsTableSkeleton.vue';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useQuery } from '@pinia/colada';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
|
||||
const toast = useAppToast();
|
||||
const confirm = useAppConfirm();
|
||||
const auth = useAuthStore();
|
||||
const { t } = useTranslation();
|
||||
|
||||
interface VastTemplate {
|
||||
@@ -26,39 +31,97 @@ interface VastTemplate {
|
||||
adFormat: 'pre-roll' | 'mid-roll' | 'post-roll';
|
||||
duration?: number;
|
||||
enabled: boolean;
|
||||
isDefault: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const templates = ref<VastTemplate[]>([
|
||||
{
|
||||
id: '1',
|
||||
name: 'Main Pre-roll Ad',
|
||||
vastUrl: 'https://ads.example.com/vast/pre-roll.xml',
|
||||
adFormat: 'pre-roll',
|
||||
enabled: true,
|
||||
createdAt: '2024-01-10',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Mid-roll Ad Break',
|
||||
vastUrl: 'https://ads.example.com/vast/mid-roll.xml',
|
||||
adFormat: 'mid-roll',
|
||||
duration: 30,
|
||||
enabled: false,
|
||||
createdAt: '2024-02-15',
|
||||
},
|
||||
]);
|
||||
type AdTemplateApiItem = {
|
||||
id?: string;
|
||||
name?: string;
|
||||
vast_tag_url?: string;
|
||||
ad_format?: 'pre-roll' | 'mid-roll' | 'post-roll';
|
||||
duration?: number | null;
|
||||
is_active?: boolean;
|
||||
is_default?: boolean;
|
||||
created_at?: string;
|
||||
};
|
||||
|
||||
const adFormatOptions = ['pre-roll', 'mid-roll', 'post-roll'] as const;
|
||||
|
||||
const showAddDialog = ref(false);
|
||||
const editingTemplate = ref<VastTemplate | null>(null);
|
||||
const saving = ref(false);
|
||||
const deletingId = ref<string | null>(null);
|
||||
const togglingId = ref<string | null>(null);
|
||||
const defaultingId = ref<string | null>(null);
|
||||
|
||||
const formData = ref({
|
||||
name: '',
|
||||
vastUrl: '',
|
||||
adFormat: 'pre-roll' as 'pre-roll' | 'mid-roll' | 'post-roll',
|
||||
duration: undefined as number | undefined,
|
||||
isDefault: false,
|
||||
});
|
||||
|
||||
const isFreePlan = computed(() => !auth.user?.plan_id);
|
||||
const isMutating = computed(() => saving.value || deletingId.value !== null || togglingId.value !== null || defaultingId.value !== null);
|
||||
const canMarkAsDefaultInDialog = computed(() => !isFreePlan.value && (!editingTemplate.value || editingTemplate.value.enabled));
|
||||
|
||||
const mapTemplate = (item: AdTemplateApiItem): VastTemplate => ({
|
||||
id: item.id || `${item.name || 'template'}:${item.vast_tag_url || item.created_at || ''}`,
|
||||
name: item.name || '',
|
||||
vastUrl: item.vast_tag_url || '',
|
||||
adFormat: item.ad_format || 'pre-roll',
|
||||
duration: typeof item.duration === 'number' ? item.duration : undefined,
|
||||
enabled: Boolean(item.is_active),
|
||||
isDefault: Boolean(item.is_default),
|
||||
createdAt: item.created_at || '',
|
||||
});
|
||||
|
||||
const { data: templatesSnapshot, error, isPending, refetch } = useQuery({
|
||||
key: () => ['settings', 'ad-templates'],
|
||||
query: async () => {
|
||||
const response = await client.adTemplates.adTemplatesList({ baseUrl: '/r' });
|
||||
return ((((response.data as any)?.data?.templates) || []) as AdTemplateApiItem[]).map(mapTemplate);
|
||||
},
|
||||
});
|
||||
|
||||
const templates = computed(() => templatesSnapshot.value || []);
|
||||
const isInitialLoading = computed(() => isPending.value && !templatesSnapshot.value);
|
||||
|
||||
const refetchTemplates = () => refetch((fetchError) => {
|
||||
throw fetchError;
|
||||
});
|
||||
|
||||
const getErrorMessage = (value: any, fallback: string) => value?.error?.message || value?.message || value?.data?.message || fallback;
|
||||
|
||||
const showActionErrorToast = (value: any) => {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('settings.adsVast.toast.failedSummary'),
|
||||
detail: getErrorMessage(value, t('settings.adsVast.toast.failedDetail')),
|
||||
life: 5000,
|
||||
});
|
||||
};
|
||||
|
||||
const showUpgradeRequiredToast = () => {
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: t('settings.adsVast.toast.upgradeRequiredSummary'),
|
||||
detail: t('settings.adsVast.toast.upgradeRequiredDetail'),
|
||||
life: 4000,
|
||||
});
|
||||
};
|
||||
|
||||
const ensurePaidPlan = () => {
|
||||
if (!isFreePlan.value) return true;
|
||||
showUpgradeRequiredToast();
|
||||
return false;
|
||||
};
|
||||
|
||||
watch(error, (value, previous) => {
|
||||
if (!value || value === previous || isMutating.value) return;
|
||||
showActionErrorToast(value);
|
||||
});
|
||||
|
||||
const resetForm = () => {
|
||||
@@ -67,27 +130,47 @@ const resetForm = () => {
|
||||
vastUrl: '',
|
||||
adFormat: 'pre-roll',
|
||||
duration: undefined,
|
||||
isDefault: false,
|
||||
};
|
||||
editingTemplate.value = null;
|
||||
};
|
||||
|
||||
const closeDialog = () => {
|
||||
showAddDialog.value = false;
|
||||
resetForm();
|
||||
};
|
||||
|
||||
const openAddDialog = () => {
|
||||
if (!ensurePaidPlan()) return;
|
||||
resetForm();
|
||||
showAddDialog.value = true;
|
||||
};
|
||||
|
||||
const openEditDialog = (template: VastTemplate) => {
|
||||
if (!ensurePaidPlan()) return;
|
||||
formData.value = {
|
||||
name: template.name,
|
||||
vastUrl: template.vastUrl,
|
||||
adFormat: template.adFormat,
|
||||
duration: template.duration,
|
||||
isDefault: template.isDefault,
|
||||
};
|
||||
editingTemplate.value = template;
|
||||
showAddDialog.value = true;
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
const buildRequestBody = (enabled = true) => ({
|
||||
name: formData.value.name.trim(),
|
||||
vast_tag_url: formData.value.vastUrl.trim(),
|
||||
ad_format: formData.value.adFormat,
|
||||
duration: formData.value.adFormat === 'mid-roll' ? formData.value.duration : undefined,
|
||||
is_active: enabled,
|
||||
is_default: enabled ? formData.value.isDefault : false,
|
||||
});
|
||||
|
||||
const handleSave = async () => {
|
||||
if (saving.value || !ensurePaidPlan()) return;
|
||||
|
||||
if (!formData.value.name.trim()) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
@@ -117,7 +200,7 @@ const handleSave = () => {
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (formData.value.adFormat === 'mid-roll' && !formData.value.duration) {
|
||||
if (formData.value.adFormat === 'mid-roll' && (!formData.value.duration || formData.value.duration <= 0)) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('settings.adsVast.toast.durationRequiredSummary'),
|
||||
@@ -127,74 +210,146 @@ const handleSave = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (editingTemplate.value) {
|
||||
const index = templates.value.findIndex(template => template.id === editingTemplate.value!.id);
|
||||
if (index !== -1) {
|
||||
templates.value[index] = { ...templates.value[index], ...formData.value };
|
||||
saving.value = true;
|
||||
try {
|
||||
if (editingTemplate.value) {
|
||||
await client.adTemplates.adTemplatesUpdate(
|
||||
editingTemplate.value.id,
|
||||
buildRequestBody(editingTemplate.value.enabled),
|
||||
{ baseUrl: '/r' },
|
||||
);
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('settings.adsVast.toast.updatedSummary'),
|
||||
detail: t('settings.adsVast.toast.updatedDetail'),
|
||||
life: 3000,
|
||||
});
|
||||
} else {
|
||||
await client.adTemplates.adTemplatesCreate(buildRequestBody(true), { baseUrl: '/r' });
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('settings.adsVast.toast.createdSummary'),
|
||||
detail: t('settings.adsVast.toast.createdDetail'),
|
||||
life: 3000,
|
||||
});
|
||||
}
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('settings.adsVast.toast.updatedSummary'),
|
||||
detail: t('settings.adsVast.toast.updatedDetail'),
|
||||
life: 3000,
|
||||
});
|
||||
} else {
|
||||
templates.value.push({
|
||||
id: Math.random().toString(36).substring(2, 9),
|
||||
...formData.value,
|
||||
enabled: true,
|
||||
createdAt: new Date().toISOString().split('T')[0],
|
||||
});
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('settings.adsVast.toast.createdSummary'),
|
||||
detail: t('settings.adsVast.toast.createdDetail'),
|
||||
life: 3000,
|
||||
});
|
||||
}
|
||||
|
||||
showAddDialog.value = false;
|
||||
resetForm();
|
||||
await refetchTemplates();
|
||||
closeDialog();
|
||||
} catch (value: any) {
|
||||
console.error(value);
|
||||
showActionErrorToast(value);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggle = (template: VastTemplate) => {
|
||||
template.enabled = !template.enabled;
|
||||
toast.add({
|
||||
severity: 'info',
|
||||
summary: template.enabled
|
||||
? t('settings.adsVast.toast.enabledSummary')
|
||||
: t('settings.adsVast.toast.disabledSummary'),
|
||||
detail: t('settings.adsVast.toast.toggleDetail', {
|
||||
const handleToggle = async (template: VastTemplate, nextValue: boolean) => {
|
||||
if (!ensurePaidPlan()) return;
|
||||
|
||||
togglingId.value = template.id;
|
||||
try {
|
||||
await client.adTemplates.adTemplatesUpdate(template.id, {
|
||||
name: template.name,
|
||||
state: template.enabled
|
||||
? t('settings.adsVast.state.enabled')
|
||||
: t('settings.adsVast.state.disabled'),
|
||||
}),
|
||||
life: 2000,
|
||||
});
|
||||
vast_tag_url: template.vastUrl,
|
||||
ad_format: template.adFormat,
|
||||
duration: template.adFormat === 'mid-roll' ? template.duration : undefined,
|
||||
is_active: nextValue,
|
||||
is_default: nextValue ? template.isDefault : false,
|
||||
}, { baseUrl: '/r' });
|
||||
|
||||
await refetchTemplates();
|
||||
toast.add({
|
||||
severity: 'info',
|
||||
summary: nextValue
|
||||
? t('settings.adsVast.toast.enabledSummary')
|
||||
: t('settings.adsVast.toast.disabledSummary'),
|
||||
detail: t('settings.adsVast.toast.toggleDetail', {
|
||||
name: template.name,
|
||||
state: nextValue
|
||||
? t('settings.adsVast.state.enabled')
|
||||
: t('settings.adsVast.state.disabled'),
|
||||
}),
|
||||
life: 2000,
|
||||
});
|
||||
} catch (value: any) {
|
||||
console.error(value);
|
||||
showActionErrorToast(value);
|
||||
} finally {
|
||||
togglingId.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetDefault = async (template: VastTemplate) => {
|
||||
if (template.isDefault || !template.enabled || !ensurePaidPlan()) return;
|
||||
|
||||
defaultingId.value = template.id;
|
||||
try {
|
||||
await client.adTemplates.adTemplatesUpdate(template.id, {
|
||||
name: template.name,
|
||||
vast_tag_url: template.vastUrl,
|
||||
ad_format: template.adFormat,
|
||||
duration: template.adFormat === 'mid-roll' ? template.duration : undefined,
|
||||
is_active: template.enabled,
|
||||
is_default: true,
|
||||
}, { baseUrl: '/r' });
|
||||
|
||||
await refetchTemplates();
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('settings.adsVast.toast.defaultUpdatedSummary'),
|
||||
detail: t('settings.adsVast.toast.defaultUpdatedDetail', { name: template.name }),
|
||||
life: 3000,
|
||||
});
|
||||
} catch (value: any) {
|
||||
console.error(value);
|
||||
showActionErrorToast(value);
|
||||
} finally {
|
||||
defaultingId.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (template: VastTemplate) => {
|
||||
if (!ensurePaidPlan()) return;
|
||||
|
||||
confirm.require({
|
||||
message: t('settings.adsVast.confirm.deleteMessage', { name: template.name }),
|
||||
header: t('settings.adsVast.confirm.deleteHeader'),
|
||||
acceptLabel: t('settings.adsVast.confirm.deleteAccept'),
|
||||
rejectLabel: t('settings.adsVast.confirm.deleteReject'),
|
||||
accept: () => {
|
||||
const index = templates.value.findIndex(item => item.id === template.id);
|
||||
if (index !== -1) templates.value.splice(index, 1);
|
||||
toast.add({
|
||||
severity: 'info',
|
||||
summary: t('settings.adsVast.toast.deletedSummary'),
|
||||
detail: t('settings.adsVast.toast.deletedDetail'),
|
||||
life: 3000,
|
||||
});
|
||||
accept: async () => {
|
||||
deletingId.value = template.id;
|
||||
try {
|
||||
await client.adTemplates.adTemplatesDelete(template.id, { baseUrl: '/r' });
|
||||
await refetchTemplates();
|
||||
toast.add({
|
||||
severity: 'info',
|
||||
summary: t('settings.adsVast.toast.deletedSummary'),
|
||||
detail: t('settings.adsVast.toast.deletedDetail'),
|
||||
life: 3000,
|
||||
});
|
||||
} catch (value: any) {
|
||||
console.error(value);
|
||||
showActionErrorToast(value);
|
||||
} finally {
|
||||
deletingId.value = null;
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
const copyToClipboard = async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch {
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = text;
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('settings.adsVast.toast.copiedSummary'),
|
||||
@@ -228,7 +383,7 @@ const getAdFormatColor = (format: string) => {
|
||||
bodyClass=""
|
||||
>
|
||||
<template #header-actions>
|
||||
<AppButton size="sm" @click="openAddDialog">
|
||||
<AppButton size="sm" :disabled="isFreePlan || isInitialLoading || isMutating" @click="openAddDialog">
|
||||
<template #icon>
|
||||
<PlusIcon class="w-4 h-4" />
|
||||
</template>
|
||||
@@ -240,7 +395,19 @@ const getAdFormatColor = (format: string) => {
|
||||
{{ t('settings.adsVast.infoBanner') }}
|
||||
</SettingsNotice>
|
||||
|
||||
<div class="border-b border-border mt-4">
|
||||
<SettingsNotice
|
||||
v-if="isFreePlan"
|
||||
tone="warning"
|
||||
:title="t('settings.adsVast.readOnlyTitle')"
|
||||
class="rounded-none border-x-0 border-t-0 p-3"
|
||||
contentClass="text-xs text-foreground/70"
|
||||
>
|
||||
{{ t('settings.adsVast.readOnlyMessage') }}
|
||||
</SettingsNotice>
|
||||
|
||||
<SettingsTableSkeleton v-if="isInitialLoading" :columns="5" :rows="4" />
|
||||
|
||||
<div v-else class="border-b border-border mt-4">
|
||||
<table class="w-full">
|
||||
<thead class="bg-muted/30">
|
||||
<tr>
|
||||
@@ -252,57 +419,84 @@ const getAdFormatColor = (format: string) => {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-border">
|
||||
<tr
|
||||
v-for="template in templates"
|
||||
:key="template.id"
|
||||
class="hover:bg-muted/30 transition-all"
|
||||
>
|
||||
<td class="px-6 py-3">
|
||||
<div>
|
||||
<span class="text-sm font-medium text-foreground">{{ template.name }}</span>
|
||||
<p class="text-xs text-foreground/50 mt-0.5">{{ t('settings.adsVast.createdOn', { date: template.createdAt }) }}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-3">
|
||||
<span :class="['text-xs px-2 py-1 rounded-full font-medium', getAdFormatColor(template.adFormat)]">
|
||||
{{ getAdFormatLabel(template.adFormat) }}
|
||||
</span>
|
||||
<span v-if="template.adFormat === 'mid-roll' && template.duration" class="text-xs text-foreground/50 ml-2">
|
||||
({{ template.duration }}s)
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-3">
|
||||
<div class="flex items-center gap-2 max-w-[200px]">
|
||||
<code class="text-xs text-foreground/60 truncate">{{ template.vastUrl }}</code>
|
||||
<AppButton variant="ghost" size="sm" @click="copyToClipboard(template.vastUrl)">
|
||||
<template #icon>
|
||||
<CheckIcon class="w-4 h-4" />
|
||||
</template>
|
||||
</AppButton>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-3 text-center">
|
||||
<AppSwitch
|
||||
:model-value="template.enabled"
|
||||
@update:model-value="handleToggle(template)"
|
||||
/>
|
||||
</td>
|
||||
<td class="px-6 py-3 text-right">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<AppButton variant="ghost" size="sm" @click="openEditDialog(template)">
|
||||
<template #icon>
|
||||
<PencilIcon class="w-4 h-4" />
|
||||
</template>
|
||||
</AppButton>
|
||||
<AppButton variant="ghost" size="sm" @click="handleDelete(template)">
|
||||
<template #icon>
|
||||
<TrashIcon class="w-4 h-4 text-danger" />
|
||||
</template>
|
||||
</AppButton>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="templates.length === 0">
|
||||
<template v-if="templates.length > 0">
|
||||
<tr
|
||||
v-for="template in templates"
|
||||
:key="template.id"
|
||||
class="hover:bg-muted/30 transition-all"
|
||||
>
|
||||
<td class="px-6 py-3">
|
||||
<div>
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="text-sm font-medium text-foreground">{{ template.name }}</span>
|
||||
<span
|
||||
v-if="template.isDefault"
|
||||
class="inline-flex items-center rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary"
|
||||
>
|
||||
{{ t('settings.adsVast.defaultBadge') }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs text-foreground/50 mt-0.5">{{ t('settings.adsVast.createdOn', { date: template.createdAt || '-' }) }}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-3">
|
||||
<span :class="['text-xs px-2 py-1 rounded-full font-medium', getAdFormatColor(template.adFormat)]">
|
||||
{{ getAdFormatLabel(template.adFormat) }}
|
||||
</span>
|
||||
<span v-if="template.adFormat === 'mid-roll' && template.duration" class="text-xs text-foreground/50 ml-2">
|
||||
({{ template.duration }}s)
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-3">
|
||||
<div class="flex items-center gap-2 max-w-[240px]">
|
||||
<code class="text-xs text-foreground/60 truncate">{{ template.vastUrl }}</code>
|
||||
<AppButton variant="ghost" size="sm" :disabled="isMutating" @click="copyToClipboard(template.vastUrl)">
|
||||
<template #icon>
|
||||
<CheckIcon class="w-4 h-4" />
|
||||
</template>
|
||||
</AppButton>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-3 text-center">
|
||||
<AppSwitch
|
||||
:model-value="template.enabled"
|
||||
:disabled="isFreePlan || saving || deletingId !== null || defaultingId !== null || togglingId === template.id"
|
||||
@update:model-value="handleToggle(template, $event)"
|
||||
/>
|
||||
</td>
|
||||
<td class="px-6 py-3 text-right">
|
||||
<div class="flex items-center justify-end gap-2 flex-wrap">
|
||||
<span
|
||||
v-if="template.isDefault"
|
||||
class="inline-flex items-center rounded-full bg-primary/10 px-2 py-1 text-xs font-medium text-primary"
|
||||
>
|
||||
{{ t('settings.adsVast.actions.default') }}
|
||||
</span>
|
||||
<AppButton
|
||||
v-else
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:loading="defaultingId === template.id"
|
||||
:disabled="isFreePlan || saving || deletingId !== null || togglingId !== null || defaultingId !== null || !template.enabled"
|
||||
@click="handleSetDefault(template)"
|
||||
>
|
||||
{{ t('settings.adsVast.actions.setDefault') }}
|
||||
</AppButton>
|
||||
<AppButton variant="ghost" size="sm" :disabled="isFreePlan || isMutating" @click="openEditDialog(template)">
|
||||
<template #icon>
|
||||
<PencilIcon class="w-4 h-4" />
|
||||
</template>
|
||||
</AppButton>
|
||||
<AppButton variant="ghost" size="sm" :disabled="isFreePlan || isMutating" @click="handleDelete(template)">
|
||||
<template #icon>
|
||||
<TrashIcon class="w-4 h-4 text-danger" />
|
||||
</template>
|
||||
</AppButton>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<tr v-else>
|
||||
<td colspan="5" class="px-6 py-12 text-center">
|
||||
<LinkIcon class="w-10 h-10 text-foreground/30 mb-3 block mx-auto" />
|
||||
<p class="text-sm text-foreground/60 mb-1">{{ t('settings.adsVast.emptyTitle') }}</p>
|
||||
@@ -315,9 +509,10 @@ const getAdFormatColor = (format: string) => {
|
||||
|
||||
<AppDialog
|
||||
:visible="showAddDialog"
|
||||
@update:visible="showAddDialog = $event"
|
||||
:title="editingTemplate ? t('settings.adsVast.dialog.editTitle') : t('settings.adsVast.dialog.createTitle')"
|
||||
maxWidthClass="max-w-lg"
|
||||
@update:visible="showAddDialog = $event"
|
||||
@close="closeDialog"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<div class="grid gap-2">
|
||||
@@ -325,6 +520,7 @@ const getAdFormatColor = (format: string) => {
|
||||
<AppInput
|
||||
id="name"
|
||||
v-model="formData.name"
|
||||
:disabled="isFreePlan || saving"
|
||||
:placeholder="t('settings.adsVast.dialog.templateNamePlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
@@ -334,6 +530,7 @@ const getAdFormatColor = (format: string) => {
|
||||
<AppInput
|
||||
id="vastUrl"
|
||||
v-model="formData.vastUrl"
|
||||
:disabled="isFreePlan || saving"
|
||||
:placeholder="t('settings.adsVast.dialog.vastUrlPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
@@ -344,13 +541,16 @@ const getAdFormatColor = (format: string) => {
|
||||
<button
|
||||
v-for="format in adFormatOptions"
|
||||
:key="format"
|
||||
@click="formData.adFormat = format"
|
||||
type="button"
|
||||
:disabled="isFreePlan || saving"
|
||||
:class="[
|
||||
'px-3 py-2 border rounded-md text-sm font-medium transition-all',
|
||||
'px-3 py-2 border rounded-md text-sm font-medium transition-all disabled:opacity-60 disabled:cursor-not-allowed',
|
||||
formData.adFormat === format
|
||||
? 'border-primary bg-primary/5 text-primary'
|
||||
: 'border-border text-foreground/60 hover:border-primary/50'
|
||||
]">
|
||||
]"
|
||||
@click="formData.adFormat = format"
|
||||
>
|
||||
{{ getAdFormatLabel(format) }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -361,20 +561,46 @@ const getAdFormatColor = (format: string) => {
|
||||
<AppInput
|
||||
id="duration"
|
||||
v-model.number="formData.duration"
|
||||
:disabled="isFreePlan || saving"
|
||||
type="number"
|
||||
:placeholder="t('settings.adsVast.dialog.adIntervalPlaceholder')"
|
||||
:min="10"
|
||||
:max="600"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<label class="text-sm font-medium text-foreground">{{ t('settings.adsVast.dialog.defaultLabel') }}</label>
|
||||
<label
|
||||
:class="[
|
||||
'flex items-start gap-3 rounded-md border border-border p-3',
|
||||
canMarkAsDefaultInDialog && !saving ? 'cursor-pointer' : 'opacity-60 cursor-not-allowed'
|
||||
]"
|
||||
>
|
||||
<input
|
||||
v-model="formData.isDefault"
|
||||
type="checkbox"
|
||||
class="mt-1 h-4 w-4 rounded border-border"
|
||||
:disabled="!canMarkAsDefaultInDialog || saving"
|
||||
>
|
||||
<div>
|
||||
<p class="text-sm text-foreground">{{ t('settings.adsVast.dialog.defaultCheckbox') }}</p>
|
||||
<p class="text-xs text-foreground/60 mt-0.5">
|
||||
{{ editingTemplate && !editingTemplate.enabled
|
||||
? t('settings.adsVast.dialog.defaultDisabledHint')
|
||||
: t('settings.adsVast.dialog.defaultHint') }}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton variant="secondary" size="sm" @click="showAddDialog = false">
|
||||
<AppButton variant="secondary" size="sm" :disabled="saving" @click="closeDialog">
|
||||
{{ t('common.cancel') }}
|
||||
</AppButton>
|
||||
<AppButton size="sm" @click="handleSave">
|
||||
<AppButton size="sm" :loading="saving" :disabled="isFreePlan" @click="handleSave">
|
||||
<template #icon>
|
||||
<CheckIcon class="w-4 h-4" />
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { client, type ModelPlan } from '@/api/client';
|
||||
import AppButton from '@/components/app/AppButton.vue';
|
||||
import AppDialog from '@/components/app/AppDialog.vue';
|
||||
import AppInput from '@/components/app/AppInput.vue';
|
||||
import { useAppToast } from '@/composables/useAppToast';
|
||||
import { useUsageQuery } from '@/composables/useUsageQuery';
|
||||
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';
|
||||
@@ -10,23 +14,36 @@ import BillingWalletRow from '@/routes/settings/components/billing/BillingWallet
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useQuery } from '@pinia/colada';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
const toast = useAppToast();
|
||||
const auth = useAuthStore();
|
||||
const { t, i18next } = useTranslation();
|
||||
const TERM_OPTIONS = [1, 3, 6, 12] as const;
|
||||
type UpgradePaymentMethod = 'wallet' | 'topup';
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
key: () => ['payments-and-plans'],
|
||||
query: () => client.plans.plansList(),
|
||||
});
|
||||
type PlansEnvelope = {
|
||||
data?: {
|
||||
plans?: ModelPlan[];
|
||||
} | ModelPlan[];
|
||||
};
|
||||
|
||||
const subscribing = ref<string | null>(null);
|
||||
type PaymentHistoryApiItem = {
|
||||
id?: string;
|
||||
amount?: number;
|
||||
currency?: string;
|
||||
status?: string;
|
||||
plan_name?: string;
|
||||
invoice_id?: string;
|
||||
kind?: string;
|
||||
term_months?: number;
|
||||
payment_method?: string;
|
||||
expires_at?: string;
|
||||
created_at?: string;
|
||||
};
|
||||
|
||||
const topupDialogVisible = ref(false);
|
||||
const topupAmount = ref<number | null>(0);
|
||||
const topupLoading = ref(false);
|
||||
const topupPresets = [10, 20, 50, 100];
|
||||
type PaymentHistoryEnvelope = {
|
||||
data?: {
|
||||
payments?: PaymentHistoryApiItem[];
|
||||
};
|
||||
};
|
||||
|
||||
type PaymentHistoryItem = {
|
||||
id: string;
|
||||
@@ -35,44 +52,108 @@ type PaymentHistoryItem = {
|
||||
plan: string;
|
||||
status: string;
|
||||
invoiceId: string;
|
||||
currency: string;
|
||||
kind: string;
|
||||
details?: string[];
|
||||
};
|
||||
|
||||
const paymentHistory = ref<PaymentHistoryItem[]>([
|
||||
{ id: 'inv_001', date: 'Oct 24, 2025', amount: 9.99, plan: 'Basic Plan', status: 'success', invoiceId: 'INV-2025-001' },
|
||||
{ id: 'inv_002', date: 'Nov 24, 2025', amount: 9.99, plan: 'Basic Plan', status: 'success', invoiceId: 'INV-2025-002' },
|
||||
{ id: 'inv_003', date: 'Dec 24, 2025', amount: 19.99, plan: 'Pro Plan', status: 'failed', invoiceId: 'INV-2025-003' },
|
||||
{ id: 'inv_004', date: 'Jan 24, 2026', amount: 19.99, plan: 'Pro Plan', status: 'pending', invoiceId: 'INV-2026-001' },
|
||||
]);
|
||||
type ApiErrorPayload = {
|
||||
code?: number;
|
||||
message?: string;
|
||||
data?: Record<string, any>;
|
||||
};
|
||||
|
||||
const storageUsed = computed(() => auth.user?.storage_used || 0);
|
||||
const storageLimit = computed(() => 10737418240);
|
||||
const uploadsUsed = ref(12);
|
||||
const uploadsLimit = ref(50);
|
||||
const toast = useAppToast();
|
||||
const auth = useAuthStore();
|
||||
const { t, i18next } = useTranslation();
|
||||
|
||||
const walletBalance = computed(() => auth.user?.wallet_balance || 0);
|
||||
const { data: plansResponse, isLoading } = useQuery({
|
||||
key: () => ['billing-plans'],
|
||||
query: () => client.plans.plansList({ baseUrl: '/r' }),
|
||||
});
|
||||
const { data: usageSnapshot, refetch: refetchUsage } = useUsageQuery();
|
||||
|
||||
const currentPlanId = computed(() => {
|
||||
if (auth.user?.plan_id) return auth.user.plan_id;
|
||||
if (Array.isArray(data?.value?.data?.data.plans) && data?.value?.data?.data.plans.length > 0) return data.value.data.data.plans[0].id;
|
||||
return undefined;
|
||||
const topupDialogVisible = ref(false);
|
||||
const topupAmount = ref<number | null>(null);
|
||||
const topupLoading = ref(false);
|
||||
const historyLoading = ref(false);
|
||||
const downloadingInvoiceId = ref<string | null>(null);
|
||||
const topupPresets = [10, 20, 50, 100];
|
||||
const paymentHistory = ref<PaymentHistoryItem[]>([]);
|
||||
|
||||
const upgradeDialogVisible = ref(false);
|
||||
const selectedPlan = ref<ModelPlan | null>(null);
|
||||
const selectedTermMonths = ref<number>(1);
|
||||
const selectedPaymentMethod = ref<UpgradePaymentMethod>('wallet');
|
||||
const purchaseTopupAmount = ref<number | null>(null);
|
||||
const purchaseLoading = ref(false);
|
||||
const purchaseError = ref<string | null>(null);
|
||||
|
||||
const plans = computed(() => {
|
||||
const body = plansResponse.value?.data as PlansEnvelope | undefined;
|
||||
const payload = body?.data;
|
||||
|
||||
if (Array.isArray(payload)) return payload;
|
||||
if (payload && typeof payload === 'object' && Array.isArray(payload.plans)) {
|
||||
return payload.plans;
|
||||
}
|
||||
|
||||
return [] as ModelPlan[];
|
||||
});
|
||||
|
||||
const plans = computed(() => data.value?.data?.data.plans || []);
|
||||
|
||||
const currentPlanId = computed(() => auth.user?.plan_id || undefined);
|
||||
const currentPlan = computed(() => plans.value.find(plan => plan.id === currentPlanId.value));
|
||||
const currentPlanName = computed(() => currentPlan.value?.name || t('settings.billing.unknownPlan'));
|
||||
const walletBalance = computed(() => auth.user?.wallet_balance || 0);
|
||||
const storageUsed = computed(() => usageSnapshot.value?.totalStorage ?? 0);
|
||||
const uploadsUsed = computed(() => usageSnapshot.value?.totalVideos ?? 0);
|
||||
const storageLimit = computed(() => {
|
||||
const activePlan = plans.value.find(plan => plan.id === currentPlanId.value);
|
||||
return activePlan?.storage_limit || 10737418240;
|
||||
});
|
||||
const uploadsLimit = computed(() => {
|
||||
const activePlan = plans.value.find(plan => plan.id === currentPlanId.value);
|
||||
return activePlan?.upload_limit || 50;
|
||||
});
|
||||
const storagePercentage = computed(() =>
|
||||
Math.min(Math.round((storageUsed.value / storageLimit.value) * 100), 100)
|
||||
Math.min(Math.round((storageUsed.value / storageLimit.value) * 100), 100),
|
||||
);
|
||||
const uploadsPercentage = computed(() =>
|
||||
Math.min(Math.round((uploadsUsed.value / uploadsLimit.value) * 100), 100)
|
||||
Math.min(Math.round((uploadsUsed.value / uploadsLimit.value) * 100), 100),
|
||||
);
|
||||
|
||||
const localeTag = computed(() => i18next.resolvedLanguage === 'vi' ? 'vi-VN' : 'en-US');
|
||||
|
||||
const currencyFormatter = computed(() => new Intl.NumberFormat(localeTag.value, {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
maximumFractionDigits: 2,
|
||||
}));
|
||||
const shortDateFormatter = computed(() => new Intl.DateTimeFormat(localeTag.value, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
}));
|
||||
|
||||
const selectedPlanId = computed(() => upgradeDialogVisible.value ? selectedPlan.value?.id || null : null);
|
||||
const selectedPlanPrice = computed(() => selectedPlan.value?.price || 0);
|
||||
const selectedTotalAmount = computed(() => selectedPlanPrice.value * selectedTermMonths.value);
|
||||
const selectedShortfall = computed(() => Math.max(selectedTotalAmount.value - walletBalance.value, 0));
|
||||
const selectedNeedsTopup = computed(() => selectedShortfall.value > 0.000001);
|
||||
const canSubmitUpgrade = computed(() => {
|
||||
if (!selectedPlan.value?.id || purchaseLoading.value) return false;
|
||||
if (!selectedNeedsTopup.value) return true;
|
||||
if (selectedPaymentMethod.value !== 'topup') return false;
|
||||
return (purchaseTopupAmount.value || 0) >= selectedShortfall.value && (purchaseTopupAmount.value || 0) > 0;
|
||||
});
|
||||
const upgradeSubmitLabel = computed(() => {
|
||||
if (selectedNeedsTopup.value && selectedPaymentMethod.value === 'topup') {
|
||||
return t('settings.billing.upgradeDialog.topupAndUpgrade');
|
||||
}
|
||||
|
||||
return t('settings.billing.upgradeDialog.payWithWallet');
|
||||
});
|
||||
|
||||
const formatMoney = (amount: number) => currencyFormatter.value.format(amount);
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes === 0) return '0 B';
|
||||
@@ -85,9 +166,33 @@ const formatBytes = (bytes: number) => {
|
||||
|
||||
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", "∞")
|
||||
return t('settings.billing.durationMinutes', { minutes: Math.floor(seconds / 60) });
|
||||
};
|
||||
|
||||
const formatHistoryDate = (value?: string) => {
|
||||
if (!value) return '-';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '-';
|
||||
return shortDateFormatter.value.format(date);
|
||||
};
|
||||
|
||||
const formatTermLabel = (months: number) => t('settings.billing.termOption', { months });
|
||||
|
||||
const formatPaymentMethodLabel = (value?: string) => {
|
||||
switch ((value || '').toLowerCase()) {
|
||||
case 'topup':
|
||||
return t('settings.billing.paymentMethod.topup');
|
||||
case 'wallet':
|
||||
default:
|
||||
return t('settings.billing.paymentMethod.wallet');
|
||||
}
|
||||
};
|
||||
|
||||
const getPlanStorageText = (plan: ModelPlan) => t('settings.billing.planStorage', { storage: formatBytes(plan.storage_limit || 0) });
|
||||
const getPlanDurationText = (plan: ModelPlan) => t('settings.billing.planDuration', { duration: formatDuration(plan.duration_limit) });
|
||||
const getPlanUploadsText = (plan: ModelPlan) => t('settings.billing.planUploads', { count: plan.upload_limit || 0 });
|
||||
|
||||
const getStatusStyles = (status: string) => {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
@@ -110,52 +215,274 @@ const getStatusLabel = (status: string) => {
|
||||
return map[status] || status;
|
||||
};
|
||||
|
||||
const formatMoney = (amount: number) => currencyFormatter.value.format(amount);
|
||||
const normalizeHistoryStatus = (status?: string) => {
|
||||
switch ((status || '').toLowerCase()) {
|
||||
case 'success':
|
||||
case 'succeeded':
|
||||
case 'paid':
|
||||
return 'success';
|
||||
case 'failed':
|
||||
case 'error':
|
||||
case 'canceled':
|
||||
case 'cancelled':
|
||||
return 'failed';
|
||||
case 'pending':
|
||||
case 'processing':
|
||||
default:
|
||||
return 'pending';
|
||||
}
|
||||
};
|
||||
|
||||
const getPlanStorageText = (plan: ModelPlan) => t('settings.billing.planStorage', { storage: formatBytes(plan.storage_limit || 0) });
|
||||
const getPlanDurationText = (plan: ModelPlan) => t('settings.billing.planDuration', { duration: formatDuration(plan.duration_limit) });
|
||||
const getPlanUploadsText = (plan: ModelPlan) => t('settings.billing.planUploads', { count: plan.upload_limit });
|
||||
const getApiErrorPayload = (error: unknown): ApiErrorPayload | null => {
|
||||
if (!error || typeof error !== 'object') return null;
|
||||
const candidate = error as { error?: ApiErrorPayload; data?: ApiErrorPayload; message?: string };
|
||||
|
||||
const subscribe = async (plan: ModelPlan) => {
|
||||
if (!plan.id) return;
|
||||
subscribing.value = plan.id;
|
||||
if (candidate.error && typeof candidate.error === 'object') return candidate.error;
|
||||
if (candidate.data && typeof candidate.data === 'object') return candidate.data;
|
||||
if (candidate.message) return { message: candidate.message };
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const getApiErrorMessage = (error: unknown, fallback: string) => {
|
||||
const payload = getApiErrorPayload(error);
|
||||
return payload?.message || fallback;
|
||||
};
|
||||
|
||||
const getApiErrorData = (error: unknown) => getApiErrorPayload(error)?.data || null;
|
||||
|
||||
const mapHistoryItem = (item: PaymentHistoryApiItem): PaymentHistoryItem => {
|
||||
const details: string[] = [];
|
||||
|
||||
if (item.kind !== 'wallet_topup' && item.term_months) {
|
||||
details.push(formatTermLabel(item.term_months));
|
||||
}
|
||||
if (item.kind !== 'wallet_topup' && item.payment_method) {
|
||||
details.push(formatPaymentMethodLabel(item.payment_method));
|
||||
}
|
||||
if (item.kind !== 'wallet_topup' && item.expires_at) {
|
||||
details.push(t('settings.billing.history.validUntil', { date: formatHistoryDate(item.expires_at) }));
|
||||
}
|
||||
|
||||
return {
|
||||
id: item.id || '',
|
||||
date: formatHistoryDate(item.created_at),
|
||||
amount: item.amount || 0,
|
||||
plan: item.kind === 'wallet_topup'
|
||||
? t('settings.billing.walletTopup')
|
||||
: (item.plan_name || t('settings.billing.unknownPlan')),
|
||||
status: normalizeHistoryStatus(item.status),
|
||||
invoiceId: item.invoice_id || '-',
|
||||
currency: item.currency || 'USD',
|
||||
kind: item.kind || 'subscription',
|
||||
details,
|
||||
};
|
||||
};
|
||||
|
||||
const loadPaymentHistory = async () => {
|
||||
historyLoading.value = true;
|
||||
try {
|
||||
await client.payments.paymentsCreate({
|
||||
amount: plan.price || 0,
|
||||
plan_id: plan.id,
|
||||
});
|
||||
const response = await client.payments.historyList({ baseUrl: '/r' });
|
||||
const body = response.data as PaymentHistoryEnvelope | undefined;
|
||||
paymentHistory.value = (body?.data?.payments || []).map(mapHistoryItem);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
paymentHistory.value = [];
|
||||
} finally {
|
||||
historyLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const refetchUsageSnapshot = () => refetchUsage((fetchError) => {
|
||||
throw fetchError;
|
||||
});
|
||||
|
||||
const refreshBillingState = async () => {
|
||||
await Promise.allSettled([
|
||||
auth.fetchMe(),
|
||||
loadPaymentHistory(),
|
||||
refetchUsageSnapshot(),
|
||||
]);
|
||||
};
|
||||
|
||||
void loadPaymentHistory();
|
||||
|
||||
const subscriptionSummary = computed(() => {
|
||||
const expiresAt = auth.user?.plan_expires_at;
|
||||
const formattedDate = formatHistoryDate(expiresAt);
|
||||
|
||||
if (auth.user?.plan_id) {
|
||||
if (auth.user?.plan_expiring_soon && expiresAt) {
|
||||
return {
|
||||
title: t('settings.billing.subscription.expiringTitle'),
|
||||
description: t('settings.billing.subscription.expiringDescription', {
|
||||
plan: currentPlanName.value,
|
||||
date: formattedDate,
|
||||
}),
|
||||
tone: 'warning' as const,
|
||||
};
|
||||
}
|
||||
|
||||
if (expiresAt) {
|
||||
return {
|
||||
title: t('settings.billing.subscription.activeTitle'),
|
||||
description: t('settings.billing.subscription.activeDescription', {
|
||||
plan: currentPlanName.value,
|
||||
date: formattedDate,
|
||||
}),
|
||||
tone: 'default' as const,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: t('settings.billing.subscription.activeTitle'),
|
||||
description: currentPlanName.value,
|
||||
tone: 'default' as const,
|
||||
};
|
||||
}
|
||||
|
||||
if (expiresAt) {
|
||||
return {
|
||||
title: t('settings.billing.subscription.expiredTitle'),
|
||||
description: t('settings.billing.subscription.expiredDescription', { date: formattedDate }),
|
||||
tone: 'warning' as const,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: t('settings.billing.subscription.freeTitle'),
|
||||
description: t('settings.billing.subscription.freeDescription'),
|
||||
tone: 'default' as const,
|
||||
};
|
||||
});
|
||||
|
||||
const resetUpgradeState = () => {
|
||||
selectedPlan.value = null;
|
||||
selectedTermMonths.value = 1;
|
||||
selectedPaymentMethod.value = 'wallet';
|
||||
purchaseTopupAmount.value = null;
|
||||
purchaseError.value = null;
|
||||
};
|
||||
|
||||
const openUpgradeDialog = (plan: ModelPlan) => {
|
||||
selectedPlan.value = plan;
|
||||
selectedTermMonths.value = 1;
|
||||
purchaseError.value = null;
|
||||
selectedPaymentMethod.value = walletBalance.value >= (plan.price || 0) ? 'wallet' : 'topup';
|
||||
purchaseTopupAmount.value = null;
|
||||
upgradeDialogVisible.value = true;
|
||||
};
|
||||
|
||||
const closeUpgradeDialog = () => {
|
||||
if (purchaseLoading.value) return;
|
||||
upgradeDialogVisible.value = false;
|
||||
resetUpgradeState();
|
||||
};
|
||||
|
||||
const onUpgradeDialogVisibilityChange = (visible: boolean) => {
|
||||
if (visible) {
|
||||
upgradeDialogVisible.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
closeUpgradeDialog();
|
||||
};
|
||||
|
||||
watch(selectedShortfall, (value) => {
|
||||
if (!upgradeDialogVisible.value) return;
|
||||
|
||||
if (value <= 0) {
|
||||
selectedPaymentMethod.value = 'wallet';
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedPaymentMethod.value === 'topup' && ((purchaseTopupAmount.value || 0) < value)) {
|
||||
purchaseTopupAmount.value = Number(value.toFixed(2));
|
||||
}
|
||||
});
|
||||
|
||||
const selectUpgradePaymentMethod = (method: UpgradePaymentMethod) => {
|
||||
selectedPaymentMethod.value = method;
|
||||
purchaseError.value = null;
|
||||
|
||||
if (method === 'topup' && selectedShortfall.value > 0 && ((purchaseTopupAmount.value || 0) < selectedShortfall.value)) {
|
||||
purchaseTopupAmount.value = Number(selectedShortfall.value.toFixed(2));
|
||||
}
|
||||
};
|
||||
|
||||
const updatePurchaseTopupAmount = (value: string | number | null) => {
|
||||
if (typeof value === 'number' || value === null) {
|
||||
purchaseTopupAmount.value = value;
|
||||
return;
|
||||
}
|
||||
|
||||
if (value === '') {
|
||||
purchaseTopupAmount.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = Number(value);
|
||||
purchaseTopupAmount.value = Number.isNaN(parsed) ? null : parsed;
|
||||
};
|
||||
|
||||
const submitUpgrade = async () => {
|
||||
if (!selectedPlan.value?.id) return;
|
||||
|
||||
purchaseLoading.value = true;
|
||||
purchaseError.value = null;
|
||||
|
||||
try {
|
||||
const paymentMethod: UpgradePaymentMethod = selectedNeedsTopup.value ? selectedPaymentMethod.value : 'wallet';
|
||||
const payload: Record<string, any> = {
|
||||
plan_id: selectedPlan.value.id,
|
||||
term_months: selectedTermMonths.value,
|
||||
payment_method: paymentMethod,
|
||||
};
|
||||
|
||||
if (paymentMethod === 'topup') {
|
||||
payload.topup_amount = purchaseTopupAmount.value || selectedShortfall.value;
|
||||
}
|
||||
|
||||
await client.payments.paymentsCreate(payload, { baseUrl: '/r' });
|
||||
await refreshBillingState();
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('settings.billing.toast.subscriptionSuccessSummary'),
|
||||
detail: t('settings.billing.toast.subscriptionSuccessDetail', { plan: plan.name || '' }),
|
||||
detail: t('settings.billing.toast.subscriptionSuccessDetail', {
|
||||
plan: selectedPlan.value.name || '',
|
||||
term: formatTermLabel(selectedTermMonths.value),
|
||||
}),
|
||||
life: 3000,
|
||||
});
|
||||
|
||||
paymentHistory.value.unshift({
|
||||
id: `inv_${Date.now()}`,
|
||||
date: new Date().toLocaleDateString(localeTag.value, { month: 'short', day: 'numeric', year: 'numeric' }),
|
||||
amount: plan.price || 0,
|
||||
plan: plan.name || t('settings.billing.unknownPlan'),
|
||||
status: 'success',
|
||||
invoiceId: `INV-${new Date().getFullYear()}-${Math.floor(Math.random() * 1000)}`,
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('settings.billing.toast.subscriptionFailedSummary'),
|
||||
detail: err.message || t('settings.billing.toast.subscriptionFailedDetail'),
|
||||
life: 5000,
|
||||
});
|
||||
closeUpgradeDialog();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
const errorData = getApiErrorData(error);
|
||||
const nextShortfall = typeof errorData?.shortfall === 'number'
|
||||
? errorData.shortfall
|
||||
: selectedShortfall.value;
|
||||
|
||||
if (nextShortfall > 0) {
|
||||
selectedPaymentMethod.value = 'topup';
|
||||
if ((purchaseTopupAmount.value || 0) < nextShortfall) {
|
||||
purchaseTopupAmount.value = Number(nextShortfall.toFixed(2));
|
||||
}
|
||||
}
|
||||
|
||||
purchaseError.value = getApiErrorMessage(error, t('settings.billing.toast.subscriptionFailedDetail'));
|
||||
} finally {
|
||||
subscribing.value = null;
|
||||
purchaseLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleTopup = async (amount: number) => {
|
||||
topupLoading.value = true;
|
||||
try {
|
||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||
await client.wallet.topupsCreate({ amount }, { baseUrl: '/r' });
|
||||
await refreshBillingState();
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
@@ -165,11 +492,12 @@ const handleTopup = async (amount: number) => {
|
||||
});
|
||||
topupDialogVisible.value = false;
|
||||
topupAmount.value = null;
|
||||
} catch (e: any) {
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('settings.billing.toast.topupFailedSummary'),
|
||||
detail: e.message || t('settings.billing.toast.topupFailedDetail'),
|
||||
detail: getApiErrorMessage(error, t('settings.billing.toast.topupFailedDetail')),
|
||||
life: 5000,
|
||||
});
|
||||
} finally {
|
||||
@@ -177,7 +505,10 @@ const handleTopup = async (amount: number) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadInvoice = (item: PaymentHistoryItem) => {
|
||||
const handleDownloadInvoice = async (item: PaymentHistoryItem) => {
|
||||
if (!item.id) return;
|
||||
|
||||
downloadingInvoiceId.value = item.id;
|
||||
toast.add({
|
||||
severity: 'info',
|
||||
summary: t('settings.billing.toast.downloadingSummary'),
|
||||
@@ -185,14 +516,36 @@ const handleDownloadInvoice = (item: PaymentHistoryItem) => {
|
||||
life: 2000,
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
try {
|
||||
const response = await client.payments.invoiceList(item.id, { baseUrl: '/r', format: 'text' });
|
||||
const content = typeof response.data === 'string' ? response.data : '';
|
||||
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = `${item.invoiceId}.txt`;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
document.body.removeChild(anchor);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('settings.billing.toast.downloadedSummary'),
|
||||
detail: t('settings.billing.toast.downloadedDetail', { invoiceId: item.invoiceId }),
|
||||
life: 3000,
|
||||
});
|
||||
}, 1500);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('settings.billing.toast.downloadFailedSummary'),
|
||||
detail: getApiErrorMessage(error, t('settings.billing.toast.downloadFailedDetail')),
|
||||
life: 5000,
|
||||
});
|
||||
} finally {
|
||||
downloadingInvoiceId.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const openTopupDialog = () => {
|
||||
@@ -214,6 +567,9 @@ const selectPreset = (amount: number) => {
|
||||
:title="t('settings.billing.walletBalance')"
|
||||
:description="t('settings.billing.currentBalance', { balance: formatMoney(walletBalance) })"
|
||||
:button-label="t('settings.billing.topUp')"
|
||||
:subscription-title="subscriptionSummary.title"
|
||||
:subscription-description="subscriptionSummary.description"
|
||||
:subscription-tone="subscriptionSummary.tone"
|
||||
@topup="openTopupDialog"
|
||||
/>
|
||||
|
||||
@@ -223,23 +579,23 @@ const selectPreset = (amount: number) => {
|
||||
:is-loading="isLoading"
|
||||
:plans="plans"
|
||||
:current-plan-id="currentPlanId"
|
||||
:subscribing="subscribing"
|
||||
:selecting-plan-id="selectedPlanId"
|
||||
:format-money="formatMoney"
|
||||
:get-plan-storage-text="getPlanStorageText"
|
||||
:get-plan-duration-text="getPlanDurationText"
|
||||
:get-plan-uploads-text="getPlanUploadsText"
|
||||
:current-plan-label="t('settings.billing.currentPlan')"
|
||||
:processing-label="t('settings.billing.processing')"
|
||||
:upgrade-label="t('settings.billing.upgrade')"
|
||||
@subscribe="subscribe"
|
||||
:selecting-label="t('settings.billing.upgradeDialog.selecting')"
|
||||
:choose-label="t('settings.billing.upgradeDialog.choosePlan')"
|
||||
@select="openUpgradeDialog"
|
||||
/>
|
||||
|
||||
<BillingUsageSection
|
||||
:storage-title="t('settings.billing.storage')"
|
||||
:storage-description="t('settings.billing.storageUsedOfLimit', { used: formatBytes(storageUsed), limit: formatBytes(storageLimit) })"
|
||||
:storage-percentage="storagePercentage"
|
||||
:uploads-title="t('settings.billing.monthlyUploads')"
|
||||
:uploads-description="t('settings.billing.uploadsUsedOfLimit', { used: uploadsUsed, limit: uploadsLimit })"
|
||||
:uploads-title="t('settings.billing.totalVideos')"
|
||||
:uploads-description="t('settings.billing.totalVideosUsedOfLimit', { used: uploadsUsed, limit: uploadsLimit })"
|
||||
:uploads-percentage="uploadsPercentage"
|
||||
/>
|
||||
|
||||
@@ -247,6 +603,8 @@ const selectPreset = (amount: number) => {
|
||||
:title="t('settings.billing.paymentHistory')"
|
||||
:description="t('settings.billing.paymentHistorySubtitle')"
|
||||
:items="paymentHistory"
|
||||
:loading="historyLoading"
|
||||
:downloading-id="downloadingInvoiceId"
|
||||
:format-money="formatMoney"
|
||||
:get-status-styles="getStatusStyles"
|
||||
:get-status-label="getStatusLabel"
|
||||
@@ -261,6 +619,180 @@ const selectPreset = (amount: number) => {
|
||||
/>
|
||||
</SettingsSectionCard>
|
||||
|
||||
<AppDialog
|
||||
:visible="upgradeDialogVisible"
|
||||
:title="t('settings.billing.upgradeDialog.title')"
|
||||
maxWidthClass="max-w-2xl"
|
||||
@update:visible="onUpgradeDialogVisibilityChange"
|
||||
@close="closeUpgradeDialog"
|
||||
>
|
||||
<div v-if="selectedPlan" class="space-y-5">
|
||||
<div class="rounded-lg border border-border bg-muted/20 p-4">
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-medium uppercase tracking-[0.18em] text-foreground/50">
|
||||
{{ t('settings.billing.upgradeDialog.selectedPlan') }}
|
||||
</p>
|
||||
<h3 class="mt-1 text-lg font-semibold text-foreground">{{ selectedPlan.name }}</h3>
|
||||
<p class="mt-1 text-sm text-foreground/70">
|
||||
{{ selectedPlan.description || t('settings.billing.availablePlansHint') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="text-left md:text-right">
|
||||
<p class="text-xs text-foreground/50">{{ t('settings.billing.upgradeDialog.basePrice') }}</p>
|
||||
<p class="mt-1 text-2xl font-semibold text-foreground">{{ formatMoney(selectedPlan.price || 0) }}</p>
|
||||
<p class="text-xs text-foreground/60">{{ t('settings.billing.upgradeDialog.perMonthBase') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">{{ t('settings.billing.upgradeDialog.termTitle') }}</p>
|
||||
<p class="mt-1 text-xs text-foreground/60">{{ t('settings.billing.upgradeDialog.termHint') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<button
|
||||
v-for="months in TERM_OPTIONS"
|
||||
:key="months"
|
||||
type="button"
|
||||
:class="[
|
||||
'rounded-lg border px-4 py-3 text-left transition-all',
|
||||
selectedTermMonths === months
|
||||
? 'border-primary bg-primary/5 text-primary'
|
||||
: 'border-border bg-surface text-foreground hover:border-primary/30 hover:bg-muted/30',
|
||||
]"
|
||||
@click="selectedTermMonths = months"
|
||||
>
|
||||
<p class="text-sm font-medium">{{ formatTermLabel(months) }}</p>
|
||||
<p class="mt-1 text-xs text-foreground/60">{{ formatMoney((selectedPlan.price || 0) * months) }}</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 md:grid-cols-3">
|
||||
<div class="rounded-lg border border-border bg-surface p-4">
|
||||
<p class="text-xs uppercase tracking-wide text-foreground/50">{{ t('settings.billing.upgradeDialog.totalLabel') }}</p>
|
||||
<p class="mt-2 text-xl font-semibold text-foreground">{{ formatMoney(selectedTotalAmount) }}</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-border bg-surface p-4">
|
||||
<p class="text-xs uppercase tracking-wide text-foreground/50">{{ t('settings.billing.upgradeDialog.walletBalanceLabel') }}</p>
|
||||
<p class="mt-2 text-xl font-semibold text-foreground">{{ formatMoney(walletBalance) }}</p>
|
||||
</div>
|
||||
<div
|
||||
class="rounded-lg border p-4"
|
||||
:class="selectedNeedsTopup
|
||||
? 'border-warning/30 bg-warning/10'
|
||||
: 'border-success/20 bg-success/5'"
|
||||
>
|
||||
<p class="text-xs uppercase tracking-wide text-foreground/50">{{ t('settings.billing.upgradeDialog.shortfallLabel') }}</p>
|
||||
<p class="mt-2 text-xl font-semibold" :class="selectedNeedsTopup ? 'text-warning' : 'text-success'">
|
||||
{{ formatMoney(selectedShortfall) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedNeedsTopup" class="space-y-3">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">{{ t('settings.billing.upgradeDialog.paymentMethodTitle') }}</p>
|
||||
<p class="mt-1 text-xs text-foreground/60">{{ t('settings.billing.upgradeDialog.paymentMethodHint') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<button
|
||||
type="button"
|
||||
:class="[
|
||||
'rounded-lg border p-4 text-left transition-all',
|
||||
selectedPaymentMethod === 'wallet'
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border bg-surface hover:border-primary/30 hover:bg-muted/30',
|
||||
]"
|
||||
@click="selectUpgradePaymentMethod('wallet')"
|
||||
>
|
||||
<p class="text-sm font-medium text-foreground">{{ t('settings.billing.paymentMethod.wallet') }}</p>
|
||||
<p class="mt-1 text-xs text-foreground/60">
|
||||
{{ t('settings.billing.upgradeDialog.walletOptionDescription') }}
|
||||
</p>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
:class="[
|
||||
'rounded-lg border p-4 text-left transition-all',
|
||||
selectedPaymentMethod === 'topup'
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border bg-surface hover:border-primary/30 hover:bg-muted/30',
|
||||
]"
|
||||
@click="selectUpgradePaymentMethod('topup')"
|
||||
>
|
||||
<p class="text-sm font-medium text-foreground">{{ t('settings.billing.paymentMethod.topup') }}</p>
|
||||
<p class="mt-1 text-xs text-foreground/60">
|
||||
{{ t('settings.billing.upgradeDialog.topupOptionDescription', { shortfall: formatMoney(selectedShortfall) }) }}
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="rounded-lg border border-success/20 bg-success/5 p-4 text-sm text-success">
|
||||
{{ t('settings.billing.upgradeDialog.walletCoveredHint') }}
|
||||
</div>
|
||||
|
||||
<div v-if="selectedNeedsTopup && selectedPaymentMethod === 'topup'" class="grid gap-2">
|
||||
<label class="text-sm font-medium text-foreground">{{ t('settings.billing.upgradeDialog.topupAmountLabel') }}</label>
|
||||
<AppInput
|
||||
:model-value="purchaseTopupAmount"
|
||||
type="number"
|
||||
min="0.01"
|
||||
step="0.01"
|
||||
:placeholder="t('settings.billing.upgradeDialog.topupAmountPlaceholder')"
|
||||
@update:model-value="updatePurchaseTopupAmount"
|
||||
/>
|
||||
<p class="text-xs text-foreground/60">
|
||||
{{ t('settings.billing.upgradeDialog.topupAmountHint', { shortfall: formatMoney(selectedShortfall) }) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="selectedNeedsTopup && selectedPaymentMethod === 'wallet'"
|
||||
class="rounded-lg border border-warning/30 bg-warning/10 p-4 text-sm text-warning"
|
||||
>
|
||||
{{ t('settings.billing.upgradeDialog.walletInsufficientHint', { shortfall: formatMoney(selectedShortfall) }) }}
|
||||
</div>
|
||||
|
||||
<div v-if="purchaseError" class="rounded-lg border border-danger bg-danger/10 p-4 text-sm text-danger">
|
||||
{{ purchaseError }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p class="text-xs text-foreground/60">
|
||||
{{ t('settings.billing.upgradeDialog.footerHint') }}
|
||||
</p>
|
||||
<div class="flex justify-end gap-3">
|
||||
<AppButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
:disabled="purchaseLoading"
|
||||
@click="closeUpgradeDialog"
|
||||
>
|
||||
{{ t('common.cancel') }}
|
||||
</AppButton>
|
||||
<AppButton
|
||||
size="sm"
|
||||
:loading="purchaseLoading"
|
||||
:disabled="!canSubmitUpgrade"
|
||||
@click="submitUpgrade"
|
||||
>
|
||||
{{ upgradeSubmitLabel }}
|
||||
</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</AppDialog>
|
||||
|
||||
<BillingTopupDialog
|
||||
:visible="topupDialogVisible"
|
||||
:title="t('settings.billing.topupDialog.title')"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { client } from '@/api/client';
|
||||
import AppButton from '@/components/app/AppButton.vue';
|
||||
import AlertTriangleIcon from '@/components/icons/AlertTriangle.vue';
|
||||
import SlidersIcon from '@/components/icons/SlidersIcon.vue';
|
||||
@@ -8,25 +9,50 @@ import { useAppToast } from '@/composables/useAppToast';
|
||||
import SettingsNotice from '@/routes/settings/components/SettingsNotice.vue';
|
||||
import SettingsRow from '@/routes/settings/components/SettingsRow.vue';
|
||||
import SettingsSectionCard from '@/routes/settings/components/SettingsSectionCard.vue';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
import { ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const auth = useAuthStore();
|
||||
const router = useRouter();
|
||||
const toast = useAppToast();
|
||||
const confirm = useAppConfirm();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const deletingAccount = ref(false);
|
||||
const clearingData = ref(false);
|
||||
|
||||
const handleDeleteAccount = () => {
|
||||
confirm.require({
|
||||
message: t('settings.dangerZone.confirm.deleteAccountMessage'),
|
||||
header: t('settings.dangerZone.confirm.deleteAccountHeader'),
|
||||
acceptLabel: t('settings.dangerZone.confirm.deleteAccountAccept'),
|
||||
rejectLabel: t('settings.dangerZone.confirm.deleteAccountReject'),
|
||||
accept: () => {
|
||||
toast.add({
|
||||
severity: 'info',
|
||||
summary: t('settings.dangerZone.toast.deleteAccountSummary'),
|
||||
detail: t('settings.dangerZone.toast.deleteAccountDetail'),
|
||||
life: 5000,
|
||||
});
|
||||
accept: async () => {
|
||||
deletingAccount.value = true;
|
||||
try {
|
||||
await client.me.deleteMe({ baseUrl: '/r' });
|
||||
|
||||
auth.$reset();
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('settings.dangerZone.toast.deleteAccountSummary'),
|
||||
detail: t('settings.dangerZone.toast.deleteAccountDetail'),
|
||||
life: 5000,
|
||||
});
|
||||
await router.push('/login');
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('settings.dangerZone.toast.failedSummary'),
|
||||
detail: e.message || t('settings.dangerZone.toast.failedDetail'),
|
||||
life: 5000,
|
||||
});
|
||||
} finally {
|
||||
deletingAccount.value = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -37,13 +63,29 @@ const handleClearData = () => {
|
||||
header: t('settings.dangerZone.confirm.clearDataHeader'),
|
||||
acceptLabel: t('settings.dangerZone.confirm.clearDataAccept'),
|
||||
rejectLabel: t('settings.dangerZone.confirm.clearDataReject'),
|
||||
accept: () => {
|
||||
toast.add({
|
||||
severity: 'info',
|
||||
summary: t('settings.dangerZone.toast.clearDataSummary'),
|
||||
detail: t('settings.dangerZone.toast.clearDataDetail'),
|
||||
life: 5000,
|
||||
});
|
||||
accept: async () => {
|
||||
clearingData.value = true;
|
||||
try {
|
||||
await client.me.clearDataCreate({ baseUrl: '/r' });
|
||||
|
||||
await auth.fetchMe();
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('settings.dangerZone.toast.clearDataSummary'),
|
||||
detail: t('settings.dangerZone.toast.clearDataDetail'),
|
||||
life: 5000,
|
||||
});
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('settings.dangerZone.toast.failedSummary'),
|
||||
detail: e.message || t('settings.dangerZone.toast.failedDetail'),
|
||||
life: 5000,
|
||||
});
|
||||
} finally {
|
||||
clearingData.value = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -66,7 +108,7 @@ const handleClearData = () => {
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
<AppButton variant="danger" size="sm" @click="handleDeleteAccount">
|
||||
<AppButton variant="danger" size="sm" :loading="deletingAccount" :disabled="clearingData" @click="handleDeleteAccount">
|
||||
<template #icon>
|
||||
<TrashIcon class="w-4 h-4" />
|
||||
</template>
|
||||
@@ -90,7 +132,7 @@ const handleClearData = () => {
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
<AppButton variant="danger" size="sm" @click="handleClearData">
|
||||
<AppButton variant="danger" size="sm" :loading="clearingData" :disabled="deletingAccount" @click="handleClearData">
|
||||
<template #icon>
|
||||
<SlidersIcon class="w-4 h-4" />
|
||||
</template>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { client } from '@/api/client';
|
||||
import AppButton from '@/components/app/AppButton.vue';
|
||||
import AppDialog from '@/components/app/AppDialog.vue';
|
||||
import AppInput from '@/components/app/AppInput.vue';
|
||||
@@ -10,23 +11,99 @@ import { useAppConfirm } from '@/composables/useAppConfirm';
|
||||
import { useAppToast } from '@/composables/useAppToast';
|
||||
import SettingsNotice from '@/routes/settings/components/SettingsNotice.vue';
|
||||
import SettingsSectionCard from '@/routes/settings/components/SettingsSectionCard.vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import SettingsTableSkeleton from '@/routes/settings/components/SettingsTableSkeleton.vue';
|
||||
import { useQuery } from '@pinia/colada';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
|
||||
const toast = useAppToast();
|
||||
const confirm = useAppConfirm();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const domains = ref([
|
||||
{ id: '1', name: 'example.com', addedAt: '2024-01-15' },
|
||||
{ id: '2', name: 'mysite.org', addedAt: '2024-02-20' },
|
||||
]);
|
||||
type DomainApiItem = {
|
||||
id?: string;
|
||||
name?: string;
|
||||
created_at?: string;
|
||||
};
|
||||
|
||||
type DomainItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
addedAt: string;
|
||||
};
|
||||
|
||||
const newDomain = ref('');
|
||||
const showAddDialog = ref(false);
|
||||
const adding = ref(false);
|
||||
const removingId = ref<string | null>(null);
|
||||
|
||||
const handleAddDomain = () => {
|
||||
if (!newDomain.value.trim()) {
|
||||
const normalizeDomainInput = (value: string) => value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^https?:\/\//, '')
|
||||
.replace(/^www\./, '')
|
||||
.replace(/\/$/, '');
|
||||
|
||||
const formatDate = (value?: string) => {
|
||||
if (!value) return '-';
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value.split('T')[0] || value;
|
||||
}
|
||||
|
||||
return date.toISOString().split('T')[0];
|
||||
};
|
||||
|
||||
const mapDomainItem = (item: DomainApiItem): DomainItem => ({
|
||||
id: item.id || `${item.name || 'domain'}:${item.created_at || ''}`,
|
||||
name: item.name || '',
|
||||
addedAt: formatDate(item.created_at),
|
||||
});
|
||||
|
||||
const { data: domainsSnapshot, error, isPending, refetch } = useQuery({
|
||||
key: () => ['settings', 'domains'],
|
||||
query: async () => {
|
||||
const response = await client.domains.domainsList({ baseUrl: '/r' });
|
||||
return ((((response.data as any)?.data?.domains) || []) as DomainApiItem[]).map(mapDomainItem);
|
||||
},
|
||||
});
|
||||
|
||||
const domains = computed(() => domainsSnapshot.value || []);
|
||||
const isInitialLoading = computed(() => isPending.value && !domainsSnapshot.value);
|
||||
|
||||
const iframeCode = computed(() => '<iframe src="https://holistream.com/embed" width="100%" height="500" frameborder="0" allowfullscreen></iframe>');
|
||||
|
||||
const refetchDomains = () => refetch((fetchError) => {
|
||||
throw fetchError;
|
||||
});
|
||||
|
||||
watch(error, (value, previous) => {
|
||||
if (!value || value === previous || adding.value || removingId.value !== null) return;
|
||||
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('settings.domainsDns.toast.failedSummary'),
|
||||
detail: (value as any)?.message || t('settings.domainsDns.toast.failedDetail'),
|
||||
life: 5000,
|
||||
});
|
||||
});
|
||||
|
||||
const openAddDialog = () => {
|
||||
newDomain.value = '';
|
||||
showAddDialog.value = true;
|
||||
};
|
||||
|
||||
const closeAddDialog = () => {
|
||||
showAddDialog.value = false;
|
||||
newDomain.value = '';
|
||||
};
|
||||
|
||||
const handleAddDomain = async () => {
|
||||
if (adding.value) return;
|
||||
|
||||
const domainName = normalizeDomainInput(newDomain.value);
|
||||
if (!domainName || !domainName.includes('.') || /[\/\s]/.test(domainName)) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('settings.domainsDns.toast.invalidSummary'),
|
||||
@@ -36,7 +113,7 @@ const handleAddDomain = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const exists = domains.value.some(d => d.name === newDomain.value.trim().toLowerCase());
|
||||
const exists = domains.value.some(domain => domain.name === domainName);
|
||||
if (exists) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
@@ -47,48 +124,95 @@ const handleAddDomain = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const domainName = newDomain.value.trim().toLowerCase();
|
||||
domains.value.push({
|
||||
id: Math.random().toString(36).substring(2, 9),
|
||||
name: domainName,
|
||||
addedAt: new Date().toISOString().split('T')[0],
|
||||
});
|
||||
adding.value = true;
|
||||
try {
|
||||
await client.domains.domainsCreate({
|
||||
name: domainName,
|
||||
}, { baseUrl: '/r' });
|
||||
|
||||
newDomain.value = '';
|
||||
showAddDialog.value = false;
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('settings.domainsDns.toast.addedSummary'),
|
||||
detail: t('settings.domainsDns.toast.addedDetail', { domain: domainName }),
|
||||
life: 3000,
|
||||
});
|
||||
await refetchDomains();
|
||||
closeAddDialog();
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('settings.domainsDns.toast.addedSummary'),
|
||||
detail: t('settings.domainsDns.toast.addedDetail', { domain: domainName }),
|
||||
life: 3000,
|
||||
});
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
const message = String(e?.message || '').toLowerCase();
|
||||
|
||||
if (message.includes('already exists')) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('settings.domainsDns.toast.duplicateSummary'),
|
||||
detail: t('settings.domainsDns.toast.duplicateDetail'),
|
||||
life: 3000,
|
||||
});
|
||||
} else if (message.includes('invalid domain')) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('settings.domainsDns.toast.invalidSummary'),
|
||||
detail: t('settings.domainsDns.toast.invalidDetail'),
|
||||
life: 3000,
|
||||
});
|
||||
} else {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('settings.domainsDns.toast.failedSummary'),
|
||||
detail: e.message || t('settings.domainsDns.toast.failedDetail'),
|
||||
life: 5000,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
adding.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveDomain = (domain: typeof domains.value[0]) => {
|
||||
const handleRemoveDomain = (domain: DomainItem) => {
|
||||
confirm.require({
|
||||
message: t('settings.domainsDns.confirm.removeMessage', { domain: domain.name }),
|
||||
header: t('settings.domainsDns.confirm.removeHeader'),
|
||||
acceptLabel: t('settings.domainsDns.confirm.removeAccept'),
|
||||
rejectLabel: t('settings.domainsDns.confirm.removeReject'),
|
||||
accept: () => {
|
||||
const index = domains.value.findIndex(d => d.id === domain.id);
|
||||
if (index !== -1) {
|
||||
domains.value.splice(index, 1);
|
||||
accept: async () => {
|
||||
removingId.value = domain.id;
|
||||
try {
|
||||
await client.domains.domainsDelete(domain.id, { baseUrl: '/r' });
|
||||
await refetchDomains();
|
||||
toast.add({
|
||||
severity: 'info',
|
||||
summary: t('settings.domainsDns.toast.removedSummary'),
|
||||
detail: t('settings.domainsDns.toast.removedDetail', { domain: domain.name }),
|
||||
life: 3000,
|
||||
});
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('settings.domainsDns.toast.failedSummary'),
|
||||
detail: e.message || t('settings.domainsDns.toast.failedDetail'),
|
||||
life: 5000,
|
||||
});
|
||||
} finally {
|
||||
removingId.value = null;
|
||||
}
|
||||
toast.add({
|
||||
severity: 'info',
|
||||
summary: t('settings.domainsDns.toast.removedSummary'),
|
||||
detail: t('settings.domainsDns.toast.removedDetail', { domain: domain.name }),
|
||||
life: 3000,
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const iframeCode = computed(() => '<iframe src="https://holistream.com/embed" width="100%" height="500" frameborder="0" allowfullscreen></iframe>');
|
||||
const copyIframeCode = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(iframeCode.value);
|
||||
} catch {
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = iframeCode.value;
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
|
||||
const copyIframeCode = () => {
|
||||
navigator.clipboard.writeText(iframeCode.value);
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('settings.domainsDns.toast.copiedSummary'),
|
||||
@@ -105,7 +229,7 @@ const copyIframeCode = () => {
|
||||
bodyClass=""
|
||||
>
|
||||
<template #header-actions>
|
||||
<AppButton size="sm" @click="showAddDialog = true">
|
||||
<AppButton size="sm" :loading="adding" :disabled="isInitialLoading || removingId !== null" @click="openAddDialog">
|
||||
<template #icon>
|
||||
<PlusIcon class="w-4 h-4" />
|
||||
</template>
|
||||
@@ -117,7 +241,9 @@ const copyIframeCode = () => {
|
||||
{{ t('settings.domainsDns.infoBanner') }}
|
||||
</SettingsNotice>
|
||||
|
||||
<div class="border-b border-border mt-4">
|
||||
<SettingsTableSkeleton v-if="isInitialLoading" :columns="3" :rows="4" />
|
||||
|
||||
<div v-else class="border-b border-border mt-4">
|
||||
<table class="w-full">
|
||||
<thead class="bg-muted/30">
|
||||
<tr>
|
||||
@@ -127,27 +253,34 @@ const copyIframeCode = () => {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-border">
|
||||
<tr
|
||||
v-for="domain in domains"
|
||||
:key="domain.id"
|
||||
class="hover:bg-muted/30 transition-all"
|
||||
>
|
||||
<td class="px-6 py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<LinkIcon class="w-4 h-4 text-foreground/40" />
|
||||
<span class="text-sm font-medium text-foreground">{{ domain.name }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-3 text-sm text-foreground/60">{{ domain.addedAt }}</td>
|
||||
<td class="px-6 py-3 text-right">
|
||||
<AppButton variant="ghost" size="sm" @click="handleRemoveDomain(domain)">
|
||||
<template #icon>
|
||||
<TrashIcon class="w-4 h-4 text-danger" />
|
||||
</template>
|
||||
</AppButton>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="domains.length === 0">
|
||||
<template v-if="domains.length > 0">
|
||||
<tr
|
||||
v-for="domain in domains"
|
||||
:key="domain.id"
|
||||
class="hover:bg-muted/30 transition-all"
|
||||
>
|
||||
<td class="px-6 py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<LinkIcon class="w-4 h-4 text-foreground/40" />
|
||||
<span class="text-sm font-medium text-foreground">{{ domain.name }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-3 text-sm text-foreground/60">{{ domain.addedAt }}</td>
|
||||
<td class="px-6 py-3 text-right">
|
||||
<AppButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:disabled="adding || removingId !== null"
|
||||
@click="handleRemoveDomain(domain)"
|
||||
>
|
||||
<template #icon>
|
||||
<TrashIcon class="w-4 h-4 text-danger" />
|
||||
</template>
|
||||
</AppButton>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<tr v-else>
|
||||
<td colspan="3" class="px-6 py-12 text-center">
|
||||
<LinkIcon class="w-10 h-10 text-foreground/30 mb-3 block mx-auto" />
|
||||
<p class="text-sm text-foreground/60 mb-1">{{ t('settings.domainsDns.emptyTitle') }}</p>
|
||||
@@ -176,9 +309,10 @@ const copyIframeCode = () => {
|
||||
|
||||
<AppDialog
|
||||
:visible="showAddDialog"
|
||||
@update:visible="showAddDialog = $event"
|
||||
:title="t('settings.domainsDns.dialog.title')"
|
||||
maxWidthClass="max-w-md"
|
||||
@update:visible="showAddDialog = $event"
|
||||
@close="closeAddDialog"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<div class="grid gap-2">
|
||||
@@ -202,15 +336,17 @@ const copyIframeCode = () => {
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<AppButton variant="secondary" size="sm" @click="showAddDialog = false">
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton variant="secondary" size="sm" :disabled="adding" @click="closeAddDialog">
|
||||
{{ t('common.cancel') }}
|
||||
</AppButton>
|
||||
<AppButton size="sm" @click="handleAddDomain">
|
||||
<AppButton size="sm" :loading="adding" @click="handleAddDomain">
|
||||
<template #icon>
|
||||
<CheckIcon class="w-4 h-4" />
|
||||
</template>
|
||||
{{ t('settings.domainsDns.addDomain') }}
|
||||
</AppButton>
|
||||
</div>
|
||||
</template>
|
||||
</AppDialog>
|
||||
</SettingsSectionCard>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { client } from '@/api/client';
|
||||
import AppButton from '@/components/app/AppButton.vue';
|
||||
import AppSwitch from '@/components/app/AppSwitch.vue';
|
||||
import BellIcon from '@/components/icons/BellIcon.vue';
|
||||
@@ -6,22 +7,24 @@ import CheckIcon from '@/components/icons/CheckIcon.vue';
|
||||
import MailIcon from '@/components/icons/MailIcon.vue';
|
||||
import SendIcon from '@/components/icons/SendIcon.vue';
|
||||
import TelegramIcon from '@/components/icons/TelegramIcon.vue';
|
||||
import {
|
||||
createNotificationSettingsDraft,
|
||||
toNotificationPreferencesPayload,
|
||||
useSettingsPreferencesQuery,
|
||||
} from '@/composables/useSettingsPreferencesQuery';
|
||||
import { useAppToast } from '@/composables/useAppToast';
|
||||
import SettingsRow from '@/routes/settings/components/SettingsRow.vue';
|
||||
import SettingsRowSkeleton from '@/routes/settings/components/SettingsRowSkeleton.vue';
|
||||
import SettingsSectionCard from '@/routes/settings/components/SettingsSectionCard.vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
|
||||
const toast = useAppToast();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const notificationSettings = ref({
|
||||
email: true,
|
||||
push: true,
|
||||
marketing: false,
|
||||
telegram: false,
|
||||
});
|
||||
const { data: preferencesSnapshot, error, isPending, refetch } = useSettingsPreferencesQuery();
|
||||
|
||||
const notificationSettings = ref(createNotificationSettingsDraft());
|
||||
const saving = ref(false);
|
||||
|
||||
const notificationTypes = computed(() => [
|
||||
@@ -59,10 +62,40 @@ const notificationTypes = computed(() => [
|
||||
},
|
||||
]);
|
||||
|
||||
const isInitialLoading = computed(() => isPending.value && !preferencesSnapshot.value);
|
||||
const isInteractionDisabled = computed(() => saving.value || isInitialLoading.value || !preferencesSnapshot.value);
|
||||
|
||||
const refetchPreferences = () => refetch((fetchError) => {
|
||||
throw fetchError;
|
||||
});
|
||||
|
||||
watch(preferencesSnapshot, (snapshot) => {
|
||||
if (!snapshot) return;
|
||||
notificationSettings.value = createNotificationSettingsDraft(snapshot);
|
||||
}, { immediate: true });
|
||||
|
||||
watch(error, (value, previous) => {
|
||||
if (!value || value === previous || saving.value) return;
|
||||
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('settings.notificationSettings.toast.failedSummary'),
|
||||
detail: (value as any)?.message || t('settings.notificationSettings.toast.failedDetail'),
|
||||
life: 5000,
|
||||
});
|
||||
});
|
||||
|
||||
const handleSave = async () => {
|
||||
if (saving.value || !preferencesSnapshot.value) return;
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
await client.settings.preferencesUpdate(
|
||||
toNotificationPreferencesPayload(notificationSettings.value),
|
||||
{ baseUrl: '/r' },
|
||||
);
|
||||
await refetchPreferences();
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('settings.notificationSettings.toast.savedSummary'),
|
||||
@@ -88,7 +121,7 @@ const handleSave = async () => {
|
||||
:description="t('settings.content.notifications.subtitle')"
|
||||
>
|
||||
<template #header-actions>
|
||||
<AppButton size="sm" :loading="saving" @click="handleSave">
|
||||
<AppButton size="sm" :loading="saving" :disabled="isInitialLoading || !preferencesSnapshot" @click="handleSave">
|
||||
<template #icon>
|
||||
<CheckIcon class="w-4 h-4" />
|
||||
</template>
|
||||
@@ -96,20 +129,29 @@ const handleSave = async () => {
|
||||
</AppButton>
|
||||
</template>
|
||||
|
||||
<SettingsRow
|
||||
v-for="type in notificationTypes"
|
||||
:key="type.key"
|
||||
:title="type.title"
|
||||
:description="type.description"
|
||||
:iconBoxClass="type.bgColor"
|
||||
>
|
||||
<template #icon>
|
||||
<component :is="type.icon" :class="[type.iconColor, 'w-5 h-5']" />
|
||||
</template>
|
||||
<template v-if="isInitialLoading">
|
||||
<SettingsRowSkeleton
|
||||
v-for="type in notificationTypes"
|
||||
:key="type.key"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
<AppSwitch v-model="notificationSettings[type.key]" />
|
||||
</template>
|
||||
</SettingsRow>
|
||||
<template v-else>
|
||||
<SettingsRow
|
||||
v-for="type in notificationTypes"
|
||||
:key="type.key"
|
||||
:title="type.title"
|
||||
:description="type.description"
|
||||
:iconBoxClass="type.bgColor"
|
||||
>
|
||||
<template #icon>
|
||||
<component :is="type.icon" :class="[type.iconColor, 'w-5 h-5']" />
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
<AppSwitch v-model="notificationSettings[type.key]" :disabled="isInteractionDisabled" />
|
||||
</template>
|
||||
</SettingsRow>
|
||||
</template>
|
||||
</SettingsSectionCard>
|
||||
</template>
|
||||
|
||||
@@ -1,94 +1,128 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
import { client } from '@/api/client';
|
||||
import AppButton from '@/components/app/AppButton.vue';
|
||||
import AppSwitch from '@/components/app/AppSwitch.vue';
|
||||
import CheckIcon from '@/components/icons/CheckIcon.vue';
|
||||
import {
|
||||
createPlayerSettingsDraft,
|
||||
toPlayerPreferencesPayload,
|
||||
useSettingsPreferencesQuery,
|
||||
} from '@/composables/useSettingsPreferencesQuery';
|
||||
import { useAppToast } from '@/composables/useAppToast';
|
||||
import SettingsRow from '@/routes/settings/components/SettingsRow.vue';
|
||||
import SettingsRowSkeleton from '@/routes/settings/components/SettingsRowSkeleton.vue';
|
||||
import SettingsSectionCard from '@/routes/settings/components/SettingsSectionCard.vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
|
||||
const toast = useAppToast();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const playerSettings = ref({
|
||||
autoplay: true,
|
||||
loop: false,
|
||||
muted: false,
|
||||
showControls: true,
|
||||
pip: true,
|
||||
airplay: true,
|
||||
Chromecast: false,
|
||||
});
|
||||
const { data: preferencesSnapshot, error, isPending, refetch } = useSettingsPreferencesQuery();
|
||||
|
||||
const playerSettings = ref(createPlayerSettingsDraft());
|
||||
const saving = ref(false);
|
||||
|
||||
const settingsItems = computed(() => [
|
||||
{
|
||||
key: 'autoplay' as const,
|
||||
title: 'settings.playerSettings.items.autoplay.title',
|
||||
description: 'settings.playerSettings.items.autoplay.description',
|
||||
svg: `<svg class="w-6 h-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 532 404"><path d="M26 45v314c0 10 9 19 19 19 5 0 9-2 13-5l186-157c4-3 6-9 6-14s-2-11-6-14L58 31c-4-3-8-5-13-5-10 0-19 9-19 19z" class="fill-primary/30"/><path d="M26 359c0 11 9 19 19 19 5 0 9-2 13-4l186-158c4-3 6-9 6-14s-2-11-6-14L58 31c-4-3-8-5-13-5-10 0-19 9-19 19v314zm-16 0V45c0-19 16-35 35-35 8 0 17 3 23 8l186 158c8 6 12 16 12 26s-4 20-12 26L68 386c-6 5-15 8-23 8-19 0-35-16-35-35zM378 18v368c0 4-4 8-8 8s-8-4-8-8V18c0-4 4-8 8-8s8 4 8 8zm144 0v368c0 4-4 8-8 8s-8-4-8-8V18c0-4 4-8 8-8s8 4 8 8z" class="fill-primary"/></svg>`,
|
||||
},
|
||||
{
|
||||
key: 'loop' as const,
|
||||
title: 'settings.playerSettings.items.loop.title',
|
||||
description: 'settings.playerSettings.items.loop.description',
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 497 496"><path d="M80 248c0 30 8 58 21 82h65c8 0 14 6 14 14s-6 14-14 14h-45c31 36 76 58 127 58 93 0 168-75 168-168 0-30-8-58-21-82h-65c-8 0-14-6-14-14s6-14 14-14h45c-31-35-76-58-127-58-93 0-168 75-168 168z" class="fill-primary/30"/><path d="M70 358c37 60 103 100 179 100 116 0 210-94 210-210 0-8 6-14 14-14 7 0 14 6 14 14 0 131-107 238-238 238-82 0-154-41-197-104v90c0 8-6 14-14 14s-14-6-14-14V344c0-8 6-14 14-14h128c8 0 14 6 14 14s-6 14-14 14H70zm374-244V24c0-8 6-14 14-14s14 6 14 14v128c0 8-6 14-14 14H330c-8 0-14-6-14-14s6-14 14-14h96C389 78 323 38 248 38 132 38 38 132 38 248c0 8-7 14-14 14-8 0-14-6-14-14C10 117 116 10 248 10c81 0 153 41 196 104z" class="fill-primary"/></svg>`,
|
||||
},
|
||||
{
|
||||
key: 'muted' as const,
|
||||
title: 'settings.playerSettings.items.muted.title',
|
||||
description: 'settings.playerSettings.items.muted.description',
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 549 468"><path d="M26 186v96c0 18 14 32 32 32h64c4 0 8 2 11 5l118 118c4 3 8 5 13 5 10 0 18-8 18-18V44c0-10-8-18-18-18-5 0-9 2-13 5L133 149c-3 3-7 5-11 5H58c-18 0-32 14-32 32z" class="fill-primary/30"/><path d="m133 319 118 118c4 3 8 5 13 5 10 0 18-8 18-18V44c0-10-8-18-18-18-5 0-9 2-13 5L133 149c-3 3-7 5-11 5H58c-18 0-32 14-32 32v96c0 18 14 32 32 32h64c4 0 8 2 11 5zM58 138h64L240 20c7-6 15-10 24-10 19 0 34 15 34 34v380c0 19-15 34-34 34-9 0-17-4-24-10L122 330H58c-26 0-48-21-48-48v-96c0-26 22-48 48-48zm322 18c3-3 9-3 12 0l66 67 66-67c3-3 8-3 12 0 3 3 3 9 0 12l-67 66 67 66c3 3 3 8 0 12-4 3-9 3-12 0l-66-67-66 67c-3 3-9 3-12 0-3-4-3-9 0-12l67-66-67-66c-3-3-3-9 0-12z" class="fill-primary"/></svg>`,
|
||||
},
|
||||
{
|
||||
key: 'showControls' as const,
|
||||
title: 'settings.playerSettings.items.showControls.title',
|
||||
description: 'settings.playerSettings.items.showControls.description',
|
||||
svg: `<svg class="h6 w-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 468 468"><path d="M26 74v320c0 27 22 48 48 48h320c27 0 48-21 48-48V74c0-26-21-48-48-48H74c-26 0-48 22-48 48zm48 72c0-4 4-8 8-8h56v-24c0-18 14-32 32-32s32 14 32 32v24h184c4 0 8 4 8 8s-4 8-8 8H202v24c0 18-14 32-32 32s-32-14-32-32v-24H82c-4 0-8-4-8-8zm0 176c0-4 4-8 8-8h184v-24c0-18 14-32 32-32s32 14 32 32v24h56c4 0 8 4 8 8s-4 8-8 8h-56v24c0 18-14 32-32 32s-32-14-32-32v-24H82c-4 0-8-4-8-8z" class="fill-primary/30"/><path d="M442 74c0-26-21-48-48-48H74c-26 0-48 22-48 48v320c0 27 22 48 48 48h320c27 0 48-21 48-48V74zm16 320c0 35-29 64-64 64H74c-35 0-64-29-64-64V74c0-35 29-64 64-64h320c35 0 64 29 64 64v320zm-64-72c0 4-4 8-8 8h-56v24c0 18-14 32-32 32s-32-14-32-32v-24H82c-4 0-8-4-8-8s4-8 8-8h184v-24c0-18 14-32 32-32s32 14 32 32v24h56c4 0 8 4 8 8zm-112 0v32c0 9 7 16 16 16s16-7 16-16v-64c0-9-7-16-16-16s-16 7-16 16v32zm104-184c4 0 8 4 8 8s-4 8-8 8H202v24c0 18-14 32-32 32s-32-14-32-32v-24H82c-4 0-8-4-8-8s4-8 8-8h56v-24c0-18 14-32 32-32s32 14 32 32v24h184zm-232-24v64c0 9 7 16 16 16s16-7 16-16v-64c0-9-7-16-16-16s-16 7-16 16z" class="fill-primary"/></svg>`,
|
||||
},
|
||||
{
|
||||
key: 'pip' as const,
|
||||
title: 'settings.playerSettings.items.pip.title',
|
||||
description: 'settings.playerSettings.items.pip.description',
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 532 468"><path d="M26 74c0-26 22-48 48-48h384c27 0 48 22 48 48v112H314c-53 0-96 43-96 96v160H74c-26 0-48-21-48-48V74z" class="fill-primary/30"/><path d="M458 10c35 0 64 29 64 64v112h-16V74c0-26-21-48-48-48H74c-26 0-48 22-48 48v320c0 27 22 48 48 48h144v16H68c-31-3-55-27-58-57V74c0-33 25-60 58-64h390zm16 224c27 0 48 22 48 48v133c-3 24-23 43-48 43H309c-22-2-40-20-43-43V282c0-26 22-48 48-48h160zm-160 16c-18 0-32 14-32 32v128c0 18 14 32 32 32h160c18 0 32-14 32-32V282c0-18-14-32-32-32H314z" class="fill-primary"/></svg>`,
|
||||
},
|
||||
{
|
||||
key: 'airplay' as const,
|
||||
title: 'settings.playerSettings.items.airplay.title',
|
||||
description: 'settings.playerSettings.items.airplay.description',
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 532 436"><path d="M26 74c0-26 22-48 48-48h384c27 0 48 22 48 48v224c0 26-21 47-47 48-45-45-91-91-136-137-32-31-82-31-114 0-45 46-91 91-136 137-26-1-47-22-47-48V74z" class="fill-primary/30"/><path d="M458 26H74c-26 0-48 22-48 48v224c0 26 21 47 47 48l-14 14c-28-7-49-32-49-62V74c0-35 29-64 64-64h384c35 0 64 29 64 64v224c0 30-21 55-49 62l-14-14c26-1 47-22 47-48V74c0-26-21-48-48-48zM138 410h256c7 0 12-4 15-10 2-6 1-13-4-17L277 255c-6-6-16-6-22 0L127 383c-5 4-6 11-4 17 3 6 9 10 15 10zm279-39c9 10 12 23 7 35s-17 20-30 20H138c-13 0-25-8-30-20s-2-25 7-35l128-128c13-12 33-12 46 0l128 128z" class="fill-primary"/></svg>`,
|
||||
},
|
||||
{
|
||||
key: 'chromecast' as const,
|
||||
title: 'settings.playerSettings.items.chromecast.title',
|
||||
description: 'settings.playerSettings.items.chromecast.description',
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 532 468"><path d="M26 74c0-26 22-48 48-48h384c27 0 48 22 48 48v320c0 27-21 48-48 48H314v-9c0-154-125-279-279-279h-9V74z" class="fill-primary/30"/><path d="M458 26H74c-26 0-48 22-48 48v80H10V74c0-35 29-64 64-64h384c35 0 64 29 64 64v320c0 35-29 64-64 64H314v-16h144c27 0 48-21 48-48V74c0-26-21-48-48-48zM18 202c137 0 248 111 248 248 0 4-4 8-8 8s-8-4-8-8c0-128-104-232-232-232-4 0-8-4-8-8s4-8 8-8zm40 224c0-9-7-16-16-16s-16 7-16 16 7 16 16 16 16-7 16-16zm-48 0c0-18 14-32 32-32s32 14 32 32-14 32-32 32-32-14-32-32zm0-120c0-4 4-8 8-8 84 0 152 68 152 152 0 4-4 8-8 8s-8-4-8-8c0-75-61-136-136-136-4 0-8-4-8-8z" class="fill-primary"/></svg>`,
|
||||
},
|
||||
{
|
||||
key: 'encrytion_m3u8' as const,
|
||||
title: 'settings.playerSettings.items.encrytion_m3u8.title',
|
||||
description: 'settings.playerSettings.items.encrytion_m3u8.description',
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" class="fill-primary/30" viewBox="0 0 564 564"><path d="M26 74c0-26 22-48 48-48h134c3 0 7 0 10 1v103c0 31 25 56 56 56h120v11c-38 18-64 56-64 101v29c-29 16-48 47-48 83v96H74c-26 0-48-21-48-48V74z"/><path d="M208 26H74c-26 0-48 22-48 48v384c0 27 22 48 48 48h208c0 6 1 11 1 16H74c-35 0-64-29-64-64V74c0-35 29-64 64-64h134c17 0 33 7 45 19l122 122c10 10 16 22 18 35H274c-31 0-56-25-56-56V27c-3-1-7-1-10-1zm156 137L241 40c-2-2-4-4-7-6v96c0 22 18 40 40 40h96c-2-3-4-5-6-7zm126 135c0-26-21-48-48-48-26 0-48 22-48 48v64h96v-64zM346 410v96c0 18 14 32 32 32h128c18 0 32-14 32-32v-96c0-18-14-32-32-32H378c-18 0-32 14-32 32zm160-112v64c27 0 48 22 48 48v96c0 27-21 48-48 48H378c-26 0-48-21-48-48v-96c0-26 22-48 48-48v-64c0-35 29-64 64-64s64 29 64 64z" class="fill-primary"/></svg>`,
|
||||
|
||||
},
|
||||
]);
|
||||
|
||||
const isInitialLoading = computed(() => isPending.value && !preferencesSnapshot.value);
|
||||
const isInteractionDisabled = computed(() => saving.value || isInitialLoading.value || !preferencesSnapshot.value);
|
||||
|
||||
|
||||
watch(preferencesSnapshot, (snapshot) => {
|
||||
if (!snapshot) return;
|
||||
playerSettings.value = createPlayerSettingsDraft(snapshot);
|
||||
}, { immediate: true });
|
||||
|
||||
watch(error, (value, previous) => {
|
||||
if (!value || value === previous || saving.value) return;
|
||||
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('settings.playerSettings.toast.failedSummary'),
|
||||
detail: (value as any)?.message || t('settings.playerSettings.toast.failedDetail'),
|
||||
life: 5000,
|
||||
});
|
||||
});
|
||||
|
||||
const handleSave = async () => {
|
||||
if (saving.value || !preferencesSnapshot.value) return;
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
await client.settings.preferencesUpdate(
|
||||
toPlayerPreferencesPayload(playerSettings.value),
|
||||
{ baseUrl: '/r' },
|
||||
);
|
||||
await refetch();
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('settings.playerSettings.toast.savedSummary'),
|
||||
detail: t('settings.playerSettings.toast.savedDetail'),
|
||||
life: 3000
|
||||
life: 3000,
|
||||
});
|
||||
} catch (e: any) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('settings.playerSettings.toast.failedSummary'),
|
||||
detail: e.message || t('settings.playerSettings.toast.failedDetail'),
|
||||
life: 5000
|
||||
life: 5000,
|
||||
});
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const settingsItems = computed(() => [
|
||||
{
|
||||
key: 'autoplay' as const,
|
||||
title: t('settings.playerSettings.items.autoplay.title'),
|
||||
description: t('settings.playerSettings.items.autoplay.description'),
|
||||
svg: `<svg class="w-6 h-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 532 404"><path d="M26 45v314c0 10 9 19 19 19 5 0 9-2 13-5l186-157c4-3 6-9 6-14s-2-11-6-14L58 31c-4-3-8-5-13-5-10 0-19 9-19 19z" class="fill-primary/30"/><path d="M26 359c0 11 9 19 19 19 5 0 9-2 13-4l186-158c4-3 6-9 6-14s-2-11-6-14L58 31c-4-3-8-5-13-5-10 0-19 9-19 19v314zm-16 0V45c0-19 16-35 35-35 8 0 17 3 23 8l186 158c8 6 12 16 12 26s-4 20-12 26L68 386c-6 5-15 8-23 8-19 0-35-16-35-35zM378 18v368c0 4-4 8-8 8s-8-4-8-8V18c0-4 4-8 8-8s8 4 8 8zm144 0v368c0 4-4 8-8 8s-8-4-8-8V18c0-4 4-8 8-8s8 4 8 8z" class="fill-primary"/></svg>`,
|
||||
},
|
||||
{
|
||||
key: 'loop' as const,
|
||||
title: t('settings.playerSettings.items.loop.title'),
|
||||
description: t('settings.playerSettings.items.loop.description'),
|
||||
svg: `<svg class="h-6 w-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 497 496"><path d="M80 248c0 30 8 58 21 82h65c8 0 14 6 14 14s-6 14-14 14h-45c31 36 76 58 127 58 93 0 168-75 168-168 0-30-8-58-21-82h-65c-8 0-14-6-14-14s6-14 14-14h45c-31-35-76-58-127-58-93 0-168 75-168 168z" class="fill-primary/30"/><path d="M70 358c37 60 103 100 179 100 116 0 210-94 210-210 0-8 6-14 14-14 7 0 14 6 14 14 0 131-107 238-238 238-82 0-154-41-197-104v90c0 8-6 14-14 14s-14-6-14-14V344c0-8 6-14 14-14h128c8 0 14 6 14 14s-6 14-14 14H70zm374-244V24c0-8 6-14 14-14s14 6 14 14v128c0 8-6 14-14 14H330c-8 0-14-6-14-14s6-14 14-14h96C389 78 323 38 248 38 132 38 38 132 38 248c0 8-7 14-14 14-8 0-14-6-14-14C10 117 116 10 248 10c81 0 153 41 196 104z" class="fill-primary"/></svg>`,
|
||||
},
|
||||
{
|
||||
key: 'muted' as const,
|
||||
title: t('settings.playerSettings.items.muted.title'),
|
||||
description: t('settings.playerSettings.items.muted.description'),
|
||||
svg: `<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" viewBox="0 0 549 468"><path d="M26 186v96c0 18 14 32 32 32h64c4 0 8 2 11 5l118 118c4 3 8 5 13 5 10 0 18-8 18-18V44c0-10-8-18-18-18-5 0-9 2-13 5L133 149c-3 3-7 5-11 5H58c-18 0-32 14-32 32z" class="fill-primary/30"/><path d="m133 319 118 118c4 3 8 5 13 5 10 0 18-8 18-18V44c0-10-8-18-18-18-5 0-9 2-13 5L133 149c-3 3-7 5-11 5H58c-18 0-32 14-32 32v96c0 18 14 32 32 32h64c4 0 8 2 11 5zM58 138h64L240 20c7-6 15-10 24-10 19 0 34 15 34 34v380c0 19-15 34-34 34-9 0-17-4-24-10L122 330H58c-26 0-48-21-48-48v-96c0-26 22-48 48-48zm322 18c3-3 9-3 12 0l66 67 66-67c3-3 8-3 12 0 3 3 3 9 0 12l-67 66 67 66c3 3 3 8 0 12-4 3-9 3-12 0l-66-67-66 67c-3 3-9 3-12 0-3-4-3-9 0-12l67-66-67-66c-3-3-3-9 0-12z" class="fill-primary"/></svg>`,
|
||||
},
|
||||
{
|
||||
key: 'showControls' as const,
|
||||
title: t('settings.playerSettings.items.showControls.title'),
|
||||
description: t('settings.playerSettings.items.showControls.description'),
|
||||
svg: `<svg class="h6 w-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 468 468"><path d="M26 74v320c0 27 22 48 48 48h320c27 0 48-21 48-48V74c0-26-21-48-48-48H74c-26 0-48 22-48 48zm48 72c0-4 4-8 8-8h56v-24c0-18 14-32 32-32s32 14 32 32v24h184c4 0 8 4 8 8s-4 8-8 8H202v24c0 18-14 32-32 32s-32-14-32-32v-24H82c-4 0-8-4-8-8zm0 176c0-4 4-8 8-8h184v-24c0-18 14-32 32-32s32 14 32 32v24h56c4 0 8 4 8 8s-4 8-8 8h-56v24c0 18-14 32-32 32s-32-14-32-32v-24H82c-4 0-8-4-8-8z" class="fill-primary/30"/><path d="M442 74c0-26-21-48-48-48H74c-26 0-48 22-48 48v320c0 27 22 48 48 48h320c27 0 48-21 48-48V74zm16 320c0 35-29 64-64 64H74c-35 0-64-29-64-64V74c0-35 29-64 64-64h320c35 0 64 29 64 64v320zm-64-72c0 4-4 8-8 8h-56v24c0 18-14 32-32 32s-32-14-32-32v-24H82c-4 0-8-4-8-8s4-8 8-8h184v-24c0-18 14-32 32-32s32 14 32 32v24h56c4 0 8 4 8 8zm-112 0v32c0 9 7 16 16 16s16-7 16-16v-64c0-9-7-16-16-16s-16 7-16 16v32zm104-184c4 0 8 4 8 8s-4 8-8 8H202v24c0 18-14 32-32 32s-32-14-32-32v-24H82c-4 0-8-4-8-8s4-8 8-8h56v-24c0-18 14-32 32-32s32 14 32 32v24h184zm-232-24v64c0 9 7 16 16 16s16-7 16-16v-64c0-9-7-16-16-16s-16 7-16 16z" class="fill-primary"/></svg>`
|
||||
},
|
||||
{
|
||||
key: 'pip' as const,
|
||||
title: t('settings.playerSettings.items.pip.title'),
|
||||
description: t('settings.playerSettings.items.pip.description'),
|
||||
svg: `<svg class="h-6 w-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 532 468"><path d="M26 74c0-26 22-48 48-48h384c27 0 48 22 48 48v112H314c-53 0-96 43-96 96v160H74c-26 0-48-21-48-48V74z" class="fill-primary/30"/><path d="M458 10c35 0 64 29 64 64v112h-16V74c0-26-21-48-48-48H74c-26 0-48 22-48 48v320c0 27 22 48 48 48h144v16H68c-31-3-55-27-58-57V74c0-33 25-60 58-64h390zm16 224c27 0 48 22 48 48v133c-3 24-23 43-48 43H309c-22-2-40-20-43-43V282c0-26 22-48 48-48h160zm-160 16c-18 0-32 14-32 32v128c0 18 14 32 32 32h160c18 0 32-14 32-32V282c0-18-14-32-32-32H314z" class="fill-primary"/></svg>`
|
||||
},
|
||||
{
|
||||
key: 'airplay' as const,
|
||||
title: t('settings.playerSettings.items.airplay.title'),
|
||||
description: t('settings.playerSettings.items.airplay.description'),
|
||||
svg: `<svg class="h-6 w-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 532 436"><path d="M26 74c0-26 22-48 48-48h384c27 0 48 22 48 48v224c0 26-21 47-47 48-45-45-91-91-136-137-32-31-82-31-114 0-45 46-91 91-136 137-26-1-47-22-47-48V74z" class="fill-primary/30"/><path d="M458 26H74c-26 0-48 22-48 48v224c0 26 21 47 47 48l-14 14c-28-7-49-32-49-62V74c0-35 29-64 64-64h384c35 0 64 29 64 64v224c0 30-21 55-49 62l-14-14c26-1 47-22 47-48V74c0-26-21-48-48-48zM138 410h256c7 0 12-4 15-10 2-6 1-13-4-17L277 255c-6-6-16-6-22 0L127 383c-5 4-6 11-4 17 3 6 9 10 15 10zm279-39c9 10 12 23 7 35s-17 20-30 20H138c-13 0-25-8-30-20s-2-25 7-35l128-128c13-12 33-12 46 0l128 128z" class="fill-primary"/></svg>`,
|
||||
},
|
||||
{
|
||||
key: 'Chromecast' as const,
|
||||
title: t('settings.playerSettings.items.chromecast.title'),
|
||||
description: t('settings.playerSettings.items.chromecast.description'),
|
||||
svg: `<svg class="h-6 w-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 532 468"><path d="M26 74c0-26 22-48 48-48h384c27 0 48 22 48 48v320c0 27-21 48-48 48H314v-9c0-154-125-279-279-279h-9V74z" class="fill-primary/30"/><path d="M458 26H74c-26 0-48 22-48 48v80H10V74c0-35 29-64 64-64h384c35 0 64 29 64 64v320c0 35-29 64-64 64H314v-16h144c27 0 48-21 48-48V74c0-26-21-48-48-48zM18 202c137 0 248 111 248 248 0 4-4 8-8 8s-8-4-8-8c0-128-104-232-232-232-4 0-8-4-8-8s4-8 8-8zm40 224c0-9-7-16-16-16s-16 7-16 16 7 16 16 16 16-7 16-16zm-48 0c0-18 14-32 32-32s32 14 32 32-14 32-32 32-32-14-32-32zm0-120c0-4 4-8 8-8 84 0 152 68 152 152 0 4-4 8-8 8s-8-4-8-8c0-75-61-136-136-136-4 0-8-4-8-8z" class="fill-primary"/></svg>`,
|
||||
},
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -97,7 +131,7 @@ const settingsItems = computed(() => [
|
||||
:description="t('settings.content.player.subtitle')"
|
||||
>
|
||||
<template #header-actions>
|
||||
<AppButton size="sm" :loading="saving" @click="handleSave">
|
||||
<AppButton size="sm" :loading="saving" :disabled="isInitialLoading || !preferencesSnapshot" @click="handleSave">
|
||||
<template #icon>
|
||||
<CheckIcon class="w-4 h-4" />
|
||||
</template>
|
||||
@@ -105,20 +139,29 @@ const settingsItems = computed(() => [
|
||||
</AppButton>
|
||||
</template>
|
||||
|
||||
<SettingsRow
|
||||
v-for="item in settingsItems"
|
||||
:key="item.key"
|
||||
:title="item.title"
|
||||
:description="item.description"
|
||||
iconBoxClass="bg-primary/10 text-primary"
|
||||
>
|
||||
<template #icon>
|
||||
<span v-html="item.svg" />
|
||||
</template>
|
||||
<template v-if="isInitialLoading">
|
||||
<SettingsRowSkeleton
|
||||
v-for="item in settingsItems"
|
||||
:key="item.key"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
<AppSwitch v-model="playerSettings[item.key]" />
|
||||
</template>
|
||||
</SettingsRow>
|
||||
<template v-else>
|
||||
<SettingsRow
|
||||
v-for="item in settingsItems"
|
||||
:key="item.key"
|
||||
:title="$t(item.title)"
|
||||
:description="$t(item.description)"
|
||||
iconBoxClass="bg-primary/10 text-primary"
|
||||
>
|
||||
<template #icon>
|
||||
<span v-html="item.svg" class="h-6 w-6" />
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
<AppSwitch v-model="playerSettings[item.key]" :disabled="isInteractionDisabled" />
|
||||
</template>
|
||||
</SettingsRow>
|
||||
</template>
|
||||
</SettingsSectionCard>
|
||||
</template>
|
||||
|
||||
@@ -19,10 +19,10 @@ import { computed, ref } from 'vue';
|
||||
const auth = useAuthStore();
|
||||
const toast = useAppToast();
|
||||
const confirm = useAppConfirm();
|
||||
const { t } = useTranslation();
|
||||
const { t, i18next } = useTranslation();
|
||||
|
||||
const languageSaving = ref(false);
|
||||
const selectedLanguage = ref('en');
|
||||
const selectedLanguage = ref<string>(auth.user?.language || "en");
|
||||
const languageOptions = computed(() => supportedLocales.map((value) => ({
|
||||
value,
|
||||
label: t(`settings.securityConnected.language.options.${value}`)
|
||||
@@ -282,7 +282,7 @@ const disconnectTelegram = async () => {
|
||||
</template>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
<SettingsRow
|
||||
:title="t('settings.securityConnected.twoFactor.label')"
|
||||
:description="twoFactorEnabled ? t('settings.securityConnected.twoFactor.enabled') : t('settings.securityConnected.twoFactor.disabled')"
|
||||
iconBoxClass="bg-primary/10"
|
||||
@@ -314,26 +314,6 @@ const disconnectTelegram = async () => {
|
||||
</template>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
:title="t('settings.securityConnected.logout.label')"
|
||||
:description="t('settings.securityConnected.logout.detail')"
|
||||
iconBoxClass="bg-danger/10"
|
||||
hoverClass="hover:bg-danger/5"
|
||||
>
|
||||
<template #icon>
|
||||
<XCircleIcon class="w-5 h-5 text-danger" />
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
<AppButton variant="danger" size="sm" @click="handleLogout">
|
||||
<template #icon>
|
||||
<XCircleIcon class="w-4 h-4" />
|
||||
</template>
|
||||
{{ t('settings.securityConnected.logout.button') }}
|
||||
</AppButton>
|
||||
</template>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
:title="t('settings.securityConnected.email.label')"
|
||||
:description="emailConnected ? t('settings.securityConnected.email.connected') : t('settings.securityConnected.email.disconnected')"
|
||||
@@ -380,6 +360,26 @@ const disconnectTelegram = async () => {
|
||||
</AppButton>
|
||||
</template>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
:title="t('settings.securityConnected.logout.label')"
|
||||
:description="t('settings.securityConnected.logout.detail')"
|
||||
iconBoxClass="bg-danger/10"
|
||||
hoverClass="hover:bg-danger/5"
|
||||
>
|
||||
<template #icon>
|
||||
<XCircleIcon class="w-5 h-5 text-danger" />
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
<AppButton variant="danger" size="sm" @click="handleLogout">
|
||||
<template #icon>
|
||||
<XCircleIcon class="w-4 h-4" />
|
||||
</template>
|
||||
{{ t('settings.securityConnected.logout.button') }}
|
||||
</AppButton>
|
||||
</template>
|
||||
</SettingsRow>
|
||||
</SettingsSectionCard>
|
||||
|
||||
<AppDialog
|
||||
|
||||
28
src/routes/settings/components/SettingsRowSkeleton.vue
Normal file
28
src/routes/settings/components/SettingsRowSkeleton.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
actionClass?: string;
|
||||
titleClass?: string;
|
||||
descriptionClass?: string;
|
||||
}>(), {
|
||||
actionClass: 'h-6 w-11',
|
||||
titleClass: 'w-32',
|
||||
descriptionClass: 'w-56 max-w-full',
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center justify-between gap-4 px-6 py-4 animate-pulse">
|
||||
<div class="flex min-w-0 items-center gap-4">
|
||||
<div class="h-10 w-10 rounded-md bg-muted/50 shrink-0" />
|
||||
|
||||
<div class="min-w-0 space-y-2">
|
||||
<div :class="cn('h-4 rounded bg-muted/50', titleClass)" />
|
||||
<div :class="cn('h-3 rounded bg-muted/40', descriptionClass)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :class="cn('shrink-0 rounded-full bg-muted/50', actionClass)" />
|
||||
</div>
|
||||
</template>
|
||||
40
src/routes/settings/components/SettingsTableSkeleton.vue
Normal file
40
src/routes/settings/components/SettingsTableSkeleton.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
const props = withDefaults(defineProps<{
|
||||
columns?: number;
|
||||
rows?: number;
|
||||
}>(), {
|
||||
columns: 3,
|
||||
rows: 4,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="border-b border-border mt-4">
|
||||
<table class="w-full">
|
||||
<thead class="bg-muted/30">
|
||||
<tr>
|
||||
<th
|
||||
v-for="column in columns"
|
||||
:key="column"
|
||||
class="px-6 py-3"
|
||||
>
|
||||
<div class="h-3 w-20 rounded bg-muted/50 animate-pulse" />
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-border">
|
||||
<tr v-for="row in rows" :key="row" class="animate-pulse">
|
||||
<td v-for="column in columns" :key="column" class="px-6 py-4">
|
||||
<div class="space-y-2">
|
||||
<div class="h-4 rounded bg-muted/50" :class="column === columns ? 'ml-auto w-16' : 'w-full max-w-[12rem]'" />
|
||||
<div
|
||||
v-if="column === 1"
|
||||
class="h-3 w-24 rounded bg-muted/40"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
@@ -8,12 +8,17 @@ type PaymentHistoryItem = {
|
||||
plan: string;
|
||||
status: string;
|
||||
invoiceId: string;
|
||||
currency: string;
|
||||
kind: string;
|
||||
details?: string[];
|
||||
};
|
||||
|
||||
defineProps<{
|
||||
title: string;
|
||||
description: string;
|
||||
items: PaymentHistoryItem[];
|
||||
loading?: boolean;
|
||||
downloadingId?: string | null;
|
||||
formatMoney: (amount: number) => string;
|
||||
getStatusStyles: (status: string) => string;
|
||||
getStatusLabel: (status: string) => string;
|
||||
@@ -52,42 +57,58 @@ const emit = defineEmits<{
|
||||
<div class="col-span-2 text-right">{{ invoiceLabel }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="items.length === 0" class="text-center py-12 text-foreground/60">
|
||||
<div v-if="loading" class="px-4 py-6 space-y-3">
|
||||
<div v-for="index in 3" :key="index" class="grid grid-cols-12 gap-4 items-center animate-pulse">
|
||||
<div class="col-span-3 h-4 rounded bg-muted/50" />
|
||||
<div class="col-span-2 h-4 rounded bg-muted/50" />
|
||||
<div class="col-span-3 h-4 rounded bg-muted/50" />
|
||||
<div class="col-span-2 h-6 rounded bg-muted/50" />
|
||||
<div class="col-span-2 h-8 rounded bg-muted/50" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="items.length === 0" class="text-center py-12 text-foreground/60">
|
||||
<div class="w-16 h-16 rounded-full bg-muted/50 flex items-center justify-center mx-auto mb-4">
|
||||
<DownloadIcon class="w-8 h-8 text-foreground/40" />
|
||||
</div>
|
||||
<p>{{ emptyLabel }}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
class="grid grid-cols-12 gap-4 px-4 py-3 items-center hover:bg-muted/30 transition-all border-t border-border"
|
||||
>
|
||||
<div class="col-span-3">
|
||||
<p class="text-sm font-medium text-foreground">{{ item.date }}</p>
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
class="grid grid-cols-12 gap-4 px-4 py-3 items-center hover:bg-muted/30 transition-all border-t border-border"
|
||||
>
|
||||
<div class="col-span-3">
|
||||
<p class="text-sm font-medium text-foreground">{{ item.date }}</p>
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<p class="text-sm text-foreground">{{ formatMoney(item.amount) }}</p>
|
||||
</div>
|
||||
<div class="col-span-3">
|
||||
<p class="text-sm text-foreground">{{ item.plan }}</p>
|
||||
<p v-if="item.details?.length" class="mt-1 text-xs text-foreground/60">
|
||||
{{ item.details.join(' · ') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<span :class="`inline-flex items-center px-2.5 py-1 rounded-md text-xs font-medium ${getStatusStyles(item.status)}`">
|
||||
{{ getStatusLabel(item.status) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="col-span-2 flex justify-end">
|
||||
<button
|
||||
class="flex items-center gap-2 px-3 py-1.5 text-sm text-foreground/70 hover:text-foreground hover:bg-muted/50 rounded-md transition-all disabled:opacity-60 disabled:cursor-wait"
|
||||
:disabled="downloadingId === item.id"
|
||||
@click="emit('download', item)"
|
||||
>
|
||||
<DownloadIcon class="w-4 h-4" />
|
||||
<span>{{ downloadingId === item.id ? '...' : downloadLabel }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<p class="text-sm text-foreground">{{ formatMoney(item.amount) }}</p>
|
||||
</div>
|
||||
<div class="col-span-3">
|
||||
<p class="text-sm text-foreground">{{ item.plan }}</p>
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<span :class="`inline-flex items-center px-2.5 py-1 rounded-md text-xs font-medium ${getStatusStyles(item.status)}`">
|
||||
{{ getStatusLabel(item.status) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="col-span-2 flex justify-end">
|
||||
<button
|
||||
class="flex items-center gap-2 px-3 py-1.5 text-sm text-foreground/70 hover:text-foreground hover:bg-muted/50 rounded-md transition-all"
|
||||
@click="emit('download', item)"
|
||||
>
|
||||
<DownloadIcon class="w-4 h-4" />
|
||||
<span>{{ downloadLabel }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -9,18 +9,18 @@ defineProps<{
|
||||
isLoading: boolean;
|
||||
plans: ModelPlan[];
|
||||
currentPlanId?: string;
|
||||
subscribing: string | null;
|
||||
selectingPlanId?: string | null;
|
||||
formatMoney: (amount: number) => string;
|
||||
getPlanStorageText: (plan: ModelPlan) => string;
|
||||
getPlanDurationText: (plan: ModelPlan) => string;
|
||||
getPlanUploadsText: (plan: ModelPlan) => string;
|
||||
currentPlanLabel: string;
|
||||
processingLabel: string;
|
||||
upgradeLabel: string;
|
||||
selectingLabel: string;
|
||||
chooseLabel: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'subscribe', plan: ModelPlan): void;
|
||||
(e: 'select', plan: ModelPlan): void;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
@@ -44,22 +44,32 @@ const emit = defineEmits<{
|
||||
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div
|
||||
v-for="plan in plans"
|
||||
v-for="plan in plans.sort((a,b) => a.price - b.price)"
|
||||
:key="plan.id"
|
||||
class="border border-border rounded-lg p-4 hover:bg-muted/30 transition-all"
|
||||
:class="[
|
||||
'border rounded-lg p-4 hover:bg-muted/30 transition-all flex flex-col',
|
||||
plan.id === currentPlanId ? 'border-primary/40 bg-primary/5' : 'border-border',
|
||||
]"
|
||||
>
|
||||
<div class="mb-3">
|
||||
<h3 class="text-lg font-semibold text-foreground">{{ plan.name }}</h3>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h3 class="text-lg font-semibold text-foreground">{{ plan.name }}</h3>
|
||||
<span
|
||||
v-if="plan.id === currentPlanId"
|
||||
class="inline-flex items-center rounded-full bg-primary/10 px-2 py-1 text-[11px] font-medium text-primary"
|
||||
>
|
||||
{{ currentPlanLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-sm text-foreground/60 mt-1 min-h-[2.5rem]">{{ plan.description }}</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<span class="text-2xl font-bold text-foreground">{{ formatMoney(plan.price || 0) }}</span>
|
||||
<span class="text-foreground/60 text-sm">/{{ plan.cycle }}</span>
|
||||
<span class="text-foreground/60 text-sm"> / {{ $t('settings.billing.cycle.'+plan.cycle) }}</span>
|
||||
</div>
|
||||
|
||||
<ul class="space-y-2 mb-4 text-sm">
|
||||
<li class="flex items-center gap-2 text-foreground/70">
|
||||
<!-- <li class="flex items-center gap-2 text-foreground/70">
|
||||
<CheckIcon class="w-4 h-4 text-success shrink-0" />
|
||||
{{ getPlanStorageText(plan) }}
|
||||
</li>
|
||||
@@ -70,24 +80,29 @@ const emit = defineEmits<{
|
||||
<li class="flex items-center gap-2 text-foreground/70">
|
||||
<CheckIcon class="w-4 h-4 text-success shrink-0" />
|
||||
{{ getPlanUploadsText(plan) }}
|
||||
</li> -->
|
||||
<li
|
||||
v-for="feature in plan.features || []"
|
||||
:key="feature"
|
||||
class="flex items-center gap-2 text-foreground/70"
|
||||
>
|
||||
<CheckIcon class="w-4 h-4 text-success shrink-0" />
|
||||
{{ feature }}
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<button
|
||||
:disabled="!!subscribing || plan.id === currentPlanId"
|
||||
v-if="plan.id !== currentPlanId"
|
||||
:disabled="selectingPlanId === plan.id"
|
||||
:class="[
|
||||
'w-full py-2 px-4 rounded-md text-sm font-medium transition-all',
|
||||
plan.id === currentPlanId
|
||||
? 'bg-muted/50 text-foreground/60 cursor-not-allowed'
|
||||
: subscribing === plan.id
|
||||
? 'bg-muted/50 text-foreground/60 cursor-wait'
|
||||
: 'bg-primary text-primary-foreground hover:bg-primary/90'
|
||||
'w-full py-2 px-4 rounded-md text-sm font-medium transition-all mt-a',
|
||||
selectingPlanId === plan.id
|
||||
? 'bg-muted/50 text-foreground/60 cursor-wait'
|
||||
: 'bg-primary text-white hover:bg-primary/90'
|
||||
]"
|
||||
@click="emit('subscribe', plan)"
|
||||
@click="emit('select', plan)"
|
||||
>
|
||||
{{ plan.id === currentPlanId
|
||||
? currentPlanLabel
|
||||
: (subscribing === plan.id ? processingLabel : upgradeLabel) }}
|
||||
{{ selectingPlanId === plan.id ? selectingLabel : chooseLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,9 @@ defineProps<{
|
||||
title: string;
|
||||
description: string;
|
||||
buttonLabel: string;
|
||||
subscriptionTitle?: string;
|
||||
subscriptionDescription?: string;
|
||||
subscriptionTone?: 'default' | 'warning';
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -22,12 +25,25 @@ const emit = defineEmits<{
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
<AppButton size="sm" @click="emit('topup')">
|
||||
<template #icon>
|
||||
<PlusIcon class="w-4 h-4" />
|
||||
</template>
|
||||
{{ buttonLabel }}
|
||||
</AppButton>
|
||||
<div class="flex flex-col items-end gap-2">
|
||||
<!-- <div
|
||||
v-if="subscriptionTitle || subscriptionDescription"
|
||||
class="rounded-md border px-3 py-2 text-right"
|
||||
:class="subscriptionTone === 'warning'
|
||||
? 'border-warning/30 bg-warning/10 text-warning'
|
||||
: 'border-border bg-muted/30 text-foreground/70'"
|
||||
>
|
||||
<p v-if="subscriptionTitle" class="text-xs font-medium">{{ subscriptionTitle }}</p>
|
||||
<p v-if="subscriptionDescription" class="mt-0.5 text-xs">{{ subscriptionDescription }}</p>
|
||||
</div> -->
|
||||
|
||||
<AppButton size="sm" @click="emit('topup')">
|
||||
<template #icon>
|
||||
<PlusIcon class="w-4 h-4" />
|
||||
</template>
|
||||
{{ buttonLabel }}
|
||||
</AppButton>
|
||||
</div>
|
||||
</template>
|
||||
</SettingsRow>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { ModelVideo } from '@/api/client';
|
||||
import { fetchMockVideoById } from '@/mocks/videos';
|
||||
import { client, type ModelVideo } from '@/api/client';
|
||||
import { useAppToast } from '@/composables/useAppToast';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
@@ -22,7 +21,8 @@ const { t } = useTranslation();
|
||||
const fetchVideo = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const videoData = await fetchMockVideoById(props.videoId);
|
||||
const response = await client.videos.videosDetail(props.videoId, { baseUrl: '/r' });
|
||||
const videoData = (response.data as any)?.data?.video || (response.data as any)?.data;
|
||||
if (videoData) {
|
||||
video.value = videoData;
|
||||
}
|
||||
@@ -44,11 +44,13 @@ const baseUrl = computed(() => typeof window !== 'undefined' ? window.location.o
|
||||
const shareLinks = computed(() => {
|
||||
if (!video.value) return [];
|
||||
const v = video.value;
|
||||
const playbackPath = v.url || `/play/index/${v.id}`;
|
||||
const playbackUrl = playbackPath.startsWith('http') ? playbackPath : `${baseUrl.value}${playbackPath}`;
|
||||
return [
|
||||
{
|
||||
key: 'embed',
|
||||
label: t('video.copyModal.embedPlayer'),
|
||||
value: `${baseUrl.value}/play/index/${v.id}`,
|
||||
value: playbackUrl,
|
||||
},
|
||||
{
|
||||
key: 'thumbnail',
|
||||
@@ -58,7 +60,7 @@ const shareLinks = computed(() => {
|
||||
{
|
||||
key: 'hls',
|
||||
label: t('video.copyModal.hls'),
|
||||
value: v.hls_path ? `${baseUrl.value}/hls/getlink/${v.id}/${v.hls_token}/${v.hls_path}` : '',
|
||||
value: playbackUrl,
|
||||
placeholder: t('video.copyModal.hlsPlaceholder'),
|
||||
hint: t('video.copyModal.hlsHint'),
|
||||
},
|
||||
@@ -129,7 +131,7 @@ watch(() => props.videoId, (newId) => {
|
||||
<p class="text-sm font-medium text-muted-foreground">{{ link.label }}</p>
|
||||
<div class="flex gap-2">
|
||||
<AppInput :model-value="link.value || ''" :placeholder="link.placeholder" readonly
|
||||
input-class="!font-mono !text-xs" @click="($event.target as HTMLInputElement)?.select()" />
|
||||
input-class="!font-mono !text-xs" wrapperClass="w-full" @click="($event.target as HTMLInputElement)?.select()" />
|
||||
<AppButton variant="secondary" :disabled="!link.value || copiedField === link.key"
|
||||
@click="copyToClipboard(link.value, link.key)" class="shrink-0">
|
||||
<!-- Copy icon -->
|
||||
|
||||
@@ -1,233 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { ModelVideo } from '@/api/client';
|
||||
import PageHeader from '@/components/dashboard/PageHeader.vue';
|
||||
import { deleteMockVideo, fetchMockVideoById, updateMockVideo } from '@/mocks/videos';
|
||||
import { useAppConfirm } from '@/composables/useAppConfirm';
|
||||
import { useAppToast } from '@/composables/useAppToast';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import VideoEditForm from './components/Detail/VideoEditForm.vue';
|
||||
import VideoHeader from './components/Detail/VideoInfoHeader.vue';
|
||||
import VideoPlayer from './components/Detail/VideoPlayer.vue';
|
||||
import VideoSkeleton from './components/Detail/VideoSkeleton.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const toast = useAppToast();
|
||||
const confirm = useAppConfirm();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const videoId = route.params.id as string;
|
||||
const video = ref<ModelVideo | null>(null);
|
||||
const loading = ref(true);
|
||||
const saving = ref(false);
|
||||
const isEditing = ref(false);
|
||||
|
||||
const form = ref({
|
||||
title: '',
|
||||
description: '',
|
||||
});
|
||||
|
||||
const fetchVideo = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const videoData = await fetchMockVideoById(videoId);
|
||||
if (videoData) {
|
||||
video.value = videoData;
|
||||
form.value.title = videoData.title || '';
|
||||
form.value.description = videoData.description || '';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch video:', error);
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('video.detailModal.toast.loadErrorSummary'),
|
||||
detail: t('video.detailModal.toast.loadErrorDetail'),
|
||||
life: 3000
|
||||
});
|
||||
router.push('/video');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleReload = async () => {
|
||||
toast.add({
|
||||
severity: 'info',
|
||||
summary: t('video.detailPage.toast.reloadSummary'),
|
||||
detail: t('video.detailPage.toast.reloadDetail'),
|
||||
life: 2000
|
||||
});
|
||||
await fetchVideo();
|
||||
};
|
||||
|
||||
const toggleEdit = () => {
|
||||
isEditing.value = !isEditing.value;
|
||||
if (!isEditing.value && video.value) {
|
||||
form.value.title = video.value.title || '';
|
||||
form.value.description = video.value.description || '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
saving.value = true;
|
||||
try {
|
||||
await updateMockVideo(videoId, form.value);
|
||||
|
||||
if (video.value) {
|
||||
video.value.title = form.value.title;
|
||||
video.value.description = form.value.description;
|
||||
}
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('video.detailModal.toast.saveSuccessSummary'),
|
||||
detail: t('video.detailModal.toast.saveSuccessDetail'),
|
||||
life: 3000
|
||||
});
|
||||
isEditing.value = false;
|
||||
} catch (error) {
|
||||
console.error('Failed to save video:', error);
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('video.detailModal.toast.saveErrorSummary'),
|
||||
detail: t('video.detailModal.toast.saveErrorDetail'),
|
||||
life: 3000
|
||||
});
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
confirm.require({
|
||||
message: t('video.detailPage.confirmDelete.message'),
|
||||
header: t('video.detailPage.confirmDelete.header'),
|
||||
acceptLabel: t('video.detailPage.confirmDelete.accept'),
|
||||
rejectLabel: t('video.detailPage.confirmDelete.reject'),
|
||||
accept: async () => {
|
||||
try {
|
||||
await deleteMockVideo(videoId);
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('video.detailPage.toast.deleteSuccessSummary'),
|
||||
detail: t('video.detailPage.toast.deleteSuccessDetail'),
|
||||
life: 3000
|
||||
});
|
||||
router.push('/video');
|
||||
} catch (error) {
|
||||
console.error('Failed to delete video:', error);
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('video.detailPage.toast.deleteErrorSummary'),
|
||||
detail: t('video.detailPage.toast.deleteErrorDetail'),
|
||||
life: 3000
|
||||
});
|
||||
}
|
||||
},
|
||||
reject: () => { }
|
||||
});
|
||||
};
|
||||
|
||||
const copyToClipboard = async (text: string, label: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch {
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = text;
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('video.detailPage.toast.copySummary'),
|
||||
detail: t('video.detailPage.toast.copyDetail', { label }),
|
||||
life: 2000
|
||||
});
|
||||
};
|
||||
|
||||
const origin = computed(() => typeof window !== 'undefined' ? window.location.origin : '');
|
||||
|
||||
const videoInfos = computed(() => {
|
||||
if (!video.value) return [];
|
||||
|
||||
const embedUrl = `${origin.value}/embed/${video.value.id}`;
|
||||
return [
|
||||
{ label: t('video.detailPage.videoInfo.videoId'), value: video.value.id ?? '' },
|
||||
{ label: t('video.detailPage.videoInfo.thumbnailUrl'), value: video.value.thumbnail ?? '' },
|
||||
{ label: t('video.detailPage.videoInfo.embedUrl'), value: embedUrl },
|
||||
{
|
||||
label: t('video.detailPage.videoInfo.iframeCode'),
|
||||
value: embedUrl ? `<iframe src="${embedUrl}" title="${video.value.title}" width="100%" height="400" frameborder="0" allowfullscreen></iframe>` : ''
|
||||
},
|
||||
{ label: t('video.detailPage.videoInfo.shareLink'), value: `${origin.value}/view/${video.value.id}` },
|
||||
];
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
fetchVideo();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader :title="t('video.detailPage.title')" :description="t('video.detailPage.description')" :breadcrumbs="[
|
||||
{ label: t('pageHeader.dashboard'), to: '/' },
|
||||
{ label: t('nav.videos'), to: '/video' },
|
||||
{ label: video?.title || t('video.detailPage.loadingBreadcrumb') }
|
||||
]" />
|
||||
|
||||
<div class="mx-auto p-4 w-full">
|
||||
<!-- Loading State -->
|
||||
<VideoSkeleton v-if="loading" />
|
||||
|
||||
<!-- Content -->
|
||||
<div v-else-if="video" class="flex flex-col lg:flex-row gap-4">
|
||||
<VideoPlayer :video="video" class="lg:flex-1" />
|
||||
|
||||
<div class="bg-white rounded-lg border border-gray-200 max-w-full lg:max-w-md w-full flex flex-col">
|
||||
<div class="px-6 py-4">
|
||||
<VideoEditForm v-if="isEditing" v-model:title="form.title"
|
||||
v-model:description="form.description" @save="handleSave" @toggle-edit="toggleEdit" :saving="saving" />
|
||||
<div v-else>
|
||||
<VideoHeader :video="video" @reload="handleReload" @toggle-edit="toggleEdit" @delete="handleDelete" />
|
||||
<div class="mb-4">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-4">{{ t('video.detailPage.detailsTitle') }}</h3>
|
||||
<div class="flex flex-col gap-2">
|
||||
<dl v-for="info in videoInfos" :key="info.label" class="space-y-2">
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">{{ info.label }}</dt>
|
||||
<dd class="text-sm text-gray-900">
|
||||
<div class="flex items-center space-x-2">
|
||||
<input readonly
|
||||
class="flex-1 px-2 py-1 text-xs border border-gray-300 rounded bg-gray-50 font-mono"
|
||||
:value="info.value || '-'">
|
||||
<button v-if="info.value"
|
||||
@click="copyToClipboard(info.value, info.label)"
|
||||
class="px-2 py-1 text-xs bg-gray-100 hover:bg-gray-200 border border-gray-300 rounded transition-colors text-gray-700"
|
||||
:title="t('video.detailPage.copyValueTitle')">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z">
|
||||
</path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,8 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import type { ModelVideo } from '@/api/client';
|
||||
import { fetchMockVideoById, updateMockVideo } from '@/mocks/videos';
|
||||
import { client, type ManualAdTemplate, type ModelVideo } from '@/api/client';
|
||||
import { useAppToast } from '@/composables/useAppToast';
|
||||
import { ref, watch } from 'vue';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -14,17 +14,35 @@ const emit = defineEmits<{
|
||||
}>();
|
||||
|
||||
const toast = useAppToast();
|
||||
const auth = useAuthStore();
|
||||
const video = ref<ModelVideo | null>(null);
|
||||
const loading = ref(true);
|
||||
const saving = ref(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
type AdConfigPayload = {
|
||||
ad_template_id: string;
|
||||
template_name?: string;
|
||||
vast_tag_url?: string;
|
||||
ad_format?: string;
|
||||
duration?: number;
|
||||
};
|
||||
|
||||
const form = ref({
|
||||
title: '',
|
||||
description: '',
|
||||
adTemplateId: '' as string,
|
||||
});
|
||||
|
||||
const errors = ref<{ title?: string; description?: string }>({});
|
||||
const currentAdConfig = ref<AdConfigPayload | null>(null);
|
||||
const adTemplates = ref<ManualAdTemplate[]>([]);
|
||||
const loadingTemplates = ref(false);
|
||||
|
||||
const errors = ref<{ title?: string }>({});
|
||||
const isFreePlan = computed(() => !auth.user?.plan_id);
|
||||
|
||||
const activeTemplates = computed(() =>
|
||||
adTemplates.value.filter(t => t.is_active),
|
||||
);
|
||||
|
||||
const subtitleForm = ref({
|
||||
file: null as File | null,
|
||||
@@ -32,15 +50,33 @@ const subtitleForm = ref({
|
||||
displayName: '',
|
||||
});
|
||||
|
||||
const fetchAdTemplates = async () => {
|
||||
loadingTemplates.value = true;
|
||||
try {
|
||||
const response = await client.adTemplates.adTemplatesList({ baseUrl: '/r' });
|
||||
const items = ((response.data as any)?.data?.templates || []) as ManualAdTemplate[];
|
||||
adTemplates.value = items;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch ad templates:', error);
|
||||
} finally {
|
||||
loadingTemplates.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchVideo = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const videoData = await fetchMockVideoById(props.videoId);
|
||||
const response = await client.videos.videosDetail(props.videoId, { baseUrl: '/r' });
|
||||
const data = (response.data as any)?.data;
|
||||
const videoData = data?.video || data;
|
||||
const adConfig = data?.ad_config as AdConfigPayload | undefined;
|
||||
|
||||
if (videoData) {
|
||||
video.value = videoData;
|
||||
currentAdConfig.value = adConfig || null;
|
||||
form.value = {
|
||||
title: videoData.title || '',
|
||||
description: videoData.description || '',
|
||||
adTemplateId: adConfig?.ad_template_id || '',
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -68,11 +104,27 @@ const onFormSubmit = async () => {
|
||||
if (!validate()) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
await updateMockVideo(props.videoId, form.value);
|
||||
const payload: Record<string, any> = {
|
||||
title: form.value.title,
|
||||
};
|
||||
|
||||
if (video.value) {
|
||||
video.value.title = form.value.title;
|
||||
video.value.description = form.value.description;
|
||||
if (!isFreePlan.value) {
|
||||
payload.ad_template_id = form.value.adTemplateId || '';
|
||||
}
|
||||
|
||||
const response = await client.videos.videosUpdate(props.videoId, payload as any, { baseUrl: '/r' });
|
||||
|
||||
const data = (response.data as any)?.data;
|
||||
const updatedVideo = data?.video as ModelVideo | undefined;
|
||||
const updatedAdConfig = data?.ad_config as AdConfigPayload | undefined;
|
||||
|
||||
if (updatedVideo) {
|
||||
video.value = updatedVideo;
|
||||
currentAdConfig.value = updatedAdConfig || null;
|
||||
form.value = {
|
||||
title: updatedVideo.title || '',
|
||||
adTemplateId: updatedAdConfig?.ad_template_id || '',
|
||||
};
|
||||
}
|
||||
|
||||
toast.add({
|
||||
@@ -118,13 +170,14 @@ watch(() => props.videoId, (newId) => {
|
||||
if (newId) {
|
||||
errors.value = {};
|
||||
fetchVideo();
|
||||
fetchAdTemplates();
|
||||
}
|
||||
}, { immediate: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppDialog :visible="!!videoId" @update:visible="emit('close')" max-width-class="max-w-xl"
|
||||
:title="loading ? '' : t('video.detailModal.title')">
|
||||
:title="loading ? '' : $t('video.detailModal.title')">
|
||||
|
||||
<!-- Loading Skeleton -->
|
||||
<div v-if="loading" class="flex flex-col gap-4">
|
||||
@@ -133,10 +186,10 @@ watch(() => props.videoId, (newId) => {
|
||||
<div class="w-12 h-3.5 bg-gray-200 rounded animate-pulse" />
|
||||
<div class="w-full h-10 bg-gray-200 rounded-md animate-pulse" />
|
||||
</div>
|
||||
<!-- Description skeleton -->
|
||||
<!-- ad-template selector skeleton -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="w-20 h-3.5 bg-gray-200 rounded animate-pulse" />
|
||||
<div class="w-full h-24 bg-gray-200 rounded-md animate-pulse" />
|
||||
<div class="w-24 h-3.5 bg-gray-200 rounded animate-pulse" />
|
||||
<div class="w-full h-10 bg-gray-200 rounded-md animate-pulse" />
|
||||
</div>
|
||||
<!-- Subtitles section skeleton -->
|
||||
<div class="flex flex-col gap-3 border-t border-gray-200 pt-4">
|
||||
@@ -171,27 +224,47 @@ watch(() => props.videoId, (newId) => {
|
||||
<p v-if="errors.title" class="text-xs text-red-500 mt-0.5">{{ errors.title }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<!-- Ad Template Selector -->
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="edit-description" class="text-sm font-medium">{{ t('video.detailModal.descriptionLabel') }}</label>
|
||||
<textarea id="edit-description" v-model="form.description" :placeholder="t('video.detailModal.descriptionPlaceholder')"
|
||||
rows="4"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent resize-y" />
|
||||
<p v-if="errors.description" class="text-xs text-red-500 mt-0.5">{{ errors.description }}</p>
|
||||
<label for="edit-ad-template" class="text-sm font-medium">{{ t('video.detailModal.adTemplateLabel') }}</label>
|
||||
<select
|
||||
id="edit-ad-template"
|
||||
v-model="form.adTemplateId"
|
||||
:disabled="isFreePlan || saving"
|
||||
class="w-full px-3 py-2 border rounded-lg text-sm transition-colors focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
:class="isFreePlan
|
||||
? 'border-border bg-muted/50 text-foreground/50 cursor-not-allowed'
|
||||
: 'border-border bg-background text-foreground cursor-pointer hover:border-primary/50'"
|
||||
>
|
||||
<option value="">{{ t('video.detailModal.adTemplateNone') }}</option>
|
||||
<option
|
||||
v-for="tmpl in activeTemplates"
|
||||
:key="tmpl.id"
|
||||
:value="tmpl.id"
|
||||
>
|
||||
{{ tmpl.name }}{{ tmpl.is_default ? ` (${t('video.detailModal.adTemplateDefault')})` : '' }}
|
||||
</option>
|
||||
</select>
|
||||
<p v-if="isFreePlan" class="text-xs text-foreground/50 mt-0.5">
|
||||
{{ t('video.detailModal.adTemplateUpgradeHint') }}
|
||||
</p>
|
||||
<p v-else-if="!form.adTemplateId" class="text-xs text-foreground/50 mt-0.5">
|
||||
{{ t('video.detailModal.adTemplateNoAdsHint') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Subtitles Section -->
|
||||
<div class="flex flex-col gap-3 border-t-2 border-gray-200 pt-4">
|
||||
<div class="flex flex-col gap-3 border-t-2 border-border pt-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="text-sm font-medium">{{ t('video.detailModal.subtitlesTitle') }}</label>
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-muted text-foreground/70">
|
||||
{{ t('video.detailModal.subtitleTracks', { count: 0 }) }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground">{{ t('video.detailModal.noSubtitles') }}</p>
|
||||
|
||||
<!-- Upload Subtitle Form -->
|
||||
<div class="flex flex-col gap-3 rounded-lg border border-gray-200 p-3">
|
||||
<div class="flex flex-col gap-3 rounded-lg border border-border p-3">
|
||||
<label class="text-sm font-medium">{{ t('video.detailModal.uploadSubtitle') }}</label>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
@@ -224,7 +297,7 @@ watch(() => props.videoId, (newId) => {
|
||||
</div>
|
||||
|
||||
<!-- Footer inside Form so submit works -->
|
||||
<div class="flex justify-end gap-2 border-t border-gray-200 pt-4">
|
||||
<div class="flex justify-end gap-2 border-t border-border pt-4">
|
||||
<AppButton variant="ghost" type="button" @click="emit('close')">{{ t('video.detailModal.cancel') }}</AppButton>
|
||||
<AppButton type="submit" :loading="saving">{{ t('video.detailModal.saveChanges') }}</AppButton>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { type ModelVideo } from '@/api/client';
|
||||
import { client, type ModelVideo } from '@/api/client';
|
||||
import EmptyState from '@/components/dashboard/EmptyState.vue';
|
||||
import PageHeader from '@/components/dashboard/PageHeader.vue';
|
||||
import { fetchMockVideos } from '@/mocks/videos';
|
||||
import { createStaticVNode, computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
@@ -48,25 +47,18 @@ const fetchVideos = async () => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
// Attempt to fetch from API
|
||||
// const response = await client.videos.videosList({ page: page.value, limit: limit.value });
|
||||
// const body = response.data.data
|
||||
|
||||
// Use mock API
|
||||
const response = await fetchMockVideos({
|
||||
const response = await client.videos.videosList({
|
||||
page: page.value,
|
||||
limit: limit.value,
|
||||
searchQuery: searchQuery.value,
|
||||
status: selectedStatus.value
|
||||
});
|
||||
|
||||
videos.value = response.data;
|
||||
total.value = response.total;
|
||||
search: searchQuery.value || undefined,
|
||||
status: selectedStatus.value !== 'all' ? selectedStatus.value : undefined,
|
||||
} as any, { baseUrl: '/r' });
|
||||
|
||||
videos.value = ((response.data as any)?.data?.videos ?? []) as ModelVideo[];
|
||||
total.value = (response.data as any)?.data?.total ?? 0;
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
// Fallback to empty on error
|
||||
console.log('Using mock data due to API error');
|
||||
error.value = err?.response?.data?.message || err?.message || t('video.page.retry');
|
||||
videos.value = [];
|
||||
total.value = 0;
|
||||
} finally {
|
||||
@@ -84,11 +76,6 @@ const handleFilter = () => {
|
||||
fetchVideos();
|
||||
};
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
page.value = newPage;
|
||||
fetchVideos();
|
||||
};
|
||||
|
||||
// Selection Logic
|
||||
const selectedVideos = ref<ModelVideo[]>([]);
|
||||
|
||||
@@ -96,13 +83,22 @@ const deleteSelectedVideos = async () => {
|
||||
if (!selectedVideos.value.length || !confirm(t('video.page.deleteSelectedConfirm', { count: selectedVideos.value.length }))) return;
|
||||
|
||||
try {
|
||||
// Mock delete
|
||||
const idsToDelete = selectedVideos.value.map(v => v.id);
|
||||
videos.value = videos.value.filter(v => v.id && !idsToDelete.includes(v.id));
|
||||
await Promise.all(
|
||||
selectedVideos.value
|
||||
.map(v => v.id)
|
||||
.filter((id): id is string => Boolean(id))
|
||||
.map(id => client.videos.videosDelete(id, { baseUrl: '/r' }))
|
||||
);
|
||||
selectedVideos.value = [];
|
||||
// In real app: await client.videos.bulkDelete(...) or loop
|
||||
await fetchVideos();
|
||||
} catch (err) {
|
||||
console.error('Failed to delete videos', err);
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('video.detailPage.toast.deleteErrorSummary'),
|
||||
detail: t('video.detailPage.toast.deleteErrorDetail'),
|
||||
life: 3000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -110,11 +106,17 @@ const deleteVideo = async (videoId?: string) => {
|
||||
if (!videoId || !confirm(t('video.page.deleteSingleConfirm'))) return;
|
||||
|
||||
try {
|
||||
videos.value = videos.value.filter(v => v.id !== videoId);
|
||||
// If deleted video was in selection, remove it
|
||||
await client.videos.videosDelete(videoId, { baseUrl: '/r' });
|
||||
selectedVideos.value = selectedVideos.value.filter(v => v.id !== videoId);
|
||||
await fetchVideos();
|
||||
} catch (err) {
|
||||
console.error('Failed to delete video:', err);
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('video.detailPage.toast.deleteErrorSummary'),
|
||||
detail: t('video.detailPage.toast.deleteErrorDetail'),
|
||||
life: 3000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -130,11 +132,11 @@ watch(() => uiState.uploadDialogVisible, (visible) => {
|
||||
}
|
||||
});
|
||||
|
||||
watch([searchQuery, selectedStatus, limit, page], () => {
|
||||
watch([selectedStatus, limit, page], () => {
|
||||
fetchVideos();
|
||||
});
|
||||
const editVideo = (videoId?: string) => {
|
||||
detailVideoId.value = videoId || '';
|
||||
detailVideoId.value = videoId || "";
|
||||
};
|
||||
|
||||
const copyVideo = (videoId?: string) => {
|
||||
@@ -257,7 +259,7 @@ onUnmounted(() => {
|
||||
<EmptyState v-else-if="videos.length === 0 && !loading" :title="t('video.page.emptyTitle')"
|
||||
:description="t('video.page.emptyDescription')"
|
||||
imageUrl="https://cdn-icons-png.flaticon.com/512/7486/7486747.png" :actionLabel="t('video.page.emptyAction')"
|
||||
:onAction="() => router.push('/upload')" />
|
||||
:onAction="() => uiState.toggleUploadDialog()" />
|
||||
<!-- Grid View -->
|
||||
<!-- <VideoGrid :videos="videos" :loading="loading" v-model:selectedVideos="selectedVideos" @delete="deleteVideo" v-else-if="viewMode === 'grid'" /> -->
|
||||
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
|
||||
defineProps<{
|
||||
title: string;
|
||||
description: string;
|
||||
saving: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:title': [value: string];
|
||||
'update:description': [value: string];
|
||||
save: [];
|
||||
toggleEdit: [];
|
||||
}>();
|
||||
|
||||
const { t } = useTranslation();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mb-4 space-y-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('video.detailModal.titleLabel') }}</label>
|
||||
<input :value="title" type="text"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
:placeholder="t('video.detailModal.titlePlaceholder')"
|
||||
@input="$emit('update:title', ($event.target as HTMLInputElement).value)">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('video.detailModal.descriptionLabel') }}</label>
|
||||
<textarea :value="description" rows="3"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
:placeholder="t('video.detailModal.descriptionPlaceholder')"
|
||||
@input="$emit('update:description', ($event.target as HTMLTextAreaElement).value)"></textarea>
|
||||
</div>
|
||||
<div class="float-right flex gap-2">
|
||||
<AppButton size="sm"
|
||||
:title="t('video.detailModal.saveChanges')" :disabled="saving" @click="$emit('save')">
|
||||
<svg v-if="!saving" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
||||
</svg>
|
||||
<span v-if="saving"
|
||||
class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></span>
|
||||
<span class="hidden sm:inline">{{ saving ? t('video.detailPage.saving') : t('common.save') }}</span>
|
||||
</AppButton>
|
||||
|
||||
<!-- Cancel Button (Edit Mode) -->
|
||||
<AppButton variant="danger" size="sm" :title="t('video.detailPage.cancelEditTitle')"
|
||||
@click="$emit('toggleEdit')">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12">
|
||||
</path>
|
||||
</svg>
|
||||
<span class="hidden sm:inline">{{ t('common.cancel') }}</span>
|
||||
</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,125 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { ModelVideo } from '@/api/client';
|
||||
import { formatBytes, getStatusSeverity } from '@/lib/utils';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
video: ModelVideo;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
reload: [];
|
||||
toggleEdit: [];
|
||||
delete: [];
|
||||
}>();
|
||||
|
||||
const { t, i18next } = useTranslation();
|
||||
|
||||
const formatFileSize = (bytes?: number): string => {
|
||||
if (!bytes) return '-';
|
||||
return formatBytes(bytes);
|
||||
};
|
||||
|
||||
const formatDuration = (seconds?: number): string => {
|
||||
if (!seconds) return '-';
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const mins = Math.floor((seconds % 3600) / 60);
|
||||
const secs = seconds % 60;
|
||||
if (hours > 0) {
|
||||
return `${hours}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const formatDate = (dateStr?: string): string => {
|
||||
if (!dateStr) return '-';
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleString(i18next.resolvedLanguage === 'vi' ? 'vi-VN' : 'en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
const severityClasses: Record<string, string> = {
|
||||
success: 'bg-green-100 text-green-800',
|
||||
info: 'bg-blue-100 text-blue-800',
|
||||
warn: 'bg-yellow-100 text-yellow-800',
|
||||
warning: 'bg-yellow-100 text-yellow-800',
|
||||
danger: 'bg-red-100 text-red-800',
|
||||
secondary: 'bg-gray-100 text-gray-800',
|
||||
};
|
||||
|
||||
const statusLabel = computed(() => {
|
||||
switch (props.video.status) {
|
||||
case 'ready':
|
||||
return t('video.filters.ready');
|
||||
case 'processing':
|
||||
return t('video.filters.processing');
|
||||
case 'failed':
|
||||
return t('video.filters.failed');
|
||||
default:
|
||||
return props.video.status;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col items-start justify-between mb-4 gap-4">
|
||||
<div class="flex-1">
|
||||
<!-- View Mode: Title -->
|
||||
<div class="mb-2">
|
||||
<h1 class="text-2xl font-bold text-gray-900 mb-1">
|
||||
{{ video.title }}
|
||||
</h1>
|
||||
<p v-if="video.description" class="text-sm text-gray-600 whitespace-pre-wrap">{{ video.description }}
|
||||
</p>
|
||||
</div>
|
||||
<!-- Metadata -->
|
||||
<div class="flex items-center space-x-4 text-sm text-gray-500">
|
||||
<span>{{ formatDate(video.created_at) }}</span>
|
||||
<span>{{ formatFileSize(video.size) }}</span>
|
||||
<span>{{ formatDuration(video.duration) }}</span>
|
||||
<span
|
||||
class="capitalize px-2 py-0.5 text-xs font-medium rounded-full"
|
||||
:class="severityClasses[getStatusSeverity(video.status) || 'secondary']">
|
||||
{{ statusLabel }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex items-center space-x-2">
|
||||
<AppButton size="sm" variant="secondary"
|
||||
:title="t('video.detailPage.reloadTitle')" @click="$emit('reload')">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15">
|
||||
</path>
|
||||
</svg>
|
||||
<span class="hidden sm:inline">{{ t('video.detailPage.reloadButton') }}</span>
|
||||
</AppButton>
|
||||
<AppButton size="sm" variant="ghost"
|
||||
:title="t('video.table.edit')" @click="$emit('toggleEdit')">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z">
|
||||
</path>
|
||||
</svg>
|
||||
<span class="hidden sm:inline">{{ t('video.table.edit') }}</span>
|
||||
</AppButton>
|
||||
<AppButton variant="danger" size="sm"
|
||||
:title="t('video.table.delete')" @click="$emit('delete')">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16">
|
||||
</path>
|
||||
</svg>
|
||||
<span class="hidden sm:inline">{{ t('video.table.delete') }}</span>
|
||||
</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,64 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { ModelVideo } from '@/api/client';
|
||||
import { computed } from 'vue';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
|
||||
const props = defineProps<{
|
||||
video: ModelVideo;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
copy: [text: string, label: string];
|
||||
}>();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleCopy = (text: string, label: string) => {
|
||||
emit('copy', text, label);
|
||||
};
|
||||
const origin = computed(() => typeof window !== 'undefined' ? window.location.origin : '');
|
||||
|
||||
const videoInfos = computed(() => {
|
||||
if (!props.video) return [];
|
||||
const embedUrl = `${origin.value}/embed/${props.video.id}`;
|
||||
|
||||
return [
|
||||
{ label: t('video.detailPage.videoInfo.videoId'), value: props.video.id },
|
||||
{ label: t('video.detailPage.videoInfo.thumbnailUrl'), value: props.video.thumbnail },
|
||||
{ label: t('video.detailPage.videoInfo.embedUrl'), value: embedUrl },
|
||||
{ label: t('video.detailPage.videoInfo.iframeCode'), value: embedUrl ? `<iframe src="${embedUrl}" title="${props.video.title}" width="100%" height="400" frameborder="0" allowfullscreen></iframe>` : '' },
|
||||
{ label: t('video.detailPage.videoInfo.shareLink'), value: `${origin.value}/view/${props.video.id}` },
|
||||
];
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="">
|
||||
<div class="mb-4">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-4">{{ t('video.detailPage.detailsTitle') }}</h3>
|
||||
<div class="flex flex-col gap-2">
|
||||
<dl v-for="info in videoInfos" :key="info.label" class="space-y-2">
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">{{ info.label }}</dt>
|
||||
<dd class="text-sm text-gray-900">
|
||||
<div class="flex items-center space-x-2">
|
||||
<input readonly
|
||||
class="flex-1 px-2 py-1 text-xs border border-gray-300 rounded bg-gray-50 font-mono"
|
||||
:value="info.value || '-'">
|
||||
<button v-if="info.value" @click="handleCopy(info.value, info.label)"
|
||||
class="px-2 py-1 text-xs bg-gray-100 hover:bg-gray-200 border border-gray-300 rounded transition-colors text-gray-700"
|
||||
:title="t('video.detailPage.copyValueTitle')">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z">
|
||||
</path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,34 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { ModelVideo } from '@/api/client';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
|
||||
defineProps<{
|
||||
video: ModelVideo;
|
||||
}>();
|
||||
|
||||
const { t } = useTranslation();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rounded-xl">
|
||||
<div v-if="video.url" class="aspect-video rounded-xl bg-black overflow-hidden">
|
||||
<video
|
||||
:src="video.url"
|
||||
controls
|
||||
class="w-full h-full object-contain"
|
||||
:poster="video.thumbnail">
|
||||
{{ t('video.detailPage.videoTagFallback') }}
|
||||
</video>
|
||||
</div>
|
||||
<div v-else class="w-full h-48 bg-gray-200 overflow-hidden flex-shrink-0">
|
||||
<img
|
||||
v-if="video.thumbnail"
|
||||
:src="video.thumbnail"
|
||||
:alt="video.title"
|
||||
class="w-full h-full object-cover" />
|
||||
<div v-else class="w-full h-full flex items-center justify-center">
|
||||
<span class="i-heroicons-film text-gray-400 text-2xl" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,44 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-col lg:flex-row gap-4">
|
||||
<!-- Video Player Skeleton -->
|
||||
<div class="md:flex-1 aspect-video rounded-xl bg-gray-200 animate-pulse" />
|
||||
|
||||
<!-- Info Card Skeleton -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-6 space-y-6">
|
||||
<!-- Header Skeleton -->
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div class="flex-1 space-y-3">
|
||||
<div class="w-3/5 h-8 bg-gray-200 rounded animate-pulse" />
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-32 h-4 bg-gray-200 rounded animate-pulse" />
|
||||
<div class="w-20 h-4 bg-gray-200 rounded animate-pulse" />
|
||||
<div class="w-16 h-4 bg-gray-200 rounded animate-pulse" />
|
||||
<div class="w-16 h-6 bg-gray-200 rounded animate-pulse" />
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-20 h-8 bg-gray-200 rounded animate-pulse" />
|
||||
<div class="w-16 h-8 bg-gray-200 rounded animate-pulse" />
|
||||
<div class="w-[4.5rem] h-8 bg-gray-200 rounded animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content Grid Skeleton -->
|
||||
<div class="grid grid-cols-1">
|
||||
<!-- Left Column -->
|
||||
<div class="space-y-4">
|
||||
<div class="w-full h-6 bg-gray-200 rounded animate-pulse" />
|
||||
<div class="space-y-3">
|
||||
<div v-for="i in 6" :key="i" class="space-y-1">
|
||||
<div class="w-full h-3.5 bg-gray-200 rounded animate-pulse" />
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-full h-7 bg-gray-200 rounded animate-pulse" />
|
||||
<div class="w-8 h-7 bg-gray-200 rounded animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user