first commit
This commit is contained in:
8
app/app.config.ts
Normal file
8
app/app.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export default defineAppConfig({
|
||||
ui: {
|
||||
colors: {
|
||||
primary: 'green',
|
||||
neutral: 'zinc'
|
||||
}
|
||||
}
|
||||
})
|
||||
42
app/app.vue
Normal file
42
app/app.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<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',
|
||||
twitterImage: '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
Normal file
18
app/assets/css/main.css
Normal 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;
|
||||
}
|
||||
52
app/components/NotificationsSlideover.vue
Normal file
52
app/components/NotificationsSlideover.vue
Normal 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>
|
||||
147
app/components/UserMenu.vue
Normal file
147
app/components/UserMenu.vue
Normal file
@@ -0,0 +1,147 @@
|
||||
<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: 'username',
|
||||
avatar: {
|
||||
src: 'i-lucide-user',
|
||||
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: '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>
|
||||
59
app/components/customers/AddModal.vue
Normal file
59
app/components/customers/AddModal.vue
Normal 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: undefined,
|
||||
email: undefined
|
||||
})
|
||||
|
||||
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>
|
||||
42
app/components/customers/DeleteModal.vue
Normal file
42
app/components/customers/DeleteModal.vue
Normal 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>
|
||||
121
app/components/home/HomeChart.client.vue
Normal file
121
app/components/home/HomeChart.client.vue
Normal 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>
|
||||
16
app/components/home/HomeChart.server.vue
Normal file
16
app/components/home/HomeChart.server.vue
Normal 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>
|
||||
132
app/components/home/HomeDateRangePicker.vue
Normal file
132
app/components/home/HomeDateRangePicker.vue
Normal 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>
|
||||
49
app/components/home/HomePeriodSelect.vue
Normal file
49
app/components/home/HomePeriodSelect.vue
Normal 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
Normal file
112
app/components/home/HomeSales.vue
Normal 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>
|
||||
98
app/components/home/HomeStats.vue
Normal file
98
app/components/home/HomeStats.vue
Normal 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>
|
||||
80
app/components/inbox/InboxList.vue
Normal file
80
app/components/inbox/InboxList.vue
Normal file
@@ -0,0 +1,80 @@
|
||||
<script setup lang="ts">
|
||||
import { format, isToday } from 'date-fns'
|
||||
import type { Mail } from '~/types'
|
||||
|
||||
const props = defineProps<{
|
||||
mails: Mail[]
|
||||
}>()
|
||||
|
||||
const mailsRefs = ref<Element[]>([])
|
||||
|
||||
const selectedMail = defineModel<Mail | null>()
|
||||
|
||||
watch(selectedMail, () => {
|
||||
if (!selectedMail.value) {
|
||||
return
|
||||
}
|
||||
const ref = mailsRefs.value[selectedMail.value.id]
|
||||
if (ref) {
|
||||
ref.scrollIntoView({ block: 'nearest' })
|
||||
}
|
||||
})
|
||||
|
||||
defineShortcuts({
|
||||
arrowdown: () => {
|
||||
const index = props.mails.findIndex(mail => mail.id === selectedMail.value?.id)
|
||||
|
||||
if (index === -1) {
|
||||
selectedMail.value = props.mails[0]
|
||||
} else if (index < props.mails.length - 1) {
|
||||
selectedMail.value = props.mails[index + 1]
|
||||
}
|
||||
},
|
||||
arrowup: () => {
|
||||
const index = props.mails.findIndex(mail => mail.id === selectedMail.value?.id)
|
||||
|
||||
if (index === -1) {
|
||||
selectedMail.value = props.mails[props.mails.length - 1]
|
||||
} else if (index > 0) {
|
||||
selectedMail.value = props.mails[index - 1]
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="overflow-y-auto divide-y divide-default">
|
||||
<div
|
||||
v-for="(mail, index) in mails"
|
||||
:key="index"
|
||||
:ref="el => { mailsRefs[mail.id] = el as Element }"
|
||||
>
|
||||
<div
|
||||
class="p-4 sm:px-6 text-sm cursor-pointer border-l-2 transition-colors"
|
||||
:class="[
|
||||
mail.unread ? 'text-highlighted' : 'text-toned',
|
||||
selectedMail && selectedMail.id === mail.id
|
||||
? 'border-primary bg-primary/10'
|
||||
: 'border-(--ui-bg) hover:border-primary hover:bg-primary/5'
|
||||
]"
|
||||
@click="selectedMail = mail"
|
||||
>
|
||||
<div class="flex items-center justify-between" :class="[mail.unread && 'font-semibold']">
|
||||
<div class="flex items-center gap-3">
|
||||
{{ mail.from.name }}
|
||||
|
||||
<UChip v-if="mail.unread" />
|
||||
</div>
|
||||
|
||||
<span>{{ isToday(new Date(mail.date)) ? format(new Date(mail.date), 'HH:mm') : format(new Date(mail.date), 'dd MMM') }}</span>
|
||||
</div>
|
||||
<p class="truncate" :class="[mail.unread && 'font-semibold']">
|
||||
{{ mail.subject }}
|
||||
</p>
|
||||
<p class="text-dimmed line-clamp-1">
|
||||
{{ mail.body }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
165
app/components/inbox/InboxMail.vue
Normal file
165
app/components/inbox/InboxMail.vue
Normal file
@@ -0,0 +1,165 @@
|
||||
<script setup lang="ts">
|
||||
import { format } from 'date-fns'
|
||||
import type { Mail } from '~/types'
|
||||
|
||||
defineProps<{
|
||||
mail: Mail
|
||||
}>()
|
||||
|
||||
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)
|
||||
|
||||
function onSubmit() {
|
||||
loading.value = true
|
||||
|
||||
setTimeout(() => {
|
||||
reply.value = ''
|
||||
|
||||
toast.add({
|
||||
title: 'Email sent',
|
||||
description: 'Your email has been sent successfully',
|
||||
icon: 'i-lucide-check-circle',
|
||||
color: 'success'
|
||||
})
|
||||
|
||||
loading.value = false
|
||||
}, 1000)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UDashboardPanel id="inbox-2">
|
||||
<UDashboardNavbar :title="mail.subject" :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="mail.from.avatar"
|
||||
:alt="mail.from.name"
|
||||
size="3xl"
|
||||
/>
|
||||
|
||||
<div class="min-w-0">
|
||||
<p class="font-semibold text-highlighted">
|
||||
{{ mail.from.name }}
|
||||
</p>
|
||||
<p class="text-muted">
|
||||
{{ mail.from.email }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="max-sm:pl-16 text-muted text-sm sm:mt-2">
|
||||
{{ format(new Date(mail.date), 'dd MMM HH:mm') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 p-4 sm:p-6 overflow-y-auto">
|
||||
<p class="whitespace-pre-wrap">
|
||||
{{ mail.body }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="pb-4 px-4 sm:px-6 shrink-0">
|
||||
<UCard variant="subtle" class="mt-auto" :ui="{ header: 'flex items-center gap-1.5 text-dimmed' }">
|
||||
<template #header>
|
||||
<UIcon name="i-lucide-reply" class="size-5" />
|
||||
|
||||
<span class="text-sm truncate">
|
||||
Reply to {{ mail.from.name }} ({{ mail.from.email }})
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<form @submit.prevent="onSubmit">
|
||||
<UTextarea
|
||||
v-model="reply"
|
||||
color="neutral"
|
||||
variant="none"
|
||||
required
|
||||
autoresize
|
||||
placeholder="Write your reply..."
|
||||
:rows="4"
|
||||
:disabled="loading"
|
||||
class="w-full"
|
||||
:ui="{ base: 'p-0 resize-none' }"
|
||||
/>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<UTooltip text="Attach file">
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
icon="i-lucide-paperclip"
|
||||
/>
|
||||
</UTooltip>
|
||||
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
label="Save draft"
|
||||
/>
|
||||
<UButton
|
||||
type="submit"
|
||||
color="neutral"
|
||||
:loading="loading"
|
||||
label="Send"
|
||||
icon="i-lucide-send"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</UCard>
|
||||
</div>
|
||||
</UDashboardPanel>
|
||||
</template>
|
||||
60
app/components/settings/MembersList.vue
Normal file
60
app/components/settings/MembersList.vue
Normal 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
Normal file
25
app/composables/useDashboard.ts
Normal 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
Normal file
24
app/error.vue
Normal 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>
|
||||
135
app/layouts/default.vue
Normal file
135
app/layouts/default.vue
Normal file
@@ -0,0 +1,135 @@
|
||||
<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: 'Inbox',
|
||||
icon: 'i-lucide-inbox',
|
||||
to: '/inbox',
|
||||
badge: '4',
|
||||
onSelect: () => {
|
||||
open.value = false
|
||||
}
|
||||
}, {
|
||||
label: 'Customers',
|
||||
icon: 'i-lucide-users',
|
||||
to: '/customers',
|
||||
onSelect: () => {
|
||||
open.value = false
|
||||
}
|
||||
}, {
|
||||
label: 'Settings',
|
||||
to: '/settings',
|
||||
icon: 'i-lucide-settings',
|
||||
defaultOpen: true,
|
||||
type: 'trigger',
|
||||
children: [{
|
||||
label: 'General',
|
||||
to: '/settings',
|
||||
exact: true,
|
||||
onSelect: () => {
|
||||
open.value = false
|
||||
}
|
||||
}, {
|
||||
label: 'Members',
|
||||
to: '/settings/members',
|
||||
onSelect: () => {
|
||||
open.value = false
|
||||
}
|
||||
}, {
|
||||
label: 'Notifications',
|
||||
to: '/settings/notifications',
|
||||
onSelect: () => {
|
||||
open.value = false
|
||||
}
|
||||
}, {
|
||||
label: 'Security',
|
||||
to: '/settings/security',
|
||||
onSelect: () => {
|
||||
open.value = false
|
||||
}
|
||||
}]
|
||||
}]] 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 }">
|
||||
<TeamsMenu :collapsed="collapsed" />
|
||||
</template> -->
|
||||
<template #header="{ collapsed }">
|
||||
<UDashboardSearchButton :collapsed="collapsed" class="w-full" />
|
||||
</template>
|
||||
|
||||
<template #default="{ collapsed }">
|
||||
<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>
|
||||
330
app/pages/customers.vue
Normal file
330
app/pages/customers.vue
Normal 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>
|
||||
90
app/pages/inbox.vue
Normal file
90
app/pages/inbox.vue
Normal file
@@ -0,0 +1,90 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { breakpointsTailwind } from '@vueuse/core'
|
||||
import type { Mail } from '~/types'
|
||||
|
||||
const tabItems = [{
|
||||
label: 'All',
|
||||
value: 'all'
|
||||
}, {
|
||||
label: 'Unread',
|
||||
value: 'unread'
|
||||
}]
|
||||
const selectedTab = ref('all')
|
||||
|
||||
const { data: mails } = await useFetch<Mail[]>('/api/mails', { default: () => [] })
|
||||
|
||||
// Filter mails based on the selected tab
|
||||
const filteredMails = computed(() => {
|
||||
if (selectedTab.value === 'unread') {
|
||||
return mails.value.filter(mail => !!mail.unread)
|
||||
}
|
||||
|
||||
return mails.value
|
||||
})
|
||||
|
||||
const selectedMail = ref<Mail | null>()
|
||||
|
||||
const isMailPanelOpen = computed({
|
||||
get() {
|
||||
return !!selectedMail.value
|
||||
},
|
||||
set(value: boolean) {
|
||||
if (!value) {
|
||||
selectedMail.value = null
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Reset selected mail if it's not in the filtered mails
|
||||
watch(filteredMails, () => {
|
||||
if (!filteredMails.value.find(mail => mail.id === selectedMail.value?.id)) {
|
||||
selectedMail.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="Inbox">
|
||||
<template #leading>
|
||||
<UDashboardSidebarCollapse />
|
||||
</template>
|
||||
<template #trailing>
|
||||
<UBadge :label="filteredMails.length" variant="subtle" />
|
||||
</template>
|
||||
|
||||
<template #right>
|
||||
<UTabs
|
||||
v-model="selectedTab"
|
||||
:items="tabItems"
|
||||
:content="false"
|
||||
size="xs"
|
||||
/>
|
||||
</template>
|
||||
</UDashboardNavbar>
|
||||
<InboxList v-model="selectedMail" :mails="filteredMails" />
|
||||
</UDashboardPanel>
|
||||
|
||||
<InboxMail v-if="selectedMail" :mail="selectedMail" @close="selectedMail = 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="isMailPanelOpen">
|
||||
<template #content>
|
||||
<InboxMail v-if="selectedMail" :mail="selectedMail" @close="selectedMail = null" />
|
||||
</template>
|
||||
</USlideover>
|
||||
</ClientOnly>
|
||||
</template>
|
||||
69
app/pages/index.vue
Normal file
69
app/pages/index.vue
Normal 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
Normal file
50
app/pages/settings.vue
Normal 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
Normal file
158
app/pages/settings/index.vue
Normal 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
Normal file
45
app/pages/settings/members.vue
Normal 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>
|
||||
71
app/pages/settings/notifications.vue
Normal file
71
app/pages/settings/notifications.vue
Normal 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
Normal file
69
app/pages/settings/security.vue
Normal 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: undefined,
|
||||
new: undefined
|
||||
})
|
||||
|
||||
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-gradient-to-tl from-error/10 from-5% to-default"
|
||||
>
|
||||
<template #footer>
|
||||
<UButton label="Delete account" color="error" />
|
||||
</template>
|
||||
</UPageCard>
|
||||
</template>
|
||||
60
app/types/index.d.ts
vendored
Normal file
60
app/types/index.d.ts
vendored
Normal file
@@ -0,0 +1,60 @@
|
||||
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 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
|
||||
}
|
||||
7
app/utils/index.ts
Normal file
7
app/utils/index.ts
Normal 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)]!
|
||||
}
|
||||
Reference in New Issue
Block a user