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