~james/core

core/web/src/lib/components/welcome/ProfileStep.svelte -rw-r--r-- 4.4 KiB
452f17b7 — james chore(import): snapshot james.dad.town/core at f70c71c7696a 16 hours ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
<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>