This commit is contained in:
2026-03-11 02:43:33 +00:00
parent edc1a33547
commit dc06412f79
61 changed files with 7408 additions and 1606 deletions

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRouteLoading } from '@/composables/useRouteLoading'
const { visible, progress } = useRouteLoading()
const barStyle = computed(() => ({
transform: `scaleX(${progress.value / 100})`,
opacity: visible.value ? '1' : '0',
}))
</script>
<template>
<div
class="pointer-events-none fixed inset-x-0 top-0 z-[9999] h-0.75"
aria-hidden="true"
>
<div
class="h-full origin-left rounded-r-full bg-primary/50 shadow-[0_0_12px_var(--colors-primary-DEFAULT)] transition-[transform,opacity] duration-200 ease-out"
:style="barStyle"
/>
</div>
</template>

View File

@@ -1,130 +1,51 @@
<script setup lang="ts">
import NotificationItem from '@/routes/notification/components/NotificationItem.vue';
import { useNotifications } from '@/composables/useNotifications';
import { onClickOutside } from '@vueuse/core';
import { computed, onMounted, ref, watch } from 'vue';
import { useTranslation } from 'i18next-vue';
// Ensure client-side only rendering to avoid hydration mismatch
const isMounted = ref(false);
onMounted(() => {
isMounted.value = true;
void notificationStore.fetchNotifications();
});
// Emit event when visibility changes
const emit = defineEmits(['change']);
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 visible = ref(false);
const drawerRef = ref(null);
const { t } = useTranslation();
const notificationStore = useNotifications();
// Mock notifications data
const notifications = computed<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
}
]);
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 unreadCount = computed(() => notificationStore.unreadCount.value);
const mutableNotifications = computed(() => notificationStore.notifications.value.slice(0, 8));
const toggle = (event?: Event) => {
console.log(event);
// Prevent event propagation to avoid immediate closure by onClickOutside
if (event) {
// We don't stop propagation here to let other listeners work,
// but we might need to ignore the trigger element in onClickOutside
// However, since the trigger is outside this component, simple toggle logic works
// if we use a small delay or ignore ref.
// Best approach: "toggle" usually comes from a button click.
}
visible.value = !visible.value;
console.log(visible.value);
if (visible.value && !notificationStore.loaded.value) {
void notificationStore.fetchNotifications();
}
};
// Handle click outside
onClickOutside(drawerRef, (event) => {
// We can just set visible to false.
// Note: If the toggle button is clicked, it might toggle it back on immediately
// if the click event propagates.
// The user calls `toggle` from the parent's button click handler.
// If that button is outside `drawerRef` (which it is), this will fire.
// To avoid conflict, we usually check if the target is the trigger.
// But we don't have access to the trigger ref here.
// A common workaround is to use `ignore` option if we had the ref,
// or relying on the fact that if this fires, it sets specific state to false.
// If the button click then fires `toggle`, it might set it true again.
// Optimization: check if visible is true before closing.
onClickOutside(drawerRef, () => {
if (visible.value) {
visible.value = false;
}
}, {
ignore: ['[name="Notification"]'] // Assuming the trigger button has this class or we can suggest adding a class to the trigger
ignore: ['[name="Notification"]']
});
const handleMarkRead = (id: string) => {
const notification = mutableNotifications.value.find(n => n.id === id);
if (notification) notification.read = true;
const handleMarkRead = async (id: string) => {
await notificationStore.markRead(id);
};
const handleDelete = (id: string) => {
mutableNotifications.value = mutableNotifications.value.filter(n => n.id !== id);
const handleDelete = async (id: string) => {
await notificationStore.deleteNotification(id);
};
const handleMarkAllRead = () => {
mutableNotifications.value.forEach(n => n.read = true);
const handleMarkAllRead = async () => {
await notificationStore.markAllRead();
};
watch(visible, (val) => {
@@ -142,7 +63,6 @@ defineExpose({ toggle });
leave-to-class="opacity-0 -translate-x-4">
<div v-if="visible" ref="drawerRef"
class="fixed top-0 left-[80px] bottom-0 w-[380px] bg-white rounded-2xl border border-gray-300 p-3 z-50 flex flex-col shadow-lg my-3">
<!-- Header -->
<div class="flex items-center justify-between p-4">
<div class="flex items-center gap-2">
<h3 class="font-semibold text-gray-900">{{ t('notification.title') }}</h3>
@@ -157,9 +77,19 @@ defineExpose({ toggle });
</button>
</div>
<!-- Notification List -->
<div class="flex flex-col flex-1 overflow-y-auto gap-2">
<template v-if="mutableNotifications.length > 0">
<template v-if="notificationStore.loading.value">
<div v-for="i in 4" :key="i" class="p-4 rounded-xl border border-gray-200 animate-pulse">
<div class="flex items-start gap-4">
<div class="w-10 h-10 rounded-full bg-gray-200"></div>
<div class="flex-1 space-y-2">
<div class="h-4 bg-gray-200 rounded w-1/3"></div>
<div class="h-3 bg-gray-200 rounded w-2/3"></div>
</div>
</div>
</div>
</template>
<template v-else-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"
@@ -167,14 +97,12 @@ defineExpose({ toggle });
</div>
</template>
<!-- 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">{{ t('notification.empty.title') }}</p>
</div>
</div>
<!-- Footer -->
<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"
@@ -186,16 +114,3 @@ defineExpose({ toggle });
</Transition>
</Teleport>
</template>
<!-- <style>
.notification-popover {
border-radius: 16px !important;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.12) !important;
border: 1px solid rgba(0, 0, 0, 0.08) !important;
overflow: hidden;
}
.notification-popover .p-popover-content {
padding: 0 !important;
}
</style> -->

View File

@@ -1,3 +1,10 @@
<template>
<ClientOnly>
<AppTopLoadingBar />
</ClientOnly>
<router-view/>
</template>
</template>
<script setup lang="ts">
import ClientOnly from '@/components/ClientOnly';
import AppTopLoadingBar from '@/components/AppTopLoadingBar.vue'
</script>

View File

@@ -1,5 +1,5 @@
<template>
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 468 532"><path d="M10 391c0 19 16 35 36 35h376c20 0 36-16 36-35 0-9-3-16-8-23l-10-12c-30-37-46-84-46-132v-22c0-77-55-142-128-157v-3c0-18-14-32-32-32s-32 14-32 32v3C129 60 74 125 74 202v22c0 48-16 95-46 132l-10 12c-5 7-8 14-8 23z" fill="#a6acb9"/><path d="M172 474c7 28 32 48 62 48s55-20 62-48H172z" fill="#1e3050"/></svg>
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 468 532"><path d="M10 391c0 19 16 35 36 35h376c20 0 36-16 36-35 0-9-3-16-8-23l-10-12c-30-37-46-84-46-132v-22c0-77-55-142-128-157v-3c0-18-14-32-32-32s-32 14-32 32v3C129 60 74 125 74 202v22c0 48-16 95-46 132l-10 12c-5 7-8 14-8 23z" fill="color-mix(in srgb, var(--colors-primary-DEFAULT) 40%, transparent)"/><path d="M172 474c7 28 32 48 62 48s55-20 62-48H172z" fill="var(--colors-primary-DEFAULT)"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" v-else viewBox="-10 -258 468 532">
<path
d="M224-248c-13 0-24 11-24 24v10C119-203 56-133 56-48v15C56 4 46 41 27 74L5 111c-3 6-5 13-5 19 0 21 17 38 38 38h372c21 0 38-17 38-38 0-6-2-13-5-19l-22-37c-19-33-29-70-29-108v-14c0-85-63-155-144-166v-10c0-13-11-24-24-24zm168 368H56l12-22c24-40 36-85 36-131v-15c0-66 54-120 120-120s120 54 120 120v15c0 46 12 91 36 131l12 22zm-236 96c10 28 37 48 68 48s58-20 68-48H156z"
@@ -8,4 +8,4 @@
</template>
<script lang="ts" setup>
defineProps<{ filled?: boolean }>();
</script>
</script>

View File

@@ -5,9 +5,6 @@ defineProps<{
</script>
<template>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="9"/>
<path d="M12 16V8"/>
<path d="M9.5 10a2.5 2.5 0 0 1 5 0v4a2.5 2.5 0 0 1-5 0"/>
</svg>
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 532"><path d="M26 427c0-121 70-194 157-273h134c87 79 157 152 157 273 0 44-35 79-79 79H105c-44 0-79-35-79-79zM138 42c0-9 7-16 16-16h192c9 0 16 7 16 16 0 3 0 5-2 8l-46 88H187l-47-88c-1-3-2-5-2-8zm56 267c0 21 15 38 36 42l38 6c13 2 22 13 22 26 0 15-12 27-27 27h-53c-4 0-8 4-8 8s4 8 8 8h32v16c0 4 4 8 8 8s8-4 8-8v-16h5c24 0 43-19 43-43 0-20-15-38-36-42l-38-6c-12-2-22-13-22-26 0-15 12-27 27-27h45c4 0 8-3 8-8 0-4-4-8-8-8h-24v-16c0-4-4-8-8-8s-8 4-8 8v16h-5c-24 0-43 19-43 43z" fill="#a6acb9"/><path d="M346 26c9 0 16 7 16 16 0 3-1 5-2 8l-46 88H187l-47-88c-1-3-2-5-2-8 0-9 7-16 16-16h192zM126 57l45 86C85 222 10 299 10 427c0 52 43 95 95 95h290c52 0 95-43 95-95 0-128-75-205-161-284l45-86c3-5 4-10 4-15 0-18-14-32-32-32H154c-18 0-32 14-32 32 0 5 1 10 4 15zM26 427c0-121 70-194 157-273h134c87 79 157 152 157 273 0 44-35 79-79 79H105c-44 0-79-35-79-79zm224-185c-4 0-8 4-8 8v16h-5c-24 0-43 19-43 43 0 20 15 38 36 42l38 6c13 2 22 13 22 26 0 15-12 27-27 27h-53c-4 0-8 4-8 8s4 8 8 8h32v16c0 4 4 8 8 8s8-4 8-8v-16h5c24 0 43-19 43-43 0-20-15-38-36-42l-38-6c-12-2-22-13-22-26 0-15 12-27 27-27h45c4 0 8-3 8-8 0-4-4-8-8-8h-24v-16c0-4-4-8-8-8z" fill="currentColor"/></svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" width="500" height="532" viewBox="6 -258 500 532"><path d="m379-191-46 81c84 77 163 154 163 279 0 52-43 95-95 95H111c-52 0-95-43-95-95C16 44 96-33 179-110l-46-81c-3-6-5-12-5-19 0-21 17-38 38-38h180c21 0 38 17 38 38 0 7-2 13-5 19zM227-88l-1 1C134-4 64 61 64 169c0 26 21 47 47 47h290c26 0 47-21 47-47C448 61 378-4 286-87l-1-1h-58zm-7-48h72l37-64H183l37 64zm40 96c11 0 20 9 20 20v4h8c11 0 20 9 20 20s-9 20-20 20h-47c-7 0-13 6-13 13 0 6 4 11 10 12l42 7c25 4 44 26 44 52s-19 47-44 51v5c0 11-9 20-20 20s-20-9-20-20v-4h-24c-11 0-20-9-20-20s9-20 20-20h56c6 0 12-5 12-12 0-6-4-12-10-13l-42-7c-25-4-44-26-44-51 0-29 23-53 52-53v-4c0-11 9-20 20-20z" fill="currentColor"/></svg>
</template>

View File

@@ -1,10 +1,10 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" v-if="filled" viewBox="0 0 539 535">
<path d="M61 281c2-1 4-3 6-5L269 89l202 187c2 2 4 4 6 5v180c0 35-29 64-64 64H125c-35 0-64-29-64-64V281z"
fill="#a6acb9" />
fill="color-mix(in srgb, var(--colors-primary-DEFAULT) 40%, transparent)" />
<path
d="M247 22c13-12 32-12 44 0l224 208c13 12 13 32 1 45s-32 14-45 2L269 89 67 276c-13 12-33 12-45-1s-12-33 1-45L247 22z"
fill="#1e3050" />
fill="var(--colors-primary-DEFAULT)" />
</svg>
<svg xmlns="http://www.w3.org/2000/svg" v-else viewBox="-11 -259 535 533">
<path
@@ -14,4 +14,4 @@
</template>
<script lang="ts" setup>
defineProps<{ filled?: boolean }>();
</script>
</script>

View File

@@ -1,5 +1,5 @@
<template>
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 567 580"><path d="M18 190c-8 14-6 32 5 43l37 36v42l-37 36c-11 12-13 29-5 43l46 80c8 14 24 21 40 17l50-14c11 8 23 15 36 21l13 50c4 15 18 26 34 26h93c16 0 30-11 34-26l13-50c13-6 25-13 36-21l50 14c15 4 32-3 40-17l46-80c8-14 6-31-6-43l-37-36c1-7 1-14 1-21s0-14-1-21l37-36c12-11 14-29 6-43l-46-80c-8-14-24-21-40-17l-50 14c-11-8-23-15-36-21l-13-50c-4-15-18-26-34-26h-93c-16 0-30 11-34 26l-13 50c-13 6-25 13-36 21l-50-13c-16-5-32 2-40 16l-46 80zm377 100c1 41-20 79-55 99-35 21-79 21-114 0-35-20-56-58-54-99-2-41 19-79 54-99 35-21 79-21 114 0 35 20 56 58 55 99zm-195 0c-2 31 14 59 40 75 27 15 59 15 86 0 26-16 42-44 41-75 1-31-15-59-41-75-27-15-59-15-86 0-26 16-42 44-40 75z" fill="#a6acb9"/><path d="M283 206c46 0 84 37 84 84 0 46-37 84-83 84-47 0-85-37-85-84 0-46 37-84 84-84zm1 196c61 0 111-51 111-112 0-62-51-112-112-112-62 0-112 51-112 112 0 62 51 112 113 112z" fill="#1e3050"/></svg>
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 567 580"><path d="M18 190c-8 14-6 32 5 43l37 36v42l-37 36c-11 12-13 29-5 43l46 80c8 14 24 21 40 17l50-14c11 8 23 15 36 21l13 50c4 15 18 26 34 26h93c16 0 30-11 34-26l13-50c13-6 25-13 36-21l50 14c15 4 32-3 40-17l46-80c8-14 6-31-6-43l-37-36c1-7 1-14 1-21s0-14-1-21l37-36c12-11 14-29 6-43l-46-80c-8-14-24-21-40-17l-50 14c-11-8-23-15-36-21l-13-50c-4-15-18-26-34-26h-93c-16 0-30 11-34 26l-13 50c-13 6-25 13-36 21l-50-13c-16-5-32 2-40 16l-46 80zm377 100c1 41-20 79-55 99-35 21-79 21-114 0-35-20-56-58-54-99-2-41 19-79 54-99 35-21 79-21 114 0 35 20 56 58 55 99zm-195 0c-2 31 14 59 40 75 27 15 59 15 86 0 26-16 42-44 41-75 1-31-15-59-41-75-27-15-59-15-86 0-26 16-42 44-40 75z" fill="color-mix(in srgb, var(--colors-primary-DEFAULT) 40%, transparent)"/><path d="M283 206c46 0 84 37 84 84 0 46-37 84-83 84-47 0-85-37-85-84 0-46 37-84 84-84zm1 196c61 0 111-51 111-112 0-62-51-112-112-112-62 0-112 51-112 112 0 62 51 112 113 112z" fill="var(--colors-primary-DEFAULT)"/></svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path

View File

@@ -1,9 +1,9 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" v-if="filled" viewBox="0 0 532 404">
<path d="M10 74v256c0 35 29 64 64 64h256c35 0 64-29 64-64V74c0-35-29-64-64-64H74c-35 0-64 29-64 64z"
fill="#a6acb9" />
fill="color-mix(in srgb, var(--colors-primary-DEFAULT) 40%, transparent)" />
<path d="M394 135v134l90 72c4 3 9 5 14 5 13 0 24-11 24-24V82c0-13-11-24-24-24-5 0-10 2-14 5l-90 72z"
fill="#1e3050" />
fill="var(--colors-primary-DEFAULT)" />
</svg>
<svg xmlns="http://www.w3.org/2000/svg" v-else viewBox="22 -194 564 404">
<path
@@ -13,4 +13,4 @@
</template>
<script lang="ts" setup>
defineProps<{ filled?: boolean }>();
</script>
</script>