add change language
This commit is contained in:
@@ -5,26 +5,28 @@ import Video from "@/components/icons/Video.vue";
|
||||
import SettingsIcon from "@/components/icons/SettingsIcon.vue";
|
||||
// import Upload from "@/components/icons/Upload.vue";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { createStaticVNode, ref } from "vue";
|
||||
import { computed, createStaticVNode, ref } from "vue";
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import NotificationDrawer from "./NotificationDrawer.vue";
|
||||
|
||||
const className = ":uno: w-12 h-12 p-2 rounded-2xl hover:bg-primary/15 flex press-animated items-center justify-center shrink-0";
|
||||
const homeHoist = createStaticVNode(`<img class="h-8 w-8" src="/apple-touch-icon.png" alt="Logo" />`, 1);
|
||||
const notificationPopover = ref<InstanceType<typeof NotificationDrawer>>();
|
||||
const isNotificationOpen = ref(false);
|
||||
const { t } = useI18n();
|
||||
|
||||
const handleNotificationClick = (event: Event) => {
|
||||
notificationPopover.value?.toggle(event);
|
||||
};
|
||||
|
||||
const links = [
|
||||
const links = computed(() => [
|
||||
{ href: "/#home", label: "app", icon: homeHoist, type: "btn", className },
|
||||
{ href: "/", label: "Overview", icon: Home, type: "a", className },
|
||||
// { href: "/upload", label: "Upload", icon: Upload, type: "a", className },
|
||||
{ href: "/videos", label: "Videos", icon: Video, type: "a", className },
|
||||
{ href: "/notification", label: "Notification", icon: Bell, type: "btn", className, action: handleNotificationClick, isActive: isNotificationOpen },
|
||||
{ href: "/settings", label: "Settings", icon: SettingsIcon, type: "a", className },
|
||||
];
|
||||
{ href: "/", label: t('nav.overview'), icon: Home, type: "a", className },
|
||||
// { href: "/upload", label: t('common.upload'), icon: Upload, type: "a", className },
|
||||
{ href: "/videos", label: t('nav.videos'), icon: Video, type: "a", className },
|
||||
{ href: "/notification", label: t('nav.notification'), icon: Bell, type: "btn", className, action: handleNotificationClick, isActive: isNotificationOpen },
|
||||
{ href: "/settings", label: t('nav.settings'), icon: SettingsIcon, type: "a", className },
|
||||
]);
|
||||
|
||||
|
||||
//v-tooltip="i.label"
|
||||
@@ -34,7 +36,7 @@ const links = [
|
||||
<header
|
||||
class=":uno: fixed left-0 flex flex-col items-center pt-4 gap-6 z-41 max-h-screen h-screen bg-muted transition-all duration-300 ease-in-out w-18 items-center">
|
||||
|
||||
<template v-for="i in links" :key="i.label">
|
||||
<template v-for="i in links" :key="i.href">
|
||||
<component :name="i.label" :is="i.type === 'a' ? 'router-link' : 'div'"
|
||||
v-bind="i.type === 'a' ? { to: i.href } : {}"
|
||||
@click="i.action && i.action($event)"
|
||||
|
||||
@@ -3,11 +3,13 @@ import { useUploadQueue } from '@/composables/useUploadQueue';
|
||||
import UploadQueueItem from '@/routes/upload/components/UploadQueueItem.vue';
|
||||
import { useUIState } from '@/stores/uiState';
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const router = useRouter();
|
||||
const { items, completeCount, pendingCount, startQueue, removeItem, cancelItem, removeAll } = useUploadQueue();
|
||||
const uiState = useUIState();
|
||||
const { t } = useI18n();
|
||||
|
||||
const isCollapsed = ref(false);
|
||||
|
||||
@@ -28,13 +30,13 @@ const isAllDone = computed(() =>
|
||||
);
|
||||
|
||||
const statusText = computed(() => {
|
||||
if (isAllDone.value) return 'All done';
|
||||
if (isAllDone.value) return t('upload.indicator.allDone');
|
||||
if (isUploading.value) {
|
||||
const count = items.value.filter(i => i.status === 'uploading' || i.status === 'fetching').length;
|
||||
return `Uploading ${count} file${count !== 1 ? 's' : ''}...`;
|
||||
return t('upload.indicator.uploading', { count });
|
||||
}
|
||||
if (pendingCount.value > 0) return `${pendingCount.value} file${pendingCount.value !== 1 ? 's' : ''} waiting`;
|
||||
return 'Processing...';
|
||||
if (pendingCount.value > 0) return t('upload.indicator.waiting', { count: pendingCount.value });
|
||||
return t('upload.queueItem.status.processing');
|
||||
});
|
||||
const isDoneWithErrors = computed(() =>
|
||||
isAllDone.value &&
|
||||
@@ -87,7 +89,7 @@ watch(isAllDone, (newItems) => {
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-semibold leading-tight truncate">{{ statusText }}</p>
|
||||
<p class="text-xs text-slate-400 leading-tight mt-0.5">
|
||||
{{ completeCount }} of {{ items.length }} complete
|
||||
{{ t('upload.indicator.completeProgress', { complete: completeCount, total: items.length }) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -100,17 +102,17 @@ watch(isAllDone, (newItems) => {
|
||||
stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polygon points="5 3 19 12 5 21 5 3" />
|
||||
</svg>
|
||||
Start
|
||||
{{ t('upload.indicator.start') }}
|
||||
</button>
|
||||
<button v-else-if="isDoneWithErrors" @click.stop="doneUpload"
|
||||
class="flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 bg-green-500 hover:bg-green-500/80 text-white rounded-lg transition-all">
|
||||
View Videos
|
||||
{{ t('upload.indicator.viewVideos') }}
|
||||
</button>
|
||||
<!-- Clear queue -->
|
||||
<!-- Add more files -->
|
||||
<button @click.stop="uiState.uploadDialogVisible = true"
|
||||
class="w-7 h-7 flex items-center justify-center text-slate-400 hover:text-white hover:bg-white/10 rounded-lg transition-all"
|
||||
title="Add more files">
|
||||
:title="t('upload.indicator.addMoreFiles')">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M5 12h14" />
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import NotificationItem from '@/routes/notification/components/NotificationItem.vue';
|
||||
import { onClickOutside } from '@vueuse/core';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
// Ensure client-side only rendering to avoid hydration mismatch
|
||||
const isMounted = ref(false);
|
||||
@@ -27,50 +28,57 @@ interface Notification {
|
||||
|
||||
const visible = ref(false);
|
||||
const drawerRef = ref(null);
|
||||
const { t } = useI18n();
|
||||
|
||||
// Mock notifications data
|
||||
const notifications = ref<Notification[]>([
|
||||
const notifications = computed<Notification[]>(() => [
|
||||
{
|
||||
id: '1',
|
||||
type: 'video',
|
||||
title: 'Video processing complete',
|
||||
message: 'Your video "Summer Vacation 2024" has been successfully processed.',
|
||||
time: '2 min ago',
|
||||
title: t('notification.mocks.videoProcessed.title'),
|
||||
message: t('notification.mocks.videoProcessed.message'),
|
||||
time: t('notification.time.minutesAgo', { count: 2 }),
|
||||
read: false,
|
||||
actionUrl: '/video',
|
||||
actionLabel: 'View'
|
||||
actionLabel: t('notification.actions.viewVideo')
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'payment',
|
||||
title: 'Payment successful',
|
||||
message: 'Your subscription to Pro Plan has been renewed successfully.',
|
||||
time: '1 hour ago',
|
||||
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: 'Receipt'
|
||||
actionLabel: t('notification.actions.viewReceipt')
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
type: 'warning',
|
||||
title: 'Storage almost full',
|
||||
message: 'You have used 85% of your storage quota.',
|
||||
time: '3 hours ago',
|
||||
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: 'Upgrade'
|
||||
actionLabel: t('notification.actions.upgradePlan')
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
type: 'success',
|
||||
title: 'Upload successful',
|
||||
message: 'Your video "Product Demo v2" has been uploaded.',
|
||||
time: '1 day ago',
|
||||
title: t('notification.mocks.uploadSuccess.title'),
|
||||
message: t('notification.mocks.uploadSuccess.message'),
|
||||
time: t('notification.time.daysAgo', { count: 1 }),
|
||||
read: true
|
||||
}
|
||||
]);
|
||||
|
||||
const unreadCount = computed(() => notifications.value.filter(n => !n.read).length);
|
||||
const mutableNotifications = ref<Notification[]>([]);
|
||||
|
||||
watch(notifications, (value) => {
|
||||
mutableNotifications.value = value.map(item => ({ ...item }));
|
||||
}, { immediate: true });
|
||||
|
||||
const unreadCount = computed(() => mutableNotifications.value.filter(n => !n.read).length);
|
||||
|
||||
const toggle = (event?: Event) => {
|
||||
console.log(event);
|
||||
@@ -107,16 +115,16 @@ onClickOutside(drawerRef, (event) => {
|
||||
});
|
||||
|
||||
const handleMarkRead = (id: string) => {
|
||||
const notification = notifications.value.find(n => n.id === id);
|
||||
const notification = mutableNotifications.value.find(n => n.id === id);
|
||||
if (notification) notification.read = true;
|
||||
};
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
notifications.value = notifications.value.filter(n => n.id !== id);
|
||||
mutableNotifications.value = mutableNotifications.value.filter(n => n.id !== id);
|
||||
};
|
||||
|
||||
const handleMarkAllRead = () => {
|
||||
notifications.value.forEach(n => n.read = true);
|
||||
mutableNotifications.value.forEach(n => n.read = true);
|
||||
};
|
||||
|
||||
watch(visible, (val) => {
|
||||
@@ -137,7 +145,7 @@ defineExpose({ toggle });
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between p-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="font-semibold text-gray-900">Notifications</h3>
|
||||
<h3 class="font-semibold text-gray-900">{{ t('notification.title') }}</h3>
|
||||
<span v-if="unreadCount > 0"
|
||||
class="px-2 py-0.5 text-xs font-medium bg-primary text-white rounded-full">
|
||||
{{ unreadCount }}
|
||||
@@ -145,14 +153,14 @@ defineExpose({ toggle });
|
||||
</div>
|
||||
<button v-if="unreadCount > 0" @click="handleMarkAllRead"
|
||||
class="text-sm text-primary hover:underline font-medium">
|
||||
Mark all read
|
||||
{{ t('notification.actions.markAllRead') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Notification List -->
|
||||
<div class="flex flex-col flex-1 overflow-y-auto gap-2">
|
||||
<template v-if="notifications.length > 0">
|
||||
<div v-for="notification in notifications" :key="notification.id"
|
||||
<template v-if="mutableNotifications.length > 0">
|
||||
<div v-for="notification in mutableNotifications" :key="notification.id"
|
||||
class="border-b border-gray-50 last:border-0">
|
||||
<NotificationItem :notification="notification" @mark-read="handleMarkRead"
|
||||
@delete="handleDelete" isDrawer />
|
||||
@@ -162,16 +170,16 @@ defineExpose({ toggle });
|
||||
<!-- Empty state -->
|
||||
<div v-else class="py-12 text-center">
|
||||
<span class="i-lucide-bell-off w-12 h-12 text-gray-300 mx-auto block mb-3"></span>
|
||||
<p class="text-gray-500 text-sm">No notifications</p>
|
||||
<p class="text-gray-500 text-sm">{{ t('notification.empty.title') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div v-if="notifications.length > 0" class="p-3 border-t border-gray-100 bg-gray-50/50">
|
||||
<div v-if="mutableNotifications.length > 0" class="p-3 border-t border-gray-100 bg-gray-50/50">
|
||||
<router-link to="/notification"
|
||||
class="block w-full text-center text-sm text-primary font-medium hover:underline"
|
||||
@click="visible = false">
|
||||
View all notifications
|
||||
{{ t('notification.actions.viewAll') }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import XIcon from '@/components/icons/XIcon.vue';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
// Ensure client-side only rendering to avoid hydration mismatch
|
||||
const isMounted = ref(false);
|
||||
@@ -25,6 +26,8 @@ const emit = defineEmits<{
|
||||
(e: 'close'): void;
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const close = () => {
|
||||
emit('update:visible', false);
|
||||
emit('close');
|
||||
@@ -87,7 +90,7 @@ onBeforeUnmount(() => {
|
||||
type="button"
|
||||
class="p-1 rounded-md text-foreground/60 hover:text-foreground hover:bg-muted/50 transition-all"
|
||||
@click="close"
|
||||
aria-label="Close"
|
||||
:aria-label="t('common.close')"
|
||||
>
|
||||
<XIcon class="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
@@ -6,9 +6,11 @@ import XCircleIcon from '@/components/icons/XCircleIcon.vue';
|
||||
import XIcon from '@/components/icons/XIcon.vue';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { onBeforeUnmount, watchEffect } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAppToast, type AppToastSeverity } from '@/composables/useAppToast';
|
||||
|
||||
const { toasts, remove } = useAppToast();
|
||||
const { t } = useI18n();
|
||||
|
||||
const timers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
|
||||
@@ -91,7 +93,7 @@ onBeforeUnmount(() => {
|
||||
type="button"
|
||||
class="p-1 rounded-md text-foreground/50 hover:text-foreground hover:bg-muted/50 transition-all"
|
||||
@click="dismiss(t.id)"
|
||||
aria-label="Dismiss"
|
||||
:aria-label="t('toast.dismissAria')"
|
||||
>
|
||||
<XIcon class="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { VNode } from 'vue';
|
||||
|
||||
interface Trend {
|
||||
@@ -18,6 +19,8 @@ withDefaults(defineProps<Props>(), {
|
||||
color: 'primary'
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
// const gradients = {
|
||||
// primary: 'from-primary/20 to-primary/5',
|
||||
// success: 'from-success/20 to-success/5',
|
||||
@@ -76,7 +79,7 @@ const iconColors = {
|
||||
</svg>
|
||||
{{ Math.abs(trend.value) }}%
|
||||
</span>
|
||||
<span class="text-gray-500">vs last month</span>
|
||||
<span class="text-gray-500">{{ t('overview.stats.trendVsLastMonth') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user