This commit is contained in:
30
.gitea/workflows/deploy.yml
Normal file
30
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,30 @@
|
||||
name: Build & Deploy Nuxt (Docker)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: [self-hosted, linux, docker]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Define image tag
|
||||
run: |
|
||||
echo "IMAGE=local/nautilusDesk:${{ gitea.sha }}" >> $GITEA_ENV
|
||||
|
||||
- name: Build image
|
||||
run: |
|
||||
docker build -t "$IMAGE" .
|
||||
|
||||
- name: Deploy via compose
|
||||
run: |
|
||||
cd deploy
|
||||
IMAGE="$IMAGE" docker compose up -d --remove-orphans
|
||||
|
||||
- name: Cleanup dangling images (optional)
|
||||
run: |
|
||||
docker image prune -f || true
|
||||
17
Dockerfile
Normal file
17
Dockerfile
Normal file
@@ -0,0 +1,17 @@
|
||||
FROM node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
|
||||
COPY --from=build /app/.output ./.output
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["node", ".output/server/index.mjs"]
|
||||
24
NautilusDesk/.gitignore
vendored
Normal file
24
NautilusDesk/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Nuxt dev/build outputs
|
||||
.output
|
||||
.data
|
||||
.nuxt
|
||||
.nitro
|
||||
.cache
|
||||
dist
|
||||
|
||||
# Node dependencies
|
||||
node_modules
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
.fleet
|
||||
.idea
|
||||
|
||||
# Local env files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
75
NautilusDesk/README.md
Normal file
75
NautilusDesk/README.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# Nuxt Minimal Starter
|
||||
|
||||
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
|
||||
|
||||
## Setup
|
||||
|
||||
Make sure to install dependencies:
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm install
|
||||
|
||||
# pnpm
|
||||
pnpm install
|
||||
|
||||
# yarn
|
||||
yarn install
|
||||
|
||||
# bun
|
||||
bun install
|
||||
```
|
||||
|
||||
## Development Server
|
||||
|
||||
Start the development server on `http://localhost:3000`:
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm run dev
|
||||
|
||||
# pnpm
|
||||
pnpm dev
|
||||
|
||||
# yarn
|
||||
yarn dev
|
||||
|
||||
# bun
|
||||
bun run dev
|
||||
```
|
||||
|
||||
## Production
|
||||
|
||||
Build the application for production:
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm run build
|
||||
|
||||
# pnpm
|
||||
pnpm build
|
||||
|
||||
# yarn
|
||||
yarn build
|
||||
|
||||
# bun
|
||||
bun run build
|
||||
```
|
||||
|
||||
Locally preview production build:
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm run preview
|
||||
|
||||
# pnpm
|
||||
pnpm preview
|
||||
|
||||
# yarn
|
||||
yarn preview
|
||||
|
||||
# bun
|
||||
bun run preview
|
||||
```
|
||||
|
||||
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.
|
||||
10
NautilusDesk/app/app.config.ts
Normal file
10
NautilusDesk/app/app.config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export default defineAppConfig({
|
||||
theme: {
|
||||
primaryColor: '#fff',
|
||||
},
|
||||
ui: {
|
||||
colors: {
|
||||
neutral: 'zinc'
|
||||
}
|
||||
}
|
||||
})
|
||||
13
NautilusDesk/app/app.vue
Normal file
13
NautilusDesk/app/app.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<UApp>
|
||||
<UMain>
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</UMain>
|
||||
</UApp>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
</script>
|
||||
2
NautilusDesk/app/assets/css/main.css
Normal file
2
NautilusDesk/app/assets/css/main.css
Normal file
@@ -0,0 +1,2 @@
|
||||
@import "tailwindcss";
|
||||
@import "@nuxt/ui";
|
||||
28
NautilusDesk/app/components/Header.vue
Normal file
28
NautilusDesk/app/components/Header.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import type { NavigationMenuItem } from '@nuxt/ui'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const items = computed<NavigationMenuItem[]>(() => [
|
||||
{
|
||||
label: 'Docs',
|
||||
to: '/docs/getting-started',
|
||||
active: route.path.startsWith('/docs/getting-started')
|
||||
},
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UHeader>
|
||||
<template #title>
|
||||
<Logo class="h-6 w-auto" />
|
||||
<h3>NautilusDesk</h3>
|
||||
</template>
|
||||
|
||||
<UNavigationMenu :items="items" />
|
||||
|
||||
<template #right>
|
||||
<UColorModeButton />
|
||||
</template>
|
||||
</UHeader>
|
||||
</template>
|
||||
5
NautilusDesk/app/components/Logo.vue
Normal file
5
NautilusDesk/app/components/Logo.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<NuxtLink to="/" class="flex items-center gap-2">
|
||||
<img src="/icon.png" alt="Logo" class="h-full w-auto" />
|
||||
</NuxtLink>
|
||||
</template>
|
||||
45
NautilusDesk/app/components/Menus/TeamsMenu.vue
Normal file
45
NautilusDesk/app/components/Menus/TeamsMenu.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuItem } from '@nuxt/ui'
|
||||
|
||||
defineProps<{
|
||||
collapsed?: boolean
|
||||
}>()
|
||||
|
||||
const teams = ref([{
|
||||
label: 'VTM-Dive',
|
||||
avatar: {
|
||||
src: '',
|
||||
alt: 'VTM-Dive'
|
||||
}
|
||||
},])
|
||||
const selectedTeam = ref(teams.value[0])
|
||||
|
||||
const items = computed<DropdownMenuItem[][]>(() => {
|
||||
return [teams.value.map(team => ({
|
||||
...team,
|
||||
onSelect() {
|
||||
selectedTeam.value = team
|
||||
}
|
||||
})), [{
|
||||
label: 'Create team',
|
||||
icon: 'i-lucide-circle-plus'
|
||||
}, {
|
||||
label: 'Manage teams',
|
||||
icon: 'i-lucide-cog'
|
||||
}]]
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UDropdownMenu :items="items" :content="{ align: 'center', collisionPadding: 12 }"
|
||||
:ui="{ content: collapsed ? 'w-40' : 'w-(--reka-dropdown-menu-trigger-width)' }">
|
||||
<UButton v-bind="{
|
||||
...selectedTeam,
|
||||
label: collapsed ? undefined : selectedTeam?.label,
|
||||
trailingIcon: collapsed ? undefined : 'i-lucide-chevrons-up-down'
|
||||
}" color="neutral" variant="ghost" block :square="collapsed" class="data-[state=open]:bg-elevated"
|
||||
:class="[!collapsed && 'py-2']" :ui="{
|
||||
trailingIcon: 'text-dimmed'
|
||||
}" />
|
||||
</UDropdownMenu>
|
||||
</template>
|
||||
87
NautilusDesk/app/components/Menus/UserMenu.vue
Normal file
87
NautilusDesk/app/components/Menus/UserMenu.vue
Normal file
@@ -0,0 +1,87 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuItem } from '@nuxt/ui'
|
||||
|
||||
defineProps<{
|
||||
collapsed?: boolean
|
||||
}>()
|
||||
|
||||
const colorMode = useColorMode()
|
||||
const appConfig = useAppConfig()
|
||||
|
||||
|
||||
const user = ref({
|
||||
name: 'Tom Trappmann',
|
||||
avatar: {
|
||||
src: '',
|
||||
alt: 'Tom Trappmann'
|
||||
}
|
||||
})
|
||||
|
||||
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: '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>
|
||||
34
NautilusDesk/app/components/NotificationsSlideover.vue
Normal file
34
NautilusDesk/app/components/NotificationsSlideover.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<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>
|
||||
44
NautilusDesk/app/components/customers/AddModal.vue
Normal file
44
NautilusDesk/app/components/customers/AddModal.vue
Normal file
@@ -0,0 +1,44 @@
|
||||
<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>
|
||||
28
NautilusDesk/app/components/customers/DeleteModal.vue
Normal file
28
NautilusDesk/app/components/customers/DeleteModal.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<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>
|
||||
3
NautilusDesk/app/composables/useAppHead.ts
Normal file
3
NautilusDesk/app/composables/useAppHead.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function useAppHead() {
|
||||
useHead({ titleTemplate: 'NautilusDesk · Dive School' })
|
||||
}
|
||||
25
NautilusDesk/app/composables/useDashboard.ts
Normal file
25
NautilusDesk/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)
|
||||
13
NautilusDesk/app/error.vue
Normal file
13
NautilusDesk/app/error.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import type { NuxtError } from '#app'
|
||||
|
||||
const props = defineProps<{ error: NuxtError }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UError :error="{
|
||||
statusCode: 404,
|
||||
statusMessage: 'Page not found',
|
||||
message: 'The page you are looking for does not exist.'
|
||||
}" />
|
||||
</template>
|
||||
139
NautilusDesk/app/layouts/default.vue
Normal file
139
NautilusDesk/app/layouts/default.vue
Normal file
@@ -0,0 +1,139 @@
|
||||
<script setup lang="ts">
|
||||
import type { NavigationMenuItem } from '@nuxt/ui'
|
||||
import TeamsMenu from '~/components/Menus/TeamsMenu.vue'
|
||||
import UserMenu from '~/components/Menus/UserMenu.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const toast = useToast()
|
||||
|
||||
const open = ref(false)
|
||||
|
||||
const links = [[{
|
||||
label: 'Home',
|
||||
icon: 'i-lucide-house',
|
||||
to: '/',
|
||||
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
|
||||
}
|
||||
}]
|
||||
}], [{
|
||||
label: 'Feedback',
|
||||
icon: 'i-lucide-message-circle',
|
||||
to: 'https://github.com/nuxt-ui-templates/dashboard',
|
||||
target: '_blank'
|
||||
}, {
|
||||
label: 'Help & Support',
|
||||
icon: 'i-lucide-info',
|
||||
to: 'https://github.com/nuxt-ui-templates/dashboard',
|
||||
target: '_blank'
|
||||
}]] satisfies NavigationMenuItem[][]
|
||||
|
||||
const groups = computed(() => [{
|
||||
id: 'links',
|
||||
label: 'Go to',
|
||||
items: links.flat()
|
||||
}, {
|
||||
id: 'code',
|
||||
label: 'Code',
|
||||
items: [{
|
||||
id: 'source',
|
||||
label: 'View page source',
|
||||
icon: 'i-simple-icons-github',
|
||||
to: `https://github.com/nuxt-ui-templates/dashboard/blob/main/app/pages${route.path === '/' ? '/index' : route.path}.vue`,
|
||||
target: '_blank'
|
||||
}]
|
||||
}])
|
||||
|
||||
onMounted(async () => {
|
||||
const cookie = useCookie('cookie-consent')
|
||||
if (cookie.value === 'accepted') {
|
||||
return
|
||||
}
|
||||
|
||||
toast.add({
|
||||
title: 'We use first-party cookies to enhance your experience on our website.',
|
||||
duration: 0,
|
||||
close: false,
|
||||
actions: [{
|
||||
label: 'Accept',
|
||||
color: 'neutral',
|
||||
variant: 'outline',
|
||||
onClick: () => {
|
||||
cookie.value = 'accepted'
|
||||
}
|
||||
}, {
|
||||
label: 'Opt out',
|
||||
color: 'neutral',
|
||||
variant: 'ghost'
|
||||
}]
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UDashboardGroup unit="rem">
|
||||
<UDashboardSidebar id="default" v-model:open="open" collapsible resizable class="bg-elevated/25"
|
||||
:ui="{ footer: 'lg:border-t lg:border-default' }">
|
||||
<template #header="{ collapsed }">
|
||||
<TeamsMenu :collapsed="collapsed" />
|
||||
</template>
|
||||
|
||||
<template #default="{ collapsed }">
|
||||
<UDashboardSearchButton :collapsed="collapsed" class="bg-transparent ring-default" />
|
||||
|
||||
<UNavigationMenu :collapsed="collapsed" :items="links[0]" orientation="vertical" tooltip popover />
|
||||
|
||||
<UNavigationMenu :collapsed="collapsed" :items="links[1]" orientation="vertical" tooltip
|
||||
class="mt-auto" />
|
||||
</template>
|
||||
|
||||
<template #footer="{ collapsed }">
|
||||
<UserMenu :collapsed="collapsed" />
|
||||
</template>
|
||||
</UDashboardSidebar>
|
||||
|
||||
<UDashboardSearch :groups="groups" />
|
||||
|
||||
<slot />
|
||||
|
||||
<NotificationsSlideover />
|
||||
</UDashboardGroup>
|
||||
</template>
|
||||
3
NautilusDesk/app/middleware/analytics.global.ts
Normal file
3
NautilusDesk/app/middleware/analytics.global.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default defineNuxtRouteMiddleware(() => {
|
||||
// runs on every navigation
|
||||
})
|
||||
5
NautilusDesk/app/middleware/auth.ts
Normal file
5
NautilusDesk/app/middleware/auth.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export default defineNuxtRouteMiddleware(() => {
|
||||
|
||||
|
||||
|
||||
})
|
||||
3
NautilusDesk/app/middleware/setups.global.ts
Normal file
3
NautilusDesk/app/middleware/setups.global.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default defineNuxtRouteMiddleware(() => {
|
||||
// runs on every navigation
|
||||
})
|
||||
324
NautilusDesk/app/pages/Customers.vue
Normal file
324
NautilusDesk/app/pages/Customers.vue
Normal file
@@ -0,0 +1,324 @@
|
||||
<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: 'View customer details',
|
||||
icon: 'i-lucide-list'
|
||||
},
|
||||
{
|
||||
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: ({ column }) => {
|
||||
const isSorted = column.getIsSorted()
|
||||
return h(UButton, {
|
||||
color: 'neutral',
|
||||
variant: 'ghost',
|
||||
label: 'ID',
|
||||
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: 'name',
|
||||
header: ({ column }) => {
|
||||
const isSorted = column.getIsSorted()
|
||||
return h(UButton, {
|
||||
color: 'neutral',
|
||||
variant: 'ghost',
|
||||
label: 'Name',
|
||||
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')
|
||||
})
|
||||
},
|
||||
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: ({ column }) => {
|
||||
const isSorted = column.getIsSorted()
|
||||
return h(UButton, {
|
||||
color: 'neutral',
|
||||
variant: 'ghost',
|
||||
label: 'Location',
|
||||
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')
|
||||
})
|
||||
},
|
||||
cell: ({ row }) => row.original.location
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: ({ column }) => {
|
||||
const isSorted = column.getIsSorted()
|
||||
return h(UButton, {
|
||||
color: 'neutral',
|
||||
variant: 'ghost',
|
||||
label: 'Status',
|
||||
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')
|
||||
})
|
||||
},
|
||||
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>
|
||||
<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>
|
||||
47
NautilusDesk/app/pages/index.vue
Normal file
47
NautilusDesk/app/pages/index.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuItem } from '@nuxt/ui'
|
||||
|
||||
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[][]
|
||||
|
||||
</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>
|
||||
|
||||
</UDashboardToolbar>
|
||||
</template>
|
||||
|
||||
</UDashboardPanel>
|
||||
</template>
|
||||
60
NautilusDesk/app/types/index.d.ts
vendored
Normal file
60
NautilusDesk/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
|
||||
}
|
||||
29
NautilusDesk/nuxt.config.ts
Normal file
29
NautilusDesk/nuxt.config.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: '2025-07-15',
|
||||
devtools: {
|
||||
enabled: true,
|
||||
|
||||
timeline: {
|
||||
enabled: true
|
||||
}
|
||||
},
|
||||
|
||||
imports: {
|
||||
presets: [
|
||||
{
|
||||
from: 'vue-i18n',
|
||||
imports: ['useI18n'],
|
||||
},
|
||||
],
|
||||
},
|
||||
modules: ['@nuxtjs/i18n', '@pinia/nuxt', '@nuxt/ui'],
|
||||
css: ['~/assets/css/main.css'],
|
||||
i18n: {
|
||||
locales: [
|
||||
{ code: 'en', language: 'en-US' },
|
||||
{ code: 'de', language: 'de-DE' }
|
||||
],
|
||||
defaultLocale: 'en',
|
||||
}
|
||||
})
|
||||
16573
NautilusDesk/package-lock.json
generated
Normal file
16573
NautilusDesk/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
NautilusDesk/package.json
Normal file
24
NautilusDesk/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "NautilusDesk",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nuxt build",
|
||||
"dev": "nuxt dev",
|
||||
"generate": "nuxt generate",
|
||||
"preview": "nuxt preview",
|
||||
"postinstall": "nuxt prepare"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nuxt/ui": "^4.3.0",
|
||||
"@nuxtjs/i18n": "^10.2.1",
|
||||
"@pinia/nuxt": "^0.11.3",
|
||||
"date-fns": "^4.1.0",
|
||||
"nuxt": "^4.2.2",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.25",
|
||||
"vue-i18n": "^11.2.7",
|
||||
"vue-router": "^4.6.4",
|
||||
"zod": "^4.2.1"
|
||||
}
|
||||
}
|
||||
BIN
NautilusDesk/public/favicon.ico
Normal file
BIN
NautilusDesk/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
BIN
NautilusDesk/public/icon.png
Normal file
BIN
NautilusDesk/public/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 MiB |
2
NautilusDesk/public/robots.txt
Normal file
2
NautilusDesk/public/robots.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
User-Agent: *
|
||||
Disallow:
|
||||
18
NautilusDesk/tsconfig.json
Normal file
18
NautilusDesk/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
// https://nuxt.com/docs/guide/concepts/typescript
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./.nuxt/tsconfig.app.json"
|
||||
},
|
||||
{
|
||||
"path": "./.nuxt/tsconfig.server.json"
|
||||
},
|
||||
{
|
||||
"path": "./.nuxt/tsconfig.shared.json"
|
||||
},
|
||||
{
|
||||
"path": "./.nuxt/tsconfig.node.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
11
deploy/docker-compose.yml
Normal file
11
deploy/docker-compose.yml
Normal file
@@ -0,0 +1,11 @@
|
||||
services:
|
||||
nuxt-app:
|
||||
image: ${IMAGE}
|
||||
container_name: nautilusDesk
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- proxy
|
||||
|
||||
networks:
|
||||
proxy:
|
||||
external: true
|
||||
Reference in New Issue
Block a user