refactory search
This commit is contained in:
parent
7bb82f1ab9
commit
15d4e4b08c
17
app/app.vue
17
app/app.vue
|
|
@ -1,32 +1,25 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const colorMode = useColorMode()
|
|
||||||
|
|
||||||
const color = computed(() => colorMode.value === 'dark' ? '#1b1718' : 'white')
|
|
||||||
|
|
||||||
useHead({
|
useHead({
|
||||||
meta: [
|
meta: [
|
||||||
{ charset: 'utf-8' },
|
{ charset: 'utf-8' },
|
||||||
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
|
{ name: 'viewport', content: 'width=device-width, initial-scale=1' }
|
||||||
{ key: 'theme-color', name: 'theme-color', content: color }
|
|
||||||
],
|
],
|
||||||
link: [
|
link: [
|
||||||
{ rel: 'icon', href: '/favicon.ico' }
|
{ rel: 'icon', href: '/favicon.ico' }
|
||||||
],
|
],
|
||||||
htmlAttrs: {
|
htmlAttrs: {
|
||||||
lang: 'en'
|
lang: 'es'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const title = 'Nuxt Dashboard Template'
|
const title = 'La Gran Carpa Catedral - Buscador'
|
||||||
const description = 'A professional dashboard template built with Nuxt UI, featuring multiple pages, data visualization, and comprehensive management capabilities for creating powerful admin interfaces.'
|
const description = 'Buscador de actividades y conferencias.'
|
||||||
|
|
||||||
useSeoMeta({
|
useSeoMeta({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
ogTitle: title,
|
ogTitle: title,
|
||||||
ogDescription: description,
|
ogDescription: description
|
||||||
ogImage: 'https://ui.nuxt.com/assets/templates/nuxt/dashboard-light.png',
|
|
||||||
twitterCard: 'summary_large_image'
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,3 +16,61 @@
|
||||||
--color-green-900: #0A5331;
|
--color-green-900: #0A5331;
|
||||||
--color-green-950: #052E16;
|
--color-green-950: #052E16;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Search match highlighting --------------------------------------------- */
|
||||||
|
|
||||||
|
/* Meilisearch returns matches wrapped in <em>. We strip italics and add a
|
||||||
|
yellow background so they really pop in both list and detail. */
|
||||||
|
.search-highlight em,
|
||||||
|
.search-highlight em * {
|
||||||
|
font-style: normal;
|
||||||
|
background-color: #fde68a;
|
||||||
|
color: #78350f;
|
||||||
|
padding: 0 2px;
|
||||||
|
border-radius: 2px;
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow: 0 0 0 1px #f59e0b inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .search-highlight em,
|
||||||
|
.dark .search-highlight em * {
|
||||||
|
background-color: #78350f;
|
||||||
|
color: #fde68a;
|
||||||
|
box-shadow: 0 0 0 1px #f59e0b inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Client-side highlights inside the detail body. */
|
||||||
|
mark.search-match {
|
||||||
|
background-color: #fde68a;
|
||||||
|
color: #78350f;
|
||||||
|
padding: 0 2px;
|
||||||
|
border-radius: 2px;
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow: 0 0 0 1px #f59e0b inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark mark.search-match {
|
||||||
|
background-color: #78350f;
|
||||||
|
color: #fde68a;
|
||||||
|
box-shadow: 0 0 0 1px #f59e0b inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The currently-focused match (the one the user navigated to). */
|
||||||
|
mark.search-match.is-current {
|
||||||
|
background-color: #f97316;
|
||||||
|
color: #ffffff;
|
||||||
|
box-shadow: 0 0 0 2px #c2410c inset;
|
||||||
|
animation: search-match-pulse 0.9s ease-out 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark mark.search-match.is-current {
|
||||||
|
background-color: #ea580c;
|
||||||
|
color: #fff7ed;
|
||||||
|
box-shadow: 0 0 0 2px #fdba74 inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes search-match-pulse {
|
||||||
|
0% { box-shadow: 0 0 0 0 rgba(249, 115, 22, 0.75), 0 0 0 2px #c2410c inset; }
|
||||||
|
70% { box-shadow: 0 0 0 14px rgba(249, 115, 22, 0), 0 0 0 2px #c2410c inset; }
|
||||||
|
100% { box-shadow: 0 0 0 0 rgba(249, 115, 22, 0), 0 0 0 2px #c2410c inset; }
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import { formatTimeAgo } from '@vueuse/core'
|
|
||||||
import type { Notification } from '~/types'
|
|
||||||
|
|
||||||
const { isNotificationsSlideoverOpen } = useDashboard()
|
|
||||||
|
|
||||||
const { data: notifications } = await useFetch<Notification[]>('/api/notifications')
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<USlideover
|
|
||||||
v-model:open="isNotificationsSlideoverOpen"
|
|
||||||
title="Notifications"
|
|
||||||
>
|
|
||||||
<template #body>
|
|
||||||
<NuxtLink
|
|
||||||
v-for="notification in notifications"
|
|
||||||
:key="notification.id"
|
|
||||||
:to="`/inbox?id=${notification.id}`"
|
|
||||||
class="px-3 py-2.5 rounded-md hover:bg-elevated/50 flex items-center gap-3 relative -mx-3 first:-mt-3 last:-mb-3"
|
|
||||||
>
|
|
||||||
<UChip
|
|
||||||
color="error"
|
|
||||||
:show="!!notification.unread"
|
|
||||||
inset
|
|
||||||
>
|
|
||||||
<UAvatar
|
|
||||||
v-bind="notification.sender.avatar"
|
|
||||||
:alt="notification.sender.name"
|
|
||||||
size="md"
|
|
||||||
/>
|
|
||||||
</UChip>
|
|
||||||
|
|
||||||
<div class="text-sm flex-1">
|
|
||||||
<p class="flex items-center justify-between">
|
|
||||||
<span class="text-highlighted font-medium">{{ notification.sender.name }}</span>
|
|
||||||
|
|
||||||
<time
|
|
||||||
:datetime="notification.date"
|
|
||||||
class="text-muted text-xs"
|
|
||||||
v-text="formatTimeAgo(new Date(notification.date))"
|
|
||||||
/>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p class="text-dimmed">
|
|
||||||
{{ notification.body }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</NuxtLink>
|
|
||||||
</template>
|
|
||||||
</USlideover>
|
|
||||||
</template>
|
|
||||||
|
|
@ -1,68 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import type { DropdownMenuItem } from '@nuxt/ui'
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
collapsed?: boolean
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const teams = ref([{
|
|
||||||
label: 'Nuxt',
|
|
||||||
avatar: {
|
|
||||||
src: 'https://github.com/nuxt.png',
|
|
||||||
alt: 'Nuxt'
|
|
||||||
}
|
|
||||||
}, {
|
|
||||||
label: 'NuxtHub',
|
|
||||||
avatar: {
|
|
||||||
src: 'https://github.com/nuxt-hub.png',
|
|
||||||
alt: 'NuxtHub'
|
|
||||||
}
|
|
||||||
}, {
|
|
||||||
label: 'NuxtLabs',
|
|
||||||
avatar: {
|
|
||||||
src: 'https://github.com/nuxtlabs.png',
|
|
||||||
alt: 'NuxtLabs'
|
|
||||||
}
|
|
||||||
}])
|
|
||||||
const selectedTeam = ref(teams.value[0])
|
|
||||||
|
|
||||||
const items = computed<DropdownMenuItem[][]>(() => {
|
|
||||||
return [teams.value.map(team => ({
|
|
||||||
...team,
|
|
||||||
onSelect() {
|
|
||||||
selectedTeam.value = team
|
|
||||||
}
|
|
||||||
})), [{
|
|
||||||
label: 'Create team',
|
|
||||||
icon: 'i-lucide-circle-plus'
|
|
||||||
}, {
|
|
||||||
label: 'Manage teams',
|
|
||||||
icon: 'i-lucide-cog'
|
|
||||||
}]]
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<UDropdownMenu
|
|
||||||
:items="items"
|
|
||||||
:content="{ align: 'center', collisionPadding: 12 }"
|
|
||||||
:ui="{ content: collapsed ? 'w-40' : 'w-(--reka-dropdown-menu-trigger-width)' }"
|
|
||||||
>
|
|
||||||
<UButton
|
|
||||||
v-bind="{
|
|
||||||
...selectedTeam,
|
|
||||||
label: collapsed ? undefined : selectedTeam?.label,
|
|
||||||
trailingIcon: collapsed ? undefined : 'i-lucide-chevrons-up-down'
|
|
||||||
}"
|
|
||||||
color="neutral"
|
|
||||||
variant="ghost"
|
|
||||||
block
|
|
||||||
:square="collapsed"
|
|
||||||
class="data-[state=open]:bg-elevated"
|
|
||||||
:class="[!collapsed && 'py-2']"
|
|
||||||
:ui="{
|
|
||||||
trailingIcon: 'text-dimmed'
|
|
||||||
}"
|
|
||||||
/>
|
|
||||||
</UDropdownMenu>
|
|
||||||
</template>
|
|
||||||
|
|
@ -1,188 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import type { DropdownMenuItem } from '@nuxt/ui'
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
collapsed?: boolean
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const colorMode = useColorMode()
|
|
||||||
const appConfig = useAppConfig()
|
|
||||||
|
|
||||||
const colors = ['red', 'orange', 'amber', 'yellow', 'lime', 'green', 'emerald', 'teal', 'cyan', 'sky', 'blue', 'indigo', 'violet', 'purple', 'fuchsia', 'pink', 'rose']
|
|
||||||
const neutrals = ['slate', 'gray', 'zinc', 'neutral', 'stone']
|
|
||||||
|
|
||||||
const user = ref({
|
|
||||||
name: 'Benjamin Canac',
|
|
||||||
avatar: {
|
|
||||||
src: 'https://github.com/benjamincanac.png',
|
|
||||||
alt: 'Benjamin Canac'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const items = computed<DropdownMenuItem[][]>(() => ([[{
|
|
||||||
type: 'label',
|
|
||||||
label: user.value.name,
|
|
||||||
avatar: user.value.avatar
|
|
||||||
}], [{
|
|
||||||
label: 'Profile',
|
|
||||||
icon: 'i-lucide-user'
|
|
||||||
}, {
|
|
||||||
label: 'Billing',
|
|
||||||
icon: 'i-lucide-credit-card'
|
|
||||||
}, {
|
|
||||||
label: 'Settings',
|
|
||||||
icon: 'i-lucide-settings',
|
|
||||||
to: '/settings'
|
|
||||||
}], [{
|
|
||||||
label: 'Theme',
|
|
||||||
icon: 'i-lucide-palette',
|
|
||||||
children: [{
|
|
||||||
label: 'Primary',
|
|
||||||
slot: 'chip',
|
|
||||||
chip: appConfig.ui.colors.primary,
|
|
||||||
content: {
|
|
||||||
align: 'center',
|
|
||||||
collisionPadding: 16
|
|
||||||
},
|
|
||||||
children: colors.map(color => ({
|
|
||||||
label: color,
|
|
||||||
chip: color,
|
|
||||||
slot: 'chip',
|
|
||||||
checked: appConfig.ui.colors.primary === color,
|
|
||||||
type: 'checkbox',
|
|
||||||
onSelect: (e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
|
|
||||||
appConfig.ui.colors.primary = color
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
}, {
|
|
||||||
label: 'Neutral',
|
|
||||||
slot: 'chip',
|
|
||||||
chip: appConfig.ui.colors.neutral === 'neutral' ? 'old-neutral' : appConfig.ui.colors.neutral,
|
|
||||||
content: {
|
|
||||||
align: 'end',
|
|
||||||
collisionPadding: 16
|
|
||||||
},
|
|
||||||
children: neutrals.map(color => ({
|
|
||||||
label: color,
|
|
||||||
chip: color === 'neutral' ? 'old-neutral' : color,
|
|
||||||
slot: 'chip',
|
|
||||||
type: 'checkbox',
|
|
||||||
checked: appConfig.ui.colors.neutral === color,
|
|
||||||
onSelect: (e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
|
|
||||||
appConfig.ui.colors.neutral = color
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
}]
|
|
||||||
}, {
|
|
||||||
label: 'Appearance',
|
|
||||||
icon: 'i-lucide-sun-moon',
|
|
||||||
children: [{
|
|
||||||
label: 'Light',
|
|
||||||
icon: 'i-lucide-sun',
|
|
||||||
type: 'checkbox',
|
|
||||||
checked: colorMode.value === 'light',
|
|
||||||
onSelect(e: Event) {
|
|
||||||
e.preventDefault()
|
|
||||||
|
|
||||||
colorMode.preference = 'light'
|
|
||||||
}
|
|
||||||
}, {
|
|
||||||
label: 'Dark',
|
|
||||||
icon: 'i-lucide-moon',
|
|
||||||
type: 'checkbox',
|
|
||||||
checked: colorMode.value === 'dark',
|
|
||||||
onUpdateChecked(checked: boolean) {
|
|
||||||
if (checked) {
|
|
||||||
colorMode.preference = 'dark'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onSelect(e: Event) {
|
|
||||||
e.preventDefault()
|
|
||||||
}
|
|
||||||
}]
|
|
||||||
}], [{
|
|
||||||
label: 'Templates',
|
|
||||||
icon: 'i-lucide-layout-template',
|
|
||||||
children: [{
|
|
||||||
label: 'Starter',
|
|
||||||
to: 'https://starter-template.nuxt.dev/'
|
|
||||||
}, {
|
|
||||||
label: 'Landing',
|
|
||||||
to: 'https://landing-template.nuxt.dev/'
|
|
||||||
}, {
|
|
||||||
label: 'Docs',
|
|
||||||
to: 'https://docs-template.nuxt.dev/'
|
|
||||||
}, {
|
|
||||||
label: 'SaaS',
|
|
||||||
to: 'https://saas-template.nuxt.dev/'
|
|
||||||
}, {
|
|
||||||
label: 'Dashboard',
|
|
||||||
to: 'https://dashboard-template.nuxt.dev/',
|
|
||||||
color: 'primary',
|
|
||||||
checked: true,
|
|
||||||
type: 'checkbox'
|
|
||||||
}, {
|
|
||||||
label: 'Chat',
|
|
||||||
to: 'https://chat-template.nuxt.dev/'
|
|
||||||
}, {
|
|
||||||
label: 'Portfolio',
|
|
||||||
to: 'https://portfolio-template.nuxt.dev/'
|
|
||||||
}, {
|
|
||||||
label: 'Changelog',
|
|
||||||
to: 'https://changelog-template.nuxt.dev/'
|
|
||||||
}]
|
|
||||||
}], [{
|
|
||||||
label: 'Documentation',
|
|
||||||
icon: 'i-lucide-book-open',
|
|
||||||
to: 'https://ui.nuxt.com/docs/getting-started/installation/nuxt',
|
|
||||||
target: '_blank'
|
|
||||||
}, {
|
|
||||||
label: 'GitHub repository',
|
|
||||||
icon: 'i-simple-icons-github',
|
|
||||||
to: 'https://github.com/nuxt-ui-templates/dashboard',
|
|
||||||
target: '_blank'
|
|
||||||
}, {
|
|
||||||
label: 'Log out',
|
|
||||||
icon: 'i-lucide-log-out'
|
|
||||||
}]]))
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<UDropdownMenu
|
|
||||||
:items="items"
|
|
||||||
:content="{ align: 'center', collisionPadding: 12 }"
|
|
||||||
:ui="{ content: collapsed ? 'w-48' : 'w-(--reka-dropdown-menu-trigger-width)' }"
|
|
||||||
>
|
|
||||||
<UButton
|
|
||||||
v-bind="{
|
|
||||||
...user,
|
|
||||||
label: collapsed ? undefined : user?.name,
|
|
||||||
trailingIcon: collapsed ? undefined : 'i-lucide-chevrons-up-down'
|
|
||||||
}"
|
|
||||||
color="neutral"
|
|
||||||
variant="ghost"
|
|
||||||
block
|
|
||||||
:square="collapsed"
|
|
||||||
class="data-[state=open]:bg-elevated"
|
|
||||||
:ui="{
|
|
||||||
trailingIcon: 'text-dimmed'
|
|
||||||
}"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<template #chip-leading="{ item }">
|
|
||||||
<div class="inline-flex items-center justify-center shrink-0 size-5">
|
|
||||||
<span
|
|
||||||
class="rounded-full ring ring-bg bg-(--chip-light) dark:bg-(--chip-dark) size-2"
|
|
||||||
:style="{
|
|
||||||
'--chip-light': `var(--color-${(item as any).chip}-500)`,
|
|
||||||
'--chip-dark': `var(--color-${(item as any).chip}-400)`
|
|
||||||
}"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</UDropdownMenu>
|
|
||||||
</template>
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import * as z from 'zod'
|
|
||||||
import type { FormSubmitEvent } from '@nuxt/ui'
|
|
||||||
|
|
||||||
const schema = z.object({
|
|
||||||
name: z.string().min(2, 'Too short'),
|
|
||||||
email: z.string().email('Invalid email')
|
|
||||||
})
|
|
||||||
const open = ref(false)
|
|
||||||
|
|
||||||
type Schema = z.output<typeof schema>
|
|
||||||
|
|
||||||
const state = reactive<Partial<Schema>>({
|
|
||||||
name: '',
|
|
||||||
email: ''
|
|
||||||
})
|
|
||||||
|
|
||||||
const toast = useToast()
|
|
||||||
async function onSubmit(event: FormSubmitEvent<Schema>) {
|
|
||||||
toast.add({ title: 'Success', description: `New customer ${event.data.name} added`, color: 'success' })
|
|
||||||
open.value = false
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<UModal v-model:open="open" title="New customer" description="Add a new customer to the database">
|
|
||||||
<UButton label="New customer" icon="i-lucide-plus" />
|
|
||||||
|
|
||||||
<template #body>
|
|
||||||
<UForm
|
|
||||||
:schema="schema"
|
|
||||||
:state="state"
|
|
||||||
class="space-y-4"
|
|
||||||
@submit="onSubmit"
|
|
||||||
>
|
|
||||||
<UFormField label="Name" placeholder="John Doe" name="name">
|
|
||||||
<UInput v-model="state.name" class="w-full" />
|
|
||||||
</UFormField>
|
|
||||||
<UFormField label="Email" placeholder="john.doe@example.com" name="email">
|
|
||||||
<UInput v-model="state.email" class="w-full" />
|
|
||||||
</UFormField>
|
|
||||||
<div class="flex justify-end gap-2">
|
|
||||||
<UButton
|
|
||||||
label="Cancel"
|
|
||||||
color="neutral"
|
|
||||||
variant="subtle"
|
|
||||||
@click="open = false"
|
|
||||||
/>
|
|
||||||
<UButton
|
|
||||||
label="Create"
|
|
||||||
color="primary"
|
|
||||||
variant="solid"
|
|
||||||
type="submit"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</UForm>
|
|
||||||
</template>
|
|
||||||
</UModal>
|
|
||||||
</template>
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
withDefaults(defineProps<{
|
|
||||||
count?: number
|
|
||||||
}>(), {
|
|
||||||
count: 0
|
|
||||||
})
|
|
||||||
|
|
||||||
const open = ref(false)
|
|
||||||
|
|
||||||
async function onSubmit() {
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
|
||||||
open.value = false
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<UModal
|
|
||||||
v-model:open="open"
|
|
||||||
:title="`Delete ${count} customer${count > 1 ? 's' : ''}`"
|
|
||||||
:description="`Are you sure, this action cannot be undone.`"
|
|
||||||
>
|
|
||||||
<slot />
|
|
||||||
|
|
||||||
<template #body>
|
|
||||||
<div class="flex justify-end gap-2">
|
|
||||||
<UButton
|
|
||||||
label="Cancel"
|
|
||||||
color="neutral"
|
|
||||||
variant="subtle"
|
|
||||||
@click="open = false"
|
|
||||||
/>
|
|
||||||
<UButton
|
|
||||||
label="Delete"
|
|
||||||
color="error"
|
|
||||||
variant="solid"
|
|
||||||
loading-auto
|
|
||||||
@click="onSubmit"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</UModal>
|
|
||||||
</template>
|
|
||||||
|
|
@ -1,121 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import { eachDayOfInterval, eachWeekOfInterval, eachMonthOfInterval, format } from 'date-fns'
|
|
||||||
import { VisXYContainer, VisLine, VisAxis, VisArea, VisCrosshair, VisTooltip } from '@unovis/vue'
|
|
||||||
import type { Period, Range } from '~/types'
|
|
||||||
|
|
||||||
const cardRef = useTemplateRef<HTMLElement | null>('cardRef')
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
period: Period
|
|
||||||
range: Range
|
|
||||||
}>()
|
|
||||||
|
|
||||||
type DataRecord = {
|
|
||||||
date: Date
|
|
||||||
amount: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const { width } = useElementSize(cardRef)
|
|
||||||
|
|
||||||
const data = ref<DataRecord[]>([])
|
|
||||||
|
|
||||||
watch([() => props.period, () => props.range], () => {
|
|
||||||
const dates = ({
|
|
||||||
daily: eachDayOfInterval,
|
|
||||||
weekly: eachWeekOfInterval,
|
|
||||||
monthly: eachMonthOfInterval
|
|
||||||
} as Record<Period, typeof eachDayOfInterval>)[props.period](props.range)
|
|
||||||
|
|
||||||
const min = 1000
|
|
||||||
const max = 10000
|
|
||||||
|
|
||||||
data.value = dates.map(date => ({ date, amount: Math.floor(Math.random() * (max - min + 1)) + min }))
|
|
||||||
}, { immediate: true })
|
|
||||||
|
|
||||||
const x = (_: DataRecord, i: number) => i
|
|
||||||
const y = (d: DataRecord) => d.amount
|
|
||||||
|
|
||||||
const total = computed(() => data.value.reduce((acc: number, { amount }) => acc + amount, 0))
|
|
||||||
|
|
||||||
const formatNumber = new Intl.NumberFormat('en', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format
|
|
||||||
|
|
||||||
const formatDate = (date: Date): string => {
|
|
||||||
return ({
|
|
||||||
daily: format(date, 'd MMM'),
|
|
||||||
weekly: format(date, 'd MMM'),
|
|
||||||
monthly: format(date, 'MMM yyy')
|
|
||||||
})[props.period]
|
|
||||||
}
|
|
||||||
|
|
||||||
const xTicks = (i: number) => {
|
|
||||||
if (i === 0 || i === data.value.length - 1 || !data.value[i]) {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
return formatDate(data.value[i].date)
|
|
||||||
}
|
|
||||||
|
|
||||||
const template = (d: DataRecord) => `${formatDate(d.date)}: ${formatNumber(d.amount)}`
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<UCard ref="cardRef" :ui="{ root: 'overflow-visible', body: 'px-0! pt-0! pb-3!' }">
|
|
||||||
<template #header>
|
|
||||||
<div>
|
|
||||||
<p class="text-xs text-muted uppercase mb-1.5">
|
|
||||||
Revenue
|
|
||||||
</p>
|
|
||||||
<p class="text-3xl text-highlighted font-semibold">
|
|
||||||
{{ formatNumber(total) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<VisXYContainer
|
|
||||||
:data="data"
|
|
||||||
:padding="{ top: 40 }"
|
|
||||||
class="h-96"
|
|
||||||
:width="width"
|
|
||||||
>
|
|
||||||
<VisLine
|
|
||||||
:x="x"
|
|
||||||
:y="y"
|
|
||||||
color="var(--ui-primary)"
|
|
||||||
/>
|
|
||||||
<VisArea
|
|
||||||
:x="x"
|
|
||||||
:y="y"
|
|
||||||
color="var(--ui-primary)"
|
|
||||||
:opacity="0.1"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<VisAxis
|
|
||||||
type="x"
|
|
||||||
:x="x"
|
|
||||||
:tick-format="xTicks"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<VisCrosshair
|
|
||||||
color="var(--ui-primary)"
|
|
||||||
:template="template"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<VisTooltip />
|
|
||||||
</VisXYContainer>
|
|
||||||
</UCard>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.unovis-xy-container {
|
|
||||||
--vis-crosshair-line-stroke-color: var(--ui-primary);
|
|
||||||
--vis-crosshair-circle-stroke-color: var(--ui-bg);
|
|
||||||
|
|
||||||
--vis-axis-grid-color: var(--ui-border);
|
|
||||||
--vis-axis-tick-color: var(--ui-border);
|
|
||||||
--vis-axis-tick-label-color: var(--ui-text-dimmed);
|
|
||||||
|
|
||||||
--vis-tooltip-background-color: var(--ui-bg);
|
|
||||||
--vis-tooltip-border-color: var(--ui-border);
|
|
||||||
--vis-tooltip-text-color: var(--ui-text-highlighted);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
<template>
|
|
||||||
<UCard class="shrink-0" :ui="{ body: 'px-0! pt-0! pb-3!' }">
|
|
||||||
<template #header>
|
|
||||||
<div>
|
|
||||||
<p class="text-xs text-muted uppercase mb-1.5">
|
|
||||||
Revenue
|
|
||||||
</p>
|
|
||||||
<p class="text-3xl text-highlighted font-semibold">
|
|
||||||
---
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<div class="h-96" />
|
|
||||||
</UCard>
|
|
||||||
</template>
|
|
||||||
|
|
@ -1,132 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import { DateFormatter, getLocalTimeZone, CalendarDate, today } from '@internationalized/date'
|
|
||||||
import type { Range } from '~/types'
|
|
||||||
|
|
||||||
const df = new DateFormatter('en-US', {
|
|
||||||
dateStyle: 'medium'
|
|
||||||
})
|
|
||||||
|
|
||||||
const selected = defineModel<Range>({ required: true })
|
|
||||||
|
|
||||||
const ranges = [
|
|
||||||
{ label: 'Last 7 days', days: 7 },
|
|
||||||
{ label: 'Last 14 days', days: 14 },
|
|
||||||
{ label: 'Last 30 days', days: 30 },
|
|
||||||
{ label: 'Last 3 months', months: 3 },
|
|
||||||
{ label: 'Last 6 months', months: 6 },
|
|
||||||
{ label: 'Last year', years: 1 }
|
|
||||||
]
|
|
||||||
|
|
||||||
const toCalendarDate = (date: Date) => {
|
|
||||||
return new CalendarDate(
|
|
||||||
date.getFullYear(),
|
|
||||||
date.getMonth() + 1,
|
|
||||||
date.getDate()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const calendarRange = computed({
|
|
||||||
get: () => ({
|
|
||||||
start: selected.value.start ? toCalendarDate(selected.value.start) : undefined,
|
|
||||||
end: selected.value.end ? toCalendarDate(selected.value.end) : undefined
|
|
||||||
}),
|
|
||||||
set: (newValue: { start: CalendarDate | null, end: CalendarDate | null }) => {
|
|
||||||
selected.value = {
|
|
||||||
start: newValue.start ? newValue.start.toDate(getLocalTimeZone()) : new Date(),
|
|
||||||
end: newValue.end ? newValue.end.toDate(getLocalTimeZone()) : new Date()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const isRangeSelected = (range: { days?: number, months?: number, years?: number }) => {
|
|
||||||
if (!selected.value.start || !selected.value.end) return false
|
|
||||||
|
|
||||||
const currentDate = today(getLocalTimeZone())
|
|
||||||
let startDate = currentDate.copy()
|
|
||||||
|
|
||||||
if (range.days) {
|
|
||||||
startDate = startDate.subtract({ days: range.days })
|
|
||||||
} else if (range.months) {
|
|
||||||
startDate = startDate.subtract({ months: range.months })
|
|
||||||
} else if (range.years) {
|
|
||||||
startDate = startDate.subtract({ years: range.years })
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedStart = toCalendarDate(selected.value.start)
|
|
||||||
const selectedEnd = toCalendarDate(selected.value.end)
|
|
||||||
|
|
||||||
return selectedStart.compare(startDate) === 0 && selectedEnd.compare(currentDate) === 0
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectRange = (range: { days?: number, months?: number, years?: number }) => {
|
|
||||||
const endDate = today(getLocalTimeZone())
|
|
||||||
let startDate = endDate.copy()
|
|
||||||
|
|
||||||
if (range.days) {
|
|
||||||
startDate = startDate.subtract({ days: range.days })
|
|
||||||
} else if (range.months) {
|
|
||||||
startDate = startDate.subtract({ months: range.months })
|
|
||||||
} else if (range.years) {
|
|
||||||
startDate = startDate.subtract({ years: range.years })
|
|
||||||
}
|
|
||||||
|
|
||||||
selected.value = {
|
|
||||||
start: startDate.toDate(getLocalTimeZone()),
|
|
||||||
end: endDate.toDate(getLocalTimeZone())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<UPopover :content="{ align: 'start' }" :modal="true">
|
|
||||||
<UButton
|
|
||||||
color="neutral"
|
|
||||||
variant="ghost"
|
|
||||||
icon="i-lucide-calendar"
|
|
||||||
class="data-[state=open]:bg-elevated group"
|
|
||||||
>
|
|
||||||
<span class="truncate">
|
|
||||||
<template v-if="selected.start">
|
|
||||||
<template v-if="selected.end">
|
|
||||||
{{ df.format(selected.start) }} - {{ df.format(selected.end) }}
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
{{ df.format(selected.start) }}
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
Pick a date
|
|
||||||
</template>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<template #trailing>
|
|
||||||
<UIcon name="i-lucide-chevron-down" class="shrink-0 text-dimmed size-5 group-data-[state=open]:rotate-180 transition-transform duration-200" />
|
|
||||||
</template>
|
|
||||||
</UButton>
|
|
||||||
|
|
||||||
<template #content>
|
|
||||||
<div class="flex items-stretch sm:divide-x divide-default">
|
|
||||||
<div class="hidden sm:flex flex-col justify-center">
|
|
||||||
<UButton
|
|
||||||
v-for="(range, index) in ranges"
|
|
||||||
:key="index"
|
|
||||||
:label="range.label"
|
|
||||||
color="neutral"
|
|
||||||
variant="ghost"
|
|
||||||
class="rounded-none px-4"
|
|
||||||
:class="[isRangeSelected(range) ? 'bg-elevated' : 'hover:bg-elevated/50']"
|
|
||||||
truncate
|
|
||||||
@click="selectRange(range)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<UCalendar
|
|
||||||
v-model="calendarRange"
|
|
||||||
class="p-2"
|
|
||||||
:number-of-months="2"
|
|
||||||
range
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</UPopover>
|
|
||||||
</template>
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import { eachDayOfInterval } from 'date-fns'
|
|
||||||
import type { Period, Range } from '~/types'
|
|
||||||
|
|
||||||
const model = defineModel<Period>({ required: true })
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
range: Range
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const days = computed(() => eachDayOfInterval(props.range))
|
|
||||||
|
|
||||||
const periods = computed<Period[]>(() => {
|
|
||||||
if (days.value.length <= 8) {
|
|
||||||
return [
|
|
||||||
'daily'
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
if (days.value.length <= 31) {
|
|
||||||
return [
|
|
||||||
'daily',
|
|
||||||
'weekly'
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'weekly',
|
|
||||||
'monthly'
|
|
||||||
]
|
|
||||||
})
|
|
||||||
|
|
||||||
// Ensure the model value is always a valid period
|
|
||||||
watch(periods, () => {
|
|
||||||
if (!periods.value.includes(model.value)) {
|
|
||||||
model.value = periods.value[0]!
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<USelect
|
|
||||||
v-model="model"
|
|
||||||
:items="periods"
|
|
||||||
variant="ghost"
|
|
||||||
class="data-[state=open]:bg-elevated"
|
|
||||||
:ui="{ value: 'capitalize', itemLabel: 'capitalize', trailingIcon: 'group-data-[state=open]:rotate-180 transition-transform duration-200' }"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
@ -1,112 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import { h, resolveComponent } from 'vue'
|
|
||||||
import type { TableColumn } from '@nuxt/ui'
|
|
||||||
import type { Period, Range, Sale } from '~/types'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
period: Period
|
|
||||||
range: Range
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const UBadge = resolveComponent('UBadge')
|
|
||||||
|
|
||||||
const sampleEmails = [
|
|
||||||
'james.anderson@example.com',
|
|
||||||
'mia.white@example.com',
|
|
||||||
'william.brown@example.com',
|
|
||||||
'emma.davis@example.com',
|
|
||||||
'ethan.harris@example.com'
|
|
||||||
]
|
|
||||||
|
|
||||||
const { data } = await useAsyncData('sales', async () => {
|
|
||||||
const sales: Sale[] = []
|
|
||||||
const currentDate = new Date()
|
|
||||||
|
|
||||||
for (let i = 0; i < 5; i++) {
|
|
||||||
const hoursAgo = randomInt(0, 48)
|
|
||||||
const date = new Date(currentDate.getTime() - hoursAgo * 3600000)
|
|
||||||
|
|
||||||
sales.push({
|
|
||||||
id: (4600 - i).toString(),
|
|
||||||
date: date.toISOString(),
|
|
||||||
status: randomFrom(['paid', 'failed', 'refunded']),
|
|
||||||
email: randomFrom(sampleEmails),
|
|
||||||
amount: randomInt(100, 1000)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return sales.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
|
|
||||||
}, {
|
|
||||||
watch: [() => props.period, () => props.range],
|
|
||||||
default: () => []
|
|
||||||
})
|
|
||||||
|
|
||||||
const columns: TableColumn<Sale>[] = [
|
|
||||||
{
|
|
||||||
accessorKey: 'id',
|
|
||||||
header: 'ID',
|
|
||||||
cell: ({ row }) => `#${row.getValue('id')}`
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'date',
|
|
||||||
header: 'Date',
|
|
||||||
cell: ({ row }) => {
|
|
||||||
return new Date(row.getValue('date')).toLocaleString('en-US', {
|
|
||||||
day: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
hour12: false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'status',
|
|
||||||
header: 'Status',
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const color = {
|
|
||||||
paid: 'success' as const,
|
|
||||||
failed: 'error' as const,
|
|
||||||
refunded: 'neutral' as const
|
|
||||||
}[row.getValue('status') as string]
|
|
||||||
|
|
||||||
return h(UBadge, { class: 'capitalize', variant: 'subtle', color }, () =>
|
|
||||||
row.getValue('status')
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'email',
|
|
||||||
header: 'Email'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'amount',
|
|
||||||
header: () => h('div', { class: 'text-right' }, 'Amount'),
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const amount = Number.parseFloat(row.getValue('amount'))
|
|
||||||
|
|
||||||
const formatted = new Intl.NumberFormat('en-US', {
|
|
||||||
style: 'currency',
|
|
||||||
currency: 'EUR'
|
|
||||||
}).format(amount)
|
|
||||||
|
|
||||||
return h('div', { class: 'text-right font-medium' }, formatted)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<UTable
|
|
||||||
:data="data"
|
|
||||||
:columns="columns"
|
|
||||||
class="shrink-0"
|
|
||||||
:ui="{
|
|
||||||
base: 'table-fixed border-separate border-spacing-0',
|
|
||||||
thead: '[&>tr]:bg-elevated/50 [&>tr]:after:content-none',
|
|
||||||
tbody: '[&>tr]:last:[&>td]:border-b-0',
|
|
||||||
th: 'first:rounded-l-lg last:rounded-r-lg border-y border-default first:border-l last:border-r',
|
|
||||||
td: 'border-b border-default'
|
|
||||||
}"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
@ -1,98 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import type { Period, Range, Stat } from '~/types'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
period: Period
|
|
||||||
range: Range
|
|
||||||
}>()
|
|
||||||
|
|
||||||
function formatCurrency(value: number): string {
|
|
||||||
return value.toLocaleString('en-US', {
|
|
||||||
style: 'currency',
|
|
||||||
currency: 'USD',
|
|
||||||
maximumFractionDigits: 0
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const baseStats = [{
|
|
||||||
title: 'Customers',
|
|
||||||
icon: 'i-lucide-users',
|
|
||||||
minValue: 400,
|
|
||||||
maxValue: 1000,
|
|
||||||
minVariation: -15,
|
|
||||||
maxVariation: 25
|
|
||||||
}, {
|
|
||||||
title: 'Conversions',
|
|
||||||
icon: 'i-lucide-chart-pie',
|
|
||||||
minValue: 1000,
|
|
||||||
maxValue: 2000,
|
|
||||||
minVariation: -10,
|
|
||||||
maxVariation: 20
|
|
||||||
}, {
|
|
||||||
title: 'Revenue',
|
|
||||||
icon: 'i-lucide-circle-dollar-sign',
|
|
||||||
minValue: 200000,
|
|
||||||
maxValue: 500000,
|
|
||||||
minVariation: -20,
|
|
||||||
maxVariation: 30,
|
|
||||||
formatter: formatCurrency
|
|
||||||
}, {
|
|
||||||
title: 'Orders',
|
|
||||||
icon: 'i-lucide-shopping-cart',
|
|
||||||
minValue: 100,
|
|
||||||
maxValue: 300,
|
|
||||||
minVariation: -5,
|
|
||||||
maxVariation: 15
|
|
||||||
}]
|
|
||||||
|
|
||||||
const { data: stats } = await useAsyncData<Stat[]>('stats', async () => {
|
|
||||||
return baseStats.map((stat) => {
|
|
||||||
const value = randomInt(stat.minValue, stat.maxValue)
|
|
||||||
const variation = randomInt(stat.minVariation, stat.maxVariation)
|
|
||||||
|
|
||||||
return {
|
|
||||||
title: stat.title,
|
|
||||||
icon: stat.icon,
|
|
||||||
value: stat.formatter ? stat.formatter(value) : value,
|
|
||||||
variation
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}, {
|
|
||||||
watch: [() => props.period, () => props.range],
|
|
||||||
default: () => []
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<UPageGrid class="lg:grid-cols-4 gap-4 sm:gap-6 lg:gap-px">
|
|
||||||
<UPageCard
|
|
||||||
v-for="(stat, index) in stats"
|
|
||||||
:key="index"
|
|
||||||
:icon="stat.icon"
|
|
||||||
:title="stat.title"
|
|
||||||
to="/customers"
|
|
||||||
variant="subtle"
|
|
||||||
:ui="{
|
|
||||||
container: 'gap-y-1.5',
|
|
||||||
wrapper: 'items-start',
|
|
||||||
leading: 'p-2.5 rounded-full bg-primary/10 ring ring-inset ring-primary/25 flex-col',
|
|
||||||
title: 'font-normal text-muted text-xs uppercase'
|
|
||||||
}"
|
|
||||||
class="lg:rounded-none first:rounded-l-lg last:rounded-r-lg hover:z-1"
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="text-2xl font-semibold text-highlighted">
|
|
||||||
{{ stat.value }}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<UBadge
|
|
||||||
:color="stat.variation > 0 ? 'success' : 'error'"
|
|
||||||
variant="subtle"
|
|
||||||
class="text-xs"
|
|
||||||
>
|
|
||||||
{{ stat.variation > 0 ? '+' : '' }}{{ stat.variation }}%
|
|
||||||
</UBadge>
|
|
||||||
</div>
|
|
||||||
</UPageCard>
|
|
||||||
</UPageGrid>
|
|
||||||
</template>
|
|
||||||
|
|
@ -1,43 +1,296 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { format } from 'date-fns'
|
import dayjs from 'dayjs'
|
||||||
import type { Activity } from '~/types'
|
import { useDebounce } from '@vueuse/core'
|
||||||
|
import type { SearchHit } from '~/types'
|
||||||
|
|
||||||
defineProps<{
|
const props = defineProps<{
|
||||||
activity: Activity
|
activity: SearchHit
|
||||||
|
collection?: 'activities' | 'conferences'
|
||||||
|
query?: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emits = defineEmits(['close'])
|
const emits = defineEmits(['close'])
|
||||||
|
|
||||||
const dropdownItems = [[{
|
const { locale } = useI18n()
|
||||||
label: 'Mark as unread',
|
|
||||||
icon: 'i-lucide-check-circle'
|
|
||||||
}, {
|
|
||||||
label: 'Mark as important',
|
|
||||||
icon: 'i-lucide-triangle-alert'
|
|
||||||
}], [{
|
|
||||||
label: 'Star thread',
|
|
||||||
icon: 'i-lucide-star'
|
|
||||||
}, {
|
|
||||||
label: 'Mute thread',
|
|
||||||
icon: 'i-lucide-circle-pause'
|
|
||||||
}]]
|
|
||||||
|
|
||||||
const toast = useToast()
|
// ---- Match URL & metadata ----------------------------------------------
|
||||||
|
|
||||||
const reply = ref('')
|
function getTimestamp(i: SearchHit): number | null {
|
||||||
const loading = ref(false)
|
const d = i.date ?? i.isodate
|
||||||
|
if (d == null) return null
|
||||||
|
const ts = typeof d === 'string' ? new Date(d).getTime() : (d as number) * 1000
|
||||||
|
return Number.isFinite(ts) ? ts : null
|
||||||
|
}
|
||||||
|
|
||||||
const meilisearch = useMeiliSearchRef()
|
const matchUrl = computed(() => {
|
||||||
|
const i = props.activity
|
||||||
const body = computed(async() => {
|
if (!i?.type) return null
|
||||||
// let item = meilisearch.index('activities_ES').getDocument(activity.value._id)
|
const ts = getTimestamp(i)
|
||||||
// return await item.body
|
if (ts == null) return null
|
||||||
|
const d = dayjs(ts)
|
||||||
|
const month = (d.month() + 1).toString().padStart(2, '0')
|
||||||
|
const slug = i.slug && i.slug !== 'undefined' ? i.slug : i.id ?? i._id
|
||||||
|
return `https://www.carpa.com/${locale.value}/${i.type}/${d.year()}/${month}/${slug}`
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const fileLinks = computed(() => formatFiles(props.activity.files || {}))
|
||||||
|
|
||||||
|
function safeDate(i: SearchHit) {
|
||||||
|
const ts = getTimestamp(i)
|
||||||
|
return ts == null ? '' : formatDate(ts / 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Find-in-document --------------------------------------------------
|
||||||
|
|
||||||
|
const bodyContainer = ref<HTMLElement | null>(null)
|
||||||
|
const scrollContainer = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
const localQuery = ref(props.query || '')
|
||||||
|
const debouncedLocalQuery = useDebounce(localQuery, 200)
|
||||||
|
|
||||||
|
const currentIdx = ref(0)
|
||||||
|
const totalMatches = ref(0)
|
||||||
|
|
||||||
|
// Reset local query and counters when the user opens a different activity.
|
||||||
|
watch(
|
||||||
|
() => props.activity?._id,
|
||||||
|
() => {
|
||||||
|
localQuery.value = props.query || ''
|
||||||
|
currentIdx.value = 0
|
||||||
|
totalMatches.value = 0
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Keep local query in sync if the parent search changes while the same
|
||||||
|
// activity stays open.
|
||||||
|
watch(() => props.query, (q) => {
|
||||||
|
if (q !== undefined && q !== localQuery.value) localQuery.value = q || ''
|
||||||
|
})
|
||||||
|
|
||||||
|
function escapeRegex(s: string) {
|
||||||
|
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalize(s: string): string {
|
||||||
|
return s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeWithMap(text: string): { normalized: string, map: number[] } {
|
||||||
|
let normalized = ''
|
||||||
|
const map: number[] = []
|
||||||
|
for (let i = 0; i < text.length; i++) {
|
||||||
|
const norm = text[i].normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase()
|
||||||
|
for (let j = 0; j < norm.length; j++) {
|
||||||
|
normalized += norm[j]
|
||||||
|
map.push(i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { normalized, map }
|
||||||
|
}
|
||||||
|
|
||||||
|
function findMatchesInText(text: string, terms: string[]): Array<{ start: number, end: number }> {
|
||||||
|
if (!text || !terms.length) return []
|
||||||
|
const sources = terms.map(t => termToRegexSource(normalize(t))).filter(s => s.length > 0)
|
||||||
|
if (!sources.length) return []
|
||||||
|
|
||||||
|
const { normalized, map } = normalizeWithMap(text)
|
||||||
|
const re = new RegExp(`(${sources.join('|')})`, 'g')
|
||||||
|
const out: Array<{ start: number, end: number }> = []
|
||||||
|
let m: RegExpExecArray | null
|
||||||
|
while ((m = re.exec(normalized)) !== null) {
|
||||||
|
if (m[0].length === 0) { re.lastIndex++; continue }
|
||||||
|
const start = map[m.index] ?? text.length
|
||||||
|
const endNormIdx = m.index + m[0].length
|
||||||
|
const end = endNormIdx < map.length ? map[endNormIdx] : text.length
|
||||||
|
out.push({ start, end })
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** In the detail view, the entire query is treated as ONE exact phrase — no
|
||||||
|
* word splitting. Surrounding double quotes are tolerated and stripped. */
|
||||||
|
function parseTerms(query: string): string[] {
|
||||||
|
const trimmed = (query || '').trim().replace(/^"+|"+$/g, '').trim()
|
||||||
|
return trimmed.length > 0 ? [trimmed] : []
|
||||||
|
}
|
||||||
|
|
||||||
|
function termToRegexSource(term: string): string {
|
||||||
|
const parts = term.split(/\s+/).filter(Boolean)
|
||||||
|
if (parts.length === 0) return ''
|
||||||
|
if (parts.length === 1) return escapeRegex(parts[0])
|
||||||
|
return parts.map(escapeRegex).join('\\s+')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Walk text nodes, build the FLAT text content of `root`, find phrase
|
||||||
|
* positions in that flat text, then mark whichever portion of each match
|
||||||
|
* falls inside each text node. Cross-node phrases (split by HTML tags) get
|
||||||
|
* highlighted as several adjacent <mark>s.
|
||||||
|
* Returns the number of distinct matches (not <mark> elements) so the
|
||||||
|
* navigation counter ("3 / N") reflects unique phrase occurrences. */
|
||||||
|
function highlightTextNodes(root: HTMLElement, terms: string[]): number {
|
||||||
|
if (!terms.length) return 0
|
||||||
|
|
||||||
|
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null)
|
||||||
|
const infos: { node: Text, start: number, end: number }[] = []
|
||||||
|
let flat = ''
|
||||||
|
let n: Node | null
|
||||||
|
while ((n = walker.nextNode())) {
|
||||||
|
const node = n as Text
|
||||||
|
const text = node.nodeValue || ''
|
||||||
|
infos.push({ node, start: flat.length, end: flat.length + text.length })
|
||||||
|
flat += text
|
||||||
|
}
|
||||||
|
if (!infos.length) return 0
|
||||||
|
|
||||||
|
const matches = findMatchesInText(flat, terms)
|
||||||
|
if (!matches.length) return 0
|
||||||
|
|
||||||
|
// Tag the first <mark> of each logical match so the navigator can step
|
||||||
|
// between distinct occurrences (not between fragments of one phrase).
|
||||||
|
const matchStartByPos = new Set(matches.map(m => m.start))
|
||||||
|
|
||||||
|
for (let i = infos.length - 1; i >= 0; i--) {
|
||||||
|
const info = infos[i]
|
||||||
|
const nodeText = info.node.nodeValue || ''
|
||||||
|
|
||||||
|
const segments: { start: number, end: number, matchStart: number }[] = []
|
||||||
|
for (const match of matches) {
|
||||||
|
const segStart = Math.max(match.start, info.start) - info.start
|
||||||
|
const segEnd = Math.min(match.end, info.end) - info.start
|
||||||
|
if (segStart < segEnd) segments.push({ start: segStart, end: segEnd, matchStart: match.start })
|
||||||
|
}
|
||||||
|
if (!segments.length) continue
|
||||||
|
segments.sort((a, b) => a.start - b.start)
|
||||||
|
|
||||||
|
const frag = document.createDocumentFragment()
|
||||||
|
let cursor = 0
|
||||||
|
for (const seg of segments) {
|
||||||
|
if (seg.start > cursor) frag.appendChild(document.createTextNode(nodeText.slice(cursor, seg.start)))
|
||||||
|
const mark = document.createElement('mark')
|
||||||
|
mark.className = 'search-match'
|
||||||
|
// First fragment of a logical match gets a marker class so we can find
|
||||||
|
// distinct occurrences for the up/down navigation buttons.
|
||||||
|
if (matchStartByPos.has(seg.matchStart) && info.start + seg.start === seg.matchStart) {
|
||||||
|
mark.classList.add('match-start')
|
||||||
|
}
|
||||||
|
mark.textContent = nodeText.slice(seg.start, seg.end)
|
||||||
|
frag.appendChild(mark)
|
||||||
|
cursor = seg.end
|
||||||
|
}
|
||||||
|
if (cursor < nodeText.length) frag.appendChild(document.createTextNode(nodeText.slice(cursor)))
|
||||||
|
info.node.parentNode?.replaceChild(frag, info.node)
|
||||||
|
}
|
||||||
|
|
||||||
|
return matches.length
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMarks(): HTMLElement[] {
|
||||||
|
const el = bodyContainer.value
|
||||||
|
if (!el) return []
|
||||||
|
return Array.from(el.querySelectorAll('mark.search-match')) as HTMLElement[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Distinct match starts — for cross-node phrases, only the FIRST <mark>
|
||||||
|
* fragment of each phrase carries the .match-start class. */
|
||||||
|
function getMatchStarts(): HTMLElement[] {
|
||||||
|
const el = bodyContainer.value
|
||||||
|
if (!el) return []
|
||||||
|
return Array.from(el.querySelectorAll('mark.search-match.match-start')) as HTMLElement[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyCurrent(scroll = true) {
|
||||||
|
const allMarks = getMarks()
|
||||||
|
allMarks.forEach(m => m.classList.remove('is-current'))
|
||||||
|
const starts = getMatchStarts()
|
||||||
|
if (!starts.length) return
|
||||||
|
const idx = Math.min(Math.max(currentIdx.value, 0), starts.length - 1)
|
||||||
|
const target = starts[idx]
|
||||||
|
if (!target) return
|
||||||
|
target.classList.remove('is-current')
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||||
|
void target.offsetWidth
|
||||||
|
target.classList.add('is-current')
|
||||||
|
if (scroll) target.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextMatch() {
|
||||||
|
if (!totalMatches.value) return
|
||||||
|
currentIdx.value = (currentIdx.value + 1) % totalMatches.value
|
||||||
|
applyCurrent()
|
||||||
|
}
|
||||||
|
|
||||||
|
function prevMatch() {
|
||||||
|
if (!totalMatches.value) return
|
||||||
|
currentIdx.value = (currentIdx.value - 1 + totalMatches.value) % totalMatches.value
|
||||||
|
applyCurrent()
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearLocalQuery() {
|
||||||
|
localQuery.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderBody() {
|
||||||
|
await nextTick()
|
||||||
|
const el = bodyContainer.value
|
||||||
|
if (!el) return
|
||||||
|
|
||||||
|
const raw = props.activity?.body
|
||||||
|
el.innerHTML = raw ? ((fixLink(raw) as string) || '') : ''
|
||||||
|
|
||||||
|
if (scrollContainer.value) scrollContainer.value.scrollTop = 0
|
||||||
|
|
||||||
|
applyHighlights(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyHighlights(preserveScrollIfSameQuery = false) {
|
||||||
|
const el = bodyContainer.value
|
||||||
|
if (!el) return
|
||||||
|
|
||||||
|
// Wipe any previous <mark> wrappers by replacing them with text nodes.
|
||||||
|
const previous = el.querySelectorAll('mark.search-match')
|
||||||
|
previous.forEach((m) => {
|
||||||
|
const text = document.createTextNode(m.textContent || '')
|
||||||
|
m.parentNode?.replaceChild(text, m)
|
||||||
|
})
|
||||||
|
// Merge adjacent text nodes so the next regex pass works cleanly.
|
||||||
|
el.normalize()
|
||||||
|
|
||||||
|
const terms = parseTerms(debouncedLocalQuery.value || '')
|
||||||
|
|
||||||
|
const count = terms.length ? highlightTextNodes(el, terms) : 0
|
||||||
|
totalMatches.value = count
|
||||||
|
currentIdx.value = 0
|
||||||
|
|
||||||
|
if (count > 0) {
|
||||||
|
nextTick(() => applyCurrent(!preserveScrollIfSameQuery))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-render entire body when the activity changes.
|
||||||
|
watch(() => props.activity?._id, renderBody, { immediate: true })
|
||||||
|
|
||||||
|
// Re-apply highlights when the local search query changes.
|
||||||
|
watch(debouncedLocalQuery, () => {
|
||||||
|
// Don't auto-scroll on every keystroke; jump to first match only on activity change.
|
||||||
|
applyHighlights(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(renderBody)
|
||||||
|
|
||||||
|
// Keyboard helpers when the input is focused.
|
||||||
|
function onInputKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
if (e.shiftKey) prevMatch()
|
||||||
|
else nextMatch()
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
clearLocalQuery()
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<UDashboardPanel id="inbox-2">
|
<UDashboardPanel id="activity-detail">
|
||||||
<UDashboardNavbar :title="activity.title" :toggle="false">
|
<UDashboardNavbar :title="activity.title" :toggle="false">
|
||||||
<template #leading>
|
<template #leading>
|
||||||
<UButton
|
<UButton
|
||||||
|
|
@ -50,59 +303,123 @@ const body = computed(async() => {
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #right>
|
<template #right>
|
||||||
<UTooltip text="Archive">
|
|
||||||
<UButton
|
<UButton
|
||||||
icon="i-lucide-inbox"
|
v-if="matchUrl"
|
||||||
color="neutral"
|
:to="matchUrl"
|
||||||
variant="ghost"
|
target="_blank"
|
||||||
|
icon="i-lucide-external-link"
|
||||||
|
label="Ver en sitio"
|
||||||
|
color="primary"
|
||||||
|
variant="solid"
|
||||||
|
size="sm"
|
||||||
/>
|
/>
|
||||||
</UTooltip>
|
|
||||||
|
|
||||||
<UTooltip text="Reply">
|
|
||||||
<UButton icon="i-lucide-reply" color="neutral" variant="ghost" />
|
|
||||||
</UTooltip>
|
|
||||||
|
|
||||||
<UDropdownMenu :items="dropdownItems">
|
|
||||||
<UButton
|
|
||||||
icon="i-lucide-ellipsis-vertical"
|
|
||||||
color="neutral"
|
|
||||||
variant="ghost"
|
|
||||||
/>
|
|
||||||
</UDropdownMenu>
|
|
||||||
</template>
|
</template>
|
||||||
</UDashboardNavbar>
|
</UDashboardNavbar>
|
||||||
|
|
||||||
<div class="flex flex-col sm:flex-row justify-between gap-1 p-4 sm:px-6 border-b border-default">
|
<div class="flex flex-col sm:flex-row justify-between gap-2 p-4 sm:px-6 border-b border-default">
|
||||||
<div class="flex items-start gap-4 sm:my-1.5">
|
|
||||||
<!-- <UAvatar
|
|
||||||
v-bind="activity.from.avatar"
|
|
||||||
:alt="activity.from.name"
|
|
||||||
size="3xl"
|
|
||||||
/> -->
|
|
||||||
|
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<p class="font-semibold text-muted text-sm text-highlighted">
|
<p class="font-semibold text-sm text-highlighted">
|
||||||
{{ formatDate(new Date(activity.date).getTime()/1000) }}
|
{{ safeDate(activity) }}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-muted">
|
<p class="text-muted text-sm truncate">
|
||||||
<!-- {{ activity.email }} -->
|
{{ formatLocation(activity) }}
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="max-sm:pl-16 text-muted text-sm sm:mt-2">
|
|
||||||
{{ formatLocation( activity ) }}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-1 p-4 sm:p-6 overflow-y-auto">
|
<ul v-if="fileLinks.length" class="flex flex-wrap gap-3 text-xs text-muted">
|
||||||
|
<li
|
||||||
{{ activity._id }}
|
v-for="(f, idx) in fileLinks"
|
||||||
<p class="whitespace-pre-wrap" v-html="body">
|
:key="idx"
|
||||||
|
class="flex items-center"
|
||||||
</p>
|
>
|
||||||
|
<ULink
|
||||||
|
:to="f.to"
|
||||||
|
:target="f.target"
|
||||||
|
class="flex items-center gap-1 hover:text-primary"
|
||||||
|
>
|
||||||
|
<UIcon :name="f.icon!" class="size-4" />
|
||||||
|
<span>{{ f.label }}</span>
|
||||||
|
</ULink>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div ref="scrollContainer" class="flex-1 overflow-y-auto relative">
|
||||||
|
<!-- Find-in-document toolbar: sticky at the top of the scroll area so
|
||||||
|
the user can always reach it while reading. -->
|
||||||
|
<div
|
||||||
|
v-if="activity?.body"
|
||||||
|
class="sticky top-0 z-10 bg-default/95 backdrop-blur-sm border-b border-default px-4 sm:px-6 py-2 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<div class="relative flex-1 min-w-0 max-w-md">
|
||||||
|
<UInput
|
||||||
|
v-model="localQuery"
|
||||||
|
icon="i-lucide-search"
|
||||||
|
placeholder="Buscar frase exacta en este documento..."
|
||||||
|
size="sm"
|
||||||
|
class="w-full"
|
||||||
|
:ui="{ trailing: 'pe-1' }"
|
||||||
|
@keydown="onInputKey"
|
||||||
|
>
|
||||||
|
<template #trailing>
|
||||||
|
<UButton
|
||||||
|
v-if="localQuery"
|
||||||
|
icon="i-lucide-x"
|
||||||
|
color="neutral"
|
||||||
|
variant="link"
|
||||||
|
size="xs"
|
||||||
|
aria-label="Limpiar"
|
||||||
|
@click="clearLocalQuery"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</UInput>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span
|
||||||
|
class="text-xs tabular-nums whitespace-nowrap min-w-[3.5rem] text-center"
|
||||||
|
:class="totalMatches ? 'text-toned font-medium' : 'text-dimmed'"
|
||||||
|
>
|
||||||
|
<template v-if="localQuery">
|
||||||
|
{{ totalMatches ? `${currentIdx + 1} / ${totalMatches}` : '0 / 0' }}
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<UTooltip text="Anterior (Shift+Enter)">
|
||||||
|
<UButton
|
||||||
|
icon="i-lucide-chevron-up"
|
||||||
|
color="neutral"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
:disabled="!totalMatches"
|
||||||
|
aria-label="Coincidencia anterior"
|
||||||
|
@click="prevMatch"
|
||||||
|
/>
|
||||||
|
</UTooltip>
|
||||||
|
<UTooltip text="Siguiente (Enter)">
|
||||||
|
<UButton
|
||||||
|
icon="i-lucide-chevron-down"
|
||||||
|
color="neutral"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
:disabled="!totalMatches"
|
||||||
|
aria-label="Coincidencia siguiente"
|
||||||
|
@click="nextMatch"
|
||||||
|
/>
|
||||||
|
</UTooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<article
|
||||||
|
v-if="activity?.body"
|
||||||
|
ref="bodyContainer"
|
||||||
|
class="prose prose-sm max-w-none dark:prose-invert p-4 sm:p-6"
|
||||||
|
/>
|
||||||
|
<p v-else class="p-4 sm:p-6 text-sm text-muted">
|
||||||
|
No hay contenido disponible para esta coincidencia.
|
||||||
|
<span v-if="matchUrl">
|
||||||
|
Puedes
|
||||||
|
<ULink :to="matchUrl" target="_blank" class="text-primary">verla en el sitio</ULink>.
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</UDashboardPanel>
|
</UDashboardPanel>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -1,101 +1,761 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { format, isToday } from 'date-fns'
|
import { useIntersectionObserver } from '@vueuse/core'
|
||||||
import type { Activity } from '~/types'
|
import type { SearchHit } from '~/types'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
activities: Activity[]
|
activities: SearchHit[]
|
||||||
|
query?: string
|
||||||
|
hasMore?: boolean
|
||||||
|
loading?: boolean
|
||||||
|
loadingMore?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const activitiesRefs = ref<Record<number, Element | null>>({})
|
const emits = defineEmits<{
|
||||||
|
loadMore: []
|
||||||
|
}>()
|
||||||
|
|
||||||
const selectedActivity = defineModel<Activity | null>()
|
const activitiesRefs = ref<Record<string | number, Element | null>>({})
|
||||||
|
|
||||||
|
const selectedActivity = defineModel<SearchHit | null>()
|
||||||
|
|
||||||
watch(selectedActivity, () => {
|
watch(selectedActivity, () => {
|
||||||
if (!selectedActivity.value) {
|
if (!selectedActivity.value) return
|
||||||
return
|
const el = activitiesRefs.value[selectedActivity.value._id]
|
||||||
}
|
if (el) el.scrollIntoView({ block: 'nearest' })
|
||||||
const ref = activitiesRefs.value[selectedActivity.value._id]
|
|
||||||
if (ref) {
|
|
||||||
ref.scrollIntoView({ block: 'nearest' })
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const activityLinks = [
|
|
||||||
{
|
|
||||||
label: 'Youtube',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Audio',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Libro',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Sencillo',
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
defineShortcuts({
|
defineShortcuts({
|
||||||
arrowdown: () => {
|
arrowdown: () => {
|
||||||
const index = props.activities.findIndex((activity: Activity) => activity._id === selectedActivity.value?._id)
|
const list = props.activities
|
||||||
|
if (!list.length) return
|
||||||
if (index === -1) {
|
const idx = list.findIndex(a => a._id === selectedActivity.value?._id)
|
||||||
selectedActivity.value = props.activities[0]
|
if (idx === -1) selectedActivity.value = list[0]
|
||||||
} else if (index < props.activities.length - 1) {
|
else if (idx < list.length - 1) selectedActivity.value = list[idx + 1]
|
||||||
selectedActivity.value = props.activities[index + 1]
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
arrowup: () => {
|
arrowup: () => {
|
||||||
const index = props.activities.findIndex((activity: Activity) => activity._id === selectedActivity.value?._id)
|
const list = props.activities
|
||||||
|
if (!list.length) return
|
||||||
if (index === -1) {
|
const idx = list.findIndex(a => a._id === selectedActivity.value?._id)
|
||||||
selectedActivity.value = props.activities[props.activities.length - 1]
|
if (idx === -1) selectedActivity.value = list[list.length - 1]
|
||||||
} else if (index > 0) {
|
else if (idx > 0) selectedActivity.value = list[idx - 1]
|
||||||
selectedActivity.value = props.activities[index - 1]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function safeDate(activity: SearchHit) {
|
||||||
|
const d = activity.date ?? activity.isodate
|
||||||
|
if (!d) return ''
|
||||||
|
const ts = typeof d === 'string' ? new Date(d).getTime() : (d as number) * 1000
|
||||||
|
if (!Number.isFinite(ts)) return ''
|
||||||
|
return formatDate(ts / 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasDate(activity: SearchHit) {
|
||||||
|
return activity.date != null || activity.isodate != null
|
||||||
|
}
|
||||||
|
|
||||||
|
const HIDDEN_FIELDS = new Set([
|
||||||
|
'_id', 'id', 'slug', 'date', 'isodate', 'type', 'thumbnail', 'files',
|
||||||
|
'place', 'city', 'state', 'country',
|
||||||
|
'title', 'body'
|
||||||
|
])
|
||||||
|
|
||||||
|
const FIELD_LABELS: Record<string, string> = {
|
||||||
|
speaker: 'orador',
|
||||||
|
speakers: 'oradores',
|
||||||
|
preacher: 'predicador',
|
||||||
|
author: 'autor',
|
||||||
|
authors: 'autores',
|
||||||
|
tags: 'etiquetas',
|
||||||
|
keywords: 'palabras clave',
|
||||||
|
categories: 'categorías',
|
||||||
|
category: 'categoría',
|
||||||
|
transcript: 'transcripción',
|
||||||
|
description: 'descripción',
|
||||||
|
excerpt: 'extracto',
|
||||||
|
summary: 'resumen',
|
||||||
|
notes: 'notas',
|
||||||
|
activity: 'actividad',
|
||||||
|
conference: 'conferencia'
|
||||||
|
}
|
||||||
|
|
||||||
|
function fieldLabel(key: string) {
|
||||||
|
return FIELD_LABELS[key] || key
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Text helpers ------------------------------------------------------
|
||||||
|
|
||||||
|
function escapeHtml(s: string): string {
|
||||||
|
return s
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeRegex(s: string): string {
|
||||||
|
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Strip diacritics + lowercase. Matches Meilisearch's default normalization
|
||||||
|
* so the user can type without accents and still find accented text. */
|
||||||
|
function normalize(s: string): string {
|
||||||
|
return s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a position-preserving normalized version of `text` plus a map from
|
||||||
|
* normalized index -> original index. Combining marks decompose into 0-length
|
||||||
|
* contributions, so the original index can drift; the map handles that. */
|
||||||
|
function normalizeWithMap(text: string): { normalized: string, map: number[] } {
|
||||||
|
let normalized = ''
|
||||||
|
const map: number[] = []
|
||||||
|
for (let i = 0; i < text.length; i++) {
|
||||||
|
const norm = text[i].normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase()
|
||||||
|
for (let j = 0; j < norm.length; j++) {
|
||||||
|
normalized += norm[j]
|
||||||
|
map.push(i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { normalized, map }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compile a term into a regex source that matches the same phrase even when
|
||||||
|
* the whitespace differs (single space vs newline, double space, etc.). */
|
||||||
|
function termToRegexSource(term: string): string {
|
||||||
|
const parts = term.split(/\s+/).filter(Boolean)
|
||||||
|
if (parts.length === 0) return ''
|
||||||
|
if (parts.length === 1) return escapeRegex(parts[0])
|
||||||
|
return parts.map(escapeRegex).join('\\s+')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Find all literal occurrences of any term in `text`, accent-insensitive and
|
||||||
|
* whitespace-flexible. Returns spans of the ORIGINAL text. */
|
||||||
|
function findMatchesInText(text: string, terms: string[]): Array<{ start: number, end: number }> {
|
||||||
|
if (!text || !terms.length) return []
|
||||||
|
const sources = terms.map(t => termToRegexSource(normalize(t))).filter(s => s.length > 0)
|
||||||
|
if (!sources.length) return []
|
||||||
|
|
||||||
|
const { normalized, map } = normalizeWithMap(text)
|
||||||
|
const re = new RegExp(`(${sources.join('|')})`, 'g')
|
||||||
|
const out: Array<{ start: number, end: number }> = []
|
||||||
|
|
||||||
|
let m: RegExpExecArray | null
|
||||||
|
while ((m = re.exec(normalized)) !== null) {
|
||||||
|
if (m[0].length === 0) { re.lastIndex++; continue }
|
||||||
|
const start = map[m.index] ?? text.length
|
||||||
|
const endNormIdx = m.index + m[0].length
|
||||||
|
const end = endNormIdx < map.length ? map[endNormIdx] : text.length
|
||||||
|
out.push({ start, end })
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripHtml(s: string): string {
|
||||||
|
return s
|
||||||
|
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, ' ')
|
||||||
|
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, ' ')
|
||||||
|
.replace(/<[^>]+>/g, ' ')
|
||||||
|
.replace(/ /g, ' ')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Like stripHtml but also drops the *content* of inline citation markers
|
||||||
|
* (superscripts, subscripts, footnote refs). Useful when a phrase like
|
||||||
|
* "los países que" gets interrupted in the source by `<sup>1</sup>` between
|
||||||
|
* words — the standard strip leaves "los 1 países que" and breaks the match. */
|
||||||
|
function stripHtmlAggressive(s: string): string {
|
||||||
|
return s
|
||||||
|
.replace(/<sup[^>]*>[\s\S]*?<\/sup>/gi, ' ')
|
||||||
|
.replace(/<sub[^>]*>[\s\S]*?<\/sub>/gi, ' ')
|
||||||
|
.replace(/<a\b[^>]*class="[^"]*(?:footnote|fn-ref|ref)[^"]*"[^>]*>[\s\S]*?<\/a>/gi, ' ')
|
||||||
|
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, ' ')
|
||||||
|
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, ' ')
|
||||||
|
.replace(/<[^>]+>/g, ' ')
|
||||||
|
.replace(/ /g, ' ')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tokenize the user's query, respecting double-quoted phrases.
|
||||||
|
* los paises "que vean" otra
|
||||||
|
* -> ['los', 'paises', 'que vean', 'otra']
|
||||||
|
* Quoted phrases are kept intact so they're matched and highlighted as a unit;
|
||||||
|
* standalone words must be at least 2 chars to avoid noisy "a", "y" matches.
|
||||||
|
*/
|
||||||
|
function buildTerms(query: string): string[] {
|
||||||
|
if (!query) return []
|
||||||
|
const out: string[] = []
|
||||||
|
const re = /"([^"]+)"|(\S+)/g
|
||||||
|
let m: RegExpExecArray | null
|
||||||
|
while ((m = re.exec(query)) !== null) {
|
||||||
|
if (m[1] !== undefined) {
|
||||||
|
const phrase = m[1].trim()
|
||||||
|
if (phrase.length > 0) out.push(phrase)
|
||||||
|
} else if (m[2] !== undefined) {
|
||||||
|
const word = m[2].trim()
|
||||||
|
if (word.length > 1) out.push(word)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wrap each accent-insensitive occurrence of any term in <mark>.
|
||||||
|
* The marked content keeps the ORIGINAL characters (with accents), so the
|
||||||
|
* display preserves the text the user is reading, not the normalized form. */
|
||||||
|
function highlightPlain(text: string, terms: string[]): string {
|
||||||
|
if (!text) return ''
|
||||||
|
if (!terms.length) return escapeHtml(text)
|
||||||
|
const matches = findMatchesInText(text, terms)
|
||||||
|
if (!matches.length) return escapeHtml(text)
|
||||||
|
|
||||||
|
let out = ''
|
||||||
|
let cursor = 0
|
||||||
|
for (const { start, end } of matches) {
|
||||||
|
if (start > cursor) out += escapeHtml(text.slice(cursor, start))
|
||||||
|
out += '<mark class="search-match">' + escapeHtml(text.slice(start, end)) + '</mark>'
|
||||||
|
cursor = end
|
||||||
|
}
|
||||||
|
if (cursor < text.length) out += escapeHtml(text.slice(cursor))
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function findInPlain(text: string, terms: string[]): boolean {
|
||||||
|
if (!text || !terms.length) return false
|
||||||
|
if (findMatchesInText(stripHtml(text), terms).length > 0) return true
|
||||||
|
// Try once more with aggressive stripping (drops <sup>/<sub> content) so
|
||||||
|
// phrases interrupted by footnote/verse markers still register as matches.
|
||||||
|
return findMatchesInText(stripHtmlAggressive(text), terms).length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Strip *block-level* tags so the snippet renders cleanly inline (with line-clamp).
|
||||||
|
* Inline tags (b, strong, em, i, u, sup, sub, a, span, mark, br, etc.) are kept. */
|
||||||
|
function flattenBlocksToInline(html: string): string {
|
||||||
|
const blockTags = 'p|div|li|ul|ol|h[1-6]|blockquote|tr|td|th|article|section|aside|header|footer|nav|main|figure|figcaption|pre|table|thead|tbody|tfoot'
|
||||||
|
const re = new RegExp(`</?(?:${blockTags})\\b[^>]*>`, 'gi')
|
||||||
|
return html.replace(re, ' ').replace(/\s+/g, ' ').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Walk all text nodes, build the FLAT text content of `root`, find matches in
|
||||||
|
* that flat text, then for each text node mark whichever portion of the match
|
||||||
|
* falls inside it. This handles phrases split across HTML tags (e.g.
|
||||||
|
* "los <em>países</em> que está") — they become several adjacent <mark>s. */
|
||||||
|
function applyHighlightsToContainer(root: Element, terms: string[]): Element[] {
|
||||||
|
if (!terms.length) return []
|
||||||
|
|
||||||
|
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null)
|
||||||
|
const infos: { node: Text, start: number, end: number }[] = []
|
||||||
|
let flat = ''
|
||||||
|
let n: Node | null
|
||||||
|
while ((n = walker.nextNode())) {
|
||||||
|
const node = n as Text
|
||||||
|
const text = node.nodeValue || ''
|
||||||
|
infos.push({ node, start: flat.length, end: flat.length + text.length })
|
||||||
|
flat += text
|
||||||
|
}
|
||||||
|
if (!infos.length) return []
|
||||||
|
|
||||||
|
const matches = findMatchesInText(flat, terms)
|
||||||
|
if (!matches.length) return []
|
||||||
|
|
||||||
|
const marks: Element[] = []
|
||||||
|
|
||||||
|
// Process text nodes from last to first so DOM modifications (which only
|
||||||
|
// affect the node currently being replaced) don't invalidate references to
|
||||||
|
// earlier nodes. Each node gets replaced once with a fragment containing
|
||||||
|
// its 0..N match segments wrapped in <mark>.
|
||||||
|
for (let i = infos.length - 1; i >= 0; i--) {
|
||||||
|
const info = infos[i]
|
||||||
|
const nodeText = info.node.nodeValue || ''
|
||||||
|
|
||||||
|
const segments: { start: number, end: number }[] = []
|
||||||
|
for (const match of matches) {
|
||||||
|
const segStart = Math.max(match.start, info.start) - info.start
|
||||||
|
const segEnd = Math.min(match.end, info.end) - info.start
|
||||||
|
if (segStart < segEnd) segments.push({ start: segStart, end: segEnd })
|
||||||
|
}
|
||||||
|
if (!segments.length) continue
|
||||||
|
segments.sort((a, b) => a.start - b.start)
|
||||||
|
|
||||||
|
const frag = document.createDocumentFragment()
|
||||||
|
let cursor = 0
|
||||||
|
for (const { start, end } of segments) {
|
||||||
|
if (start > cursor) frag.appendChild(document.createTextNode(nodeText.slice(cursor, start)))
|
||||||
|
const mark = document.createElement('mark')
|
||||||
|
mark.className = 'search-match'
|
||||||
|
mark.textContent = nodeText.slice(start, end)
|
||||||
|
frag.appendChild(mark)
|
||||||
|
marks.push(mark)
|
||||||
|
cursor = end
|
||||||
|
}
|
||||||
|
if (cursor < nodeText.length) frag.appendChild(document.createTextNode(nodeText.slice(cursor)))
|
||||||
|
info.node.parentNode?.replaceChild(frag, info.node)
|
||||||
|
}
|
||||||
|
|
||||||
|
marks.reverse() // restore document order
|
||||||
|
return marks
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build an HTML snippet centered on the first match, preserving inline
|
||||||
|
* formatting from the source (bold, italic, sup, links, etc.). Uses Range API
|
||||||
|
* so any tag opened inside the cropped region is automatically closed. */
|
||||||
|
function snippetHtmlAroundMatch(rawHtml: string, terms: string[], ctx = 120): string | null {
|
||||||
|
if (!rawHtml) return null
|
||||||
|
if (typeof document === 'undefined') return null
|
||||||
|
if (!terms.length) return null
|
||||||
|
|
||||||
|
const container = document.createElement('div')
|
||||||
|
container.innerHTML = (typeof fixLink === 'function' ? fixLink(rawHtml) || '' : rawHtml)
|
||||||
|
|
||||||
|
const marks = applyHighlightsToContainer(container, terms)
|
||||||
|
if (!marks.length) return null
|
||||||
|
|
||||||
|
// Collect all text nodes in document order *after* highlighting.
|
||||||
|
const allText: Text[] = []
|
||||||
|
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, null)
|
||||||
|
let n: Node | null
|
||||||
|
while ((n = walker.nextNode())) allText.push(n as Text)
|
||||||
|
if (!allText.length) return null
|
||||||
|
|
||||||
|
// Find text-node boundaries of the first <mark>.
|
||||||
|
const firstMark = marks[0]
|
||||||
|
const markText: Text[] = []
|
||||||
|
const mw = document.createTreeWalker(firstMark, NodeFilter.SHOW_TEXT, null)
|
||||||
|
while ((n = mw.nextNode())) markText.push(n as Text)
|
||||||
|
if (!markText.length) return null
|
||||||
|
|
||||||
|
const matchStartIdx = allText.indexOf(markText[0])
|
||||||
|
const matchEndIdx = allText.indexOf(markText[markText.length - 1])
|
||||||
|
if (matchStartIdx < 0 || matchEndIdx < 0) return null
|
||||||
|
|
||||||
|
// Walk back to gather ~ctx chars before the match.
|
||||||
|
let startNode: Text = allText[matchStartIdx]
|
||||||
|
let startOffset = 0
|
||||||
|
let before = ctx
|
||||||
|
for (let i = matchStartIdx - 1; i >= 0 && before > 0; i--) {
|
||||||
|
const len = allText[i].nodeValue?.length || 0
|
||||||
|
if (len >= before) {
|
||||||
|
startNode = allText[i]
|
||||||
|
startOffset = Math.max(0, len - before)
|
||||||
|
before = 0
|
||||||
|
} else {
|
||||||
|
before -= len
|
||||||
|
startNode = allText[i]
|
||||||
|
startOffset = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk forward to gather ~ctx chars after the match.
|
||||||
|
let endNode: Text = allText[matchEndIdx]
|
||||||
|
let endOffset = endNode.nodeValue?.length || 0
|
||||||
|
let after = ctx
|
||||||
|
for (let i = matchEndIdx + 1; i < allText.length && after > 0; i++) {
|
||||||
|
const len = allText[i].nodeValue?.length || 0
|
||||||
|
if (len >= after) {
|
||||||
|
endNode = allText[i]
|
||||||
|
endOffset = after
|
||||||
|
after = 0
|
||||||
|
} else {
|
||||||
|
after -= len
|
||||||
|
endNode = allText[i]
|
||||||
|
endOffset = len
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snap boundaries to nearest word breaks for cleaner cuts.
|
||||||
|
const startText = startNode.nodeValue || ''
|
||||||
|
if (startOffset > 0 && startOffset < startText.length) {
|
||||||
|
const sp = startText.indexOf(' ', startOffset)
|
||||||
|
if (sp !== -1 && sp - startOffset < 25) startOffset = sp + 1
|
||||||
|
}
|
||||||
|
const endText = endNode.nodeValue || ''
|
||||||
|
if (endOffset > 0 && endOffset < endText.length) {
|
||||||
|
const sp = endText.lastIndexOf(' ', endOffset)
|
||||||
|
if (sp !== -1 && endOffset - sp < 25) endOffset = sp
|
||||||
|
}
|
||||||
|
|
||||||
|
const range = document.createRange()
|
||||||
|
range.setStart(startNode, startOffset)
|
||||||
|
range.setEnd(endNode, endOffset)
|
||||||
|
const tmp = document.createElement('div')
|
||||||
|
tmp.appendChild(range.cloneContents())
|
||||||
|
|
||||||
|
let html = flattenBlocksToInline(tmp.innerHTML)
|
||||||
|
|
||||||
|
// Ellipsis if we cut anything off.
|
||||||
|
const truncatedStart = startNode !== allText[0] || startOffset > 0
|
||||||
|
const lastNode = allText[allText.length - 1]
|
||||||
|
const lastLen = lastNode.nodeValue?.length || 0
|
||||||
|
const truncatedEnd = endNode !== lastNode || endOffset < lastLen
|
||||||
|
if (truncatedStart) html = '… ' + html
|
||||||
|
if (truncatedEnd) html = html + ' …'
|
||||||
|
|
||||||
|
return html
|
||||||
|
}
|
||||||
|
|
||||||
|
/** No-query case: take a short HTML chunk from the start of the body. */
|
||||||
|
function startHtmlChunk(rawHtml: string, maxChars = 180): string | null {
|
||||||
|
if (!rawHtml) return null
|
||||||
|
if (typeof document === 'undefined') {
|
||||||
|
const plain = stripHtml(rawHtml)
|
||||||
|
return plain ? escapeHtml(plain.length > maxChars ? plain.slice(0, maxChars) + '…' : plain) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = document.createElement('div')
|
||||||
|
container.innerHTML = (typeof fixLink === 'function' ? fixLink(rawHtml) || '' : rawHtml)
|
||||||
|
|
||||||
|
const allText: Text[] = []
|
||||||
|
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, null)
|
||||||
|
let n: Node | null
|
||||||
|
while ((n = walker.nextNode())) allText.push(n as Text)
|
||||||
|
if (!allText.length) return null
|
||||||
|
|
||||||
|
let endNode: Text = allText[0]
|
||||||
|
let endOffset = 0
|
||||||
|
let remaining = maxChars
|
||||||
|
for (let i = 0; i < allText.length && remaining > 0; i++) {
|
||||||
|
const len = allText[i].nodeValue?.length || 0
|
||||||
|
if (len >= remaining) {
|
||||||
|
endNode = allText[i]
|
||||||
|
endOffset = remaining
|
||||||
|
remaining = 0
|
||||||
|
} else {
|
||||||
|
remaining -= len
|
||||||
|
endNode = allText[i]
|
||||||
|
endOffset = len
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const range = document.createRange()
|
||||||
|
range.setStart(allText[0], 0)
|
||||||
|
range.setEnd(endNode, endOffset)
|
||||||
|
const tmp = document.createElement('div')
|
||||||
|
tmp.appendChild(range.cloneContents())
|
||||||
|
|
||||||
|
let html = flattenBlocksToInline(tmp.innerHTML)
|
||||||
|
|
||||||
|
const lastNode = allText[allText.length - 1]
|
||||||
|
const lastLen = lastNode.nodeValue?.length || 0
|
||||||
|
if (endNode !== lastNode || endOffset < lastLen) html = html + ' …'
|
||||||
|
|
||||||
|
return html
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Plain-text fallback when the HTML-preserving snippet can't find the phrase
|
||||||
|
* (because tags get in the way). We strip HTML, find the phrase in the
|
||||||
|
* resulting text, and build a snippet around it. Loses inline formatting but
|
||||||
|
* guarantees a snippet exists for every result Meilisearch returned. */
|
||||||
|
function snippetPlainAroundMatch(rawHtml: string, terms: string[], ctx = 120, aggressive = false): string | null {
|
||||||
|
const plain = aggressive ? stripHtmlAggressive(rawHtml) : stripHtml(rawHtml)
|
||||||
|
if (!plain) return null
|
||||||
|
|
||||||
|
const matches = findMatchesInText(plain, terms)
|
||||||
|
if (!matches.length) return null
|
||||||
|
|
||||||
|
const first = matches[0]
|
||||||
|
let start = Math.max(0, first.start - ctx)
|
||||||
|
let end = Math.min(plain.length, first.end + ctx)
|
||||||
|
|
||||||
|
if (start > 0) {
|
||||||
|
const sp = plain.indexOf(' ', start)
|
||||||
|
if (sp !== -1 && sp - start < 25) start = sp + 1
|
||||||
|
}
|
||||||
|
if (end < plain.length) {
|
||||||
|
const sp = plain.lastIndexOf(' ', end)
|
||||||
|
if (sp !== -1 && end - sp < 25) end = sp
|
||||||
|
}
|
||||||
|
|
||||||
|
// Highlight every match that falls inside the snippet window.
|
||||||
|
const winMatches = matches.filter(m => m.start >= start && m.end <= end)
|
||||||
|
let html = ''
|
||||||
|
let cursor = start
|
||||||
|
for (const m of winMatches) {
|
||||||
|
if (m.start > cursor) html += escapeHtml(plain.slice(cursor, m.start))
|
||||||
|
html += '<mark class="search-match">' + escapeHtml(plain.slice(m.start, m.end)) + '</mark>'
|
||||||
|
cursor = m.end
|
||||||
|
}
|
||||||
|
if (cursor < end) html += escapeHtml(plain.slice(cursor, end))
|
||||||
|
|
||||||
|
if (start > 0) html = '… ' + html
|
||||||
|
if (end < plain.length) html = html + ' …'
|
||||||
|
return html
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BodyPreview {
|
||||||
|
html: string
|
||||||
|
/** True if we couldn't pinpoint the phrase position in the body and are
|
||||||
|
* just showing the start of the document as fallback context. */
|
||||||
|
approximate: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function bodyPreview(rawHtml: string, terms: string[], meiliMatchedBody = false): BodyPreview | null {
|
||||||
|
if (!rawHtml) return null
|
||||||
|
if (terms.length) {
|
||||||
|
// 1. HTML-preserving snippet (keeps bold/italic/sup formatting).
|
||||||
|
const htmlSnippet = snippetHtmlAroundMatch(rawHtml, terms, 120)
|
||||||
|
if (htmlSnippet) return { html: htmlSnippet, approximate: false }
|
||||||
|
// 2. Plain-text snippet (handles phrases split across simple inline tags).
|
||||||
|
const plainSnippet = snippetPlainAroundMatch(rawHtml, terms, 120, false)
|
||||||
|
if (plainSnippet) return { html: plainSnippet, approximate: false }
|
||||||
|
// 3. Aggressive plain-text snippet — drops <sup>/<sub> content (footnote
|
||||||
|
// numbers, verse markers) that often interrupt phrases between words.
|
||||||
|
const aggressiveSnippet = snippetPlainAroundMatch(rawHtml, terms, 120, true)
|
||||||
|
if (aggressiveSnippet) return { html: aggressiveSnippet, approximate: false }
|
||||||
|
// 4. Meilisearch insists the body matched but we can't pinpoint where —
|
||||||
|
// show the start of the body as fallback context, and flag the row.
|
||||||
|
if (meiliMatchedBody) {
|
||||||
|
const start = startHtmlChunk(rawHtml, 180)
|
||||||
|
if (start) return { html: start, approximate: true }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const start = startHtmlChunk(rawHtml, 200)
|
||||||
|
return start ? { html: start, approximate: false } : null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Row view-model ----------------------------------------------------
|
||||||
|
|
||||||
|
interface RowVm {
|
||||||
|
activity: SearchHit
|
||||||
|
title: string
|
||||||
|
body: string | null
|
||||||
|
bodyApproximate: boolean
|
||||||
|
extraSnippets: { field: string, snippet: string }[]
|
||||||
|
extraFields: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = computed<RowVm[]>(() => {
|
||||||
|
const terms = buildTerms(props.query || '')
|
||||||
|
const hasQuery = terms.length > 0
|
||||||
|
|
||||||
|
const result = props.activities.map((activity) => {
|
||||||
|
const titleText = activity.title || ''
|
||||||
|
const titleHtml = highlightPlain(titleText, terms)
|
||||||
|
|
||||||
|
const meiliMatchedBody = !!activity._matchesPosition?.body
|
||||||
|
let bodyResult = bodyPreview(activity.body || '', terms, meiliMatchedBody)
|
||||||
|
|
||||||
|
const titleMatched = hasQuery && findInPlain(titleText, terms)
|
||||||
|
const bodyMatched = hasQuery && !!bodyResult && !bodyResult.approximate
|
||||||
|
&& bodyResult.html.includes('<mark')
|
||||||
|
|
||||||
|
const showFallback = hasQuery && !titleMatched && !bodyMatched
|
||||||
|
|
||||||
|
const extraSnippets: RowVm['extraSnippets'] = []
|
||||||
|
const extraFields: string[] = []
|
||||||
|
|
||||||
|
if (showFallback) {
|
||||||
|
for (const key of Object.keys(activity)) {
|
||||||
|
if (HIDDEN_FIELDS.has(key) || key.startsWith('_')) continue
|
||||||
|
const val = (activity as Record<string, unknown>)[key]
|
||||||
|
if (typeof val !== 'string') continue
|
||||||
|
if (!findInPlain(val, terms)) continue
|
||||||
|
if (!extraFields.includes(key)) extraFields.push(key)
|
||||||
|
if (extraSnippets.length < 2) {
|
||||||
|
const snip = snippetHtmlAroundMatch(val, terms, 90)
|
||||||
|
|| snippetPlainAroundMatch(val, terms, 90, false)
|
||||||
|
|| snippetPlainAroundMatch(val, terms, 90, true)
|
||||||
|
if (snip) extraSnippets.push({ field: key, snippet: snip })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!extraFields.length && activity._matchesPosition) {
|
||||||
|
for (const key of Object.keys(activity._matchesPosition)) {
|
||||||
|
if (!HIDDEN_FIELDS.has(key)) extraFields.push(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Universal safety net: if there's a query but we still have NOTHING to
|
||||||
|
// show below the title (no body snippet, no extras, no "matched in" hint),
|
||||||
|
// surface at least the start of the body so the user knows what document
|
||||||
|
// they're looking at and that Meilisearch saw a match somewhere.
|
||||||
|
if (
|
||||||
|
hasQuery
|
||||||
|
&& !bodyResult
|
||||||
|
&& !extraSnippets.length
|
||||||
|
&& !extraFields.length
|
||||||
|
&& activity.body
|
||||||
|
) {
|
||||||
|
const start = startHtmlChunk(activity.body, 180)
|
||||||
|
if (start) bodyResult = { html: start, approximate: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
activity,
|
||||||
|
title: titleHtml,
|
||||||
|
body: bodyResult?.html ?? null,
|
||||||
|
bodyApproximate: bodyResult?.approximate ?? false,
|
||||||
|
extraSnippets,
|
||||||
|
extraFields
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (hasQuery) {
|
||||||
|
return result.filter(row => {
|
||||||
|
const titleHasMatch = row.title.includes('<mark')
|
||||||
|
const hasBodyMatch = row.body && !row.bodyApproximate
|
||||||
|
const hasExtraSnippets = row.extraSnippets.length > 0
|
||||||
|
return titleHasMatch || hasBodyMatch || hasExtraSnippets
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---- Infinite scroll ---------------------------------------------------
|
||||||
|
|
||||||
|
const sentinel = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
useIntersectionObserver(
|
||||||
|
sentinel,
|
||||||
|
([entry]) => {
|
||||||
|
if (!entry?.isIntersecting) return
|
||||||
|
if (props.hasMore && !props.loadingMore && !props.loading) {
|
||||||
|
emits('loadMore')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold: 0 }
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="overflow-y-auto divide-y divide-default">
|
<div class="overflow-y-auto divide-y divide-default flex-1">
|
||||||
<div
|
<div
|
||||||
v-for="(activity, index) in activities"
|
v-if="loading && !rows.length"
|
||||||
:key="index"
|
class="flex items-center justify-center gap-2 py-16 text-sm text-muted"
|
||||||
:ref="(el) => { activitiesRefs[activity._id] = el as Element | null }"
|
>
|
||||||
|
<UIcon name="i-lucide-loader-circle" class="size-4 animate-spin" />
|
||||||
|
Buscando...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else-if="!rows.length"
|
||||||
|
class="flex flex-col items-center justify-center gap-2 py-16 text-dimmed text-sm"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-inbox" class="size-10" />
|
||||||
|
<p>{{ query ? `Sin coincidencias para "${query}"` : 'No hay actividades' }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="(row, index) in rows"
|
||||||
|
:key="row.activity._id ?? index"
|
||||||
|
:ref="(el) => { activitiesRefs[row.activity._id] = el as Element | null }"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="p-4 sm:px-6 text-sm cursor-pointer border-l-2 transition-colors"
|
class="p-4 sm:px-6 text-sm cursor-pointer border-l-2 transition-colors"
|
||||||
:class="[
|
:class="[
|
||||||
activity.unread ? 'text-highlighted' : 'text-toned',
|
row.activity.unread ? 'text-highlighted' : 'text-toned',
|
||||||
selectedActivity && selectedActivity._id === activity._id
|
selectedActivity && selectedActivity._id === row.activity._id
|
||||||
? 'border-primary bg-primary/10'
|
? 'border-primary bg-primary/10'
|
||||||
: 'border-bg hover:border-primary hover:bg-primary/5'
|
: 'border-transparent hover:border-primary hover:bg-primary/5'
|
||||||
]"
|
]"
|
||||||
@click="selectedActivity = activity"
|
@click="selectedActivity = row.activity"
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between" :class="[activity.unread && 'font-semibold']">
|
<div class="flex items-start justify-between gap-2 mb-1">
|
||||||
<div class="flex items-center gap-3 text-md font-semibold mb-2">
|
<div class="text-sm font-semibold line-clamp-2" v-html="row.title" />
|
||||||
{{ activity.title }}
|
<UChip v-if="row.activity.unread" />
|
||||||
|
|
||||||
<UChip v-if="activity.unread" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- <span>{{ isToday(new Date(activity.date || '')) ? format(new Date(activity.date || ''), 'HH:mm') : format(new Date(activity.date || ''), 'dd MMM') }}</span> -->
|
<p class="flex items-center gap-2 text-xs text-muted mb-1">
|
||||||
</div>
|
<span v-if="hasDate(row.activity)">{{ safeDate(row.activity) }}</span>
|
||||||
<p class="flex justify-between text-xs" :class="[activity.unread && 'font-semibold']">
|
<USeparator v-if="hasDate(row.activity)" orientation="vertical" class="h-3" />
|
||||||
{{ formatDate(new Date(activity.date).getTime()/1000) }}<USeparator orientation="vertical" class="h-4" /> {{ formatLocation(activity) }}
|
<span class="truncate">{{ formatLocation(row.activity) }}</span>
|
||||||
</p>
|
</p>
|
||||||
<p class="text-dimmed line-clamp-1" v-html="activity.body">
|
|
||||||
|
|
||||||
</p>
|
<div v-if="row.body">
|
||||||
<ul class="flex space-x-4 text-xs my-2 text-gray-400">
|
<div
|
||||||
<li class="flex align-center"><UIcon name="i-lucide-youtube" class="size-4 mr-1" />Youtube</li>
|
v-if="row.bodyApproximate"
|
||||||
<li class="flex align-center"><UIcon name="i-lucide-file-audio" class="size-4 mr-1" />Audio</li>
|
class="text-[11px] text-warning flex items-center gap-1 mb-0.5"
|
||||||
<li class="flex align-center"><UIcon name="i-lucide-book-open-text" class="size-4 mr-1" />Libro</li>
|
>
|
||||||
<li class="flex align-center"><UIcon name="i-lucide-notebook-text" class="size-4 mr-1" />Sencillo</li>
|
<UIcon name="i-lucide-info" class="size-3" />
|
||||||
</ul>
|
<span>Coincidencia en el documento (abre para ubicarla con la búsqueda interna)</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
class="snippet-html text-dimmed line-clamp-3 text-xs"
|
||||||
|
v-html="row.body"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="extra in row.extraSnippets"
|
||||||
|
:key="extra.field"
|
||||||
|
class="mt-1.5 text-xs text-dimmed flex items-start gap-1.5"
|
||||||
|
>
|
||||||
|
<UBadge
|
||||||
|
:label="fieldLabel(extra.field)"
|
||||||
|
size="xs"
|
||||||
|
color="warning"
|
||||||
|
variant="subtle"
|
||||||
|
class="mt-0.5 shrink-0 capitalize"
|
||||||
|
/>
|
||||||
|
<span class="snippet-html line-clamp-2" v-html="extra.snippet" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="!row.extraSnippets.length && row.extraFields.length"
|
||||||
|
class="mt-1.5 text-xs text-muted flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-info" class="size-3.5" />
|
||||||
|
<span>
|
||||||
|
Coincide en:
|
||||||
|
<span class="font-medium capitalize">{{ row.extraFields.map(fieldLabel).join(', ') }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ref="sentinel" class="h-12 -mt-12 pointer-events-none" aria-hidden="true" />
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="loadingMore"
|
||||||
|
class="py-3 flex items-center justify-center gap-2 text-xs text-muted"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-loader-circle" class="size-4 animate-spin" />
|
||||||
|
Cargando más...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else-if="rows.length && !hasMore && !loading"
|
||||||
|
class="py-3 text-center text-xs text-dimmed"
|
||||||
|
>
|
||||||
|
No hay más resultados
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Keep snippet HTML readable but compact. We render arbitrary inline formatting
|
||||||
|
from the source (bold, italic, sup, links, mark) — strip default link colors
|
||||||
|
so the row stays readable, and prevent inline elements from breaking layout. */
|
||||||
|
.snippet-html :deep(a) {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
pointer-events: none;
|
||||||
|
font-weight: inherit;
|
||||||
|
}
|
||||||
|
.snippet-html :deep(strong),
|
||||||
|
.snippet-html :deep(b) {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ui-color-toned, inherit);
|
||||||
|
}
|
||||||
|
.snippet-html :deep(em),
|
||||||
|
.snippet-html :deep(i) {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.snippet-html :deep(sup) {
|
||||||
|
font-size: 0.7em;
|
||||||
|
vertical-align: super;
|
||||||
|
}
|
||||||
|
.snippet-html :deep(br) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import type { DropdownMenuItem } from '@nuxt/ui'
|
|
||||||
import type { Member } from '~/types'
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
members: Member[]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const items = [{
|
|
||||||
label: 'Edit member',
|
|
||||||
onSelect: () => console.log('Edit member')
|
|
||||||
}, {
|
|
||||||
label: 'Remove member',
|
|
||||||
color: 'error' as const,
|
|
||||||
onSelect: () => console.log('Remove member')
|
|
||||||
}] satisfies DropdownMenuItem[]
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<ul role="list" class="divide-y divide-default">
|
|
||||||
<li
|
|
||||||
v-for="(member, index) in members"
|
|
||||||
:key="index"
|
|
||||||
class="flex items-center justify-between gap-3 py-3 px-4 sm:px-6"
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-3 min-w-0">
|
|
||||||
<UAvatar
|
|
||||||
v-bind="member.avatar"
|
|
||||||
size="md"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="text-sm min-w-0">
|
|
||||||
<p class="text-highlighted font-medium truncate">
|
|
||||||
{{ member.name }}
|
|
||||||
</p>
|
|
||||||
<p class="text-muted truncate">
|
|
||||||
{{ member.username }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<USelect
|
|
||||||
:model-value="member.role"
|
|
||||||
:items="['member', 'owner']"
|
|
||||||
color="neutral"
|
|
||||||
:ui="{ value: 'capitalize', item: 'capitalize' }"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<UDropdownMenu :items="items" :content="{ align: 'end' }">
|
|
||||||
<UButton
|
|
||||||
icon="i-lucide-ellipsis-vertical"
|
|
||||||
color="neutral"
|
|
||||||
variant="ghost"
|
|
||||||
/>
|
|
||||||
</UDropdownMenu>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</template>
|
|
||||||
|
|
@ -1,25 +1,14 @@
|
||||||
import { createSharedComposable } from '@vueuse/core'
|
import { createSharedComposable } from '@vueuse/core'
|
||||||
|
|
||||||
const _useDashboard = () => {
|
const _useDashboard = () => {
|
||||||
const route = useRoute()
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const isNotificationsSlideoverOpen = ref(false)
|
|
||||||
|
|
||||||
defineShortcuts({
|
defineShortcuts({
|
||||||
'g-h': () => router.push('/'),
|
'g-a': () => router.push('/actividades'),
|
||||||
'g-i': () => router.push('/inbox'),
|
'g-c': () => router.push('/conferencias')
|
||||||
'g-c': () => router.push('/customers'),
|
|
||||||
'g-s': () => router.push('/settings'),
|
|
||||||
'n': () => isNotificationsSlideoverOpen.value = !isNotificationsSlideoverOpen.value
|
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(() => route.fullPath, () => {
|
return {}
|
||||||
isNotificationsSlideoverOpen.value = false
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
isNotificationsSlideoverOpen
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useDashboard = createSharedComposable(_useDashboard)
|
export const useDashboard = createSharedComposable(_useDashboard)
|
||||||
|
|
|
||||||
|
|
@ -1,87 +1,19 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { NavigationMenuItem } from '@nuxt/ui'
|
import type { NavigationMenuItem } from '@nuxt/ui'
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const toast = useToast()
|
|
||||||
|
|
||||||
const open = ref(false)
|
const open = ref(false)
|
||||||
|
|
||||||
const links = [[{
|
const links = [[{
|
||||||
label: 'Home',
|
|
||||||
icon: 'i-lucide-house',
|
|
||||||
to: '/',
|
|
||||||
onSelect: () => {
|
|
||||||
open.value = false
|
|
||||||
}
|
|
||||||
}, {
|
|
||||||
label: 'Actividades',
|
label: 'Actividades',
|
||||||
icon: 'i-lucide-inbox',
|
icon: 'i-lucide-calendar-days',
|
||||||
to: '/actividades',
|
to: '/actividades',
|
||||||
badge: '4',
|
onSelect: () => { open.value = false }
|
||||||
onSelect: () => {
|
|
||||||
open.value = false
|
|
||||||
}
|
|
||||||
}, {
|
}, {
|
||||||
label: 'Conferencias',
|
label: 'Conferencias',
|
||||||
icon: 'i-lucide-inbox',
|
icon: 'i-lucide-mic',
|
||||||
to: '/conferencias',
|
to: '/conferencias',
|
||||||
onSelect: () => {
|
onSelect: () => { open.value = false }
|
||||||
open.value = false
|
}]] satisfies NavigationMenuItem[][]
|
||||||
}
|
|
||||||
}], [
|
|
||||||
// {
|
|
||||||
// label: 'Feedback',
|
|
||||||
// icon: 'i-lucide-message-circle',
|
|
||||||
// to: 'https://github.com/nuxt-ui-templates/dashboard',
|
|
||||||
// target: '_blank'
|
|
||||||
// }, {
|
|
||||||
// label: 'Help & Support',
|
|
||||||
// icon: 'i-lucide-info',
|
|
||||||
// to: 'https://github.com/nuxt-ui-templates/dashboard',
|
|
||||||
// target: '_blank'
|
|
||||||
// }
|
|
||||||
]] satisfies NavigationMenuItem[][]
|
|
||||||
|
|
||||||
const groups = computed(() => [{
|
|
||||||
id: 'links',
|
|
||||||
label: 'Go to',
|
|
||||||
items: links.flat()
|
|
||||||
}, {
|
|
||||||
id: 'code',
|
|
||||||
label: 'Code',
|
|
||||||
items: [{
|
|
||||||
id: 'source',
|
|
||||||
label: 'View page source',
|
|
||||||
icon: 'i-simple-icons-github',
|
|
||||||
to: `https://github.com/nuxt-ui-templates/dashboard/blob/main/app/pages${route.path === '/' ? '/index' : route.path}.vue`,
|
|
||||||
target: '_blank'
|
|
||||||
}]
|
|
||||||
}])
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
const cookie = useCookie('cookie-consent')
|
|
||||||
if (cookie.value === 'accepted') {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.add({
|
|
||||||
title: 'We use first-party cookies to enhance your experience on our website.',
|
|
||||||
duration: 0,
|
|
||||||
close: false,
|
|
||||||
actions: [{
|
|
||||||
label: 'Accept',
|
|
||||||
color: 'neutral',
|
|
||||||
variant: 'outline',
|
|
||||||
onClick: () => {
|
|
||||||
cookie.value = 'accepted'
|
|
||||||
}
|
|
||||||
}, {
|
|
||||||
label: 'Opt out',
|
|
||||||
color: 'neutral',
|
|
||||||
variant: 'ghost'
|
|
||||||
}]
|
|
||||||
})
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -94,13 +26,11 @@ onMounted(async () => {
|
||||||
class="bg-elevated/25"
|
class="bg-elevated/25"
|
||||||
:ui="{ footer: 'lg:border-t lg:border-default' }"
|
:ui="{ footer: 'lg:border-t lg:border-default' }"
|
||||||
>
|
>
|
||||||
<template #header="{ collapsed }">
|
<template #header>
|
||||||
<ULink as="button">La Gran Carpa Catedral</ULink>
|
<ULink as="button" class="font-semibold">La Gran Carpa Catedral</ULink>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #default="{ collapsed }">
|
<template #default="{ collapsed }">
|
||||||
<UDashboardSearchButton :collapsed="collapsed" class="bg-transparent ring-default" />
|
|
||||||
|
|
||||||
<UNavigationMenu
|
<UNavigationMenu
|
||||||
:collapsed="collapsed"
|
:collapsed="collapsed"
|
||||||
:items="links[0]"
|
:items="links[0]"
|
||||||
|
|
@ -108,14 +38,6 @@ onMounted(async () => {
|
||||||
tooltip
|
tooltip
|
||||||
popover
|
popover
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<UNavigationMenu
|
|
||||||
:collapsed="collapsed"
|
|
||||||
:items="links[1]"
|
|
||||||
orientation="vertical"
|
|
||||||
tooltip
|
|
||||||
class="mt-auto"
|
|
||||||
/>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #footer="{ collapsed }">
|
<template #footer="{ collapsed }">
|
||||||
|
|
@ -123,10 +45,6 @@ onMounted(async () => {
|
||||||
</template>
|
</template>
|
||||||
</UDashboardSidebar>
|
</UDashboardSidebar>
|
||||||
|
|
||||||
<UDashboardSearch :groups="groups" />
|
|
||||||
|
|
||||||
<slot />
|
<slot />
|
||||||
|
|
||||||
<NotificationsSlideover />
|
|
||||||
</UDashboardGroup>
|
</UDashboardGroup>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -1,112 +1,200 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||||
import { breakpointsTailwind } from '@vueuse/core'
|
import { breakpointsTailwind, useDebounce } from '@vueuse/core'
|
||||||
import type { Activity } from '~/types'
|
import type { SearchHit } from '~/types'
|
||||||
import InboxActivity from '~/components/inbox/InboxActivity.vue'
|
import InboxActivity from '~/components/inbox/InboxActivity.vue'
|
||||||
|
|
||||||
|
const PAGE_SIZE = 15
|
||||||
|
const REQUEST_TIMEOUT_MS = 15000
|
||||||
|
|
||||||
|
const query = ref('')
|
||||||
|
const debouncedQuery = useDebounce(query, 150)
|
||||||
|
const loading = ref(false)
|
||||||
|
const loadingMore = ref(false)
|
||||||
|
const errorMsg = ref<string | null>(null)
|
||||||
|
|
||||||
const tabItems = [{
|
// Use the raw Meilisearch client so we can pass an AbortSignal.
|
||||||
label: 'All',
|
const meili = useMeiliSearchRef()
|
||||||
value: 'all'
|
|
||||||
}, {
|
|
||||||
label: 'Unread',
|
|
||||||
value: 'unread'
|
|
||||||
}]
|
|
||||||
const selectedTab = ref('all')
|
|
||||||
const activities = ref()
|
|
||||||
|
|
||||||
// const searchKey = "04be59c1f633e2bb434082fc1a6fcc6ce97e3630e3fcf9e814e1f03a386c03e1"
|
const hits = ref<SearchHit[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
|
||||||
// const { data: activities } = await useFetch<Activity[]>('https://search.carpa.com/indexes/activities_ES/search?attributesToRetrieve=["title"]&q=', { default: () => [], headers: {
|
const hasMore = computed(() => hits.value.length < total.value)
|
||||||
// Authorization: `Bearer ${searchKey}`
|
|
||||||
// } })
|
|
||||||
|
|
||||||
const { search, result } = useMeiliSearch('activities_ES')
|
let searchSeq = 0
|
||||||
|
let abortController: AbortController | null = null
|
||||||
|
|
||||||
activities.value = await( search('',
|
async function runSearch(q: string, append = false) {
|
||||||
{
|
// Cancel any in-flight search; saves bandwidth and prevents pile-ups.
|
||||||
attributesToRetrieve: ['_id', 'title', 'slug', 'date', 'activity', 'city', 'state', 'country'],
|
abortController?.abort()
|
||||||
attributesToCrop: ['body:250'],
|
const ac = new AbortController()
|
||||||
attributesToHighlight: ['body'],
|
abortController = ac
|
||||||
cropLength: 250,
|
|
||||||
limit: 30,
|
|
||||||
sort: ['date:desc'],
|
|
||||||
|
|
||||||
|
const seq = ++searchSeq
|
||||||
|
if (append) loadingMore.value = true
|
||||||
|
else loading.value = true
|
||||||
|
errorMsg.value = null
|
||||||
|
|
||||||
|
// Safety net in case the network just hangs.
|
||||||
|
const timeoutId = setTimeout(() => ac.abort(), REQUEST_TIMEOUT_MS)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await meili.index('activities_ES').search(q || '', {
|
||||||
|
attributesToRetrieve: ['*'],
|
||||||
|
showMatchesPosition: true,
|
||||||
|
limit: PAGE_SIZE,
|
||||||
|
offset: append ? hits.value.length : 0,
|
||||||
|
sort: q ? undefined : ['date:desc']
|
||||||
|
}, { signal: ac.signal })
|
||||||
|
|
||||||
|
if (seq !== searchSeq) return
|
||||||
|
|
||||||
|
const newHits = (res?.hits ?? []) as SearchHit[]
|
||||||
|
hits.value = append ? hits.value.concat(newHits) : newHits
|
||||||
|
total.value = res?.estimatedTotalHits ?? hits.value.length
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const name = (err as { name?: string })?.name
|
||||||
|
// Aborts are expected when the user types fast; don't surface them.
|
||||||
|
if (name === 'AbortError') return
|
||||||
|
if (seq !== searchSeq) return
|
||||||
|
console.error('Meilisearch error', err)
|
||||||
|
errorMsg.value = (err as Error)?.message || 'Error al buscar.'
|
||||||
|
if (!append) {
|
||||||
|
hits.value = []
|
||||||
|
total.value = 0
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
if (seq === searchSeq) {
|
||||||
|
loading.value = false
|
||||||
|
loadingMore.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
) )
|
function loadMore() {
|
||||||
|
if (loadingMore.value || loading.value || !hasMore.value) return
|
||||||
|
runSearch(query.value, true)
|
||||||
|
}
|
||||||
|
|
||||||
// Filter activities based on the selected tab
|
// Initial fetch on the client (avoids blocking SSR).
|
||||||
const filteredActivities = computed(() => {
|
onMounted(() => runSearch(''))
|
||||||
// if (selectedTab.value === 'unread') {
|
|
||||||
// return activities.hits.filter(activity => !!activity.unread)
|
onBeforeUnmount(() => {
|
||||||
// }
|
abortController?.abort()
|
||||||
console.log( 'Activities', activities )
|
|
||||||
return activities.value?.hits
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const selectedActivity = ref<Activity | null>()
|
watch(debouncedQuery, (q) => {
|
||||||
|
hits.value = []
|
||||||
|
total.value = 0
|
||||||
|
runSearch(q, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedActivity = ref<SearchHit | null>(null)
|
||||||
|
|
||||||
const isActivityPanelOpen = computed({
|
const isActivityPanelOpen = computed({
|
||||||
get() {
|
get() { return !!selectedActivity.value },
|
||||||
return !!selectedActivity.value
|
set(value: boolean) { if (!value) selectedActivity.value = null }
|
||||||
},
|
|
||||||
set(value: boolean) {
|
|
||||||
if (!value) {
|
|
||||||
selectedActivity.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Reset selected activity if it's not in the filtered activities
|
watch(hits, () => {
|
||||||
watch(filteredActivities, () => {
|
if (!selectedActivity.value) return
|
||||||
if (!filteredActivities.value.find(activity => activity.id === selectedActivity.value?.id)) {
|
const stillThere = hits.value.find(h => h._id === selectedActivity.value?._id)
|
||||||
selectedActivity.value = null
|
if (!stillThere) selectedActivity.value = null
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const breakpoints = useBreakpoints(breakpointsTailwind)
|
const breakpoints = useBreakpoints(breakpointsTailwind)
|
||||||
const isMobile = breakpoints.smaller('lg')
|
const isMobile = breakpoints.smaller('lg')
|
||||||
|
|
||||||
|
function retry() {
|
||||||
|
runSearch(query.value, false)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<UDashboardPanel
|
<UDashboardPanel
|
||||||
id="inbox-1"
|
id="activities-list"
|
||||||
:default-size="25"
|
:default-size="28"
|
||||||
:min-size="20"
|
:min-size="22"
|
||||||
:max-size="30"
|
:max-size="40"
|
||||||
resizable
|
resizable
|
||||||
>
|
>
|
||||||
<UDashboardNavbar title="Actividades">
|
<UDashboardNavbar title="Actividades">
|
||||||
<template #leading>
|
<template #leading>
|
||||||
<UDashboardSidebarCollapse />
|
<UDashboardSidebarCollapse />
|
||||||
</template>
|
</template>
|
||||||
<template #trailing>
|
|
||||||
<UBadge :label="filteredActivities?.length" variant="subtle" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #right>
|
<template #trailing>
|
||||||
<UTabs
|
<UBadge :label="total" variant="subtle" />
|
||||||
v-model="selectedTab"
|
|
||||||
:items="tabItems"
|
|
||||||
:content="false"
|
|
||||||
size="xs"
|
|
||||||
/>
|
|
||||||
</template>
|
</template>
|
||||||
</UDashboardNavbar>
|
</UDashboardNavbar>
|
||||||
<InboxList v-model="selectedActivity" :activities="filteredActivities" />
|
|
||||||
|
<div class="px-4 sm:px-6 py-3 border-b border-default">
|
||||||
|
<UInput
|
||||||
|
v-model="query"
|
||||||
|
icon="i-lucide-search"
|
||||||
|
placeholder='Buscar actividades... (usa "comillas" para frase exacta)'
|
||||||
|
:loading="loading"
|
||||||
|
size="md"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
<p class="mt-1.5 flex items-center gap-1 text-[11px] text-dimmed">
|
||||||
|
<UIcon name="i-lucide-lightbulb" class="size-3" />
|
||||||
|
<span>
|
||||||
|
Tip: envuelve en
|
||||||
|
<code class="px-1 rounded bg-elevated text-toned font-mono">"comillas"</code>
|
||||||
|
para frase exacta en ese orden.
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UAlert
|
||||||
|
v-if="errorMsg"
|
||||||
|
:title="errorMsg"
|
||||||
|
color="error"
|
||||||
|
variant="subtle"
|
||||||
|
icon="i-lucide-triangle-alert"
|
||||||
|
class="mx-4 my-2"
|
||||||
|
:actions="[{ label: 'Reintentar', color: 'neutral', variant: 'outline', onClick: retry }]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<InboxList
|
||||||
|
v-model="selectedActivity"
|
||||||
|
:activities="hits"
|
||||||
|
:query="debouncedQuery"
|
||||||
|
:has-more="hasMore"
|
||||||
|
:loading="loading"
|
||||||
|
:loading-more="loadingMore"
|
||||||
|
@load-more="loadMore"
|
||||||
|
/>
|
||||||
</UDashboardPanel>
|
</UDashboardPanel>
|
||||||
|
|
||||||
<InboxActivity v-if="selectedActivity" :activity="selectedActivity" @close="selectedActivity = null" />
|
<InboxActivity
|
||||||
|
v-if="selectedActivity"
|
||||||
|
:activity="selectedActivity"
|
||||||
|
collection="activities"
|
||||||
|
:query="debouncedQuery"
|
||||||
|
@close="selectedActivity = null"
|
||||||
|
/>
|
||||||
<div v-else class="hidden lg:flex flex-1 items-center justify-center">
|
<div v-else class="hidden lg:flex flex-1 items-center justify-center">
|
||||||
<UIcon name="i-lucide-inbox" class="size-32 text-dimmed" />
|
<div class="flex flex-col items-center gap-2 text-dimmed">
|
||||||
|
<UIcon name="i-lucide-search" class="size-16" />
|
||||||
|
<p class="text-sm">
|
||||||
|
{{ query ? 'Selecciona una coincidencia para ver el detalle' : 'Escribe arriba para buscar' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
<USlideover v-if="isMobile" v-model:open="isActivityPanelOpen">
|
<USlideover v-if="isMobile" v-model:open="isActivityPanelOpen">
|
||||||
<template #content>
|
<template #content>
|
||||||
<InboxActivity v-if="selectedActivity" :activity="selectedActivity" @close="selectedActivity = null" />
|
<InboxActivity
|
||||||
|
v-if="selectedActivity"
|
||||||
|
:activity="selectedActivity"
|
||||||
|
collection="activities"
|
||||||
|
:query="debouncedQuery"
|
||||||
|
@close="selectedActivity = null"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
</USlideover>
|
</USlideover>
|
||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
|
|
|
||||||
|
|
@ -1,330 +1,196 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { TableColumn } from '@nuxt/ui'
|
import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||||
import { upperFirst } from 'scule'
|
import { breakpointsTailwind, useDebounce } from '@vueuse/core'
|
||||||
import { getPaginationRowModel } from '@tanstack/table-core'
|
import type { SearchHit } from '~/types'
|
||||||
import type { Row } from '@tanstack/table-core'
|
import InboxActivity from '~/components/inbox/InboxActivity.vue'
|
||||||
import type { User } from '~/types'
|
|
||||||
|
|
||||||
const UAvatar = resolveComponent('UAvatar')
|
const PAGE_SIZE = 15
|
||||||
const UButton = resolveComponent('UButton')
|
const REQUEST_TIMEOUT_MS = 15000
|
||||||
const UBadge = resolveComponent('UBadge')
|
|
||||||
const UDropdownMenu = resolveComponent('UDropdownMenu')
|
|
||||||
const UCheckbox = resolveComponent('UCheckbox')
|
|
||||||
|
|
||||||
const toast = useToast()
|
const query = ref('')
|
||||||
const table = useTemplateRef('table')
|
const debouncedQuery = useDebounce(query, 150)
|
||||||
|
const loading = ref(false)
|
||||||
|
const loadingMore = ref(false)
|
||||||
|
const errorMsg = ref<string | null>(null)
|
||||||
|
|
||||||
const columnFilters = ref([{
|
const meili = useMeiliSearchRef()
|
||||||
id: 'email',
|
|
||||||
value: ''
|
|
||||||
}])
|
|
||||||
const columnVisibility = ref()
|
|
||||||
const rowSelection = ref({ 1: true })
|
|
||||||
|
|
||||||
const { data, status } = await useFetch<User[]>('/api/customers', {
|
const hits = ref<SearchHit[]>([])
|
||||||
lazy: true
|
const total = ref(0)
|
||||||
})
|
|
||||||
|
|
||||||
function getRowItems(row: Row<User>) {
|
const hasMore = computed(() => hits.value.length < total.value)
|
||||||
return [
|
|
||||||
{
|
let searchSeq = 0
|
||||||
type: 'label',
|
let abortController: AbortController | null = null
|
||||||
label: 'Actions'
|
|
||||||
},
|
async function runSearch(q: string, append = false) {
|
||||||
{
|
abortController?.abort()
|
||||||
label: 'Copy customer ID',
|
const ac = new AbortController()
|
||||||
icon: 'i-lucide-copy',
|
abortController = ac
|
||||||
onSelect() {
|
|
||||||
navigator.clipboard.writeText(row.original.id.toString())
|
const seq = ++searchSeq
|
||||||
toast.add({
|
if (append) loadingMore.value = true
|
||||||
title: 'Copied to clipboard',
|
else loading.value = true
|
||||||
description: 'Customer ID copied to clipboard'
|
errorMsg.value = null
|
||||||
})
|
|
||||||
|
const timeoutId = setTimeout(() => ac.abort(), REQUEST_TIMEOUT_MS)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await meili.index('conferences_ES').search(q || '', {
|
||||||
|
attributesToRetrieve: ['*'],
|
||||||
|
showMatchesPosition: true,
|
||||||
|
limit: PAGE_SIZE,
|
||||||
|
offset: append ? hits.value.length : 0,
|
||||||
|
sort: q ? undefined : ['isodate:desc']
|
||||||
|
}, { signal: ac.signal })
|
||||||
|
|
||||||
|
if (seq !== searchSeq) return
|
||||||
|
|
||||||
|
const newHits = (res?.hits ?? []) as SearchHit[]
|
||||||
|
hits.value = append ? hits.value.concat(newHits) : newHits
|
||||||
|
total.value = res?.estimatedTotalHits ?? hits.value.length
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const name = (err as { name?: string })?.name
|
||||||
|
if (name === 'AbortError') return
|
||||||
|
if (seq !== searchSeq) return
|
||||||
|
console.error('Meilisearch error', err)
|
||||||
|
errorMsg.value = (err as Error)?.message || 'Error al buscar.'
|
||||||
|
if (!append) {
|
||||||
|
hits.value = []
|
||||||
|
total.value = 0
|
||||||
}
|
}
|
||||||
},
|
} finally {
|
||||||
{
|
clearTimeout(timeoutId)
|
||||||
type: 'separator'
|
if (seq === searchSeq) {
|
||||||
},
|
loading.value = false
|
||||||
{
|
loadingMore.value = false
|
||||||
label: 'View customer details',
|
|
||||||
icon: 'i-lucide-list'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'View customer payments',
|
|
||||||
icon: 'i-lucide-wallet'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'separator'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Delete customer',
|
|
||||||
icon: 'i-lucide-trash',
|
|
||||||
color: 'error',
|
|
||||||
onSelect() {
|
|
||||||
toast.add({
|
|
||||||
title: 'Customer deleted',
|
|
||||||
description: 'The customer has been deleted.'
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns: TableColumn<User>[] = [
|
function loadMore() {
|
||||||
{
|
if (loadingMore.value || loading.value || !hasMore.value) return
|
||||||
id: 'select',
|
runSearch(query.value, true)
|
||||||
header: ({ table }) =>
|
}
|
||||||
h(UCheckbox, {
|
|
||||||
'modelValue': table.getIsSomePageRowsSelected()
|
|
||||||
? 'indeterminate'
|
|
||||||
: table.getIsAllPageRowsSelected(),
|
|
||||||
'onUpdate:modelValue': (value: boolean | 'indeterminate') =>
|
|
||||||
table.toggleAllPageRowsSelected(!!value),
|
|
||||||
'ariaLabel': 'Select all'
|
|
||||||
}),
|
|
||||||
cell: ({ row }) =>
|
|
||||||
h(UCheckbox, {
|
|
||||||
'modelValue': row.getIsSelected(),
|
|
||||||
'onUpdate:modelValue': (value: boolean | 'indeterminate') => row.toggleSelected(!!value),
|
|
||||||
'ariaLabel': 'Select row'
|
|
||||||
})
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'id',
|
|
||||||
header: 'ID'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'name',
|
|
||||||
header: 'Name',
|
|
||||||
cell: ({ row }) => {
|
|
||||||
return h('div', { class: 'flex items-center gap-3' }, [
|
|
||||||
h(UAvatar, {
|
|
||||||
...row.original.avatar,
|
|
||||||
size: 'lg'
|
|
||||||
}),
|
|
||||||
h('div', undefined, [
|
|
||||||
h('p', { class: 'font-medium text-highlighted' }, row.original.name),
|
|
||||||
h('p', { class: '' }, `@${row.original.name}`)
|
|
||||||
])
|
|
||||||
])
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'email',
|
|
||||||
header: ({ column }) => {
|
|
||||||
const isSorted = column.getIsSorted()
|
|
||||||
|
|
||||||
return h(UButton, {
|
onMounted(() => runSearch(''))
|
||||||
color: 'neutral',
|
|
||||||
variant: 'ghost',
|
|
||||||
label: 'Email',
|
|
||||||
icon: isSorted
|
|
||||||
? isSorted === 'asc'
|
|
||||||
? 'i-lucide-arrow-up-narrow-wide'
|
|
||||||
: 'i-lucide-arrow-down-wide-narrow'
|
|
||||||
: 'i-lucide-arrow-up-down',
|
|
||||||
class: '-mx-2.5',
|
|
||||||
onClick: () => column.toggleSorting(column.getIsSorted() === 'asc')
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'location',
|
|
||||||
header: 'Location',
|
|
||||||
cell: ({ row }) => row.original.location
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'status',
|
|
||||||
header: 'Status',
|
|
||||||
filterFn: 'equals',
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const color = {
|
|
||||||
subscribed: 'success' as const,
|
|
||||||
unsubscribed: 'error' as const,
|
|
||||||
bounced: 'warning' as const
|
|
||||||
}[row.original.status]
|
|
||||||
|
|
||||||
return h(UBadge, { class: 'capitalize', variant: 'subtle', color }, () =>
|
onBeforeUnmount(() => {
|
||||||
row.original.status
|
abortController?.abort()
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'actions',
|
|
||||||
cell: ({ row }) => {
|
|
||||||
return h(
|
|
||||||
'div',
|
|
||||||
{ class: 'text-right' },
|
|
||||||
h(
|
|
||||||
UDropdownMenu,
|
|
||||||
{
|
|
||||||
content: {
|
|
||||||
align: 'end'
|
|
||||||
},
|
|
||||||
items: getRowItems(row)
|
|
||||||
},
|
|
||||||
() =>
|
|
||||||
h(UButton, {
|
|
||||||
icon: 'i-lucide-ellipsis-vertical',
|
|
||||||
color: 'neutral',
|
|
||||||
variant: 'ghost',
|
|
||||||
class: 'ml-auto'
|
|
||||||
})
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
const statusFilter = ref('all')
|
|
||||||
|
|
||||||
watch(() => statusFilter.value, (newVal) => {
|
|
||||||
if (!table?.value?.tableApi) return
|
|
||||||
|
|
||||||
const statusColumn = table.value.tableApi.getColumn('status')
|
|
||||||
if (!statusColumn) return
|
|
||||||
|
|
||||||
if (newVal === 'all') {
|
|
||||||
statusColumn.setFilterValue(undefined)
|
|
||||||
} else {
|
|
||||||
statusColumn.setFilterValue(newVal)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const email = computed({
|
watch(debouncedQuery, (q) => {
|
||||||
get: (): string => {
|
hits.value = []
|
||||||
return (table.value?.tableApi?.getColumn('email')?.getFilterValue() as string) || ''
|
total.value = 0
|
||||||
},
|
runSearch(q, false)
|
||||||
set: (value: string) => {
|
|
||||||
table.value?.tableApi?.getColumn('email')?.setFilterValue(value || undefined)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const pagination = ref({
|
const selected = ref<SearchHit | null>(null)
|
||||||
pageIndex: 0,
|
|
||||||
pageSize: 10
|
const isPanelOpen = computed({
|
||||||
|
get() { return !!selected.value },
|
||||||
|
set(value: boolean) { if (!value) selected.value = null }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(hits, () => {
|
||||||
|
if (!selected.value) return
|
||||||
|
const stillThere = hits.value.find(h => h._id === selected.value?._id)
|
||||||
|
if (!stillThere) selected.value = null
|
||||||
|
})
|
||||||
|
|
||||||
|
const breakpoints = useBreakpoints(breakpointsTailwind)
|
||||||
|
const isMobile = breakpoints.smaller('lg')
|
||||||
|
|
||||||
|
function retry() {
|
||||||
|
runSearch(query.value, false)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<UDashboardPanel id="customers">
|
<UDashboardPanel
|
||||||
<template #header>
|
id="conferences-list"
|
||||||
<UDashboardNavbar title="Customers">
|
:default-size="28"
|
||||||
|
:min-size="22"
|
||||||
|
:max-size="40"
|
||||||
|
resizable
|
||||||
|
>
|
||||||
|
<UDashboardNavbar title="Conferencias">
|
||||||
<template #leading>
|
<template #leading>
|
||||||
<UDashboardSidebarCollapse />
|
<UDashboardSidebarCollapse />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #right>
|
<template #trailing>
|
||||||
<CustomersAddModal />
|
<UBadge :label="total" variant="subtle" />
|
||||||
</template>
|
</template>
|
||||||
</UDashboardNavbar>
|
</UDashboardNavbar>
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #body>
|
<div class="px-4 sm:px-6 py-3 border-b border-default">
|
||||||
<div class="flex flex-wrap items-center justify-between gap-1.5">
|
|
||||||
<UInput
|
<UInput
|
||||||
v-model="email"
|
v-model="query"
|
||||||
class="max-w-sm"
|
|
||||||
icon="i-lucide-search"
|
icon="i-lucide-search"
|
||||||
placeholder="Filter emails..."
|
placeholder='Buscar conferencias... (usa "comillas" para frase exacta)'
|
||||||
|
:loading="loading"
|
||||||
|
size="md"
|
||||||
|
class="w-full"
|
||||||
/>
|
/>
|
||||||
|
<p class="mt-1.5 flex items-center gap-1 text-[11px] text-dimmed">
|
||||||
|
<UIcon name="i-lucide-lightbulb" class="size-3" />
|
||||||
|
<span>
|
||||||
|
Tip: envuelve en
|
||||||
|
<code class="px-1 rounded bg-elevated text-toned font-mono">"comillas"</code>
|
||||||
|
para frase exacta en ese orden.
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center gap-1.5">
|
<UAlert
|
||||||
<CustomersDeleteModal :count="table?.tableApi?.getFilteredSelectedRowModel().rows.length">
|
v-if="errorMsg"
|
||||||
<UButton
|
:title="errorMsg"
|
||||||
v-if="table?.tableApi?.getFilteredSelectedRowModel().rows.length"
|
|
||||||
label="Delete"
|
|
||||||
color="error"
|
color="error"
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
icon="i-lucide-trash"
|
icon="i-lucide-triangle-alert"
|
||||||
>
|
class="mx-4 my-2"
|
||||||
<template #trailing>
|
:actions="[{ label: 'Reintentar', color: 'neutral', variant: 'outline', onClick: retry }]"
|
||||||
<UKbd>
|
|
||||||
{{ table?.tableApi?.getFilteredSelectedRowModel().rows.length }}
|
|
||||||
</UKbd>
|
|
||||||
</template>
|
|
||||||
</UButton>
|
|
||||||
</CustomersDeleteModal>
|
|
||||||
|
|
||||||
<USelect
|
|
||||||
v-model="statusFilter"
|
|
||||||
:items="[
|
|
||||||
{ label: 'All', value: 'all' },
|
|
||||||
{ label: 'Subscribed', value: 'subscribed' },
|
|
||||||
{ label: 'Unsubscribed', value: 'unsubscribed' },
|
|
||||||
{ label: 'Bounced', value: 'bounced' }
|
|
||||||
]"
|
|
||||||
:ui="{ trailingIcon: 'group-data-[state=open]:rotate-180 transition-transform duration-200' }"
|
|
||||||
placeholder="Filter status"
|
|
||||||
class="min-w-28"
|
|
||||||
/>
|
|
||||||
<UDropdownMenu
|
|
||||||
:items="
|
|
||||||
table?.tableApi
|
|
||||||
?.getAllColumns()
|
|
||||||
.filter((column: any) => column.getCanHide())
|
|
||||||
.map((column: any) => ({
|
|
||||||
label: upperFirst(column.id),
|
|
||||||
type: 'checkbox' as const,
|
|
||||||
checked: column.getIsVisible(),
|
|
||||||
onUpdateChecked(checked: boolean) {
|
|
||||||
table?.tableApi?.getColumn(column.id)?.toggleVisibility(!!checked)
|
|
||||||
},
|
|
||||||
onSelect(e?: Event) {
|
|
||||||
e?.preventDefault()
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
"
|
|
||||||
:content="{ align: 'end' }"
|
|
||||||
>
|
|
||||||
<UButton
|
|
||||||
label="Display"
|
|
||||||
color="neutral"
|
|
||||||
variant="outline"
|
|
||||||
trailing-icon="i-lucide-settings-2"
|
|
||||||
/>
|
|
||||||
</UDropdownMenu>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<UTable
|
|
||||||
ref="table"
|
|
||||||
v-model:column-filters="columnFilters"
|
|
||||||
v-model:column-visibility="columnVisibility"
|
|
||||||
v-model:row-selection="rowSelection"
|
|
||||||
v-model:pagination="pagination"
|
|
||||||
:pagination-options="{
|
|
||||||
getPaginationRowModel: getPaginationRowModel()
|
|
||||||
}"
|
|
||||||
class="shrink-0"
|
|
||||||
:data="data"
|
|
||||||
:columns="columns"
|
|
||||||
:loading="status === 'pending'"
|
|
||||||
:ui="{
|
|
||||||
base: 'table-fixed border-separate border-spacing-0',
|
|
||||||
thead: '[&>tr]:bg-elevated/50 [&>tr]:after:content-none',
|
|
||||||
tbody: '[&>tr]:last:[&>td]:border-b-0',
|
|
||||||
th: 'py-2 first:rounded-l-lg last:rounded-r-lg border-y border-default first:border-l last:border-r',
|
|
||||||
td: 'border-b border-default',
|
|
||||||
separator: 'h-0'
|
|
||||||
}"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="flex items-center justify-between gap-3 border-t border-default pt-4 mt-auto">
|
<InboxList
|
||||||
<div class="text-sm text-muted">
|
v-model="selected"
|
||||||
{{ table?.tableApi?.getFilteredSelectedRowModel().rows.length || 0 }} of
|
:activities="hits"
|
||||||
{{ table?.tableApi?.getFilteredRowModel().rows.length || 0 }} row(s) selected.
|
:query="debouncedQuery"
|
||||||
</div>
|
:has-more="hasMore"
|
||||||
|
:loading="loading"
|
||||||
<div class="flex items-center gap-1.5">
|
:loading-more="loadingMore"
|
||||||
<UPagination
|
@load-more="loadMore"
|
||||||
:default-page="(table?.tableApi?.getState().pagination.pageIndex || 0) + 1"
|
|
||||||
:items-per-page="table?.tableApi?.getState().pagination.pageSize"
|
|
||||||
:total="table?.tableApi?.getFilteredRowModel().rows.length"
|
|
||||||
@update:page="(p: number) => table?.tableApi?.setPageIndex(p - 1)"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</UDashboardPanel>
|
</UDashboardPanel>
|
||||||
|
|
||||||
|
<InboxActivity
|
||||||
|
v-if="selected"
|
||||||
|
:activity="selected"
|
||||||
|
collection="conferences"
|
||||||
|
:query="debouncedQuery"
|
||||||
|
@close="selected = null"
|
||||||
|
/>
|
||||||
|
<div v-else class="hidden lg:flex flex-1 items-center justify-center">
|
||||||
|
<div class="flex flex-col items-center gap-2 text-dimmed">
|
||||||
|
<UIcon name="i-lucide-search" class="size-16" />
|
||||||
|
<p class="text-sm">
|
||||||
|
{{ query ? 'Selecciona una coincidencia para ver el detalle' : 'Escribe arriba para buscar' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ClientOnly>
|
||||||
|
<USlideover v-if="isMobile" v-model:open="isPanelOpen">
|
||||||
|
<template #content>
|
||||||
|
<InboxActivity
|
||||||
|
v-if="selected"
|
||||||
|
:activity="selected"
|
||||||
|
collection="conferences"
|
||||||
|
:query="debouncedQuery"
|
||||||
|
@close="selected = null"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</USlideover>
|
||||||
|
</ClientOnly>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -1,69 +1,10 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { sub } from 'date-fns'
|
// Home redirects straight to the search experience.
|
||||||
import type { DropdownMenuItem } from '@nuxt/ui'
|
definePageMeta({
|
||||||
import type { Period, Range } from '~/types'
|
middleware: () => navigateTo('/actividades', { redirectCode: 302 })
|
||||||
|
|
||||||
const { isNotificationsSlideoverOpen } = useDashboard()
|
|
||||||
|
|
||||||
const items = [[{
|
|
||||||
label: 'New mail',
|
|
||||||
icon: 'i-lucide-send',
|
|
||||||
to: '/inbox'
|
|
||||||
}, {
|
|
||||||
label: 'New customer',
|
|
||||||
icon: 'i-lucide-user-plus',
|
|
||||||
to: '/customers'
|
|
||||||
}]] satisfies DropdownMenuItem[][]
|
|
||||||
|
|
||||||
const range = shallowRef<Range>({
|
|
||||||
start: sub(new Date(), { days: 14 }),
|
|
||||||
end: new Date()
|
|
||||||
})
|
})
|
||||||
const period = ref<Period>('daily')
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<UDashboardPanel id="home">
|
<div />
|
||||||
<template #header>
|
|
||||||
<UDashboardNavbar title="Home" :ui="{ right: 'gap-3' }">
|
|
||||||
<template #leading>
|
|
||||||
<UDashboardSidebarCollapse />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #right>
|
|
||||||
<UTooltip text="Notifications" :shortcuts="['N']">
|
|
||||||
<UButton
|
|
||||||
color="neutral"
|
|
||||||
variant="ghost"
|
|
||||||
square
|
|
||||||
@click="isNotificationsSlideoverOpen = true"
|
|
||||||
>
|
|
||||||
<UChip color="error" inset>
|
|
||||||
<UIcon name="i-lucide-bell" class="size-5 shrink-0" />
|
|
||||||
</UChip>
|
|
||||||
</UButton>
|
|
||||||
</UTooltip>
|
|
||||||
|
|
||||||
<UDropdownMenu :items="items">
|
|
||||||
<UButton icon="i-lucide-plus" size="md" class="rounded-full" />
|
|
||||||
</UDropdownMenu>
|
|
||||||
</template>
|
|
||||||
</UDashboardNavbar>
|
|
||||||
|
|
||||||
<UDashboardToolbar>
|
|
||||||
<template #left>
|
|
||||||
<!-- NOTE: The `-ms-1` class is used to align with the `DashboardSidebarCollapse` button here. -->
|
|
||||||
<HomeDateRangePicker v-model="range" class="-ms-1" />
|
|
||||||
|
|
||||||
<HomePeriodSelect v-model="period" :range="range" />
|
|
||||||
</template>
|
|
||||||
</UDashboardToolbar>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #body>
|
|
||||||
<HomeStats :period="period" :range="range" />
|
|
||||||
<HomeChart :period="period" :range="range" />
|
|
||||||
<HomeSales :period="period" :range="range" />
|
|
||||||
</template>
|
|
||||||
</UDashboardPanel>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import type { NavigationMenuItem } from '@nuxt/ui'
|
|
||||||
|
|
||||||
const links = [[{
|
|
||||||
label: 'General',
|
|
||||||
icon: 'i-lucide-user',
|
|
||||||
to: '/settings',
|
|
||||||
exact: true
|
|
||||||
}, {
|
|
||||||
label: 'Members',
|
|
||||||
icon: 'i-lucide-users',
|
|
||||||
to: '/settings/members'
|
|
||||||
}, {
|
|
||||||
label: 'Notifications',
|
|
||||||
icon: 'i-lucide-bell',
|
|
||||||
to: '/settings/notifications'
|
|
||||||
}, {
|
|
||||||
label: 'Security',
|
|
||||||
icon: 'i-lucide-shield',
|
|
||||||
to: '/settings/security'
|
|
||||||
}], [{
|
|
||||||
label: 'Documentation',
|
|
||||||
icon: 'i-lucide-book-open',
|
|
||||||
to: 'https://ui.nuxt.com/docs/getting-started/installation/nuxt',
|
|
||||||
target: '_blank'
|
|
||||||
}]] satisfies NavigationMenuItem[][]
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<UDashboardPanel id="settings" :ui="{ body: 'lg:py-12' }">
|
|
||||||
<template #header>
|
|
||||||
<UDashboardNavbar title="Settings">
|
|
||||||
<template #leading>
|
|
||||||
<UDashboardSidebarCollapse />
|
|
||||||
</template>
|
|
||||||
</UDashboardNavbar>
|
|
||||||
|
|
||||||
<UDashboardToolbar>
|
|
||||||
<!-- NOTE: The `-mx-1` class is used to align with the `DashboardSidebarCollapse` button here. -->
|
|
||||||
<UNavigationMenu :items="links" highlight class="-mx-1 flex-1" />
|
|
||||||
</UDashboardToolbar>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #body>
|
|
||||||
<div class="flex flex-col gap-4 sm:gap-6 lg:gap-12 w-full lg:max-w-2xl mx-auto">
|
|
||||||
<NuxtPage />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</UDashboardPanel>
|
|
||||||
</template>
|
|
||||||
|
|
@ -1,158 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import * as z from 'zod'
|
|
||||||
import type { FormSubmitEvent } from '@nuxt/ui'
|
|
||||||
|
|
||||||
const fileRef = ref<HTMLInputElement>()
|
|
||||||
|
|
||||||
const profileSchema = z.object({
|
|
||||||
name: z.string().min(2, 'Too short'),
|
|
||||||
email: z.string().email('Invalid email'),
|
|
||||||
username: z.string().min(2, 'Too short'),
|
|
||||||
avatar: z.string().optional(),
|
|
||||||
bio: z.string().optional()
|
|
||||||
})
|
|
||||||
|
|
||||||
type ProfileSchema = z.output<typeof profileSchema>
|
|
||||||
|
|
||||||
const profile = reactive<Partial<ProfileSchema>>({
|
|
||||||
name: 'Benjamin Canac',
|
|
||||||
email: 'ben@nuxtlabs.com',
|
|
||||||
username: 'benjamincanac',
|
|
||||||
avatar: undefined,
|
|
||||||
bio: undefined
|
|
||||||
})
|
|
||||||
const toast = useToast()
|
|
||||||
async function onSubmit(event: FormSubmitEvent<ProfileSchema>) {
|
|
||||||
toast.add({
|
|
||||||
title: 'Success',
|
|
||||||
description: 'Your settings have been updated.',
|
|
||||||
icon: 'i-lucide-check',
|
|
||||||
color: 'success'
|
|
||||||
})
|
|
||||||
console.log(event.data)
|
|
||||||
}
|
|
||||||
|
|
||||||
function onFileChange(e: Event) {
|
|
||||||
const input = e.target as HTMLInputElement
|
|
||||||
|
|
||||||
if (!input.files?.length) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
profile.avatar = URL.createObjectURL(input.files[0]!)
|
|
||||||
}
|
|
||||||
|
|
||||||
function onFileClick() {
|
|
||||||
fileRef.value?.click()
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<UForm
|
|
||||||
id="settings"
|
|
||||||
:schema="profileSchema"
|
|
||||||
:state="profile"
|
|
||||||
@submit="onSubmit"
|
|
||||||
>
|
|
||||||
<UPageCard
|
|
||||||
title="Profile"
|
|
||||||
description="These informations will be displayed publicly."
|
|
||||||
variant="naked"
|
|
||||||
orientation="horizontal"
|
|
||||||
class="mb-4"
|
|
||||||
>
|
|
||||||
<UButton
|
|
||||||
form="settings"
|
|
||||||
label="Save changes"
|
|
||||||
color="neutral"
|
|
||||||
type="submit"
|
|
||||||
class="w-fit lg:ms-auto"
|
|
||||||
/>
|
|
||||||
</UPageCard>
|
|
||||||
|
|
||||||
<UPageCard variant="subtle">
|
|
||||||
<UFormField
|
|
||||||
name="name"
|
|
||||||
label="Name"
|
|
||||||
description="Will appear on receipts, invoices, and other communication."
|
|
||||||
required
|
|
||||||
class="flex max-sm:flex-col justify-between items-start gap-4"
|
|
||||||
>
|
|
||||||
<UInput
|
|
||||||
v-model="profile.name"
|
|
||||||
autocomplete="off"
|
|
||||||
/>
|
|
||||||
</UFormField>
|
|
||||||
<USeparator />
|
|
||||||
<UFormField
|
|
||||||
name="email"
|
|
||||||
label="Email"
|
|
||||||
description="Used to sign in, for email receipts and product updates."
|
|
||||||
required
|
|
||||||
class="flex max-sm:flex-col justify-between items-start gap-4"
|
|
||||||
>
|
|
||||||
<UInput
|
|
||||||
v-model="profile.email"
|
|
||||||
type="email"
|
|
||||||
autocomplete="off"
|
|
||||||
/>
|
|
||||||
</UFormField>
|
|
||||||
<USeparator />
|
|
||||||
<UFormField
|
|
||||||
name="username"
|
|
||||||
label="Username"
|
|
||||||
description="Your unique username for logging in and your profile URL."
|
|
||||||
required
|
|
||||||
class="flex max-sm:flex-col justify-between items-start gap-4"
|
|
||||||
>
|
|
||||||
<UInput
|
|
||||||
v-model="profile.username"
|
|
||||||
type="username"
|
|
||||||
autocomplete="off"
|
|
||||||
/>
|
|
||||||
</UFormField>
|
|
||||||
<USeparator />
|
|
||||||
<UFormField
|
|
||||||
name="avatar"
|
|
||||||
label="Avatar"
|
|
||||||
description="JPG, GIF or PNG. 1MB Max."
|
|
||||||
class="flex max-sm:flex-col justify-between sm:items-center gap-4"
|
|
||||||
>
|
|
||||||
<div class="flex flex-wrap items-center gap-3">
|
|
||||||
<UAvatar
|
|
||||||
:src="profile.avatar"
|
|
||||||
:alt="profile.name"
|
|
||||||
size="lg"
|
|
||||||
/>
|
|
||||||
<UButton
|
|
||||||
label="Choose"
|
|
||||||
color="neutral"
|
|
||||||
@click="onFileClick"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
ref="fileRef"
|
|
||||||
type="file"
|
|
||||||
class="hidden"
|
|
||||||
accept=".jpg, .jpeg, .png, .gif"
|
|
||||||
@change="onFileChange"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</UFormField>
|
|
||||||
<USeparator />
|
|
||||||
<UFormField
|
|
||||||
name="bio"
|
|
||||||
label="Bio"
|
|
||||||
description="Brief description for your profile. URLs are hyperlinked."
|
|
||||||
class="flex max-sm:flex-col justify-between items-start gap-4"
|
|
||||||
:ui="{ container: 'w-full' }"
|
|
||||||
>
|
|
||||||
<UTextarea
|
|
||||||
v-model="profile.bio"
|
|
||||||
:rows="5"
|
|
||||||
autoresize
|
|
||||||
class="w-full"
|
|
||||||
/>
|
|
||||||
</UFormField>
|
|
||||||
</UPageCard>
|
|
||||||
</UForm>
|
|
||||||
</template>
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import type { Member } from '~/types'
|
|
||||||
|
|
||||||
const { data: members } = await useFetch<Member[]>('/api/members', { default: () => [] })
|
|
||||||
|
|
||||||
const q = ref('')
|
|
||||||
|
|
||||||
const filteredMembers = computed(() => {
|
|
||||||
return members.value.filter((member) => {
|
|
||||||
return member.name.search(new RegExp(q.value, 'i')) !== -1 || member.username.search(new RegExp(q.value, 'i')) !== -1
|
|
||||||
})
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div>
|
|
||||||
<UPageCard
|
|
||||||
title="Members"
|
|
||||||
description="Invite new members by email address."
|
|
||||||
variant="naked"
|
|
||||||
orientation="horizontal"
|
|
||||||
class="mb-4"
|
|
||||||
>
|
|
||||||
<UButton
|
|
||||||
label="Invite people"
|
|
||||||
color="neutral"
|
|
||||||
class="w-fit lg:ms-auto"
|
|
||||||
/>
|
|
||||||
</UPageCard>
|
|
||||||
|
|
||||||
<UPageCard variant="subtle" :ui="{ container: 'p-0 sm:p-0 gap-y-0', wrapper: 'items-stretch', header: 'p-4 mb-0 border-b border-default' }">
|
|
||||||
<template #header>
|
|
||||||
<UInput
|
|
||||||
v-model="q"
|
|
||||||
icon="i-lucide-search"
|
|
||||||
placeholder="Search members"
|
|
||||||
autofocus
|
|
||||||
class="w-full"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<SettingsMembersList :members="filteredMembers" />
|
|
||||||
</UPageCard>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
const state = reactive<{ [key: string]: boolean }>({
|
|
||||||
email: true,
|
|
||||||
desktop: false,
|
|
||||||
product_updates: true,
|
|
||||||
weekly_digest: false,
|
|
||||||
important_updates: true
|
|
||||||
})
|
|
||||||
|
|
||||||
const sections = [{
|
|
||||||
title: 'Notification channels',
|
|
||||||
description: 'Where can we notify you?',
|
|
||||||
fields: [{
|
|
||||||
name: 'email',
|
|
||||||
label: 'Email',
|
|
||||||
description: 'Receive a daily email digest.'
|
|
||||||
}, {
|
|
||||||
name: 'desktop',
|
|
||||||
label: 'Desktop',
|
|
||||||
description: 'Receive desktop notifications.'
|
|
||||||
}]
|
|
||||||
}, {
|
|
||||||
title: 'Account updates',
|
|
||||||
description: 'Receive updates about Nuxt UI.',
|
|
||||||
fields: [{
|
|
||||||
name: 'weekly_digest',
|
|
||||||
label: 'Weekly digest',
|
|
||||||
description: 'Receive a weekly digest of news.'
|
|
||||||
}, {
|
|
||||||
name: 'product_updates',
|
|
||||||
label: 'Product updates',
|
|
||||||
description: 'Receive a monthly email with all new features and updates.'
|
|
||||||
}, {
|
|
||||||
name: 'important_updates',
|
|
||||||
label: 'Important updates',
|
|
||||||
description: 'Receive emails about important updates like security fixes, maintenance, etc.'
|
|
||||||
}]
|
|
||||||
}]
|
|
||||||
|
|
||||||
async function onChange() {
|
|
||||||
// Do something with data
|
|
||||||
console.log(state)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div v-for="(section, index) in sections" :key="index">
|
|
||||||
<UPageCard
|
|
||||||
:title="section.title"
|
|
||||||
:description="section.description"
|
|
||||||
variant="naked"
|
|
||||||
class="mb-4"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<UPageCard variant="subtle" :ui="{ container: 'divide-y divide-default' }">
|
|
||||||
<UFormField
|
|
||||||
v-for="field in section.fields"
|
|
||||||
:key="field.name"
|
|
||||||
:name="field.name"
|
|
||||||
:label="field.label"
|
|
||||||
:description="field.description"
|
|
||||||
class="flex items-center justify-between not-last:pb-4 gap-2"
|
|
||||||
>
|
|
||||||
<USwitch
|
|
||||||
v-model="state[field.name]"
|
|
||||||
@update:model-value="onChange"
|
|
||||||
/>
|
|
||||||
</UFormField>
|
|
||||||
</UPageCard>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
@ -1,69 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import * as z from 'zod'
|
|
||||||
import type { FormError } from '@nuxt/ui'
|
|
||||||
|
|
||||||
const passwordSchema = z.object({
|
|
||||||
current: z.string().min(8, 'Must be at least 8 characters'),
|
|
||||||
new: z.string().min(8, 'Must be at least 8 characters')
|
|
||||||
})
|
|
||||||
|
|
||||||
type PasswordSchema = z.output<typeof passwordSchema>
|
|
||||||
|
|
||||||
const password = reactive<Partial<PasswordSchema>>({
|
|
||||||
current: '',
|
|
||||||
new: ''
|
|
||||||
})
|
|
||||||
|
|
||||||
const validate = (state: Partial<PasswordSchema>): FormError[] => {
|
|
||||||
const errors: FormError[] = []
|
|
||||||
if (state.current && state.new && state.current === state.new) {
|
|
||||||
errors.push({ name: 'new', message: 'Passwords must be different' })
|
|
||||||
}
|
|
||||||
return errors
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<UPageCard
|
|
||||||
title="Password"
|
|
||||||
description="Confirm your current password before setting a new one."
|
|
||||||
variant="subtle"
|
|
||||||
>
|
|
||||||
<UForm
|
|
||||||
:schema="passwordSchema"
|
|
||||||
:state="password"
|
|
||||||
:validate="validate"
|
|
||||||
class="flex flex-col gap-4 max-w-xs"
|
|
||||||
>
|
|
||||||
<UFormField name="current">
|
|
||||||
<UInput
|
|
||||||
v-model="password.current"
|
|
||||||
type="password"
|
|
||||||
placeholder="Current password"
|
|
||||||
class="w-full"
|
|
||||||
/>
|
|
||||||
</UFormField>
|
|
||||||
|
|
||||||
<UFormField name="new">
|
|
||||||
<UInput
|
|
||||||
v-model="password.new"
|
|
||||||
type="password"
|
|
||||||
placeholder="New password"
|
|
||||||
class="w-full"
|
|
||||||
/>
|
|
||||||
</UFormField>
|
|
||||||
|
|
||||||
<UButton label="Update" class="w-fit" type="submit" />
|
|
||||||
</UForm>
|
|
||||||
</UPageCard>
|
|
||||||
|
|
||||||
<UPageCard
|
|
||||||
title="Account"
|
|
||||||
description="No longer want to use our service? You can delete your account here. This action is not reversible. All information related to this account will be deleted permanently."
|
|
||||||
class="bg-linear-to-tl from-error/10 from-5% to-default"
|
|
||||||
>
|
|
||||||
<template #footer>
|
|
||||||
<UButton label="Delete account" color="error" />
|
|
||||||
</template>
|
|
||||||
</UPageCard>
|
|
||||||
</template>
|
|
||||||
|
|
@ -12,20 +12,42 @@ export interface User {
|
||||||
location: string
|
location: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Activity {
|
export interface SearchFiles {
|
||||||
id: number
|
youtube?: string
|
||||||
title: string
|
video?: string
|
||||||
|
audio?: string
|
||||||
|
booklet?: string
|
||||||
|
simple?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Mail {
|
export interface MatchPosition { start: number, length: number }
|
||||||
id: number
|
|
||||||
|
export interface SearchHit {
|
||||||
|
_id: string | number
|
||||||
|
id?: string | number
|
||||||
|
title: string
|
||||||
|
slug?: string
|
||||||
|
date?: number | string
|
||||||
|
isodate?: string
|
||||||
|
activity?: string
|
||||||
|
conference?: string
|
||||||
|
place?: string
|
||||||
|
city?: string
|
||||||
|
state?: string
|
||||||
|
country?: string
|
||||||
|
body?: string
|
||||||
|
thumbnail?: string
|
||||||
|
type?: string
|
||||||
|
files?: SearchFiles
|
||||||
unread?: boolean
|
unread?: boolean
|
||||||
from: User
|
_formatted?: Record<string, unknown>
|
||||||
subject: string
|
_matchesPosition?: Record<string, MatchPosition[]>
|
||||||
body: string
|
[key: string]: unknown
|
||||||
date: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Backwards-compatible alias used by InboxList / InboxActivity
|
||||||
|
export type Activity = SearchHit
|
||||||
|
|
||||||
export interface Member {
|
export interface Member {
|
||||||
name: string
|
name: string
|
||||||
username: string
|
username: string
|
||||||
|
|
@ -33,22 +55,6 @@ export interface Member {
|
||||||
avatar: AvatarProps
|
avatar: AvatarProps
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Stat {
|
|
||||||
title: string
|
|
||||||
icon: string
|
|
||||||
value: number | string
|
|
||||||
variation: number
|
|
||||||
formatter?: (value: number) => string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Sale {
|
|
||||||
id: string
|
|
||||||
date: string
|
|
||||||
status: SaleStatus
|
|
||||||
email: string
|
|
||||||
amount: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Notification {
|
export interface Notification {
|
||||||
id: number
|
id: number
|
||||||
unread?: boolean
|
unread?: boolean
|
||||||
|
|
@ -56,10 +62,3 @@ export interface Notification {
|
||||||
body: string
|
body: string
|
||||||
date: string
|
date: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Period = 'daily' | 'weekly' | 'monthly'
|
|
||||||
|
|
||||||
export interface Range {
|
|
||||||
start: Date
|
|
||||||
end: Date
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,57 +1,3 @@
|
||||||
import Typesense from 'typesense';
|
// removed: Typesense fetcher no longer used. Document retrieval now uses
|
||||||
import { ref } from 'vue';
|
// the Meilisearch client via `useMeiliSearchRef()`.
|
||||||
|
export {}
|
||||||
const documentData = ref({});
|
|
||||||
const error = ref("");
|
|
||||||
|
|
||||||
// Initialize the Typesense client
|
|
||||||
const client = new Typesense.Client({
|
|
||||||
'nodes': [{
|
|
||||||
'host': 'searchts.carpa.com', // For Typesense Cloud use xxx.a1.typesense.net
|
|
||||||
'port': 443, // For Typesense Cloud use 443
|
|
||||||
'protocol': 'https' // For Typesense Cloud use https
|
|
||||||
}],
|
|
||||||
'apiKey': '3KrmYlcirARCxG4AZPV5bnJgQD0qtoW0',
|
|
||||||
'connectionTimeoutSeconds': 2
|
|
||||||
});
|
|
||||||
|
|
||||||
export async function fetchDocumentBySlug( collectionName:string, slug: string ){
|
|
||||||
error.value = "";
|
|
||||||
documentData.value = {};
|
|
||||||
|
|
||||||
const nSlug = parseInt(slug);
|
|
||||||
|
|
||||||
if( Number.isFinite( nSlug ) ){
|
|
||||||
return fetchDocument( collectionName, slug );
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Call the retrieve a document API directly
|
|
||||||
const doc = await client.collections(collectionName).documents().search({
|
|
||||||
'q': slug,
|
|
||||||
'query_by': 'slug'
|
|
||||||
})
|
|
||||||
|
|
||||||
//documentData.value = doc.hits?.[0]?.document;
|
|
||||||
return doc.hits?.[0]?.document;
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Error fetching document:", err);
|
|
||||||
error.value = "Failed to fetch document.";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchDocument( collectionName:string, documentId:string) {
|
|
||||||
error.value = "";
|
|
||||||
documentData.value = {};
|
|
||||||
try {
|
|
||||||
// Call the retrieve a document API directly
|
|
||||||
const doc = await client.collections(collectionName).documents(documentId).retrieve();
|
|
||||||
//documentData.value = doc;
|
|
||||||
|
|
||||||
return doc;
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Error fetching document:", err);
|
|
||||||
error.value = "Failed to fetch document.";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -167,20 +167,5 @@ export function getAuthor( type:string ){
|
||||||
return author
|
return author
|
||||||
}
|
}
|
||||||
|
|
||||||
export function openItem( item:ItemObject ) {
|
// removed: openItem relied on a Pinia store that is not installed in this app.
|
||||||
console.log("Open Item", item.id)
|
// Detail navigation is now handled directly inside the search pages.
|
||||||
// const { $i18n } = useNuxtApp();
|
|
||||||
// const locale = $i18n.locale;
|
|
||||||
|
|
||||||
// //Pinia datastore
|
|
||||||
const dataStore = useDataStore()
|
|
||||||
|
|
||||||
// //Set document into datastore array
|
|
||||||
dataStore.openItem( item )
|
|
||||||
dataStore.isDetail = true
|
|
||||||
|
|
||||||
// //Create url for history
|
|
||||||
// const newUrl = setUrl( item );
|
|
||||||
|
|
||||||
// //return navigateTo( newUrl )
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"Activities": {
|
||||||
|
"video": "Video",
|
||||||
|
"audio": "Audio",
|
||||||
|
"book": "Book",
|
||||||
|
"simple": "Simple"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"Activities": {
|
||||||
|
"video": "Vídeo",
|
||||||
|
"audio": "Audio",
|
||||||
|
"book": "Libro",
|
||||||
|
"simple": "Sencillo"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"Activities": {
|
||||||
|
"video": "Vidéo",
|
||||||
|
"audio": "Audio",
|
||||||
|
"book": "Livre",
|
||||||
|
"simple": "Simple"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"Activities": {
|
||||||
|
"video": "Vídeo",
|
||||||
|
"audio": "Áudio",
|
||||||
|
"book": "Livro",
|
||||||
|
"simple": "Simples"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
export default defineI18nLocale(() => ({
|
||||||
|
Activities: {
|
||||||
|
video: 'Video',
|
||||||
|
audio: 'Audio',
|
||||||
|
book: 'Booklet',
|
||||||
|
simple: 'Simple'
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
export default defineI18nLocale(() => ({
|
||||||
|
Activities: {
|
||||||
|
video: 'Video',
|
||||||
|
audio: 'Audio',
|
||||||
|
book: 'Folleto',
|
||||||
|
simple: 'Simple'
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
export default defineI18nLocale(() => ({
|
||||||
|
Activities: {
|
||||||
|
video: 'Vidéo',
|
||||||
|
audio: 'Audio',
|
||||||
|
book: 'Brochure',
|
||||||
|
simple: 'Simple'
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
export default defineI18nLocale(() => ({
|
||||||
|
Activities: {
|
||||||
|
video: 'Vídeo',
|
||||||
|
audio: 'Áudio',
|
||||||
|
book: 'Folheto',
|
||||||
|
simple: 'Simples'
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
@ -36,41 +36,20 @@ export default defineNuxtConfig({
|
||||||
},
|
},
|
||||||
|
|
||||||
i18n: {
|
i18n: {
|
||||||
locales: [{
|
restructureDir: false,
|
||||||
code: "es",
|
langDir: '../locales/',
|
||||||
name: "Español",
|
locales: [
|
||||||
language: "es-PR",
|
{ code: 'es', language: 'es-PR', name: 'Español', flag: '🇵🇷', file: 'es.ts' },
|
||||||
file: "es.json",
|
{ code: 'en', language: 'en-US', name: 'English', flag: '🇺🇸', file: 'en.ts' },
|
||||||
icon: 'i-circle-flags:es',
|
{ code: 'fr', language: 'fr-FR', name: 'Français', flag: '🇫🇷', file: 'fr.ts' },
|
||||||
},
|
{ code: 'pt', language: 'pt-BR', name: 'Português', flag: '🇧🇷', file: 'pt.ts' }
|
||||||
{
|
],
|
||||||
code: "en",
|
defaultLocale: 'es',
|
||||||
name: "English",
|
strategy: 'prefix',
|
||||||
language: "en-US",
|
detectBrowserLanguage: {
|
||||||
file: "en.json",
|
useCookie: true,
|
||||||
icon: 'i-circle-flags:en',
|
cookieKey: 'i18n_redirected',
|
||||||
},
|
redirectOn: 'root'
|
||||||
{
|
}
|
||||||
code: "fr",
|
}
|
||||||
name: "Francois",
|
|
||||||
language: "fr-FR",
|
|
||||||
file: "fr.json",
|
|
||||||
icon: 'i-circle-flags:fr',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
code: "pt",
|
|
||||||
name: "Portugues",
|
|
||||||
language: "pt-BR",
|
|
||||||
file: "pt.json",
|
|
||||||
icon: 'i-circle-flags:br',
|
|
||||||
}],
|
|
||||||
lazy: true,
|
|
||||||
langDir: 'lang/',
|
|
||||||
strategy: "prefix",
|
|
||||||
defaultLocale: "es",
|
|
||||||
detectBrowserLanguage: false,
|
|
||||||
//skipSettingLocaleOnNavigate: true
|
|
||||||
|
|
||||||
//vueI18n: "./i18n.config.ts",
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
@ -1,187 +0,0 @@
|
||||||
import type { User } from '~/types'
|
|
||||||
|
|
||||||
const customers: User[] = [{
|
|
||||||
id: 1,
|
|
||||||
name: 'Alex Smith',
|
|
||||||
email: 'alex.smith@example.com',
|
|
||||||
avatar: {
|
|
||||||
src: 'https://i.pravatar.cc/128?u=1'
|
|
||||||
},
|
|
||||||
status: 'subscribed',
|
|
||||||
location: 'New York, USA'
|
|
||||||
}, {
|
|
||||||
id: 2,
|
|
||||||
name: 'Jordan Brown',
|
|
||||||
email: 'jordan.brown@example.com',
|
|
||||||
avatar: {
|
|
||||||
src: 'https://i.pravatar.cc/128?u=2'
|
|
||||||
},
|
|
||||||
status: 'unsubscribed',
|
|
||||||
location: 'London, UK'
|
|
||||||
}, {
|
|
||||||
id: 3,
|
|
||||||
name: 'Taylor Green',
|
|
||||||
email: 'taylor.green@example.com',
|
|
||||||
avatar: {
|
|
||||||
src: 'https://i.pravatar.cc/128?u=3'
|
|
||||||
},
|
|
||||||
status: 'bounced',
|
|
||||||
location: 'Paris, France'
|
|
||||||
}, {
|
|
||||||
id: 4,
|
|
||||||
name: 'Morgan White',
|
|
||||||
email: 'morgan.white@example.com',
|
|
||||||
avatar: {
|
|
||||||
src: 'https://i.pravatar.cc/128?u=4'
|
|
||||||
},
|
|
||||||
status: 'subscribed',
|
|
||||||
location: 'Berlin, Germany'
|
|
||||||
}, {
|
|
||||||
id: 5,
|
|
||||||
name: 'Casey Gray',
|
|
||||||
email: 'casey.gray@example.com',
|
|
||||||
avatar: {
|
|
||||||
src: 'https://i.pravatar.cc/128?u=5'
|
|
||||||
},
|
|
||||||
status: 'subscribed',
|
|
||||||
location: 'Tokyo, Japan'
|
|
||||||
}, {
|
|
||||||
id: 6,
|
|
||||||
name: 'Jamie Johnson',
|
|
||||||
email: 'jamie.johnson@example.com',
|
|
||||||
avatar: {
|
|
||||||
src: 'https://i.pravatar.cc/128?u=6'
|
|
||||||
},
|
|
||||||
status: 'subscribed',
|
|
||||||
location: 'Sydney, Australia'
|
|
||||||
}, {
|
|
||||||
id: 7,
|
|
||||||
name: 'Riley Davis',
|
|
||||||
email: 'riley.davis@example.com',
|
|
||||||
avatar: {
|
|
||||||
src: 'https://i.pravatar.cc/128?u=7'
|
|
||||||
},
|
|
||||||
status: 'subscribed',
|
|
||||||
location: 'New York, USA'
|
|
||||||
}, {
|
|
||||||
id: 8,
|
|
||||||
name: 'Kelly Wilson',
|
|
||||||
email: 'kelly.wilson@example.com',
|
|
||||||
avatar: {
|
|
||||||
src: 'https://i.pravatar.cc/128?u=8'
|
|
||||||
},
|
|
||||||
status: 'subscribed',
|
|
||||||
location: 'London, UK'
|
|
||||||
}, {
|
|
||||||
id: 9,
|
|
||||||
name: 'Drew Moore',
|
|
||||||
email: 'drew.moore@example.com',
|
|
||||||
avatar: {
|
|
||||||
src: 'https://i.pravatar.cc/128?u=9'
|
|
||||||
},
|
|
||||||
status: 'bounced',
|
|
||||||
location: 'Paris, France'
|
|
||||||
}, {
|
|
||||||
id: 10,
|
|
||||||
name: 'Jordan Taylor',
|
|
||||||
email: 'jordan.taylor@example.com',
|
|
||||||
avatar: {
|
|
||||||
src: 'https://i.pravatar.cc/128?u=10'
|
|
||||||
},
|
|
||||||
status: 'subscribed',
|
|
||||||
location: 'Berlin, Germany'
|
|
||||||
}, {
|
|
||||||
id: 11,
|
|
||||||
name: 'Morgan Anderson',
|
|
||||||
email: 'morgan.anderson@example.com',
|
|
||||||
avatar: {
|
|
||||||
src: 'https://i.pravatar.cc/128?u=11'
|
|
||||||
},
|
|
||||||
status: 'subscribed',
|
|
||||||
location: 'Tokyo, Japan'
|
|
||||||
}, {
|
|
||||||
id: 12,
|
|
||||||
name: 'Casey Thomas',
|
|
||||||
email: 'casey.thomas@example.com',
|
|
||||||
avatar: {
|
|
||||||
src: 'https://i.pravatar.cc/128?u=12'
|
|
||||||
},
|
|
||||||
status: 'unsubscribed',
|
|
||||||
location: 'Sydney, Australia'
|
|
||||||
}, {
|
|
||||||
id: 13,
|
|
||||||
name: 'Jamie Jackson',
|
|
||||||
email: 'jamie.jackson@example.com',
|
|
||||||
avatar: {
|
|
||||||
src: 'https://i.pravatar.cc/128?u=13'
|
|
||||||
},
|
|
||||||
status: 'unsubscribed',
|
|
||||||
location: 'New York, USA'
|
|
||||||
}, {
|
|
||||||
id: 14,
|
|
||||||
name: 'Riley White',
|
|
||||||
email: 'riley.white@example.com',
|
|
||||||
avatar: {
|
|
||||||
src: 'https://i.pravatar.cc/128?u=14'
|
|
||||||
},
|
|
||||||
status: 'unsubscribed',
|
|
||||||
location: 'London, UK'
|
|
||||||
}, {
|
|
||||||
id: 15,
|
|
||||||
name: 'Kelly Harris',
|
|
||||||
email: 'kelly.harris@example.com',
|
|
||||||
avatar: {
|
|
||||||
src: 'https://i.pravatar.cc/128?u=15'
|
|
||||||
},
|
|
||||||
status: 'subscribed',
|
|
||||||
location: 'Paris, France'
|
|
||||||
}, {
|
|
||||||
id: 16,
|
|
||||||
name: 'Drew Martin',
|
|
||||||
email: 'drew.martin@example.com',
|
|
||||||
avatar: {
|
|
||||||
src: 'https://i.pravatar.cc/128?u=16'
|
|
||||||
},
|
|
||||||
status: 'subscribed',
|
|
||||||
location: 'Berlin, Germany'
|
|
||||||
}, {
|
|
||||||
id: 17,
|
|
||||||
name: 'Alex Thompson',
|
|
||||||
email: 'alex.thompson@example.com',
|
|
||||||
avatar: {
|
|
||||||
src: 'https://i.pravatar.cc/128?u=17'
|
|
||||||
},
|
|
||||||
status: 'unsubscribed',
|
|
||||||
location: 'Tokyo, Japan'
|
|
||||||
}, {
|
|
||||||
id: 18,
|
|
||||||
name: 'Jordan Garcia',
|
|
||||||
email: 'jordan.garcia@example.com',
|
|
||||||
avatar: {
|
|
||||||
src: 'https://i.pravatar.cc/128?u=18'
|
|
||||||
},
|
|
||||||
status: 'subscribed',
|
|
||||||
location: 'Sydney, Australia'
|
|
||||||
}, {
|
|
||||||
id: 19,
|
|
||||||
name: 'Taylor Rodriguez',
|
|
||||||
email: 'taylor.rodriguez@example.com',
|
|
||||||
avatar: {
|
|
||||||
src: 'https://i.pravatar.cc/128?u=19'
|
|
||||||
},
|
|
||||||
status: 'bounced',
|
|
||||||
location: 'New York, USA'
|
|
||||||
}, {
|
|
||||||
id: 20,
|
|
||||||
name: 'Morgan Lopez',
|
|
||||||
email: 'morgan.lopez@example.com',
|
|
||||||
avatar: {
|
|
||||||
src: 'https://i.pravatar.cc/128?u=20'
|
|
||||||
},
|
|
||||||
status: 'subscribed',
|
|
||||||
location: 'London, UK'
|
|
||||||
}]
|
|
||||||
|
|
||||||
export default eventHandler(async () => {
|
|
||||||
return customers
|
|
||||||
})
|
|
||||||
|
|
@ -1,691 +0,0 @@
|
||||||
import { sub } from 'date-fns'
|
|
||||||
|
|
||||||
const mails = [{
|
|
||||||
id: 1,
|
|
||||||
from: {
|
|
||||||
name: 'Alex Smith',
|
|
||||||
email: 'alex.smith@example.com',
|
|
||||||
avatar: {
|
|
||||||
src: 'https://i.pravatar.cc/128?u=1'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
subject: 'Meeting Schedule: Q1 Marketing Strategy Review',
|
|
||||||
body: `Dear Team,
|
|
||||||
|
|
||||||
I hope this email finds you well. Just a quick reminder about our Q1 Marketing Strategy meeting scheduled for tomorrow at 10 AM EST in Conference Room A.
|
|
||||||
|
|
||||||
Agenda:
|
|
||||||
- Q4 Performance Review
|
|
||||||
- New Campaign Proposals
|
|
||||||
- Budget Allocation for Q2
|
|
||||||
- Team Resource Planning
|
|
||||||
|
|
||||||
Please come prepared with your department updates. I've attached the preliminary deck for your review.
|
|
||||||
|
|
||||||
Best regards,
|
|
||||||
Alex Smith
|
|
||||||
Senior Marketing Director
|
|
||||||
Tel: (555) 123-4567`,
|
|
||||||
date: new Date().toISOString()
|
|
||||||
}, {
|
|
||||||
id: 2,
|
|
||||||
unread: true,
|
|
||||||
from: {
|
|
||||||
name: 'Jordan Brown',
|
|
||||||
email: 'jordan.brown@example.com',
|
|
||||||
avatar: {
|
|
||||||
src: 'https://i.pravatar.cc/128?u=2'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
subject: 'RE: Project Phoenix - Sprint 3 Update',
|
|
||||||
body: `Hi team,
|
|
||||||
|
|
||||||
Quick update on Sprint 3 deliverables:
|
|
||||||
|
|
||||||
✅ User authentication module completed
|
|
||||||
🏗️ Payment integration at 80%
|
|
||||||
⏳ API documentation pending review
|
|
||||||
|
|
||||||
Key metrics:
|
|
||||||
- Code coverage: 94%
|
|
||||||
- Sprint velocity: 45 points
|
|
||||||
- Bug resolution rate: 98%
|
|
||||||
|
|
||||||
Please review the attached report for detailed analysis. Let's discuss any blockers in tomorrow's stand-up.
|
|
||||||
|
|
||||||
Regards,
|
|
||||||
Jordan
|
|
||||||
|
|
||||||
--
|
|
||||||
Jordan Brown
|
|
||||||
Lead Developer | Tech Solutions
|
|
||||||
Mobile: +1 (555) 234-5678`,
|
|
||||||
date: sub(new Date(), { minutes: 7 }).toISOString()
|
|
||||||
}, {
|
|
||||||
id: 3,
|
|
||||||
unread: true,
|
|
||||||
from: {
|
|
||||||
name: 'Taylor Green',
|
|
||||||
email: 'taylor.green@example.com',
|
|
||||||
avatar: {
|
|
||||||
src: 'https://i.pravatar.cc/128?u=3'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
subject: 'Lunch Plans',
|
|
||||||
body: `Hi there!
|
|
||||||
|
|
||||||
I was wondering if you'd like to grab lunch this Friday? There's this amazing new Mexican restaurant downtown called "La Casa" that I've been wanting to try. They're known for their authentic tacos and house-made guacamole.
|
|
||||||
|
|
||||||
Would 12:30 PM work for you? It would be great to catch up and discuss the upcoming team building event while we're there.
|
|
||||||
|
|
||||||
Let me know what you think!
|
|
||||||
|
|
||||||
Best,
|
|
||||||
Taylor`,
|
|
||||||
date: sub(new Date(), { hours: 3 }).toISOString()
|
|
||||||
}, {
|
|
||||||
id: 4,
|
|
||||||
from: {
|
|
||||||
name: 'Morgan White',
|
|
||||||
email: 'morgan.white@example.com',
|
|
||||||
avatar: {
|
|
||||||
src: 'https://i.pravatar.cc/128?u=4'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
subject: 'New Proposal: Project Horizon',
|
|
||||||
body: `Hi team,
|
|
||||||
|
|
||||||
I've just uploaded the comprehensive proposal for Project Horizon to our shared drive. The document includes:
|
|
||||||
|
|
||||||
• Detailed project objectives and success metrics
|
|
||||||
• Resource allocation and team structure
|
|
||||||
• Timeline with key milestones
|
|
||||||
• Budget breakdown
|
|
||||||
• Risk assessment and mitigation strategies
|
|
||||||
|
|
||||||
I'm particularly excited about our innovative approach to the user engagement component, which could set a new standard for our industry.
|
|
||||||
|
|
||||||
Could you please review and provide feedback by EOD Friday? I'd like to present this to the steering committee next week.
|
|
||||||
|
|
||||||
Thanks in advance,
|
|
||||||
|
|
||||||
Morgan White
|
|
||||||
Senior Project Manager
|
|
||||||
Tel: (555) 234-5678`,
|
|
||||||
date: sub(new Date(), { days: 1 }).toISOString()
|
|
||||||
}, {
|
|
||||||
id: 5,
|
|
||||||
from: {
|
|
||||||
name: 'Casey Gray',
|
|
||||||
email: 'casey.gray@example.com'
|
|
||||||
},
|
|
||||||
subject: 'Updated: San Francisco Conference Trip Itinerary',
|
|
||||||
body: `Dear [Name],
|
|
||||||
|
|
||||||
Please find your confirmed travel itinerary below:
|
|
||||||
|
|
||||||
FLIGHT DETAILS:
|
|
||||||
Outbound: AA 1234
|
|
||||||
Date: March 15, 2024
|
|
||||||
DEP: JFK 09:30 AM
|
|
||||||
ARR: SFO 12:45 PM
|
|
||||||
|
|
||||||
HOTEL:
|
|
||||||
Marriott San Francisco
|
|
||||||
Check-in: March 15
|
|
||||||
Check-out: March 18
|
|
||||||
Confirmation #: MR123456
|
|
||||||
|
|
||||||
SCHEDULE:
|
|
||||||
March 15 - Evening: Welcome Reception (6 PM)
|
|
||||||
March 16 - Conference Day 1 (9 AM - 5 PM)
|
|
||||||
March 17 - Conference Day 2 (9 AM - 4 PM)
|
|
||||||
|
|
||||||
Please let me know if you need any modifications.
|
|
||||||
|
|
||||||
Best regards,
|
|
||||||
Casey Gray
|
|
||||||
Travel Coordinator
|
|
||||||
Office: (555) 345-6789`,
|
|
||||||
date: sub(new Date(), { days: 1 }).toISOString()
|
|
||||||
}, {
|
|
||||||
id: 6,
|
|
||||||
from: {
|
|
||||||
name: 'Jamie Johnson',
|
|
||||||
email: 'jamie.johnson@example.com'
|
|
||||||
},
|
|
||||||
subject: 'Q1 2024 Financial Performance Review',
|
|
||||||
body: `Dear Leadership Team,
|
|
||||||
|
|
||||||
Please find attached our Q1 2024 financial analysis report. Key highlights:
|
|
||||||
|
|
||||||
PERFORMANCE METRICS:
|
|
||||||
• Revenue: $12.4M (+15% YoY)
|
|
||||||
• Operating Expenses: $8.2M (-3% vs. budget)
|
|
||||||
• Net Profit Margin: 18.5% (+2.5% vs. Q4 2023)
|
|
||||||
|
|
||||||
AREAS OF OPTIMIZATION:
|
|
||||||
1. Cloud infrastructure costs (+22% over budget)
|
|
||||||
2. Marketing spend efficiency (-8% ROI vs. target)
|
|
||||||
3. Office operational costs (+5% vs. forecast)
|
|
||||||
|
|
||||||
I've scheduled a detailed review for Thursday at 2 PM EST. Calendar invite to follow.
|
|
||||||
|
|
||||||
Best regards,
|
|
||||||
Jamie Johnson
|
|
||||||
Chief Financial Officer
|
|
||||||
Ext: 4567`,
|
|
||||||
date: sub(new Date(), { days: 2 }).toISOString()
|
|
||||||
}, {
|
|
||||||
id: 7,
|
|
||||||
from: {
|
|
||||||
name: 'Riley Davis',
|
|
||||||
email: 'riley.davis@example.com',
|
|
||||||
avatar: {
|
|
||||||
src: 'https://i.pravatar.cc/128?u=7'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
subject: '[Mandatory] New DevOps Tools Training Session',
|
|
||||||
body: `Hello Development Team,
|
|
||||||
|
|
||||||
This is a reminder about next week's mandatory training session on our updated DevOps toolkit.
|
|
||||||
|
|
||||||
📅 Date: Tuesday, March 19
|
|
||||||
⏰ Time: 10:00 AM - 12:30 PM EST
|
|
||||||
📍 Location: Virtual (Zoom link below)
|
|
||||||
|
|
||||||
We'll be covering:
|
|
||||||
• GitLab CI/CD pipeline improvements
|
|
||||||
• Docker container optimization
|
|
||||||
• Kubernetes cluster management
|
|
||||||
• New monitoring tools integration
|
|
||||||
|
|
||||||
Prerequisites:
|
|
||||||
1. Install Docker Desktop 4.25
|
|
||||||
2. Update VS Code to latest version
|
|
||||||
3. Complete pre-training survey (link attached)
|
|
||||||
|
|
||||||
Zoom Link: https://zoom.us/j/123456789
|
|
||||||
Password: DevOps2024
|
|
||||||
|
|
||||||
--
|
|
||||||
Riley Davis
|
|
||||||
DevOps Lead
|
|
||||||
Technical Operations
|
|
||||||
M: (555) 777-8888`,
|
|
||||||
date: sub(new Date(), { days: 2 }).toISOString()
|
|
||||||
}, {
|
|
||||||
id: 8,
|
|
||||||
unread: true,
|
|
||||||
from: {
|
|
||||||
name: 'Kelly Wilson',
|
|
||||||
email: 'kelly.wilson@example.com',
|
|
||||||
avatar: {
|
|
||||||
src: 'https://i.pravatar.cc/128?u=8'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
subject: '🎉 Happy Birthday!',
|
|
||||||
body: `Dear [Name],
|
|
||||||
|
|
||||||
On behalf of the entire team, wishing you a fantastic birthday! 🎂
|
|
||||||
|
|
||||||
We've organized a small celebration in the break room at 3 PM today. Cake and refreshments will be served!
|
|
||||||
|
|
||||||
Your dedication and positive energy make our workplace better every day. Here's to another great year ahead!
|
|
||||||
|
|
||||||
Best wishes,
|
|
||||||
Kelly & The HR Team
|
|
||||||
|
|
||||||
P.S. Don't forget to check your email for a special birthday surprise from the company! 🎁
|
|
||||||
|
|
||||||
--
|
|
||||||
Kelly Wilson
|
|
||||||
HR Director
|
|
||||||
Human Resources Department
|
|
||||||
Tel: (555) 999-0000`,
|
|
||||||
date: sub(new Date(), { days: 2 }).toISOString()
|
|
||||||
}, {
|
|
||||||
id: 9,
|
|
||||||
from: {
|
|
||||||
name: 'Drew Moore',
|
|
||||||
email: 'drew.moore@example.com'
|
|
||||||
},
|
|
||||||
subject: 'Website Redesign Feedback Request - Phase 2',
|
|
||||||
body: `Hi there,
|
|
||||||
|
|
||||||
We're entering Phase 2 of our website redesign project and would value your input on the latest iterations.
|
|
||||||
|
|
||||||
New Features Implementation:
|
|
||||||
1. Dynamic product catalog
|
|
||||||
2. Enhanced search functionality
|
|
||||||
3. Personalized user dashboard
|
|
||||||
4. Mobile-responsive navigation
|
|
||||||
|
|
||||||
Review Links:
|
|
||||||
• Staging Environment: https://staging.example.com
|
|
||||||
• Design Specs: [Figma Link]
|
|
||||||
• User Flow Documentation: [Confluence Link]
|
|
||||||
|
|
||||||
Please provide feedback by EOD Friday. Key areas to focus on:
|
|
||||||
- User experience
|
|
||||||
- Navigation flow
|
|
||||||
- Content hierarchy
|
|
||||||
- Mobile responsiveness
|
|
||||||
|
|
||||||
Your insights will be crucial for our final implementation decisions.
|
|
||||||
|
|
||||||
Thanks in advance,
|
|
||||||
Drew Moore
|
|
||||||
UX Design Lead
|
|
||||||
Product Design Team`,
|
|
||||||
date: sub(new Date(), { days: 5 }).toISOString()
|
|
||||||
}, {
|
|
||||||
id: 10,
|
|
||||||
from: {
|
|
||||||
name: 'Jordan Taylor',
|
|
||||||
email: 'jordan.taylor@example.com'
|
|
||||||
},
|
|
||||||
subject: 'Corporate Wellness Program - Membership Renewal',
|
|
||||||
body: `Dear Valued Member,
|
|
||||||
|
|
||||||
Your corporate wellness program membership is due for renewal on April 1st, 2024.
|
|
||||||
|
|
||||||
NEW AMENITIES:
|
|
||||||
✨ Expanded yoga studio
|
|
||||||
🏋️ State-of-the-art cardio equipment
|
|
||||||
🧘 Meditation room
|
|
||||||
👥 Additional group fitness classes
|
|
||||||
|
|
||||||
RENEWAL BENEFITS:
|
|
||||||
• 15% early bird discount
|
|
||||||
• 3 complimentary personal training sessions
|
|
||||||
• Free wellness assessment
|
|
||||||
• Access to new mobile app
|
|
||||||
|
|
||||||
To schedule a tour or discuss renewal options, please book a time here: [Booking Link]
|
|
||||||
|
|
||||||
Stay healthy!
|
|
||||||
|
|
||||||
Best regards,
|
|
||||||
Jordan Taylor
|
|
||||||
Corporate Wellness Coordinator
|
|
||||||
Downtown Fitness Center
|
|
||||||
Tel: (555) 123-7890`,
|
|
||||||
date: sub(new Date(), { days: 5 }).toISOString()
|
|
||||||
}, {
|
|
||||||
id: 11,
|
|
||||||
unread: true,
|
|
||||||
from: {
|
|
||||||
name: 'Morgan Anderson',
|
|
||||||
email: 'morgan.anderson@example.com'
|
|
||||||
},
|
|
||||||
subject: 'Important: Updates to Your Corporate Insurance Policy',
|
|
||||||
body: `Dear [Employee Name],
|
|
||||||
|
|
||||||
This email contains important information about changes to your corporate insurance coverage effective April 1, 2024.
|
|
||||||
|
|
||||||
KEY UPDATES:
|
|
||||||
1. Health Insurance
|
|
||||||
• Reduced co-pay for specialist visits ($35 → $25)
|
|
||||||
• Extended telehealth coverage
|
|
||||||
• New mental health benefits
|
|
||||||
|
|
||||||
2. Dental Coverage
|
|
||||||
• Increased annual maximum ($1,500 → $2,000)
|
|
||||||
• Added orthodontic coverage for dependents
|
|
||||||
|
|
||||||
3. Vision Benefits
|
|
||||||
• Enhanced frame allowance
|
|
||||||
• New LASIK discount program
|
|
||||||
|
|
||||||
Please review the attached documentation carefully and complete the acknowledgment form by March 25th.
|
|
||||||
|
|
||||||
Questions? Join our virtual info session:
|
|
||||||
📅 March 20th, 2024
|
|
||||||
⏰ 11:00 AM EST
|
|
||||||
🔗 [Teams Link]
|
|
||||||
|
|
||||||
Regards,
|
|
||||||
Morgan Anderson
|
|
||||||
Benefits Coordinator
|
|
||||||
HR Department`,
|
|
||||||
date: sub(new Date(), { days: 12 }).toISOString()
|
|
||||||
}, {
|
|
||||||
id: 12,
|
|
||||||
from: {
|
|
||||||
name: 'Casey Thomas',
|
|
||||||
email: 'casey.thomas@example.com'
|
|
||||||
},
|
|
||||||
subject: '📚 March Book Club Meeting: "The Great Gatsby"',
|
|
||||||
body: `Hello Book Lovers!
|
|
||||||
|
|
||||||
I hope you're enjoying F. Scott Fitzgerald's masterpiece! Our next meeting details:
|
|
||||||
|
|
||||||
📅 Thursday, March 21st
|
|
||||||
⏰ 5:30 PM - 7:00 PM
|
|
||||||
📍 Main Conference Room (or Zoom)
|
|
||||||
|
|
||||||
Discussion Topics:
|
|
||||||
1. Symbolism of the green light
|
|
||||||
2. The American Dream theme
|
|
||||||
3. Character development
|
|
||||||
4. Social commentary
|
|
||||||
|
|
||||||
Please bring your suggestions for April's book selection!
|
|
||||||
|
|
||||||
Refreshments will be provided 🍪
|
|
||||||
|
|
||||||
RSVP by replying to this email.
|
|
||||||
|
|
||||||
Happy reading!
|
|
||||||
Casey
|
|
||||||
|
|
||||||
--
|
|
||||||
Casey Thomas
|
|
||||||
Book Club Coordinator
|
|
||||||
Internal Culture Committee`,
|
|
||||||
date: sub(new Date(), { months: 1 }).toISOString()
|
|
||||||
}, {
|
|
||||||
id: 13,
|
|
||||||
from: {
|
|
||||||
name: 'Jamie Jackson',
|
|
||||||
email: 'jamie.jackson@example.com'
|
|
||||||
},
|
|
||||||
subject: '🍳 Company Cookbook Project - Recipe Submission Reminder',
|
|
||||||
body: `Dear Colleagues,
|
|
||||||
|
|
||||||
Final call for our company cookbook project submissions!
|
|
||||||
|
|
||||||
Guidelines for Recipe Submission:
|
|
||||||
1. Include ingredients list with measurements
|
|
||||||
2. Step-by-step instructions
|
|
||||||
3. Cooking time and servings
|
|
||||||
4. Photo of the finished dish (optional)
|
|
||||||
5. Any cultural or personal significance
|
|
||||||
|
|
||||||
Submission Deadline: March 22nd, 2024
|
|
||||||
|
|
||||||
We already have some amazing entries:
|
|
||||||
• Sarah's Famous Chili
|
|
||||||
• Mike's Mediterranean Pasta
|
|
||||||
• Lisa's Vegan Brownies
|
|
||||||
• Tom's Family Paella
|
|
||||||
|
|
||||||
All proceeds from cookbook sales will support our local food bank.
|
|
||||||
|
|
||||||
Submit here: [Form Link]
|
|
||||||
|
|
||||||
Cooking together,
|
|
||||||
Jamie Jackson
|
|
||||||
Community Engagement Committee
|
|
||||||
Ext. 5432`,
|
|
||||||
date: sub(new Date(), { months: 1 }).toISOString()
|
|
||||||
}, {
|
|
||||||
id: 14,
|
|
||||||
from: {
|
|
||||||
name: 'Riley White',
|
|
||||||
email: 'riley.white@example.com'
|
|
||||||
},
|
|
||||||
subject: '🧘♀️ Updated Corporate Wellness Schedule - Spring 2024',
|
|
||||||
body: `Dear Wellness Program Participants,
|
|
||||||
|
|
||||||
Our Spring 2024 wellness schedule is now available!
|
|
||||||
|
|
||||||
NEW CLASSES:
|
|
||||||
Monday:
|
|
||||||
• 7:30 AM - Morning Flow Yoga
|
|
||||||
• 12:15 PM - HIIT Express
|
|
||||||
• 5:30 PM - Meditation Basics
|
|
||||||
|
|
||||||
Wednesday:
|
|
||||||
• 8:00 AM - Power Vinyasa
|
|
||||||
• 12:00 PM - Desk Stretching
|
|
||||||
• 4:30 PM - Mindfulness Workshop
|
|
||||||
|
|
||||||
Friday:
|
|
||||||
• 7:45 AM - Gentle Yoga
|
|
||||||
• 12:30 PM - Stress Management
|
|
||||||
• 4:45 PM - Weekend Wind-Down
|
|
||||||
|
|
||||||
All classes available in-person and via Zoom.
|
|
||||||
Download our app to reserve your spot!
|
|
||||||
|
|
||||||
Namaste,
|
|
||||||
Riley White
|
|
||||||
Corporate Wellness Instructor
|
|
||||||
Wellness & Benefits Team`,
|
|
||||||
date: sub(new Date(), { months: 1 }).toISOString()
|
|
||||||
}, {
|
|
||||||
id: 15,
|
|
||||||
from: {
|
|
||||||
name: 'Kelly Harris',
|
|
||||||
email: 'kelly.harris@example.com'
|
|
||||||
},
|
|
||||||
subject: '📚 Book Launch Event: "Digital Transformation in the Modern Age"',
|
|
||||||
body: `Dear [Name],
|
|
||||||
|
|
||||||
You're cordially invited to the launch of my new book, "Digital Transformation in the Modern Age: A Leadership Guide"
|
|
||||||
|
|
||||||
EVENT DETAILS:
|
|
||||||
📅 Date: April 15th, 2024
|
|
||||||
⏰ Time: 6:00 PM - 8:30 PM EST
|
|
||||||
📍 Grand Hotel Downtown
|
|
||||||
123 Business Ave.
|
|
||||||
|
|
||||||
AGENDA:
|
|
||||||
6:00 PM - Welcome Reception
|
|
||||||
6:30 PM - Keynote Presentation
|
|
||||||
7:15 PM - Q&A Session
|
|
||||||
7:45 PM - Book Signing
|
|
||||||
8:00 PM - Networking
|
|
||||||
|
|
||||||
Light refreshments will be served.
|
|
||||||
Each attendee will receive a signed copy of the book.
|
|
||||||
|
|
||||||
RSVP by April 1st: [Event Link]
|
|
||||||
|
|
||||||
Looking forward to sharing this milestone with you!
|
|
||||||
|
|
||||||
Best regards,
|
|
||||||
Kelly Harris
|
|
||||||
Digital Strategy Consultant
|
|
||||||
Author, "Digital Transformation in the Modern Age"`,
|
|
||||||
date: sub(new Date(), { months: 1 }).toISOString()
|
|
||||||
}, {
|
|
||||||
id: 16,
|
|
||||||
from: {
|
|
||||||
name: 'Drew Martin',
|
|
||||||
email: 'drew.martin@example.com'
|
|
||||||
},
|
|
||||||
subject: '🚀 TechCon 2024: Early Bird Registration Now Open',
|
|
||||||
body: `Dear Tech Enthusiasts,
|
|
||||||
|
|
||||||
Registration is now open for TechCon 2024: "Innovation at Scale"
|
|
||||||
|
|
||||||
CONFERENCE HIGHLIGHTS:
|
|
||||||
📅 May 15-17, 2024
|
|
||||||
📍 Tech Convention Center
|
|
||||||
|
|
||||||
KEYNOTE SPEAKERS:
|
|
||||||
• Sarah Johnson - CEO, Future Tech Inc.
|
|
||||||
• Dr. Michael Chang - AI Research Director
|
|
||||||
• Lisa Rodriguez - Cybersecurity Expert
|
|
||||||
|
|
||||||
TRACKS:
|
|
||||||
1. AI/ML Innovation
|
|
||||||
2. Cloud Architecture
|
|
||||||
3. DevSecOps
|
|
||||||
4. Digital Transformation
|
|
||||||
5. Emerging Technologies
|
|
||||||
|
|
||||||
EARLY BIRD PRICING (ends April 1):
|
|
||||||
Full Conference Pass: $899 (reg. $1,199)
|
|
||||||
Team Discount (5+): 15% off
|
|
||||||
|
|
||||||
Register here: [Registration Link]
|
|
||||||
|
|
||||||
Best regards,
|
|
||||||
Drew Martin
|
|
||||||
Conference Director
|
|
||||||
TechCon 2024`,
|
|
||||||
date: sub(new Date(), { months: 1, days: 4 }).toISOString()
|
|
||||||
}, {
|
|
||||||
id: 17,
|
|
||||||
from: {
|
|
||||||
name: 'Alex Thompson',
|
|
||||||
email: 'alex.thompson@example.com'
|
|
||||||
},
|
|
||||||
subject: '🎨 Modern Perspectives: Contemporary Art Exhibition',
|
|
||||||
body: `Hi there,
|
|
||||||
|
|
||||||
Hope you're well! I wanted to personally invite you to an extraordinary art exhibition this weekend.
|
|
||||||
|
|
||||||
"Modern Perspectives: Breaking Boundaries"
|
|
||||||
📅 Saturday & Sunday
|
|
||||||
⏰ 10 AM - 6 PM
|
|
||||||
📍 Metropolitan Art Gallery
|
|
||||||
|
|
||||||
FEATURED ARTISTS:
|
|
||||||
• Maria Chen - Mixed Media
|
|
||||||
• James Wright - Digital Art
|
|
||||||
• Sofia Patel - Installation
|
|
||||||
• Robert Kim - Photography
|
|
||||||
|
|
||||||
SPECIAL EVENTS:
|
|
||||||
• Artist Talk: Saturday, 2 PM
|
|
||||||
• Workshop: Sunday, 11 AM
|
|
||||||
• Wine Reception: Saturday, 5 PM
|
|
||||||
|
|
||||||
Would love to meet you there! Let me know if you'd like to go together.
|
|
||||||
|
|
||||||
Best,
|
|
||||||
Alex Thompson
|
|
||||||
Curator
|
|
||||||
Metropolitan Art Gallery
|
|
||||||
Tel: (555) 234-5678`,
|
|
||||||
date: sub(new Date(), { months: 1, days: 15 }).toISOString()
|
|
||||||
}, {
|
|
||||||
id: 18,
|
|
||||||
from: {
|
|
||||||
name: 'Jordan Garcia',
|
|
||||||
email: 'jordan.garcia@example.com'
|
|
||||||
},
|
|
||||||
subject: '🤝 Industry Networking Event: "Connect & Innovate 2024"',
|
|
||||||
body: `Dear Professional Network,
|
|
||||||
|
|
||||||
You're invited to our premier networking event!
|
|
||||||
|
|
||||||
EVENT DETAILS:
|
|
||||||
📅 March 28th, 2024
|
|
||||||
⏰ 6:00 PM - 9:00 PM
|
|
||||||
📍 Innovation Hub
|
|
||||||
456 Enterprise Street
|
|
||||||
|
|
||||||
SPEAKERS:
|
|
||||||
• Mark Thompson - "Future of Work"
|
|
||||||
• Dr. Sarah Chen - "Innovation Trends"
|
|
||||||
• Robert Mills - "Digital Leadership"
|
|
||||||
|
|
||||||
SCHEDULE:
|
|
||||||
6:00 - Registration & Welcome
|
|
||||||
6:30 - Keynote Presentations
|
|
||||||
7:30 - Networking Session
|
|
||||||
8:30 - Panel Discussion
|
|
||||||
|
|
||||||
Complimentary hors d'oeuvres and beverages will be served.
|
|
||||||
|
|
||||||
RSVP Required: [Registration Link]
|
|
||||||
|
|
||||||
Best regards,
|
|
||||||
Jordan Garcia
|
|
||||||
Event Coordinator
|
|
||||||
Professional Networking Association`,
|
|
||||||
date: sub(new Date(), { months: 1, days: 18 }).toISOString()
|
|
||||||
}, {
|
|
||||||
id: 19,
|
|
||||||
from: {
|
|
||||||
name: 'Taylor Rodriguez',
|
|
||||||
email: 'taylor.rodriguez@example.com'
|
|
||||||
},
|
|
||||||
subject: '🌟 Community Service Day - Volunteer Opportunities',
|
|
||||||
body: `Dear Colleagues,
|
|
||||||
|
|
||||||
Join us for our annual Community Service Day!
|
|
||||||
|
|
||||||
EVENT DETAILS:
|
|
||||||
📅 Saturday, April 6th, 2024
|
|
||||||
⏰ 9:00 AM - 3:00 PM
|
|
||||||
📍 Multiple Locations
|
|
||||||
|
|
||||||
VOLUNTEER OPPORTUNITIES:
|
|
||||||
1. City Park Cleanup
|
|
||||||
• Garden maintenance
|
|
||||||
• Trail restoration
|
|
||||||
• Playground repair
|
|
||||||
|
|
||||||
2. Food Bank
|
|
||||||
• Sorting donations
|
|
||||||
• Packing meals
|
|
||||||
• Distribution
|
|
||||||
|
|
||||||
3. Animal Shelter
|
|
||||||
• Dog walking
|
|
||||||
• Facility cleaning
|
|
||||||
• Social media support
|
|
||||||
|
|
||||||
All volunteers receive:
|
|
||||||
• Company volunteer t-shirt
|
|
||||||
• Lunch and refreshments
|
|
||||||
• Certificate of participation
|
|
||||||
• 8 hours community service credit
|
|
||||||
|
|
||||||
Sign up here: [Volunteer Portal]
|
|
||||||
|
|
||||||
Making a difference together,
|
|
||||||
Taylor Rodriguez
|
|
||||||
Community Outreach Coordinator
|
|
||||||
Corporate Social Responsibility Team`,
|
|
||||||
date: sub(new Date(), { months: 1, days: 25 }).toISOString()
|
|
||||||
}, {
|
|
||||||
id: 20,
|
|
||||||
from: {
|
|
||||||
name: 'Morgan Lopez',
|
|
||||||
email: 'morgan.lopez@example.com'
|
|
||||||
},
|
|
||||||
subject: '🚗 Vehicle Maintenance Reminder: 30,000 Mile Service',
|
|
||||||
body: `Dear Valued Customer,
|
|
||||||
|
|
||||||
Your vehicle is due for its 30,000-mile maintenance service.
|
|
||||||
|
|
||||||
RECOMMENDED SERVICES:
|
|
||||||
• Oil and filter change
|
|
||||||
• Tire rotation and alignment
|
|
||||||
• Brake system inspection
|
|
||||||
• Multi-point safety inspection
|
|
||||||
• Fluid level check and top-off
|
|
||||||
• Battery performance test
|
|
||||||
|
|
||||||
SERVICE CENTER DETAILS:
|
|
||||||
📍 Downtown Auto Care
|
|
||||||
789 Service Road
|
|
||||||
|
|
||||||
☎️ (555) 987-6543
|
|
||||||
|
|
||||||
Available Appointments:
|
|
||||||
• Monday-Friday: 7:30 AM - 6:00 PM
|
|
||||||
• Saturday: 8:00 AM - 2:00 PM
|
|
||||||
|
|
||||||
Schedule online: [Booking Link]
|
|
||||||
or call our service desk directly.
|
|
||||||
|
|
||||||
Drive safely,
|
|
||||||
Morgan Lopez
|
|
||||||
Service Coordinator
|
|
||||||
Downtown Auto Care
|
|
||||||
Emergency: (555) 987-6544`,
|
|
||||||
date: sub(new Date(), { months: 2 }).toISOString()
|
|
||||||
}]
|
|
||||||
|
|
||||||
export default eventHandler(async () => {
|
|
||||||
return mails
|
|
||||||
})
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
const members = [{
|
|
||||||
name: 'Anthony Fu',
|
|
||||||
username: 'antfu',
|
|
||||||
role: 'member',
|
|
||||||
avatar: { src: 'https://ipx.nuxt.com/f_auto,s_192x192/gh_avatar/antfu' }
|
|
||||||
}, {
|
|
||||||
name: 'Baptiste Leproux',
|
|
||||||
username: 'larbish',
|
|
||||||
role: 'member',
|
|
||||||
avatar: { src: 'https://ipx.nuxt.com/f_auto,s_192x192/gh_avatar/larbish' }
|
|
||||||
}, {
|
|
||||||
name: 'Benjamin Canac',
|
|
||||||
username: 'benjamincanac',
|
|
||||||
role: 'owner',
|
|
||||||
avatar: { src: 'https://ipx.nuxt.com/f_auto,s_192x192/gh_avatar/benjamincanac' }
|
|
||||||
}, {
|
|
||||||
name: 'Céline Dumerc',
|
|
||||||
username: 'celinedumerc',
|
|
||||||
role: 'member',
|
|
||||||
avatar: { src: 'https://ipx.nuxt.com/f_auto,s_192x192/gh_avatar/celinedumerc' }
|
|
||||||
}, {
|
|
||||||
name: 'Daniel Roe',
|
|
||||||
username: 'danielroe',
|
|
||||||
role: 'member',
|
|
||||||
avatar: { src: 'https://ipx.nuxt.com/f_auto,s_192x192/gh_avatar/danielroe' }
|
|
||||||
}, {
|
|
||||||
name: 'Farnabaz',
|
|
||||||
username: 'farnabaz',
|
|
||||||
role: 'member',
|
|
||||||
avatar: { src: 'https://ipx.nuxt.com/f_auto,s_192x192/gh_avatar/farnabaz' }
|
|
||||||
}, {
|
|
||||||
name: 'Ferdinand Coumau',
|
|
||||||
username: 'FerdinandCoumau',
|
|
||||||
role: 'member',
|
|
||||||
avatar: { src: 'https://ipx.nuxt.com/f_auto,s_192x192/gh_avatar/FerdinandCoumau' }
|
|
||||||
}, {
|
|
||||||
name: 'Hugo Richard',
|
|
||||||
username: 'hugorcd',
|
|
||||||
role: 'owner',
|
|
||||||
avatar: { src: 'https://ipx.nuxt.com/f_auto,s_192x192/gh_avatar/hugorcd' }
|
|
||||||
}, {
|
|
||||||
name: 'Pooya Parsa',
|
|
||||||
username: 'pi0',
|
|
||||||
role: 'member',
|
|
||||||
avatar: { src: 'https://ipx.nuxt.com/f_auto,s_192x192/gh_avatar/pi0' }
|
|
||||||
}, {
|
|
||||||
name: 'Sarah Moriceau',
|
|
||||||
username: 'SarahM19',
|
|
||||||
role: 'member',
|
|
||||||
avatar: { src: 'https://ipx.nuxt.com/f_auto,s_192x192/gh_avatar/SarahM19' }
|
|
||||||
}, {
|
|
||||||
name: 'Sébastien Chopin',
|
|
||||||
username: 'Atinux',
|
|
||||||
role: 'owner',
|
|
||||||
avatar: { src: 'https://ipx.nuxt.com/f_auto,s_192x192/gh_avatar/atinux' }
|
|
||||||
}]
|
|
||||||
|
|
||||||
export default eventHandler(async () => {
|
|
||||||
return members
|
|
||||||
})
|
|
||||||
|
|
@ -1,256 +0,0 @@
|
||||||
import { sub } from 'date-fns'
|
|
||||||
|
|
||||||
const notifications = [{
|
|
||||||
id: 1,
|
|
||||||
unread: true,
|
|
||||||
sender: {
|
|
||||||
name: 'Jordan Brown',
|
|
||||||
email: 'jordan.brown@example.com',
|
|
||||||
avatar: {
|
|
||||||
src: 'https://i.pravatar.cc/128?u=2'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
body: 'sent you a message',
|
|
||||||
date: sub(new Date(), { minutes: 7 }).toISOString()
|
|
||||||
}, {
|
|
||||||
id: 2,
|
|
||||||
sender: {
|
|
||||||
name: 'Lindsay Walton'
|
|
||||||
},
|
|
||||||
body: 'subscribed to your email list',
|
|
||||||
date: sub(new Date(), { hours: 1 }).toISOString()
|
|
||||||
}, {
|
|
||||||
id: 3,
|
|
||||||
unread: true,
|
|
||||||
sender: {
|
|
||||||
name: 'Taylor Green',
|
|
||||||
email: 'taylor.green@example.com',
|
|
||||||
avatar: {
|
|
||||||
src: 'https://i.pravatar.cc/128?u=3'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
body: 'sent you a message',
|
|
||||||
date: sub(new Date(), { hours: 3 }).toISOString()
|
|
||||||
}, {
|
|
||||||
id: 4,
|
|
||||||
sender: {
|
|
||||||
name: 'Courtney Henry',
|
|
||||||
avatar: {
|
|
||||||
src: 'https://i.pravatar.cc/128?u=4'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
body: 'added you to a project',
|
|
||||||
date: sub(new Date(), { hours: 3 }).toISOString()
|
|
||||||
}, {
|
|
||||||
id: 5,
|
|
||||||
sender: {
|
|
||||||
name: 'Tom Cook',
|
|
||||||
avatar: {
|
|
||||||
src: 'https://i.pravatar.cc/128?u=5'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
body: 'abandonned cart',
|
|
||||||
date: sub(new Date(), { hours: 7 }).toISOString()
|
|
||||||
}, {
|
|
||||||
id: 6,
|
|
||||||
sender: {
|
|
||||||
name: 'Casey Thomas',
|
|
||||||
avatar: {
|
|
||||||
src: 'https://i.pravatar.cc/128?u=6'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
body: 'purchased your product',
|
|
||||||
date: sub(new Date(), { days: 1, hours: 3 }).toISOString()
|
|
||||||
}, {
|
|
||||||
id: 7,
|
|
||||||
unread: true,
|
|
||||||
sender: {
|
|
||||||
name: 'Kelly Wilson',
|
|
||||||
email: 'kelly.wilson@example.com',
|
|
||||||
avatar: {
|
|
||||||
src: 'https://i.pravatar.cc/128?u=8'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
body: 'sent you a message',
|
|
||||||
date: sub(new Date(), { days: 2 }).toISOString()
|
|
||||||
}, {
|
|
||||||
id: 8,
|
|
||||||
sender: {
|
|
||||||
name: 'Jamie Johnson',
|
|
||||||
email: 'jamie.johnson@example.com',
|
|
||||||
avatar: {
|
|
||||||
src: 'https://i.pravatar.cc/128?u=9'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
body: 'requested a refund',
|
|
||||||
date: sub(new Date(), { days: 5, hours: 4 }).toISOString()
|
|
||||||
}, {
|
|
||||||
id: 9,
|
|
||||||
unread: true,
|
|
||||||
sender: {
|
|
||||||
name: 'Morgan Anderson',
|
|
||||||
email: 'morgan.anderson@example.com'
|
|
||||||
},
|
|
||||||
body: 'sent you a message',
|
|
||||||
date: sub(new Date(), { days: 6 }).toISOString()
|
|
||||||
}, {
|
|
||||||
id: 10,
|
|
||||||
sender: {
|
|
||||||
name: 'Drew Moore'
|
|
||||||
},
|
|
||||||
body: 'subscribed to your email list',
|
|
||||||
date: sub(new Date(), { days: 6 }).toISOString()
|
|
||||||
}, {
|
|
||||||
id: 11,
|
|
||||||
sender: {
|
|
||||||
name: 'Riley Davis'
|
|
||||||
},
|
|
||||||
body: 'abandonned cart',
|
|
||||||
date: sub(new Date(), { days: 7 }).toISOString()
|
|
||||||
}, {
|
|
||||||
id: 12,
|
|
||||||
sender: {
|
|
||||||
name: 'Jordan Taylor'
|
|
||||||
},
|
|
||||||
body: 'subscribed to your email list',
|
|
||||||
date: sub(new Date(), { days: 9 }).toISOString()
|
|
||||||
}, {
|
|
||||||
id: 13,
|
|
||||||
sender: {
|
|
||||||
name: 'Kelly Wilson',
|
|
||||||
email: 'kelly.wilson@example.com',
|
|
||||||
avatar: {
|
|
||||||
src: 'https://i.pravatar.cc/128?u=8'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
body: 'subscribed to your email list',
|
|
||||||
date: sub(new Date(), { days: 10 }).toISOString()
|
|
||||||
}, {
|
|
||||||
id: 14,
|
|
||||||
sender: {
|
|
||||||
name: 'Jamie Johnson',
|
|
||||||
email: 'jamie.johnson@example.com',
|
|
||||||
avatar: {
|
|
||||||
src: 'https://i.pravatar.cc/128?u=9'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
body: 'subscribed to your email list',
|
|
||||||
date: sub(new Date(), { days: 11 }).toISOString()
|
|
||||||
}, {
|
|
||||||
id: 15,
|
|
||||||
sender: {
|
|
||||||
name: 'Morgan Anderson'
|
|
||||||
},
|
|
||||||
body: 'purchased your product',
|
|
||||||
date: sub(new Date(), { days: 12 }).toISOString()
|
|
||||||
}, {
|
|
||||||
id: 16,
|
|
||||||
sender: {
|
|
||||||
name: 'Drew Moore',
|
|
||||||
avatar: {
|
|
||||||
src: 'https://i.pravatar.cc/128?u=16'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
body: 'subscribed to your email list',
|
|
||||||
date: sub(new Date(), { days: 13 }).toISOString()
|
|
||||||
}, {
|
|
||||||
id: 17,
|
|
||||||
sender: {
|
|
||||||
name: 'Riley Davis'
|
|
||||||
},
|
|
||||||
body: 'subscribed to your email list',
|
|
||||||
date: sub(new Date(), { days: 14 }).toISOString()
|
|
||||||
}, {
|
|
||||||
id: 18,
|
|
||||||
sender: {
|
|
||||||
name: 'Jordan Taylor'
|
|
||||||
},
|
|
||||||
body: 'subscribed to your email list',
|
|
||||||
date: sub(new Date(), { days: 15 }).toISOString()
|
|
||||||
}, {
|
|
||||||
id: 19,
|
|
||||||
sender: {
|
|
||||||
name: 'Kelly Wilson',
|
|
||||||
email: 'kelly.wilson@example.com',
|
|
||||||
avatar: {
|
|
||||||
src: 'https://i.pravatar.cc/128?u=8'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
body: 'subscribed to your email list',
|
|
||||||
date: sub(new Date(), { days: 16 }).toISOString()
|
|
||||||
}, {
|
|
||||||
id: 20,
|
|
||||||
sender: {
|
|
||||||
name: 'Jamie Johnson',
|
|
||||||
email: 'jamie.johnson@example.com',
|
|
||||||
avatar: {
|
|
||||||
src: 'https://i.pravatar.cc/128?u=9'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
body: 'purchased your product',
|
|
||||||
date: sub(new Date(), { days: 17 }).toISOString()
|
|
||||||
}, {
|
|
||||||
id: 21,
|
|
||||||
sender: {
|
|
||||||
name: 'Morgan Anderson'
|
|
||||||
},
|
|
||||||
body: 'abandonned cart',
|
|
||||||
date: sub(new Date(), { days: 17 }).toISOString()
|
|
||||||
}, {
|
|
||||||
id: 22,
|
|
||||||
sender: {
|
|
||||||
name: 'Drew Moore'
|
|
||||||
},
|
|
||||||
body: 'subscribed to your email list',
|
|
||||||
date: sub(new Date(), { days: 18 }).toISOString()
|
|
||||||
}, {
|
|
||||||
id: 23,
|
|
||||||
sender: {
|
|
||||||
name: 'Riley Davis'
|
|
||||||
},
|
|
||||||
body: 'subscribed to your email list',
|
|
||||||
date: sub(new Date(), { days: 19 }).toISOString()
|
|
||||||
}, {
|
|
||||||
id: 24,
|
|
||||||
sender: {
|
|
||||||
name: 'Jordan Taylor',
|
|
||||||
avatar: {
|
|
||||||
src: 'https://i.pravatar.cc/128?u=24'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
body: 'subscribed to your email list',
|
|
||||||
date: sub(new Date(), { days: 20 }).toISOString()
|
|
||||||
}, {
|
|
||||||
id: 25,
|
|
||||||
sender: {
|
|
||||||
name: 'Kelly Wilson',
|
|
||||||
email: 'kelly.wilson@example.com',
|
|
||||||
avatar: {
|
|
||||||
src: 'https://i.pravatar.cc/128?u=8'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
body: 'subscribed to your email list',
|
|
||||||
date: sub(new Date(), { days: 20 }).toISOString()
|
|
||||||
}, {
|
|
||||||
id: 26,
|
|
||||||
sender: {
|
|
||||||
name: 'Jamie Johnson',
|
|
||||||
email: 'jamie.johnson@example.com',
|
|
||||||
avatar: {
|
|
||||||
src: 'https://i.pravatar.cc/128?u=9'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
body: 'abandonned cart',
|
|
||||||
date: sub(new Date(), { days: 21 }).toISOString()
|
|
||||||
}, {
|
|
||||||
id: 27,
|
|
||||||
sender: {
|
|
||||||
name: 'Morgan Anderson'
|
|
||||||
},
|
|
||||||
body: 'subscribed to your email list',
|
|
||||||
date: sub(new Date(), { days: 22 }).toISOString()
|
|
||||||
}]
|
|
||||||
|
|
||||||
export default eventHandler(async () => {
|
|
||||||
return notifications
|
|
||||||
})
|
|
||||||
Loading…
Reference in New Issue