Adding initial commit

This commit is contained in:
Julio Ruiz 2026-05-07 00:11:28 -05:00
commit 7bb82f1ab9
51 changed files with 34914 additions and 0 deletions

24
.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Nuxt dev/build outputs
.output
.data
.nuxt
.nitro
.cache
dist
# Node dependencies
node_modules
# Logs
logs
*.log
# Misc
.DS_Store
.fleet
.idea
# Local env files
.env
.env.*
!.env.example

21
LICENSE Executable file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Nuxt UI Templates
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

64
README.md Executable file
View File

@ -0,0 +1,64 @@
# Nuxt Dashboard Template
[![Nuxt UI](https://img.shields.io/badge/Made%20with-Nuxt%20UI-00DC82?logo=nuxt&labelColor=020420)](https://ui.nuxt.com)
Get started with the Nuxt dashboard template with multiple pages, collapsible sidebar, keyboard shortcuts, light & dark mode, command palette and more, powered by [Nuxt UI](https://ui.nuxt.com).
- [Live demo](https://dashboard-template.nuxt.dev/)
- [Documentation](https://ui.nuxt.com/docs/getting-started/installation/nuxt)
<a href="https://dashboard-template.nuxt.dev/" target="_blank">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://ui.nuxt.com/assets/templates/nuxt/dashboard-dark.png">
<source media="(prefers-color-scheme: light)" srcset="https://ui.nuxt.com/assets/templates/nuxt/dashboard-light.png">
<img alt="Nuxt Dashboard Template" src="https://ui.nuxt.com/assets/templates/nuxt/dashboard-light.png">
</picture>
</a>
> The dashboard template for Vue is on https://github.com/nuxt-ui-templates/dashboard-vue.
## Quick Start
```bash [Terminal]
npm create nuxt@latest -- -t ui/dashboard
```
## Deploy your own
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-name=dashboard&repository-url=https%3A%2F%2Fgithub.com%2Fnuxt-ui-templates%2Fdashboard&demo-image=https%3A%2F%2Fui.nuxt.com%2Fassets%2Ftemplates%2Fnuxt%2Fdashboard-dark.png&demo-url=https%3A%2F%2Fdashboard-template.nuxt.dev%2F&demo-title=Nuxt%20Dashboard%20Template&demo-description=A%20dashboard%20template%20with%20multi-column%20layout%20for%20building%20sophisticated%20admin%20interfaces.)
## Setup
Make sure to install the dependencies:
```bash
pnpm install
```
## Development Server
Start the development server on `http://localhost:3000`:
```bash
pnpm dev
```
## Production
Build the application for production:
```bash
pnpm build
```
Locally preview production build:
```bash
pnpm preview
```
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.
## Renovate integration
Install [Renovate GitHub app](https://github.com/apps/renovate/installations/select_target) on your repository and you are good to go.

8
app/app.config.ts Executable file
View File

@ -0,0 +1,8 @@
export default defineAppConfig({
ui: {
colors: {
primary: 'blue',
neutral: 'zinc'
}
}
})

41
app/app.vue Executable file
View File

@ -0,0 +1,41 @@
<script setup lang="ts">
const colorMode = useColorMode()
const color = computed(() => colorMode.value === 'dark' ? '#1b1718' : 'white')
useHead({
meta: [
{ charset: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{ key: 'theme-color', name: 'theme-color', content: color }
],
link: [
{ rel: 'icon', href: '/favicon.ico' }
],
htmlAttrs: {
lang: 'en'
}
})
const title = 'Nuxt Dashboard Template'
const description = 'A professional dashboard template built with Nuxt UI, featuring multiple pages, data visualization, and comprehensive management capabilities for creating powerful admin interfaces.'
useSeoMeta({
title,
description,
ogTitle: title,
ogDescription: description,
ogImage: 'https://ui.nuxt.com/assets/templates/nuxt/dashboard-light.png',
twitterCard: 'summary_large_image'
})
</script>
<template>
<UApp>
<NuxtLoadingIndicator />
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</UApp>
</template>

18
app/assets/css/main.css Executable file
View File

@ -0,0 +1,18 @@
@import "tailwindcss" theme(static);
@import "@nuxt/ui";
@theme static {
--font-sans: 'Public Sans', sans-serif;
--color-green-50: #EFFDF5;
--color-green-100: #D9FBE8;
--color-green-200: #B3F5D1;
--color-green-300: #75EDAE;
--color-green-400: #00DC82;
--color-green-500: #00C16A;
--color-green-600: #00A155;
--color-green-700: #007F45;
--color-green-800: #016538;
--color-green-900: #0A5331;
--color-green-950: #052E16;
}

View File

@ -0,0 +1,52 @@
<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>

68
app/components/TeamsMenu.vue Executable file
View File

@ -0,0 +1,68 @@
<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>

188
app/components/UserMenu.vue Executable file
View File

@ -0,0 +1,188 @@
<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>

View File

@ -0,0 +1,59 @@
<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>

View File

@ -0,0 +1,42 @@
<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>

View File

@ -0,0 +1,121 @@
<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>

View File

@ -0,0 +1,16 @@
<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>

View File

@ -0,0 +1,132 @@
<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>

View File

@ -0,0 +1,49 @@
<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>

112
app/components/home/HomeSales.vue Executable file
View File

@ -0,0 +1,112 @@
<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>

View File

@ -0,0 +1,98 @@
<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>

View File

@ -0,0 +1,108 @@
<script setup lang="ts">
import { format } from 'date-fns'
import type { Activity } from '~/types'
defineProps<{
activity: Activity
}>()
const emits = defineEmits(['close'])
const dropdownItems = [[{
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()
const reply = ref('')
const loading = ref(false)
const meilisearch = useMeiliSearchRef()
const body = computed(async() => {
// let item = meilisearch.index('activities_ES').getDocument(activity.value._id)
// return await item.body
})
</script>
<template>
<UDashboardPanel id="inbox-2">
<UDashboardNavbar :title="activity.title" :toggle="false">
<template #leading>
<UButton
icon="i-lucide-x"
color="neutral"
variant="ghost"
class="-ms-1.5"
@click="emits('close')"
/>
</template>
<template #right>
<UTooltip text="Archive">
<UButton
icon="i-lucide-inbox"
color="neutral"
variant="ghost"
/>
</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>
</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 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">
<p class="font-semibold text-muted text-sm text-highlighted">
{{ formatDate(new Date(activity.date).getTime()/1000) }}
</p>
<p class="text-muted">
<!-- {{ activity.email }} -->
</p>
</div>
</div>
<p class="max-sm:pl-16 text-muted text-sm sm:mt-2">
{{ formatLocation( activity ) }}
</p>
</div>
<div class="flex-1 p-4 sm:p-6 overflow-y-auto">
{{ activity._id }}
<p class="whitespace-pre-wrap" v-html="body">
</p>
</div>
</UDashboardPanel>
</template>

View File

@ -0,0 +1,101 @@
<script setup lang="ts">
import { format, isToday } from 'date-fns'
import type { Activity } from '~/types'
const props = defineProps<{
activities: Activity[]
}>()
const activitiesRefs = ref<Record<number, Element | null>>({})
const selectedActivity = defineModel<Activity | null>()
watch(selectedActivity, () => {
if (!selectedActivity.value) {
return
}
const ref = activitiesRefs.value[selectedActivity.value._id]
if (ref) {
ref.scrollIntoView({ block: 'nearest' })
}
})
const activityLinks = [
{
label: 'Youtube',
},
{
label: 'Audio',
},
{
label: 'Libro',
},
{
label: 'Sencillo',
}
]
defineShortcuts({
arrowdown: () => {
const index = props.activities.findIndex((activity: Activity) => activity._id === selectedActivity.value?._id)
if (index === -1) {
selectedActivity.value = props.activities[0]
} else if (index < props.activities.length - 1) {
selectedActivity.value = props.activities[index + 1]
}
},
arrowup: () => {
const index = props.activities.findIndex((activity: Activity) => activity._id === selectedActivity.value?._id)
if (index === -1) {
selectedActivity.value = props.activities[props.activities.length - 1]
} else if (index > 0) {
selectedActivity.value = props.activities[index - 1]
}
}
})
</script>
<template>
<div class="overflow-y-auto divide-y divide-default">
<div
v-for="(activity, index) in activities"
:key="index"
:ref="(el) => { activitiesRefs[activity._id] = el as Element | null }"
>
<div
class="p-4 sm:px-6 text-sm cursor-pointer border-l-2 transition-colors"
:class="[
activity.unread ? 'text-highlighted' : 'text-toned',
selectedActivity && selectedActivity._id === activity._id
? 'border-primary bg-primary/10'
: 'border-bg hover:border-primary hover:bg-primary/5'
]"
@click="selectedActivity = activity"
>
<div class="flex items-center justify-between" :class="[activity.unread && 'font-semibold']">
<div class="flex items-center gap-3 text-md font-semibold mb-2">
{{ activity.title }}
<UChip v-if="activity.unread" />
</div>
<!-- <span>{{ isToday(new Date(activity.date || '')) ? format(new Date(activity.date || ''), 'HH:mm') : format(new Date(activity.date || ''), 'dd MMM') }}</span> -->
</div>
<p class="flex justify-between text-xs" :class="[activity.unread && 'font-semibold']">
{{ formatDate(new Date(activity.date).getTime()/1000) }}<USeparator orientation="vertical" class="h-4" /> {{ formatLocation(activity) }}
</p>
<p class="text-dimmed line-clamp-1" v-html="activity.body">
</p>
<ul class="flex space-x-4 text-xs my-2 text-gray-400">
<li class="flex align-center"><UIcon name="i-lucide-youtube" class="size-4 mr-1" />Youtube</li>
<li class="flex align-center"><UIcon name="i-lucide-file-audio" class="size-4 mr-1" />Audio</li>
<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>
</ul>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,60 @@
<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>

25
app/composables/useDashboard.ts Executable file
View File

@ -0,0 +1,25 @@
import { createSharedComposable } from '@vueuse/core'
const _useDashboard = () => {
const route = useRoute()
const router = useRouter()
const isNotificationsSlideoverOpen = ref(false)
defineShortcuts({
'g-h': () => router.push('/'),
'g-i': () => router.push('/inbox'),
'g-c': () => router.push('/customers'),
'g-s': () => router.push('/settings'),
'n': () => isNotificationsSlideoverOpen.value = !isNotificationsSlideoverOpen.value
})
watch(() => route.fullPath, () => {
isNotificationsSlideoverOpen.value = false
})
return {
isNotificationsSlideoverOpen
}
}
export const useDashboard = createSharedComposable(_useDashboard)

24
app/error.vue Executable file
View File

@ -0,0 +1,24 @@
<script setup lang="ts">
import type { NuxtError } from '#app'
defineProps<{
error: NuxtError
}>()
useSeoMeta({
title: 'Page not found',
description: 'We are sorry but this page could not be found.'
})
useHead({
htmlAttrs: {
lang: 'en'
}
})
</script>
<template>
<UApp>
<UError :error="error" />
</UApp>
</template>

132
app/layouts/default.vue Executable file
View File

@ -0,0 +1,132 @@
<script setup lang="ts">
import type { NavigationMenuItem } from '@nuxt/ui'
const route = useRoute()
const toast = useToast()
const open = ref(false)
const links = [[{
label: 'Home',
icon: 'i-lucide-house',
to: '/',
onSelect: () => {
open.value = false
}
}, {
label: 'Actividades',
icon: 'i-lucide-inbox',
to: '/actividades',
badge: '4',
onSelect: () => {
open.value = false
}
}, {
label: 'Conferencias',
icon: 'i-lucide-inbox',
to: '/conferencias',
onSelect: () => {
open.value = false
}
}], [
// {
// 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>
<template>
<UDashboardGroup unit="rem">
<UDashboardSidebar
id="default"
v-model:open="open"
collapsible
resizable
class="bg-elevated/25"
:ui="{ footer: 'lg:border-t lg:border-default' }"
>
<template #header="{ collapsed }">
<ULink as="button">La Gran Carpa Catedral</ULink>
</template>
<template #default="{ collapsed }">
<UDashboardSearchButton :collapsed="collapsed" class="bg-transparent ring-default" />
<UNavigationMenu
:collapsed="collapsed"
:items="links[0]"
orientation="vertical"
tooltip
popover
/>
<UNavigationMenu
:collapsed="collapsed"
:items="links[1]"
orientation="vertical"
tooltip
class="mt-auto"
/>
</template>
<template #footer="{ collapsed }">
<UserMenu :collapsed="collapsed" />
</template>
</UDashboardSidebar>
<UDashboardSearch :groups="groups" />
<slot />
<NotificationsSlideover />
</UDashboardGroup>
</template>

113
app/pages/actividades.vue Executable file
View File

@ -0,0 +1,113 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { breakpointsTailwind } from '@vueuse/core'
import type { Activity } from '~/types'
import InboxActivity from '~/components/inbox/InboxActivity.vue'
const tabItems = [{
label: 'All',
value: 'all'
}, {
label: 'Unread',
value: 'unread'
}]
const selectedTab = ref('all')
const activities = ref()
// const searchKey = "04be59c1f633e2bb434082fc1a6fcc6ce97e3630e3fcf9e814e1f03a386c03e1"
// const { data: activities } = await useFetch<Activity[]>('https://search.carpa.com/indexes/activities_ES/search?attributesToRetrieve=["title"]&q=', { default: () => [], headers: {
// Authorization: `Bearer ${searchKey}`
// } })
const { search, result } = useMeiliSearch('activities_ES')
activities.value = await( search('',
{
attributesToRetrieve: ['_id', 'title', 'slug', 'date', 'activity', 'city', 'state', 'country'],
attributesToCrop: ['body:250'],
attributesToHighlight: ['body'],
cropLength: 250,
limit: 30,
sort: ['date:desc'],
}
) )
// Filter activities based on the selected tab
const filteredActivities = computed(() => {
// if (selectedTab.value === 'unread') {
// return activities.hits.filter(activity => !!activity.unread)
// }
console.log( 'Activities', activities )
return activities.value?.hits
})
const selectedActivity = ref<Activity | null>()
const isActivityPanelOpen = computed({
get() {
return !!selectedActivity.value
},
set(value: boolean) {
if (!value) {
selectedActivity.value = null
}
}
})
// Reset selected activity if it's not in the filtered activities
watch(filteredActivities, () => {
if (!filteredActivities.value.find(activity => activity.id === selectedActivity.value?.id)) {
selectedActivity.value = null
}
})
const breakpoints = useBreakpoints(breakpointsTailwind)
const isMobile = breakpoints.smaller('lg')
</script>
<template>
<UDashboardPanel
id="inbox-1"
:default-size="25"
:min-size="20"
:max-size="30"
resizable
>
<UDashboardNavbar title="Actividades">
<template #leading>
<UDashboardSidebarCollapse />
</template>
<template #trailing>
<UBadge :label="filteredActivities?.length" variant="subtle" />
</template>
<template #right>
<UTabs
v-model="selectedTab"
:items="tabItems"
:content="false"
size="xs"
/>
</template>
</UDashboardNavbar>
<InboxList v-model="selectedActivity" :activities="filteredActivities" />
</UDashboardPanel>
<InboxActivity v-if="selectedActivity" :activity="selectedActivity" @close="selectedActivity = null" />
<div v-else class="hidden lg:flex flex-1 items-center justify-center">
<UIcon name="i-lucide-inbox" class="size-32 text-dimmed" />
</div>
<ClientOnly>
<USlideover v-if="isMobile" v-model:open="isActivityPanelOpen">
<template #content>
<InboxActivity v-if="selectedActivity" :activity="selectedActivity" @close="selectedActivity = null" />
</template>
</USlideover>
</ClientOnly>
</template>

330
app/pages/conferencias.vue Executable file
View File

@ -0,0 +1,330 @@
<script setup lang="ts">
import type { TableColumn } from '@nuxt/ui'
import { upperFirst } from 'scule'
import { getPaginationRowModel } from '@tanstack/table-core'
import type { Row } from '@tanstack/table-core'
import type { User } from '~/types'
const UAvatar = resolveComponent('UAvatar')
const UButton = resolveComponent('UButton')
const UBadge = resolveComponent('UBadge')
const UDropdownMenu = resolveComponent('UDropdownMenu')
const UCheckbox = resolveComponent('UCheckbox')
const toast = useToast()
const table = useTemplateRef('table')
const columnFilters = ref([{
id: 'email',
value: ''
}])
const columnVisibility = ref()
const rowSelection = ref({ 1: true })
const { data, status } = await useFetch<User[]>('/api/customers', {
lazy: true
})
function getRowItems(row: Row<User>) {
return [
{
type: 'label',
label: 'Actions'
},
{
label: 'Copy customer ID',
icon: 'i-lucide-copy',
onSelect() {
navigator.clipboard.writeText(row.original.id.toString())
toast.add({
title: 'Copied to clipboard',
description: 'Customer ID copied to clipboard'
})
}
},
{
type: 'separator'
},
{
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>[] = [
{
id: 'select',
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, {
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 }, () =>
row.original.status
)
}
},
{
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({
get: (): string => {
return (table.value?.tableApi?.getColumn('email')?.getFilterValue() as string) || ''
},
set: (value: string) => {
table.value?.tableApi?.getColumn('email')?.setFilterValue(value || undefined)
}
})
const pagination = ref({
pageIndex: 0,
pageSize: 10
})
</script>
<template>
<UDashboardPanel id="customers">
<template #header>
<UDashboardNavbar title="Customers">
<template #leading>
<UDashboardSidebarCollapse />
</template>
<template #right>
<CustomersAddModal />
</template>
</UDashboardNavbar>
</template>
<template #body>
<div class="flex flex-wrap items-center justify-between gap-1.5">
<UInput
v-model="email"
class="max-w-sm"
icon="i-lucide-search"
placeholder="Filter emails..."
/>
<div class="flex flex-wrap items-center gap-1.5">
<CustomersDeleteModal :count="table?.tableApi?.getFilteredSelectedRowModel().rows.length">
<UButton
v-if="table?.tableApi?.getFilteredSelectedRowModel().rows.length"
label="Delete"
color="error"
variant="subtle"
icon="i-lucide-trash"
>
<template #trailing>
<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">
<div class="text-sm text-muted">
{{ table?.tableApi?.getFilteredSelectedRowModel().rows.length || 0 }} of
{{ table?.tableApi?.getFilteredRowModel().rows.length || 0 }} row(s) selected.
</div>
<div class="flex items-center gap-1.5">
<UPagination
: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>
</template>

69
app/pages/index.vue Executable file
View File

@ -0,0 +1,69 @@
<script setup lang="ts">
import { sub } from 'date-fns'
import type { DropdownMenuItem } from '@nuxt/ui'
import type { Period, Range } from '~/types'
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>
<template>
<UDashboardPanel id="home">
<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>

50
app/pages/settings.vue Executable file
View File

@ -0,0 +1,50 @@
<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>

158
app/pages/settings/index.vue Executable file
View File

@ -0,0 +1,158 @@
<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>

45
app/pages/settings/members.vue Executable file
View File

@ -0,0 +1,45 @@
<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>

View File

@ -0,0 +1,71 @@
<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>

69
app/pages/settings/security.vue Executable file
View File

@ -0,0 +1,69 @@
<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>

65
app/types/index.d.ts vendored Executable file
View File

@ -0,0 +1,65 @@
import type { AvatarProps } from '@nuxt/ui'
export type UserStatus = 'subscribed' | 'unsubscribed' | 'bounced'
export type SaleStatus = 'paid' | 'failed' | 'refunded'
export interface User {
id: number
name: string
email: string
avatar?: AvatarProps
status: UserStatus
location: string
}
export interface Activity {
id: number
title: string
}
export interface Mail {
id: number
unread?: boolean
from: User
subject: string
body: string
date: string
}
export interface Member {
name: string
username: string
role: 'member' | 'owner'
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 {
id: number
unread?: boolean
sender: User
body: string
date: string
}
export type Period = 'daily' | 'weekly' | 'monthly'
export interface Range {
start: Date
end: Date
}

30
app/utils/UrlUtils.ts Executable file
View File

@ -0,0 +1,30 @@
interface FileItem {
to?: string
label?: string
icon?: string
target?: string
}
const baseUrl = `https://ewr1.vultrobjects.com/lgcc-`
export const isAbsoluteUrl = (url?: string) => {
if (!url) return false
return /^https?:\/\//i.test(url)
}
export const buildUrl = (
file: FileItem,
type?: string
) => {
if (!file?.to) return '#'
if (isAbsoluteUrl(file.to)) {
return file.to
}
if (!type) {
return `${baseUrl}${file.to}`
}
return `${baseUrl}${type}/${file.to}`
}

View File

@ -0,0 +1,3 @@
export default function ( s:String){
return s.charAt(0).toUpperCase() + s.slice(1);
}

57
app/utils/fetchDocument.ts Executable file
View File

@ -0,0 +1,57 @@
import Typesense from 'typesense';
import { ref } from 'vue';
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.";
}
};

3
app/utils/fixLink.ts Executable file
View File

@ -0,0 +1,3 @@
export default function (link: String){
return link?.replace('https://www.carpa','https://actividadeswp.carpa')
}

7
app/utils/index.ts Executable file
View File

@ -0,0 +1,7 @@
export function randomInt(min: number, max: number): number {
return Math.floor(Math.random() * (max - min + 1)) + min
}
export function randomFrom<T>(array: T[]): T {
return array[Math.floor(Math.random() * array.length)]!
}

186
app/utils/itemUtilities.ts Executable file
View File

@ -0,0 +1,186 @@
import dayjs from "dayjs";
export interface ItemObject {
id: string;
date: number;
slug: string;
type: string;
place: string;
city: string;
state: string;
country: string;
thumbnail: string;
}
export interface FilesObject {
youtube?: string;
video?: string;
audio?: string;
booklet?: string;
simple?: string;
}
export function formatFiles( files:FilesObject ){
const { $i18n } = useNuxtApp();
const t = $i18n.t;
let items = []
if (files) {
if (files.youtube) {
items.push({
to: files.youtube,
target: '_blank',
label: 'Youtube',
icon: 'ph:youtube-logo-thin',
labelClass: 'text-xs',
})
}
if (files.video) {
items.push({
to: files.video,
target: '_blank',
label: t('Activities.video'),
icon: 'ph:file-video-thin',
labelClass: 'text-xs'
})
}
if (files.audio) {
items.push({
to: files.audio,
target: '_blank',
label: t('Activities.audio'),
icon: 'ph:file-audio-thin',
labelClass: 'text-xs'
})
}
if (files.booklet) {
items.push({
to: files.booklet,
target: '_blank',
label: t('Activities.book'),
icon: 'ph:notebook-thin',
labelClass: 'text-xs'
})
}
if (files.simple) {
items.push({
to: files.simple,
target: '_blank',
label: t('Activities.simple'),
icon: 'ph:note-thin',
labelClass: 'text-xs'
})
}
}
return items
}
export function formatDate( d:number ){
const { $i18n } = useNuxtApp();
const locale = $i18n.locale;
let date = new Date(d * 1000)
let dateString = date.toLocaleString(locale.value, { year: 'numeric', month:'long', day: 'numeric', weekday:'long', timeZone:'UTC' })
return capitalizeFirstLetter( dateString );
}
export function formatLocation( i: ItemObject){
const { $i18n } = useNuxtApp();
const locale = $i18n.locale;
const regionNames = new Intl.DisplayNames(
[ locale.value], {type: 'region'}
);
var locationParts = [];
locationParts.push(i?.place);
locationParts.push(i?.city);
locationParts.push(i?.state);
if( i.country ){
locationParts.push(regionNames.of(i.country));
}
return locationParts.filter(Boolean).join(', ');
}
export function getDay( d:number ){
const { $i18n } = useNuxtApp();
const locale = $i18n.locale;
let date = new Date(d * 1000);
return date.toLocaleString(locale.value, { weekday: 'long', timeZone: 'utc' });
}
export function getDayDate( d:number ){
const { $i18n } = useNuxtApp();
const locale = $i18n.locale;
let date = new Date(d * 1000);
return date.toLocaleString(locale.value, { day: 'numeric', timeZone: 'utc' });
}
export function getMonth( d:number ){
const { $i18n } = useNuxtApp();
const locale = $i18n.locale;
let date = new Date(d * 1000);
return date.toLocaleString(locale.value, { month: 'long', timeZone: 'utc' });
}
export function setUrl( item:ItemObject, isSearch:boolean ) {
const { $i18n } = useNuxtApp();
const locale = $i18n.locale;
const date = dayjs(item.date*1000);
const month = (date.month()+1).toString()
const slug = (item.slug || item.slug === 'undefined' ? item.slug : item.id);
if( isSearch ){
return false;
} else {
return `/${locale.value}/${item.type}/${date.year()}/${month.padStart(2, '0')}/${slug}`;
}
}
export function getThumbnail( item:ItemObject ) {
console.log("ITEM",item);
let path = item.thumbnail
if (!path) {
return "https://images.carpa.com/tr:w-900,f-auto/youtube_thumbnail_46396.png"
} else {
return `https://images.carpa.com/${item.type}/${path}?tr=w-900`
}
}
export function getAuthor( type:string ){
let author = ''
switch (type){
case "activities":
author = 'Dr. José Benjamín Pérez Matos'
break
case "conferences":
author = 'Dr. William Soto Santiago'
break
case "sermons":
author = 'William Marrion Branham';
break
}
return author
}
export function openItem( item:ItemObject ) {
console.log("Open Item", item.id)
// 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 )
}

9
eslint.config.mjs Executable file
View File

@ -0,0 +1,9 @@
// @ts-check
import withNuxt from './.nuxt/eslint.config.mjs'
export default withNuxt({
rules: {
'vue/no-multiple-template-root': 'off',
'vue/max-attributes-per-line': ['error', { singleline: 3 }]
}
})

76
nuxt.config.ts Executable file
View File

@ -0,0 +1,76 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
modules: ['@nuxt/eslint', '@nuxt/ui', '@vueuse/nuxt', 'nuxt-meilisearch', '@nuxtjs/i18n'],
meilisearch: {
hostUrl: 'https://search.carpa.com', //required
searchApiKey: '04be59c1f633e2bb434082fc1a6fcc6ce97e3630e3fcf9e814e1f03a386c03e1', // required
serverSideUsage: true // default: false
},
devtools: {
enabled: true
},
ui: {
colorMode: false
},
css: ['~/assets/css/main.css'],
routeRules: {
'/api/**': {
cors: true
}
},
compatibilityDate: '2024-07-11',
eslint: {
config: {
stylistic: {
commaDangle: 'never',
braceStyle: '1tbs'
}
}
},
i18n: {
locales: [{
code: "es",
name: "Español",
language: "es-PR",
file: "es.json",
icon: 'i-circle-flags:es',
},
{
code: "en",
name: "English",
language: "en-US",
file: "en.json",
icon: 'i-circle-flags:en',
},
{
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",
},
})

19239
package-lock.json generated Executable file

File diff suppressed because it is too large Load Diff

41
package.json Executable file
View File

@ -0,0 +1,41 @@
{
"name": "search_app",
"private": true,
"type": "module",
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
"preview": "nuxt preview",
"postinstall": "nuxt prepare",
"lint": "eslint .",
"typecheck": "nuxt typecheck"
},
"dependencies": {
"@iconify-json/lucide": "^1.2.104",
"@iconify-json/simple-icons": "^1.2.80",
"@internationalized/date": "^3.12.1",
"@nuxt/ui": "^4.7.0",
"@nuxtjs/i18n": "^9.5.6",
"@tanstack/table-core": "^8.21.3",
"@unovis/ts": "^1.6.5",
"@unovis/vue": "^1.6.5",
"@vueuse/core": "^14.2.1",
"@vueuse/nuxt": "^14.2.1",
"date-fns": "^4.1.0",
"dayjs": "^1.11.20",
"nuxt": "^4.4.2",
"nuxt-meilisearch": "^1.4.17",
"scule": "^1.3.0",
"tailwindcss": "^4.2.4",
"vue": "^3.5.33",
"zod": "^4.3.6"
},
"devDependencies": {
"@iconify-json/ph": "^1.2.2",
"@nuxt/eslint": "^1.15.2",
"eslint": "^10.2.1",
"typescript": "^6.0.3",
"vue-tsc": "^3.2.7"
},
"packageManager": "pnpm@10.33.2"
}

11506
pnpm-lock.yaml Executable file

File diff suppressed because it is too large Load Diff

7
pnpm-workspace.yaml Executable file
View File

@ -0,0 +1,7 @@
ignoredBuiltDependencies:
- '@parcel/watcher'
- '@tailwindcss/oxide'
- esbuild
- maplibre-gl
- unrs-resolver
- vue-demi

BIN
public/favicon.ico Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

13
renovate.json Executable file
View File

@ -0,0 +1,13 @@
{
"extends": [
"github>nuxt/renovate-config-nuxt"
],
"lockFileMaintenance": {
"enabled": true
},
"packageRules": [{
"matchDepTypes": ["resolutions"],
"enabled": false
}],
"postUpdateOptions": ["pnpmDedupe"]
}

187
server/api/customers.ts Executable file
View File

@ -0,0 +1,187 @@
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
})

691
server/api/mails.ts Executable file
View File

@ -0,0 +1,691 @@
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
})

60
server/api/members.ts Executable file
View File

@ -0,0 +1,60 @@
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
})

256
server/api/notifications.ts Executable file
View File

@ -0,0 +1,256 @@
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
})

10
tsconfig.json Executable file
View File

@ -0,0 +1,10 @@
{
// https://nuxt.com/docs/guide/concepts/typescript
"files": [],
"references": [
{ "path": "./.nuxt/tsconfig.app.json" },
{ "path": "./.nuxt/tsconfig.server.json" },
{ "path": "./.nuxt/tsconfig.shared.json" },
{ "path": "./.nuxt/tsconfig.node.json" }
]
}