<script module lang="ts">
import { tv, type VariantProps } from "tailwind-variants";
export const avatarPicker = tv({
slots: {
label: "flex size-40 cursor-pointer flex-col items-center justify-center gap-2xs overflow-hidden rounded-full border border-border-default text-foreground-subtle transition-colors focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-border-focus hover:bg-background-subtle",
image: "size-full rounded-full object-cover",
placeholder: "flex flex-col items-center justify-center gap-2xs"
}
});
export type AvatarPickerVariants = VariantProps<typeof avatarPicker>;
export interface ProfileDraft {
description: string;
pronouns: string;
website: string;
avatarFile: File | null;
avatarPreview?: string;
}
</script>
<script lang="ts">
import { prefersReducedMotion } from "svelte/motion";
import Camera from "$icon/camera";
import { PROFILE_LIMITS } from "#lib/api/profile.ts";
import Field from "#lib/components/ui/Field.svelte";
import Input from "#lib/components/ui/Input.svelte";
import StepHeader from "#lib/components/welcome/StepHeader.svelte";
let { draft, websiteError }: { draft: ProfileDraft; websiteError?: string } = $props();
const slot = avatarPicker();
const acceptedTypes: Record<string, true> = { "image/png": true, "image/jpeg": true };
const uid = $props.id();
const pictureErrorId = `${uid}-picture-error`;
const websiteErrorId = `${uid}-website-error`;
let pictureError = $state<string>();
const pick = (event: Event) => {
const file = (event.currentTarget as HTMLInputElement).files?.[0];
if (!file) return;
if (!acceptedTypes[file.type]) {
pictureError = "Choose a PNG or JPEG image.";
return;
}
if (file.size > 1_000_000) {
pictureError = "Profile pictures must be 1 MB or smaller.";
return;
}
if (draft.avatarPreview) URL.revokeObjectURL(draft.avatarPreview);
draft.avatarFile = file;
draft.avatarPreview = URL.createObjectURL(file);
pictureError = undefined;
};
</script>
<div class="flex flex-col gap-xl">
<StepHeader title="Welcome!" subtitle="Let's set up your profile. All fields are optional." />
<div class="flex flex-col gap-xl md:flex-row">
<div class="flex shrink-0 justify-center md:block">
<label class={slot.label()}>
{#if draft.avatarPreview}
<img
src={draft.avatarPreview}
alt="Your new avatar"
class={slot.image()}
class:avatar-preview-enter={!prefersReducedMotion.current}
/>
{:else}
<span class={slot.placeholder()}>
<Camera class="size-6" aria-hidden="true" />
<span class="typography-paragraph-small">Upload picture</span>
</span>
{/if}
<input
data-testid="welcomeAvatarInput"
type="file"
accept="image/png,image/jpeg"
class="sr-only"
aria-describedby={pictureErrorId}
aria-invalid={pictureError ? "true" : undefined}
onchange={pick}
/>
</label>
<p
id={pictureErrorId}
role="status"
class="mt-xs text-center typography-paragraph-small {pictureError
? 'text-foreground-danger'
: 'text-foreground-subtle'}"
>
{pictureError ?? "PNG or JPEG, up to 1 MB."}
</p>
</div>
<div class="flex min-w-0 flex-1 flex-col gap-md">
<Field label="Bio" labelCase="normal">
{#snippet children({ id })}
<Input
{id}
placeholder="Tell us about yourself"
maxlength={PROFILE_LIMITS.description}
bind:value={draft.description}
/>
{/snippet}
</Field>
<Field label="Pronouns" labelCase="normal">
{#snippet children({ id })}
<Input
{id}
placeholder="they/them"
maxlength={PROFILE_LIMITS.pronouns}
bind:value={draft.pronouns}
/>
{/snippet}
</Field>
<Field label="Website" labelCase="normal">
{#snippet children({ id })}
<Input
{id}
placeholder="example.com"
error={!!websiteError}
aria-describedby={websiteError ? websiteErrorId : undefined}
bind:value={draft.website}
/>
{#if websiteError}
<p
id={websiteErrorId}
class="typography-paragraph-small text-foreground-danger"
>
{websiteError}
</p>
{/if}
{/snippet}
</Field>
</div>
</div>
</div>
<style>
.avatar-preview-enter {
transition:
opacity 220ms cubic-bezier(0.23, 1, 0.32, 1),
scale 220ms cubic-bezier(0.23, 1, 0.32, 1);
@starting-style {
opacity: 0;
scale: 0.94;
}
}
</style>