This commit is contained in:
149
src/lib/components/AuthForm.svelte
Normal file
149
src/lib/components/AuthForm.svelte
Normal file
@@ -0,0 +1,149 @@
|
||||
<script lang="ts">
|
||||
import { authClient } from '$lib/auth-client';
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
let {
|
||||
mode,
|
||||
providers,
|
||||
returnTo,
|
||||
oauthError
|
||||
}: {
|
||||
mode: 'login' | 'register';
|
||||
providers: { google: boolean; osu: boolean };
|
||||
returnTo: string;
|
||||
oauthError: boolean;
|
||||
} = $props();
|
||||
let name = $state('');
|
||||
let email = $state('');
|
||||
let password = $state('');
|
||||
let busy = $state(false);
|
||||
let ready = $state(false);
|
||||
onMount(() => {
|
||||
ready = true;
|
||||
});
|
||||
let message = $state('');
|
||||
async function submit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
const destination = `${resolve('/')}${returnTo.slice(1)}`;
|
||||
busy = true;
|
||||
message = '';
|
||||
try {
|
||||
const result =
|
||||
mode === 'register'
|
||||
? await authClient.signUp.email({ name: name.trim(), email, password })
|
||||
: await authClient.signIn.email({ email, password });
|
||||
if (result.error) {
|
||||
message = result.error.message || 'Unable to sign in. Please try again.';
|
||||
return;
|
||||
}
|
||||
// Destination is a validated local path, prefixed by resolve('/') above.
|
||||
// eslint-disable-next-line svelte/no-navigation-without-resolve
|
||||
await goto(destination, { invalidateAll: true });
|
||||
} catch {
|
||||
message = 'Unable to connect. Please try again.';
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
async function social(provider: 'google' | 'osu') {
|
||||
busy = true;
|
||||
message = '';
|
||||
try {
|
||||
const result = await authClient.signIn.social({
|
||||
provider,
|
||||
callbackURL: returnTo,
|
||||
errorCallbackURL: `/login?returnTo=${encodeURIComponent(returnTo)}`
|
||||
});
|
||||
if (result.error) {
|
||||
message = result.error.message || 'Unable to connect to this provider.';
|
||||
busy = false;
|
||||
}
|
||||
} catch {
|
||||
message = 'Unable to connect. Please try again.';
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="mx-auto my-12 max-w-md px-4 sm:my-20">
|
||||
<h1 class="mb-8 text-3xl font-semibold tracking-tight">
|
||||
{mode === 'register' ? 'create account' : 'sign in'}
|
||||
</h1>
|
||||
{#if message || oauthError}
|
||||
<p role="alert" class="mb-4 rounded bg-red-50 p-3 text-red-800">
|
||||
{message ||
|
||||
'Provider sign-in failed or was cancelled. Try again, or use your existing login and link the provider from your account.'}
|
||||
</p>
|
||||
{/if}
|
||||
<form method="POST" onsubmit={submit} class="flex flex-col gap-4">
|
||||
{#if mode === 'register'}
|
||||
<label class="flex flex-col gap-1"
|
||||
>Name<input
|
||||
class="rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2"
|
||||
disabled={!ready || busy}
|
||||
name="name"
|
||||
autocomplete="name"
|
||||
required
|
||||
maxlength="100"
|
||||
bind:value={name}
|
||||
/></label
|
||||
>
|
||||
{/if}
|
||||
<label class="flex flex-col gap-1"
|
||||
>Email<input
|
||||
class="rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2"
|
||||
disabled={!ready || busy}
|
||||
name="email"
|
||||
type="email"
|
||||
autocomplete="email"
|
||||
required
|
||||
bind:value={email}
|
||||
/></label
|
||||
>
|
||||
<label class="flex flex-col gap-1"
|
||||
>Password<input
|
||||
class="rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2"
|
||||
disabled={!ready || busy}
|
||||
name="password"
|
||||
type="password"
|
||||
autocomplete={mode === 'register' ? 'new-password' : 'current-password'}
|
||||
required
|
||||
minlength={mode === 'register' ? 12 : undefined}
|
||||
maxlength="128"
|
||||
bind:value={password}
|
||||
/></label
|
||||
>
|
||||
{#if mode === 'register'}<p class="text-sm text-gray-600">Use at least 12 characters.</p>{/if}
|
||||
<button
|
||||
disabled={!ready || busy}
|
||||
class="rounded bg-[var(--site-text)] px-4 py-2 font-semibold text-[var(--site-bg)] disabled:opacity-50"
|
||||
>{busy ? 'please wait…' : mode === 'register' ? 'create account' : 'sign in'}</button
|
||||
>
|
||||
</form>
|
||||
{#if providers.google || providers.osu}
|
||||
<div class="my-6 flex flex-col gap-3 border-t border-[var(--site-border)] pt-4">
|
||||
{#if providers.google}<button
|
||||
disabled={!ready || busy}
|
||||
onclick={() => social('google')}
|
||||
class="rounded border border-[var(--site-border)] px-4 py-2 disabled:opacity-50"
|
||||
>Continue with Google</button
|
||||
>{/if}
|
||||
{#if providers.osu}<button
|
||||
disabled={!ready || busy}
|
||||
onclick={() => social('osu')}
|
||||
class="rounded border border-[var(--site-border)] px-4 py-2 disabled:opacity-50"
|
||||
>Continue with osu!</button
|
||||
>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<p class="mt-6 text-base">
|
||||
<a
|
||||
class="underline underline-offset-4"
|
||||
href={resolve(
|
||||
`${mode === 'register' ? '/login' : '/register'}?returnTo=${encodeURIComponent(returnTo)}`
|
||||
)}>{mode === 'register' ? 'sign in' : 'Create account'}</a
|
||||
>
|
||||
</p>
|
||||
<noscript><p class="mt-4">Enable JavaScript to securely sign in or register.</p></noscript>
|
||||
</section>
|
||||
@@ -33,7 +33,7 @@
|
||||
<button
|
||||
onclick={copy}
|
||||
aria-label={`Copy ${label}: ${value}`}
|
||||
class="text-2xl transition hover:opacity-60 cursor-pointer"
|
||||
class="flex h-10 w-10 cursor-pointer items-center justify-center text-xl transition hover:text-[var(--site-text)]"
|
||||
>
|
||||
<Icon />
|
||||
</button>
|
||||
|
||||
64
src/lib/components/ListPictureDropzone.svelte
Normal file
64
src/lib/components/ListPictureDropzone.svelte
Normal file
@@ -0,0 +1,64 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let { label = 'Picture (optional)' }: { label?: string } = $props();
|
||||
let input: HTMLInputElement;
|
||||
let fileName = $state('');
|
||||
let dragging = $state(false);
|
||||
|
||||
function selected() {
|
||||
fileName = input.files?.[0]?.name ?? '';
|
||||
}
|
||||
|
||||
function drop(event: DragEvent) {
|
||||
event.preventDefault();
|
||||
dragging = false;
|
||||
const file = event.dataTransfer?.files?.[0];
|
||||
if (!file) return;
|
||||
const transfer = new DataTransfer();
|
||||
transfer.items.add(file);
|
||||
input.files = transfer.files;
|
||||
selected();
|
||||
}
|
||||
|
||||
function clear() {
|
||||
input.value = '';
|
||||
fileName = '';
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const form = input.form;
|
||||
form?.addEventListener('reset', clear);
|
||||
return () => form?.removeEventListener('reset', clear);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<span class="block font-medium">{label}</span>
|
||||
<input
|
||||
bind:this={input}
|
||||
name="picture"
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
onchange={selected}
|
||||
class="sr-only"
|
||||
aria-label={label}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => input.click()}
|
||||
ondragover={(event) => {
|
||||
event.preventDefault();
|
||||
dragging = true;
|
||||
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy';
|
||||
}}
|
||||
ondragleave={() => (dragging = false)}
|
||||
ondrop={drop}
|
||||
class={`mt-2 block w-full rounded border border-dashed p-5 text-center transition-colors ${dragging ? 'border-[var(--site-text)] bg-[var(--site-hover)]' : 'border-[var(--site-border)] hover:border-[var(--site-muted)]'}`}
|
||||
>
|
||||
{fileName ? `Selected: ${fileName}` : 'Drop an image here or click to browse'}
|
||||
</button>
|
||||
<p class="mt-2 text-base text-[var(--site-muted)]">JPEG, PNG, or WebP · up to 5 MB</p>
|
||||
{#if fileName}<button type="button" onclick={clear} class="mt-2 underline">Clear selection</button
|
||||
>{/if}
|
||||
</div>
|
||||
43
src/lib/components/ProgressBar.svelte
Normal file
43
src/lib/components/ProgressBar.svelte
Normal file
@@ -0,0 +1,43 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
value,
|
||||
max,
|
||||
label,
|
||||
small = false
|
||||
}: {
|
||||
value: number;
|
||||
max: number;
|
||||
label: string;
|
||||
small?: boolean;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<progress class:small {value} max={max || 1} aria-label={label}></progress>
|
||||
|
||||
<style>
|
||||
progress {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 0.625rem;
|
||||
appearance: none;
|
||||
border: 0;
|
||||
border-radius: 9999px;
|
||||
overflow: hidden;
|
||||
background: var(--site-border);
|
||||
}
|
||||
progress.small {
|
||||
height: 0.375rem;
|
||||
}
|
||||
progress::-webkit-progress-bar {
|
||||
background: var(--site-border);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
progress::-webkit-progress-value {
|
||||
background: var(--site-text);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
progress::-moz-progress-bar {
|
||||
background: var(--site-text);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user