Initial commit

This commit is contained in:
2026-04-17 23:26:01 +00:00
commit 2ea4ca5d52
409 changed files with 63459 additions and 0 deletions

61
app/CLAUDE.md Normal file
View File

@@ -0,0 +1,61 @@
# CLAUDE.md — app/
## Nuxt Frontend (SPA Mode)
This is a **client-side only** Nuxt 4 app (`ssr: false`). No server routes, no `server/` directory. All data comes from PocketBase via its SDK.
## PocketBase Client Access
```ts
const { pb, authStore } = usePocketBase()
```
- `pb` — PocketBase SDK instance (configured in `plugins/pocketbase.ts`)
- `authStore` — reactive Vue ref wrapping PocketBase's auth state (`.isValid`, `.record`, `.token`)
**Never instantiate PocketBase directly.** Always use the `usePocketBase()` composable.
## Stores (Pinia)
All backend interaction goes through Pinia stores in `stores/`:
| Store | Collection | Key patterns |
|-------|-----------|--------------|
| `user.ts` | `users` | Auth flows (OTP, OAuth), profile CRUD, `isAuthenticated` getter |
| `counter.ts` | `counters` | Realtime subscription, increment/reset |
| `notifications.ts` | `notifications` + `fcm_tokens` | Realtime subscription, FCM token registration |
| `avatar.ts` | `users` (file field) | Avatar URL getter, file upload |
**Realtime pattern:** Stores call `pb.collection('x').subscribe('*', callback)` in a `subscribeToChanges()` action. Components call this on mount and `unsubscribe()` on unmount.
## Routing & Middleware
Pages use Nuxt file-based routing. Two middleware:
- `middleware/auth.ts` — route-level guard, add via `definePageMeta({ middleware: 'auth' })`
- `middleware/00.fetchUser.global.ts` — runs on every navigation, refreshes auth token
Two layouts: `default` (with AppHeader) and `empty` (login/confirm pages).
## Components
Organized by feature domain: `App/` (header, notifications), `Counter/` (widget), `Profile/` (avatar, upload, lang select). Components use Nuxt UI v3 primitives (`UButton`, `UCard`, `UForm`, `UModal`, etc.).
## Styling
Tailwind CSS v4 + Nuxt UI. Theme colors set in `app.config.ts` (primary: teal, neutral: slate). Custom font: Public Sans (defined in `assets/css/main.css`).
## i18n
Strategy: `no_prefix` — URLs are not prefixed with locale. Two locales: `en`, `de`. Translation files in `i18n/locales/`. Use `$t('key')` in templates or `const { t } = useI18n()` in setup.
`LanguageCode` type defined in `types/i18n.types.ts`.
## Types
- `types/pocketbase.types.ts`**auto-generated**, do not edit. Contains typed collection names (`Collections` enum) and record interfaces. Regenerate with `pnpm pocketbase:types`.
- `types/i18n.types.ts` — manual type for supported language codes.
## Mobile (Capacitor)
`app.vue` handles Capacitor-specific setup: deep link listener (`App.addListener('appUrlOpen')`), push notification registration, and safe-area CSS. Check `Capacitor.isNativePlatform()` before using native APIs.

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

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

150
app/app.vue Normal file
View File

@@ -0,0 +1,150 @@
<template>
<UApp
:locale="locales[locale]"
>
<div class="inside-safe-area">
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</div>
</UApp>
</template>
<script setup lang="ts">
import * as locales from '@nuxt/ui/locale'
import type { URLOpenListenerEvent } from '@capacitor/app'
import { Capacitor } from '@capacitor/core'
import { App } from '@capacitor/app'
import type {
Token,
ActionPerformed,
PushNotificationSchema } from '@capacitor/push-notifications'
import {
PushNotifications
} from '@capacitor/push-notifications'
import type { UsersLanguageOptions } from './types/pocketbase.types'
const { locale, t, setLocale } = useI18n()
const lang = computed(() => locales[locale.value].code)
const dir = computed(() => locales[locale.value].dir)
useHead({
meta: [
{ name: 'viewport', content: 'width=device-width, initial-scale=1' }
],
link: [
{ rel: 'icon', href: '/favicon.ico' }
],
htmlAttrs: {
lang,
dir
}
})
const title = t('hero.title')
const description = t('hero.description')
useSeoMeta({
title,
description,
ogTitle: title,
ogDescription: description
})
const { isAuthenticated } = storeToRefs(useUser())
const { addFcmToken } = useNotifications()
const toast = useToast()
const registerPushNotifications = async () => {
if (Capacitor.isNativePlatform() && isAuthenticated.value) {
// Request permission to use push notifications
// iOS will prompt user and return if they granted permission or not
// Android will just grant without prompting
PushNotifications.requestPermissions().then((result) => {
if (result.receive === 'granted') {
// Register with Apple / Google to receive push via APNS/FCM
PushNotifications.register()
} else {
// Show some error
}
})
// On success, we should be able to receive notifications
PushNotifications.addListener('registration',
(token: Token) => {
// Add the FCM token for the current user
addFcmToken(token.value)
}
)
// Some issue with our setup and push will not work
PushNotifications.addListener('registrationError',
(error) => {
console.warn('Error on push notification registration:', JSON.stringify(error))
}
)
// Show the notification payload as toast message if the app is open on our device
PushNotifications.addListener('pushNotificationReceived',
(notification: PushNotificationSchema) => {
toast.add({
title: notification.title,
description: notification.subtitle ?? notification.body,
color: 'primary'
})
}
)
// Method called when tapping on a notification
PushNotifications.addListener('pushNotificationActionPerformed',
(notification: ActionPerformed) => {
console.log('Push action performed:', JSON.stringify(notification))
}
)
}
}
// handle locale based on user prefs
const { updateUser, user } = useUser()
watch(isAuthenticated, async () => {
if (isAuthenticated.value) {
// Fetch the user profile after successful sign-in
const prefLanguage = user?.language
if (prefLanguage) {
// Set the locale based on the user's profile locale
setLocale(prefLanguage)
} else {
// If the locale was not yet saved to the profile, update it
updateUser({ language: locale.value as UsersLanguageOptions })
}
// Register push notifications after successful sign-in
registerPushNotifications()
}
}, { immediate: true })
// Activate Capacitor features if we are on native platform (Android/iOS)
if (Capacitor.isNativePlatform()) {
const router = useRouter()
// Register and handle deep links
App.addListener('appUrlOpen', function (event: URLOpenListenerEvent) {
const slug = event.url.split('.app').pop()
if (slug) {
router.push({
path: slug
})
}
})
}
</script>
<style>
/* Safe area insets needed for mobile app to avoid notches and safe areas */
.inside-safe-area {
margin-top: env(safe-area-inset-top, unset);
padding-right: env(safe-area-inset-right, unset);
padding-bottom: env(safe-area-inset-bottom, unset);
padding-left: env(safe-area-inset-left, unset);
}
</style>

6
app/assets/css/main.css Normal file
View File

@@ -0,0 +1,6 @@
@import "tailwindcss";
@import "@nuxt/ui";
@theme static {
--font-sans: 'Public Sans', sans-serif;
}

View File

@@ -0,0 +1,51 @@
<template>
<UHeader :toggle="false">
<template #left>
<ULink to="/">
<NuxtImg
src="logo.png"
alt="Logo"
class="size-12"
/>
</ULink>
<ProfileLangSelect />
</template>
<template #right>
<UColorModeButton />
<AppNotifications v-if="isAuthenticated" />
<UDropdownMenu :items="items">
<ProfileAvatar
size="xl"
/>
</UDropdownMenu>
</template>
</UHeader>
</template>
<script setup lang="ts">
const { isAuthenticated } = storeToRefs(useUser())
const { signOut } = useUser()
const items = computed(() => {
if (isAuthenticated.value) {
return [[{
label: 'Logout',
icon: 'i-lucide-log-out',
onSelect: () => {
signOut()
navigateTo('/')
} }]
]
} else {
return [[{
label: 'Sign In',
icon: 'i-lucide-log-in',
onSelect: () => {
navigateTo('/login')
} }]
]
}
})
</script>

View File

@@ -0,0 +1,85 @@
<template>
<UTooltip
:text="$t('notifications')"
:shortcuts="['N']"
>
<UButton
color="neutral"
variant="ghost"
square
@click="drawerOpen = true"
>
<UChip
color="error"
:show="hasUnreadNotifications"
inset
>
<UIcon
name="i-lucide-bell"
class="size-5 shrink-0"
/>
</UChip>
</UButton>
</UTooltip>
<USlideover
v-model:open="drawerOpen"
:title="$t('notifications')"
>
<template #body>
<div
v-for="notification in notifications"
:key="notification.id"
v-on-visible="() => markAsRead(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.isRead"
inset
>
<UAvatar
alt="Nuxt"
size="md"
/>
</UChip>
<div class="text-sm flex-1">
<p class="flex items-center justify-between">
<span class="text-highlighted font-medium">{{ notification.title }}</span>
<time
:datetime="notification.created"
class="text-muted text-xs"
v-text="$d(new Date(notification.created), { year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric' })"
/>
</p>
<p class="text-dimmed">
{{ notification.body }}
</p>
</div>
</div>
</template>
</USlideover>
</template>
<script setup lang="ts">
const { fetchNotifications, subscribeToChanges, unsubscribe, markAsRead } = useNotifications()
const { notifications } = storeToRefs(useNotifications())
const drawerOpen = ref(false)
const hasUnreadNotifications = computed(() => notifications.value.some(n => !n.isRead))
// Fetch the notifications once on page load
fetchNotifications()
onMounted(() => {
// Subscribe to realtime changes of notifications
subscribeToChanges()
})
// Unsubscribe for cleanup
onUnmounted(() => {
unsubscribe()
})
</script>

View File

@@ -0,0 +1,51 @@
<template>
<UCard>
<div class="flex flex-col items-center justify-center gap-4 p-4">
<div class="flex items-center gap-4">
<UButton
icon="i-lucide-minus"
size="lg"
variant="outline"
@click="increment(-1)"
/>
<div class="text-4xl font-bold w-16 text-center">
{{ count }}
</div>
<UButton
icon="i-lucide-plus"
size="lg"
variant="outline"
@click="increment(1)"
/>
</div>
<UButton
v-if="recordId"
icon="i-lucide-rotate-ccw"
size="md"
variant="ghost"
@click="reset()"
>
{{ $t("counter.reset") }}
</UButton>
</div>
</UCard>
</template>
<script setup lang="ts">
const { fetchCurrentCount, subscribeToChanges, unsubscribe, reset, increment }
= useCounter()
const { count, recordId } = storeToRefs(useCounter())
// Fetch the current count once on page load
fetchCurrentCount()
onMounted(() => {
// Subscribe to realtime changes of the count
subscribeToChanges()
})
// Unsubscribe for cleanup
onUnmounted(() => {
unsubscribe()
})
</script>

View File

@@ -0,0 +1,21 @@
<template>
<UAvatar
v-bind="forwardedProps"
:src="src ?? undefined"
:alt="name ?? undefined"
:icon="icon"
/>
</template>
<script setup lang="ts">
import type { AvatarProps } from '@nuxt/ui'
const forwardedProps = defineProps<Omit<AvatarProps, 'src' | 'alt' | 'icon'>>()
const { name, src } = storeToRefs(useAvatar())
// show initials if name is present, otherwise show icon
const icon = computed(() => {
return !name.value ? 'i-lucide-user' : undefined
})
</script>

View File

@@ -0,0 +1,57 @@
<template>
<div class="flex flex-wrap items-center gap-3">
<ProfileAvatar
size="lg"
/>
<UButton
:label="$t('profile.choose')"
color="neutral"
:loading="uploading"
@click="onFileClick"
/>
<input
ref="fileRef"
type="file"
class="hidden"
accept=".jpg, .jpeg, .png, .gif"
@change="onFileChange"
>
</div>
</template>
<script setup lang="ts">
const { t } = useI18n()
const toast = useToast()
const fileRef = ref<HTMLInputElement>()
const uploading = ref(false)
async function onFileChange(e: Event) {
const input = e.target as HTMLInputElement
const files = input.files
try {
uploading.value = true
if (!files || files.length === 0) {
throw new Error('You must select an image to upload.')
}
const file = files[0]
await useAvatar().uploadAvatar(file!)
toast.add({
title: t('profile.success'),
description: t('profile.avatarUploaded'),
icon: 'i-lucide-check',
color: 'success'
})
} catch (error) {
toast.add({ color: 'error', description: (error as Error).message })
} finally {
uploading.value = false
}
}
function onFileClick() {
fileRef.value?.click()
}
</script>

View File

@@ -0,0 +1,25 @@
<template>
<ULocaleSelect
:model-value="locale"
:locales="availableLocales"
@update:model-value="setLanguage($event as LanguageCode)"
/>
</template>
<script setup lang="ts">
import * as allLocales from '@nuxt/ui/locale'
import type { LanguageCode } from '~/types/i18n.types'
import type { UsersLanguageOptions } from '~/types/pocketbase.types'
const { locale, locales, setLocale } = useI18n()
const availableLocales = computed(() =>
locales.value.map(l => allLocales[l.code])
)
const { updateUser } = useUser()
const setLanguage = (language: LanguageCode) => {
setLocale(language)
updateUser({ language: language as UsersLanguageOptions })
}
</script>

View File

@@ -0,0 +1,18 @@
import type { ReactiveAuthStore } from '~/plugins/pocketbase'
/**
* Returns the PocketBase client and a reactive ref of the auth store.
*/
export const usePocketBase = () => {
const { $pb } = useNuxtApp()
if (!$pb) throw new Error('Pocketbase plugin not accessible')
return {
pb: $pb,
/**
* Reactive wrapper around the PocketBase auth store.
* Access state via `authStore.value.isValid`, `.record`, `.token`, `.isSuperuser` etc.
* Use only for reactive access; for imperative methods use `$pb.authStore` directly.
*/
authStore: ($pb.authStore as ReactiveAuthStore).ref
}
}

53
app/i18n/locales/de.json Normal file
View File

@@ -0,0 +1,53 @@
{
"hero": {
"title": "Nuxt PocketBase Starter Template",
"description": "Eine Starter-Vorlage mit vorgegebenen Frameworks, die von Nuxt und PocketBase sowie weiteren Tools unterstützt wird, um eine schnelle Veröffentlichung von Apps zu ermöglichen.",
"signUp": "Registrieren",
"profile": "Profil"
},
"login": {
"title": "Anmelden",
"email": "E-Mail",
"emailPlaceholder": "Geben Sie Ihre E-Mail ein",
"otp": "Code",
"continue": "Weiter",
"agree": "Mit der Anmeldung stimmen Sie unseren",
"terms": "Nutzungsbedingungen",
"google": "Mit Google anmelden",
"invalidEmail": "Ungültige E-Mail",
"invalidCharacterCount": "Muss mindestens 8 Zeichen lang sein",
"emailSent": "E-Mail gesendet",
"emailSentDescription": "Überprüfen Sie Ihr Postfach und geben Sie den Code aus der E-Email im angezeigten Feld ein."
},
"confirm": {
"signingIn": "Anmelden",
"redirectedSoon": "Sie werden in Kürze weitergeleitet..."
},
"profile": {
"title": "Profil",
"description": "Diese Informationen werden öffentlich angezeigt.",
"save": "Änderungen speichern",
"name": "Name",
"nameDescription": "Wird auf Quittungen, Rechnungen und in anderer Kommunikation angezeigt.",
"email": "E-Mail",
"emailDescription": "Wird für die Anmeldung, E-Mail-Quittungen und Produkt-Updates verwendet.",
"avatar": "Profilbild",
"avatarDescription": "JPG, GIF oder PNG. Maximal 1MB.",
"choose": "Auswählen",
"tooShort": "Zu kurz",
"invalidEmail": "Ungültige E-Mail",
"success": "Erfolg",
"settingsUpdated": "Ihre Einstellungen wurden aktualisiert.",
"avatarUploaded": "Ihr Profilbild wurde hochgeladen",
"delete": "Konto löschen",
"deleteDescription": "Hier können Sie Ihre Daten löschen.",
"deleteWarning": "Wenn Sie Ihr Konto löschen, werden alle Ihre Daten gelöscht. Dies kann nicht rückgängig gemacht werden."
},
"cancel": "Abbrechen",
"notifications": "Benachrichtigungen",
"counter": {
"reset": "Zurücksetzen",
"notAuthenticated": "Nicht angemeldet",
"signInToUse": "Bitte melden Sie sich an um das Beispiel Zähler-Widget auszuprobieren"
}
}

53
app/i18n/locales/en.json Normal file
View File

@@ -0,0 +1,53 @@
{
"hero": {
"title": "Nuxt PocketBase Starter Template",
"description": "A highly opinionated starter template powered by Nuxt and PocketBase among others to enable quick release of Apps.",
"signUp": "Sign Up",
"profile": "Profile"
},
"login": {
"title": "Sign In",
"email": "Email",
"emailPlaceholder": "Enter your email",
"otp": "Code",
"continue": "Continue",
"agree": "By signing in, you agree to our",
"terms": "Terms of Service",
"google": "Login with Google",
"invalidEmail": "Invalid email",
"invalidCharacterCount": "Must be at least 8 characters",
"emailSent": "Email sent",
"emailSentDescription": "Check your email and enter the code from the email in the field."
},
"confirm": {
"signingIn": "Signing you in",
"redirectedSoon": "You will be redirected soon..."
},
"profile": {
"title": "Profile",
"description": "These informations will be displayed publicly.",
"save": "Save changes",
"name": "Name",
"nameDescription": "Will appear on receipts, invoices, and other communication.",
"email": "Email",
"emailDescription": "Used to sign in, for email receipts and product updates.",
"avatar": "Avatar",
"avatarDescription": "JPG, GIF or PNG. 1MB Max.",
"choose": "Choose",
"tooShort": "Too short",
"invalidEmail": "Invalid email",
"success": "Success",
"settingsUpdated": "Your settings have been updated.",
"avatarUploaded": "Your avatar was uploaded.",
"delete": "Delete account",
"deleteDescription": "Here you can delete your account.",
"deleteWarning": "If you delete your account, all your data will be deleted. This cannot be undone."
},
"cancel": "Cancel",
"notifications": "Notifications",
"counter": {
"reset": "Reset",
"notAuthenticated": "Not authenticated",
"signInToUse": "Please sign in to try the example counter widget"
}
}

11
app/layouts/default.vue Normal file
View File

@@ -0,0 +1,11 @@
<template>
<div>
<AppHeader />
<UMain>
<UContainer class="py-8">
<slot />
</UContainer>
</UMain>
</div>
</template>

7
app/layouts/empty.vue Normal file
View File

@@ -0,0 +1,7 @@
<template>
<UMain>
<UContainer class="py-8">
<slot />
</UContainer>
</UMain>
</template>

View File

@@ -0,0 +1,10 @@
/**
* Middleware to refresh the authenticated user if possible
* Executed on all route changes
*/
export default defineNuxtRouteMiddleware(async () => {
if (!useUser().isAuthenticated || useUser().user === null) {
// Attempt to refresh the auth store
await useUser().authRefresh()
}
})

10
app/middleware/auth.ts Normal file
View File

@@ -0,0 +1,10 @@
/**
* Middleware to protect routes that require authentication.
* Redirects to the login page if the user is not authenticated.
*/
export default defineNuxtRouteMiddleware(async () => {
if (!useUser().isAuthenticated) {
// If still not authenticated, redirect to login
return navigateTo('/login')
}
})

20
app/pages/confirm.vue Normal file
View File

@@ -0,0 +1,20 @@
<template>
<UEmpty
:title="$t('confirm.signingIn')"
:description="$t('confirm.redirectedSoon')"
/>
</template>
<script setup lang="ts">
definePageMeta({
layout: 'empty'
})
const { isAuthenticated } = storeToRefs(useUser())
watch(isAuthenticated, () => {
if (isAuthenticated.value) {
return navigateTo('/profile')
}
}, { immediate: true })
</script>

41
app/pages/index.vue Normal file
View File

@@ -0,0 +1,41 @@
<template>
<div>
<UPageHero
:title="$t('hero.title')"
:description="$t('hero.description')"
:links="links"
/>
<div class="flex justify-center mt-8">
<CounterWidget v-if="isAuthenticated" />
<UEmpty
v-else
icon="i-lucide-user-x"
:title="$t('counter.notAuthenticated')"
:description="$t('counter.signInToUse')"
/>
</div>
</div>
</template>
<script setup lang="ts">
import type { ButtonProps } from '@nuxt/ui'
const { isAuthenticated } = storeToRefs(useUser())
const { t } = useI18n()
const links = computed(() => [
{
label: t('hero.signUp'),
to: '/login',
icon: 'i-lucide-log-in'
},
{
label: t('hero.profile'),
to: '/profile',
color: 'neutral',
variant: 'subtle',
trailingIcon: 'i-lucide-arrow-right'
}
] as ButtonProps[])
</script>

110
app/pages/login.vue Normal file
View File

@@ -0,0 +1,110 @@
<template>
<UAuthForm
:fields="fields"
:schema="schema"
:providers="providers"
:title="$t('login.title')"
icon="i-lucide-lock"
@submit="onSubmit($event)"
>
<template #footer>
{{ $t('login.agree') }}
<ULink
to="/"
class="text-primary font-medium"
>
{{ $t('login.terms') }}
</ULink>.
</template>
</UAuthForm>
</template>
<script setup lang="ts">
import * as z from 'zod'
import type { FormSubmitEvent } from '@nuxt/ui'
import type { AuthFormField } from '@nuxt/ui/runtime/components/AuthForm.vue.js'
definePageMeta({
layout: 'empty'
})
const { t } = useI18n()
const toast = useToast()
// The otpId is used to track the OTP request and verify the user
// null if no request was sent yet
const otpId = ref<string | null>(null)
const fields = computed(() => {
const fields: AuthFormField[] = [{
name: 'email',
type: 'text',
label: t('login.email'),
placeholder: t('login.emailPlaceholder'),
required: true
}]
if (otpId.value !== null) {
fields.push({
name: 'otp',
type: 'otp',
label: t('login.otp'),
length: 6,
size: 'xl'
})
}
return fields
})
const { signInWithEmail, signInWithOAuth, signInWithOtp } = useUser()
const oAuthAndRedirect = async (provider: 'apple' | 'google') => {
try {
await signInWithOAuth(provider)
navigateTo('/confirm')
} catch (error) {
toast.add({ color: 'error', description: (error as Error).message })
}
}
const providers = [
{
label: 'Google',
icon: 'i-simple-icons-google',
onClick: () => oAuthAndRedirect('google')
},
{
label: 'Apple',
icon: 'i-simple-icons-apple',
onClick: () => oAuthAndRedirect('apple')
}
]
const schema = z.object({
email: z.email(t('login.invalidEmail')),
otp: z.array(z.string()).optional().transform(val => val?.join(''))
})
type Schema = z.output<typeof schema>
const { locale } = useI18n()
async function onSubmit(payload: FormSubmitEvent<Schema>) {
try {
if (otpId.value !== null && payload.data.otp?.length) {
// If OTP is provided, sign in with OTP
await signInWithOtp(otpId.value, payload.data.otp)
navigateTo('/confirm')
} else {
// If OTP is not provided, sign in with email (send OTP)
otpId.value = await signInWithEmail(payload.data.email, locale.value)
toast.add({
title: t('login.emailSent'),
description: t('login.emailSentDescription'),
icon: 'i-lucide-check',
color: 'success'
})
}
} catch (error) {
toast.add({ color: 'error', description: (error as Error).message })
}
}
</script>

142
app/pages/profile.vue Normal file
View File

@@ -0,0 +1,142 @@
<template>
<UForm
id="settings"
:schema="profileSchema"
:state="profile"
@submit="onSubmit"
>
<UPageCard
:title="$t('profile.title')"
:description="$t('profile.description')"
variant="naked"
orientation="horizontal"
class="mb-4"
>
<UButton
form="settings"
:label="$t('profile.save')"
color="neutral"
type="submit"
class="w-fit lg:ms-auto"
/>
</UPageCard>
<UPageCard variant="subtle">
<UFormField
name="name"
:label="$t('profile.name')"
:description="$t('profile.nameDescription')"
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="$t('profile.email')"
:description="$t('profile.emailDescription')"
required
class="flex max-sm:flex-col justify-between items-start gap-4"
>
<UInput
:model-value="user?.email"
type="email"
autocomplete="off"
disabled
/>
</UFormField>
<USeparator />
<UFormField
name="avatar"
:label="$t('profile.avatar')"
:description="$t('profile.avatarDescription')"
class="flex max-sm:flex-col justify-between sm:items-center gap-4"
>
<ProfileAvatarUpload />
</UFormField>
<USeparator />
<UFormField
name="delete"
:label="$t('profile.delete')"
:description="$t('profile.deleteDescription')"
class="flex max-sm:flex-col justify-between sm:items-center gap-4"
>
<UModal
:title="$t('profile.delete')"
:description="$t('profile.deleteWarning')"
:ui="{ footer: 'justify-end' }"
:close="false"
>
<UButton
:label="$t('profile.delete')"
color="error"
/>
<template #footer="{ close }">
<UButton
:label="$t('cancel')"
color="neutral"
variant="outline"
@click="close"
/>
<UButton
:label="$t('profile.delete')"
color="error"
@click="onDelete"
/>
</template>
</UModal>
</UFormField>
</UPageCard>
</UForm>
</template>
<script setup lang="ts">
import * as z from 'zod'
definePageMeta({
middleware: ['auth']
})
const { t } = useI18n()
const { user, updateUser, deleteUser } = useUser()
const profileSchema = z.object({
name: z.string().min(2, t('profile.tooShort'))
})
type ProfileSchema = z.output<typeof profileSchema>
const profile = reactive<Partial<ProfileSchema>>({
name: user?.name ?? ''
})
const toast = useToast()
async function onSubmit() {
try {
if (profile.name) {
await updateUser({ name: profile.name })
}
toast.add({
title: t('profile.success'),
description: t('profile.settingsUpdated'),
icon: 'i-lucide-check',
color: 'success'
})
} catch (error) {
toast.add({ color: 'error', description: (error as Error).message })
console.warn(error)
}
}
const onDelete = async () => {
try {
await deleteUser()
navigateTo('/')
} catch (error) {
toast.add({ color: 'error', description: (error as Error).message })
}
}
</script>

43
app/plugins/pocketbase.ts Normal file
View File

@@ -0,0 +1,43 @@
import PocketBase, { LocalAuthStore } from 'pocketbase'
import type { RecordModel } from 'pocketbase'
import type { TypedPocketBase } from '~/types/pocketbase.types'
/**
* Extends LocalAuthStore to make auth state reactive in Vue.
*
* PocketBase mutates its internal state directly on `this`, bypassing any
* reactive() proxy. Instead, `ref` holds a ShallowRef to the store instance,
* and triggerRef() manually notifies Vue after each state change — so consumers
* always read up-to-date values directly from the store without mirroring state.
*/
export class ReactiveAuthStore extends LocalAuthStore {
readonly ref = shallowRef(this)
override save(token: string, record: RecordModel | null) {
super.save(token, record)
triggerRef(this.ref)
}
override clear() {
super.clear()
triggerRef(this.ref)
}
}
export default defineNuxtPlugin((nuxtApp) => {
const pocketbaseUrl = nuxtApp.$config.public.pocketbaseUrl
if (!pocketbaseUrl) {
throw new Error('Pocketbase config not set')
}
const pb = new PocketBase(pocketbaseUrl, new ReactiveAuthStore()) as TypedPocketBase
// Provide pocketbase client to the app
// Will be available eg. as `const { $pb } = useNuxtApp()`
return {
provide: {
pb
}
}
})

View File

@@ -0,0 +1,24 @@
export default defineNuxtPlugin((nuxtApp) => {
/**
* Directive that calls a callback when an element gets visible in the viewport.
*/
nuxtApp.vueApp.directive('on-visible', {
mounted(el, binding) {
const callback = binding.value
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
callback(entry)
observer.disconnect()
}
})
},
{ threshold: 0.5 }
)
observer.observe(el)
}
})
})

BIN
app/public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

33
app/stores/avatar.ts Normal file
View File

@@ -0,0 +1,33 @@
export const useAvatar = defineStore('avatar', {
getters: {
name: () => useUser().user?.name,
/**
* Returns the URL of the user's avatar, or null if not available
*/
src: () => {
const user = useUser().user
const fileName = useUser().user?.avatar
if (user && fileName) {
const { pb } = usePocketBase()
return pb.files.getURL(user, fileName, { thumb: '80x80' })
}
return null
}
},
actions: {
/**
* Uploads an avatar for the current user
*/
async uploadAvatar(file: File) {
const { isAuthenticated, userId } = useUser()
if (isAuthenticated) {
const { pb } = usePocketBase()
useUser().user = await pb.collection('users').update(userId!, {
avatar: file
})
} else {
console.warn('Avatar upload failed: user is not authenticated')
}
}
}
})

81
app/stores/counter.ts Normal file
View File

@@ -0,0 +1,81 @@
const COLLECTION = 'counters'
export const useCounter = defineStore('counter', {
state: () => {
return {
recordId: null as string | null,
count: 0
}
},
actions: {
/**
* Subscribes to changes of the count in realtime
* and calls the method to fetch the current count
*/
subscribeToChanges() {
if (useUser().isAuthenticated) {
const { pb } = usePocketBase()
pb.collection(COLLECTION).subscribe('*', () => this.fetchCurrentCount())
}
},
/**
* Unsubscribe from the realtime channel
* Should be done when the user leaves the page for cleanup
*/
async unsubscribe() {
const { pb } = usePocketBase()
pb.collection(COLLECTION).unsubscribe('*')
},
/**
* Fetches the current count and sets the state
*/
async fetchCurrentCount() {
const userId = useUser().userId
if (!userId) return
try {
const { pb } = usePocketBase()
const record = await pb.collection(COLLECTION).getFirstListItem(`userId="${userId}"`)
this.count = record.count
this.recordId = record.id
return
} catch (error) {
console.error(error)
}
},
/**
* Increments the count by the given amount (positive or negative)
*/
async increment(amount: number) {
try {
const { pb } = usePocketBase()
if (this.recordId) {
await pb.collection(COLLECTION).update(this.recordId, {
count: this.count + amount
})
} else {
const record = await pb.collection(COLLECTION).create({
userId: useUser().userId,
count: this.count + amount
})
this.recordId = record.id
}
} catch (error) {
console.error(error)
}
},
/**
* Resets the count to zero
* Uses a custom PocketBase endpoint for demonstration
*/
async reset() {
try {
const { pb } = usePocketBase()
await pb.send('/counter/reset', {
method: 'POST'
})
} catch (error) {
console.error(error)
}
}
}
})

View File

@@ -0,0 +1,83 @@
import type { NotificationsRecord } from '~/types/pocketbase.types'
const COLLECTION = 'notifications'
export const useNotifications = defineStore('notifications', {
state: () => {
return {
notifications: [] as NotificationsRecord[]
}
},
actions: {
/**
* Subscribes to changes of notifications in realtime
* and calls the method to refetch them
*/
subscribeToChanges() {
if (useUser().isAuthenticated) {
const { pb } = usePocketBase()
pb.collection(COLLECTION).subscribe('*', () => this.fetchNotifications())
}
},
/**
* Unsubscribe from the realtime channel
* Should be done when the user leaves the page for cleanup
*/
unsubscribe() {
const { pb } = usePocketBase()
pb.collection(COLLECTION).unsubscribe('*')
},
/**
* Fetches the notifications and sets the state
*/
async fetchNotifications() {
const userId = useUser().userId
if (!userId) return
try {
const { pb } = usePocketBase()
const resultList = await pb.collection(COLLECTION).getList(1, 50, {
filter: `userId="${userId}"`
})
this.notifications = resultList.items
} catch (error) {
console.error(error)
}
},
/**
* Marks a notification as read
*/
async markAsRead(notificationId: string) {
try {
const { pb } = usePocketBase()
await pb.collection(COLLECTION).update(notificationId, {
isRead: true
})
} catch (error) {
console.error(error)
}
},
/**
* Adds a new FCM token for the current user for push notifications
*/
async addFcmToken(token: string) {
const userId = useUser().userId
if (!userId) return
try {
const { pb } = usePocketBase()
const result = await pb.collection('fcm_tokens').getList(1, 0, {
filter: `token = "${token}"`
})
if (result.totalItems > 0) {
// skip if token already exists
return
}
await pb.collection('fcm_tokens').create({
userId,
token
})
} catch (error) {
console.error(error)
}
}
}
})

106
app/stores/user.ts Normal file
View File

@@ -0,0 +1,106 @@
import type { UsersRecord } from '~/types/pocketbase.types'
export const useUser = defineStore('user', {
state: () => ({
user: null as UsersRecord | null
}),
getters: {
isAuthenticated() {
const { authStore } = usePocketBase()
return authStore.value.isValid && authStore.value.record !== null
},
userId() {
const { authStore } = usePocketBase()
return authStore.value.record?.id
}
},
actions: {
/**
* Signs in a user with email (sends OTP)
* @param email The email address of the user
* @returns The OTP ID needed for authenticating with the received OTP
*/
async signInWithEmail(email: string, language?: string) {
const { pb } = usePocketBase()
const req = await pb.collection('users').requestOTP(email, {
headers: {
language: language ?? 'en'
}
})
return req.otpId
},
/**
* Signs in a user with OTP
* @param otpId The OTP ID from the email OTP request
* @param otp The OTP code sent to the user's email
*/
async signInWithOtp(otpId: string, otp: string) {
const { pb } = usePocketBase()
await pb.collection('users').authWithOTP(otpId, otp)
},
/**
* Signs in a user with OAuth provider
* @param provider The OAuth provider to use
*/
async signInWithOAuth(provider: 'apple' | 'google') {
const { pb } = usePocketBase()
await pb.collection('users').authWithOAuth2({
provider
})
},
/**
* Refreshes the auth store for the current user
*/
async authRefresh() {
try {
const { pb } = usePocketBase()
await pb.collection('users').authRefresh()
await this.fetchUser()
} catch (error) {
console.debug('User not authenticated', error)
}
},
/**
* Signs out the current user
*/
signOut() {
const { pb } = usePocketBase()
pb.authStore.clear()
this.user = null
},
/**
* Fetches all data of the currently authenticated user
*/
async fetchUser() {
if (this.isAuthenticated) {
const { pb } = usePocketBase()
this.user = await pb.collection('users').getOne(this.userId!)
} else {
console.warn('Fetch user failed: user is not authenticated')
}
},
/**
* Updates the given fields of the current user
*/
async updateUser(data: Partial<UsersRecord>) {
if (this.isAuthenticated) {
const { pb } = usePocketBase()
this.user = await pb.collection('users').update(this.userId!, data)
} else {
console.warn('Update user failed: user is not authenticated')
}
},
/**
* Deletes the current user's account
*/
async deleteUser() {
if (this.isAuthenticated) {
const { pb } = usePocketBase()
await pb.collection('users').delete(this.userId!)
this.signOut()
} else {
console.warn('Delete user failed: user is not authenticated')
}
}
}
})

1
app/types/i18n.types.ts Normal file
View File

@@ -0,0 +1 @@
export type LanguageCode = 'en' | 'de'

View File

@@ -0,0 +1,243 @@
/**
* This file was @generated using pocketbase-typegen
*/
import type PocketBase from 'pocketbase'
import type { RecordService } from 'pocketbase'
export enum Collections {
Authorigins = "_authOrigins",
Externalauths = "_externalAuths",
Mfas = "_mfas",
Otps = "_otps",
Superusers = "_superusers",
Counters = "counters",
FcmTokens = "fcm_tokens",
Notifications = "notifications",
Users = "users",
}
// Alias types for improved usability
export type IsoDateString = string
export type IsoAutoDateString = string & { readonly autodate: unique symbol }
export type RecordIdString = string
export type FileNameString = string & { readonly filename: unique symbol }
export type HTMLString = string
type ExpandType<T> = unknown extends T
? T extends unknown
? { expand?: unknown }
: { expand: T }
: { expand: T }
// System fields
export type BaseSystemFields<T = unknown> = {
id: RecordIdString
collectionId: string
collectionName: Collections
} & ExpandType<T>
export type AuthSystemFields<T = unknown> = {
email: string
emailVisibility: boolean
username: string
verified: boolean
} & BaseSystemFields<T>
// Record types for each collection
export type AuthoriginsRecord = {
collectionRef: string
created: IsoAutoDateString
fingerprint: string
id: string
recordRef: string
updated: IsoAutoDateString
}
export type ExternalauthsRecord = {
collectionRef: string
created: IsoAutoDateString
id: string
provider: string
providerId: string
recordRef: string
updated: IsoAutoDateString
}
export type MfasRecord = {
collectionRef: string
created: IsoAutoDateString
id: string
method: string
recordRef: string
updated: IsoAutoDateString
}
export type OtpsRecord = {
collectionRef: string
created: IsoAutoDateString
id: string
password: string
recordRef: string
sentTo?: string
updated: IsoAutoDateString
}
export type SuperusersRecord = {
created: IsoAutoDateString
email: string
emailVisibility?: boolean
id: string
password: string
tokenKey: string
updated: IsoAutoDateString
verified?: boolean
}
export type CountersRecord = {
count?: number
created: IsoAutoDateString
id: string
updated: IsoAutoDateString
userId?: RecordIdString
}
export type FcmTokensRecord = {
created: IsoAutoDateString
id: string
token?: string
updated: IsoAutoDateString
userId?: RecordIdString
}
export type NotificationsRecord = {
body?: string
created: IsoAutoDateString
id: string
isRead?: boolean
title?: string
updated: IsoAutoDateString
userId?: RecordIdString
}
export enum UsersLanguageOptions {
"de" = "de",
"en" = "en",
}
export type UsersRecord = {
avatar?: FileNameString
created: IsoAutoDateString
email: string
emailVisibility?: boolean
id: string
language?: UsersLanguageOptions
name?: string
password: string
tokenKey: string
updated: IsoAutoDateString
verified?: boolean
}
// Response types include system fields and match responses from the PocketBase API
export type AuthoriginsResponse<Texpand = unknown> = Required<AuthoriginsRecord> & BaseSystemFields<Texpand>
export type ExternalauthsResponse<Texpand = unknown> = Required<ExternalauthsRecord> & BaseSystemFields<Texpand>
export type MfasResponse<Texpand = unknown> = Required<MfasRecord> & BaseSystemFields<Texpand>
export type OtpsResponse<Texpand = unknown> = Required<OtpsRecord> & BaseSystemFields<Texpand>
export type SuperusersResponse<Texpand = unknown> = Required<SuperusersRecord> & AuthSystemFields<Texpand>
export type CountersResponse<Texpand = unknown> = Required<CountersRecord> & BaseSystemFields<Texpand>
export type FcmTokensResponse<Texpand = unknown> = Required<FcmTokensRecord> & BaseSystemFields<Texpand>
export type NotificationsResponse<Texpand = unknown> = Required<NotificationsRecord> & BaseSystemFields<Texpand>
export type UsersResponse<Texpand = unknown> = Required<UsersRecord> & AuthSystemFields<Texpand>
// Types containing all Records and Responses, useful for creating typing helper functions
export type CollectionRecords = {
_authOrigins: AuthoriginsRecord
_externalAuths: ExternalauthsRecord
_mfas: MfasRecord
_otps: OtpsRecord
_superusers: SuperusersRecord
counters: CountersRecord
fcm_tokens: FcmTokensRecord
notifications: NotificationsRecord
users: UsersRecord
}
export type CollectionResponses = {
_authOrigins: AuthoriginsResponse
_externalAuths: ExternalauthsResponse
_mfas: MfasResponse
_otps: OtpsResponse
_superusers: SuperusersResponse
counters: CountersResponse
fcm_tokens: FcmTokensResponse
notifications: NotificationsResponse
users: UsersResponse
}
// Utility types for create/update operations
type ProcessCreateAndUpdateFields<T> = Omit<{
// Omit AutoDate fields
[K in keyof T as Extract<T[K], IsoAutoDateString> extends never ? K : never]:
// Convert FileNameString to File
T[K] extends infer U ?
U extends (FileNameString | FileNameString[]) ?
U extends any[] ? File[] : File
: U
: never
}, 'id'>
// Create type for Auth collections
export type CreateAuth<T> = {
id?: RecordIdString
email: string
emailVisibility?: boolean
password: string
passwordConfirm: string
verified?: boolean
} & ProcessCreateAndUpdateFields<T>
// Create type for Base collections
export type CreateBase<T> = {
id?: RecordIdString
} & ProcessCreateAndUpdateFields<T>
// Update type for Auth collections
export type UpdateAuth<T> = Partial<
Omit<ProcessCreateAndUpdateFields<T>, keyof AuthSystemFields>
> & {
email?: string
emailVisibility?: boolean
oldPassword?: string
password?: string
passwordConfirm?: string
verified?: boolean
}
// Update type for Base collections
export type UpdateBase<T> = Partial<
Omit<ProcessCreateAndUpdateFields<T>, keyof BaseSystemFields>
>
// Get the correct create type for any collection
export type Create<T extends keyof CollectionResponses> =
CollectionResponses[T] extends AuthSystemFields
? CreateAuth<CollectionRecords[T]>
: CreateBase<CollectionRecords[T]>
// Get the correct update type for any collection
export type Update<T extends keyof CollectionResponses> =
CollectionResponses[T] extends AuthSystemFields
? UpdateAuth<CollectionRecords[T]>
: UpdateBase<CollectionRecords[T]>
// Type for usage with type asserted PocketBase instance
// https://github.com/pocketbase/js-sdk#specify-typescript-definitions
export type TypedPocketBase = {
collection<T extends keyof CollectionResponses>(
idOrName: T
): RecordService<CollectionResponses[T]>
} & PocketBase