This commit is contained in:
2026-06-17 10:59:45 +02:00
parent 408e48c568
commit a2ccec4bb1
35 changed files with 2514 additions and 257 deletions
+165 -89
View File
@@ -1,26 +1,30 @@
@import 'tailwindcss';
@import "tailwindcss";
/* Web fonts loaded via <link> in app.html (terminal + brutalist mono vibe). */
/* Web fonts loaded via <link> in app.html (ethereal serif + clean sans + mono). */
/* ── Design tokens ─────────────────────────────────────────── */
@theme {
--color-void: #0a0a0b;
--color-ash: #131316;
--color-panel: #17171b;
--color-smoke: #2a2a30;
--color-ink: #e9e7e1;
--color-mute: #8a8a93;
/* Twilight base — dark but dreamy, never pitch black. */
--color-void: #0c0a14;
--color-ash: #14111f;
--color-panel: #181425;
--color-smoke: #2e2740;
--color-ink: #f3eefb;
--color-mute: #9a90b5;
--color-acid: #c2f73f; /* toxic green */
--color-blood: #ff1f6b; /* hot magenta */
--color-cyber: #28e0e0; /* cyan */
--color-bruise: #7a3cff; /* electric purple */
/* Ethereal pastels — heaven's clearance sale. */
--color-iris: #b9a7ff; /* lavender-violet (primary) */
--color-rose: #ffaecb; /* rose quartz */
--color-mint: #9af7d8; /* aqua halo */
--color-gold: #ffe6a3; /* divine gilt */
--color-holo: #cbd6ff; /* holographic sheen */
--font-display: 'Space Grotesk', system-ui, sans-serif;
--font-mono: 'Space Mono', ui-monospace, monospace;
--font-term: 'VT323', monospace;
--font-display: "Space Grotesk", system-ui, sans-serif;
--font-gospel: "Fraunces", "Times New Roman", serif;
--font-mono: "Space Mono", ui-monospace, monospace;
--radius-none: 0px;
--radius-soft: 0.625rem; /* one radius language for panels/fields/buttons */
}
/* ── Base ──────────────────────────────────────────────────── */
@@ -42,38 +46,40 @@
overflow-x: hidden;
}
/* Film grain + scanlines layered over everything. */
/* Soft drifting aurora — celestial light pollution. */
body::before {
content: '';
content: "";
position: fixed;
inset: 0;
inset: -20%;
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;
z-index: -1;
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%);
radial-gradient(
40% 35% at 18% 12%,
rgba(185, 167, 255, 0.18),
transparent 70%
),
radial-gradient(
38% 40% at 85% 20%,
rgba(255, 174, 203, 0.14),
transparent 70%
),
radial-gradient(
45% 45% at 70% 88%,
rgba(154, 247, 216, 0.12),
transparent 70%
),
radial-gradient(
50% 50% at 30% 80%,
rgba(203, 214, 255, 0.1),
transparent 70%
);
filter: blur(10px);
animation: drift 26s ease-in-out infinite alternate;
}
::selection {
background: var(--color-acid);
background: var(--color-iris);
color: var(--color-void);
}
@@ -85,14 +91,15 @@
}
a {
color: var(--color-cyber);
color: var(--color-iris);
text-decoration: none;
transition: color 0.12s ease;
}
a:hover {
color: var(--color-acid);
color: var(--color-rose);
}
/* Brutalist scrollbar. */
/* Soft scrollbar with a pastel edge. */
::-webkit-scrollbar {
width: 10px;
}
@@ -101,21 +108,29 @@
}
::-webkit-scrollbar-thumb {
background: var(--color-smoke);
border: 1px solid var(--color-blood);
border: 1px solid var(--color-iris);
}
}
/* ── Components ────────────────────────────────────────────── */
@layer components {
/* Hard-edged panel with offset shadow — xerox/zine look. */
/* Panel: glassy twilight slab with a pastel halo glow + faint offset. */
.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);
position: relative;
border-radius: var(--radius-soft);
background:
linear-gradient(160deg, rgba(255, 255, 255, 0.03), transparent 60%),
var(--color-panel);
border: 1px solid var(--color-smoke);
box-shadow:
0 0 0 1px rgba(185, 167, 255, 0.06),
0 18px 50px -24px rgba(185, 167, 255, 0.45);
}
.panel-acid {
box-shadow: 6px 6px 0 0 var(--color-void), 9px 9px 0 0 var(--color-acid);
box-shadow:
0 0 0 1px rgba(154, 247, 216, 0.12),
0 18px 50px -22px rgba(154, 247, 216, 0.45);
}
.label {
@@ -126,25 +141,46 @@
color: var(--color-mute);
}
/* Ethereal gilt serif italic — the consumerist gospel voice. */
.gospel {
font-family: var(--font-gospel);
font-style: italic;
font-weight: 300;
background: linear-gradient(
100deg,
var(--color-gold),
var(--color-rose),
var(--color-iris)
);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.field {
width: 100%;
background: var(--color-void);
border: 2px solid var(--color-smoke);
border-radius: var(--radius-soft);
background: rgba(12, 10, 20, 0.7);
border: 1px 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;
transition:
border-color 0.12s ease,
box-shadow 0.12s ease;
}
.field::placeholder {
color: #55555e;
color: #5d5474;
}
.field:focus {
border-color: var(--color-acid);
box-shadow: 0 0 0 1px var(--color-acid), 0 0 18px -6px var(--color-acid);
border-color: var(--color-iris);
box-shadow:
0 0 0 1px var(--color-iris),
0 0 24px -6px var(--color-iris);
}
/* Chunky brutalist button. */
/* Button: soft glow, gentle lift. Halo instead of hard shadow. */
.btn {
display: inline-flex;
align-items: center;
@@ -155,33 +191,45 @@
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.7rem 1.2rem;
border: 2px solid var(--color-ink);
border-radius: var(--radius-soft);
border: 1px 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);
transition:
transform 0.1s ease,
box-shadow 0.15s ease,
filter 0.15s;
box-shadow: 0 8px 24px -10px rgba(255, 174, 203, 0.7);
}
.btn:hover {
transform: translate(-2px, -2px);
box-shadow: 6px 6px 0 0 var(--color-blood);
transform: translateY(-2px);
box-shadow: 0 14px 34px -10px rgba(255, 174, 203, 0.85);
}
.btn:active {
transform: translate(2px, 2px);
box-shadow: 1px 1px 0 0 var(--color-blood);
transform: translateY(0);
box-shadow: 0 6px 16px -10px rgba(255, 174, 203, 0.7);
}
/* Primary "ascend" button — holographic gradient. */
.btn-acid {
background: var(--color-acid);
border-color: var(--color-acid);
box-shadow: 4px 4px 0 0 var(--color-void);
border: none;
color: var(--color-void);
background: linear-gradient(
110deg,
var(--color-iris),
var(--color-rose) 55%,
var(--color-gold)
);
box-shadow: 0 10px 30px -8px rgba(185, 167, 255, 0.8);
}
.btn-acid:hover {
box-shadow: 6px 6px 0 0 var(--color-void);
box-shadow: 0 16px 40px -8px rgba(185, 167, 255, 0.95);
}
.btn-ghost {
background: transparent;
color: var(--color-ink);
box-shadow: 4px 4px 0 0 var(--color-smoke);
border-color: var(--color-smoke);
box-shadow: 0 8px 24px -14px rgba(185, 167, 255, 0.6);
}
.btn:disabled {
opacity: 0.4;
@@ -196,9 +244,10 @@
text-transform: uppercase;
padding: 0.15rem 0.5rem;
border: 1px solid currentColor;
border-radius: 999px;
}
/* Glitchy duplicated-layer heading. */
/* Chromatic aura heading — pastel iris/rose ghosts drift behind the text. */
.glitch {
position: relative;
color: var(--color-ink);
@@ -209,44 +258,71 @@
position: absolute;
inset: 0;
pointer-events: none;
filter: blur(0.5px);
}
.glitch::before {
color: var(--color-blood);
transform: translate(-2px, 0);
color: var(--color-iris);
transform: translate(-1.5px, 0);
mix-blend-mode: screen;
clip-path: inset(0 0 55% 0);
animation: glitch-x 3.5s infinite steps(2);
opacity: 0.7;
animation: aura 5s ease-in-out infinite;
}
.glitch::after {
color: var(--color-cyber);
transform: translate(2px, 0);
color: var(--color-rose);
transform: translate(1.5px, 0);
mix-blend-mode: screen;
clip-path: inset(55% 0 0 0);
animation: glitch-x 2.7s infinite steps(2) reverse;
opacity: 0.7;
animation: aura 5s ease-in-out infinite reverse;
}
}
@keyframes glitch-x {
0%, 92%, 100% { transform: translate(0, 0); }
93% { transform: translate(-3px, 1px); }
96% { transform: translate(3px, -1px); }
@keyframes aura {
0%,
100% {
transform: translate(-1.5px, 0);
}
50% {
transform: translate(1.5px, 0.5px);
}
}
@keyframes drift {
from {
transform: translate(0, 0) scale(1);
}
to {
transform: translate(2%, -2%) scale(1.08);
}
}
.marquee {
white-space: nowrap;
animation: marquee 22s linear infinite;
animation: marquee 28s linear infinite;
}
@keyframes marquee {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
from {
transform: translateX(0);
}
to {
transform: translateX(-50%);
}
}
.flicker {
animation: flicker 4s infinite;
animation: flicker 6s infinite;
}
@keyframes flicker {
0%, 100% { opacity: 1; }
97% { opacity: 1; }
98% { opacity: 0.4; }
99% { opacity: 0.9; }
0%,
100% {
opacity: 1;
}
97% {
opacity: 1;
}
98% {
opacity: 0.6;
}
99% {
opacity: 0.95;
}
}
+2 -2
View File
@@ -4,11 +4,11 @@
<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" />
<meta name="theme-color" content="#0c0a14" />
<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"
href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@1,9..144,300;1,9..144,400&family=Space+Grotesk:wght@400;500;700&family=Space+Mono:ital,wght@0,400;0,700;1,400&display=swap"
rel="stylesheet"
/>
%sveltekit.head%
+14 -7
View File
@@ -1,6 +1,6 @@
import { env } from '$env/dynamic/public';
import { env } from "$env/dynamic/public";
const BASE = env.PUBLIC_API_BASE || 'http://localhost:8080';
const BASE = env.PUBLIC_API_BASE || "http://localhost:8080";
export class ApiError extends Error {
status: number;
@@ -12,9 +12,9 @@ export class ApiError extends Error {
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
credentials: "include",
headers: { "Content-Type": "application/json", ...(opts.headers ?? {}) },
...opts,
});
if (res.status === 204) return undefined as T;
@@ -31,7 +31,14 @@ async function request<T>(path: string, opts: RequestInit = {}): Promise<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 }),
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 })
request<T>(p, {
method: "PATCH",
body: body ? JSON.stringify(body) : undefined,
}),
del: <T>(p: string) => request<T>(p, { method: "DELETE" }),
};
+3 -3
View File
@@ -1,4 +1,4 @@
import { api } from './api';
import { api } from "./api";
export type User = {
id: string;
@@ -23,7 +23,7 @@ class AuthStore {
async refresh() {
try {
const me = await api.get<Me>('/auth/me');
const me = await api.get<Me>("/auth/me");
this.user = me.user;
this.settings = me.settings;
} catch {
@@ -42,7 +42,7 @@ class AuthStore {
async logout() {
try {
await api.post('/auth/logout');
await api.post("/auth/logout");
} finally {
this.user = null;
this.settings = null;
+101
View File
@@ -0,0 +1,101 @@
import { api } from "./api";
export type ItemStatus = "coveted" | "acquired" | "renounced";
export type List = {
id: string;
name: string;
emoji: string | null;
description: string | null;
position: number;
created_at: string;
updated_at: string;
};
export type Item = {
id: string;
list_id: string;
title: string;
url: string | null;
note: string | null;
status: ItemStatus;
target_price: number | null;
position: number;
// Filled by the Phase 3 fetcher; null until then.
title_fetched: string | null;
current_price: number | null;
currency: string | null;
image_url: string | null;
in_stock: boolean | null;
source: string | null;
fetched_at: string | null;
track_enabled: boolean;
last_error: string | null;
checked_at: string | null;
created_at: string;
updated_at: string;
};
export type PricePoint = {
price: number;
currency: string;
in_stock: boolean | null;
fetched_at: string;
};
export type NewList = {
name: string;
emoji?: string | null;
description?: string | null;
};
export type NewItem = {
title: string;
url?: string | null;
note?: string | null;
target_price?: number | null;
};
// ---- Lists ----------------------------------------------------------------
export const listsApi = {
all: () => api.get<List[]>("/lists"),
create: (b: NewList) => api.post<List>("/lists", b),
update: (id: string, b: Partial<NewList> & { position?: number }) =>
api.patch<List>(`/lists/${id}`, b),
remove: (id: string) => api.del<{ deleted: string }>(`/lists/${id}`),
items: (listId: string) => api.get<Item[]>(`/lists/${listId}/items`),
addItem: (listId: string, b: NewItem) =>
api.post<Item>(`/lists/${listId}/items`, b),
updateItem: (
id: string,
b: Partial<NewItem> & { status?: ItemStatus; position?: number },
) => api.patch<Item>(`/items/${id}`, b),
removeItem: (id: string) => api.del<{ deleted: string }>(`/items/${id}`),
refetch: (id: string) => api.post<Item>(`/items/${id}/refetch`, {}),
history: (id: string) => api.get<PricePoint[]>(`/items/${id}/history`),
};
/** Reactive store for the user's lists. */
class ListsStore {
items = $state<List[]>([]);
loaded = $state(false);
async load() {
this.items = await listsApi.all();
this.loaded = true;
}
async create(b: NewList): Promise<List> {
const created = await listsApi.create(b);
this.items.push(created);
return created;
}
async remove(id: string) {
await listsApi.remove(id);
this.items = this.items.filter((l) => l.id !== id);
}
}
export const lists = new ListsStore();
+15 -10
View File
@@ -17,12 +17,15 @@
}
const ticker =
'BUY LESS · WANT MORE · TRACK THE DROP · NO IMPULSE · GRAB THE DEAL · ';
'CONSUME · ASCEND · ACCUMULATE · YOU DESERVE IT · MANIFEST THE DEBT · TREAT YOURSELF · ONE MORE WONT HURT · ';
</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="overflow-hidden border-b border-void text-void"
style="background-image:linear-gradient(110deg,var(--color-iris),var(--color-rose) 55%,var(--color-gold));"
>
<div class="marquee py-1 font-mono text-xs font-bold tracking-widest">
{ticker.repeat(6)}
</div>
@@ -34,15 +37,16 @@
<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"
data-text="consume·rs"
>
//WANTLIST
consume<span class="text-iris">·</span>rs
</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">
<a href="/lists" class="tag border-smoke text-mute hover:text-iris">lists</a>
<a href="/settings" class="tag border-smoke text-mute hover:text-iris">
{auth.user.display_name ?? auth.user.email}
</a>
<button class="btn btn-ghost !px-3 !py-1 text-xs" onclick={doLogout}>
@@ -50,7 +54,7 @@
</button>
{:else if auth.loaded}
{#if page.url.pathname !== '/login'}
<a href="/login" class="tag border-smoke text-mute hover:text-acid">login</a>
<a href="/login" class="tag border-smoke text-mute hover:text-iris">login</a>
{/if}
{#if page.url.pathname !== '/register'}
<a href="/register" class="btn btn-acid !px-3 !py-1 text-xs">sign up</a>
@@ -62,8 +66,8 @@
<!-- 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?
<div class="border-b border-rose bg-rose/10 px-4 py-2 text-center text-xs text-rose">
email unconfirmed — your indulgence awaits. lost the link?
<a href="/settings" class="underline">resend from settings</a>
</div>
{/if}
@@ -72,7 +76,8 @@
{@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 class="border-t border-smoke px-4 py-6 text-center">
<p class="gospel text-base">spend now, ascend later</p>
<p class="label mt-1">consume·rs · self-hosted · rust + sveltekit</p>
</footer>
</div>
+21 -38
View File
@@ -1,70 +1,53 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { auth } from '$lib/auth.svelte';
// Logged-in users go straight to their lists — no placeholder dashboard.
$effect(() => {
if (auth.loaded && auth.user) goto('/lists', { replaceState: true });
});
</script>
<svelte:head>
<title>//WANTLIST</title>
<title>consume·rs — want more, ascend</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>
<p class="text-center text-mute flicker">entering…</p>
{: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>
<p class="tag inline-block border-mint text-mint">self-hosted · rust core · ✦ blessed</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."
data-text="WANT MORE. ASCEND. ACCUMULATE THE DEBT."
>
TRACK WHAT YOU WANT. STRIKE ON THE DROP.
WANT MORE. ASCEND. ACCUMULATE THE DEBT.
</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.
A serene little shrine to your every craving. Paste a product URL; we keep
vigil over the price and summon you the instant it falls. No feed. No
algorithm. Just you, your wants, and the gentle hum of impending debt.
<span class="gospel">You deserve it.</span>
</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>
<a href="/register" class="btn btn-acid">begin ascension</a>
<a href="/login" class="btn btn-ghost">return to worship</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]}
{#each [['I', 'COVET', 'group wants by topic'], ['II', 'PASTE URL', 'we keep the vigil'], ['III', 'BE SUMMONED', '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="gospel text-4xl">{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>
<p class="gospel text-center text-lg">“blessed are the carts, for they shall be filled”</p>
</section>
{/if}
+4 -4
View File
@@ -21,15 +21,15 @@
}
</script>
<svelte:head><title>reset · //WANTLIST</title></svelte:head>
<svelte:head><title>reset · consume·rs</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>
<h1 class="mb-6 font-display text-3xl font-bold">RECLAIM THE KEY</h1>
{#if done}
<p class="border-2 border-acid bg-acid/10 px-3 py-3 text-sm text-acid">
<p class="border-2 border-mint bg-mint/10 px-3 py-3 text-sm text-mint">
if that email exists, a reset link is on its way. check your inbox.
</p>
{:else}
@@ -39,7 +39,7 @@
<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>
<p class="border-2 border-rose bg-rose/10 px-3 py-2 text-sm text-rose">{error}</p>
{/if}
<button class="btn w-full" disabled={busy}>{busy ? 'sending…' : 'send reset link'}</button>
</form>
+125
View File
@@ -0,0 +1,125 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { ApiError } from '$lib/api';
import { auth } from '$lib/auth.svelte';
import { lists } from '$lib/lists.svelte';
let name = $state('');
let emoji = $state('');
let description = $state('');
let busy = $state(false);
let error = $state('');
let showForm = $state(false);
// Gate on auth, load lists once.
$effect(() => {
if (auth.loaded && !auth.user) {
goto('/login');
} else if (auth.loaded && auth.user && !lists.loaded) {
lists.load().catch((e) => (error = e instanceof ApiError ? e.message : 'failed to load'));
}
});
async function create(e: SubmitEvent) {
e.preventDefault();
if (!name.trim()) return;
error = '';
busy = true;
try {
await lists.create({ name, emoji: emoji || null, description: description || null });
name = '';
emoji = '';
description = '';
showForm = false;
} catch (err) {
error = err instanceof ApiError ? err.message : 'failed to create';
} finally {
busy = false;
}
}
async function remove(id: string, listName: string) {
if (!confirm(`delete the list “${listName}” and everything on it?`)) return;
try {
await lists.remove(id);
} catch (err) {
error = err instanceof ApiError ? err.message : 'failed to delete';
}
}
</script>
<svelte:head><title>your lists · consume·rs</title></svelte:head>
{#if auth.loaded && auth.user}
<section class="space-y-8">
<div class="flex flex-wrap items-end justify-between gap-4">
<div>
<p class="label">your devotion</p>
<h1 class="font-display text-4xl font-bold">YOUR LISTS</h1>
<p class="gospel mt-1 text-lg">each a temple to a different craving</p>
</div>
<button class="btn btn-acid" onclick={() => (showForm = !showForm)}>
{showForm ? 'never mind' : 'new list +'}
</button>
</div>
{#if showForm}
<form class="panel panel-acid space-y-4 p-6" onsubmit={create}>
<div class="grid gap-4 sm:grid-cols-[5rem_1fr]">
<div>
<label class="label" for="emoji">glyph</label>
<input id="emoji" class="field mt-1 text-center" bind:value={emoji} maxlength="4" placeholder="🛍" />
</div>
<div>
<label class="label" for="name">name</label>
<input id="name" class="field mt-1" bind:value={name} maxlength="80" placeholder="clothes, gear, indulgences…" />
</div>
</div>
<div>
<label class="label" for="desc">creed <span class="text-mute">(optional)</span></label>
<input id="desc" class="field mt-1" bind:value={description} maxlength="500" placeholder="what you tell yourself you need" />
</div>
{#if error}<p class="border-2 border-rose bg-rose/10 px-3 py-2 text-sm text-rose">{error}</p>{/if}
<button class="btn btn-acid" disabled={busy}>{busy ? 'creating…' : 'create it'}</button>
</form>
{/if}
{#if !lists.loaded}
<p class="text-center text-mute flicker">summoning your lists…</p>
{:else if lists.items.length === 0}
<div class="panel p-10 text-center">
<p class="gospel text-2xl">no lists yet</p>
<p class="mt-2 text-mute">make your first and begin the accumulation.</p>
</div>
{:else}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each lists.items as l (l.id)}
<div class="panel group relative flex flex-col p-5 transition-transform hover:-translate-y-0.5">
<a href="/lists/{l.id}" class="flex-1">
<div class="flex items-start gap-3">
<span class="text-3xl leading-none">{l.emoji ?? '✦'}</span>
<div class="min-w-0">
<h2 class="truncate font-display text-xl font-bold">{l.name}</h2>
{#if l.description}
<p class="mt-1 line-clamp-2 text-sm text-mute">{l.description}</p>
{/if}
</div>
</div>
</a>
<div class="mt-4 flex items-center justify-between">
<a href="/lists/{l.id}" class="tag border-smoke text-mute hover:text-iris">enter →</a>
<button
class="text-xs text-mute opacity-0 transition-opacity hover:text-rose group-hover:opacity-100"
onclick={() => remove(l.id, l.name)}
>
renounce
</button>
</div>
</div>
{/each}
</div>
{/if}
</section>
{:else}
<p class="text-center text-mute flicker">loading…</p>
{/if}
+336
View File
@@ -0,0 +1,336 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { ApiError } from '$lib/api';
import { auth } from '$lib/auth.svelte';
import {
lists,
listsApi,
type Item,
type ItemStatus,
type List,
type PricePoint
} from '$lib/lists.svelte';
const id = $derived(page.params.id);
let list = $state<List | null>(null);
let items = $state<Item[]>([]);
let loaded = $state(false);
let loadError = $state('');
// form
let title = $state('');
let url = $state('');
let note = $state('');
let targetPrice = $state('');
let busy = $state(false);
let formError = $state('');
// tracking
let refetchingId = $state<string | null>(null);
let historyFor = $state<string | null>(null);
let history = $state<PricePoint[]>([]);
let historyLoading = $state(false);
let lastId = '';
$effect(() => {
if (auth.loaded && !auth.user) {
goto('/login');
return;
}
if (auth.loaded && auth.user && id && id !== lastId) {
lastId = id;
load(id);
}
});
async function load(listId: string) {
loaded = false;
loadError = '';
try {
if (!lists.loaded) await lists.load();
list = lists.items.find((l) => l.id === listId) ?? null;
items = await listsApi.items(listId);
} catch (e) {
loadError = e instanceof ApiError ? e.message : 'failed to load';
} finally {
loaded = true;
}
}
async function addItem(e: SubmitEvent) {
e.preventDefault();
if (!title.trim() || !id) return;
formError = '';
busy = true;
try {
const tp = targetPrice.trim() ? Number(targetPrice) : null;
const created = await listsApi.addItem(id, {
title,
url: url.trim() || null,
note: note.trim() || null,
target_price: Number.isFinite(tp as number) ? tp : null
});
items.push(created);
title = '';
url = '';
note = '';
targetPrice = '';
} catch (err) {
formError = err instanceof ApiError ? err.message : 'failed to add';
} finally {
busy = false;
}
}
const cycle: Record<ItemStatus, ItemStatus> = {
coveted: 'acquired',
acquired: 'renounced',
renounced: 'coveted'
};
async function cycleStatus(item: Item) {
const next = cycle[item.status];
try {
const updated = await listsApi.updateItem(item.id, { status: next });
const i = items.findIndex((x) => x.id === item.id);
if (i >= 0) items[i] = updated;
} catch (err) {
formError = err instanceof ApiError ? err.message : 'failed to update';
}
}
async function removeItem(item: Item) {
if (!confirm(`cast out “${item.title}”?`)) return;
try {
await listsApi.removeItem(item.id);
items = items.filter((x) => x.id !== item.id);
} catch (err) {
formError = err instanceof ApiError ? err.message : 'failed to delete';
}
}
async function refetchItem(item: Item) {
refetchingId = item.id;
formError = '';
try {
const updated = await listsApi.refetch(item.id);
const i = items.findIndex((x) => x.id === item.id);
if (i >= 0) items[i] = updated;
if (historyFor === item.id) history = await listsApi.history(item.id);
} catch (err) {
formError = err instanceof ApiError ? err.message : 'failed to keep vigil';
} finally {
refetchingId = null;
}
}
async function toggleHistory(item: Item) {
if (historyFor === item.id) {
historyFor = null;
return;
}
historyFor = item.id;
history = [];
historyLoading = true;
try {
history = await listsApi.history(item.id);
} catch (err) {
formError = err instanceof ApiError ? err.message : 'failed to read the chronicle';
} finally {
historyLoading = false;
}
}
// A want is "answered" when its watched price falls to/under the target.
function onSale(item: Item): boolean {
return (
item.current_price != null &&
item.target_price != null &&
item.current_price <= item.target_price
);
}
function fmtDate(s: string): string {
return new Date(s).toLocaleDateString('en-GB', {
day: '2-digit',
month: 'short',
hour: '2-digit',
minute: '2-digit'
});
}
const STATUS_STYLE: Record<ItemStatus, string> = {
coveted: 'border-iris text-iris',
acquired: 'border-mint text-mint',
renounced: 'border-smoke text-mute'
};
const STATUS_LABEL: Record<ItemStatus, string> = {
coveted: 'coveted',
acquired: 'acquired',
renounced: 'renounced'
};
function money(v: number | null, cur: string | null) {
if (v == null) return null;
return `${cur ?? 'EUR'} ${v.toFixed(2)}`;
}
</script>
<svelte:head><title>{list?.name ?? 'list'} · consume·rs</title></svelte:head>
{#if auth.loaded && auth.user}
<section class="space-y-8">
<div>
<a href="/lists" class="label hover:text-iris">← all lists</a>
<div class="mt-2 flex items-start gap-3">
<span class="text-4xl leading-none">{list?.emoji ?? '✦'}</span>
<div>
<h1 class="font-display text-4xl font-bold">{list?.name ?? '…'}</h1>
{#if list?.description}<p class="gospel mt-1 text-lg">{list.description}</p>{/if}
</div>
</div>
</div>
<!-- Add temptation -->
<form class="panel panel-acid space-y-4 p-6" onsubmit={addItem}>
<p class="label">add a temptation</p>
<input class="field" bind:value={title} maxlength="200" placeholder="what you covet" />
<div class="grid gap-4 sm:grid-cols-[1fr_8rem]">
<input class="field" bind:value={url} placeholder="product URL (we'll keep vigil on the price)" />
<input class="field" bind:value={targetPrice} inputmode="decimal" placeholder="target price" />
</div>
<input class="field" bind:value={note} maxlength="1000" placeholder="note to self (optional)" />
{#if formError}<p class="border-2 border-rose bg-rose/10 px-3 py-2 text-sm text-rose">{formError}</p>{/if}
<button class="btn btn-acid" disabled={busy}>{busy ? 'coveting…' : 'covet it +'}</button>
</form>
{#if !loaded}
<p class="text-center text-mute flicker">unveiling temptations…</p>
{:else if loadError}
<p class="border-2 border-rose bg-rose/10 px-3 py-2 text-sm text-rose">{loadError}</p>
{:else if items.length === 0}
<div class="panel p-10 text-center">
<p class="gospel text-2xl">this list is bare</p>
<p class="mt-2 text-mute">paste a craving above to begin.</p>
</div>
{:else}
<ul class="space-y-3">
{#each items as item (item.id)}
<li
class="panel flex flex-col gap-3 p-4"
class:opacity-60={item.status === 'renounced'}
class:ring-1={onSale(item)}
class:ring-mint={onSale(item)}
>
<!-- Line 1: identity + state -->
<div class="flex items-start gap-4">
{#if item.image_url}
<img src={item.image_url} alt="" class="size-14 shrink-0 rounded-lg object-cover" />
{/if}
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<h3 class="truncate font-display font-bold" class:line-through={item.status === 'renounced'}>
{item.title_fetched ?? item.title}
</h3>
{#if item.in_stock === true}
<span class="tag shrink-0 border-mint text-mint">in stock</span>
{:else if item.in_stock === false}
<span class="tag shrink-0 border-rose text-rose">sold out</span>
{/if}
</div>
<div class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-mute">
{#if money(item.current_price, item.currency)}
<span class:text-mint={onSale(item)} class:text-ink={!onSale(item)}>
now {money(item.current_price, item.currency)}
</span>
{/if}
{#if item.target_price != null}
<span>target {money(item.target_price, item.currency)}</span>
{/if}
{#if item.url}
<a href={item.url} target="_blank" rel="noopener noreferrer" class="hover:text-iris" title="open product page">visit ↗</a>
{/if}
{#if item.note}<span class="italic">{item.note}</span>{/if}
{#if !item.url && item.current_price == null}
<span class="text-mute">not tracked</span>
{/if}
{#if item.checked_at}
<span class="text-mute" title="last price check">checked {fmtDate(item.checked_at)}</span>
{/if}
</div>
</div>
<!-- Status is the primary control: a real, cyclable badge. -->
<button
class="tag shrink-0 cursor-pointer transition hover:brightness-125 {STATUS_STYLE[item.status]}"
title="click to cycle: coveted → acquired → renounced"
onclick={() => cycleStatus(item)}
>
{STATUS_LABEL[item.status]}
</button>
</div>
<!-- Line 2: utility actions — plain verbs, real button chrome -->
<div class="flex flex-wrap items-center justify-end gap-2 text-xs">
{#if item.url}
<button
class="rounded border border-smoke px-2 py-1 text-mute transition hover:border-iris hover:text-iris disabled:opacity-40"
title="refetch price now (keep vigil)"
disabled={refetchingId === item.id}
onclick={() => refetchItem(item)}
>
{refetchingId === item.id ? '↻ checking…' : '↻ refresh'}
</button>
<button
class="rounded border border-smoke px-2 py-1 text-mute transition hover:border-iris hover:text-iris"
title="price history (the chronicle)"
onclick={() => toggleHistory(item)}
>
{historyFor === item.id ? 'hide history' : 'history'}
</button>
{/if}
<button
class="rounded border border-smoke px-2 py-1 text-mute transition hover:border-rose hover:text-rose"
title="remove from this list"
onclick={() => removeItem(item)}
>
✕ remove
</button>
</div>
{#if onSale(item)}
<p class="gospel text-sm text-mint">✦ the price has fallen — your moment is upon you</p>
{/if}
{#if item.last_error}
<p class="text-xs text-rose">vigil faltered: {item.last_error}</p>
{/if}
{#if historyFor === item.id}
<div class="border-t border-smoke pt-3">
{#if historyLoading}
<p class="text-xs text-mute flicker">unrolling the chronicle…</p>
{:else if history.length === 0}
<p class="text-xs text-mute">no observations yet — keep vigil to begin the record.</p>
{:else}
<ul class="space-y-1 text-xs">
{#each history as h}
<li class="flex items-center justify-between gap-3">
<span class="text-ink">{money(h.price, h.currency)}</span>
{#if h.in_stock === false}<span class="text-rose">sold out</span>{/if}
<span class="text-mute">{fmtDate(h.fetched_at)}</span>
</li>
{/each}
</ul>
{/if}
</div>
{/if}
</li>
{/each}
</ul>
{/if}
</section>
{:else}
<p class="text-center text-mute flicker">loading…</p>
{/if}
+3 -3
View File
@@ -24,12 +24,12 @@
}
</script>
<svelte:head><title>log in · //WANTLIST</title></svelte:head>
<svelte:head><title>log in · consume·rs</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>
<h1 class="mb-6 font-display text-3xl font-bold">RETURN TO WORSHIP</h1>
<form class="space-y-4" onsubmit={submit}>
<div>
@@ -42,7 +42,7 @@
</div>
{#if error}
<p class="border-2 border-blood bg-blood/10 px-3 py-2 text-sm text-blood">{error}</p>
<p class="border-2 border-rose bg-rose/10 px-3 py-2 text-sm text-rose">{error}</p>
{/if}
<button class="btn w-full" disabled={busy}>{busy ? 'entering…' : 'log in'}</button>
+4 -4
View File
@@ -29,12 +29,12 @@
}
</script>
<svelte:head><title>sign up · //WANTLIST</title></svelte:head>
<svelte:head><title>sign up · consume·rs</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>
<p class="label">new devotee</p>
<h1 class="mb-6 font-display text-3xl font-bold">BEGIN ASCENSION</h1>
<form class="space-y-4" onsubmit={submit}>
<div>
@@ -51,7 +51,7 @@
</div>
{#if error}
<p class="border-2 border-blood bg-blood/10 px-3 py-2 text-sm text-blood">{error}</p>
<p class="border-2 border-rose bg-rose/10 px-3 py-2 text-sm text-rose">{error}</p>
{/if}
<button class="btn btn-acid w-full" disabled={busy}>
+5 -5
View File
@@ -26,20 +26,20 @@
}
</script>
<svelte:head><title>new password · //WANTLIST</title></svelte:head>
<svelte:head><title>new password · consume·rs</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>
<h1 class="mb-6 font-display text-3xl font-bold">FORGE A NEW KEY</h1>
{#if !token}
<p class="border-2 border-blood bg-blood/10 px-3 py-3 text-sm text-blood">
<p class="border-2 border-rose bg-rose/10 px-3 py-3 text-sm text-rose">
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">
<p class="border-2 border-mint bg-mint/10 px-3 py-3 text-sm text-mint">
password changed. redirecting to login…
</p>
{:else}
@@ -49,7 +49,7 @@
<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>
<p class="border-2 border-rose bg-rose/10 px-3 py-2 text-sm text-rose">{error}</p>
{/if}
<button class="btn btn-acid w-full" disabled={busy}>{busy ? 'cutting…' : 'set password'}</button>
</form>
+11 -28
View File
@@ -4,7 +4,7 @@
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 settings = $state<Settings>({ locale: 'de', currency: 'EUR', theme: 'ethereal', notify_email: true });
let msg = $state('');
let error = $state('');
@@ -24,7 +24,6 @@
}
});
const themes = ['breakcore', 'grunge', 'minimal'];
const locales = ['de', 'en'];
async function save(e: SubmitEvent) {
@@ -55,13 +54,14 @@
}
</script>
<svelte:head><title>settings · //WANTLIST</title></svelte:head>
<svelte:head><title>settings · consume·rs</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>
<p class="label">your rites</p>
<h1 class="font-display text-4xl font-bold">THE SANCTUM</h1>
<p class="gospel mt-1 text-lg">tune your devotion</p>
</div>
<!-- Verification status -->
@@ -70,10 +70,10 @@
<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>
<span class="tag border-mint text-mint">verified</span>
{:else}
<div class="flex items-center gap-2">
<span class="tag border-blood text-blood">unverified</span>
<span class="tag border-rose text-rose">unverified</span>
<button class="btn btn-ghost !px-3 !py-1 text-xs" onclick={resend}>resend</button>
</div>
{/if}
@@ -100,33 +100,16 @@
</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>
<input type="checkbox" class="size-5 accent-mint" bind:checked={settings.notify_email} />
<span class="font-mono text-sm">summon me when the price falls</span>
</label>
{#if error}
<p class="border-2 border-blood bg-blood/10 px-3 py-2 text-sm text-blood">{error}</p>
<p class="border-2 border-rose bg-rose/10 px-3 py-2 text-sm text-rose">{error}</p>
{/if}
{#if msg}
<p class="border-2 border-acid bg-acid/10 px-3 py-2 text-sm text-acid">{msg}</p>
<p class="border-2 border-mint bg-mint/10 px-3 py-2 text-sm text-mint">{msg}</p>
{/if}
<button class="btn btn-acid" disabled={busy}>{busy ? 'saving…' : 'save changes'}</button>
+3 -3
View File
@@ -26,7 +26,7 @@
});
</script>
<svelte:head><title>verify · //WANTLIST</title></svelte:head>
<svelte:head><title>verify · consume·rs</title></svelte:head>
<div class="mx-auto max-w-md">
<div class="panel p-8 text-center">
@@ -35,11 +35,11 @@
{#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>
<h1 class="mt-4 font-display text-3xl font-bold text-mint">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>
<h1 class="mt-4 font-display text-3xl font-bold text-rose">FAILED ✗</h1>
<p class="mt-3 text-mute">{message}</p>
<a href="/" class="btn btn-ghost mt-6">go home</a>
{/if}