Add server-side avatar processing and responsive bottom nav
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
3170cfda18
commit
0bf6b96687
20 changed files with 608 additions and 147 deletions
|
|
@ -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.",
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
47
lib/profile/avatar-processing.ts
Normal file
47
lib/profile/avatar-processing.ts
Normal 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.",
|
||||
);
|
||||
}
|
||||
|
|
@ -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`;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
15
lib/supabase/admin.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue