feat(profile): user avatar upload endpoint, UserAvatar in header and profile, account home rework

This commit is contained in:
wdunn001 2026-08-20 23:32:27 -04:00
parent 927f9aae21
commit 5278bce000
5 changed files with 177 additions and 85 deletions

View file

@ -3,6 +3,12 @@
class="relative group/iconupload rounded-xl overflow-hidden w-20 mx-auto"
>
<img v-if="objectId" :src="useObject(objectId)" :alt="imageAlt" />
<div
v-else-if="fallbackName"
class="w-20 h-20 rounded-xl bg-zinc-800 flex items-center justify-center text-zinc-500 font-bold font-display text-2xl"
>
{{ fallbackName.charAt(0).toUpperCase() }}
</div>
<ArrowUpTrayIcon v-else />
<button
type="button"
@ -18,10 +24,11 @@
<script setup lang="ts">
import { ArrowUpTrayIcon } from "@heroicons/vue/24/solid";
const { objectId, openModal, hoverText, imageAlt } = defineProps<{
const { objectId, openModal, hoverText, imageAlt, fallbackName } = defineProps<{
objectId: string | null;
openModal: () => void;
hoverText: string;
imageAlt: string;
fallbackName?: string;
}>();
</script>

View file

@ -3,9 +3,14 @@
<MenuButton>
<UserHeaderWidget>
<div class="inline-flex items-center text-zinc-300 hover:text-white">
<img
:src="useObject(user.profilePictureObjectId)"
class="w-5 h-5 rounded-sm"
<UserAvatar
:avatar-url="
user.profilePictureObjectId
? useObject(user.profilePictureObjectId)
: null
"
:name="user.displayName"
:size-px="20"
/>
<span class="ml-2 text-sm font-bold">{{ user.displayName }}</span>
<ChevronDownIcon class="ml-3 h-4" />
@ -30,9 +35,14 @@
class="transition inline-flex items-center w-full py-3 px-4 hover:bg-zinc-800"
>
<div class="inline-flex items-center text-zinc-300">
<img
:src="useObject(user.profilePictureObjectId)"
class="w-5 h-5 rounded-sm"
<UserAvatar
:avatar-url="
user.profilePictureObjectId
? useObject(user.profilePictureObjectId)
: null
"
:name="user.displayName"
:size-px="20"
/>
<span class="ml-2 text-sm font-bold">{{ user.displayName }}</span>
</div>

View file

@ -1,89 +1,77 @@
<template>
<!-- go away eslint -->
<div />
<!-- I don't want to localize this -->
<!--
<div>
<div v-if="user" class="mx-auto max-w-2xl lg:mx-0">
<h2
class="mt-2 text-xl font-semibold tracking-tight text-zinc-100 sm:text-3xl"
>
Hello, {{ user.displayName }}!
</h2>
<p
class="mt-2 text-pretty text-sm font-medium text-zinc-400 sm:text-md/8"
>
Welcome to your Drop account. Here you can view and manage your account
information.
</p>
</div>
<div class="mx-auto max-w-3xl px-4 py-10">
<h1 class="text-2xl font-bold font-display text-zinc-100">
{{ $t("account.title") }}
</h1>
<div v-if="user" class="mt-8 grid grid-cols-1 gap-6 sm:grid-cols-2">
<div
class="overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900 shadow-sm transition-all duration-200 hover:shadow-lg hover:shadow-zinc-900/50"
>
<div class="p-6">
<h3 class="text-base font-semibold text-zinc-100">
Account Information
</h3>
<dl class="mt-4 space-y-4">
<div class="flex justify-between">
<dt class="text-sm font-medium text-zinc-400">Username</dt>
<dd class="text-sm text-zinc-100">{{ user.username }}</dd>
</div>
<div class="flex justify-between">
<dt class="text-sm font-medium text-zinc-400">Email</dt>
<dd class="text-sm text-zinc-100">{{ user.email }}</dd>
</div>
<div class="flex justify-between">
<dt class="text-sm font-medium text-zinc-400">Account Type</dt>
<dd>
<span
:class="[
'inline-flex items-center rounded-md px-2 py-1 text-xs font-medium ring-1 ring-inset',
user.admin
? 'bg-blue-400/10 text-blue-400 ring-blue-400/20'
: 'bg-zinc-400/10 text-zinc-400 ring-zinc-400/20',
]"
>
{{ user.admin ? "Administrator" : "Standard User" }}
</span>
</dd>
</div>
</dl>
<div
v-if="user"
class="mt-6 rounded-xl border border-zinc-800 bg-zinc-900 p-6 space-y-6"
>
<div class="flex items-center gap-x-4">
<ImageUpload
:object-id="avatarObjectId || null"
:open-modal="openAvatarModal"
:hover-text="$t('upload')"
:image-alt="user.displayName"
:fallback-name="user.displayName"
/>
<div class="flex flex-col">
<span class="text-zinc-100 font-semibold">{{ user.displayName }}</span>
<span class="text-zinc-400 text-sm">@{{ user.username }}</span>
</div>
</div>
<div
class="overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900 shadow-sm transition-all duration-200 hover:shadow-lg hover:shadow-zinc-900/50"
>
<div class="p-6">
<h3 class="text-base font-semibold text-zinc-100">Account Actions</h3>
<div class="mt-4 space-y-3">
<button
type="button"
class="w-full inline-flex items-center justify-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm transition-all duration-200 hover:bg-zinc-700 hover:scale-[1.02] hover:shadow-lg active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-600"
>
Change Password
</button>
<button
type="button"
class="w-full inline-flex items-center justify-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm transition-all duration-200 hover:bg-zinc-700 hover:scale-[1.02] hover:shadow-lg active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-600"
>
Update Email
</button>
</div>
<dl class="space-y-3">
<div class="flex justify-between gap-x-6">
<dt class="text-zinc-400 text-sm">{{ $t("auth.displayName") }}</dt>
<dd class="text-zinc-100 text-sm text-right">{{ user.displayName }}</dd>
</div>
<div class="flex justify-between gap-x-6">
<dt class="text-zinc-400 text-sm">{{ $t("auth.username") }}</dt>
<dd class="text-zinc-100 text-sm text-right">{{ user.username }}</dd>
</div>
<div class="flex justify-between gap-x-6">
<dt class="text-zinc-400 text-sm">{{ $t("auth.email") }}</dt>
<dd class="text-zinc-100 text-sm text-right">{{ user.email }}</dd>
</div>
</dl>
<div class="flex flex-wrap items-center gap-3">
<NuxtLink
:to="`/community/profile/${user.username}`"
class="inline-flex text-sm font-semibold text-blue-400 hover:text-blue-300"
>
{{ $t("userHeader.profile.communityProfile") }}
</NuxtLink>
<NuxtLink
to="/account/settings"
class="inline-flex text-sm font-semibold text-blue-400 hover:text-blue-300"
>
{{ $t("account.home.manageGameAccounts") }}
</NuxtLink>
<NuxtLink
to="/community/servers"
class="inline-flex text-sm font-semibold text-blue-400 hover:text-blue-300"
>
{{ $t("account.home.browseServers") }}
</NuxtLink>
</div>
</div>
<div v-else class="flex items-center justify-center min-h-[200px]">
<div class="text-zinc-400">Loading account information...</div>
</div>
<ModalUploadFile
v-model="uploadAvatarOpen"
endpoint="/api/v1/user/avatar"
accept="image/*"
@upload="updateAvatar"
/>
</div>
-->
</template>
<script setup lang="ts">
import type { UserModel } from "~/prisma/client/models";
definePageMeta({
layout: "default",
});
@ -91,4 +79,33 @@ definePageMeta({
useHead({
title: "Account",
});
const user = useUser();
const uploadAvatarOpen = ref(false);
const avatarObjectId = ref(user.value?.profilePictureObjectId || "");
watch(
() => user.value?.profilePictureObjectId,
(value) => {
avatarObjectId.value = value || "";
},
{ immediate: true },
);
const openAvatarModal = () => {
uploadAvatarOpen.value = true;
};
async function updateAvatar(response: { id: string }) {
avatarObjectId.value = response.id;
if (user.value) {
user.value = {
...(user.value as UserModel),
profilePictureObjectId: response.id,
};
}
await updateUser().catch(() => {});
}
</script>

View file

@ -1,10 +1,14 @@
<template>
<div class="max-w-6xl mx-auto px-4 py-10">
<div class="flex items-center gap-x-6">
<img
v-if="profile?.profilePictureObjectId"
:src="useObject(profile.profilePictureObjectId)"
class="w-24 h-24 rounded-md object-cover"
<UserAvatar
:avatar-url="
profile?.profilePictureObjectId
? useObject(profile.profilePictureObjectId)
: null
"
:name="profile?.displayName ?? profile?.username ?? $t('user.unknown')"
:size-px="96"
/>
<div>
<h1 class="text-2xl font-bold font-display text-zinc-100">

View file

@ -0,0 +1,54 @@
import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database";
import objectHandler from "~/server/internal/objects";
import { handleFileUpload } from "~/server/internal/utils/handlefileupload";
export default defineEventHandler(async (h3) => {
const userId = await aclManager.getUserIdACL(h3, ["object:update"]);
if (!userId) throw createError({ statusCode: 403 });
const result = await handleFileUpload(h3, {}, ["anonymous:read"], 1);
if (!result) {
throw createError({
statusCode: 400,
statusMessage: "File upload required (multipart form)",
});
}
const [ids, , pull, dump] = result;
const id = ids.at(0);
if (!id) {
dump();
throw createError({
statusCode: 400,
statusMessage: "Upload at least one file.",
});
}
const current = await prisma.user.findUnique({
where: { id: userId },
select: { profilePictureObjectId: true },
});
if (!current) {
dump();
throw createError({ statusCode: 404, statusMessage: "User not found." });
}
const { count } = await prisma.user.updateMany({
where: { id: userId },
data: { profilePictureObjectId: id },
});
if (count === 0) {
dump();
throw createError({ statusCode: 404, statusMessage: "User not found." });
}
await pull();
const previous = current.profilePictureObjectId?.trim();
if (previous && previous !== id) {
await objectHandler.deleteAsSystem(previous).catch(() => {});
}
return { id };
});