This commit is contained in:
2026-06-18 01:43:50 +02:00
parent d6d61df86a
commit 7773199b91
43 changed files with 3283 additions and 279 deletions
+6
View File
@@ -0,0 +1,6 @@
build/
.svelte-kit/
node_modules/
pnpm-lock.yaml
package-lock.json
static/
+9
View File
@@ -0,0 +1,9 @@
{
"useTabs": false,
"tabWidth": 2,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte"],
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
}
+39
View File
@@ -0,0 +1,39 @@
import js from '@eslint/js';
import ts from 'typescript-eslint';
import svelte from 'eslint-plugin-svelte';
import globals from 'globals';
export default ts.config(
// Never lint generated / vendored output.
{
ignores: ['build/', '.svelte-kit/', 'node_modules/', 'dist/', 'static/']
},
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs.recommended,
{
languageOptions: {
globals: { ...globals.browser, ...globals.node }
}
},
{
// Svelte files parse <script lang="ts"> blocks with the TS parser.
files: ['**/*.svelte', '**/*.svelte.ts'],
languageOptions: {
parserOptions: { parser: ts.parser }
}
},
{
rules: {
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
// This project links/navigates with plain hrefs and goto(path); we don't
// use the resolve() route helper. Off rather than churn every link.
'svelte/no-navigation-without-resolve': 'off',
// Nice-to-have, not worth failing the build over for static lists.
'svelte/require-each-key': 'warn',
// `svelte-ignore` comments target svelte-check (a11y), which eslint can't
// see — so it wrongly reports them as unused.
'svelte/no-unused-svelte-ignore': 'off'
}
}
);
+18 -2
View File
@@ -7,17 +7,33 @@
"dev": "vite dev --port 5173",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"lint": "eslint .",
"format": "prettier --write .",
"format:check": "prettier --check .",
"test": "vitest run"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@sveltejs/adapter-node": "^5.2.0",
"@sveltejs/kit": "^2.8.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tailwindcss/vite": "^4.0.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/svelte": "^5.3.1",
"@types/node": "^25.9.3",
"eslint": "^10.5.0",
"eslint-plugin-svelte": "^3.19.0",
"globals": "^17.6.0",
"jsdom": "^29.1.1",
"prettier": "^3.8.4",
"prettier-plugin-svelte": "^4.1.1",
"svelte": "^5.15.0",
"svelte-check": "^4.1.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.7.0",
"vite": "^6.0.0"
"typescript-eslint": "^8.61.1",
"vite": "^6.0.0",
"vitest": "^4.1.9"
}
}
+1721 -32
View File
File diff suppressed because it is too large Load Diff
+11 -33
View File
@@ -1,4 +1,4 @@
@import "tailwindcss";
@import 'tailwindcss';
/* Web fonts loaded via <link> in app.html (ethereal serif + clean sans + mono). */
@@ -19,9 +19,9 @@
--color-gold: #ffe6a3; /* divine gilt */
--color-holo: #cbd6ff; /* holographic sheen */
--font-display: "Space Grotesk", system-ui, sans-serif;
--font-gospel: "Fraunces", "Times New Roman", serif;
--font-mono: "Space Mono", ui-monospace, 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 */
@@ -48,32 +48,16 @@
/* Soft drifting aurora — celestial light pollution. */
body::before {
content: "";
content: '';
position: fixed;
inset: -20%;
pointer-events: none;
z-index: -1;
background:
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%
);
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;
}
@@ -119,8 +103,7 @@
position: relative;
border-radius: var(--radius-soft);
background:
linear-gradient(160deg, rgba(255, 255, 255, 0.03), transparent 60%),
var(--color-panel);
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),
@@ -146,12 +129,7 @@
font-family: var(--font-gospel);
font-style: italic;
font-weight: 300;
background: linear-gradient(
100deg,
var(--color-gold),
var(--color-rose),
var(--color-iris)
);
background: linear-gradient(100deg, var(--color-gold), var(--color-rose), var(--color-iris));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
+42 -7
View File
@@ -61,7 +61,9 @@
const linePath = $derived(
coords.length
? coords.map((c, i) => `${i === 0 ? 'M' : 'L'} ${c.cx.toFixed(1)} ${c.cy.toFixed(1)}`).join(' ')
? coords
.map((c, i) => `${i === 0 ? 'M' : 'L'} ${c.cx.toFixed(1)} ${c.cy.toFixed(1)}`)
.join(' ')
: ''
);
// Area = the line, then down to the baseline and back — fills under the curve.
@@ -278,10 +280,32 @@
stroke-width="1"
opacity="0.25"
/>
<circle cx={active.cx} cy={active.cy} r="5.5" fill="none" stroke="var(--color-ink)" stroke-width="1.5" />
<circle
cx={active.cx}
cy={active.cy}
r="5.5"
fill="none"
stroke="var(--color-ink)"
stroke-width="1.5"
/>
<g transform="translate({tipX}, {Math.max(active.cy - 16, PAD.t + 8)})">
<rect x="-54" y="-26" width="108" height="34" rx="6" fill="var(--color-ash)" stroke="var(--color-smoke)" />
<text x="0" y="-12" text-anchor="middle" class="fill-ink" font-size="11" font-weight="700">
<rect
x="-54"
y="-26"
width="108"
height="34"
rx="6"
fill="var(--color-ash)"
stroke="var(--color-smoke)"
/>
<text
x="0"
y="-12"
text-anchor="middle"
class="fill-ink"
font-size="11"
font-weight="700"
>
{fmtMoney(active.price)}
</text>
<text x="0" y="2" text-anchor="middle" class="fill-mute" font-size="9">
@@ -303,10 +327,21 @@
<figcaption class="mt-2 flex flex-wrap items-center justify-between gap-2 text-xs">
<span class="flex items-center gap-3 text-mute">
<span class="flex items-center gap-1"><span class="inline-block size-2 rounded-full" style="background:var(--color-gold)"></span>low {fmtMoney(coords[lowIdx].price)}</span>
<span class="flex items-center gap-1"><span class="inline-block size-2 rounded-full" style="background:var(--color-rose)"></span>high {fmtMoney(coords[highIdx].price)}</span>
<span class="flex items-center gap-1"
><span class="inline-block size-2 rounded-full" style="background:var(--color-gold)"
></span>low {fmtMoney(coords[lowIdx].price)}</span
>
<span class="flex items-center gap-1"
><span class="inline-block size-2 rounded-full" style="background:var(--color-rose)"
></span>high {fmtMoney(coords[highIdx].price)}</span
>
{#if anyOos}
<span class="flex items-center gap-1"><span class="inline-block h-2 w-3 rounded-sm" style="background:var(--color-rose);opacity:0.35"></span>out of stock</span>
<span class="flex items-center gap-1"
><span
class="inline-block h-2 w-3 rounded-sm"
style="background:var(--color-rose);opacity:0.35"
></span>out of stock</span
>
{/if}
</span>
{#if latest}
+37
View File
@@ -0,0 +1,37 @@
import '@testing-library/jest-dom/vitest';
import { describe, expect, it } from 'vitest';
import { render } from '@testing-library/svelte';
import PriceChart from './PriceChart.svelte';
import type { PricePoint } from './lists.svelte';
function point(price: number, day: string, in_stock: boolean | null = true): PricePoint {
return { price, currency: 'EUR', in_stock, fetched_at: `2026-06-${day}T12:00:00Z` };
}
describe('PriceChart', () => {
it('shows an empty state when there is no history', () => {
const { getByText, container } = render(PriceChart, { props: { history: [] } });
expect(getByText(/no price checks yet/i)).toBeInTheDocument();
expect(container.querySelector('svg')).toBeNull();
});
it('renders an svg with low/high stats from history', () => {
const history = [point(20, '10'), point(12, '11'), point(25, '12')];
const { container, getByText } = render(PriceChart, {
props: { history, target: 15, currency: 'EUR' }
});
expect(container.querySelector('svg')).not.toBeNull();
// Lowest and highest are surfaced in the caption legend.
expect(getByText(/low EUR 12\.00/)).toBeInTheDocument();
expect(getByText(/high EUR 25\.00/)).toBeInTheDocument();
// Latest (25.00) is above the 15 target, so not on sale (no ✦).
expect(getByText(/now EUR 25\.00/)).toBeInTheDocument();
});
it('flags an out-of-stock legend when a check was sold out', () => {
const history = [point(20, '10', true), point(18, '11', false)];
const { getByText } = render(PriceChart, { props: { history } });
expect(getByText(/out of stock/i)).toBeInTheDocument();
});
});
+62
View File
@@ -0,0 +1,62 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { api, ApiError } from './api';
/** Minimal stand-in for a fetch Response. */
function fakeRes(status: number, body?: unknown): Response {
return {
status,
ok: status >= 200 && status < 300,
statusText: `status ${status}`,
text: async () => (body === undefined ? '' : JSON.stringify(body))
} as Response;
}
describe('api client', () => {
beforeEach(() => vi.restoreAllMocks());
afterEach(() => vi.unstubAllGlobals());
it('GET parses JSON and sends credentials', async () => {
const fetchMock = vi.fn().mockResolvedValue(fakeRes(200, { hello: 'world' }));
vi.stubGlobal('fetch', fetchMock);
const data = await api.get<{ hello: string }>('/x');
expect(data).toEqual({ hello: 'world' });
expect(fetchMock).toHaveBeenCalledWith(
'http://localhost:8080/api/x',
expect.objectContaining({ credentials: 'include' })
);
});
it('POST serialises the body and sets the method', async () => {
const fetchMock = vi.fn().mockResolvedValue(fakeRes(201, { id: '1' }));
vi.stubGlobal('fetch', fetchMock);
await api.post('/lists', { name: 'Gadgets' });
expect(fetchMock).toHaveBeenCalledWith(
'http://localhost:8080/api/lists',
expect.objectContaining({ method: 'POST', body: JSON.stringify({ name: 'Gadgets' }) })
);
});
it('returns undefined for 204 No Content', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(fakeRes(204)));
await expect(api.del('/lists/1')).resolves.toBeUndefined();
});
it('throws ApiError carrying status and server message', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(fakeRes(404, { error: 'not found' })));
const err = (await api.get('/missing').catch((e) => e)) as ApiError;
expect(err).toBeInstanceOf(ApiError);
expect(err.status).toBe(404);
expect(err.message).toBe('not found');
});
it('falls back to statusText when the body has no error field', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(fakeRes(500, {})));
const err = (await api.get('/boom').catch((e) => e)) as ApiError;
expect(err.message).toBe('status 500');
});
});
+10 -10
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;
@@ -32,13 +32,13 @@ 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,
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,
method: 'PATCH',
body: body ? JSON.stringify(body) : undefined
}),
del: <T>(p: string) => request<T>(p, { method: "DELETE" }),
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;
+20 -26
View File
@@ -1,8 +1,8 @@
import { api } from "./api";
import { api } from './api';
export type ItemStatus = "coveted" | "acquired" | "renounced";
export type ItemStatus = 'coveted' | 'acquired' | 'renounced';
export type ListRole = "owner" | "editor" | "crosser";
export type ListRole = 'owner' | 'editor' | 'crosser';
export type List = {
id: string;
@@ -23,13 +23,13 @@ export type SharedView = { list: List; items: Item[] };
export type Invite = {
id: string;
token: string;
role: "editor" | "crosser";
role: 'editor' | 'crosser';
created_at: string;
};
export type Collaborator = {
user_id: string;
role: "editor" | "crosser";
role: 'editor' | 'crosser';
display_name: string | null;
email: string;
created_at: string;
@@ -39,12 +39,12 @@ export type InvitePreview = {
list_id: string;
list_name: string;
emoji: string | null;
role: "editor" | "crosser";
role: 'editor' | 'crosser';
};
export type Subscription = {
id: string;
kind: "list" | "item";
kind: 'list' | 'item';
created_at: string;
list_id: string | null;
item_id: string | null;
@@ -109,11 +109,11 @@ export type NewItem = {
// ---- Lists ----------------------------------------------------------------
export const listsApi = {
all: () => api.get<List[]>("/lists"),
create: (b: NewList) => api.post<List>("/lists", b),
all: () => api.get<List[]>('/lists'),
create: (b: NewList) => api.post<List>('/lists', b),
update: (
id: string,
b: Partial<NewList> & { position?: number; allow_guest_crossoff?: boolean },
b: Partial<NewList> & { position?: number; allow_guest_crossoff?: boolean }
) => api.patch<List>(`/lists/${id}`, b),
remove: (id: string) => api.del<{ deleted: string }>(`/lists/${id}`),
share: (id: string) => api.post<List>(`/lists/${id}/share`, {}),
@@ -123,8 +123,7 @@ export const listsApi = {
api.get<PricePoint[]>(`/shared/${token}/items/${itemId}/history`),
// Cross off / claim (collaborator route).
claim: (id: string, name?: string) =>
api.post<Item>(`/items/${id}/claim`, name ? { name } : {}),
claim: (id: string, name?: string) => api.post<Item>(`/items/${id}/claim`, name ? { name } : {}),
unclaim: (id: string) => api.del<Item>(`/items/${id}/claim`),
// Cross off via public share link (guest crossoff must be enabled).
guestClaim: (token: string, itemId: string, name: string) =>
@@ -134,34 +133,29 @@ export const listsApi = {
// Collaboration: invites + collaborators.
invites: (listId: string) => api.get<Invite[]>(`/lists/${listId}/invites`),
createInvite: (listId: string, role: "editor" | "crosser") =>
createInvite: (listId: string, role: 'editor' | 'crosser') =>
api.post<Invite>(`/lists/${listId}/invites`, { role }),
revokeInvite: (listId: string, inviteId: string) =>
api.del<{ deleted: string }>(`/lists/${listId}/invites/${inviteId}`),
collaborators: (listId: string) =>
api.get<Collaborator[]>(`/lists/${listId}/collaborators`),
collaborators: (listId: string) => api.get<Collaborator[]>(`/lists/${listId}/collaborators`),
removeCollaborator: (listId: string, userId: string) =>
api.del<{ removed: string }>(`/lists/${listId}/collaborators/${userId}`),
previewInvite: (token: string) =>
api.get<InvitePreview>(`/invites/${token}`),
previewInvite: (token: string) => api.get<InvitePreview>(`/invites/${token}`),
acceptInvite: (token: string) =>
api.post<{ list_id: string; role: string }>(`/invites/${token}/accept`, {}),
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),
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`),
subscriptions: () => api.get<Subscription[]>("/subscriptions"),
subscriptions: () => api.get<Subscription[]>('/subscriptions'),
subscribe: (b: { list_id?: string; item_id?: string }) =>
api.post<{ id: string }>("/subscriptions", b),
unsubscribe: (id: string) => api.del<{ deleted: string }>(`/subscriptions/${id}`),
api.post<{ id: string }>('/subscriptions', b),
unsubscribe: (id: string) => api.del<{ deleted: string }>(`/subscriptions/${id}`)
};
/** Reactive store for the user's lists. */
+75
View File
@@ -0,0 +1,75 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { lists, subs, type List, type Subscription } from './lists.svelte';
function list(id: string, name = 'L'): List {
return {
id,
name,
emoji: null,
description: null,
share_token: null,
allow_guest_crossoff: false,
position: 0,
created_at: '',
updated_at: '',
role: 'owner'
};
}
function sub(id: string, opts: Partial<Subscription>): Subscription {
return {
id,
kind: 'list',
created_at: '',
list_id: null,
item_id: null,
title: 'T',
emoji: null,
share_token: null,
url: null,
image_url: null,
current_price: null,
currency: null,
in_stock: null,
target_price: null,
...opts
};
}
describe('ListsStore.replace', () => {
beforeEach(() => {
lists.items = [];
});
it('swaps a list in place by id', () => {
lists.items = [list('a', 'old'), list('b')];
lists.replace(list('a', 'new'));
expect(lists.items.find((l) => l.id === 'a')?.name).toBe('new');
expect(lists.items).toHaveLength(2);
});
it('is a no-op when the id is unknown', () => {
lists.items = [list('a')];
lists.replace(list('zzz', 'ghost'));
expect(lists.items).toHaveLength(1);
expect(lists.items[0].id).toBe('a');
});
});
describe('SubsStore lookups', () => {
beforeEach(() => {
subs.items = [];
});
it('finds an existing subscription id by list', () => {
subs.items = [sub('s1', { kind: 'list', list_id: 'L1' })];
expect(subs.forList('L1')).toBe('s1');
expect(subs.forList('nope')).toBeNull();
});
it('finds an existing subscription id by item', () => {
subs.items = [sub('s2', { kind: 'item', item_id: 'I9' })];
expect(subs.forItem('I9')).toBe('s2');
expect(subs.forItem('nope')).toBeNull();
});
});
+9 -3
View File
@@ -55,17 +55,23 @@
{#if auth.loaded && auth.user}
<a
href="/lists"
class="tag {active('/lists') ? 'border-iris text-iris' : 'border-smoke text-mute hover:text-iris'}"
class="tag {active('/lists')
? 'border-iris text-iris'
: 'border-smoke text-mute hover:text-iris'}"
aria-current={active('/lists') ? 'page' : undefined}>lists</a
>
<a
href="/subscriptions"
class="tag {active('/subscriptions') ? 'border-iris text-iris' : 'border-smoke text-mute hover:text-iris'}"
class="tag {active('/subscriptions')
? 'border-iris text-iris'
: 'border-smoke text-mute hover:text-iris'}"
aria-current={active('/subscriptions') ? 'page' : undefined}>following</a
>
<a
href="/settings"
class="tag max-w-[40vw] truncate sm:max-w-none {active('/settings') ? 'border-iris text-iris' : 'border-smoke text-mute hover:text-iris'}"
class="tag max-w-[40vw] truncate sm:max-w-none {active('/settings')
? 'border-iris text-iris'
: 'border-smoke text-mute hover:text-iris'}"
aria-current={active('/settings') ? 'page' : undefined}
>
<span class="sm:hidden"></span>
+3 -3
View File
@@ -25,9 +25,9 @@
WANT MORE. SPEND MORE. ACCUMULATE THE DEBT.
</h1>
<p class="max-w-xl text-lg text-mute">
A wishlist for your every craving. Paste a product link; we watch the price
and email you the moment it drops. No feed. No algorithm. Just you, your
wants, and the gentle hum of impending debt.
A wishlist for your every craving. Paste a product link; we watch the price and email you
the moment it drops. No feed. No algorithm. Just you, your wants, and the gentle hum of
impending debt.
<span class="gospel">You deserve it.</span>
</p>
</div>
+8 -1
View File
@@ -36,7 +36,14 @@
<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" />
<input
id="em"
class="field mt-1"
type="email"
bind:value={email}
required
autocomplete="email"
/>
</div>
{#if error}
<p class="border-2 border-rose bg-rose/10 px-3 py-2 text-sm text-rose">{error}</p>
@@ -82,8 +82,8 @@
</div>
</div>
<p class="mt-4 text-sm text-mute">
accepting lets you <span class="text-ink">{roleBlurb(preview.role)}</span> on this list.
it'll appear under your lists.
accepting lets you <span class="text-ink">{roleBlurb(preview.role)}</span> on this list. it'll
appear under your lists.
</p>
{#if auth.loaded && auth.user}
+30 -6
View File
@@ -68,18 +68,40 @@
<div class="grid gap-4 sm:grid-cols-[5rem_1fr]">
<div>
<label class="label" for="emoji">emoji</label>
<input id="emoji" class="field mt-1 text-center" bind:value={emoji} maxlength="4" placeholder="🛍" />
<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…" />
<input
id="name"
class="field mt-1"
bind:value={name}
maxlength="80"
placeholder="clothes, gear, indulgences…"
/>
</div>
</div>
<div>
<label class="label" for="desc">description <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" />
<label class="label" for="desc"
>description <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}
{#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}
@@ -94,7 +116,9 @@
{: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">
<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>
+159 -49
View File
@@ -422,12 +422,17 @@
<button
class="tag border-mint text-mint"
title="this list is shared — manage below"
onclick={() => document.getElementById('share-box')?.scrollIntoView({ behavior: 'smooth' })}
onclick={() =>
document.getElementById('share-box')?.scrollIntoView({ behavior: 'smooth' })}
>
◈ shared
</button>
{:else}
<button class="tag border-smoke text-mute hover:text-iris" disabled={sharing} onclick={share}>
<button
class="tag border-smoke text-mute hover:text-iris"
disabled={sharing}
onclick={share}
>
{sharing ? '…' : '◈ share'}
</button>
{/if}
@@ -446,12 +451,30 @@
{#if list?.share_token}
<div id="share-box" class="panel mt-4 flex flex-wrap items-center gap-2 p-3 text-sm">
<span class="label shrink-0">public link</span>
<input class="field flex-1 text-xs" readonly value={shareUrl} onclick={(e) => e.currentTarget.select()} />
<button class="rounded border border-smoke px-3 py-1.5 text-xs text-mute transition hover:border-iris hover:text-iris" onclick={copyShare}>
<input
class="field flex-1 text-xs"
readonly
value={shareUrl}
onclick={(e) => e.currentTarget.select()}
/>
<button
class="rounded border border-smoke px-3 py-1.5 text-xs text-mute transition hover:border-iris hover:text-iris"
onclick={copyShare}
>
{copied ? '✓ copied' : 'copy'}
</button>
<a href={shareUrl} target="_blank" rel="noopener noreferrer" class="rounded border border-smoke px-3 py-1.5 text-xs text-mute transition hover:border-iris hover:text-iris">open ↗</a>
<button class="rounded border border-smoke px-3 py-1.5 text-xs text-mute transition hover:border-rose hover:text-rose" disabled={sharing} onclick={unshare}>
<a
href={shareUrl}
target="_blank"
rel="noopener noreferrer"
class="rounded border border-smoke px-3 py-1.5 text-xs text-mute transition hover:border-iris hover:text-iris"
>open ↗</a
>
<button
class="rounded border border-smoke px-3 py-1.5 text-xs text-mute transition hover:border-rose hover:text-rose"
disabled={sharing}
onclick={unshare}
>
{sharing ? '…' : 'unshare'}
</button>
</div>
@@ -461,7 +484,9 @@
<div class="panel mt-4 space-y-5 p-4 text-sm">
<div class="flex items-center justify-between">
<p class="label">list settings</p>
<button class="text-xs text-mute hover:text-iris" onclick={() => (showSettings = false)}>close</button>
<button class="text-xs text-mute hover:text-iris" onclick={() => (showSettings = false)}
>close</button
>
</div>
<!-- Guest cross-off -->
@@ -470,11 +495,15 @@
<p class="font-bold">let anyone with the link cross items off</p>
<p class="mt-0.5 text-xs text-mute">
for gift/birthday lists — visitors don't need an account to mark something as taken.
{#if !list?.share_token}<span class="text-rose/80">share the list first to use this.</span>{/if}
{#if !list?.share_token}<span class="text-rose/80"
>share the list first to use this.</span
>{/if}
</p>
</div>
<button
class="tag shrink-0 {list?.allow_guest_crossoff ? 'border-mint text-mint' : 'border-smoke text-mute hover:text-iris'}"
class="tag shrink-0 {list?.allow_guest_crossoff
? 'border-mint text-mint'
: 'border-smoke text-mute hover:text-iris'}"
disabled={settingsBusy || !list?.share_token}
onclick={toggleGuestCrossoff}
>
@@ -489,10 +518,18 @@
create a link, then share it. whoever opens it (and logs in) joins with that role.
</p>
<div class="mt-3 flex flex-wrap gap-2">
<button class="rounded border border-smoke px-3 py-1.5 text-xs text-mute transition hover:border-iris hover:text-iris" disabled={settingsBusy} onclick={() => makeInvite('editor')}>
<button
class="rounded border border-smoke px-3 py-1.5 text-xs text-mute transition hover:border-iris hover:text-iris"
disabled={settingsBusy}
onclick={() => makeInvite('editor')}
>
+ editor link <span class="text-mute/70">(add & edit items)</span>
</button>
<button class="rounded border border-smoke px-3 py-1.5 text-xs text-mute transition hover:border-iris hover:text-iris" disabled={settingsBusy} onclick={() => makeInvite('crosser')}>
<button
class="rounded border border-smoke px-3 py-1.5 text-xs text-mute transition hover:border-iris hover:text-iris"
disabled={settingsBusy}
onclick={() => makeInvite('crosser')}
>
+ cross-off link <span class="text-mute/70">(only tick items off)</span>
</button>
</div>
@@ -501,12 +538,27 @@
<ul class="mt-3 space-y-2">
{#each invites as inv (inv.id)}
<li class="flex flex-wrap items-center gap-2 rounded border border-smoke p-2">
<span class="tag shrink-0 {inv.role === 'editor' ? 'border-iris text-iris' : 'border-mint text-mint'}">{inv.role}</span>
<input class="field flex-1 text-xs" readonly value={inviteUrl(inv.token)} onclick={(e) => e.currentTarget.select()} />
<button class="rounded border border-smoke px-2 py-1 text-xs text-mute hover:border-iris hover:text-iris" onclick={() => copyInvite(inv.token)}>
<span
class="tag shrink-0 {inv.role === 'editor'
? 'border-iris text-iris'
: 'border-mint text-mint'}">{inv.role}</span
>
<input
class="field flex-1 text-xs"
readonly
value={inviteUrl(inv.token)}
onclick={(e) => e.currentTarget.select()}
/>
<button
class="rounded border border-smoke px-2 py-1 text-xs text-mute hover:border-iris hover:text-iris"
onclick={() => copyInvite(inv.token)}
>
{copiedInvite === inv.token ? '✓' : 'copy'}
</button>
<button class="rounded border border-smoke px-2 py-1 text-xs text-mute hover:border-rose hover:text-rose" onclick={() => dropInvite(inv.id)}>revoke</button>
<button
class="rounded border border-smoke px-2 py-1 text-xs text-mute hover:border-rose hover:text-rose"
onclick={() => dropInvite(inv.id)}>revoke</button
>
</li>
{/each}
</ul>
@@ -519,12 +571,21 @@
<p class="font-bold">collaborators</p>
<ul class="mt-2 space-y-2">
{#each collaborators as c (c.user_id)}
<li class="flex items-center justify-between gap-2 rounded border border-smoke p-2">
<li
class="flex items-center justify-between gap-2 rounded border border-smoke p-2"
>
<span class="min-w-0 truncate">
{c.display_name ?? c.email}
<span class="tag ml-1 {c.role === 'editor' ? 'border-iris text-iris' : 'border-mint text-mint'}">{c.role}</span>
<span
class="tag ml-1 {c.role === 'editor'
? 'border-iris text-iris'
: 'border-mint text-mint'}">{c.role}</span
>
</span>
<button class="shrink-0 rounded border border-smoke px-2 py-1 text-xs text-mute hover:border-rose hover:text-rose" onclick={() => dropCollaborator(c.user_id)}>remove</button>
<button
class="shrink-0 rounded border border-smoke px-2 py-1 text-xs text-mute hover:border-rose hover:text-rose"
onclick={() => dropCollaborator(c.user_id)}>remove</button
>
</li>
{/each}
</ul>
@@ -536,29 +597,52 @@
<!-- Add item — compact quick-add; tracking details on demand -->
{#if canEdit}
<form class="panel space-y-3 p-4" onsubmit={addItem}>
<div class="flex gap-2">
<input class="field" bind:value={title} maxlength="200" placeholder="add an item — what do you want?" />
<button class="btn btn-acid shrink-0" disabled={busy || !title.trim()}>{busy ? '…' : 'add +'}</button>
</div>
<button
type="button"
class="label transition hover:text-iris"
onclick={() => (showDetails = !showDetails)}
>
{showDetails ? ' fewer' : '+ link, target price & note'}
</button>
{#if showDetails}
<div class="space-y-3 border-t border-smoke pt-3">
<div class="grid gap-3 sm:grid-cols-[1fr_8rem]">
<input class="field" bind:value={url} placeholder="product link (we'll track 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)" />
<form class="panel space-y-3 p-4" onsubmit={addItem}>
<div class="flex gap-2">
<input
class="field"
bind:value={title}
maxlength="200"
placeholder="add an item — what do you want?"
/>
<button class="btn btn-acid shrink-0" disabled={busy || !title.trim()}
>{busy ? '…' : 'add +'}</button
>
</div>
{/if}
{#if formError}<p class="border-2 border-rose bg-rose/10 px-3 py-2 text-sm text-rose">{formError}</p>{/if}
</form>
<button
type="button"
class="label transition hover:text-iris"
onclick={() => (showDetails = !showDetails)}
>
{showDetails ? ' fewer' : '+ link, target price & note'}
</button>
{#if showDetails}
<div class="space-y-3 border-t border-smoke pt-3">
<div class="grid gap-3 sm:grid-cols-[1fr_8rem]">
<input
class="field"
bind:value={url}
placeholder="product link (we'll track 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)"
/>
</div>
{/if}
{#if formError}<p class="border-2 border-rose bg-rose/10 px-3 py-2 text-sm text-rose">
{formError}
</p>{/if}
</form>
{:else if formError}
<p class="border-2 border-rose bg-rose/10 px-3 py-2 text-sm text-rose">{formError}</p>
{/if}
@@ -602,7 +686,11 @@
</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-sm font-bold" class:text-mint={onSale(item)} class:text-ink={!onSale(item)}>
<span
class="text-sm font-bold"
class:text-mint={onSale(item)}
class:text-ink={!onSale(item)}
>
{money(item.current_price, item.currency)}
</span>
{/if}
@@ -610,19 +698,28 @@
<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>
<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>
<span class="text-mute" title="last price check"
>checked {fmtDate(item.checked_at)}</span
>
{/if}
</div>
{#if item.claimed_at}
<p class="mt-1 text-xs font-bold text-mint">
☑ taken{#if item.claimed_by_name} by {item.claimed_by_name}{/if}
☑ taken{#if item.claimed_by_name}
by {item.claimed_by_name}{/if}
</p>
{/if}
</div>
@@ -630,8 +727,12 @@
<!-- Right controls: cross-off (everyone) + status (editors). -->
<div class="flex shrink-0 flex-col items-end gap-2">
<button
class="tag cursor-pointer transition hover:brightness-125 {item.claimed_at ? 'border-mint bg-mint/10 text-mint' : 'border-smoke text-mute hover:text-iris'}"
title={item.claimed_at ? 'crossed off — click to undo' : 'cross off / claim this item'}
class="tag cursor-pointer transition hover:brightness-125 {item.claimed_at
? 'border-mint bg-mint/10 text-mint'
: 'border-smoke text-mute hover:text-iris'}"
title={item.claimed_at
? 'crossed off — click to undo'
: 'cross off / claim this item'}
disabled={claimBusy === item.id}
onclick={() => toggleClaim(item)}
>
@@ -639,7 +740,9 @@
</button>
{#if canEdit}
<button
class="tag cursor-pointer transition hover:brightness-125 {STATUS_STYLE[item.status]}"
class="tag cursor-pointer transition hover:brightness-125 {STATUS_STYLE[
item.status
]}"
title="click to cycle: want → bought → skip"
onclick={() => cycleStatus(item)}
>
@@ -710,8 +813,15 @@
<input class="field" bind:value={edit.note} maxlength="1000" placeholder="note" />
{#if editError}<p class="text-xs text-rose">{editError}</p>{/if}
<div class="flex justify-end gap-2 text-xs">
<button class="rounded border border-smoke px-3 py-1 text-mute hover:text-ink" onclick={cancelEdit}>cancel</button>
<button class="btn btn-acid px-3 py-1" disabled={editBusy} onclick={() => saveEdit(item)}>
<button
class="rounded border border-smoke px-3 py-1 text-mute hover:text-ink"
onclick={cancelEdit}>cancel</button
>
<button
class="btn btn-acid px-3 py-1"
disabled={editBusy}
onclick={() => saveEdit(item)}
>
{editBusy ? 'saving…' : 'save'}
</button>
</div>
+16 -2
View File
@@ -44,11 +44,25 @@
<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" />
<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" />
<input
id="pw"
class="field mt-1"
type="password"
bind:value={password}
required
autocomplete="current-password"
/>
</div>
{#if error}
+24 -3
View File
@@ -47,15 +47,36 @@
<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" />
<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" />
<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" />
<input
id="pw"
class="field mt-1"
type="password"
bind:value={password}
required
minlength="10"
autocomplete="new-password"
/>
</div>
{#if error}
+12 -2
View File
@@ -46,12 +46,22 @@
<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" />
<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-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 ? 'saving…' : 'set password'}</button>
<button class="btn btn-acid w-full" disabled={busy}
>{busy ? 'saving…' : 'set password'}</button
>
</form>
{/if}
</div>
+12 -2
View File
@@ -4,7 +4,12 @@
import { auth, type Settings } from '$lib/auth.svelte';
let displayName = $state('');
let settings = $state<Settings>({ locale: 'de', currency: 'EUR', theme: 'ethereal', notify_email: true });
let settings = $state<Settings>({
locale: 'de',
currency: 'EUR',
theme: 'ethereal',
notify_email: true
});
let msg = $state('');
let error = $state('');
@@ -96,7 +101,12 @@
</div>
<div>
<label class="label" for="cur">currency</label>
<input id="cur" class="field mt-1 uppercase" bind:value={settings.currency} maxlength="3" />
<input
id="cur"
class="field mt-1 uppercase"
bind:value={settings.currency}
maxlength="3"
/>
</div>
</div>
@@ -172,7 +172,10 @@
{#if list.description}<p class="gospel mt-1 text-lg">{list.description}</p>{/if}
</div>
{#if !auth.user}
<button class="tag shrink-0 border-iris text-iris hover:brightness-125" onclick={toggleList}>
<button
class="tag shrink-0 border-iris text-iris hover:brightness-125"
onclick={toggleList}
>
☆ subscribe
</button>
{:else if subs.forList(list.id)}
@@ -204,9 +207,16 @@
<div class="panel flex flex-wrap items-center gap-2 p-3 text-sm">
<span class="label shrink-0">cross items off</span>
{#if auth.user}
<span class="text-mute">you can tick items as <span class="text-mint">taken</span> below.</span>
<span class="text-mute"
>you can tick items as <span class="text-mint">taken</span> below.</span
>
{:else}
<input class="field flex-1" bind:value={guestName} maxlength="80" placeholder="your name (so others don't double-buy)" />
<input
class="field flex-1"
bind:value={guestName}
maxlength="80"
placeholder="your name (so others don't double-buy)"
/>
{/if}
</div>
{/if}
@@ -230,7 +240,10 @@
{/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.claimed_at}>
<h3
class="truncate font-display font-bold"
class:line-through={!!item.claimed_at}
>
{item.title_fetched ?? item.title}
</h3>
{#if item.in_stock === true}
@@ -241,7 +254,11 @@
</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-sm font-bold" class:text-mint={onSale(item)} class:text-ink={!onSale(item)}>
<span
class="text-sm font-bold"
class:text-mint={onSale(item)}
class:text-ink={!onSale(item)}
>
{money(item.current_price, item.currency)}
</span>
{/if}
@@ -249,7 +266,12 @@
<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">visit ↗</a>
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
class="hover:text-iris">visit ↗</a
>
{/if}
{#if item.note}<span class="italic">“{item.note}”</span>{/if}
{#if item.url}
@@ -260,7 +282,8 @@
</div>
{#if item.claimed_at}
<p class="mt-1 text-xs font-bold text-mint">
☑ taken{#if item.claimed_by_name} by {item.claimed_by_name}{/if}
☑ taken{#if item.claimed_by_name}
by {item.claimed_by_name}{/if}
</p>
{/if}
{#if onSale(item)}
@@ -271,8 +294,12 @@
<div class="flex shrink-0 flex-col items-end gap-2">
{#if canGuestCross}
<button
class="tag cursor-pointer transition hover:brightness-125 {item.claimed_at ? 'border-mint bg-mint/10 text-mint' : 'border-smoke text-mute hover:text-iris'}"
title={item.claimed_at ? 'crossed off — click to undo' : 'cross off / claim this item'}
class="tag cursor-pointer transition hover:brightness-125 {item.claimed_at
? 'border-mint bg-mint/10 text-mint'
: 'border-smoke text-mute hover:text-iris'}"
title={item.claimed_at
? 'crossed off — click to undo'
: 'cross off / claim this item'}
disabled={claimBusy === item.id}
onclick={() => toggleClaim(item)}
>
@@ -84,7 +84,11 @@
</div>
<div class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-mute">
{#if money(s.current_price, s.currency)}
<span class="text-sm font-bold" class:text-mint={onSale(s)} class:text-ink={!onSale(s)}>
<span
class="text-sm font-bold"
class:text-mint={onSale(s)}
class:text-ink={!onSale(s)}
>
{money(s.current_price, s.currency)}
</span>
{/if}
@@ -92,7 +96,9 @@
<span>target {money(s.target_price, s.currency)}</span>
{/if}
{#if s.url}
<a href={s.url} target="_blank" rel="noopener noreferrer" class="hover:text-iris">visit ↗</a>
<a href={s.url} target="_blank" rel="noopener noreferrer" class="hover:text-iris"
>visit ↗</a
>
{/if}
{#if s.share_token}
<a href="/shared/{s.share_token}" class="hover:text-iris">view list →</a>
@@ -0,0 +1,3 @@
// Stand-in for SvelteKit's `$env/dynamic/public` virtual module under Vitest,
// which otherwise reads a runtime env object that doesn't exist in tests.
export const env: Record<string, string> = {};
+20 -4
View File
@@ -1,7 +1,23 @@
import { sveltekit } from '@sveltejs/kit/vite';
import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vite';
import { fileURLToPath } from 'node:url';
import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [tailwindcss(), sveltekit()]
});
export default defineConfig(({ mode }) => ({
plugins: [tailwindcss(), sveltekit()],
// Under Vitest, resolve Svelte's browser (client) build so components can
// `mount` in jsdom instead of hitting the SSR build.
resolve: mode === 'test' ? { conditions: ['browser'] } : {},
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./vitest-setup.ts'],
include: ['src/**/*.{test,spec}.{js,ts}'],
alias: {
// SvelteKit's $env virtual module isn't available outside the dev server.
'$env/dynamic/public': fileURLToPath(
new URL('./src/test-mocks/env-dynamic-public.ts', import.meta.url)
)
}
}
}));
+1
View File
@@ -0,0 +1 @@
import '@testing-library/jest-dom/vitest';