Add server-side avatar processing and responsive bottom nav

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-04-19 22:06:41 +02:00
parent 3170cfda18
commit 0bf6b96687
20 changed files with 608 additions and 147 deletions

View file

@ -37,6 +37,11 @@ const settingsStatusToasts: Record<string, StatusToast> = {
title: "Instellingen opgeslagen",
message: "Je voorkeuren zijn bijgewerkt.",
},
"avatar-saved": {
variant: "success",
title: "Profielfoto opgeslagen",
message: "Je profiel gebruikt nu direct de nieuwe, verkleinde afbeelding.",
},
};
const settingsErrorToasts: Record<string, StatusToast> = {
@ -48,7 +53,8 @@ const settingsErrorToasts: Record<string, StatusToast> = {
"invalid-avatar-file": {
variant: "error",
title: "Profielfoto niet opgeslagen",
message: "Gebruik een JPG, PNG of WebP-bestand tot 2 MB en probeer het opnieuw.",
message:
"Gebruik een JPG, PNG of WebP-bestand. Grote foto's worden automatisch verkleind voordat ze worden opgeslagen.",
},
};

View file

@ -0,0 +1,47 @@
import "server-only";
import sharp from "sharp";
import {
PROFILE_AVATAR_MAX_DIMENSION,
PROFILE_AVATAR_STORED_MAX_BYTES,
} from "@/lib/profile/avatar";
const OUTPUT_QUALITY_STEPS = [82, 72, 64] as const;
const OUTPUT_CONTENT_TYPE = "image/webp";
export type ProcessedProfileAvatar = {
buffer: Buffer;
contentType: string;
};
export class ProfileAvatarProcessingError extends Error {}
export async function processProfileAvatar(
file: File,
): Promise<ProcessedProfileAvatar> {
const inputBuffer = Buffer.from(await file.arrayBuffer());
for (const quality of OUTPUT_QUALITY_STEPS) {
const outputBuffer = await sharp(inputBuffer)
.rotate()
.resize({
width: PROFILE_AVATAR_MAX_DIMENSION,
height: PROFILE_AVATAR_MAX_DIMENSION,
fit: "inside",
withoutEnlargement: true,
})
.webp({ quality })
.toBuffer();
if (outputBuffer.byteLength <= PROFILE_AVATAR_STORED_MAX_BYTES) {
return {
buffer: outputBuffer,
contentType: OUTPUT_CONTENT_TYPE,
};
}
}
throw new ProfileAvatarProcessingError(
"De profielfoto blijft te groot na verkleinen.",
);
}

View file

@ -5,7 +5,9 @@ const PROFILE_AVATAR_ALLOWED_MIME_TYPES = [
] as const;
export const PROFILE_AVATAR_BUCKET = "profile-avatars";
export const PROFILE_AVATAR_MAX_BYTES = 2 * 1024 * 1024;
export const PROFILE_AVATAR_UPLOAD_MAX_BYTES = 12 * 1024 * 1024;
export const PROFILE_AVATAR_STORED_MAX_BYTES = 2 * 1024 * 1024;
export const PROFILE_AVATAR_MAX_DIMENSION = 700;
export function getProfileAvatarPath(userId: string) {
return `${userId}/avatar`;

View file

@ -1,9 +1,11 @@
import { getAuthenticatedUser } from "@/lib/auth/session";
import { isSupportedOnboardingTimezone } from "@/lib/onboarding/options";
import { processProfileAvatar } from "@/lib/profile/avatar-processing";
import {
getProfileAvatarPath,
PROFILE_AVATAR_BUCKET,
} from "@/lib/profile/avatar";
import { createAdminClient } from "@/lib/supabase/admin";
import { createClient } from "@/lib/supabase/server";
import type {
OnboardingSubmission,
@ -71,7 +73,6 @@ const DEFAULT_TIMEZONE = "Europe/Amsterdam";
const SUPPORTED_LOCALES = new Set([DEFAULT_LOCALE]);
async function buildProfileRecord(
supabase: SupabaseServerClient,
row: ProfileRow,
): Promise<ProfileRecord> {
return {
@ -81,7 +82,7 @@ async function buildProfileRecord(
tagline: row.tagline,
bio: row.bio,
avatarPath: row.avatar_path,
avatarUrl: await getProfileAvatarUrl(supabase, row.avatar_path),
avatarUrl: await getProfileAvatarUrl(row.avatar_path),
locale: row.locale,
timezone: row.timezone,
onboardingSeen: row.onboarding_seen,
@ -164,14 +165,14 @@ function resolveTimezone(value: string) {
}
async function getProfileAvatarUrl(
supabase: SupabaseServerClient,
avatarPath: string | null,
) {
if (!avatarPath) {
return null;
}
const { data, error } = await supabase.storage
const adminSupabase = createAdminClient();
const { data, error } = await adminSupabase.storage
.from(PROFILE_AVATAR_BUCKET)
.createSignedUrl(avatarPath, 60 * 60);
@ -182,19 +183,39 @@ async function getProfileAvatarUrl(
return data.signedUrl;
}
async function getRequiredAuthenticatedUser(supabase: SupabaseServerClient) {
const {
data: { user },
error,
} = await supabase.auth.getUser();
if (error) {
throw new Error(`Gebruiker kon niet worden geladen: ${error.message}`);
}
if (!user) {
throw new Error("Er is geen ingelogde gebruiker beschikbaar.");
}
return {
id: user.id,
email: user.email ?? null,
};
}
async function uploadProfileAvatar(
supabase: SupabaseServerClient,
userId: string,
file: File,
) {
const avatarPath = getProfileAvatarPath(userId);
const fileBytes = await file.arrayBuffer();
const processedAvatar = await processProfileAvatar(file);
const adminSupabase = createAdminClient();
const { error } = await supabase.storage
const { error } = await adminSupabase.storage
.from(PROFILE_AVATAR_BUCKET)
.upload(avatarPath, fileBytes, {
.upload(avatarPath, processedAvatar.buffer, {
cacheControl: "3600",
contentType: file.type,
contentType: processedAvatar.contentType,
upsert: true,
});
@ -366,15 +387,9 @@ export async function completeOnboardingForCurrentUser(
export async function saveSettingsForCurrentUser(
submission: SettingsSubmission,
) {
const user = await getAuthenticatedUser();
if (!user) {
throw new Error("Er is geen ingelogde gebruiker beschikbaar.");
}
await ensureProfileBundleForCurrentUser();
const supabase = await createClient();
const user = await getRequiredAuthenticatedUser(supabase);
await ensureProfileBundleForCurrentUser();
const locale = normalizeLocale(submission.locale);
const timezone = resolveTimezone(submission.timezone);
const displayName = normalizeDisplayName(submission.displayName);
@ -385,7 +400,7 @@ export async function saveSettingsForCurrentUser(
submission.morningReminderEnabled,
);
const avatarPath = submission.avatarFile
? await uploadProfileAvatar(supabase, user.id, submission.avatarFile)
? await uploadProfileAvatar(user.id, submission.avatarFile)
: null;
const { error: profileError } = await supabase
@ -421,6 +436,26 @@ export async function saveSettingsForCurrentUser(
return ensureProfileBundleForCurrentUser();
}
export async function saveProfileAvatarForCurrentUser(file: File) {
const supabase = await createClient();
const user = await getRequiredAuthenticatedUser(supabase);
await ensureProfileBundleForCurrentUser();
const avatarPath = await uploadProfileAvatar(user.id, file);
const { error } = await supabase
.from("profiles")
.update({
avatar_path: avatarPath,
})
.eq("id", user.id);
if (error) {
throw new Error(`Profielfoto kon niet worden opgeslagen: ${error.message}`);
}
return ensureProfileBundleForCurrentUser();
}
export async function ensureProfileBundleForCurrentUser(): Promise<ProfileBundle | null> {
const user = await getAuthenticatedUser();
@ -457,7 +492,7 @@ export async function ensureProfileBundleForCurrentUser(): Promise<ProfileBundle
}
return {
profile: await buildProfileRecord(supabase, profileRow),
profile: await buildProfileRecord(profileRow),
settings: mapUserSettingsRow(userSettingsRow),
};
}

View file

@ -50,3 +50,8 @@ export type SettingsSubmission = {
reflectionReminderEnabled: boolean;
showEnergyPoints: boolean;
};
export type AvatarUploadActionState = {
status: "idle" | "success" | "error";
code?: string;
};

15
lib/supabase/admin.ts Normal file
View file

@ -0,0 +1,15 @@
import "server-only";
import { createClient } from "@supabase/supabase-js";
import { getSupabaseAdminEnv } from "@/lib/supabase/config";
export function createAdminClient() {
const { url, secretKey } = getSupabaseAdminEnv();
return createClient(url, secretKey, {
auth: {
persistSession: false,
autoRefreshToken: false,
},
});
}

View file

@ -20,3 +20,22 @@ export function getSupabaseEnv() {
publishableKey,
};
}
export function getSupabaseAdminEnv() {
const { url } = getSupabaseEnv();
const secretKey =
process.env.SUPABASE_SECRET_KEY ??
process.env.SUPABASE_SERVICE_ROLE_KEY ??
process.env.NEXT_PUBLIC_SUPABASE_SERVICE_KEY;
if (!secretKey) {
throw new Error(
"Supabase admin-configuratie ontbreekt. Voeg SUPABASE_SECRET_KEY toe voor server-only taken zoals avatar-uploads.",
);
}
return {
url,
secretKey,
};
}