done ui
This commit is contained in:
23
src/components/AppTopLoadingBar.vue
Normal file
23
src/components/AppTopLoadingBar.vue
Normal 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>
|
||||
@@ -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> -->
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user