init
This commit is contained in:
@@ -0,0 +1,252 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
/* Web fonts loaded via <link> in app.html (terminal + brutalist mono vibe). */
|
||||
|
||||
/* ── Design tokens ─────────────────────────────────────────── */
|
||||
@theme {
|
||||
--color-void: #0a0a0b;
|
||||
--color-ash: #131316;
|
||||
--color-panel: #17171b;
|
||||
--color-smoke: #2a2a30;
|
||||
--color-ink: #e9e7e1;
|
||||
--color-mute: #8a8a93;
|
||||
|
||||
--color-acid: #c2f73f; /* toxic green */
|
||||
--color-blood: #ff1f6b; /* hot magenta */
|
||||
--color-cyber: #28e0e0; /* cyan */
|
||||
--color-bruise: #7a3cff; /* electric purple */
|
||||
|
||||
--font-display: 'Space Grotesk', system-ui, sans-serif;
|
||||
--font-mono: 'Space Mono', ui-monospace, monospace;
|
||||
--font-term: 'VT323', monospace;
|
||||
|
||||
--radius-none: 0px;
|
||||
}
|
||||
|
||||
/* ── Base ──────────────────────────────────────────────────── */
|
||||
@layer base {
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
background-color: var(--color-void);
|
||||
color: var(--color-ink);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100dvh;
|
||||
position: relative;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Film grain + scanlines layered over everything. */
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
opacity: 0.05;
|
||||
background-image: repeating-linear-gradient(
|
||||
0deg,
|
||||
rgba(255, 255, 255, 0.6) 0px,
|
||||
rgba(255, 255, 255, 0.6) 1px,
|
||||
transparent 1px,
|
||||
transparent 3px
|
||||
);
|
||||
mix-blend-mode: overlay;
|
||||
}
|
||||
|
||||
body::after {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 9998;
|
||||
opacity: 0.4;
|
||||
background:
|
||||
radial-gradient(circle at 20% 10%, rgba(122, 60, 255, 0.12), transparent 40%),
|
||||
radial-gradient(circle at 85% 80%, rgba(255, 31, 107, 0.1), transparent 45%);
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--color-acid);
|
||||
color: var(--color-void);
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
font-family: var(--font-display);
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-cyber);
|
||||
text-decoration: none;
|
||||
}
|
||||
a:hover {
|
||||
color: var(--color-acid);
|
||||
}
|
||||
|
||||
/* Brutalist scrollbar. */
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--color-void);
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--color-smoke);
|
||||
border: 1px solid var(--color-blood);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Components ────────────────────────────────────────────── */
|
||||
@layer components {
|
||||
/* Hard-edged panel with offset shadow — xerox/zine look. */
|
||||
.panel {
|
||||
background: var(--color-panel);
|
||||
border: 2px solid var(--color-smoke);
|
||||
box-shadow: 6px 6px 0 0 var(--color-void), 9px 9px 0 0 var(--color-blood);
|
||||
}
|
||||
|
||||
.panel-acid {
|
||||
box-shadow: 6px 6px 0 0 var(--color-void), 9px 9px 0 0 var(--color-acid);
|
||||
}
|
||||
|
||||
.label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.25em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-mute);
|
||||
}
|
||||
|
||||
.field {
|
||||
width: 100%;
|
||||
background: var(--color-void);
|
||||
border: 2px solid var(--color-smoke);
|
||||
color: var(--color-ink);
|
||||
padding: 0.7rem 0.8rem;
|
||||
font-family: var(--font-mono);
|
||||
outline: none;
|
||||
transition: border-color 0.1s ease, box-shadow 0.1s ease;
|
||||
}
|
||||
.field::placeholder {
|
||||
color: #55555e;
|
||||
}
|
||||
.field:focus {
|
||||
border-color: var(--color-acid);
|
||||
box-shadow: 0 0 0 1px var(--color-acid), 0 0 18px -6px var(--color-acid);
|
||||
}
|
||||
|
||||
/* Chunky brutalist button. */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 0.7rem 1.2rem;
|
||||
border: 2px solid var(--color-ink);
|
||||
background: var(--color-ink);
|
||||
color: var(--color-void);
|
||||
cursor: pointer;
|
||||
transition: transform 0.06s ease, box-shadow 0.06s ease, background 0.1s;
|
||||
box-shadow: 4px 4px 0 0 var(--color-blood);
|
||||
}
|
||||
.btn:hover {
|
||||
transform: translate(-2px, -2px);
|
||||
box-shadow: 6px 6px 0 0 var(--color-blood);
|
||||
}
|
||||
.btn:active {
|
||||
transform: translate(2px, 2px);
|
||||
box-shadow: 1px 1px 0 0 var(--color-blood);
|
||||
}
|
||||
.btn-acid {
|
||||
background: var(--color-acid);
|
||||
border-color: var(--color-acid);
|
||||
box-shadow: 4px 4px 0 0 var(--color-void);
|
||||
}
|
||||
.btn-acid:hover {
|
||||
box-shadow: 6px 6px 0 0 var(--color-void);
|
||||
}
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--color-ink);
|
||||
box-shadow: 4px 4px 0 0 var(--color-smoke);
|
||||
}
|
||||
.btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.tag {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.65rem;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border: 1px solid currentColor;
|
||||
}
|
||||
|
||||
/* Glitchy duplicated-layer heading. */
|
||||
.glitch {
|
||||
position: relative;
|
||||
color: var(--color-ink);
|
||||
}
|
||||
.glitch::before,
|
||||
.glitch::after {
|
||||
content: attr(data-text);
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.glitch::before {
|
||||
color: var(--color-blood);
|
||||
transform: translate(-2px, 0);
|
||||
mix-blend-mode: screen;
|
||||
clip-path: inset(0 0 55% 0);
|
||||
animation: glitch-x 3.5s infinite steps(2);
|
||||
}
|
||||
.glitch::after {
|
||||
color: var(--color-cyber);
|
||||
transform: translate(2px, 0);
|
||||
mix-blend-mode: screen;
|
||||
clip-path: inset(55% 0 0 0);
|
||||
animation: glitch-x 2.7s infinite steps(2) reverse;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes glitch-x {
|
||||
0%, 92%, 100% { transform: translate(0, 0); }
|
||||
93% { transform: translate(-3px, 1px); }
|
||||
96% { transform: translate(3px, -1px); }
|
||||
}
|
||||
|
||||
.marquee {
|
||||
white-space: nowrap;
|
||||
animation: marquee 22s linear infinite;
|
||||
}
|
||||
@keyframes marquee {
|
||||
from { transform: translateX(0); }
|
||||
to { transform: translateX(-50%); }
|
||||
}
|
||||
|
||||
.flicker {
|
||||
animation: flicker 4s infinite;
|
||||
}
|
||||
@keyframes flicker {
|
||||
0%, 100% { opacity: 1; }
|
||||
97% { opacity: 1; }
|
||||
98% { opacity: 0.4; }
|
||||
99% { opacity: 0.9; }
|
||||
}
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,19 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#0a0a0a" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&family=Space+Mono:ital,wght@0,400;0,700;1,400&family=VT323&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div class="contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,37 @@
|
||||
import { env } from '$env/dynamic/public';
|
||||
|
||||
const BASE = env.PUBLIC_API_BASE || 'http://localhost:8080';
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(path: string, opts: RequestInit = {}): Promise<T> {
|
||||
const res = await fetch(`${BASE}/api${path}`, {
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', ...(opts.headers ?? {}) },
|
||||
...opts
|
||||
});
|
||||
|
||||
if (res.status === 204) return undefined as T;
|
||||
|
||||
const text = await res.text();
|
||||
const data = text ? JSON.parse(text) : null;
|
||||
|
||||
if (!res.ok) {
|
||||
throw new ApiError(res.status, data?.error ?? res.statusText);
|
||||
}
|
||||
return data as T;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(p: string) => request<T>(p),
|
||||
post: <T>(p: string, body?: unknown) =>
|
||||
request<T>(p, { method: 'POST', body: body ? JSON.stringify(body) : undefined }),
|
||||
patch: <T>(p: string, body?: unknown) =>
|
||||
request<T>(p, { method: 'PATCH', body: body ? JSON.stringify(body) : undefined })
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import { api } from './api';
|
||||
|
||||
export type User = {
|
||||
id: string;
|
||||
email: string;
|
||||
display_name: string | null;
|
||||
email_verified: boolean;
|
||||
};
|
||||
|
||||
export type Settings = {
|
||||
locale: string;
|
||||
currency: string;
|
||||
theme: string;
|
||||
notify_email: boolean;
|
||||
};
|
||||
|
||||
type Me = { user: User; settings: Settings };
|
||||
|
||||
class AuthStore {
|
||||
user = $state<User | null>(null);
|
||||
settings = $state<Settings | null>(null);
|
||||
loaded = $state(false);
|
||||
|
||||
async refresh() {
|
||||
try {
|
||||
const me = await api.get<Me>('/auth/me');
|
||||
this.user = me.user;
|
||||
this.settings = me.settings;
|
||||
} catch {
|
||||
this.user = null;
|
||||
this.settings = null;
|
||||
} finally {
|
||||
this.loaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
set(me: Me) {
|
||||
this.user = me.user;
|
||||
this.settings = me.settings;
|
||||
this.loaded = true;
|
||||
}
|
||||
|
||||
async logout() {
|
||||
try {
|
||||
await api.post('/auth/logout');
|
||||
} finally {
|
||||
this.user = null;
|
||||
this.settings = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const auth = new AuthStore();
|
||||
@@ -0,0 +1,78 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { auth } from '$lib/auth.svelte';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
onMount(() => {
|
||||
auth.refresh();
|
||||
});
|
||||
|
||||
async function doLogout() {
|
||||
await auth.logout();
|
||||
goto('/login');
|
||||
}
|
||||
|
||||
const ticker =
|
||||
'BUY LESS · WANT MORE · TRACK THE DROP · NO IMPULSE · GRAB THE DEAL · ';
|
||||
</script>
|
||||
|
||||
<div class="min-h-dvh flex flex-col">
|
||||
<!-- Ticker strip -->
|
||||
<div class="bg-acid text-void overflow-hidden border-b-2 border-void">
|
||||
<div class="marquee py-1 font-mono text-xs font-bold tracking-widest">
|
||||
{ticker.repeat(6)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Header -->
|
||||
<header class="border-b-2 border-smoke">
|
||||
<div class="mx-auto flex max-w-5xl items-center justify-between px-4 py-4">
|
||||
<a href="/" class="group flex items-baseline gap-2">
|
||||
<span
|
||||
class="glitch flicker font-display text-2xl font-bold tracking-tighter text-ink"
|
||||
data-text="//WANTLIST"
|
||||
>
|
||||
//WANTLIST
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<nav class="flex items-center gap-2 text-sm">
|
||||
{#if auth.loaded && auth.user}
|
||||
<a href="/settings" class="tag border-smoke text-mute hover:text-acid">
|
||||
{auth.user.display_name ?? auth.user.email}
|
||||
</a>
|
||||
<button class="btn btn-ghost !px-3 !py-1 text-xs" onclick={doLogout}>
|
||||
logout
|
||||
</button>
|
||||
{:else if auth.loaded}
|
||||
{#if page.url.pathname !== '/login'}
|
||||
<a href="/login" class="tag border-smoke text-mute hover:text-acid">login</a>
|
||||
{/if}
|
||||
{#if page.url.pathname !== '/register'}
|
||||
<a href="/register" class="btn btn-acid !px-3 !py-1 text-xs">sign up</a>
|
||||
{/if}
|
||||
{/if}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Unverified banner -->
|
||||
{#if auth.loaded && auth.user && !auth.user.email_verified}
|
||||
<div class="border-b-2 border-blood bg-blood/10 px-4 py-2 text-center text-xs text-blood">
|
||||
⚠ email not verified — check your inbox. lost it?
|
||||
<a href="/settings" class="underline">resend from settings</a>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<main class="mx-auto w-full max-w-5xl flex-1 px-4 py-10">
|
||||
{@render children()}
|
||||
</main>
|
||||
|
||||
<footer class="border-t-2 border-smoke px-4 py-6 text-center">
|
||||
<p class="label">self-hosted · rust + sveltekit · phase 1</p>
|
||||
</footer>
|
||||
</div>
|
||||
@@ -0,0 +1,70 @@
|
||||
<script lang="ts">
|
||||
import { auth } from '$lib/auth.svelte';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>//WANTLIST</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if auth.loaded && auth.user}
|
||||
<!-- Logged-in placeholder dashboard -->
|
||||
<section class="space-y-6">
|
||||
<div>
|
||||
<p class="label">logged in as</p>
|
||||
<h1 class="font-display text-3xl font-bold">
|
||||
{auth.user.display_name ?? auth.user.email}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div class="panel panel-acid p-6">
|
||||
<p class="tag mb-3 inline-block border-acid text-acid">phase 2</p>
|
||||
<h2 class="mb-2 text-xl font-bold">your lists land here</h2>
|
||||
<p class="max-w-prose text-mute">
|
||||
topic-based wantlists (clothes, gear, whatever), item tracking, and pasted
|
||||
product URLs that get refetched for price drops. building it next.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-3">
|
||||
{#each [['LISTS', 'soon'], ['TRACKED URLS', 'soon'], ['DEAL ALERTS', 'soon']] as [k, v]}
|
||||
<div class="panel p-4">
|
||||
<p class="label">{k}</p>
|
||||
<p class="font-display text-2xl text-blood">{v}</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{:else}
|
||||
<!-- Marketing hero -->
|
||||
<section class="space-y-10">
|
||||
<div class="space-y-4">
|
||||
<p class="tag inline-block border-cyber text-cyber">self-hosted · rust core</p>
|
||||
<h1
|
||||
class="glitch font-display text-5xl font-bold leading-[0.95] sm:text-7xl"
|
||||
data-text="TRACK WHAT YOU WANT. STRIKE ON THE DROP."
|
||||
>
|
||||
TRACK WHAT YOU WANT. STRIKE ON THE DROP.
|
||||
</h1>
|
||||
<p class="max-w-xl text-lg text-mute">
|
||||
Topic-based shopping lists for the things you actually want. Paste a product
|
||||
URL, and get mailed the moment the price tanks. No feed. No algorithm. Your
|
||||
server, your rules.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<a href="/register" class="btn btn-acid">make an account →</a>
|
||||
<a href="/login" class="btn btn-ghost">log in</a>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-3">
|
||||
{#each [['01', 'LIST IT', 'group wants by topic'], ['02', 'PASTE URL', 'we watch the price'], ['03', 'GET MAILED', 'strike on the drop']] as [n, t, d]}
|
||||
<div class="panel p-5">
|
||||
<p class="font-term text-4xl text-blood">{n}</p>
|
||||
<p class="mt-1 font-display text-lg font-bold">{t}</p>
|
||||
<p class="text-sm text-mute">{d}</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
@@ -0,0 +1,50 @@
|
||||
<script lang="ts">
|
||||
import { api, ApiError } from '$lib/api';
|
||||
|
||||
let email = $state('');
|
||||
let done = $state(false);
|
||||
let error = $state('');
|
||||
let busy = $state(false);
|
||||
|
||||
async function submit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
error = '';
|
||||
busy = true;
|
||||
try {
|
||||
await api.post('/auth/request-password-reset', { email });
|
||||
done = true;
|
||||
} catch (err) {
|
||||
error = err instanceof ApiError ? err.message : 'something broke';
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>reset · //WANTLIST</title></svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-md">
|
||||
<div class="panel p-8">
|
||||
<p class="label">password reset</p>
|
||||
<h1 class="mb-6 font-display text-3xl font-bold">LOST THE KEY</h1>
|
||||
|
||||
{#if done}
|
||||
<p class="border-2 border-acid bg-acid/10 px-3 py-3 text-sm text-acid">
|
||||
if that email exists, a reset link is on its way. check your inbox.
|
||||
</p>
|
||||
{:else}
|
||||
<form class="space-y-4" onsubmit={submit}>
|
||||
<div>
|
||||
<label class="label" for="em">email</label>
|
||||
<input id="em" class="field mt-1" type="email" bind:value={email} required autocomplete="email" />
|
||||
</div>
|
||||
{#if error}
|
||||
<p class="border-2 border-blood bg-blood/10 px-3 py-2 text-sm text-blood">{error}</p>
|
||||
{/if}
|
||||
<button class="btn w-full" disabled={busy}>{busy ? 'sending…' : 'send reset link'}</button>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
<p class="mt-5 text-center text-sm text-mute"><a href="/login">back to login</a></p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,56 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { api, ApiError } from '$lib/api';
|
||||
import { auth } from '$lib/auth.svelte';
|
||||
|
||||
let email = $state('');
|
||||
let password = $state('');
|
||||
let error = $state('');
|
||||
let busy = $state(false);
|
||||
|
||||
async function submit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
error = '';
|
||||
busy = true;
|
||||
try {
|
||||
await api.post('/auth/login', { email, password });
|
||||
await auth.refresh();
|
||||
goto('/');
|
||||
} catch (err) {
|
||||
error = err instanceof ApiError ? err.message : 'something broke';
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>log in · //WANTLIST</title></svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-md">
|
||||
<div class="panel p-8">
|
||||
<p class="label">welcome back</p>
|
||||
<h1 class="mb-6 font-display text-3xl font-bold">RE-ENTER THE PIT</h1>
|
||||
|
||||
<form class="space-y-4" onsubmit={submit}>
|
||||
<div>
|
||||
<label class="label" for="em">email</label>
|
||||
<input id="em" class="field mt-1" type="email" bind:value={email} required autocomplete="email" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label" for="pw">password</label>
|
||||
<input id="pw" class="field mt-1" type="password" bind:value={password} required autocomplete="current-password" />
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<p class="border-2 border-blood bg-blood/10 px-3 py-2 text-sm text-blood">{error}</p>
|
||||
{/if}
|
||||
|
||||
<button class="btn w-full" disabled={busy}>{busy ? 'entering…' : 'log in'}</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-5 flex justify-between text-sm text-mute">
|
||||
<a href="/register">need an account?</a>
|
||||
<a href="/forgot">forgot password?</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,66 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { api, ApiError } from '$lib/api';
|
||||
import { auth } from '$lib/auth.svelte';
|
||||
|
||||
let email = $state('');
|
||||
let password = $state('');
|
||||
let displayName = $state('');
|
||||
let error = $state('');
|
||||
let busy = $state(false);
|
||||
|
||||
async function submit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
error = '';
|
||||
busy = true;
|
||||
try {
|
||||
await api.post('/auth/register', {
|
||||
email,
|
||||
password,
|
||||
display_name: displayName || null
|
||||
});
|
||||
await auth.refresh();
|
||||
goto('/');
|
||||
} catch (err) {
|
||||
error = err instanceof ApiError ? err.message : 'something broke';
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>sign up · //WANTLIST</title></svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-md">
|
||||
<div class="panel panel-acid p-8">
|
||||
<p class="label">new account</p>
|
||||
<h1 class="mb-6 font-display text-3xl font-bold">CARVE YOUR MARK</h1>
|
||||
|
||||
<form class="space-y-4" onsubmit={submit}>
|
||||
<div>
|
||||
<label class="label" for="dn">display name <span class="text-mute">(optional)</span></label>
|
||||
<input id="dn" class="field mt-1" bind:value={displayName} maxlength="80" autocomplete="nickname" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label" for="em">email</label>
|
||||
<input id="em" class="field mt-1" type="email" bind:value={email} required autocomplete="email" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label" for="pw">password <span class="text-mute">(min 10)</span></label>
|
||||
<input id="pw" class="field mt-1" type="password" bind:value={password} required minlength="10" autocomplete="new-password" />
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<p class="border-2 border-blood bg-blood/10 px-3 py-2 text-sm text-blood">{error}</p>
|
||||
{/if}
|
||||
|
||||
<button class="btn btn-acid w-full" disabled={busy}>
|
||||
{busy ? 'carving…' : 'sign up'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="mt-5 text-center text-sm text-mute">
|
||||
already have one? <a href="/login">log in</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,58 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { api, ApiError } from '$lib/api';
|
||||
|
||||
const token = $derived(page.url.searchParams.get('token') ?? '');
|
||||
|
||||
let password = $state('');
|
||||
let error = $state('');
|
||||
let busy = $state(false);
|
||||
let done = $state(false);
|
||||
|
||||
async function submit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
error = '';
|
||||
busy = true;
|
||||
try {
|
||||
await api.post('/auth/reset-password', { token, new_password: password });
|
||||
done = true;
|
||||
setTimeout(() => goto('/login'), 1500);
|
||||
} catch (err) {
|
||||
error = err instanceof ApiError ? err.message : 'something broke';
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>new password · //WANTLIST</title></svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-md">
|
||||
<div class="panel panel-acid p-8">
|
||||
<p class="label">set new password</p>
|
||||
<h1 class="mb-6 font-display text-3xl font-bold">CUT A NEW KEY</h1>
|
||||
|
||||
{#if !token}
|
||||
<p class="border-2 border-blood bg-blood/10 px-3 py-3 text-sm text-blood">
|
||||
no reset token in this link. request a fresh one.
|
||||
</p>
|
||||
<p class="mt-5 text-center text-sm text-mute"><a href="/forgot">request reset</a></p>
|
||||
{:else if done}
|
||||
<p class="border-2 border-acid bg-acid/10 px-3 py-3 text-sm text-acid">
|
||||
password changed. redirecting to login…
|
||||
</p>
|
||||
{:else}
|
||||
<form class="space-y-4" onsubmit={submit}>
|
||||
<div>
|
||||
<label class="label" for="pw">new password <span class="text-mute">(min 10)</span></label>
|
||||
<input id="pw" class="field mt-1" type="password" bind:value={password} required minlength="10" autocomplete="new-password" />
|
||||
</div>
|
||||
{#if error}
|
||||
<p class="border-2 border-blood bg-blood/10 px-3 py-2 text-sm text-blood">{error}</p>
|
||||
{/if}
|
||||
<button class="btn btn-acid w-full" disabled={busy}>{busy ? 'cutting…' : 'set password'}</button>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,137 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { api, ApiError } from '$lib/api';
|
||||
import { auth, type Settings } from '$lib/auth.svelte';
|
||||
|
||||
let displayName = $state('');
|
||||
let settings = $state<Settings>({ locale: 'de', currency: 'EUR', theme: 'breakcore', notify_email: true });
|
||||
|
||||
let msg = $state('');
|
||||
let error = $state('');
|
||||
let busy = $state(false);
|
||||
let resendMsg = $state('');
|
||||
|
||||
// Sync local form state once auth finishes loading.
|
||||
$effect(() => {
|
||||
if (auth.loaded && !auth.user) {
|
||||
goto('/login');
|
||||
}
|
||||
if (auth.user) {
|
||||
displayName = auth.user.display_name ?? '';
|
||||
}
|
||||
if (auth.settings) {
|
||||
settings = { ...auth.settings };
|
||||
}
|
||||
});
|
||||
|
||||
const themes = ['breakcore', 'grunge', 'minimal'];
|
||||
const locales = ['de', 'en'];
|
||||
|
||||
async function save(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
error = '';
|
||||
msg = '';
|
||||
busy = true;
|
||||
try {
|
||||
await api.patch('/profile', { display_name: displayName || null });
|
||||
await api.patch('/settings', settings);
|
||||
await auth.refresh();
|
||||
msg = 'saved.';
|
||||
} catch (err) {
|
||||
error = err instanceof ApiError ? err.message : 'save failed';
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function resend() {
|
||||
resendMsg = '';
|
||||
try {
|
||||
await api.post('/auth/resend-verification');
|
||||
resendMsg = 'sent — check your inbox.';
|
||||
} catch (err) {
|
||||
resendMsg = err instanceof ApiError ? err.message : 'failed';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>settings · //WANTLIST</title></svelte:head>
|
||||
|
||||
{#if auth.loaded && auth.user}
|
||||
<div class="mx-auto max-w-2xl space-y-6">
|
||||
<div>
|
||||
<p class="label">configuration</p>
|
||||
<h1 class="font-display text-4xl font-bold">CONTROL PANEL</h1>
|
||||
</div>
|
||||
|
||||
<!-- Verification status -->
|
||||
<div class="panel p-5">
|
||||
<p class="label">email</p>
|
||||
<div class="mt-1 flex flex-wrap items-center justify-between gap-3">
|
||||
<span class="font-mono">{auth.user.email}</span>
|
||||
{#if auth.user.email_verified}
|
||||
<span class="tag border-acid text-acid">verified</span>
|
||||
{:else}
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="tag border-blood text-blood">unverified</span>
|
||||
<button class="btn btn-ghost !px-3 !py-1 text-xs" onclick={resend}>resend</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if resendMsg}<p class="mt-2 text-xs text-mute">{resendMsg}</p>{/if}
|
||||
</div>
|
||||
|
||||
<form class="panel panel-acid space-y-6 p-6" onsubmit={save}>
|
||||
<div>
|
||||
<label class="label" for="dn">display name</label>
|
||||
<input id="dn" class="field mt-1" bind:value={displayName} maxlength="80" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="label" for="loc">language</label>
|
||||
<select id="loc" class="field mt-1" bind:value={settings.locale}>
|
||||
{#each locales as l}<option value={l}>{l.toUpperCase()}</option>{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label" for="cur">currency</label>
|
||||
<input id="cur" class="field mt-1 uppercase" bind:value={settings.currency} maxlength="3" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="label mb-2">theme</p>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
{#each themes as t}
|
||||
<button
|
||||
type="button"
|
||||
class="tag border-smoke py-2 text-center transition-colors"
|
||||
class:!border-acid={settings.theme === t}
|
||||
class:text-acid={settings.theme === t}
|
||||
onclick={() => (settings.theme = t)}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="flex cursor-pointer items-center gap-3">
|
||||
<input type="checkbox" class="size-5 accent-acid" bind:checked={settings.notify_email} />
|
||||
<span class="font-mono text-sm">email me about deals & price drops</span>
|
||||
</label>
|
||||
|
||||
{#if error}
|
||||
<p class="border-2 border-blood bg-blood/10 px-3 py-2 text-sm text-blood">{error}</p>
|
||||
{/if}
|
||||
{#if msg}
|
||||
<p class="border-2 border-acid bg-acid/10 px-3 py-2 text-sm text-acid">{msg}</p>
|
||||
{/if}
|
||||
|
||||
<button class="btn btn-acid" disabled={busy}>{busy ? 'saving…' : 'save changes'}</button>
|
||||
</form>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-center text-mute flicker">loading…</p>
|
||||
{/if}
|
||||
@@ -0,0 +1,47 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/state';
|
||||
import { api, ApiError } from '$lib/api';
|
||||
import { auth } from '$lib/auth.svelte';
|
||||
|
||||
type State = 'working' | 'ok' | 'error';
|
||||
let status = $state<State>('working');
|
||||
let message = $state('');
|
||||
|
||||
onMount(async () => {
|
||||
const token = page.url.searchParams.get('token');
|
||||
if (!token) {
|
||||
status = 'error';
|
||||
message = 'no verification token in this link.';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.post('/auth/verify', { token });
|
||||
await auth.refresh();
|
||||
status = 'ok';
|
||||
} catch (err) {
|
||||
status = 'error';
|
||||
message = err instanceof ApiError ? err.message : 'verification failed';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head><title>verify · //WANTLIST</title></svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-md">
|
||||
<div class="panel p-8 text-center">
|
||||
<p class="label">email verification</p>
|
||||
|
||||
{#if status === 'working'}
|
||||
<h1 class="mt-4 font-display text-3xl font-bold flicker">VERIFYING…</h1>
|
||||
{:else if status === 'ok'}
|
||||
<h1 class="mt-4 font-display text-3xl font-bold text-acid">VERIFIED ✓</h1>
|
||||
<p class="mt-3 text-mute">your email is confirmed. you're all set.</p>
|
||||
<a href="/" class="btn btn-acid mt-6">enter →</a>
|
||||
{:else}
|
||||
<h1 class="mt-4 font-display text-3xl font-bold text-blood">FAILED ✗</h1>
|
||||
<p class="mt-3 text-mute">{message}</p>
|
||||
<a href="/" class="btn btn-ghost mt-6">go home</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user