diff --git a/apps/web/components/AppShell.vue b/apps/web/components/AppShell.vue index 11cbd02..6022409 100644 --- a/apps/web/components/AppShell.vue +++ b/apps/web/components/AppShell.vue @@ -1,6 +1,6 @@ @@ -22,7 +21,9 @@ async function leave() { PRIVATE ALPHA / {{ section }} ⌂ - {{ initials }} + + {{ initials }} + D&G @@ -31,5 +32,5 @@ async function leave() { diff --git a/apps/web/composables/useDngAuth.ts b/apps/web/composables/useDngAuth.ts index 227dde5..080a4b0 100644 --- a/apps/web/composables/useDngAuth.ts +++ b/apps/web/composables/useDngAuth.ts @@ -211,6 +211,64 @@ export function useDngAuth() { return user } + async function updateUserMetadata(data: Record) { + const token = await accessToken() + const user = normalizeUser(await $fetch(`${config.public.supabaseUrl}/auth/v1/user`, { + method: 'PUT', + headers: authHeaders(token), + body: { data }, + })) + if (session.value) persist({ ...session.value, user }) + return user + } + + async function avatarStorageRequest(path: string, options: RequestInit) { + const token = await accessToken() + const baseUrl = String(config.public.supabaseUrl).replace(/\/$/, '') + const response = await fetch(`${baseUrl}/storage/v1/${path}`, { + ...options, + headers: { + apikey: config.public.supabaseAnonKey, + Authorization: `Bearer ${token}`, + ...options.headers, + }, + }) + if (!response.ok) { + const payload = await response.json().catch(() => null) as { message?: string; error?: string } | null + throw new Error(payload?.message || payload?.error || `Profile picture request failed (${response.status}).`) + } + } + + async function uploadAvatar(file: File) { + if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type)) { + throw new Error('Choose a JPG, PNG, or WebP image.') + } + if (file.size > 2 * 1024 * 1024) throw new Error('Profile pictures must be 2 MB or smaller.') + await restore() + if (!session.value) throw new Error('Sign in to update your profile picture.') + const path = `${session.value.user.id}/avatar` + const body = new FormData() + body.append('cacheControl', '3600') + body.append('', file) + await avatarStorageRequest(`object/profile-avatars/${path}`, { + method: 'POST', + headers: { 'x-upsert': 'true' }, + body, + }) + return path + } + + async function removeAvatar() { + await restore() + if (!session.value) throw new Error('Sign in to update your profile picture.') + const path = `${session.value.user.id}/avatar` + await avatarStorageRequest('object/profile-avatars', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prefixes: [path] }), + }) + } + async function accessToken() { await restore() if (!session.value) throw new Error('Enter the alpha to continue.') @@ -238,5 +296,20 @@ export function useDngAuth() { hydrated.value = true } - return { session, hydrated, restore, refresh, signUp, signIn, requestPasswordReset, updatePassword, accessToken, signOut, invalidate } + return { + session, + hydrated, + restore, + refresh, + signUp, + signIn, + requestPasswordReset, + updatePassword, + updateUserMetadata, + uploadAvatar, + removeAvatar, + accessToken, + signOut, + invalidate, + } } diff --git a/apps/web/pages/campaign/[id].vue b/apps/web/pages/campaign/[id].vue index 5cd6453..da97aca 100644 --- a/apps/web/pages/campaign/[id].vue +++ b/apps/web/pages/campaign/[id].vue @@ -7,7 +7,7 @@ type Controller = 'human' | 'ai' | 'delegated' interface CampaignPayload { campaign: Record world: Record - members: Array & { profile?: { display_name?: string } | null }> + members: Array & { profile?: { id?: string; display_name?: string; avatar_url?: string | null } | null }> characters: Array> round: Record | null rounds: Array> @@ -311,7 +311,7 @@ onBeforeUnmount(() => {