first commit
This commit is contained in:
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>
|
||||
Reference in New Issue
Block a user