From b70ad6bc86571ede77f24629accb2fd71bad4235 Mon Sep 17 00:00:00 2001 From: Andrew Novac <16753077+novatorem@users.noreply.github.com> Date: Sun, 15 Mar 2026 09:18:07 -0400 Subject: [PATCH 1/7] =?UTF-8?q?I=20know,=20I=20know,=20this=20is=20not=20h?= =?UTF-8?q?ow=20I=E2=80=99m=20supposed=20to=20do=20it,=20but=20I=20can't?= =?UTF-8?q?=20think=20of=20something=20better.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 28 + .prettierrc | 31 +- CLAUDE.md | 50 + src/app.css | 98 +- src/app.d.ts | 42 +- src/app.html | 2 + src/database.types.ts | 292 +-- src/hooks.server.ts | 262 +- src/lib/dashboard/GettingStarted.svelte | 129 + src/lib/dashboard/loader.ts | 581 ++--- src/lib/friends/api.ts | 162 +- src/lib/friends/components/DeleteModal.svelte | 79 +- src/lib/friends/components/List.svelte | 827 +++--- .../friends/components/ListSkeleton.svelte | 64 +- src/lib/friends/components/Requests.svelte | 888 ++++--- .../components/RequestsSkeleton.svelte | 58 +- src/lib/friends/order.test.ts | 222 +- src/lib/friends/order.ts | 166 +- src/lib/profile/api.ts | 50 +- src/lib/profile/validation.test.ts | 224 +- src/lib/profile/validation.ts | 98 +- src/lib/realtime/subscriptions.ts | 334 +-- src/lib/status/components/Section.svelte | 232 +- src/lib/status/components/Skeleton.svelte | 38 +- src/lib/status/formatting.test.ts | 128 +- src/lib/status/formatting.ts | 76 +- src/lib/status/quick.test.ts | 220 +- src/lib/status/quick.ts | 132 +- src/lib/status/validation.test.ts | 44 +- src/lib/status/validation.ts | 16 +- src/lib/ui/DebugPanel.svelte | 532 ++-- src/lib/ui/DotGridBackground.svelte | 165 +- src/lib/ui/Footer.svelte | 82 +- src/lib/ui/Navigation.svelte | 201 +- src/lib/ui/RelativeTime.svelte | 45 +- src/lib/ui/ThemeSelect.svelte | 200 +- src/lib/ui/Toast.svelte | 92 +- src/lib/ui/ToastContainer.svelte | 20 +- src/lib/ui/notifications.ts | 58 +- src/lib/ui/now.svelte.ts | 23 + src/lib/ui/themes.ts | 74 +- src/lib/ui/toast.ts | 82 +- src/routes/+layout.server.ts | 18 +- src/routes/+layout.svelte | 151 +- src/routes/+layout.ts | 62 +- src/routes/+page.svelte | 168 +- src/routes/auth/+layout.svelte | 17 +- src/routes/auth/+page.server.ts | 148 +- src/routes/auth/+page.svelte | 997 +++---- src/routes/auth/confirm/+server.ts | 62 +- src/routes/auth/error/+page.svelte | 168 +- src/routes/auth/logout/+server.ts | 58 +- .../auth/reset-password/+page.server.ts | 18 +- src/routes/auth/reset-password/+page.svelte | 424 +-- src/routes/dashboard/+layout.server.ts | 32 +- src/routes/dashboard/+layout.svelte | 24 +- src/routes/dashboard/+page.server.ts | 30 +- src/routes/dashboard/+page.svelte | 632 ++--- src/routes/dashboard/settings/+page.server.ts | 18 +- src/routes/dashboard/settings/+page.svelte | 2323 ++++++++--------- tsconfig.json | 38 +- vite.config.ts | 22 +- 62 files changed, 6444 insertions(+), 6113 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 CLAUDE.md create mode 100644 src/lib/dashboard/GettingStarted.svelte create mode 100644 src/lib/ui/now.svelte.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..5bae4ff --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,28 @@ +{ + "permissions": { + "allow": [ + "Bash(python3:*)", + "Bash(cd \"C:/Projects/rez\" && npm run check 2>&1)", + "Bash(cd \"C:/Projects/rez\" && npx svelte-kit sync && npx svelte-check --tsconfig ./tsconfig.json 2>&1)", + "Bash(cd \"C:/Projects/rez\" && node_modules/.bin/svelte-kit sync && node_modules/.bin/svelte-check --tsconfig ./tsconfig.json 2>&1)", + "Bash(which node:*)", + "Bash(tail:*)", + "Bash(cd \"C:/Projects/rez\" && node_modules/.bin/svelte-kit sync 2>&1 && node_modules/.bin/svelte-check --tsconfig ./tsconfig.json 2>&1)", + "Bash(cd C:/Projects/rez && node_modules/.bin/svelte-kit sync && node_modules/.bin/svelte-check --tsconfig ./tsconfig.json 2>&1)", + "Bash(cd C:/Projects/rez && npx --yes npm-check-updates --upgrade 2>&1)", + "Bash(cd C:/Projects/rez && npm install 2>&1)", + "Bash(cd C:/Projects/rez && npm audit 2>&1)", + "Bash(cd C:/Projects/rez && npm run build 2>&1)", + "Bash(npm view svelte-boring-avatars versions --json 2>&1 | python3 -c \"import sys,json; vs=json.load\\(sys.stdin\\); print\\('All versions:', vs[-5:]\\)\" && npm view svelte-boring-avatars dist-tags 2>&1)", + "Bash(find /c/Projects/rez/src -type f | grep -E \"\\\\.\\(svelte|ts|tsx|js|jsx|css\\)$\" | sort)", + "Bash(find /c/Projects/rez -maxdepth 2 -type f -name \"*.md\" -o -name \"*.json\" -o -name \"*.config.*\" 2>/dev/null | head -20)", + "Bash(ls -la /c/Projects/rez/*.config.* 2>/dev/null)", + "Bash(find /c/Projects/rez -maxdepth 1 -type f -name \"*config*\" | grep -E \"\\\\.\\(ts|js|mjs|cjs\\)$\")", + "Bash(find /c/Projects/rez/src/routes -type f -name \"*.svelte\" | xargs ls -la)" + ] + }, + "enabledPlugins": { + "impeccable@impeccable": true, + "frontend-design@claude-plugins-official": true + } +} diff --git a/.prettierrc b/.prettierrc index 7ebb855..7317c54 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,15 +1,16 @@ -{ - "useTabs": true, - "singleQuote": true, - "trailingComma": "none", - "printWidth": 100, - "plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"], - "overrides": [ - { - "files": "*.svelte", - "options": { - "parser": "svelte" - } - } - ] -} +{ + "useTabs": false, + "tabWidth": 2, + "singleQuote": true, + "trailingComma": "none", + "printWidth": 100, + "plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"], + "overrides": [ + { + "files": "*.svelte", + "options": { + "parser": "svelte" + } + } + ] +} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..7375e1d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,50 @@ +# Rez — Claude Guidelines + +## Project Overview + +**Rez (Rezonate)** is a lightweight, real-time social status app. Users post brief status messages (max 42 characters) so a small circle of close friends knows what they're up to right now. It's privacy-first (friend-only, no public feed), installable as a PWA, and supports 35 DaisyUI themes. + +## Tech Stack + +- **Framework:** SvelteKit 2 + Svelte 5 (rune-based: `$state`, `$derived`, `$effect`, `$props`, `$bindable`) +- **Styling:** Tailwind CSS v4 + DaisyUI v5 (all 35 themes enabled) +- **Backend:** Supabase (PostgreSQL, Auth, Realtime) +- **Language:** TypeScript +- **Build:** Vite 7 + +## Code Conventions + +- **Indentation:** 2 spaces (no tabs) — `.prettierrc` enforces this +- **Components:** Svelte 5 runes only (no legacy Options API / stores except `toastStore`) +- **Animations:** Centralized in `src/app.css` — easing tokens (`--ease-out-quart`, `--ease-out-expo`), keyframes, and utility classes. Do not define duplicate keyframes in component ` + + +
+
+

My Friends

+
+ {#if friends && friends.length > 0} + {#each friends as friend, index (friend.id)} + {#if draggedIndex !== -1 && dropGapIndex === index && !isNeutralGap(dropGapIndex, draggedIndex)} +
+ {/if} +
{ + if (e.key === 'Enter' || e.key === ' ') e.preventDefault(); + }} + ondragstart={(e) => handleDragStart(e, friend, index)} + ondragover={(e) => handleDragOver(e, index)} + ondrop={handleDrop} + ondragend={handleDragEnd} + > +
+ +
+
+ +
+
+ +
+
+
+

+ {getDisplayName(friend.display_name, friend.username)} +

+ {#if friend.display_name} +

@{friend.username}

+ {/if} +
+ + +
+ +
+ {#key friend.status} + {#if friend.status} +
+

+ {friend.status} +

+ {#if friend.status_updated_at} + + + + {#if expandedTimestampId === friend.id} +

+ {formatStatusUpdatedAtTooltip(friend.status_updated_at)} +

+ {/if} + {/if} +
+ {:else} +

No status

+ {/if} + {/key} +
+
+
+
+ {/each} + + {#if draggedIndex !== -1 && dropGapIndex === friends.length && !isNeutralGap(dropGapIndex, draggedIndex)} +
+ {/if} + {:else} +
+

No friends yet

+

+ Search for friends by username in the panel below. +

+
+ {/if} +
+
+
+ + diff --git a/src/lib/friends/components/ListSkeleton.svelte b/src/lib/friends/components/ListSkeleton.svelte index 84fd080..2d0bf6e 100644 --- a/src/lib/friends/components/ListSkeleton.svelte +++ b/src/lib/friends/components/ListSkeleton.svelte @@ -1,32 +1,32 @@ - - -
-
-
-

My Friends

-
- {#each Array.from({ length: 3 }, (_, i) => i) as i (i)} -
-
-
- -
-
-
-
-
-
-
-
-
-
-
-
- {/each} -
-
-
-
+ + +
+
+
+

My Friends

+
+ {#each Array.from({ length: 3 }, (_, i) => i) as i (i)} +
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+ {/each} +
+
+
+
diff --git a/src/lib/friends/components/Requests.svelte b/src/lib/friends/components/Requests.svelte index 8b82ff3..f5e3073 100644 --- a/src/lib/friends/components/Requests.svelte +++ b/src/lib/friends/components/Requests.svelte @@ -1,438 +1,460 @@
-
-

Friend Requests

- -
-
-
- -
- -
-
- - {#if friendRequests && friendRequests.length > 0} -

Friend Requests

- - {/if} - - {#if sentFriendRequests && sentFriendRequests.length > 0} -

Sent Friend Requests

- - {/if} -
+
+

Add Friends

+ +
+
+
+ +
+ +
+
+

+ Ask your friend for their username — they can find it in Settings. +

+ + {#if (!friendRequests || friendRequests.length === 0) && (!sentFriendRequests || sentFriendRequests.length === 0)} +

+ No pending requests. Enter a username above to find someone. +

+ {/if} + + {#if friendRequests && friendRequests.length > 0} + + {/if} + + {#if sentFriendRequests && sentFriendRequests.length > 0} +

Sent

+ + {/if} +
diff --git a/src/lib/friends/components/RequestsSkeleton.svelte b/src/lib/friends/components/RequestsSkeleton.svelte index 884aa17..b0de2e0 100644 --- a/src/lib/friends/components/RequestsSkeleton.svelte +++ b/src/lib/friends/components/RequestsSkeleton.svelte @@ -1,29 +1,29 @@ - - -
-
-

Friend Requests

- -
-
-
-
-
-
- -
- {#each Array.from({ length: 2 }, (_, i) => i) as i (i)} -
-
-
-
-
-
-
-
-
- {/each} -
-
-
+ + +
+
+

Friend Requests

+ +
+
+
+
+
+
+ +
+ {#each Array.from({ length: 2 }, (_, i) => i) as i (i)} +
+
+
+
+
+
+
+
+
+ {/each} +
+
+
diff --git a/src/lib/friends/order.test.ts b/src/lib/friends/order.test.ts index 0b9c673..7f379b7 100644 --- a/src/lib/friends/order.test.ts +++ b/src/lib/friends/order.test.ts @@ -1,111 +1,111 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { FriendOrderStore } from './order'; - -const localStorageMock = (() => { - let store: Record = {}; - return { - getItem: vi.fn((key: string) => store[key] ?? null), - setItem: vi.fn((key: string, value: string) => { - store[key] = value; - }), - removeItem: vi.fn((key: string) => { - delete store[key]; - }), - clear: vi.fn(() => { - store = {}; - }), - get length() { - return Object.keys(store).length; - }, - key: vi.fn((index: number) => Object.keys(store)[index] ?? null) - }; -})(); - -Object.defineProperty(globalThis, 'window', { - value: { localStorage: localStorageMock }, - writable: true -}); -Object.defineProperty(globalThis, 'localStorage', { - value: localStorageMock, - writable: true -}); - -vi.mock('$app/environment', () => ({ browser: true })); - -function makeFriend(id: string) { - return { id, username: id, display_name: null, status: null, status_updated_at: null }; -} - -describe('FriendOrderStore', () => { - beforeEach(() => { - localStorageMock.clear(); - vi.clearAllMocks(); - }); - - it('returns friends in original order when no order is stored', () => { - const store = new FriendOrderStore(); - const friends = [makeFriend('a'), makeFriend('b'), makeFriend('c')]; - - const ordered = store.getOrderedFriends(friends); - expect(ordered.map((f) => f.id)).toEqual(['a', 'b', 'c']); - }); - - it('persists order to localStorage on first call', () => { - const store = new FriendOrderStore(); - const friends = [makeFriend('a'), makeFriend('b')]; - - store.getOrderedFriends(friends); - expect(localStorageMock.setItem).toHaveBeenCalledWith( - 'friend-order', - JSON.stringify(['a', 'b']) - ); - }); - - it('applies stored order', () => { - localStorageMock.setItem('friend-order', JSON.stringify(['c', 'a', 'b'])); - const store = new FriendOrderStore(); - const friends = [makeFriend('a'), makeFriend('b'), makeFriend('c')]; - - const ordered = store.getOrderedFriends(friends); - expect(ordered.map((f) => f.id)).toEqual(['c', 'a', 'b']); - }); - - it('appends new friends to the end', () => { - localStorageMock.setItem('friend-order', JSON.stringify(['a', 'b'])); - const store = new FriendOrderStore(); - const friends = [makeFriend('a'), makeFriend('b'), makeFriend('c')]; - - const ordered = store.getOrderedFriends(friends); - expect(ordered.map((f) => f.id)).toEqual(['a', 'b', 'c']); - }); - - it('skips IDs no longer in the friend list', () => { - localStorageMock.setItem('friend-order', JSON.stringify(['a', 'b', 'c'])); - const store = new FriendOrderStore(); - const friends = [makeFriend('a'), makeFriend('c')]; - - const ordered = store.getOrderedFriends(friends); - expect(ordered.map((f) => f.id)).toEqual(['a', 'c']); - }); - - it('updateOrder persists new order', () => { - const store = new FriendOrderStore(); - const reordered = [makeFriend('c'), makeFriend('a'), makeFriend('b')]; - - store.updateOrder(reordered); - expect(localStorageMock.setItem).toHaveBeenCalledWith( - 'friend-order', - JSON.stringify(['c', 'a', 'b']) - ); - }); - - it('removeFriend removes from stored order', () => { - localStorageMock.setItem('friend-order', JSON.stringify(['a', 'b', 'c'])); - const store = new FriendOrderStore(); - store.getOrderedFriends([makeFriend('a'), makeFriend('b'), makeFriend('c')]); - - store.removeFriend('b'); - const stored = JSON.parse(localStorageMock.getItem('friend-order')!); - expect(stored).toEqual(['a', 'c']); - }); -}); +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { FriendOrderStore } from './order'; + +const localStorageMock = (() => { + let store: Record = {}; + return { + getItem: vi.fn((key: string) => store[key] ?? null), + setItem: vi.fn((key: string, value: string) => { + store[key] = value; + }), + removeItem: vi.fn((key: string) => { + delete store[key]; + }), + clear: vi.fn(() => { + store = {}; + }), + get length() { + return Object.keys(store).length; + }, + key: vi.fn((index: number) => Object.keys(store)[index] ?? null) + }; +})(); + +Object.defineProperty(globalThis, 'window', { + value: { localStorage: localStorageMock }, + writable: true +}); +Object.defineProperty(globalThis, 'localStorage', { + value: localStorageMock, + writable: true +}); + +vi.mock('$app/environment', () => ({ browser: true })); + +function makeFriend(id: string) { + return { id, username: id, display_name: null, status: null, status_updated_at: null }; +} + +describe('FriendOrderStore', () => { + beforeEach(() => { + localStorageMock.clear(); + vi.clearAllMocks(); + }); + + it('returns friends in original order when no order is stored', () => { + const store = new FriendOrderStore(); + const friends = [makeFriend('a'), makeFriend('b'), makeFriend('c')]; + + const ordered = store.getOrderedFriends(friends); + expect(ordered.map((f) => f.id)).toEqual(['a', 'b', 'c']); + }); + + it('persists order to localStorage on first call', () => { + const store = new FriendOrderStore(); + const friends = [makeFriend('a'), makeFriend('b')]; + + store.getOrderedFriends(friends); + expect(localStorageMock.setItem).toHaveBeenCalledWith( + 'friend-order', + JSON.stringify(['a', 'b']) + ); + }); + + it('applies stored order', () => { + localStorageMock.setItem('friend-order', JSON.stringify(['c', 'a', 'b'])); + const store = new FriendOrderStore(); + const friends = [makeFriend('a'), makeFriend('b'), makeFriend('c')]; + + const ordered = store.getOrderedFriends(friends); + expect(ordered.map((f) => f.id)).toEqual(['c', 'a', 'b']); + }); + + it('appends new friends to the end', () => { + localStorageMock.setItem('friend-order', JSON.stringify(['a', 'b'])); + const store = new FriendOrderStore(); + const friends = [makeFriend('a'), makeFriend('b'), makeFriend('c')]; + + const ordered = store.getOrderedFriends(friends); + expect(ordered.map((f) => f.id)).toEqual(['a', 'b', 'c']); + }); + + it('skips IDs no longer in the friend list', () => { + localStorageMock.setItem('friend-order', JSON.stringify(['a', 'b', 'c'])); + const store = new FriendOrderStore(); + const friends = [makeFriend('a'), makeFriend('c')]; + + const ordered = store.getOrderedFriends(friends); + expect(ordered.map((f) => f.id)).toEqual(['a', 'c']); + }); + + it('updateOrder persists new order', () => { + const store = new FriendOrderStore(); + const reordered = [makeFriend('c'), makeFriend('a'), makeFriend('b')]; + + store.updateOrder(reordered); + expect(localStorageMock.setItem).toHaveBeenCalledWith( + 'friend-order', + JSON.stringify(['c', 'a', 'b']) + ); + }); + + it('removeFriend removes from stored order', () => { + localStorageMock.setItem('friend-order', JSON.stringify(['a', 'b', 'c'])); + const store = new FriendOrderStore(); + store.getOrderedFriends([makeFriend('a'), makeFriend('b'), makeFriend('c')]); + + store.removeFriend('b'); + const stored = JSON.parse(localStorageMock.getItem('friend-order')!); + expect(stored).toEqual(['a', 'c']); + }); +}); diff --git a/src/lib/friends/order.ts b/src/lib/friends/order.ts index 6b3c413..8b5e853 100644 --- a/src/lib/friends/order.ts +++ b/src/lib/friends/order.ts @@ -1,83 +1,83 @@ -import { browser } from '$app/environment'; - -interface Friend { - id: string; - display_name: string | null; - username: string; - status: string | null; - status_updated_at: string | null; -} - -export class FriendOrderStore { - private order: string[] = []; - private readonly STORAGE_KEY = 'friend-order'; - - constructor() { - this.loadFromStorage(); - } - - private loadFromStorage(): void { - if (!browser) return; - - try { - const stored = localStorage.getItem(this.STORAGE_KEY); - if (stored) { - this.order = JSON.parse(stored); - } - } catch (error) { - console.warn('Failed to load friend order from localStorage:', error); - this.order = []; - } - } - - private saveToStorage(): void { - if (!browser) return; - - try { - localStorage.setItem(this.STORAGE_KEY, JSON.stringify(this.order)); - } catch (error) { - console.warn('Failed to save friend order to localStorage:', error); - } - } - - getOrderedFriends(friends: Friend[]): Friend[] { - if (this.order.length === 0) { - this.order = friends.map((f) => f.id); - this.saveToStorage(); - return friends; - } - - const storedSet = new Set(this.order); - const friendMap = new Map(friends.map((f) => [f.id, f])); - - const ordered: Friend[] = []; - for (const id of this.order) { - const f = friendMap.get(id); - if (f) ordered.push(f); - } - - const newFriends = friends.filter((f) => !storedSet.has(f.id)); - - if (newFriends.length > 0) { - const result = [...ordered, ...newFriends]; - this.order = result.map((f) => f.id); - this.saveToStorage(); - return result; - } - - return ordered; - } - - updateOrder(newOrder: Friend[]): void { - this.order = newOrder.map(friend => friend.id); - this.saveToStorage(); - } - - removeFriend(friendId: string): void { - this.order = this.order.filter(id => id !== friendId); - this.saveToStorage(); - } - -} - -export const friendOrderStore = new FriendOrderStore(); +import { browser } from '$app/environment'; + +interface Friend { + id: string; + display_name: string | null; + username: string; + status: string | null; + status_updated_at: string | null; +} + +export class FriendOrderStore { + private order: string[] = []; + private readonly STORAGE_KEY = 'friend-order'; + + constructor() { + this.loadFromStorage(); + } + + private loadFromStorage(): void { + if (!browser) return; + + try { + const stored = localStorage.getItem(this.STORAGE_KEY); + if (stored) { + this.order = JSON.parse(stored); + } + } catch (error) { + console.warn('Failed to load friend order from localStorage:', error); + this.order = []; + } + } + + private saveToStorage(): void { + if (!browser) return; + + try { + localStorage.setItem(this.STORAGE_KEY, JSON.stringify(this.order)); + } catch (error) { + console.warn('Failed to save friend order to localStorage:', error); + } + } + + getOrderedFriends(friends: Friend[]): Friend[] { + if (this.order.length === 0) { + this.order = friends.map((f) => f.id); + this.saveToStorage(); + return friends; + } + + const storedSet = new Set(this.order); + const friendMap = new Map(friends.map((f) => [f.id, f])); + + const ordered: Friend[] = []; + for (const id of this.order) { + const f = friendMap.get(id); + if (f) ordered.push(f); + } + + const newFriends = friends.filter((f) => !storedSet.has(f.id)); + + if (newFriends.length > 0) { + const result = [...ordered, ...newFriends]; + this.order = result.map((f) => f.id); + this.saveToStorage(); + return result; + } + + return ordered; + } + + updateOrder(newOrder: Friend[]): void { + this.order = newOrder.map(friend => friend.id); + this.saveToStorage(); + } + + removeFriend(friendId: string): void { + this.order = this.order.filter(id => id !== friendId); + this.saveToStorage(); + } + +} + +export const friendOrderStore = new FriendOrderStore(); diff --git a/src/lib/profile/api.ts b/src/lib/profile/api.ts index 6999aa7..0978a64 100644 --- a/src/lib/profile/api.ts +++ b/src/lib/profile/api.ts @@ -1,25 +1,25 @@ -import type { SupabaseClient } from '@supabase/supabase-js'; - -export async function checkUsernameAvailability( - supabase: SupabaseClient, - username: string, - currentUserId: string -): Promise { - try { - const { data: existingUser, error } = await supabase - .from('users') - .select('id') - .eq('username', username) - .neq('id', currentUserId) - .single(); - - if (error && error.code !== 'PGRST116') { - throw error; - } - - return !existingUser; - } catch (error) { - console.error('Error checking username availability:', error); - throw error; - } -} +import type { SupabaseClient } from '@supabase/supabase-js'; + +export async function checkUsernameAvailability( + supabase: SupabaseClient, + username: string, + currentUserId: string +): Promise { + try { + const { data: existingUser, error } = await supabase + .from('users') + .select('id') + .eq('username', username) + .neq('id', currentUserId) + .single(); + + if (error && error.code !== 'PGRST116') { + throw error; + } + + return !existingUser; + } catch (error) { + console.error('Error checking username availability:', error); + throw error; + } +} diff --git a/src/lib/profile/validation.test.ts b/src/lib/profile/validation.test.ts index acdf531..0a63139 100644 --- a/src/lib/profile/validation.test.ts +++ b/src/lib/profile/validation.test.ts @@ -1,112 +1,112 @@ -import { describe, it, expect } from 'vitest'; -import { - validateUsername, - validateDisplayName, - sanitizeUsername, - sanitizeDisplayName, - MIN_USERNAME_LENGTH, - MAX_USERNAME_LENGTH, - MAX_DISPLAY_NAME_LENGTH, - ERROR_MESSAGES -} from './validation'; - -describe('validateUsername', () => { - it('rejects empty username', () => { - expect(validateUsername('')).toBe(ERROR_MESSAGES.USERNAME_EMPTY); - }); - - it('rejects username shorter than minimum', () => { - expect(validateUsername('ab')).toBe(ERROR_MESSAGES.USERNAME_TOO_SHORT); - expect(validateUsername('a')).toBe(ERROR_MESSAGES.USERNAME_TOO_SHORT); - }); - - it('accepts username at minimum length', () => { - expect(validateUsername('abc')).toBeNull(); - }); - - it('rejects username exceeding max length', () => { - const long = 'a'.repeat(MAX_USERNAME_LENGTH + 1); - expect(validateUsername(long)).toBe(ERROR_MESSAGES.USERNAME_TOO_LONG); - }); - - it('accepts username at max length', () => { - const exact = 'a'.repeat(MAX_USERNAME_LENGTH); - expect(validateUsername(exact)).toBeNull(); - }); - - it('rejects username starting with a number', () => { - expect(validateUsername('1user')).toBe(ERROR_MESSAGES.USERNAME_INVALID); - }); - - it('rejects username starting with a dot', () => { - expect(validateUsername('.user')).toBe(ERROR_MESSAGES.USERNAME_INVALID); - }); - - it('rejects username with spaces', () => { - expect(validateUsername('user name')).toBe(ERROR_MESSAGES.USERNAME_INVALID); - }); - - it('rejects username with special characters', () => { - expect(validateUsername('user@name')).toBe(ERROR_MESSAGES.USERNAME_INVALID); - expect(validateUsername('user!name')).toBe(ERROR_MESSAGES.USERNAME_INVALID); - }); - - it('accepts valid usernames', () => { - expect(validateUsername('alice')).toBeNull(); - expect(validateUsername('Bob42')).toBeNull(); - expect(validateUsername('user.name')).toBeNull(); - expect(validateUsername('user-name')).toBeNull(); - expect(validateUsername('user_name')).toBeNull(); - expect(validateUsername('A.b-c_1')).toBeNull(); - }); - - it('enforces documented length constants', () => { - expect(MIN_USERNAME_LENGTH).toBe(3); - expect(MAX_USERNAME_LENGTH).toBe(20); - }); -}); - -describe('validateDisplayName', () => { - it('rejects empty display name', () => { - expect(validateDisplayName('')).toBe(ERROR_MESSAGES.DISPLAY_NAME_EMPTY); - }); - - it('rejects display name exceeding max length', () => { - const long = 'a'.repeat(MAX_DISPLAY_NAME_LENGTH + 1); - expect(validateDisplayName(long)).toBe(ERROR_MESSAGES.DISPLAY_NAME_TOO_LONG); - }); - - it('accepts display name at max length', () => { - const exact = 'a'.repeat(MAX_DISPLAY_NAME_LENGTH); - expect(validateDisplayName(exact)).toBeNull(); - }); - - it('accepts normal display names', () => { - expect(validateDisplayName('Alice Smith')).toBeNull(); - expect(validateDisplayName('Bob')).toBeNull(); - }); - - it('enforces documented max length constant', () => { - expect(MAX_DISPLAY_NAME_LENGTH).toBe(50); - }); -}); - -describe('sanitizeUsername', () => { - it('trims whitespace', () => { - expect(sanitizeUsername(' alice ')).toBe('alice'); - }); - - it('returns trimmed value unchanged', () => { - expect(sanitizeUsername('bob')).toBe('bob'); - }); -}); - -describe('sanitizeDisplayName', () => { - it('trims whitespace', () => { - expect(sanitizeDisplayName(' Alice ')).toBe('Alice'); - }); - - it('returns trimmed value unchanged', () => { - expect(sanitizeDisplayName('Bob')).toBe('Bob'); - }); -}); +import { describe, it, expect } from 'vitest'; +import { + validateUsername, + validateDisplayName, + sanitizeUsername, + sanitizeDisplayName, + MIN_USERNAME_LENGTH, + MAX_USERNAME_LENGTH, + MAX_DISPLAY_NAME_LENGTH, + ERROR_MESSAGES +} from './validation'; + +describe('validateUsername', () => { + it('rejects empty username', () => { + expect(validateUsername('')).toBe(ERROR_MESSAGES.USERNAME_EMPTY); + }); + + it('rejects username shorter than minimum', () => { + expect(validateUsername('ab')).toBe(ERROR_MESSAGES.USERNAME_TOO_SHORT); + expect(validateUsername('a')).toBe(ERROR_MESSAGES.USERNAME_TOO_SHORT); + }); + + it('accepts username at minimum length', () => { + expect(validateUsername('abc')).toBeNull(); + }); + + it('rejects username exceeding max length', () => { + const long = 'a'.repeat(MAX_USERNAME_LENGTH + 1); + expect(validateUsername(long)).toBe(ERROR_MESSAGES.USERNAME_TOO_LONG); + }); + + it('accepts username at max length', () => { + const exact = 'a'.repeat(MAX_USERNAME_LENGTH); + expect(validateUsername(exact)).toBeNull(); + }); + + it('rejects username starting with a number', () => { + expect(validateUsername('1user')).toBe(ERROR_MESSAGES.USERNAME_INVALID); + }); + + it('rejects username starting with a dot', () => { + expect(validateUsername('.user')).toBe(ERROR_MESSAGES.USERNAME_INVALID); + }); + + it('rejects username with spaces', () => { + expect(validateUsername('user name')).toBe(ERROR_MESSAGES.USERNAME_INVALID); + }); + + it('rejects username with special characters', () => { + expect(validateUsername('user@name')).toBe(ERROR_MESSAGES.USERNAME_INVALID); + expect(validateUsername('user!name')).toBe(ERROR_MESSAGES.USERNAME_INVALID); + }); + + it('accepts valid usernames', () => { + expect(validateUsername('alice')).toBeNull(); + expect(validateUsername('Bob42')).toBeNull(); + expect(validateUsername('user.name')).toBeNull(); + expect(validateUsername('user-name')).toBeNull(); + expect(validateUsername('user_name')).toBeNull(); + expect(validateUsername('A.b-c_1')).toBeNull(); + }); + + it('enforces documented length constants', () => { + expect(MIN_USERNAME_LENGTH).toBe(3); + expect(MAX_USERNAME_LENGTH).toBe(20); + }); +}); + +describe('validateDisplayName', () => { + it('rejects empty display name', () => { + expect(validateDisplayName('')).toBe(ERROR_MESSAGES.DISPLAY_NAME_EMPTY); + }); + + it('rejects display name exceeding max length', () => { + const long = 'a'.repeat(MAX_DISPLAY_NAME_LENGTH + 1); + expect(validateDisplayName(long)).toBe(ERROR_MESSAGES.DISPLAY_NAME_TOO_LONG); + }); + + it('accepts display name at max length', () => { + const exact = 'a'.repeat(MAX_DISPLAY_NAME_LENGTH); + expect(validateDisplayName(exact)).toBeNull(); + }); + + it('accepts normal display names', () => { + expect(validateDisplayName('Alice Smith')).toBeNull(); + expect(validateDisplayName('Bob')).toBeNull(); + }); + + it('enforces documented max length constant', () => { + expect(MAX_DISPLAY_NAME_LENGTH).toBe(50); + }); +}); + +describe('sanitizeUsername', () => { + it('trims whitespace', () => { + expect(sanitizeUsername(' alice ')).toBe('alice'); + }); + + it('returns trimmed value unchanged', () => { + expect(sanitizeUsername('bob')).toBe('bob'); + }); +}); + +describe('sanitizeDisplayName', () => { + it('trims whitespace', () => { + expect(sanitizeDisplayName(' Alice ')).toBe('Alice'); + }); + + it('returns trimmed value unchanged', () => { + expect(sanitizeDisplayName('Bob')).toBe('Bob'); + }); +}); diff --git a/src/lib/profile/validation.ts b/src/lib/profile/validation.ts index eb08fb7..2f4733a 100644 --- a/src/lib/profile/validation.ts +++ b/src/lib/profile/validation.ts @@ -1,49 +1,49 @@ -export const MIN_USERNAME_LENGTH = 3; -export const MAX_USERNAME_LENGTH = 20; -export const MAX_DISPLAY_NAME_LENGTH = 50; -export const USERNAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9._-]*$/; - -export const ERROR_MESSAGES = { - USERNAME_EMPTY: 'Username cannot be empty', - USERNAME_TOO_SHORT: `Username must be at least ${MIN_USERNAME_LENGTH} characters`, - USERNAME_TOO_LONG: `Username must be ${MAX_USERNAME_LENGTH} characters or less`, - USERNAME_INVALID: - 'Username must start with a letter and can only contain letters, numbers, dots, dashes, and underscores', - USERNAME_TAKEN: 'Username is already taken', - DISPLAY_NAME_TOO_LONG: `Display name must be ${MAX_DISPLAY_NAME_LENGTH} characters or less`, - DISPLAY_NAME_EMPTY: 'Display name cannot be empty' -} as const; - -export function validateUsername(username: string): string | null { - if (username.length === 0) { - return ERROR_MESSAGES.USERNAME_EMPTY; - } - if (username.length < MIN_USERNAME_LENGTH) { - return ERROR_MESSAGES.USERNAME_TOO_SHORT; - } - if (username.length > MAX_USERNAME_LENGTH) { - return ERROR_MESSAGES.USERNAME_TOO_LONG; - } - if (!USERNAME_PATTERN.test(username)) { - return ERROR_MESSAGES.USERNAME_INVALID; - } - return null; -} - -export function validateDisplayName(displayName: string): string | null { - if (displayName.length === 0) { - return ERROR_MESSAGES.DISPLAY_NAME_EMPTY; - } - if (displayName.length > MAX_DISPLAY_NAME_LENGTH) { - return ERROR_MESSAGES.DISPLAY_NAME_TOO_LONG; - } - return null; -} - -export function sanitizeUsername(username: string): string { - return username.trim(); -} - -export function sanitizeDisplayName(displayName: string): string { - return displayName.trim(); -} +export const MIN_USERNAME_LENGTH = 3; +export const MAX_USERNAME_LENGTH = 20; +export const MAX_DISPLAY_NAME_LENGTH = 50; +export const USERNAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9._-]*$/; + +export const ERROR_MESSAGES = { + USERNAME_EMPTY: 'Username cannot be empty', + USERNAME_TOO_SHORT: `Username must be at least ${MIN_USERNAME_LENGTH} characters`, + USERNAME_TOO_LONG: `Username must be ${MAX_USERNAME_LENGTH} characters or less`, + USERNAME_INVALID: + 'Must start with a letter. Letters, numbers, dots, dashes, and underscores only.', + USERNAME_TAKEN: 'Username is already taken', + DISPLAY_NAME_TOO_LONG: `Display name must be ${MAX_DISPLAY_NAME_LENGTH} characters or less`, + DISPLAY_NAME_EMPTY: 'Display name cannot be empty' +} as const; + +export function validateUsername(username: string): string | null { + if (username.length === 0) { + return ERROR_MESSAGES.USERNAME_EMPTY; + } + if (username.length < MIN_USERNAME_LENGTH) { + return ERROR_MESSAGES.USERNAME_TOO_SHORT; + } + if (username.length > MAX_USERNAME_LENGTH) { + return ERROR_MESSAGES.USERNAME_TOO_LONG; + } + if (!USERNAME_PATTERN.test(username)) { + return ERROR_MESSAGES.USERNAME_INVALID; + } + return null; +} + +export function validateDisplayName(displayName: string): string | null { + if (displayName.length === 0) { + return ERROR_MESSAGES.DISPLAY_NAME_EMPTY; + } + if (displayName.length > MAX_DISPLAY_NAME_LENGTH) { + return ERROR_MESSAGES.DISPLAY_NAME_TOO_LONG; + } + return null; +} + +export function sanitizeUsername(username: string): string { + return username.trim(); +} + +export function sanitizeDisplayName(displayName: string): string { + return displayName.trim(); +} diff --git a/src/lib/realtime/subscriptions.ts b/src/lib/realtime/subscriptions.ts index 812d9f9..bb4f11c 100644 --- a/src/lib/realtime/subscriptions.ts +++ b/src/lib/realtime/subscriptions.ts @@ -1,167 +1,167 @@ -import type { RealtimeChannel, RealtimePostgresChangesPayload, SupabaseClient } from '@supabase/supabase-js'; -import type { Database } from '../../database.types'; - -export interface StatusChangePayload { - id: string; - status: string | null; - updated_at: string; -} - -interface SubscriptionCallbacks { - onFriendRequestChange?: () => void; - onFriendshipChange?: () => void; - onStatusChange?: (payload: StatusChangePayload) => void; -} - -// Realtime only supports a single eq filter per channel, so each table needs two channels. -export class RealtimeSubscriptionManager { - private supabase: SupabaseClient; - private userId: string; - private channels: RealtimeChannel[] = []; - private profileChannel: RealtimeChannel | null = null; - private callbacks: SubscriptionCallbacks = {}; - private subscribedFriendIds: string[] = []; - - constructor(supabase: SupabaseClient, userId: string) { - this.supabase = supabase; - this.userId = userId; - } - - subscribe(callbacks: SubscriptionCallbacks): void { - this.callbacks = callbacks; - this.subscribeToFriendRequests(); - this.subscribeToFriends(); - } - - updateFriendIds(friendIds: string[]): void { - const sorted = [...friendIds].sort(); - - const unchanged = - sorted.length === this.subscribedFriendIds.length && - sorted.every((id, i) => id === this.subscribedFriendIds[i]); - if (unchanged) return; - - this.subscribedFriendIds = sorted; - - if (this.profileChannel) { - this.supabase.removeChannel(this.profileChannel); - this.profileChannel = null; - } - - if (sorted.length > 0) { - this.profileChannel = this.openProfileChannel(sorted); - } - } - - private subscribeToFriendRequests(): void { - const outgoing = this.supabase - .channel(`friend_requests_from_${this.userId}`) - .on( - 'postgres_changes', - { - event: '*', - schema: 'public', - table: 'friend_requests', - filter: `requester_id=eq.${this.userId}` - }, - () => this.callbacks.onFriendRequestChange?.() - ) - .subscribe((_, err) => { - if (err) console.error('Realtime: friend_requests (outgoing) error:', err); - }); - - const incoming = this.supabase - .channel(`friend_requests_to_${this.userId}`) - .on( - 'postgres_changes', - { - event: '*', - schema: 'public', - table: 'friend_requests', - filter: `target_id=eq.${this.userId}` - }, - () => this.callbacks.onFriendRequestChange?.() - ) - .subscribe((_, err) => { - if (err) console.error('Realtime: friend_requests (incoming) error:', err); - }); - - this.channels.push(outgoing, incoming); - } - - private subscribeToFriends(): void { - const asUser = this.supabase - .channel(`friends_user_${this.userId}`) - .on( - 'postgres_changes', - { - event: '*', - schema: 'public', - table: 'friends', - filter: `user_id=eq.${this.userId}` - }, - () => this.callbacks.onFriendshipChange?.() - ) - .subscribe((_, err) => { - if (err) console.error('Realtime: friends (as user_id) error:', err); - }); - - const asFriend = this.supabase - .channel(`friends_friend_${this.userId}`) - .on( - 'postgres_changes', - { - event: '*', - schema: 'public', - table: 'friends', - filter: `friend_id=eq.${this.userId}` - }, - () => this.callbacks.onFriendshipChange?.() - ) - .subscribe((_, err) => { - if (err) console.error('Realtime: friends (as friend_id) error:', err); - }); - - this.channels.push(asUser, asFriend); - } - - private openProfileChannel(friendIds: string[]): RealtimeChannel { - return this.supabase - .channel(`profiles_friends_${this.userId}`) - .on( - 'postgres_changes', - { - event: 'UPDATE', - schema: 'public', - table: 'profiles', - filter: `id=in.(${friendIds.join(',')})` - }, - (payload: RealtimePostgresChangesPayload<{ id: string; status: string | null; updated_at: string }>) => { - const row = payload.new as { id: string; status: string | null; updated_at: string } | undefined; - if (row?.id) { - this.callbacks.onStatusChange?.({ - id: row.id, - status: row.status, - updated_at: row.updated_at - }); - } - } - ) - .subscribe((_, err) => { - if (err) console.error('Realtime: profiles error:', err); - }); - } - - unsubscribe(): void { - for (const channel of this.channels) { - this.supabase.removeChannel(channel); - } - if (this.profileChannel) { - this.supabase.removeChannel(this.profileChannel); - this.profileChannel = null; - } - this.channels = []; - this.callbacks = {}; - this.subscribedFriendIds = []; - } -} +import type { RealtimeChannel, RealtimePostgresChangesPayload, SupabaseClient } from '@supabase/supabase-js'; +import type { Database } from '../../database.types'; + +export interface StatusChangePayload { + id: string; + status: string | null; + updated_at: string; +} + +interface SubscriptionCallbacks { + onFriendRequestChange?: () => void; + onFriendshipChange?: () => void; + onStatusChange?: (payload: StatusChangePayload) => void; +} + +// Realtime only supports a single eq filter per channel, so each table needs two channels. +export class RealtimeSubscriptionManager { + private supabase: SupabaseClient; + private userId: string; + private channels: RealtimeChannel[] = []; + private profileChannel: RealtimeChannel | null = null; + private callbacks: SubscriptionCallbacks = {}; + private subscribedFriendIds: string[] = []; + + constructor(supabase: SupabaseClient, userId: string) { + this.supabase = supabase; + this.userId = userId; + } + + subscribe(callbacks: SubscriptionCallbacks): void { + this.callbacks = callbacks; + this.subscribeToFriendRequests(); + this.subscribeToFriends(); + } + + updateFriendIds(friendIds: string[]): void { + const sorted = [...friendIds].sort(); + + const unchanged = + sorted.length === this.subscribedFriendIds.length && + sorted.every((id, i) => id === this.subscribedFriendIds[i]); + if (unchanged) return; + + this.subscribedFriendIds = sorted; + + if (this.profileChannel) { + this.supabase.removeChannel(this.profileChannel); + this.profileChannel = null; + } + + if (sorted.length > 0) { + this.profileChannel = this.openProfileChannel(sorted); + } + } + + private subscribeToFriendRequests(): void { + const outgoing = this.supabase + .channel(`friend_requests_from_${this.userId}`) + .on( + 'postgres_changes', + { + event: '*', + schema: 'public', + table: 'friend_requests', + filter: `requester_id=eq.${this.userId}` + }, + () => this.callbacks.onFriendRequestChange?.() + ) + .subscribe((_, err) => { + if (err) console.error('Realtime: friend_requests (outgoing) error:', err); + }); + + const incoming = this.supabase + .channel(`friend_requests_to_${this.userId}`) + .on( + 'postgres_changes', + { + event: '*', + schema: 'public', + table: 'friend_requests', + filter: `target_id=eq.${this.userId}` + }, + () => this.callbacks.onFriendRequestChange?.() + ) + .subscribe((_, err) => { + if (err) console.error('Realtime: friend_requests (incoming) error:', err); + }); + + this.channels.push(outgoing, incoming); + } + + private subscribeToFriends(): void { + const asUser = this.supabase + .channel(`friends_user_${this.userId}`) + .on( + 'postgres_changes', + { + event: '*', + schema: 'public', + table: 'friends', + filter: `user_id=eq.${this.userId}` + }, + () => this.callbacks.onFriendshipChange?.() + ) + .subscribe((_, err) => { + if (err) console.error('Realtime: friends (as user_id) error:', err); + }); + + const asFriend = this.supabase + .channel(`friends_friend_${this.userId}`) + .on( + 'postgres_changes', + { + event: '*', + schema: 'public', + table: 'friends', + filter: `friend_id=eq.${this.userId}` + }, + () => this.callbacks.onFriendshipChange?.() + ) + .subscribe((_, err) => { + if (err) console.error('Realtime: friends (as friend_id) error:', err); + }); + + this.channels.push(asUser, asFriend); + } + + private openProfileChannel(friendIds: string[]): RealtimeChannel { + return this.supabase + .channel(`profiles_friends_${this.userId}`) + .on( + 'postgres_changes', + { + event: 'UPDATE', + schema: 'public', + table: 'profiles', + filter: `id=in.(${friendIds.join(',')})` + }, + (payload: RealtimePostgresChangesPayload<{ id: string; status: string | null; updated_at: string }>) => { + const row = payload.new as { id: string; status: string | null; updated_at: string } | undefined; + if (row?.id) { + this.callbacks.onStatusChange?.({ + id: row.id, + status: row.status, + updated_at: row.updated_at + }); + } + } + ) + .subscribe((_, err) => { + if (err) console.error('Realtime: profiles error:', err); + }); + } + + unsubscribe(): void { + for (const channel of this.channels) { + this.supabase.removeChannel(channel); + } + if (this.profileChannel) { + this.supabase.removeChannel(this.profileChannel); + this.profileChannel = null; + } + this.channels = []; + this.callbacks = {}; + this.subscribedFriendIds = []; + } +} diff --git a/src/lib/status/components/Section.svelte b/src/lib/status/components/Section.svelte index 1b9bd34..d16ad4a 100644 --- a/src/lib/status/components/Section.svelte +++ b/src/lib/status/components/Section.svelte @@ -1,119 +1,113 @@ - - -
-
-

Status

- -
-
-
- -
- {statusCharacterCount}/{MAX_STATUS_LENGTH} characters - {statusCharacterCount > MAX_STATUS_LENGTH ? ' - Status too long!' : ''} -
-
- -
-
- - {#if quickStatuses.length > 0} -
-
- Quick Status -
-
- - {#each quickStatuses as quickStatus (quickStatus.id)} - handleQuickStatusChange(quickStatus.status_text, quickStatus.id)} - /> - {/each} -
-
- {/if} - - {#if currentStatus} -
- Current Status -
-
-

{currentStatus}

-
- {/if} -
-
+ + +
+
+

Right now

+ +
+
+
+ +
+ {statusCharacterCount}/{MAX_STATUS_LENGTH} characters + {statusCharacterCount > MAX_STATUS_LENGTH ? ' - Status too long!' : ''} +
+
+ +
+
+ + {#if quickStatuses.length > 0} +
+ + {#each quickStatuses as quickStatus (quickStatus.id)} + handleQuickStatusChange(quickStatus.status_text, quickStatus.id)} + /> + {/each} +
+ {/if} + + {#if currentStatus} + {#key currentStatus} +
+

{currentStatus}

+
+ {/key} + {/if} +
+
diff --git a/src/lib/status/components/Skeleton.svelte b/src/lib/status/components/Skeleton.svelte index ab09d7c..2598b54 100644 --- a/src/lib/status/components/Skeleton.svelte +++ b/src/lib/status/components/Skeleton.svelte @@ -1,19 +1,19 @@ - - -
-
-

Status

- -
-
-
- -
-
-
-
-
-
-
-
+ + +
+
+

Right now

+ +
+
+
+
+
+
+ +
+
+
+
+
diff --git a/src/lib/status/formatting.test.ts b/src/lib/status/formatting.test.ts index 9d630d1..605911a 100644 --- a/src/lib/status/formatting.test.ts +++ b/src/lib/status/formatting.test.ts @@ -1,64 +1,64 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { formatStatusUpdatedAt, formatStatusUpdatedAtTooltip } from './formatting'; - -describe('formatStatusUpdatedAt', () => { - beforeEach(() => { - vi.useFakeTimers(); - vi.setSystemTime(new Date('2026-02-26T12:00:00Z')); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - it('returns empty string for null', () => { - expect(formatStatusUpdatedAt(null)).toBe(''); - }); - - it('returns "just now" for less than a minute ago', () => { - const thirtySecondsAgo = new Date('2026-02-26T11:59:35Z').toISOString(); - expect(formatStatusUpdatedAt(thirtySecondsAgo)).toBe('just now'); - }); - - it('returns minutes ago', () => { - const fiveMinAgo = new Date('2026-02-26T11:55:00Z').toISOString(); - expect(formatStatusUpdatedAt(fiveMinAgo)).toBe('5m ago'); - }); - - it('returns hours ago', () => { - const threeHoursAgo = new Date('2026-02-26T09:00:00Z').toISOString(); - expect(formatStatusUpdatedAt(threeHoursAgo)).toBe('3h ago'); - }); - - it('returns days ago', () => { - const twoDaysAgo = new Date('2026-02-24T12:00:00Z').toISOString(); - expect(formatStatusUpdatedAt(twoDaysAgo)).toBe('2d ago'); - }); - - it('returns formatted date for older than a week', () => { - const twoWeeksAgo = new Date('2026-02-10T12:00:00Z').toISOString(); - const result = formatStatusUpdatedAt(twoWeeksAgo); - expect(result).toContain('Feb'); - expect(result).toContain('10'); - }); - - it('includes year for dates in a different year', () => { - const lastYear = new Date('2025-06-15T12:00:00Z').toISOString(); - const result = formatStatusUpdatedAt(lastYear); - expect(result).toContain('2025'); - }); -}); - -describe('formatStatusUpdatedAtTooltip', () => { - it('returns empty string for null', () => { - expect(formatStatusUpdatedAtTooltip(null)).toBe(''); - }); - - it('returns full formatted date string', () => { - const date = new Date('2026-02-26T14:30:45Z').toISOString(); - const result = formatStatusUpdatedAtTooltip(date); - expect(result).toContain('2026'); - expect(result).toContain('February'); - expect(result).toContain('26'); - }); -}); +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { formatStatusUpdatedAt, formatStatusUpdatedAtTooltip } from './formatting'; + +describe('formatStatusUpdatedAt', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-02-26T12:00:00Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('returns empty string for null', () => { + expect(formatStatusUpdatedAt(null)).toBe(''); + }); + + it('returns "just now" for less than a minute ago', () => { + const thirtySecondsAgo = new Date('2026-02-26T11:59:35Z').toISOString(); + expect(formatStatusUpdatedAt(thirtySecondsAgo)).toBe('just now'); + }); + + it('returns minutes ago', () => { + const fiveMinAgo = new Date('2026-02-26T11:55:00Z').toISOString(); + expect(formatStatusUpdatedAt(fiveMinAgo)).toBe('5m ago'); + }); + + it('returns hours ago', () => { + const threeHoursAgo = new Date('2026-02-26T09:00:00Z').toISOString(); + expect(formatStatusUpdatedAt(threeHoursAgo)).toBe('3h ago'); + }); + + it('returns days ago', () => { + const twoDaysAgo = new Date('2026-02-24T12:00:00Z').toISOString(); + expect(formatStatusUpdatedAt(twoDaysAgo)).toBe('2d ago'); + }); + + it('returns formatted date for older than a week', () => { + const twoWeeksAgo = new Date('2026-02-10T12:00:00Z').toISOString(); + const result = formatStatusUpdatedAt(twoWeeksAgo); + expect(result).toContain('Feb'); + expect(result).toContain('10'); + }); + + it('includes year for dates in a different year', () => { + const lastYear = new Date('2025-06-15T12:00:00Z').toISOString(); + const result = formatStatusUpdatedAt(lastYear); + expect(result).toContain('2025'); + }); +}); + +describe('formatStatusUpdatedAtTooltip', () => { + it('returns empty string for null', () => { + expect(formatStatusUpdatedAtTooltip(null)).toBe(''); + }); + + it('returns full formatted date string', () => { + const date = new Date('2026-02-26T14:30:45Z').toISOString(); + const result = formatStatusUpdatedAtTooltip(date); + expect(result).toContain('2026'); + expect(result).toContain('February'); + expect(result).toContain('26'); + }); +}); diff --git a/src/lib/status/formatting.ts b/src/lib/status/formatting.ts index 5aecd14..7effb4e 100644 --- a/src/lib/status/formatting.ts +++ b/src/lib/status/formatting.ts @@ -1,38 +1,38 @@ -export const formatStatusUpdatedAt = (updatedAt: string | null): string => { - if (!updatedAt) return ''; - - const date = new Date(updatedAt); - const now = new Date(); - const diffInMinutes = Math.floor((now.getTime() - date.getTime()) / (1000 * 60)); - - if (diffInMinutes < 1) return 'just now'; - if (diffInMinutes < 60) return `${diffInMinutes}m ago`; - - const diffInHours = Math.floor(diffInMinutes / 60); - if (diffInHours < 24) return `${diffInHours}h ago`; - - const diffInDays = Math.floor(diffInHours / 24); - if (diffInDays < 7) return `${diffInDays}d ago`; - - return date.toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - ...(date.getFullYear() !== now.getFullYear() ? { year: 'numeric' } : {}) - }); -}; - -export const formatStatusUpdatedAtTooltip = (updatedAt: string | null): string => { - if (!updatedAt) return ''; - - const date = new Date(updatedAt); - return date.toLocaleString('en-US', { - weekday: 'long', - year: 'numeric', - month: 'long', - day: 'numeric', - hour: 'numeric', - minute: '2-digit', - second: '2-digit', - timeZoneName: 'short' - }); -}; +export const formatStatusUpdatedAt = (updatedAt: string | null): string => { + if (!updatedAt) return ''; + + const date = new Date(updatedAt); + const now = new Date(); + const diffInMinutes = Math.floor((now.getTime() - date.getTime()) / (1000 * 60)); + + if (diffInMinutes < 1) return 'just now'; + if (diffInMinutes < 60) return `${diffInMinutes}m ago`; + + const diffInHours = Math.floor(diffInMinutes / 60); + if (diffInHours < 24) return `${diffInHours}h ago`; + + const diffInDays = Math.floor(diffInHours / 24); + if (diffInDays < 7) return `${diffInDays}d ago`; + + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + ...(date.getFullYear() !== now.getFullYear() ? { year: 'numeric' } : {}) + }); +}; + +export const formatStatusUpdatedAtTooltip = (updatedAt: string | null): string => { + if (!updatedAt) return ''; + + const date = new Date(updatedAt); + return date.toLocaleString('en-US', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + second: '2-digit', + timeZoneName: 'short' + }); +}; diff --git a/src/lib/status/quick.test.ts b/src/lib/status/quick.test.ts index 179e32c..e87d08d 100644 --- a/src/lib/status/quick.test.ts +++ b/src/lib/status/quick.test.ts @@ -1,110 +1,110 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { getQuickStatuses, saveQuickStatuses } from './quick'; - -const localStorageMock = (() => { - let store: Record = {}; - return { - getItem: vi.fn((key: string) => store[key] ?? null), - setItem: vi.fn((key: string, value: string) => { - store[key] = value; - }), - removeItem: vi.fn((key: string) => { - delete store[key]; - }), - clear: vi.fn(() => { - store = {}; - }), - get length() { - return Object.keys(store).length; - }, - key: vi.fn((index: number) => Object.keys(store)[index] ?? null) - }; -})(); - -Object.defineProperty(globalThis, 'window', { - value: { localStorage: localStorageMock }, - writable: true -}); -Object.defineProperty(globalThis, 'localStorage', { - value: localStorageMock, - writable: true -}); - -describe('getQuickStatuses', () => { - beforeEach(() => { - localStorageMock.clear(); - vi.clearAllMocks(); - }); - - it('returns default statuses when nothing is stored', () => { - const statuses = getQuickStatuses(); - expect(statuses).toHaveLength(4); - expect(statuses[0].status_text).toBe('Travelling'); - expect(statuses[1].status_text).toBe('Sleeping'); - expect(statuses[2].status_text).toBe('Working'); - expect(statuses[3].status_text).toBe('Lounging'); - }); - - it('returns stored statuses when available', () => { - const stored = [ - { id: 'a', status_text: 'Coding', display_order: 0 }, - { id: 'b', status_text: 'Gaming', display_order: 1 } - ]; - localStorageMock.setItem('rez_quick_statuses', JSON.stringify(stored)); - - const statuses = getQuickStatuses(); - expect(statuses).toHaveLength(2); - expect(statuses[0].status_text).toBe('Coding'); - expect(statuses[1].status_text).toBe('Gaming'); - }); - - it('returns defaults for invalid JSON', () => { - localStorageMock.setItem('rez_quick_statuses', 'not json'); - const statuses = getQuickStatuses(); - expect(statuses).toHaveLength(4); - }); - - it('filters out malformed entries', () => { - const stored = [ - { id: 'a', status_text: 'Valid', display_order: 0 }, - { id: 123, status_text: 'Bad ID', display_order: 1 }, - { status_text: 'No ID', display_order: 2 } - ]; - localStorageMock.setItem('rez_quick_statuses', JSON.stringify(stored)); - - const statuses = getQuickStatuses(); - expect(statuses).toHaveLength(1); - expect(statuses[0].status_text).toBe('Valid'); - }); -}); - -describe('saveQuickStatuses', () => { - beforeEach(() => { - localStorageMock.clear(); - vi.clearAllMocks(); - }); - - it('saves non-empty statuses to localStorage', () => { - saveQuickStatuses(['Working', 'Gaming', '']); - - const stored = JSON.parse(localStorageMock.getItem('rez_quick_statuses')!); - expect(stored).toHaveLength(2); - expect(stored[0].status_text).toBe('Working'); - expect(stored[1].status_text).toBe('Gaming'); - }); - - it('trims whitespace from statuses', () => { - saveQuickStatuses([' Coding ']); - - const stored = JSON.parse(localStorageMock.getItem('rez_quick_statuses')!); - expect(stored[0].status_text).toBe('Coding'); - }); - - it('filters out empty and whitespace-only statuses', () => { - saveQuickStatuses(['', ' ', 'Valid']); - - const stored = JSON.parse(localStorageMock.getItem('rez_quick_statuses')!); - expect(stored).toHaveLength(1); - expect(stored[0].status_text).toBe('Valid'); - }); -}); +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { getQuickStatuses, saveQuickStatuses } from './quick'; + +const localStorageMock = (() => { + let store: Record = {}; + return { + getItem: vi.fn((key: string) => store[key] ?? null), + setItem: vi.fn((key: string, value: string) => { + store[key] = value; + }), + removeItem: vi.fn((key: string) => { + delete store[key]; + }), + clear: vi.fn(() => { + store = {}; + }), + get length() { + return Object.keys(store).length; + }, + key: vi.fn((index: number) => Object.keys(store)[index] ?? null) + }; +})(); + +Object.defineProperty(globalThis, 'window', { + value: { localStorage: localStorageMock }, + writable: true +}); +Object.defineProperty(globalThis, 'localStorage', { + value: localStorageMock, + writable: true +}); + +describe('getQuickStatuses', () => { + beforeEach(() => { + localStorageMock.clear(); + vi.clearAllMocks(); + }); + + it('returns default statuses when nothing is stored', () => { + const statuses = getQuickStatuses(); + expect(statuses).toHaveLength(4); + expect(statuses[0].status_text).toBe('Travelling'); + expect(statuses[1].status_text).toBe('Sleeping'); + expect(statuses[2].status_text).toBe('Working'); + expect(statuses[3].status_text).toBe('Lounging'); + }); + + it('returns stored statuses when available', () => { + const stored = [ + { id: 'a', status_text: 'Coding', display_order: 0 }, + { id: 'b', status_text: 'Gaming', display_order: 1 } + ]; + localStorageMock.setItem('rez_quick_statuses', JSON.stringify(stored)); + + const statuses = getQuickStatuses(); + expect(statuses).toHaveLength(2); + expect(statuses[0].status_text).toBe('Coding'); + expect(statuses[1].status_text).toBe('Gaming'); + }); + + it('returns defaults for invalid JSON', () => { + localStorageMock.setItem('rez_quick_statuses', 'not json'); + const statuses = getQuickStatuses(); + expect(statuses).toHaveLength(4); + }); + + it('filters out malformed entries', () => { + const stored = [ + { id: 'a', status_text: 'Valid', display_order: 0 }, + { id: 123, status_text: 'Bad ID', display_order: 1 }, + { status_text: 'No ID', display_order: 2 } + ]; + localStorageMock.setItem('rez_quick_statuses', JSON.stringify(stored)); + + const statuses = getQuickStatuses(); + expect(statuses).toHaveLength(1); + expect(statuses[0].status_text).toBe('Valid'); + }); +}); + +describe('saveQuickStatuses', () => { + beforeEach(() => { + localStorageMock.clear(); + vi.clearAllMocks(); + }); + + it('saves non-empty statuses to localStorage', () => { + saveQuickStatuses(['Working', 'Gaming', '']); + + const stored = JSON.parse(localStorageMock.getItem('rez_quick_statuses')!); + expect(stored).toHaveLength(2); + expect(stored[0].status_text).toBe('Working'); + expect(stored[1].status_text).toBe('Gaming'); + }); + + it('trims whitespace from statuses', () => { + saveQuickStatuses([' Coding ']); + + const stored = JSON.parse(localStorageMock.getItem('rez_quick_statuses')!); + expect(stored[0].status_text).toBe('Coding'); + }); + + it('filters out empty and whitespace-only statuses', () => { + saveQuickStatuses(['', ' ', 'Valid']); + + const stored = JSON.parse(localStorageMock.getItem('rez_quick_statuses')!); + expect(stored).toHaveLength(1); + expect(stored[0].status_text).toBe('Valid'); + }); +}); diff --git a/src/lib/status/quick.ts b/src/lib/status/quick.ts index 4f8f8b7..9494c37 100644 --- a/src/lib/status/quick.ts +++ b/src/lib/status/quick.ts @@ -1,66 +1,66 @@ -export interface QuickStatus { - id: string; - status_text: string; - display_order: number; -} - -const QUICK_STATUS_STORAGE_KEY = 'rez_quick_statuses'; - -export function getQuickStatuses(): QuickStatus[] { - if (typeof window === 'undefined' || !window.localStorage) { - return []; - } - - try { - const stored = localStorage.getItem(QUICK_STATUS_STORAGE_KEY); - if (!stored) { - return getDefaultQuickStatuses(); - } - - const parsed = JSON.parse(stored); - if (Array.isArray(parsed)) { - return parsed.filter( - (item) => - item && - typeof item.id === 'string' && - typeof item.status_text === 'string' && - typeof item.display_order === 'number' - ); - } - return getDefaultQuickStatuses(); - } catch (error) { - console.warn('Failed to parse quick statuses from localStorage:', error); - return getDefaultQuickStatuses(); - } -} - -export function saveQuickStatuses(statuses: string[]): void { - if (typeof window === 'undefined' || !window.localStorage) { - console.warn('localStorage not available'); - return; - } - - try { - const validStatuses: QuickStatus[] = statuses - .map((text, index) => ({ text: text.trim(), order: index })) - .filter((qs) => qs.text.length > 0) - .map((qs) => ({ - id: `local_${qs.order}_${Date.now()}`, - status_text: qs.text, - display_order: qs.order - })); - - localStorage.setItem(QUICK_STATUS_STORAGE_KEY, JSON.stringify(validStatuses)); - } catch (error) { - console.error('Failed to save quick statuses to localStorage:', error); - } -} - -function getDefaultQuickStatuses(): QuickStatus[] { - return [ - { id: 'default_0', status_text: 'Travelling', display_order: 0 }, - { id: 'default_1', status_text: 'Sleeping', display_order: 1 }, - { id: 'default_2', status_text: 'Working', display_order: 2 }, - { id: 'default_3', status_text: 'Lounging', display_order: 3 } - ]; -} +export interface QuickStatus { + id: string; + status_text: string; + display_order: number; +} + +const QUICK_STATUS_STORAGE_KEY = 'rez_quick_statuses'; + +export function getQuickStatuses(): QuickStatus[] { + if (typeof window === 'undefined' || !window.localStorage) { + return []; + } + + try { + const stored = localStorage.getItem(QUICK_STATUS_STORAGE_KEY); + if (!stored) { + return getDefaultQuickStatuses(); + } + + const parsed = JSON.parse(stored); + if (Array.isArray(parsed)) { + return parsed.filter( + (item) => + item && + typeof item.id === 'string' && + typeof item.status_text === 'string' && + typeof item.display_order === 'number' + ); + } + return getDefaultQuickStatuses(); + } catch (error) { + console.warn('Failed to parse quick statuses from localStorage:', error); + return getDefaultQuickStatuses(); + } +} + +export function saveQuickStatuses(statuses: string[]): void { + if (typeof window === 'undefined' || !window.localStorage) { + console.warn('localStorage not available'); + return; + } + + try { + const validStatuses: QuickStatus[] = statuses + .map((text, index) => ({ text: text.trim(), order: index })) + .filter((qs) => qs.text.length > 0) + .map((qs) => ({ + id: `local_${qs.order}_${Date.now()}`, + status_text: qs.text, + display_order: qs.order + })); + + localStorage.setItem(QUICK_STATUS_STORAGE_KEY, JSON.stringify(validStatuses)); + } catch (error) { + console.error('Failed to save quick statuses to localStorage:', error); + } +} + +function getDefaultQuickStatuses(): QuickStatus[] { + return [ + { id: 'default_0', status_text: 'Travelling', display_order: 0 }, + { id: 'default_1', status_text: 'Sleeping', display_order: 1 }, + { id: 'default_2', status_text: 'Working', display_order: 2 }, + { id: 'default_3', status_text: 'Lounging', display_order: 3 } + ]; +} diff --git a/src/lib/status/validation.test.ts b/src/lib/status/validation.test.ts index eb6c9cd..3dd0007 100644 --- a/src/lib/status/validation.test.ts +++ b/src/lib/status/validation.test.ts @@ -1,22 +1,22 @@ -import { describe, it, expect } from 'vitest'; -import { validateStatus, MAX_STATUS_LENGTH } from './validation'; - -describe('validateStatus', () => { - it('accepts empty status', () => { - expect(validateStatus('')).toBeNull(); - }); - - it('accepts status within limit', () => { - expect(validateStatus('Working')).toBeNull(); - expect(validateStatus('a'.repeat(MAX_STATUS_LENGTH))).toBeNull(); - }); - - it('rejects status exceeding limit', () => { - const long = 'a'.repeat(MAX_STATUS_LENGTH + 1); - expect(validateStatus(long)).toBe(`Status must be ${MAX_STATUS_LENGTH} characters or less`); - }); - - it('enforces documented max length constant', () => { - expect(MAX_STATUS_LENGTH).toBe(42); - }); -}); +import { describe, it, expect } from 'vitest'; +import { validateStatus, MAX_STATUS_LENGTH } from './validation'; + +describe('validateStatus', () => { + it('accepts empty status', () => { + expect(validateStatus('')).toBeNull(); + }); + + it('accepts status within limit', () => { + expect(validateStatus('Working')).toBeNull(); + expect(validateStatus('a'.repeat(MAX_STATUS_LENGTH))).toBeNull(); + }); + + it('rejects status exceeding limit', () => { + const long = 'a'.repeat(MAX_STATUS_LENGTH + 1); + expect(validateStatus(long)).toBe(`Status must be ${MAX_STATUS_LENGTH} characters or less`); + }); + + it('enforces documented max length constant', () => { + expect(MAX_STATUS_LENGTH).toBe(42); + }); +}); diff --git a/src/lib/status/validation.ts b/src/lib/status/validation.ts index d3b855c..1835725 100644 --- a/src/lib/status/validation.ts +++ b/src/lib/status/validation.ts @@ -1,8 +1,8 @@ -export const MAX_STATUS_LENGTH = 42; - -export function validateStatus(status: string): string | null { - if (status.length > MAX_STATUS_LENGTH) { - return `Status must be ${MAX_STATUS_LENGTH} characters or less`; - } - return null; -} +export const MAX_STATUS_LENGTH = 42; + +export function validateStatus(status: string): string | null { + if (status.length > MAX_STATUS_LENGTH) { + return `Status must be ${MAX_STATUS_LENGTH} characters or less`; + } + return null; +} diff --git a/src/lib/ui/DebugPanel.svelte b/src/lib/ui/DebugPanel.svelte index 393370f..e331ba2 100644 --- a/src/lib/ui/DebugPanel.svelte +++ b/src/lib/ui/DebugPanel.svelte @@ -1,266 +1,266 @@ - - -{#if showPanel} - - - {#if isOpen} -
-
-
-
-

Debug Panel

- -
- -
-

Device Information

-
-

Platform: {isIOS ? 'iOS' : 'Other'}

-

User Agent: {navigator.userAgent}

-

URL: {window.location.href}

-

Cookies Enabled: {navigator.cookieEnabled ? 'Yes' : 'No'}

-

- Local Storage: - {typeof Storage !== 'undefined' ? 'Available' : 'Not Available'} -

-
-
- -
-
-

Logs ({logs.length})

-
- - -
-
-
- {#if logs.length === 0} -

No logs yet...

- {:else} - {#each logs as log, index (index)} -
-
- - {log.level} - - {log.timestamp} -
-

{log.message}

- {#if log.data} -
- View details -
{JSON.stringify(
-														log.data,
-														null,
-														2
-													)}
-
- {/if} -
- {/each} - {/if} -
-
- -
- - -
- -
-

About Debug Mode:

-

- When enabled, the debug panel will always be visible (even when there are no errors). - This is useful for developers or when troubleshooting issues. The setting is saved in - your browser's local storage. -

-
-
-
-
- {/if} -{/if} + + +{#if showPanel} + + + {#if isOpen} +
+
+
+
+

Debug Panel

+ +
+ +
+

Device Information

+
+

Platform: {isIOS ? 'iOS' : 'Other'}

+

User Agent: {navigator.userAgent}

+

URL: {window.location.href}

+

Cookies Enabled: {navigator.cookieEnabled ? 'Yes' : 'No'}

+

+ Local Storage: + {typeof Storage !== 'undefined' ? 'Available' : 'Not Available'} +

+
+
+ +
+
+

Logs ({logs.length})

+
+ + +
+
+
+ {#if logs.length === 0} +

No logs yet...

+ {:else} + {#each logs as log, index (index)} +
+
+ + {log.level} + + {log.timestamp} +
+

{log.message}

+ {#if log.data} +
+ View details +
{JSON.stringify(
+                            log.data,
+                            null,
+                            2
+                          )}
+
+ {/if} +
+ {/each} + {/if} +
+
+ +
+ + +
+ +
+

About Debug Mode:

+

+ When enabled, the debug panel will always be visible (even when there are no errors). + This is useful for developers or when troubleshooting issues. The setting is saved in + your browser's local storage. +

+
+
+
+
+ {/if} +{/if} diff --git a/src/lib/ui/DotGridBackground.svelte b/src/lib/ui/DotGridBackground.svelte index dcbdf3d..8c5b437 100644 --- a/src/lib/ui/DotGridBackground.svelte +++ b/src/lib/ui/DotGridBackground.svelte @@ -1,79 +1,86 @@ -
-
-
-
- - +
+
+
+
+ + diff --git a/src/lib/ui/Footer.svelte b/src/lib/ui/Footer.svelte index 704ac69..5f7dfb0 100644 --- a/src/lib/ui/Footer.svelte +++ b/src/lib/ui/Footer.svelte @@ -1,41 +1,41 @@ - + diff --git a/src/lib/ui/Navigation.svelte b/src/lib/ui/Navigation.svelte index 731006c..7039cfa 100644 --- a/src/lib/ui/Navigation.svelte +++ b/src/lib/ui/Navigation.svelte @@ -1,100 +1,101 @@ - - - + + + diff --git a/src/lib/ui/RelativeTime.svelte b/src/lib/ui/RelativeTime.svelte index c72b992..ccee2ba 100644 --- a/src/lib/ui/RelativeTime.svelte +++ b/src/lib/ui/RelativeTime.svelte @@ -1,24 +1,21 @@ - - -{display} + + +{display} diff --git a/src/lib/ui/ThemeSelect.svelte b/src/lib/ui/ThemeSelect.svelte index c6a41a4..ef92748 100644 --- a/src/lib/ui/ThemeSelect.svelte +++ b/src/lib/ui/ThemeSelect.svelte @@ -1,100 +1,100 @@ - - - + + + diff --git a/src/lib/ui/Toast.svelte b/src/lib/ui/Toast.svelte index cba44f0..1f07538 100644 --- a/src/lib/ui/Toast.svelte +++ b/src/lib/ui/Toast.svelte @@ -1,45 +1,47 @@ - - - + + + diff --git a/src/lib/ui/ToastContainer.svelte b/src/lib/ui/ToastContainer.svelte index eaf825d..8c0246a 100644 --- a/src/lib/ui/ToastContainer.svelte +++ b/src/lib/ui/ToastContainer.svelte @@ -1,10 +1,10 @@ - - -
- {#each $toasts as toast (toast.id)} - - {/each} -
+ + +
+ {#each $toasts as toast (toast.id)} + + {/each} +
diff --git a/src/lib/ui/notifications.ts b/src/lib/ui/notifications.ts index 7bdb368..4b9f208 100644 --- a/src/lib/ui/notifications.ts +++ b/src/lib/ui/notifications.ts @@ -1,29 +1,29 @@ -import { toastStore } from './toast.js'; - -export class NotificationManager { - private static show(message: string, type: 'success' | 'error' | 'info' = 'info') { - toastStore.add(message, type); - - if (type === 'error') { - console.error(message); - } - } - - static showError(message: string) { - this.show(message, 'error'); - } - - static showSuccess(message: string) { - this.show(message, 'success'); - } -} - -export function handleDatabaseError(error: unknown, operation: string): boolean { - console.error(`Database error during ${operation}:`, error); - NotificationManager.showError(`Failed to ${operation}`); - return false; -} - -export function getDisplayName(displayName: string | null, username: string): string { - return displayName || username; -} +import { toastStore } from './toast.js'; + +export class NotificationManager { + private static show(message: string, type: 'success' | 'error' | 'info' = 'info') { + toastStore.add(message, type); + + if (type === 'error') { + console.error(message); + } + } + + static showError(message: string) { + this.show(message, 'error'); + } + + static showSuccess(message: string) { + this.show(message, 'success'); + } +} + +export function handleDatabaseError(error: unknown, operation: string): boolean { + console.error(`Database error during ${operation}:`, error); + NotificationManager.showError(`Something went wrong — couldn't ${operation}.`); + return false; +} + +export function getDisplayName(displayName: string | null, username: string): string { + return displayName || username; +} diff --git a/src/lib/ui/now.svelte.ts b/src/lib/ui/now.svelte.ts new file mode 100644 index 0000000..fd17d5f --- /dev/null +++ b/src/lib/ui/now.svelte.ts @@ -0,0 +1,23 @@ +let _now = $state(Date.now()); +let _refCount = 0; +let _intervalId: ReturnType | null = null; + +export function getNow(): number { + return _now; +} + +export function subscribeToTick(): () => void { + _refCount++; + if (_refCount === 1) { + _intervalId = setInterval(() => { + _now = Date.now(); + }, 30_000); + } + return () => { + _refCount--; + if (_refCount === 0 && _intervalId !== null) { + clearInterval(_intervalId); + _intervalId = null; + } + }; +} diff --git a/src/lib/ui/themes.ts b/src/lib/ui/themes.ts index adbaa3b..0ef08e0 100644 --- a/src/lib/ui/themes.ts +++ b/src/lib/ui/themes.ts @@ -1,37 +1,37 @@ -export const themes = [ - 'abyss', - 'acid', - 'aqua', - 'autumn', - 'black', - 'bumblebee', - 'business', - 'caramellatte', - 'cmyk', - 'coffee', - 'corporate', - 'cupcake', - 'cyberpunk', - 'dark', - 'dim', - 'dracula', - 'emerald', - 'fantasy', - 'forest', - 'garden', - 'halloween', - 'lemonade', - 'light', - 'lofi', - 'luxury', - 'night', - 'nord', - 'pastel', - 'retro', - 'silk', - 'sunset', - 'synthwave', - 'valentine', - 'winter', - 'wireframe' -]; +export const themes = [ + 'abyss', + 'acid', + 'aqua', + 'autumn', + 'black', + 'bumblebee', + 'business', + 'caramellatte', + 'cmyk', + 'coffee', + 'corporate', + 'cupcake', + 'cyberpunk', + 'dark', + 'dim', + 'dracula', + 'emerald', + 'fantasy', + 'forest', + 'garden', + 'halloween', + 'lemonade', + 'light', + 'lofi', + 'luxury', + 'night', + 'nord', + 'pastel', + 'retro', + 'silk', + 'sunset', + 'synthwave', + 'valentine', + 'winter', + 'wireframe' +]; diff --git a/src/lib/ui/toast.ts b/src/lib/ui/toast.ts index 6943fc5..f4035d5 100644 --- a/src/lib/ui/toast.ts +++ b/src/lib/ui/toast.ts @@ -1,41 +1,41 @@ -import { writable } from 'svelte/store'; - -export interface Toast { - id: string; - message: string; - type: 'success' | 'error' | 'info'; - duration?: number; -} - -export const toasts = writable([]); - -let toastIdCounter = 0; - -export const toastStore = { - add: (message: string, type: Toast['type'] = 'info', duration = 5000) => { - const id = `toast-${++toastIdCounter}`; - const toast: Toast = { id, message, type, duration }; - - toasts.update((current) => [...current, toast]); - - if (duration > 0) { - setTimeout(() => { - toastStore.remove(id); - }, duration); - } - - return id; - }, - - remove: (id: string) => { - toasts.update((current) => current.filter((toast) => toast.id !== id)); - }, - - clear: () => { - toasts.set([]); - }, - - success: (message: string, duration = 5000) => toastStore.add(message, 'success', duration), - error: (message: string, duration = 8000) => toastStore.add(message, 'error', duration), - info: (message: string, duration = 5000) => toastStore.add(message, 'info', duration) -}; +import { writable } from 'svelte/store'; + +export interface Toast { + id: string; + message: string; + type: 'success' | 'error' | 'info'; + duration?: number; +} + +export const toasts = writable([]); + +let toastIdCounter = 0; + +export const toastStore = { + add: (message: string, type: Toast['type'] = 'info', duration = 5000) => { + const id = `toast-${++toastIdCounter}`; + const toast: Toast = { id, message, type, duration }; + + toasts.update((current) => [...current, toast]); + + if (duration > 0) { + setTimeout(() => { + toastStore.remove(id); + }, duration); + } + + return id; + }, + + remove: (id: string) => { + toasts.update((current) => current.filter((toast) => toast.id !== id)); + }, + + clear: () => { + toasts.set([]); + }, + + success: (message: string, duration = 5000) => toastStore.add(message, 'success', duration), + error: (message: string, duration = 8000) => toastStore.add(message, 'error', duration), + info: (message: string, duration = 5000) => toastStore.add(message, 'info', duration) +}; diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts index 50f9269..ae6fe8f 100644 --- a/src/routes/+layout.server.ts +++ b/src/routes/+layout.server.ts @@ -1,9 +1,9 @@ -import type { LayoutServerLoad } from './$types'; - -export const load: LayoutServerLoad = async ({ locals: { safeGetSession }, cookies }) => { - const { session } = await safeGetSession(); - return { - session, - cookies: cookies.getAll() - }; -}; +import type { LayoutServerLoad } from './$types'; + +export const load: LayoutServerLoad = async ({ locals: { safeGetSession }, cookies }) => { + const { session } = await safeGetSession(); + return { + session, + cookies: cookies.getAll() + }; +}; diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index c045b16..2a6373a 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,72 +1,79 @@ - - - - {pageTitle} - - -
-
- {@render children()} -
- -
- -
+ + + + {pageTitle} + + +
+
+ {#key page.url.pathname} +
+ {@render children()} +
+ {/key} +
+ + {#if !page.url.pathname.startsWith('/dashboard')} +
+ {/if} + +
diff --git a/src/routes/+layout.ts b/src/routes/+layout.ts index 470273d..d9f136c 100644 --- a/src/routes/+layout.ts +++ b/src/routes/+layout.ts @@ -1,31 +1,31 @@ -import { PUBLIC_SUPABASE_ANON_KEY, PUBLIC_SUPABASE_URL } from '$env/static/public'; -import { createBrowserClient, createServerClient, isBrowser } from '@supabase/ssr'; -import type { LayoutLoad } from './$types'; - -export const load: LayoutLoad = async ({ data, depends, fetch }) => { - depends('supabase:auth'); - - const supabase = isBrowser() - ? createBrowserClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY) - : createServerClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, { - global: { - fetch - }, - cookies: { - getAll() { - return data.cookies; - } - } - }); - - // getSession is safe here: on the server it reads from LayoutData validated by safeGetSession - const { - data: { session } - } = await supabase.auth.getSession(); - - const { - data: { user } - } = await supabase.auth.getUser(); - - return { session, supabase, user }; -}; +import { PUBLIC_SUPABASE_ANON_KEY, PUBLIC_SUPABASE_URL } from '$env/static/public'; +import { createBrowserClient, createServerClient, isBrowser } from '@supabase/ssr'; +import type { LayoutLoad } from './$types'; + +export const load: LayoutLoad = async ({ data, depends, fetch }) => { + depends('supabase:auth'); + + const supabase = isBrowser() + ? createBrowserClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY) + : createServerClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, { + global: { + fetch + }, + cookies: { + getAll() { + return data.cookies; + } + } + }); + + // getSession is safe here: on the server it reads from LayoutData validated by safeGetSession + const { + data: { session } + } = await supabase.auth.getSession(); + + const { + data: { user } + } = await supabase.auth.getUser(); + + return { session, supabase, user }; +}; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index b6f688d..bac0964 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,75 +1,93 @@ - - -
- -
-
-
-

-
- - Rezonate -
- with others -

-

- Connect to people. Stay up to date. Always in the know. -

-
- - - -
-
-
-
- - + + +
+ + + +
+ +

+ Rezonate +

+ + +

+ Know what your
close friends
+ are up to. +

+ +

+ Real-time status for the people who matter. +

+ + +
+
+
+ A +
+ at the coffee shop ☕ +
+
+ +
+
+ M +
+ just finished a run 🏃 +
+
+ +
+
+ J +
+ working from home today +
+
+
+ + + +
+
diff --git a/src/routes/auth/+layout.svelte b/src/routes/auth/+layout.svelte index a73dd26..6235879 100644 --- a/src/routes/auth/+layout.svelte +++ b/src/routes/auth/+layout.svelte @@ -1,10 +1,7 @@ - - -
- - {@render children()} -
+ + +
+ {@render children()} +
diff --git a/src/routes/auth/+page.server.ts b/src/routes/auth/+page.server.ts index 987fd57..0843af1 100644 --- a/src/routes/auth/+page.server.ts +++ b/src/routes/auth/+page.server.ts @@ -1,74 +1,74 @@ -import { fail, redirect } from '@sveltejs/kit'; - -import type { Actions } from './$types'; - -export const actions: Actions = { - signup: async ({ request, locals: { supabase }, url }) => { - const formData = await request.formData(); - const email = formData.get('email') as string; - const password = formData.get('password') as string; - - const { data, error } = await supabase.auth.signUp({ - email, - password, - options: { - emailRedirectTo: `${url.origin}/auth/confirm` - } - }); - - if (error) { - return fail(400, { - error: error.message, - errorCode: error.status?.toString() || 'unknown', - email - }); - } - - const emailConfirmationRequired = data.user && !data.session; - if (emailConfirmationRequired) { - return { - requiresConfirmation: true, - message: 'Please check your email to confirm your account before signing in.', - email - }; - } - - if (data.session) { - redirect(303, '/dashboard'); - } - - return fail(500, { - error: 'Signup completed but no session was created. Please try logging in.', - email - }); - }, - login: async ({ request, locals: { supabase } }) => { - const formData = await request.formData(); - const email = formData.get('email') as string; - const password = formData.get('password') as string; - - const { error } = await supabase.auth.signInWithPassword({ email, password }); - if (error) { - return fail(401, { - error: error.message, - errorCode: error.status?.toString() || 'unknown', - email - }); - } - - redirect(303, '/dashboard'); - }, - forgotPassword: async ({ request, locals: { supabase }, url }) => { - const formData = await request.formData(); - const email = formData.get('email') as string; - - await supabase.auth.resetPasswordForEmail(email, { - redirectTo: `${url.origin}/auth/confirm?next=/auth/reset-password` - }); - - return { - forgotPasswordSuccess: true, - message: "If an account exists with that email, you'll receive a password reset link." - }; - } -}; +import { fail, redirect } from '@sveltejs/kit'; + +import type { Actions } from './$types'; + +export const actions: Actions = { + signup: async ({ request, locals: { supabase }, url }) => { + const formData = await request.formData(); + const email = formData.get('email') as string; + const password = formData.get('password') as string; + + const { data, error } = await supabase.auth.signUp({ + email, + password, + options: { + emailRedirectTo: `${url.origin}/auth/confirm` + } + }); + + if (error) { + return fail(400, { + error: error.message, + errorCode: error.status?.toString() || 'unknown', + email + }); + } + + const emailConfirmationRequired = data.user && !data.session; + if (emailConfirmationRequired) { + return { + requiresConfirmation: true, + message: 'Please check your email to confirm your account before signing in.', + email + }; + } + + if (data.session) { + redirect(303, '/dashboard'); + } + + return fail(500, { + error: 'Signup completed but no session was created. Please try logging in.', + email + }); + }, + login: async ({ request, locals: { supabase } }) => { + const formData = await request.formData(); + const email = formData.get('email') as string; + const password = formData.get('password') as string; + + const { error } = await supabase.auth.signInWithPassword({ email, password }); + if (error) { + return fail(401, { + error: error.message, + errorCode: error.status?.toString() || 'unknown', + email + }); + } + + redirect(303, '/dashboard'); + }, + forgotPassword: async ({ request, locals: { supabase }, url }) => { + const formData = await request.formData(); + const email = formData.get('email') as string; + + await supabase.auth.resetPasswordForEmail(email, { + redirectTo: `${url.origin}/auth/confirm?next=/auth/reset-password` + }); + + return { + forgotPasswordSuccess: true, + message: "If an account exists with that email, you'll receive a password reset link." + }; + } +}; diff --git a/src/routes/auth/+page.svelte b/src/routes/auth/+page.svelte index 5cc54a2..3e701f8 100644 --- a/src/routes/auth/+page.svelte +++ b/src/routes/auth/+page.svelte @@ -1,494 +1,513 @@ -
-
-
- - {#if view === 'login'} -

- Sign In -

- - {#if loginError} - - {/if} - -
{ - return async ({ result }) => { - if (result.type === 'failure' && result.data) { - const data = result.data as { error?: string; errorCode?: string }; - loginError = data.error || 'Login failed. Please try again.'; - if (debugPanel) { - debugPanel.addDebugLog('error', 'Login form error', { - errorCode: data.errorCode - }); - } - } else { - await applyAction(result); - } - }; - }} - > -
- -
- (loginEmailTouched = true)} - class="input input-bordered h-12 w-full px-4 text-base {loginEmailError ? 'input-error' : ''} {loginEmailValid ? 'input-success' : ''}" - /> - {#if loginEmailValid} - - {/if} -
-
- - - - {loginEmailError ?? '\u00A0'} -
-
- -
- -
- (loginPasswordTouched = true)} - class="input input-bordered h-12 w-full px-4 pr-12 text-base {loginPasswordError ? 'input-error' : ''} {loginPasswordValid ? 'input-success' : ''}" - /> - -
-
- - - - {loginPasswordError ?? '\u00A0'} -
-
- -
- -
- -
- -
-
- -

- New user? - -

- - {:else if view === 'signup'} -

- Create Account -

- - {#if signupError} - - {/if} - - {#if signupSuccess} - - {/if} - -
{ - return async ({ result }) => { - if (result.type === 'failure' && result.data) { - const data = result.data as { - error?: string; - errorCode?: string; - errorName?: string; - isIOS?: boolean; - }; - signupError = data.error || 'Signup failed. Please try again.'; - if (debugPanel) { - debugPanel.addDebugLog('error', 'Signup form error', data); - } - } else if (result.type === 'success' && result.data) { - const data = result.data as { requiresConfirmation?: boolean; message?: string }; - if (data.requiresConfirmation) { - signupSuccess = - data.message || 'Please check your email to confirm your account.'; - signupError = null; - } else { - await applyAction(result); - } - } else { - await applyAction(result); - } - }; - }} - > -
- -
- (signupEmailTouched = true)} - class="input input-bordered h-12 w-full px-4 text-base {signupEmailError ? 'input-error' : ''} {signupEmailValid ? 'input-success' : ''}" - /> - {#if signupEmailValid} - - {/if} -
-
- - - - {signupEmailError ?? '\u00A0'} -
-
- -
- -
- (signupPasswordTouched = true)} - class="input input-bordered h-12 w-full px-4 pr-12 text-base {signupPasswordError ? 'input-error' : ''} {signupPasswordValid ? 'input-success' : ''}" - /> - -
-
- - - - {signupPasswordError ?? '\u00A0'} -
-
- -
- -
-
- -

- Already have an account? - -

- - {:else if view === 'forgotPassword'} -

- Reset Password -

- - {#if forgotPasswordSuccess} - - {/if} - - {#if forgotPasswordError} - - {/if} - -
{ - return async ({ result }) => { - if (result.type === 'failure' && result.data) { - const data = result.data as { error?: string }; - forgotPasswordError = - data.error || 'Failed to send reset email. Please try again.'; - } else if (result.type === 'success' && result.data) { - const data = result.data as { message?: string }; - forgotPasswordSuccess = - data.message || - "If an account exists with that email, you'll receive a reset link."; - forgotPasswordError = null; - } - }; - }} - > -

- Enter your email address and we'll send you a link to reset your password. -

- -
- - -
- -
- -
-
- -

- -

- {/if} -
-
+
+ + +
+ + +
+

Rezonate

+
+ + {#key view} +
+ + {#if view === 'login'} +

+ Welcome back. +

+ + {#if loginError} + + {/if} + +
{ + return async ({ result }) => { + if (result.type === 'failure' && result.data) { + const data = result.data as { error?: string; errorCode?: string }; + loginError = data.error || "Couldn't sign in — check your email and password."; + if (debugPanel) { + debugPanel.addDebugLog('error', 'Login form error', { + errorCode: data.errorCode + }); + } + } else { + await applyAction(result); + } + }; + }} + > +
+ +
+ (loginEmailTouched = true)} + class="input input-bordered h-12 w-full px-4 text-base {loginEmailError ? 'input-error' : ''} {loginEmailValid ? 'input-success' : ''}" + /> + {#if loginEmailValid} + + {/if} +
+
+ + + + {loginEmailError ?? '\u00A0'} +
+
+ +
+ +
+ (loginPasswordTouched = true)} + class="input input-bordered h-12 w-full px-4 pr-12 text-base {loginPasswordError ? 'input-error' : ''} {loginPasswordValid ? 'input-success' : ''}" + /> + +
+
+ + + + {loginPasswordError ?? '\u00A0'} +
+
+ +
+ +
+ +
+ +
+
+ +

+ New to Rez? + +

+ + {:else if view === 'signup'} +

+ Join Rez. +

+ + {#if signupError} + + {/if} + + {#if signupSuccess} + + {/if} + +
{ + return async ({ result }) => { + if (result.type === 'failure' && result.data) { + const data = result.data as { + error?: string; + errorCode?: string; + errorName?: string; + isIOS?: boolean; + }; + signupError = data.error || "Couldn't create your account — please try again."; + if (debugPanel) { + debugPanel.addDebugLog('error', 'Signup form error', data); + } + } else if (result.type === 'success' && result.data) { + const data = result.data as { requiresConfirmation?: boolean; message?: string }; + if (data.requiresConfirmation) { + signupSuccess = + data.message || 'Please check your email to confirm your account.'; + signupError = null; + } else { + await applyAction(result); + } + } else { + await applyAction(result); + } + }; + }} + > +
+ +
+ (signupEmailTouched = true)} + class="input input-bordered h-12 w-full px-4 text-base {signupEmailError ? 'input-error' : ''} {signupEmailValid ? 'input-success' : ''}" + /> + {#if signupEmailValid} + + {/if} +
+
+ + + + {signupEmailError ?? '\u00A0'} +
+
+ +
+ +
+ (signupPasswordTouched = true)} + class="input input-bordered h-12 w-full px-4 pr-12 text-base {signupPasswordError ? 'input-error' : ''} {signupPasswordValid ? 'input-success' : ''}" + /> + +
+
+ + + + {signupPasswordError ?? '\u00A0'} +
+
+ +
+ +
+
+ +

+ Already have an account? + +

+ + {:else if view === 'forgotPassword'} +

+ Forgot your password? +

+ +

+ Enter your email and we'll send you a reset link. +

+ + {#if forgotPasswordSuccess} + + {/if} + + {#if forgotPasswordError} + + {/if} + +
{ + return async ({ result }) => { + if (result.type === 'failure' && result.data) { + const data = result.data as { error?: string }; + forgotPasswordError = + data.error || 'Failed to send reset email. Please try again.'; + } else if (result.type === 'success' && result.data) { + const data = result.data as { message?: string }; + forgotPasswordSuccess = + data.message || + "If an account exists with that email, you'll receive a reset link."; + forgotPasswordError = null; + } + }; + }} + > +
+ + +
+ +
+ +
+
+ +

+ +

+ {/if} + +
+ {/key} + +
- +{#if dev} + +{/if} diff --git a/src/routes/auth/confirm/+server.ts b/src/routes/auth/confirm/+server.ts index a9b4e44..15d2c70 100644 --- a/src/routes/auth/confirm/+server.ts +++ b/src/routes/auth/confirm/+server.ts @@ -1,31 +1,31 @@ -import type { EmailOtpType } from '@supabase/supabase-js'; -import { redirect } from '@sveltejs/kit'; - -import type { RequestHandler } from './$types'; - -export const GET: RequestHandler = async ({ url, locals }) => { - const token_hash = url.searchParams.get('token_hash'); - const type = url.searchParams.get('type') as EmailOtpType | null; - const next = url.searchParams.get('next') ?? '/dashboard'; - - const redirectTo = new URL(url); - redirectTo.pathname = next; - redirectTo.searchParams.delete('token_hash'); - redirectTo.searchParams.delete('type'); - - if (token_hash && type) { - const { error } = await locals.supabase.auth.verifyOtp({ type, token_hash }); - if (!error) { - redirectTo.searchParams.delete('next'); - redirect(303, redirectTo); - } - } else { - const { session } = await locals.safeGetSession(); - if (session) { - redirect(303, '/dashboard'); - } - } - - redirectTo.pathname = '/auth/error'; - redirect(303, redirectTo); -}; +import type { EmailOtpType } from '@supabase/supabase-js'; +import { redirect } from '@sveltejs/kit'; + +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ url, locals }) => { + const token_hash = url.searchParams.get('token_hash'); + const type = url.searchParams.get('type') as EmailOtpType | null; + const next = url.searchParams.get('next') ?? '/dashboard'; + + const redirectTo = new URL(url); + redirectTo.pathname = next; + redirectTo.searchParams.delete('token_hash'); + redirectTo.searchParams.delete('type'); + + if (token_hash && type) { + const { error } = await locals.supabase.auth.verifyOtp({ type, token_hash }); + if (!error) { + redirectTo.searchParams.delete('next'); + redirect(303, redirectTo); + } + } else { + const { session } = await locals.safeGetSession(); + if (session) { + redirect(303, '/dashboard'); + } + } + + redirectTo.pathname = '/auth/error'; + redirect(303, redirectTo); +}; diff --git a/src/routes/auth/error/+page.svelte b/src/routes/auth/error/+page.svelte index 47fecf8..4b8841c 100644 --- a/src/routes/auth/error/+page.svelte +++ b/src/routes/auth/error/+page.svelte @@ -1,84 +1,84 @@ - - -
-
-
-

- Authentication Error -

- - - - {#if !errorMessage} -

- There was a problem with your sign-in attempt. This could be due to: -

- -
    -
  • Incorrect email or password
  • -
  • Your account may not exist
  • -
  • A temporary connection issue
  • -
  • iOS Safari cookie restrictions (if on iPhone/iPad)
  • -
- {/if} - - -
-
-
- - + + +
+
+
+

+ Couldn't sign you in +

+ + + + {#if !errorMessage} +

+ There was a problem with your sign-in attempt. This could be due to: +

+ +
    +
  • Wrong email or password
  • +
  • No account with that email
  • +
  • A temporary connection issue
  • +
  • Cookie restrictions in Safari on iOS
  • +
+ {/if} + + +
+
+
+ + diff --git a/src/routes/auth/logout/+server.ts b/src/routes/auth/logout/+server.ts index 13b2e7d..e7108d8 100644 --- a/src/routes/auth/logout/+server.ts +++ b/src/routes/auth/logout/+server.ts @@ -1,29 +1,29 @@ -import { redirect } from '@sveltejs/kit'; -import type { RequestHandler } from './$types'; - -export const POST: RequestHandler = async ({ locals, cookies }) => { - if (locals.supabase) { - await locals.supabase.auth.signOut(); - } - - const authCookieNames = [ - 'sb-access-token', - 'sb-refresh-token', - 'supabase-auth-token', - 'supabase.auth.token' - ]; - - const allCookies = cookies.getAll(); - allCookies.forEach((cookie) => { - if (cookie.name.includes('supabase') || cookie.name.includes('sb-')) { - cookies.delete(cookie.name, { path: '/' }); - } - }); - - authCookieNames.forEach((name) => { - cookies.delete(name, { path: '/' }); - cookies.delete(name, { path: '/', domain: undefined }); - }); - - throw redirect(303, '/auth'); -}; +import { redirect } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; + +export const POST: RequestHandler = async ({ locals, cookies }) => { + if (locals.supabase) { + await locals.supabase.auth.signOut(); + } + + const authCookieNames = [ + 'sb-access-token', + 'sb-refresh-token', + 'supabase-auth-token', + 'supabase.auth.token' + ]; + + const allCookies = cookies.getAll(); + allCookies.forEach((cookie) => { + if (cookie.name.includes('supabase') || cookie.name.includes('sb-')) { + cookies.delete(cookie.name, { path: '/' }); + } + }); + + authCookieNames.forEach((name) => { + cookies.delete(name, { path: '/' }); + cookies.delete(name, { path: '/', domain: undefined }); + }); + + throw redirect(303, '/auth'); +}; diff --git a/src/routes/auth/reset-password/+page.server.ts b/src/routes/auth/reset-password/+page.server.ts index 43d5877..ccb7f41 100644 --- a/src/routes/auth/reset-password/+page.server.ts +++ b/src/routes/auth/reset-password/+page.server.ts @@ -1,9 +1,9 @@ -import type { PageServerLoad } from './$types'; - -export const load: PageServerLoad = async ({ locals: { safeGetSession } }) => { - const { session } = await safeGetSession(); - - return { - session - }; -}; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ locals: { safeGetSession } }) => { + const { session } = await safeGetSession(); + + return { + session + }; +}; diff --git a/src/routes/auth/reset-password/+page.svelte b/src/routes/auth/reset-password/+page.svelte index 8431b4d..7d2b3e4 100644 --- a/src/routes/auth/reset-password/+page.svelte +++ b/src/routes/auth/reset-password/+page.svelte @@ -1,212 +1,212 @@ - - -
-
-
- {#if !session} -

- Link Expired -

-

- This password reset link has expired or is invalid. Please request a new one. -

- - Back to Sign In - - {:else} -

- Set New Password -

-

- Enter your new password below. -

- - {#if error} - - {/if} - -
-
- -
- (newPasswordTouched = true)} - class="input input-bordered h-12 w-full px-4 pr-12 text-base {newPasswordError ? 'input-error' : ''} {newPasswordValid ? 'input-success' : ''}" - /> - -
-
- - - - {newPasswordError ?? '\u00A0'} -
-
- -
- -
- (confirmPasswordTouched = true)} - class="input input-bordered h-12 w-full px-4 pr-12 text-base {confirmPasswordError ? 'input-error' : ''} {confirmPasswordValid ? 'input-success' : ''}" - /> - -
-
- - - - {confirmPasswordError ?? '\u00A0'} -
-
- -
- -
-
- {/if} -
-
-
+ + +
+
+
+ {#if !session} +

+ Link Expired +

+

+ This password reset link has expired or is invalid. Please request a new one. +

+ + Back to Sign In + + {:else} +

+ Set New Password +

+

+ Enter your new password below. +

+ + {#if error} + + {/if} + +
+
+ +
+ (newPasswordTouched = true)} + class="input input-bordered h-12 w-full px-4 pr-12 text-base {newPasswordError ? 'input-error' : ''} {newPasswordValid ? 'input-success' : ''}" + /> + +
+
+ + + + {newPasswordError ?? '\u00A0'} +
+
+ +
+ +
+ (confirmPasswordTouched = true)} + class="input input-bordered h-12 w-full px-4 pr-12 text-base {confirmPasswordError ? 'input-error' : ''} {confirmPasswordValid ? 'input-success' : ''}" + /> + +
+
+ + + + {confirmPasswordError ?? '\u00A0'} +
+
+ +
+ +
+
+ {/if} +
+
+
diff --git a/src/routes/dashboard/+layout.server.ts b/src/routes/dashboard/+layout.server.ts index a7fa62f..aa0f9ca 100644 --- a/src/routes/dashboard/+layout.server.ts +++ b/src/routes/dashboard/+layout.server.ts @@ -1,16 +1,16 @@ -// This file makes dashboard routes dynamic, ensuring hooks.server.ts auth guards run on every request. - -import { redirect } from '@sveltejs/kit'; -import type { LayoutServerLoad } from './$types'; - -export const load: LayoutServerLoad = async ({ locals: { safeGetSession } }) => { - const { session } = await safeGetSession(); - - if (!session) { - redirect(303, '/'); - } - - return { - session - }; -}; +// This file makes dashboard routes dynamic, ensuring hooks.server.ts auth guards run on every request. + +import { redirect } from '@sveltejs/kit'; +import type { LayoutServerLoad } from './$types'; + +export const load: LayoutServerLoad = async ({ locals: { safeGetSession } }) => { + const { session } = await safeGetSession(); + + if (!session) { + redirect(303, '/'); + } + + return { + session + }; +}; diff --git a/src/routes/dashboard/+layout.svelte b/src/routes/dashboard/+layout.svelte index 9016247..c7586fe 100644 --- a/src/routes/dashboard/+layout.svelte +++ b/src/routes/dashboard/+layout.svelte @@ -1,12 +1,12 @@ - - - - -
- {@render children()} -
+ + + + +
+ {@render children()} +
diff --git a/src/routes/dashboard/+page.server.ts b/src/routes/dashboard/+page.server.ts index dd7d766..7478523 100644 --- a/src/routes/dashboard/+page.server.ts +++ b/src/routes/dashboard/+page.server.ts @@ -1,15 +1,15 @@ -import type { PageServerLoad } from './$types'; - -export const load: PageServerLoad = async ({ depends, locals: { safeGetSession } }) => { - depends('supabase:auth'); - - const { session } = await safeGetSession(); - - if (!session) { - throw new Error('User not authenticated'); - } - - return { - session - }; -}; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ depends, locals: { safeGetSession } }) => { + depends('supabase:auth'); + + const { session } = await safeGetSession(); + + if (!session) { + throw new Error('User not authenticated'); + } + + return { + session + }; +}; diff --git a/src/routes/dashboard/+page.svelte b/src/routes/dashboard/+page.svelte index 3fe1d67..1ac72cc 100644 --- a/src/routes/dashboard/+page.svelte +++ b/src/routes/dashboard/+page.svelte @@ -1,321 +1,325 @@ -
- {#if !isReady} - -
- - -
- {:else} -
- -
- -
- - -
- {/if} +
+ {#if !isReady} + + + + {:else} + 0} /> + +
+ +
+ +
+ +
+ +
+ +
+ {/if}
diff --git a/src/routes/dashboard/settings/+page.server.ts b/src/routes/dashboard/settings/+page.server.ts index 43d5877..ccb7f41 100644 --- a/src/routes/dashboard/settings/+page.server.ts +++ b/src/routes/dashboard/settings/+page.server.ts @@ -1,9 +1,9 @@ -import type { PageServerLoad } from './$types'; - -export const load: PageServerLoad = async ({ locals: { safeGetSession } }) => { - const { session } = await safeGetSession(); - - return { - session - }; -}; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ locals: { safeGetSession } }) => { + const { session } = await safeGetSession(); + + return { + session + }; +}; diff --git a/src/routes/dashboard/settings/+page.svelte b/src/routes/dashboard/settings/+page.svelte index 0fc1d1a..3b64a49 100644 --- a/src/routes/dashboard/settings/+page.svelte +++ b/src/routes/dashboard/settings/+page.svelte @@ -1,1165 +1,1158 @@ - - -
-
-

Settings

-

- Manage your account preferences and application settings. -

-
- -
-
-
-

- - - - Profile Information -

-
-
-

- Account Information -

- -
- - -
-
- -
-

- Username -

- - {#if isLoadingData} -
-
-
- {:else} -
- {#if currentUsername} -
-

- Current username: {currentUsername} -

-
- {/if} - -
-
- -
-
- -
- -
-
- - Must be 3 to {MAX_USERNAME_LENGTH} characters, start with a letter, and contain - only letters, numbers, dots, dashes, or underscores - -
-
-
-
- {/if} -
- -
-

- Display Name -

- - {#if isLoadingData} -
-
-
- {:else} -
- {#if currentDisplayName} -
-

- Current display name: {currentDisplayName} -

-
- {/if} - -
-
- -
-
- -
- -
-
- - Optional friendly name shown to other users. Up to {MAX_DISPLAY_NAME_LENGTH} - characters. Leave empty to use username. - -
-
-
-
- {/if} -
-
-
-
- -
-
-

- - - - Security -

-
-
-

- Email Address -

- -
-

- Current email: {session?.user?.email || ''} -

-
- - {#if emailChangeSuccess} - - {/if} - -
-
- -
- - -
-
- - A confirmation link will be sent to the new address. Your email won't change - until you confirm. - -
-
-
-
- -
-

- Change Password -

- -
-
- - -
- -
- - -
- -
- - -
- -
- -
-
-
-
-
-
- -
-
-

- - Quick Statuses -

-
-

- Set up to 5 pre-defined statuses for quick selection from the dashboard. -

- - {#if isLoadingData} -
- {#each Array.from({ length: 5 }, (_, i) => i) as i (i)} -
- {/each} -
- {:else} -
- {#each Array.from({ length: 5 }, (_, i) => i) as index (index)} -
- - -
- {/each} - -
- -
- - These statuses will appear as quick options on your dashboard. Leave blank to - remove. - -
-
-
- {/if} -
-
-
- -
-
-

- - Appearance -

-
- -
-
-
- -
-
-

- - - - Data Management -

-
-
- -
- Download your data in JSON format for backup or migration purposes -
-
-
-
-
- -
-
-

- - Danger Zone -

-
-
- -
- - Permanently delete your account and all associated data. This action cannot be - undone. - -
-
-
-
-
-
-
- -{#if showDeleteModal} - -{/if} + + +
+
+

Settings

+

+ Update your profile, security, and appearance. +

+
+ +
+
+
+

+ + + + Profile Information +

+
+ {#if dev} +
+

+ Account Information +

+ +
+ + +
+
+ {/if} + +
+

+ Username +

+ + {#if isLoadingData} +
+
+
+ {:else} +
+ {#if currentUsername} +
+

+ Current username: {currentUsername} +

+
+ {/if} + +
+
+ +
+
+ +
+ +
+
+ + 3–{MAX_USERNAME_LENGTH} characters. Must start with a letter. Letters, numbers, dots, dashes, and underscores only. + +
+
+
+
+ {/if} +
+ +
+

+ Display Name +

+ + {#if isLoadingData} +
+
+
+ {:else} +
+ {#if currentDisplayName} +
+

+ Current display name: {currentDisplayName} +

+
+ {/if} + +
+
+ +
+
+ +
+ +
+
+ + Shown to friends instead of your username. Leave blank to show your username. + +
+
+
+
+ {/if} +
+
+
+
+ +
+
+

+ + + + Security +

+
+
+

+ Email Address +

+ +
+

+ Current email: {session?.user?.email || ''} +

+
+ + {#if emailChangeSuccess} + + {/if} + +
+
+ +
+ + +
+
+ + A confirmation link will be sent to the new address. Your email won't change + until you confirm. + +
+
+
+
+ +
+

+ Change Password +

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+
+
+
+
+ +
+
+

+ + Quick Statuses +

+
+

+ Save statuses you use often — they'll appear as one-tap options on your dashboard. +

+ + {#if isLoadingData} +
+ {#each Array.from({ length: 5 }, (_, i) => i) as i (i)} +
+ {/each} +
+ {:else} +
+ {#each Array.from({ length: 5 }, (_, i) => i) as index (index)} +
+ + +
+ {/each} + +
+ +
+ + Leave a field blank to remove that quick status. + +
+
+
+ {/if} +
+
+
+ +
+
+

+ + Appearance +

+
+ +
+
+
+ +
+
+

+ + + + Data Management +

+
+
+ +
+ Download a copy of your friends, statuses, and account info. +
+
+
+
+
+ +
+
+

+ + Danger Zone +

+
+
+ +
+ + Deletes your account, all friends, and all statuses. This can't be undone. + +
+
+
+
+
+
+
+ + e.target === e.currentTarget && cancelDeleteAccount()} +> + + + diff --git a/tsconfig.json b/tsconfig.json index 0b2d886..92773a9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,19 +1,19 @@ -{ - "extends": "./.svelte-kit/tsconfig.json", - "compilerOptions": { - "allowJs": true, - "checkJs": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "resolveJsonModule": true, - "skipLibCheck": true, - "sourceMap": true, - "strict": true, - "moduleResolution": "bundler" - } - // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias - // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files - // - // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes - // from the referenced tsconfig.json - TypeScript does not merge them in -} +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } + // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias + // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files + // + // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes + // from the referenced tsconfig.json - TypeScript does not merge them in +} diff --git a/vite.config.ts b/vite.config.ts index 10bc094..62eb92f 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,11 +1,11 @@ -/// -import tailwindcss from '@tailwindcss/vite'; -import { sveltekit } from '@sveltejs/kit/vite'; -import { defineConfig } from 'vite'; - -export default defineConfig({ - plugins: [tailwindcss(), sveltekit()], - test: { - include: ['src/**/*.test.ts'] - } -}); +/// +import tailwindcss from '@tailwindcss/vite'; +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [tailwindcss(), sveltekit()], + test: { + include: ['src/**/*.test.ts'] + } +}); From 4e67cf32b533ce8645a60badadd85a41eb7b4228 Mon Sep 17 00:00:00 2001 From: Andrew Novac <16753077+novatorem@users.noreply.github.com> Date: Sun, 15 Mar 2026 09:18:12 -0400 Subject: [PATCH 2/7] I will run 'terraform fmt' before committing. --- CLAUDE.md | 34 ++- README.md | 150 ++++----- src/lib/dashboard/GettingStarted.svelte | 6 +- src/lib/friends/components/List.svelte | 23 +- src/lib/friends/components/Requests.svelte | 12 +- src/lib/friends/pendingCount.svelte.ts | 36 +++ src/lib/status/components/Section.svelte | 16 +- src/lib/status/formatting.ts | 10 +- src/lib/ui/Footer.svelte | 2 +- src/lib/ui/Navigation.svelte | 32 +- src/lib/ui/notifications.ts | 2 +- src/routes/+page.svelte | 15 +- src/routes/auth/+page.svelte | 337 ++++++++++++++++----- src/routes/dashboard/+layout.svelte | 44 ++- src/routes/dashboard/+page.svelte | 43 --- src/routes/dashboard/friends/+page.svelte | 63 ++++ src/routes/dashboard/settings/+page.svelte | 63 ++-- src/sql/functions/get_dashboard_data.psql | 4 +- src/sql/tables/friend_requests.psql | 2 +- 19 files changed, 592 insertions(+), 302 deletions(-) create mode 100644 src/lib/friends/pendingCount.svelte.ts create mode 100644 src/routes/dashboard/friends/+page.svelte diff --git a/CLAUDE.md b/CLAUDE.md index 7375e1d..84b2fcb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,4 +1,4 @@ -# Rez — Claude Guidelines +# Rez - Claude Guidelines ## Project Overview @@ -14,37 +14,43 @@ ## Code Conventions -- **Indentation:** 2 spaces (no tabs) — `.prettierrc` enforces this +- **Indentation:** 2 spaces (no tabs) - `.prettierrc` enforces this - **Components:** Svelte 5 runes only (no legacy Options API / stores except `toastStore`) -- **Animations:** Centralized in `src/app.css` — easing tokens (`--ease-out-quart`, `--ease-out-expo`), keyframes, and utility classes. Do not define duplicate keyframes in component ` diff --git a/src/lib/ui/Footer.svelte b/src/lib/ui/Footer.svelte index b918ba4..b7f8590 100644 --- a/src/lib/ui/Footer.svelte +++ b/src/lib/ui/Footer.svelte @@ -1,4 +1,4 @@ -