Files
pichunt/app/pages/profile.vue
T
Pascal 61cc516f47
Docker Image CI / test (push) Failing after 41s
Docker Image CI / deploy (push) Skipped
Tests / unit (push) Failing after 36s
Tests / integration (push) Successful in 1m34s
feat(hunt): track walking distance via GPS, show live locations on map
Add foreground-only GPS distance tracking for hunts
2026-09-27 20:55:58 +02:00

321 lines
9.4 KiB
Vue

<script setup lang="ts">
import { useI18nKey } from '~/composables/useI18nKey';
definePageMeta({
middleware: ['auth']
});
const router = useRouter();
const { t } = useI18n();
const { loggedIn, fetch } = useUserSession();
const localePath = useLocalePath();
const validation = useValidation();
const { tKey, tSluggy } = useI18nKey();
const { formatDistance } = useFormatDistance();
watch(loggedIn, (isLoggedIn) => {
if (!isLoggedIn) {
router.push(localePath('/login'));
}
});
const { data, refresh, pending } = await useFetch('/api/auth/profile');
const distanceEntries = computed(() =>
(data.value?.distances ?? []).map((entry) => ({
id: entry.huntId,
name_de: entry.huntName_de,
name_en: entry.huntName_en,
distanceMeters: entry.distanceMeters
}))
);
useHead({
title: t('layouts.title', {
title: t('page.profile.title')
})
});
const form = reactive({
email: data.value!.email,
name: data.value!.name
});
const uploading = ref(false);
const changed = computed(
() => form.name !== data.value?.name || form.email !== data.value?.email
);
const profileError = ref('');
async function submitProfile() {
uploading.value = true;
profileError.value = '';
try {
await $fetch(`/api/auth/profile`, {
method: 'POST',
body: form
});
await refresh();
form.email = data.value!.email;
form.name = data.value!.name;
await fetch();
} catch (e) {
if (e instanceof Error && 'statusCode' in e && e.statusCode === 409) {
profileError.value = t('auth.conflict');
} else {
profileError.value = parseError(e);
}
}
uploading.value = false;
}
const password = reactive({
password: '',
old: ''
});
const passwordError = ref('');
async function submitPassword() {
uploading.value = true;
passwordError.value = '';
try {
await $fetch(`/api/auth/password`, {
method: 'POST',
body: password
});
password.password = '';
password.old = '';
} catch (e) {
if (e instanceof Error && 'statusCode' in e && e.statusCode === 404) {
passwordError.value = t('auth.old_invalid');
} else {
passwordError.value = parseError(e);
}
}
uploading.value = false;
}
</script>
<template>
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
<VaCard stripe stripe-color="success">
<VaCardTitle>Public profile</VaCardTitle>
<VaCardContent v-if="data">
<div class="flex flex-col gap-4">
<VaInput
v-model="form.name"
:label="t('auth.name')"
clearable
:clear-value="data.name"
clearable-icon="replay"
:disabled="uploading"
counter
max-length="265"
min-length="3"
:rules="[
validation.required(t('auth.name')),
validation.min(t('auth.name'), 3),
validation.max(t('auth.name'), 256)
]"
/>
<VaInput
v-model="form.email"
:label="t('auth.email')"
type="email"
clearable
:clear-value="data.email"
clearable-icon="replay"
:disabled="uploading"
:rules="[
validation.required(t('auth.email')),
validation.min(t('auth.email'), 3)
]"
/>
<VaAlert
v-if="profileError.length > 0"
color="danger"
class="w-full text-center"
>{{ t(profileError) }}</VaAlert
>
<VaButton
:loading="pending || uploading"
color="success"
:disabled="!changed || pending || uploading"
@click="submitProfile"
>{{ t('page.common.update_btn') }}</VaButton
>
</div>
</VaCardContent>
</VaCard>
<VaCard stripe stripe-color="danger">
<VaCardTitle>Change password</VaCardTitle>
<VaCardContent>
<div class="flex flex-col gap-4">
<VaValue v-slot="isPasswordVisible" :default-value="false">
<VaInput
v-model="password.old"
class="w-full"
:type="isPasswordVisible.value ? 'text' : 'password'"
:label="t('page.profile.old_password')"
:placeholder="t('page.profile.old_password')"
autocomplete="current-password"
:minlength="8"
required
>
<template #prependInner>
<VaIcon name="key" color="secondary" />
</template>
<template #appendInner>
<VaButton
v-if="password.old"
preset="plain"
:title="
t(
isPasswordVisible.value
? 'auth.hidePassword'
: 'auth.showPassword'
)
"
@click="isPasswordVisible.value = !isPasswordVisible.value"
>
<VaIcon
:name="
isPasswordVisible.value ? 'visibility_off' : 'visibility'
"
color="primary"
/>
</VaButton>
</template>
</VaInput>
</VaValue>
<VaValue v-slot="isPasswordVisible" :default-value="false">
<VaInput
v-model="password.password"
class="w-full"
:type="isPasswordVisible.value ? 'text' : 'password'"
:label="t('page.profile.new_password')"
:placeholder="t('auth.password')"
autocomplete="current-password"
:minlength="8"
required
>
<template #prependInner>
<VaIcon name="key" color="secondary" />
</template>
<template #appendInner>
<VaButton
v-if="password.password"
preset="plain"
:title="
t(
isPasswordVisible.value
? 'auth.hidePassword'
: 'auth.showPassword'
)
"
@click="isPasswordVisible.value = !isPasswordVisible.value"
>
<VaIcon
:name="
isPasswordVisible.value ? 'visibility_off' : 'visibility'
"
color="primary"
/>
</VaButton>
</template>
</VaInput>
</VaValue>
<VaAlert
v-if="passwordError.length > 0"
color="danger"
class="w-full text-center"
>{{ t(profileError) }}</VaAlert
>
<VaButton
:loading="pending || uploading"
color="success"
:disabled="
password.password.length < 8 ||
password.old.length < 8 ||
pending ||
uploading
"
@click="submitPassword"
>Update</VaButton
>
</div>
</VaCardContent>
</VaCard>
<VaCard stripe stripe-color="primary">
<VaCardTitle>{{ t('page.profile.uploaded_pictures') }}</VaCardTitle>
<VaCardContent v-if="data && data.uploadedFiles.length > 0">
<VaCarousel
stateful
:items="data.uploadedFiles"
arrows
indicators
lazy
height="50vh"
indicator-trigger="hover"
>
<template #default="{ item }"
><ClickableImage
:src="item.url"
:title="`${tKey(item.answer.quest, 'title').value} — ${tKey(item.answer.quest.hunt, 'name').value}`"
/>
<NuxtLink
:to="
localePath(
`/hunt/${tSluggy(item.answer.quest.hunt).value}/${tSluggy(item.answer.quest).value}`
)
"
class="absolute top-2 mx-auto rounded bg-gray-200/75 px-2 py-1 text-center text-xl hover:underline"
>
{{ tKey(item.answer.quest, 'title') }} —
{{ tKey(item.answer.quest.hunt, 'name') }}
</NuxtLink></template
>
</VaCarousel>
</VaCardContent>
<VaCardContent v-else>{{ t('page.profile.no_pictures') }}</VaCardContent>
</VaCard>
<VaCard stripe stripe-color="info">
<VaCardTitle>{{ t('page.profile.distances.title') }}</VaCardTitle>
<VaCardContent v-if="data && data.distances.length > 0">
<p class="mb-4 text-xl font-bold">
{{
t('page.profile.distances.total', {
distance: formatDistance(data.totalDistance)
})
}}
</p>
<ul class="flex flex-col gap-2">
<li
v-for="entry in distanceEntries"
:key="entry.id"
class="flex items-center justify-between gap-2"
>
<NuxtLink
:to="localePath(`/hunt/${tSluggy(entry).value}`)"
class="hover:underline"
>
{{ tKey(entry, 'name') }}
</NuxtLink>
<span class="font-mono">{{
formatDistance(entry.distanceMeters)
}}</span>
</li>
</ul>
</VaCardContent>
<VaCardContent v-else>{{
t('page.profile.distances.no_distances')
}}</VaCardContent>
</VaCard>
<AdminUserRoles />
</div>
</template>
<style scoped></style>