Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "users" ALTER COLUMN "avatar_url" DROP NOT NULL;
2 changes: 1 addition & 1 deletion prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ model User {
name String @map("name") @db.VarChar(255)
email String @unique @map("email")
googleProviderId String @unique @map("google_provider_id")
avatarURL String @map("avatar_url")
avatarURL String? @map("avatar_url")

// Relations.
userProfile UserProfile?
Expand Down
20 changes: 20 additions & 0 deletions src/lib/components/Avatar/Avatar.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<script lang="ts">
export interface Props {
src: string | null;
name: string;
}

const { src, name }: Props = $props();
const initial = $derived(name.trim().charAt(0).toUpperCase());
</script>

{#if src}
<img {src} alt="profile" class="h-full w-full object-cover" />
{:else}
<span
aria-label="profile"
class="flex h-full w-full items-center justify-center bg-slate-700 text-sm font-semibold text-white"
>
{initial}
</span>
{/if}
40 changes: 40 additions & 0 deletions src/lib/components/Avatar/Avatar.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { render, screen } from '@testing-library/svelte';
import { describe, expect, test } from 'vitest';

import { Avatar } from './index.js';

describe('Avatar', () => {
test('renders img with the given src when src is provided', () => {
const props = { src: 'data:image/png;base64,abc', name: 'Alice' };

render(Avatar, { props });

const img = screen.getByRole('img');
expect(img).toHaveAttribute('src', 'data:image/png;base64,abc');
});

test('renders initials chip with first letter of name when src is null', () => {
const props = { src: null, name: 'Alice' };

render(Avatar, { props });

expect(screen.queryByRole('img')).not.toBeInTheDocument();
expect(screen.getByText('A')).toBeInTheDocument();
});

test('uppercases the initial', () => {
const props = { src: null, name: 'alice' };

render(Avatar, { props });

expect(screen.getByText('A')).toBeInTheDocument();
});

test('trims leading whitespace before taking the initial', () => {
const props = { src: null, name: ' alice' };

render(Avatar, { props });

expect(screen.getByText('A')).toBeInTheDocument();
});
});
1 change: 1 addition & 0 deletions src/lib/components/Avatar/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as Avatar, type Props as AvatarProps } from './Avatar.svelte';
10 changes: 5 additions & 5 deletions src/lib/server/auth/google.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@ export interface GoogleProfile {
*/
name: string;
/**
* A URL to the picture of the Google profile.
* A URL to the picture of the Google profile, or null if Google did not provide a `picture` claim.
*/
picture: string;
picture: string | null;
}

/**
Expand Down Expand Up @@ -148,8 +148,8 @@ export async function verifyIdToken(idToken: string): Promise<GoogleProfile> {
throw new InvalidIdTokenError('Google ID token payload missing');
}
const { sub, email, name, picture } = payload;
if (!sub || !email || !name || !picture) {
const missing = !sub ? 'sub' : !email ? 'email' : !name ? 'name' : 'picture';
if (!sub || !email || !name) {
const missing = !sub ? 'sub' : !email ? 'email' : 'name';
throw new InvalidIdTokenError(`Google ID token missing claim: ${missing}`);
}

Expand All @@ -159,7 +159,7 @@ export async function verifyIdToken(idToken: string): Promise<GoogleProfile> {
);
}

return { id: sub, email, name, picture };
return { id: sub, email, name, picture: picture ?? null };
}

/**
Expand Down
4 changes: 2 additions & 2 deletions src/lib/server/cache/avatar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ const AVATAR_TTL = 24 * 60 * 60;
* for direct use as the `src` of an `<img>` element.
*
* @param userId - The ID of the user whose avatar should be retrieved.
* @returns The base64-encoded avatar, or `null` if the user is not found.
* @returns The base64-encoded avatar, or `null` if the user has no avatar URL or is not found.
*/
export async function getBase64EncodedAvatar(userId: string): Promise<string | null> {
let avatar = await valkey.get(`${AVATAR_NAMESPACE}:${userId}`);
Expand All @@ -34,7 +34,7 @@ export async function getBase64EncodedAvatar(userId: string): Promise<string | n
id: userId,
},
});
if (!user) {
if (!user || user.avatarURL === null) {
return null;
}

Expand Down
3 changes: 2 additions & 1 deletion src/routes/(main)/(protected)/(core)/+layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import type { MouseEventHandler } from 'svelte/elements';

import { page } from '$app/state';
import { Avatar } from '$lib/components/Avatar/index.js';
import { trackProfileClick } from '$lib/helpers/analytics.js';
import { HOME_PATH, IsWithinViewport } from '$lib/helpers/index.js';

Expand Down Expand Up @@ -42,7 +43,7 @@
class="h-10 w-10 cursor-pointer overflow-hidden rounded-full"
onclick={handleProfileClick}
>
<img src={data.avatar} alt="profile" />
<Avatar src={data.avatar} name={data.username} />
</a>
</div>

Expand Down
3 changes: 2 additions & 1 deletion src/routes/(main)/(protected)/profile/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { ArrowLeft, BookOpenCheck, Lightbulb } from '@lucide/svelte';

import { afterNavigate } from '$app/navigation';
import { Avatar } from '$lib/components/Avatar/index.js';
import { HOME_PATH, IsWithinViewport } from '$lib/helpers/index.js';

const { data } = $props();
Expand Down Expand Up @@ -81,7 +82,7 @@
<main class="relative mx-auto flex min-h-svh max-w-5xl flex-col gap-y-4 px-4 py-3 pt-23">
<div class="flex items-center gap-x-6 rounded-3xl bg-white p-4">
<div class="h-10 w-10 overflow-hidden rounded-full">
<img src={data.avatar} alt="profile" />
<Avatar src={data.avatar} name={data.name} />
</div>

<div class="flex flex-col">
Expand Down
Loading