Add team creation, team join and debug login screen

This commit is contained in:
2025-08-03 18:58:17 +02:00
parent facb07de5b
commit 36e8ef41bf
15 changed files with 735 additions and 11 deletions
+82
View File
@@ -0,0 +1,82 @@
<script setup lang="ts">
import { parseError } from '#shared/utils/error';
const emits = defineEmits<{
update: [];
}>();
const props = defineProps<{
huntId: number;
}>();
const isOpen = ref(false);
const data = reactive({
name: '',
description: ''
});
const pending = ref(false);
const error = ref<string>();
async function submit() {
if (data.name.length < 3 || pending.value) {
return;
}
pending.value = true;
try {
const result = await $fetch(`/api/hunt/${props.huntId}/team`, {
body: JSON.stringify(data),
method: 'POST'
});
data.name = '';
data.description = '';
emits('update');
isOpen.value = false;
error.value = undefined;
} catch (e) {
console.error(e);
error.value = parseError(e);
}
pending.value = false;
}
</script>
<template>
<VaButton color="success" icon="group_add" @click="isOpen = true"
>Create a new team</VaButton
>
<VaModal v-model="isOpen" close-button hide-default-actions>
<h3 class="text-3xl">Create a new team</h3>
<div class="mt-4 flex flex-col gap-4">
<VaInput v-model="data.name" label="Name" placeholder="My cool team" />
<VaTextarea
max-rows="4"
min-rows="2"
v-model="data.description"
label="Description"
placeholder="The best team!"
/>
<p>An invite code will be generated automatically.</p>
<VaAlert color="danger" v-if="error">{{ $t(error) }}</VaAlert>
</div>
<template #footer>
<VaButtonGroup :loading="pending" :disabled="pending">
<VaButton color="secondary" @click="isOpen = false">Cancel</VaButton>
<VaButton
color="success"
:disabled="data.name.length < 3 || pending"
@click="submit"
:loading="pending"
>Create</VaButton
>
</VaButtonGroup>
</template>
</VaModal>
</template>
<style scoped></style>
+128
View File
@@ -0,0 +1,128 @@
<script setup lang="ts">
import { parseError } from '#shared/utils/error';
const emits = defineEmits<{
update: [];
}>();
const props = defineProps<{
huntId: number;
}>();
const isOpen = ref(false);
const data = reactive({
team: null as number | null,
password: ''
});
const submitPending = ref(false);
const {
data: teams,
pending,
refresh
} = useFetch(`/api/hunt/${props.huntId}/teams`, {
lazy: true,
server: false,
immediate: false,
default: () => []
});
const error = ref<string>();
async function openModal() {
isOpen.value = true;
await refresh();
}
async function submit() {
if (data.password.length < 3 || data.team === null || submitPending.value) {
return;
}
submitPending.value = true;
try {
const result = await $fetch(`/api/hunt/${props.huntId}/join`, {
body: JSON.stringify(data),
method: 'POST'
});
data.team = null;
data.password = '';
emits('update');
isOpen.value = false;
error.value = undefined;
} catch (e) {
console.error(e);
if (
e instanceof Error &&
e.name === 'FetchError' &&
'statusCode' in e &&
e.statusCode === 404
) {
error.value = 'error.team_not_found';
} else {
error.value = parseError(e);
}
}
submitPending.value = false;
}
</script>
<template>
<VaButton color="info" icon="search" @click="openModal">Join a team</VaButton>
<VaModal v-model="isOpen" close-button hide-default-actions>
<h3 class="text-3xl">Join a team</h3>
<div class="mt-4 flex flex-col gap-4">
<VaSelect
:options="teams"
v-model="data.team"
value-by="id"
track-by="id"
searchable
highlight-matched-text
placeholder="Select a team"
label="Select a team"
:text-by="
(team: (typeof teams)[number]) =>
`'${team.name}' of '${team.owner.name}', ${team._count.members} members`
"
>
<template #prependInner>
<VaIcon name="group" />
</template>
</VaSelect>
<p v-if="teams && teams.length <= 0">
No team has been created yet, be the first!
</p>
<VaInput v-model="data.password" label="Password" placeholder="abc123">
<template #prependInner>
<VaIcon name="password" />
</template>
</VaInput>
<VaAlert color="danger" v-if="error">{{ $t(error) }}</VaAlert>
</div>
<template #footer>
<VaButtonGroup
:loading="submitPending || pending"
:disabled="submitPending"
>
<VaButton color="secondary" @click="isOpen = false">Cancel</VaButton>
<VaButton
color="success"
:disabled="
data.password.length < 3 || data.team === null || submitPending
"
@click="submit"
:loading="submitPending || pending"
>Join</VaButton
>
</VaButtonGroup>
</template>
</VaModal>
</template>
<style scoped></style>
+6
View File
@@ -14,6 +14,12 @@
"switch_lang": "Sprache ändern zu {locale}", "switch_lang": "Sprache ändern zu {locale}",
"refresh": "Aktualisieren" "refresh": "Aktualisieren"
}, },
"error": {
"401": "Bitte melde dich vorher an!",
"404": "Nicht gefunden",
"default": "Unbekannter Fehler",
"team_not_found": "Team nicht gefunden oder Passwort inkorrekt!"
},
"vuestic": { "vuestic": {
"search": "Search", "search": "Search",
"noOptions": "Items not found", "noOptions": "Items not found",
+6
View File
@@ -14,6 +14,12 @@
"switch_lang": "Switch locale to {locale}", "switch_lang": "Switch locale to {locale}",
"refresh": "Refresh" "refresh": "Refresh"
}, },
"error": {
"401": "Please login!",
"404": "Not found",
"default": "Unkown error",
"team_not_found": "Team not found or password incorrect!"
},
"vuestic": { "vuestic": {
"search": "Search", "search": "Search",
"noOptions": "Items not found", "noOptions": "Items not found",
+19 -1
View File
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { useTitleStore } from '~/stores/title'; import { useTitleStore } from '~/stores/title';
const { loggedIn, clear } = useUserSession(); const { loggedIn, clear, user } = useUserSession();
const route = useRoute(); const route = useRoute();
const breakpoints = useBreakpoint(); const breakpoints = useBreakpoint();
@@ -88,6 +88,24 @@ const slugs = computed(() => checkSlug(route.params));
</VaSidebarItemContent> </VaSidebarItemContent>
</VaSidebarItem> </VaSidebarItem>
<VaSpacer /> <VaSpacer />
<VaSidebarItem
v-if="user"
:hover-color="user.role === 'ADMIN' ? 'danger' : undefined"
:text-color="user.role === 'ADMIN' ? 'danger' : undefined"
>
<VaSidebarItemContent>
<VaIcon
:name="
user.role === 'ADMIN'
? 'admin_panel_settings'
: 'account_circle'
"
/>
<VaSidebarItemTitle>
{{ user.name }}
</VaSidebarItemTitle>
</VaSidebarItemContent>
</VaSidebarItem>
<VaSidebarItem v-if="loggedIn" @click="clear"> <VaSidebarItem v-if="loggedIn" @click="clear">
<VaSidebarItemContent> <VaSidebarItemContent>
<VaIcon name="logout" /> <VaIcon name="logout" />
+72 -5
View File
@@ -1,8 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import TeamAddModal from '~/components/TeamAddModal.vue';
import { useTitleStore } from '~/stores/title'; import { useTitleStore } from '~/stores/title';
const app = useNuxtApp(); const app = useNuxtApp();
const { user } = useUserSession(); const { loggedIn } = useUserSession();
const localePath = useLocalePath(); const localePath = useLocalePath();
const { t } = useI18n(); const { t } = useI18n();
@@ -10,7 +11,7 @@ const { name, id } = app.$slugData.huntSlug!;
const titleStore = useTitleStore(); const titleStore = useTitleStore();
const { data, pending } = await useFetch(`/api/hunt/${id}`); const { data, pending, refresh } = await useFetch(`/api/hunt/${id}`);
const hideAnswers = ref(true); const hideAnswers = ref(true);
@@ -20,26 +21,92 @@ useHead({
}) })
}); });
watch(loggedIn, () => refresh());
titleStore.title = data.value?.name; titleStore.title = data.value?.name;
async function invite() {
if (!data.value?.ownTeam) {
return;
}
const clipboardItemData = {
// TODO: add invite link
// 'text/plain': `https://pichunt.syma.dev/hunt/${id}/join?id=${data.value.ownTeam.id}&pw=${data.value.ownTeam.password}`
'text/plain': `Join team ${data.value.ownTeam.name} with code "${data.value.ownTeam.password}" on hunt "${data.value.name || name}"!`
};
const clipboardItem = new ClipboardItem(clipboardItemData);
await navigator.clipboard.write([clipboardItem]);
}
</script> </script>
<template> <template>
<h1 class="text-2xl"> <h1 class="text-2xl">
{{ data?.name || name }} {{ data?.name || name }}
<VaChip color="danger" v-if="data?.isMember">Admin</VaChip>
<VaChip v-if="data?.totalScore" class="float-right" color="success" <VaChip v-if="data?.totalScore" class="float-right" color="success"
>Total points: {{ data!!.totalScore }}</VaChip >Total points: {{ data!!.totalScore }}</VaChip
> >
</h1> </h1>
<div v-if="data"> <div v-if="data">
<p>{{ data.description }}</p> <p>{{ data.description }}</p>
<p v-if="data.start">Start: {{ $d(data.start) }}</p> <p v-if="data.start">
<p v-if="data.end">End: {{ $d(data.end) }}</p> Start:
{{
$d(data.start, {
dateStyle: 'medium',
timeStyle: 'medium'
})
}}
</p>
<p v-if="data.end">
End:
{{
$d(data.end, {
dateStyle: 'medium',
timeStyle: 'medium'
})
}}
</p>
<p> <p>
{{ data._count.teams }} teams joined{{ {{ data._count.teams }} teams joined{{
data.ownTeam !== null ? ', just like you' : ', unlike you' data.ownTeam !== null ? ', just like you' : ', unlike you'
}}! }}!
</p> </p>
<div class="flex flex-row gap-2" v-if="data.ownTeam !== null"> <p v-if="data.ownTeam">
Your team: <span class="font-bold">{{ data.ownTeam.name }}</span>
<VaButton size="small" @click="invite" class="ml-2"
>Invite:
<span
class="font-mono font-bold text-white blur transition hover:blur-none active:blur-none"
>
{{ data.ownTeam.password }}</span
></VaButton
>
</p>
<VaCard
v-if="data.ownTeam === null && !data.isMember"
color="primary"
class="mb-16 mt-8"
>
<VaCardTitle>Join this hunt!</VaCardTitle>
<VaCardContent class="text-2xl"
>To join this hunt you need to join or create a team:</VaCardContent
>
<VaCardActions v-if="data.loggedIn">
<TeamJoinModal @update="refresh" :hunt-id="data.id" />
<TeamAddModal @update="refresh" :hunt-id="data.id" />
</VaCardActions>
<VaCardActions v-else>
<VaButton
color="info"
icon="login"
:to="$localePath(`/login?to=/hunt/${sluggy(data)}`)"
>Login first</VaButton
>
</VaCardActions>
</VaCard>
<div class="flex flex-row gap-2" v-else>
<VaSwitch v-model="hideAnswers" icon="image">Hide answers</VaSwitch> <VaSwitch v-model="hideAnswers" icon="image">Hide answers</VaSwitch>
</div> </div>
<VaDivider /> <VaDivider />
+103 -2
View File
@@ -1,8 +1,109 @@
<script setup lang="ts"></script> <script setup lang="ts">
const { loggedIn } = useUserSession();
const { data, pending, refresh } = await useFetch('/api/home');
watch(loggedIn, () => refresh());
</script>
<template> <template>
<h1 class="text-primary text-3xl">{{ $t('hello') }}</h1> <h1 class="text-primary text-3xl">{{ $t('hello') }}</h1>
<VaButton icon="home" :to="$localePath('/hunt/bremer-stadtrallye-1')" /> <VaButton icon="refresh" @click="refresh" :disabled="pending" />
<div v-if="data">
<div v-if="data.own !== null">
<h2 class="my-4 text-2xl font-bold">Joined hunts:</h2>
<div class="lg-grid-cols-3 grid grid-cols-1 gap-4 md:grid-cols-2">
<VaCard
v-for="hunt in data.own"
:key="hunt.id"
stripe
:to="$localePath(`/hunt/${sluggy(hunt)}`)"
>
<VaCardTitle>{{ hunt.name }}</VaCardTitle>
<VaCardContent>
<h3 class="text-xl">{{ hunt.name }}</h3>
{{ hunt.description }}
<VaDivider />
<div class="flex flex-col gap-2">
<p>{{ hunt._count.quests }} Quests</p>
<p v-if="hunt.start">
Start:
{{
$d(hunt.start, {
dateStyle: 'medium',
timeStyle: 'medium'
})
}}
</p>
<p v-if="hunt.end">
End:
{{
$d(hunt.end, {
dateStyle: 'medium',
timeStyle: 'medium'
})
}}
</p>
<p>{{ hunt._count.teams }} teams joined!</p>
</div>
</VaCardContent>
</VaCard>
<VaCard v-if="data.own.length <= 0">
<VaCardTitle>No hunt joined yet</VaCardTitle>
<VaCardContent>Click on a hunt below and join them!</VaCardContent>
</VaCard>
</div>
</div>
<div v-else>
<VaButton color="info" icon="login" :to="$localePath(`/login`)"
>Login first</VaButton
>
</div>
<div class="mt-8">
<VaDivider />
<h2 class="my-4 text-2xl font-bold">Open to join hunts:</h2>
<div class="lg-grid-cols-3 grid grid-cols-1 gap-4 md:grid-cols-2">
<VaCard
v-for="hunt in data.others"
:key="hunt.id"
:to="$localePath(`/hunt/${sluggy(hunt)}`)"
>
<VaCardTitle>{{ hunt.name }}</VaCardTitle>
<VaCardContent>
<h3 class="text-xl">{{ hunt.name }}</h3>
{{ hunt.description }}
<VaDivider />
<div class="flex flex-col gap-2">
<p>{{ hunt._count.quests }} Quests</p>
<p v-if="hunt.start">
Start:
{{
$d(hunt.start, {
dateStyle: 'medium',
timeStyle: 'medium'
})
}}
</p>
<p v-if="hunt.end">
End:
{{
$d(hunt.end, {
dateStyle: 'medium',
timeStyle: 'medium'
})
}}
</p>
<p>{{ hunt._count.teams }} teams joined!</p>
</div>
</VaCardContent>
</VaCard>
<VaCard v-if="data.others.length <= 0">
<VaCardTitle>No hunt created yet</VaCardTitle>
<VaCardContent>New hunts will appear soon!</VaCardContent>
</VaCard>
</div>
</div>
</div>
</template> </template>
<style scoped></style> <style scoped></style>
+30 -1
View File
@@ -1,9 +1,38 @@
<script setup lang="ts"> <script setup lang="ts">
import { useRouter } from '#app';
definePageMeta({ definePageMeta({
middleware: ['guest'] middleware: ['guest']
}); });
const { fetch } = useUserSession();
const router = useRouter();
const route = useRoute();
// TODO: remove this lol
const { data } = await useFetch('/api/auth/test-list');
async function login(id: number) {
await $fetch(`/api/auth/test?id=${id}`);
await fetch();
await router.push(
(route.query.to
? typeof route.query.to === 'string'
? route.query.to
: route.query.to[0]
: '/') || '/'
);
}
</script> </script>
<template><h1>login</h1></template> <template>
<h1>login</h1>
<div class="flex flex-col gap-2" v-if="data">
<VaButton v-for="user in data" :key="user.id" @click="login(user.id)"
>Login as "{{ user.name }}" {{ user.email }}</VaButton
>
</div>
</template>
<style scoped></style> <style scoped></style>
+11
View File
@@ -0,0 +1,11 @@
import prisma from '~~/lib/prisma';
export default defineEventHandler(() => {
return prisma.user.findMany({
select: {
id: true,
name: true,
email: true
}
});
});
+95
View File
@@ -0,0 +1,95 @@
import prisma from '~~/lib/prisma';
async function getOwnHunts(userId: number | undefined) {
if (!userId) {
return null;
}
return prisma.hunt.findMany({
select: {
id: true,
name: true,
description: true,
createdAt: true,
start: true,
end: true,
_count: {
select: {
teams: true,
quests: true
}
},
creator: {
select: {
name: true,
id: true
}
}
},
where: {
deletedAt: null,
teams: {
some: {
OR: [
{
ownerId: userId
},
{
members: {
some: {
memberId: userId
}
}
}
]
}
}
}
});
}
export default defineEventHandler(async (event) => {
const user = await getUserSession(event);
const userId = user.user?.id;
const own = await getOwnHunts(userId);
const others = await prisma.hunt.findMany({
select: {
id: true,
name: true,
description: true,
createdAt: true,
start: true,
end: true,
_count: {
select: {
teams: true,
quests: true
}
},
creator: {
select: {
name: true,
id: true
}
}
},
where: {
deletedAt: null,
OR: [
{
start: {
gt: new Date()
}
},
{
start: null
}
],
allowJoin: true,
id: {
notIn: own?.map((h) => h.id) ?? []
}
}
});
return { own, others };
});
+4 -2
View File
@@ -51,7 +51,8 @@ export default defineEventHandler(async (event) => {
teams: { teams: {
select: { select: {
id: true, id: true,
name: true name: true,
password: true
}, },
where: { where: {
OR: [ OR: [
@@ -140,7 +141,7 @@ export default defineEventHandler(async (event) => {
const { teams, members, quests, ...hunt } = _hunt; const { teams, members, quests, ...hunt } = _hunt;
const isMember = hunt.creator.id === userId || members.length > 0; const isMember = hunt.creator.id === userId || members.length > 0;
const ownTeam = teams.length > 0 ? teams[0].id : null; const ownTeam = teams.length > 0 ? teams[0] : null;
const mappedQuests = quests.map(({ answers, ...q }) => ({ const mappedQuests = quests.map(({ answers, ...q }) => ({
...q, ...q,
@@ -156,6 +157,7 @@ export default defineEventHandler(async (event) => {
return { return {
isMember, isMember,
ownTeam, ownTeam,
loggedIn: !!user.user,
totalScore: ownTeam !== null ? totalScore : undefined, totalScore: ownTeam !== null ? totalScore : undefined,
quests: mappedQuests, quests: mappedQuests,
...hunt ...hunt
+71
View File
@@ -0,0 +1,71 @@
import { requireUserSession } from '#imports';
import { useValidatedBody, useValidatedParams, z, zh } from 'h3-zod';
import prisma from '~~/lib/prisma';
export default defineEventHandler(async (event) => {
const { huntId } = await useValidatedParams(
event,
z.object({
huntId: zh.numAsString
})
);
const user = await requireUserSession(event);
const userId = user.user.id;
const existingTeam = await prisma.huntTeam.findFirst({
select: {
id: true
},
where: {
huntId,
OR: [
{
ownerId: userId
},
{
members: {
some: {
memberId: userId
}
}
}
]
}
});
if (existingTeam) {
throw createError({ status: 409, statusText: 'You already have a team!' });
}
const { team: teamId, password } = await useValidatedBody(
event,
z.object({
team: z.number().min(1),
password: z.string().max(256)
})
);
const team = await prisma.huntTeam.findUnique({
where: {
huntId,
id: teamId,
password
},
select: { id: true }
});
if (!team) {
throw createError({
status: 404,
statusText: 'Team not found or invalid password!'
});
}
await prisma.teamMember.create({
data: {
memberId: userId,
teamId
}
});
return { success: true };
});
+63
View File
@@ -0,0 +1,63 @@
import { useValidatedBody, useValidatedParams, z, zh } from 'h3-zod';
import prisma from '~~/lib/prisma';
import { randomBytes } from 'node:crypto';
export default defineEventHandler(async (event) => {
const { huntId } = await useValidatedParams(
event,
z.object({
huntId: zh.numAsString
})
);
const user = await requireUserSession(event);
const userId = user.user.id;
console.log(user);
const existingTeam = await prisma.huntTeam.findFirst({
select: {
id: true
},
where: {
huntId,
OR: [
{
ownerId: userId
},
{
members: {
some: {
memberId: userId
}
}
}
]
}
});
if (existingTeam) {
throw createError({ status: 409, statusText: 'You already have a team!' });
}
const { name, description } = await useValidatedBody(
event,
z.object({
name: z.string().max(256).min(3).trim(),
description: z.string().max(256).trim().optional()
})
);
const password = randomBytes(3).toString('hex');
const team = await prisma.huntTeam.create({
data: {
name,
description,
ownerId: userId,
password,
huntId
}
});
return { team };
});
+34
View File
@@ -0,0 +1,34 @@
import { useValidatedParams, z, zh } from 'h3-zod';
import prisma from '~~/lib/prisma';
export default defineEventHandler(async (event) => {
const { huntId } = await useValidatedParams(
event,
z.object({
huntId: zh.numAsString
})
);
return prisma.huntTeam.findMany({
where: {
huntId
},
select: {
id: true,
name: true,
description: true,
owner: {
select: {
name: true
}
},
_count: {
select: {
members: true
}
}
},
orderBy: {
name: 'asc'
}
});
});
+11
View File
@@ -0,0 +1,11 @@
export function parseError(e: unknown): string {
if (e instanceof Error && e.name === 'FetchError' && 'statusCode' in e) {
switch (e.statusCode) {
case 401:
return 'error.401';
case 404:
return 'error.404';
}
}
return 'error.default';
}