Initial commit
This commit is contained in:
61
app/CLAUDE.md
Normal file
61
app/CLAUDE.md
Normal 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
8
app/app.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export default defineAppConfig({
|
||||
ui: {
|
||||
colors: {
|
||||
primary: 'teal',
|
||||
neutral: 'slate'
|
||||
}
|
||||
}
|
||||
})
|
||||
150
app/app.vue
Normal file
150
app/app.vue
Normal 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
6
app/assets/css/main.css
Normal file
@@ -0,0 +1,6 @@
|
||||
@import "tailwindcss";
|
||||
@import "@nuxt/ui";
|
||||
|
||||
@theme static {
|
||||
--font-sans: 'Public Sans', sans-serif;
|
||||
}
|
||||
51
app/components/App/Header.vue
Normal file
51
app/components/App/Header.vue
Normal 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>
|
||||
85
app/components/App/Notifications.vue
Normal file
85
app/components/App/Notifications.vue
Normal 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>
|
||||
51
app/components/Counter/Widget.vue
Normal file
51
app/components/Counter/Widget.vue
Normal 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>
|
||||
21
app/components/Profile/Avatar.vue
Normal file
21
app/components/Profile/Avatar.vue
Normal 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>
|
||||
57
app/components/Profile/AvatarUpload.vue
Normal file
57
app/components/Profile/AvatarUpload.vue
Normal 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>
|
||||
25
app/components/Profile/LangSelect.vue
Normal file
25
app/components/Profile/LangSelect.vue
Normal 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>
|
||||
18
app/composables/usePocketBase.ts
Normal file
18
app/composables/usePocketBase.ts
Normal 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
53
app/i18n/locales/de.json
Normal 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
53
app/i18n/locales/en.json
Normal 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
11
app/layouts/default.vue
Normal 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
7
app/layouts/empty.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<UMain>
|
||||
<UContainer class="py-8">
|
||||
<slot />
|
||||
</UContainer>
|
||||
</UMain>
|
||||
</template>
|
||||
10
app/middleware/00.fetchUser.global.ts
Normal file
10
app/middleware/00.fetchUser.global.ts
Normal 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
10
app/middleware/auth.ts
Normal 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
20
app/pages/confirm.vue
Normal 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
41
app/pages/index.vue
Normal 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
110
app/pages/login.vue
Normal 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
142
app/pages/profile.vue
Normal 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
43
app/plugins/pocketbase.ts
Normal 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
|
||||
}
|
||||
}
|
||||
})
|
||||
24
app/plugins/visibility-directive.ts
Normal file
24
app/plugins/visibility-directive.ts
Normal 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
BIN
app/public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 35 KiB |
33
app/stores/avatar.ts
Normal file
33
app/stores/avatar.ts
Normal 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
81
app/stores/counter.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
83
app/stores/notifications.ts
Normal file
83
app/stores/notifications.ts
Normal 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
106
app/stores/user.ts
Normal 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
1
app/types/i18n.types.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type LanguageCode = 'en' | 'de'
|
||||
243
app/types/pocketbase.types.ts
Normal file
243
app/types/pocketbase.types.ts
Normal 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
|
||||
Reference in New Issue
Block a user