From 4ebfbfe39c180dad5218be5b34a5661e408efb2d Mon Sep 17 00:00:00 2001 From: novatorem <16753077+novatorem@users.noreply.github.com> Date: Sun, 15 Mar 2026 14:35:00 -0400 Subject: [PATCH] Normalize between mobile and desktop; finally run format/lint --- .prettierrc | 32 +- .vscode/extensions.json | 14 +- eslint.config.js | 48 +- package.json | 90 +- src/app.css | 261 +- src/app.d.ts | 42 +- src/app.html | 35 +- src/database.types.ts | 292 +-- src/hooks.server.ts | 260 +- src/lib/dashboard/GettingStarted.svelte | 258 +- src/lib/dashboard/loader.ts | 576 ++-- src/lib/friends/api.ts | 162 +- src/lib/friends/components/DeleteModal.svelte | 78 +- src/lib/friends/components/List.svelte | 1297 +++++---- .../friends/components/ListSkeleton.svelte | 64 +- src/lib/friends/components/Requests.svelte | 928 +++---- .../components/RequestsSkeleton.svelte | 58 +- src/lib/friends/order.test.ts | 222 +- src/lib/friends/order.ts | 165 +- src/lib/friends/pendingCount.svelte.ts | 72 +- src/lib/profile/api.ts | 50 +- src/lib/profile/validation.test.ts | 224 +- src/lib/profile/validation.ts | 98 +- src/lib/realtime/subscriptions.ts | 346 +-- src/lib/status/components/Section.svelte | 379 ++- src/lib/status/components/Skeleton.svelte | 38 +- src/lib/status/formatting.test.ts | 128 +- src/lib/status/formatting.ts | 72 +- 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/Footer.svelte | 82 +- src/lib/ui/Navigation.svelte | 310 ++- src/lib/ui/RelativeTime.svelte | 42 +- src/lib/ui/ThemeSelect.svelte | 200 +- src/lib/ui/Toast.svelte | 140 +- src/lib/ui/ToastContainer.svelte | 20 +- src/lib/ui/notifications.ts | 58 +- src/lib/ui/now.svelte.ts | 46 +- src/lib/ui/themes.ts | 74 +- src/lib/ui/toast.ts | 82 +- src/routes/+layout.server.ts | 18 +- src/routes/+layout.svelte | 158 +- src/routes/+layout.ts | 62 +- src/routes/+page.svelte | 242 +- src/routes/auth/+layout.svelte | 14 +- src/routes/auth/+page.server.ts | 148 +- src/routes/auth/+page.svelte | 1406 +++++----- src/routes/auth/confirm/+server.ts | 62 +- src/routes/auth/error/+page.svelte | 187 +- src/routes/auth/logout/+server.ts | 58 +- .../auth/reset-password/+page.server.ts | 18 +- src/routes/auth/reset-password/+page.svelte | 507 ++-- src/routes/dashboard/+layout.server.ts | 32 +- src/routes/dashboard/+layout.svelte | 125 +- src/routes/dashboard/+page.server.ts | 30 +- src/routes/dashboard/+page.svelte | 568 ++-- src/routes/dashboard/friends/+page.svelte | 130 +- src/routes/dashboard/settings/+page.server.ts | 18 +- src/routes/dashboard/settings/+page.svelte | 2336 ++++++++--------- static/manifest.json | 48 +- tsconfig.json | 38 +- vite.config.ts | 22 +- 65 files changed, 7342 insertions(+), 7172 deletions(-) diff --git a/.prettierrc b/.prettierrc index 7317c54..ef3063b 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,16 +1,16 @@ -{ - "useTabs": false, - "tabWidth": 2, - "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/.vscode/extensions.json b/.vscode/extensions.json index 5dad6bd..1e2e7c1 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,9 +1,9 @@ { - "recommendations": [ - "svelte.svelte-vscode", - "catppuccin.catppuccin-vsc", - "catppuccin.catppuccin-vsc-icons", - "catppuccin.catppuccin-vsc-pack", - "donjayamanne.githistory" - ] + "recommendations": [ + "svelte.svelte-vscode", + "catppuccin.catppuccin-vsc", + "catppuccin.catppuccin-vsc-icons", + "catppuccin.catppuccin-vsc-pack", + "donjayamanne.githistory" + ] } diff --git a/eslint.config.js b/eslint.config.js index 9b34ef5..d857e3a 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -10,28 +10,28 @@ import svelteConfig from './svelte.config.js'; const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url)); export default ts.config( - includeIgnoreFile(gitignorePath), - js.configs.recommended, - ...ts.configs.recommended, - ...svelte.configs.recommended, - prettier, - ...svelte.configs.prettier, - { - languageOptions: { - globals: { ...globals.browser, ...globals.node } - }, - rules: { 'no-undef': 'off' } - }, - { - files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'], - ignores: ['eslint.config.js', 'svelte.config.js'], - languageOptions: { - parserOptions: { - projectService: true, - extraFileExtensions: ['.svelte'], - parser: ts.parser, - svelteConfig - } - } - } + includeIgnoreFile(gitignorePath), + js.configs.recommended, + ...ts.configs.recommended, + ...svelte.configs.recommended, + prettier, + ...svelte.configs.prettier, + { + languageOptions: { + globals: { ...globals.browser, ...globals.node } + }, + rules: { 'no-undef': 'off' } + }, + { + files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'], + ignores: ['eslint.config.js', 'svelte.config.js'], + languageOptions: { + parserOptions: { + projectService: true, + extraFileExtensions: ['.svelte'], + parser: ts.parser, + svelteConfig + } + } + } ); diff --git a/package.json b/package.json index d19d1ad..43fcce5 100644 --- a/package.json +++ b/package.json @@ -1,47 +1,47 @@ { - "name": "rez", - "private": true, - "version": "0.0.1", - "type": "module", - "scripts": { - "dev": "vite dev", - "build": "vite build", - "preview": "vite preview", - "prepare": "svelte-kit sync || echo ''", - "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", - "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", - "format": "prettier --write .", - "lint": "prettier --check . && eslint .", - "test": "vitest run", - "test:watch": "vitest" - }, - "devDependencies": { - "@eslint/compat": "^1.4.1", - "@eslint/js": "^9.39.3", - "@sveltejs/adapter-auto": "^7.0.1", - "@sveltejs/kit": "^2.53.0", - "@sveltejs/vite-plugin-svelte": "^6.2.4", - "@tailwindcss/vite": "^4.2.1", - "eslint": "^9.39.3", - "eslint-config-prettier": "^10.1.8", - "eslint-plugin-svelte": "^3.15.0", - "globals": "^17.3.0", - "prettier": "^3.8.1", - "prettier-plugin-svelte": "^3.5.0", - "prettier-plugin-tailwindcss": "^0.7.2", - "svelte": "^5.53.3", - "svelte-check": "^4.4.3", - "tailwindcss": "^4.2.1", - "typescript": "^5.9.3", - "typescript-eslint": "^8.56.1", - "vite": "^7.3.1", - "vitest": "^4.0.18" - }, - "dependencies": { - "@supabase/ssr": "^0.8.0", - "@supabase/supabase-js": "^2.97.0", - "daisyui": "^5.5.19", - "svelte-boring-avatars": "^1.2.6", - "theme-change": "^2.5.0" - } + "name": "rez", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "prepare": "svelte-kit sync || echo ''", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "format": "prettier --write .", + "lint": "prettier --check . && eslint .", + "test": "vitest run", + "test:watch": "vitest" + }, + "devDependencies": { + "@eslint/compat": "^1.4.1", + "@eslint/js": "^9.39.3", + "@sveltejs/adapter-auto": "^7.0.1", + "@sveltejs/kit": "^2.53.0", + "@sveltejs/vite-plugin-svelte": "^6.2.4", + "@tailwindcss/vite": "^4.2.1", + "eslint": "^9.39.3", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-svelte": "^3.15.0", + "globals": "^17.3.0", + "prettier": "^3.8.1", + "prettier-plugin-svelte": "^3.5.0", + "prettier-plugin-tailwindcss": "^0.7.2", + "svelte": "^5.53.3", + "svelte-check": "^4.4.3", + "tailwindcss": "^4.2.1", + "typescript": "^5.9.3", + "typescript-eslint": "^8.56.1", + "vite": "^7.3.1", + "vitest": "^4.0.18" + }, + "dependencies": { + "@supabase/ssr": "^0.8.0", + "@supabase/supabase-js": "^2.97.0", + "daisyui": "^5.5.19", + "svelte-boring-avatars": "^1.2.6", + "theme-change": "^2.5.0" + } } diff --git a/src/app.css b/src/app.css index a18b177..a35ec50 100644 --- a/src/app.css +++ b/src/app.css @@ -1,121 +1,140 @@ -@import 'tailwindcss'; -@plugin "daisyui" { - themes: all; -} - -/* ── Typography ────────────────────────────────────────────── */ -@theme { - --font-sans: 'Figtree', ui-sans-serif, system-ui, sans-serif; -} - -/* ── Easing tokens ─────────────────────────────────────────── */ -:root { - --ease-out-quart: cubic-bezier(0.25, 1, 0.5, 1); - --ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1); -} - -/* ── Keyframes ─────────────────────────────────────────────── */ -@keyframes fadeInUp { - from { - opacity: 0; - transform: translateY(16px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -@keyframes alertIn { - from { - opacity: 0; - transform: translateY(-6px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -@keyframes statusReveal { - from { - opacity: 0; - transform: translateY(5px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -@keyframes modalIn { - from { - opacity: 0; - transform: scale(0.96) translateY(8px); - } - to { - opacity: 1; - transform: scale(1) translateY(0); - } -} - -/* ── Animation utilities ───────────────────────────────────── */ -.animate-fade-in-up { - animation: fadeInUp 0.6s var(--ease-out-expo) both; -} - -.animate-fade-in-up-fast { - animation: fadeInUp 0.35s var(--ease-out-expo) both; -} - -/* Animation delay utilities */ -.animate-delay-0 { animation-delay: 0ms; } -.animate-delay-1 { animation-delay: 60ms; } -.animate-delay-2 { animation-delay: 120ms; } -.animate-delay-3 { animation-delay: 190ms; } -.animate-delay-4 { animation-delay: 300ms; } - -.animate-alert-in { - animation: alertIn 0.22s var(--ease-out-quart) both; -} - -.animate-status-reveal { - animation: statusReveal 0.28s var(--ease-out-quart) both; -} - -.animate-modal-in { - animation: modalIn 0.28s var(--ease-out-quart) both; -} - -/* ── Button press micro-interaction ───────────────────────── */ -.btn:not(:disabled):active { - transform: scale(0.96); - transition: transform 0.08s var(--ease-out-quart) !important; -} - -/* ── Drop placement confirmation ──────────────────────────── */ -@keyframes justPlaced { - 0% { outline: 2px solid color-mix(in oklch, var(--color-primary) 55%, transparent); } - 100% { outline: 2px solid color-mix(in oklch, var(--color-primary) 0%, transparent); } -} - -/* ── Live status pulse ─────────────────────────────────────── */ -@keyframes livePulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.35; } -} - -.animate-live-pulse { - animation: livePulse 1.8s ease-in-out infinite; -} - -/* ── Global reduced-motion catch-all ───────────────────────── */ -@media (prefers-reduced-motion: reduce) { - *, - ::before, - ::after { - animation-duration: 0.01ms !important; - animation-iteration-count: 1 !important; - transition-duration: 0.01ms !important; - } -} +@import 'tailwindcss'; +@plugin 'daisyui' { + themes: all; +} + +/* ── Typography ────────────────────────────────────────────── */ +@theme { + --font-sans: 'Figtree', ui-sans-serif, system-ui, sans-serif; +} + +/* ── Easing tokens ─────────────────────────────────────────── */ +:root { + --ease-out-quart: cubic-bezier(0.25, 1, 0.5, 1); + --ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1); +} + +/* ── Keyframes ─────────────────────────────────────────────── */ +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(16px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes alertIn { + from { + opacity: 0; + transform: translateY(-6px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes statusReveal { + from { + opacity: 0; + transform: translateY(5px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes modalIn { + from { + opacity: 0; + transform: scale(0.96) translateY(8px); + } + to { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +/* ── Animation utilities ───────────────────────────────────── */ +.animate-fade-in-up { + animation: fadeInUp 0.6s var(--ease-out-expo) both; +} + +.animate-fade-in-up-fast { + animation: fadeInUp 0.35s var(--ease-out-expo) both; +} + +/* Animation delay utilities */ +.animate-delay-0 { + animation-delay: 0ms; +} +.animate-delay-1 { + animation-delay: 60ms; +} +.animate-delay-2 { + animation-delay: 120ms; +} +.animate-delay-3 { + animation-delay: 190ms; +} +.animate-delay-4 { + animation-delay: 300ms; +} + +.animate-alert-in { + animation: alertIn 0.22s var(--ease-out-quart) both; +} + +.animate-status-reveal { + animation: statusReveal 0.28s var(--ease-out-quart) both; +} + +.animate-modal-in { + animation: modalIn 0.28s var(--ease-out-quart) both; +} + +/* ── Button press micro-interaction ───────────────────────── */ +.btn:not(:disabled):active { + transform: scale(0.96); + transition: transform 0.08s var(--ease-out-quart) !important; +} + +/* ── Drop placement confirmation ──────────────────────────── */ +@keyframes justPlaced { + 0% { + outline: 2px solid color-mix(in oklch, var(--color-primary) 55%, transparent); + } + 100% { + outline: 2px solid color-mix(in oklch, var(--color-primary) 0%, transparent); + } +} + +/* ── Live status pulse ─────────────────────────────────────── */ +@keyframes livePulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.35; + } +} + +.animate-live-pulse { + animation: livePulse 1.8s ease-in-out infinite; +} + +/* ── Global reduced-motion catch-all ───────────────────────── */ +@media (prefers-reduced-motion: reduce) { + *, + ::before, + ::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} diff --git a/src/app.d.ts b/src/app.d.ts index a7e1717..0d32ed6 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -1,21 +1,21 @@ -import type { Session, SupabaseClient, User } from '@supabase/supabase-js'; -import type { Database } from './database.types.ts'; // import generated types - -declare global { - namespace App { - // interface Error {} - interface Locals { - supabase: SupabaseClient; - safeGetSession: () => Promise<{ session: Session | null; user: User | null }>; - session: Session | null; - user: User | null; - } - interface PageData { - session: Session | null; - } - // interface PageState {} - // interface Platform {} - } -} - -export { type Database }; +import type { Session, SupabaseClient, User } from '@supabase/supabase-js'; +import type { Database } from './database.types.ts'; // import generated types + +declare global { + namespace App { + // interface Error {} + interface Locals { + supabase: SupabaseClient; + safeGetSession: () => Promise<{ session: Session | null; user: User | null }>; + session: Session | null; + user: User | null; + } + interface PageData { + session: Session | null; + } + // interface PageState {} + // interface Platform {} + } +} + +export { type Database }; diff --git a/src/app.html b/src/app.html index 48a61d4..137b068 100644 --- a/src/app.html +++ b/src/app.html @@ -1,19 +1,22 @@ - - - - - - - - - - - - %sveltekit.head% - - -
%sveltekit.body%
- + + + + + + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ diff --git a/src/database.types.ts b/src/database.types.ts index 50ae7ee..c2ec122 100644 --- a/src/database.types.ts +++ b/src/database.types.ts @@ -1,146 +1,146 @@ -export type Json = string | number | boolean | null | { [key: string]: Json | undefined } | Json[]; - -export interface Database { - public: { - Tables: { - users: { - Row: { - id: string; - username: string; - email: string | null; - display_name: string | null; - created_at: string; - updated_at: string; - }; - Insert: { - id: string; - username: string; - email?: string | null; - display_name?: string | null; - created_at?: string; - updated_at?: string; - }; - Update: { - id?: string; - username?: string; - email?: string | null; - display_name?: string | null; - created_at?: string; - updated_at?: string; - }; - Relationships: []; - }; - profiles: { - Row: { - id: string; - status: string | null; - created_at: string; - updated_at: string; - }; - Insert: { - id: string; - status?: string | null; - created_at?: string; - updated_at?: string; - }; - Update: { - id?: string; - status?: string | null; - created_at?: string; - updated_at?: string; - }; - Relationships: []; - }; - friend_requests: { - Row: { - id: string; - requester_id: string; - target_id: string; - created_at: string; - updated_at: string; - }; - Insert: { - id?: string; - requester_id: string; - target_id: string; - created_at?: string; - updated_at?: string; - }; - Update: { - id?: string; - requester_id?: string; - target_id?: string; - created_at?: string; - updated_at?: string; - }; - Relationships: [ - { - foreignKeyName: 'friend_requests_requester_id_fkey'; - columns: ['requester_id']; - referencedRelation: 'users'; - referencedColumns: ['id']; - }, - { - foreignKeyName: 'friend_requests_target_id_fkey'; - columns: ['target_id']; - referencedRelation: 'users'; - referencedColumns: ['id']; - } - ]; - }; - friends: { - Row: { - id: string; - user_id: string; - friend_id: string; - created_at: string; - }; - Insert: { - id?: string; - user_id: string; - friend_id: string; - created_at?: string; - }; - Update: { - id?: string; - user_id?: string; - friend_id?: string; - created_at?: string; - }; - Relationships: [ - { - foreignKeyName: 'friends_user_id_fkey'; - columns: ['user_id']; - referencedRelation: 'users'; - referencedColumns: ['id']; - }, - { - foreignKeyName: 'friends_friend_id_fkey'; - columns: ['friend_id']; - referencedRelation: 'users'; - referencedColumns: ['id']; - } - ]; - }; - }; - Views: { - [_ in never]: never; - }; - Functions: { - get_dashboard_data: { - Args: Record; - Returns: Json; - }; - delete_user_account: { - Args: Record; - Returns: undefined; - }; - }; - Enums: { - [_ in never]: never; - }; - CompositeTypes: { - [_ in never]: never; - }; - }; -} +export type Json = string | number | boolean | null | { [key: string]: Json | undefined } | Json[]; + +export interface Database { + public: { + Tables: { + users: { + Row: { + id: string; + username: string; + email: string | null; + display_name: string | null; + created_at: string; + updated_at: string; + }; + Insert: { + id: string; + username: string; + email?: string | null; + display_name?: string | null; + created_at?: string; + updated_at?: string; + }; + Update: { + id?: string; + username?: string; + email?: string | null; + display_name?: string | null; + created_at?: string; + updated_at?: string; + }; + Relationships: []; + }; + profiles: { + Row: { + id: string; + status: string | null; + created_at: string; + updated_at: string; + }; + Insert: { + id: string; + status?: string | null; + created_at?: string; + updated_at?: string; + }; + Update: { + id?: string; + status?: string | null; + created_at?: string; + updated_at?: string; + }; + Relationships: []; + }; + friend_requests: { + Row: { + id: string; + requester_id: string; + target_id: string; + created_at: string; + updated_at: string; + }; + Insert: { + id?: string; + requester_id: string; + target_id: string; + created_at?: string; + updated_at?: string; + }; + Update: { + id?: string; + requester_id?: string; + target_id?: string; + created_at?: string; + updated_at?: string; + }; + Relationships: [ + { + foreignKeyName: 'friend_requests_requester_id_fkey'; + columns: ['requester_id']; + referencedRelation: 'users'; + referencedColumns: ['id']; + }, + { + foreignKeyName: 'friend_requests_target_id_fkey'; + columns: ['target_id']; + referencedRelation: 'users'; + referencedColumns: ['id']; + } + ]; + }; + friends: { + Row: { + id: string; + user_id: string; + friend_id: string; + created_at: string; + }; + Insert: { + id?: string; + user_id: string; + friend_id: string; + created_at?: string; + }; + Update: { + id?: string; + user_id?: string; + friend_id?: string; + created_at?: string; + }; + Relationships: [ + { + foreignKeyName: 'friends_user_id_fkey'; + columns: ['user_id']; + referencedRelation: 'users'; + referencedColumns: ['id']; + }, + { + foreignKeyName: 'friends_friend_id_fkey'; + columns: ['friend_id']; + referencedRelation: 'users'; + referencedColumns: ['id']; + } + ]; + }; + }; + Views: { + [_ in never]: never; + }; + Functions: { + get_dashboard_data: { + Args: Record; + Returns: Json; + }; + delete_user_account: { + Args: Record; + Returns: undefined; + }; + }; + Enums: { + [_ in never]: never; + }; + CompositeTypes: { + [_ in never]: never; + }; + }; +} diff --git a/src/hooks.server.ts b/src/hooks.server.ts index fdcba2e..c22c2c1 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,131 +1,129 @@ -if (process.env.NODE_ENV === 'development') { - process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; - console.log('⚠️\tSSL certificate verification disabled for development'); -} - -import { PUBLIC_SUPABASE_ANON_KEY, PUBLIC_SUPABASE_URL } from '$env/static/public'; -import { themes } from '$lib/ui/themes'; -import { createServerClient } from '@supabase/ssr'; -import { type Handle, redirect } from '@sveltejs/kit'; -import { sequence } from '@sveltejs/kit/hooks'; -import https from 'node:https'; - -const isDev = process.env.NODE_ENV === 'development'; -const httpsAgent = isDev ? new https.Agent({ rejectUnauthorized: false }) : undefined; - -const customFetch = (input: URL | RequestInfo, init?: RequestInit) => { - if (isDev) { - return fetch(input, { - ...init, - // @ts-expect-error - Agent is not in standard RequestInit but works with Node.js fetch - agent: httpsAgent - }); - } - return fetch(input, init); -}; - -const supabase: Handle = async ({ event, resolve }) => { - event.locals.supabase = createServerClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, { - cookies: { - getAll: () => event.cookies.getAll(), - setAll: ( - cookiesToSet: { name: string; value: string; options?: Record }[] - ) => { - cookiesToSet.forEach(({ name, value, options }) => { - const host = event.url.hostname; - - const secureOptions = { - ...options, - path: '/', - secure: event.url.protocol === 'https:', - sameSite: 'lax' as const, - domain: host.startsWith('localhost') || host.startsWith('127.0.0.1') - ? undefined - : host - }; - - event.cookies.set(name, value, secureOptions); - }); - } - }, - global: { - fetch: customFetch - } - }); - - event.locals.safeGetSession = async () => { - const { - data: { session }, - error: sessionError - } = await event.locals.supabase.auth.getSession(); - - if (!session || sessionError) { - return { session: null, user: null }; - } - - const { - data: { user }, - error: userError - } = await event.locals.supabase.auth.getUser(); - - if (userError) { - return { session: null, user: null }; - } - - return { session, user }; - }; - - return resolve(event, { - filterSerializedResponseHeaders(name) { - return name === 'content-range' || name === 'x-supabase-api-version'; - } - }); -}; - -const authGuard: Handle = async ({ event, resolve }) => { - const { session, user } = await event.locals.safeGetSession(); - event.locals.session = session; - event.locals.user = user; - - if (!event.locals.session && event.url.pathname.startsWith('/dashboard')) { - redirect(303, '/auth'); - } - - if (event.locals.session && event.url.pathname === '/auth') { - redirect(303, '/dashboard'); - } - - return resolve(event); -}; - -const handleDevToolsRequests: Handle = async ({ event, resolve }) => { - if (event.url.pathname.includes('/.well-known/appspecific/com.chrome.devtools')) { - return new Response(JSON.stringify({ message: 'Not implemented' }), { - status: 200, - headers: { 'Content-Type': 'application/json' } - }); - } - - return resolve(event); -}; - -const themeHandler: Handle = async ({ event, resolve }) => { - const theme = event.cookies.get('theme'); - - const isValidTheme = theme && themes.includes(theme); - - const transformOptions = isValidTheme - ? { - transformPageChunk: ({ html }: { html: string }) => { - return html.replace( - //, - `` - ); - } - } - : {}; - - return resolve(event, transformOptions); -}; - -export const handle: Handle = sequence(supabase, authGuard, handleDevToolsRequests, themeHandler); +if (process.env.NODE_ENV === 'development') { + process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; + console.log('⚠️\tSSL certificate verification disabled for development'); +} + +import { PUBLIC_SUPABASE_ANON_KEY, PUBLIC_SUPABASE_URL } from '$env/static/public'; +import { themes } from '$lib/ui/themes'; +import { createServerClient } from '@supabase/ssr'; +import { type Handle, redirect } from '@sveltejs/kit'; +import { sequence } from '@sveltejs/kit/hooks'; +import https from 'node:https'; + +const isDev = process.env.NODE_ENV === 'development'; +const httpsAgent = isDev ? new https.Agent({ rejectUnauthorized: false }) : undefined; + +const customFetch = (input: URL | RequestInfo, init?: RequestInit) => { + if (isDev) { + return fetch(input, { + ...init, + // @ts-expect-error - Agent is not in standard RequestInit but works with Node.js fetch + agent: httpsAgent + }); + } + return fetch(input, init); +}; + +const supabase: Handle = async ({ event, resolve }) => { + event.locals.supabase = createServerClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, { + cookies: { + getAll: () => event.cookies.getAll(), + setAll: ( + cookiesToSet: { name: string; value: string; options?: Record }[] + ) => { + cookiesToSet.forEach(({ name, value, options }) => { + const host = event.url.hostname; + + const secureOptions = { + ...options, + path: '/', + secure: event.url.protocol === 'https:', + sameSite: 'lax' as const, + domain: host.startsWith('localhost') || host.startsWith('127.0.0.1') ? undefined : host + }; + + event.cookies.set(name, value, secureOptions); + }); + } + }, + global: { + fetch: customFetch + } + }); + + event.locals.safeGetSession = async () => { + const { + data: { session }, + error: sessionError + } = await event.locals.supabase.auth.getSession(); + + if (!session || sessionError) { + return { session: null, user: null }; + } + + const { + data: { user }, + error: userError + } = await event.locals.supabase.auth.getUser(); + + if (userError) { + return { session: null, user: null }; + } + + return { session, user }; + }; + + return resolve(event, { + filterSerializedResponseHeaders(name) { + return name === 'content-range' || name === 'x-supabase-api-version'; + } + }); +}; + +const authGuard: Handle = async ({ event, resolve }) => { + const { session, user } = await event.locals.safeGetSession(); + event.locals.session = session; + event.locals.user = user; + + if (!event.locals.session && event.url.pathname.startsWith('/dashboard')) { + redirect(303, '/auth'); + } + + if (event.locals.session && event.url.pathname === '/auth') { + redirect(303, '/dashboard'); + } + + return resolve(event); +}; + +const handleDevToolsRequests: Handle = async ({ event, resolve }) => { + if (event.url.pathname.includes('/.well-known/appspecific/com.chrome.devtools')) { + return new Response(JSON.stringify({ message: 'Not implemented' }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }); + } + + return resolve(event); +}; + +const themeHandler: Handle = async ({ event, resolve }) => { + const theme = event.cookies.get('theme'); + + const isValidTheme = theme && themes.includes(theme); + + const transformOptions = isValidTheme + ? { + transformPageChunk: ({ html }: { html: string }) => { + return html.replace( + //, + `` + ); + } + } + : {}; + + return resolve(event, transformOptions); +}; + +export const handle: Handle = sequence(supabase, authGuard, handleDevToolsRequests, themeHandler); diff --git a/src/lib/dashboard/GettingStarted.svelte b/src/lib/dashboard/GettingStarted.svelte index e095e32..a7fe480 100644 --- a/src/lib/dashboard/GettingStarted.svelte +++ b/src/lib/dashboard/GettingStarted.svelte @@ -1,129 +1,129 @@ - - -{#if !dismissed} -
-
-
-
-

Get started

-

- Two steps to start seeing what your friends are up to. -

- -
    -
  • - - {#if hasStatus} - - {/if} - - - Set your status - -
  • - -
  • - - {#if hasFriends} - - {/if} - - - Add a friend - -
  • -
-
- - -
-
-
-{/if} + + +{#if !dismissed} +
+
+
+
+

Get started

+

+ Two steps to start seeing what your friends are up to. +

+ +
    +
  • + + {#if hasStatus} + + {/if} + + + Set your status + +
  • + +
  • + + {#if hasFriends} + + {/if} + + + Add a friend + +
  • +
+
+ + +
+
+
+{/if} diff --git a/src/lib/dashboard/loader.ts b/src/lib/dashboard/loader.ts index 547c29b..22143ce 100644 --- a/src/lib/dashboard/loader.ts +++ b/src/lib/dashboard/loader.ts @@ -1,288 +1,288 @@ -import type { SupabaseClient } from '@supabase/supabase-js'; -import type { Database } from '../../database.types'; -import { getQuickStatuses, type QuickStatus } from '../status/quick.js'; - -export interface FriendRequest { - id: string; - requester_id: string; - requester_username: string; - requester_display_name: string | null; -} - -interface FriendRequestWithUser { - id: string; - requester_id: string; - users: { - username: string; - display_name: string | null; - }; -} - -interface SentFriendRequestWithUser { - id: string; - target_id: string; - users: { - username: string; - display_name: string | null; - }; - created_at: string; -} - -export interface SentFriendRequest { - id: string; - target_id: string; - target_username: string; - target_display_name: string | null; - created_at: string; -} - -export interface Friend { - id: string; - username: string; - display_name: string | null; - status: string | null; - status_updated_at: string | null; -} - -export interface DashboardData { - currentUsername: string; - currentDisplayName: string | null; - currentStatus: string; - friendRequests: FriendRequest[]; - sentFriendRequests: SentFriendRequest[]; - friends: Friend[]; - quickStatuses: QuickStatus[]; -} - -export interface UserExportData { - user: { - id: string; - email: string; - username: string; - display_name: string | null; - created_at: string; - updated_at: string; - }; - profile: { - status: string; - created_at: string; - updated_at: string; - }; - friends: Friend[]; - friendRequests: FriendRequest[]; - sentFriendRequests: SentFriendRequest[]; - quickStatuses: QuickStatus[]; - exportMetadata: { - exportedAt: string; - version: string; - }; -} - -const deduplicateById = (items: T[]): T[] => - items.filter((item, index, self) => index === self.findIndex((other) => other.id === item.id)); - -interface DashboardRpcResult { - username: string; - display_name: string | null; - status: string; - friend_requests: Array<{ - id: string; - requester_id: string; - requester_username: string; - requester_display_name: string | null; - }>; - sent_friend_requests: Array<{ - id: string; - target_id: string; - target_username: string; - target_display_name: string | null; - created_at: string; - }>; - friends: Array<{ - id: string; - username: string; - display_name: string | null; - status: string | null; - status_updated_at: string | null; - }>; -} - -export class DashboardDataLoader { - private supabase: SupabaseClient; - private userId: string; - - constructor(supabase: SupabaseClient, userId: string) { - this.supabase = supabase; - this.userId = userId; - } - - async loadFriendRequests(): Promise { - const { data } = await this.supabase - .from('friend_requests') - .select('id, requester_id, users!requester_id(username, display_name)') - .eq('target_id', this.userId); - - const formattedRequests = - data?.map((request) => { - const requestWithUser = request as FriendRequestWithUser; - return { - id: requestWithUser.id, - requester_id: requestWithUser.requester_id, - requester_username: requestWithUser.users?.username || 'Unknown user', - requester_display_name: requestWithUser.users?.display_name || null - }; - }) || []; - - return deduplicateById(formattedRequests); - } - - async loadSentFriendRequests(): Promise { - const { data } = await this.supabase - .from('friend_requests') - .select('id, target_id, users!target_id(username, display_name), created_at') - .eq('requester_id', this.userId); - - const formattedSentRequests = - data?.map((request) => { - const requestWithUser = request as SentFriendRequestWithUser; - return { - id: requestWithUser.id, - target_id: requestWithUser.target_id, - target_username: requestWithUser.users?.username || 'Unknown user', - target_display_name: requestWithUser.users?.display_name || null, - created_at: requestWithUser.created_at - }; - }) || []; - - return deduplicateById(formattedSentRequests); - } - - async loadFriends(): Promise { - const { data: friendships } = await this.supabase - .from('friends') - .select('id, user_id, friend_id') - .or(`user_id.eq.${this.userId},friend_id.eq.${this.userId}`); - - if (!friendships || friendships.length === 0) { - return []; - } - - const friendIds = friendships.map((friendship) => { - return friendship.user_id === this.userId ? friendship.friend_id : friendship.user_id; - }); - - const [{ data: friendUsers }, { data: friendStatuses }] = await Promise.all([ - this.supabase.from('users').select('id, username, display_name').in('id', friendIds), - this.supabase.from('profiles').select('id, status, updated_at').in('id', friendIds) - ]); - - const userMap = new Map(friendUsers?.map((user) => [user.id, user]) || []); - const statusMap = new Map(friendStatuses?.map((profile) => [profile.id, profile.status]) || []); - const statusUpdatedAtMap = new Map( - friendStatuses?.map((profile) => [profile.id, profile.updated_at]) || [] - ); - - const formattedFriends = friendIds.map((friendId) => { - const user = userMap.get(friendId); - return { - id: friendId, - username: user?.username || 'Unknown', - display_name: user?.display_name || null, - status: statusMap.get(friendId) || null, - status_updated_at: statusUpdatedAtMap.get(friendId) || null - }; - }); - - return deduplicateById(formattedFriends); - } - - async loadQuickStatuses(): Promise { - return getQuickStatuses(); - } - - async loadAllData(): Promise { - const rpcResult = await this.supabase.rpc('get_dashboard_data'); - - if (rpcResult.error) { - throw rpcResult.error; - } - - const data = rpcResult.data as DashboardRpcResult | null; - if (!data) { - throw new Error('No dashboard data returned'); - } - - return { - currentUsername: data.username, - currentDisplayName: data.display_name, - currentStatus: data.status, - friendRequests: data.friend_requests, - sentFriendRequests: data.sent_friend_requests, - friends: data.friends, - quickStatuses: getQuickStatuses() - }; - } - - async exportUserData(): Promise { - const [userData, profileData, friendRequests, sentFriendRequests, friends, quickStatuses] = - await Promise.all([ - this.supabase - .from('users') - .select('id, email, username, display_name, created_at, updated_at') - .eq('id', this.userId) - .single(), - this.supabase - .from('profiles') - .select('status, created_at, updated_at') - .eq('id', this.userId) - .single(), - this.loadFriendRequests(), - this.loadSentFriendRequests(), - this.loadFriends(), - this.loadQuickStatuses() - ]); - - if (!userData.data) { - throw new Error('User data not found'); - } - - return { - user: { - id: userData.data.id, - email: userData.data.email || '', - username: userData.data.username, - display_name: userData.data.display_name, - created_at: userData.data.created_at, - updated_at: userData.data.updated_at - }, - profile: { - status: profileData.data?.status || '', - created_at: profileData.data?.created_at || '', - updated_at: profileData.data?.updated_at || '' - }, - friends, - friendRequests, - sentFriendRequests, - quickStatuses, - exportMetadata: { - exportedAt: new Date().toISOString(), - version: '1.0' - } - }; - } - - async deleteUserAccount(): Promise { - try { - const { error } = await this.supabase.rpc('delete_user_account'); - - if (error) { - throw new Error(`Failed to delete account: ${error.message}`); - } - } catch (error) { - if (error instanceof Error) { - throw error; - } - throw new Error(`Account deletion failed: ${String(error)}`); - } - } -} +import type { SupabaseClient } from '@supabase/supabase-js'; +import type { Database } from '../../database.types'; +import { getQuickStatuses, type QuickStatus } from '../status/quick.js'; + +export interface FriendRequest { + id: string; + requester_id: string; + requester_username: string; + requester_display_name: string | null; +} + +interface FriendRequestWithUser { + id: string; + requester_id: string; + users: { + username: string; + display_name: string | null; + }; +} + +interface SentFriendRequestWithUser { + id: string; + target_id: string; + users: { + username: string; + display_name: string | null; + }; + created_at: string; +} + +export interface SentFriendRequest { + id: string; + target_id: string; + target_username: string; + target_display_name: string | null; + created_at: string; +} + +export interface Friend { + id: string; + username: string; + display_name: string | null; + status: string | null; + status_updated_at: string | null; +} + +export interface DashboardData { + currentUsername: string; + currentDisplayName: string | null; + currentStatus: string; + friendRequests: FriendRequest[]; + sentFriendRequests: SentFriendRequest[]; + friends: Friend[]; + quickStatuses: QuickStatus[]; +} + +export interface UserExportData { + user: { + id: string; + email: string; + username: string; + display_name: string | null; + created_at: string; + updated_at: string; + }; + profile: { + status: string; + created_at: string; + updated_at: string; + }; + friends: Friend[]; + friendRequests: FriendRequest[]; + sentFriendRequests: SentFriendRequest[]; + quickStatuses: QuickStatus[]; + exportMetadata: { + exportedAt: string; + version: string; + }; +} + +const deduplicateById = (items: T[]): T[] => + items.filter((item, index, self) => index === self.findIndex((other) => other.id === item.id)); + +interface DashboardRpcResult { + username: string; + display_name: string | null; + status: string; + friend_requests: Array<{ + id: string; + requester_id: string; + requester_username: string; + requester_display_name: string | null; + }>; + sent_friend_requests: Array<{ + id: string; + target_id: string; + target_username: string; + target_display_name: string | null; + created_at: string; + }>; + friends: Array<{ + id: string; + username: string; + display_name: string | null; + status: string | null; + status_updated_at: string | null; + }>; +} + +export class DashboardDataLoader { + private supabase: SupabaseClient; + private userId: string; + + constructor(supabase: SupabaseClient, userId: string) { + this.supabase = supabase; + this.userId = userId; + } + + async loadFriendRequests(): Promise { + const { data } = await this.supabase + .from('friend_requests') + .select('id, requester_id, users!requester_id(username, display_name)') + .eq('target_id', this.userId); + + const formattedRequests = + data?.map((request) => { + const requestWithUser = request as FriendRequestWithUser; + return { + id: requestWithUser.id, + requester_id: requestWithUser.requester_id, + requester_username: requestWithUser.users?.username || 'Unknown user', + requester_display_name: requestWithUser.users?.display_name || null + }; + }) || []; + + return deduplicateById(formattedRequests); + } + + async loadSentFriendRequests(): Promise { + const { data } = await this.supabase + .from('friend_requests') + .select('id, target_id, users!target_id(username, display_name), created_at') + .eq('requester_id', this.userId); + + const formattedSentRequests = + data?.map((request) => { + const requestWithUser = request as SentFriendRequestWithUser; + return { + id: requestWithUser.id, + target_id: requestWithUser.target_id, + target_username: requestWithUser.users?.username || 'Unknown user', + target_display_name: requestWithUser.users?.display_name || null, + created_at: requestWithUser.created_at + }; + }) || []; + + return deduplicateById(formattedSentRequests); + } + + async loadFriends(): Promise { + const { data: friendships } = await this.supabase + .from('friends') + .select('id, user_id, friend_id') + .or(`user_id.eq.${this.userId},friend_id.eq.${this.userId}`); + + if (!friendships || friendships.length === 0) { + return []; + } + + const friendIds = friendships.map((friendship) => { + return friendship.user_id === this.userId ? friendship.friend_id : friendship.user_id; + }); + + const [{ data: friendUsers }, { data: friendStatuses }] = await Promise.all([ + this.supabase.from('users').select('id, username, display_name').in('id', friendIds), + this.supabase.from('profiles').select('id, status, updated_at').in('id', friendIds) + ]); + + const userMap = new Map(friendUsers?.map((user) => [user.id, user]) || []); + const statusMap = new Map(friendStatuses?.map((profile) => [profile.id, profile.status]) || []); + const statusUpdatedAtMap = new Map( + friendStatuses?.map((profile) => [profile.id, profile.updated_at]) || [] + ); + + const formattedFriends = friendIds.map((friendId) => { + const user = userMap.get(friendId); + return { + id: friendId, + username: user?.username || 'Unknown', + display_name: user?.display_name || null, + status: statusMap.get(friendId) || null, + status_updated_at: statusUpdatedAtMap.get(friendId) || null + }; + }); + + return deduplicateById(formattedFriends); + } + + async loadQuickStatuses(): Promise { + return getQuickStatuses(); + } + + async loadAllData(): Promise { + const rpcResult = await this.supabase.rpc('get_dashboard_data'); + + if (rpcResult.error) { + throw rpcResult.error; + } + + const data = rpcResult.data as DashboardRpcResult | null; + if (!data) { + throw new Error('No dashboard data returned'); + } + + return { + currentUsername: data.username, + currentDisplayName: data.display_name, + currentStatus: data.status, + friendRequests: data.friend_requests, + sentFriendRequests: data.sent_friend_requests, + friends: data.friends, + quickStatuses: getQuickStatuses() + }; + } + + async exportUserData(): Promise { + const [userData, profileData, friendRequests, sentFriendRequests, friends, quickStatuses] = + await Promise.all([ + this.supabase + .from('users') + .select('id, email, username, display_name, created_at, updated_at') + .eq('id', this.userId) + .single(), + this.supabase + .from('profiles') + .select('status, created_at, updated_at') + .eq('id', this.userId) + .single(), + this.loadFriendRequests(), + this.loadSentFriendRequests(), + this.loadFriends(), + this.loadQuickStatuses() + ]); + + if (!userData.data) { + throw new Error('User data not found'); + } + + return { + user: { + id: userData.data.id, + email: userData.data.email || '', + username: userData.data.username, + display_name: userData.data.display_name, + created_at: userData.data.created_at, + updated_at: userData.data.updated_at + }, + profile: { + status: profileData.data?.status || '', + created_at: profileData.data?.created_at || '', + updated_at: profileData.data?.updated_at || '' + }, + friends, + friendRequests, + sentFriendRequests, + quickStatuses, + exportMetadata: { + exportedAt: new Date().toISOString(), + version: '1.0' + } + }; + } + + async deleteUserAccount(): Promise { + try { + const { error } = await this.supabase.rpc('delete_user_account'); + + if (error) { + throw new Error(`Failed to delete account: ${error.message}`); + } + } catch (error) { + if (error instanceof Error) { + throw error; + } + throw new Error(`Account deletion failed: ${String(error)}`); + } + } +} diff --git a/src/lib/friends/api.ts b/src/lib/friends/api.ts index dad52be..38319c0 100644 --- a/src/lib/friends/api.ts +++ b/src/lib/friends/api.ts @@ -1,81 +1,81 @@ -import type { SupabaseClient } from '@supabase/supabase-js'; - -export async function verifyFriendshipExists( - supabase: SupabaseClient, - userId: string, - friendId: string -): Promise { - try { - const { data: friendship, error } = await supabase - .from('friends') - .select('id') - .or( - `and(user_id.eq.${userId},friend_id.eq.${friendId}),and(user_id.eq.${friendId},friend_id.eq.${userId})` - ) - .maybeSingle(); - - if (error && error.code !== 'PGRST116') { - console.error('Error verifying friendship:', error); - return false; - } - - return !!friendship; - } catch (error) { - console.error('Error verifying friendship:', error); - return false; - } -} - -export async function checkExistingFriendRequest( - supabase: SupabaseClient, - requesterId: string, - targetId: string -): Promise<{ exists: boolean; request?: { id: string } }> { - try { - const { data: existingRequest, error } = await supabase - .from('friend_requests') - .select('id') - .eq('requester_id', requesterId) - .eq('target_id', targetId) - .maybeSingle(); - - if (error && error.code !== 'PGRST116') { - throw error; - } - - return { - exists: !!existingRequest, - request: existingRequest || undefined - }; - } catch (error) { - console.error('Error checking existing friend request:', error); - throw error; - } -} - -export async function checkIncomingFriendRequest( - supabase: SupabaseClient, - requesterId: string, - targetId: string -): Promise<{ exists: boolean; isPending: boolean }> { - try { - const { data: incomingRequest, error } = await supabase - .from('friend_requests') - .select('id') - .eq('requester_id', targetId) - .eq('target_id', requesterId) - .maybeSingle(); - - if (error && error.code !== 'PGRST116') { - throw error; - } - - return { - exists: !!incomingRequest, - isPending: !!incomingRequest - }; - } catch (error) { - console.error('Error checking incoming friend request:', error); - throw error; - } -} +import type { SupabaseClient } from '@supabase/supabase-js'; + +export async function verifyFriendshipExists( + supabase: SupabaseClient, + userId: string, + friendId: string +): Promise { + try { + const { data: friendship, error } = await supabase + .from('friends') + .select('id') + .or( + `and(user_id.eq.${userId},friend_id.eq.${friendId}),and(user_id.eq.${friendId},friend_id.eq.${userId})` + ) + .maybeSingle(); + + if (error && error.code !== 'PGRST116') { + console.error('Error verifying friendship:', error); + return false; + } + + return !!friendship; + } catch (error) { + console.error('Error verifying friendship:', error); + return false; + } +} + +export async function checkExistingFriendRequest( + supabase: SupabaseClient, + requesterId: string, + targetId: string +): Promise<{ exists: boolean; request?: { id: string } }> { + try { + const { data: existingRequest, error } = await supabase + .from('friend_requests') + .select('id') + .eq('requester_id', requesterId) + .eq('target_id', targetId) + .maybeSingle(); + + if (error && error.code !== 'PGRST116') { + throw error; + } + + return { + exists: !!existingRequest, + request: existingRequest || undefined + }; + } catch (error) { + console.error('Error checking existing friend request:', error); + throw error; + } +} + +export async function checkIncomingFriendRequest( + supabase: SupabaseClient, + requesterId: string, + targetId: string +): Promise<{ exists: boolean; isPending: boolean }> { + try { + const { data: incomingRequest, error } = await supabase + .from('friend_requests') + .select('id') + .eq('requester_id', targetId) + .eq('target_id', requesterId) + .maybeSingle(); + + if (error && error.code !== 'PGRST116') { + throw error; + } + + return { + exists: !!incomingRequest, + isPending: !!incomingRequest + }; + } catch (error) { + console.error('Error checking incoming friend request:', error); + throw error; + } +} diff --git a/src/lib/friends/components/DeleteModal.svelte b/src/lib/friends/components/DeleteModal.svelte index fe4bd3b..fd031d3 100644 --- a/src/lib/friends/components/DeleteModal.svelte +++ b/src/lib/friends/components/DeleteModal.svelte @@ -1,39 +1,39 @@ - - - - - - + + + + + + diff --git a/src/lib/friends/components/List.svelte b/src/lib/friends/components/List.svelte index 486d011..0be6128 100644 --- a/src/lib/friends/components/List.svelte +++ b/src/lib/friends/components/List.svelte @@ -1,685 +1,612 @@ - - -
-
-
-

My Friends

- -
- - {#if showSortMenu} -
- {#each Object.entries(SORT_LABELS) as [key, label] (key)} - {@const k = key as SortKey} - {@const active = sortKey === k} - - {/each} -
- {/if} - -
- {#if friends && friends.length > 0} - {#each friends as friend, index (friend.id)} - -
- {#if draggedIndex !== -1 && dropGapIndex === index && !isNeutralGap(dropGapIndex, draggedIndex)} -
- {/if} -
toggleExpand(friend.id)} - onkeydown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - toggleExpand(friend.id); - } - }} - oncontextmenu={(e) => handleContextMenu(e, friend.id)} - 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} -
- {:else} -

No status

- {/if} - {/key} -
- - - {#if deletingFriends.has(friend.id)} - - {/if} -
- - -
-
-
-
-
- {#if friend.status} -

- {friend.status} -

- {:else} -

No status set

- {/if} - {#if friend.status_updated_at} -

- {formatStatusUpdatedAtTooltip(friend.status_updated_at)} -

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

No friends yet

-

- Add friends by searching for their - username. -

-
- {/if} -
-
-
- - -{#if contextMenuFriendId} - {@const ctxFriend = friends.find((f) => f.id === contextMenuFriendId)} - - - - -{/if} - - + + +
+
+
+

My Friends

+ +
+ + {#if showSortMenu} +
+ {#each Object.entries(SORT_LABELS) as [key, label] (key)} + {@const k = key as SortKey} + {@const active = sortKey === k} + + {/each} +
+ {/if} + +
+ {#if friends && friends.length > 0} + {#each friends as friend, index (friend.id)} + +
+ {#if draggedIndex !== -1 && dropGapIndex === index && !isNeutralGap(dropGapIndex, draggedIndex)} +
+ {/if} +
toggleExpand(friend.id)} + onkeydown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + toggleExpand(friend.id); + } + }} + 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} +
+ {:else} +

No status

+ {/if} + {/key} +
+ + + {#if deletingFriends.has(friend.id)} + + {/if} +
+ + +
+
+
+
+
+ {#if friend.status} +

+ {friend.status} +

+ {:else} +

No status set

+ {/if} + {#if friend.status_updated_at} +

+ {formatStatusUpdatedAtTooltip(friend.status_updated_at)} +

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

No friends yet

+

+ Add friends by searching + for their username. +

+
+ {/if} +
+
+
+ + diff --git a/src/lib/friends/components/ListSkeleton.svelte b/src/lib/friends/components/ListSkeleton.svelte index 2d0bf6e..ac6ac2e 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 f87ff47..ce2a9ce 100644 --- a/src/lib/friends/components/Requests.svelte +++ b/src/lib/friends/components/Requests.svelte @@ -1,463 +1,465 @@ - - -
-
-

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 yet - search by username to add a friend. -

- {/if} - - {#if friendRequests && friendRequests.length > 0} -
    - {#each friendRequests as request (request.id)} -
  • -
    -
    -
    - -
    -
    - {getDisplayName(request.requester_display_name, request.requester_username)} wants - to be your friend - {#if request.requester_display_name} - @{request.requester_username} - {/if} -
    -
    -
    - - -
    -
    -
  • - {/each} -
- {/if} - - {#if sentFriendRequests && sentFriendRequests.length > 0} -

Sent requests

-
    - {#each sentFriendRequests as request (request.id)} -
  • -
    -
    -
    - -
    -
    - {getDisplayName(request.target_display_name, request.target_username)} - {#if request.target_display_name} - @{request.target_username} - {/if} -
    -
    - -
    -
  • - {/each} -
- {/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 yet - search by username to add a friend. +

+ {/if} + + {#if friendRequests && friendRequests.length > 0} +
    + {#each friendRequests as request (request.id)} +
  • +
    +
    +
    + +
    +
    + {getDisplayName( + request.requester_display_name, + request.requester_username + )} + {#if request.requester_display_name} + @{request.requester_username} + {/if} +
    +
    +
    + + +
    +
    +
  • + {/each} +
+ {/if} + + {#if sentFriendRequests && sentFriendRequests.length > 0} +

Sent requests

+
    + {#each sentFriendRequests as request (request.id)} +
  • +
    +
    +
    + +
    +
    + {getDisplayName(request.target_display_name, request.target_username)} + {#if request.target_display_name} + @{request.target_username} + {/if} +
    +
    + +
    +
  • + {/each} +
+ {/if} +
+
diff --git a/src/lib/friends/components/RequestsSkeleton.svelte b/src/lib/friends/components/RequestsSkeleton.svelte index ffa165a..2e7fb9b 100644 --- a/src/lib/friends/components/RequestsSkeleton.svelte +++ b/src/lib/friends/components/RequestsSkeleton.svelte @@ -1,29 +1,29 @@ - - -
-
-

Add Friends

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

Add Friends

+ +
+
+
+
+
+
+ +
+ {#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 7f379b7..fcb7a8b 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 8b5e853..756ac6e 100644 --- a/src/lib/friends/order.ts +++ b/src/lib/friends/order.ts @@ -1,83 +1,82 @@ -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/friends/pendingCount.svelte.ts b/src/lib/friends/pendingCount.svelte.ts index 0b8ba6f..6631ae2 100644 --- a/src/lib/friends/pendingCount.svelte.ts +++ b/src/lib/friends/pendingCount.svelte.ts @@ -1,36 +1,36 @@ -import { browser } from '$app/environment'; - -const SEEN_KEY = 'rez_friend_requests_seen'; - -let _count = $state(0); -let _hasUnseen = $state(false); - -export function getPendingCount(): number { - return _count; -} - -export function setPendingCount(n: number): void { - _count = n; - if (n === 0) _hasUnseen = false; -} - -export function getHasUnseen(): boolean { - return _hasUnseen; -} - -export function markUnseen(): void { - _hasUnseen = true; - if (browser) localStorage.removeItem(SEEN_KEY); -} - -export function markSeen(): void { - _hasUnseen = false; - if (browser) localStorage.setItem(SEEN_KEY, '1'); -} - -export function initFromStorage(): void { - if (!browser) return; - if (_count > 0 && !localStorage.getItem(SEEN_KEY)) { - _hasUnseen = true; - } -} +import { browser } from '$app/environment'; + +const SEEN_KEY = 'rez_friend_requests_seen'; + +let _count = $state(0); +let _hasUnseen = $state(false); + +export function getPendingCount(): number { + return _count; +} + +export function setPendingCount(n: number): void { + _count = n; + if (n === 0) _hasUnseen = false; +} + +export function getHasUnseen(): boolean { + return _hasUnseen; +} + +export function markUnseen(): void { + _hasUnseen = true; + if (browser) localStorage.removeItem(SEEN_KEY); +} + +export function markSeen(): void { + _hasUnseen = false; + if (browser) localStorage.setItem(SEEN_KEY, '1'); +} + +export function initFromStorage(): void { + if (!browser) return; + if (_count > 0 && !localStorage.getItem(SEEN_KEY)) { + _hasUnseen = true; + } +} diff --git a/src/lib/profile/api.ts b/src/lib/profile/api.ts index 0978a64..818a300 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 0a63139..a78289b 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 2f4733a..00d298a 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: - '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(); -} +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 bb4f11c..e632d56 100644 --- a/src/lib/realtime/subscriptions.ts +++ b/src/lib/realtime/subscriptions.ts @@ -1,167 +1,179 @@ -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 ee5cbcc..a271c56 100644 --- a/src/lib/status/components/Section.svelte +++ b/src/lib/status/components/Section.svelte @@ -1,191 +1,188 @@ - - -
-
-
-

Right now

- {#if quickStatuses.length > 0} - - {/if} -
- -
-
-
- - {#if showCounter} - {charsRemaining} - {/if} -
- -
-
- - {#if showQuickStatuses && quickStatuses.length > 0} -
- {#if selectedQuickStatusId} - - {/if} - {#each quickStatuses as quickStatus (quickStatus.id)} - handleQuickStatusChange(quickStatus.status_text, quickStatus.id)} - /> - {/each} -
- {/if} - - {#if currentStatus} - {#key currentStatus} -
-

{currentStatus}

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

Right now

+ {#if quickStatuses.length > 0} + + {/if} +
+ + {#if showQuickStatuses && quickStatuses.length > 0} +
+ {#if selectedQuickStatusId} + + {/if} + {#each quickStatuses as quickStatus (quickStatus.id)} + handleQuickStatusChange(quickStatus.status_text, quickStatus.id)} + /> + {/each} +
+ {/if} + +
+
+
+ + {#if showCounter} + {charsRemaining} + {/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 2598b54..20de376 100644 --- a/src/lib/status/components/Skeleton.svelte +++ b/src/lib/status/components/Skeleton.svelte @@ -1,19 +1,19 @@ - - -
-
-

Right now

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

Right now

+ +
+
+
+
+
+
+ +
+
+
+
+
diff --git a/src/lib/status/formatting.test.ts b/src/lib/status/formatting.test.ts index 605911a..4c21bc0 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 226a9e4..ad76836 100644 --- a/src/lib/status/formatting.ts +++ b/src/lib/status/formatting.ts @@ -1,36 +1,36 @@ -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); - const now = new Date(); - return date.toLocaleString('en-US', { - month: 'short', - day: 'numeric', - ...(date.getFullYear() !== now.getFullYear() ? { year: 'numeric' } : {}), - hour: 'numeric', - minute: '2-digit' - }); -}; +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); + const now = new Date(); + return date.toLocaleString('en-US', { + month: 'short', + day: 'numeric', + ...(date.getFullYear() !== now.getFullYear() ? { year: 'numeric' } : {}), + hour: 'numeric', + minute: '2-digit' + }); +}; diff --git a/src/lib/status/quick.test.ts b/src/lib/status/quick.test.ts index e87d08d..00c25f4 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 9494c37..600cda2 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 3dd0007..8d468bc 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 1835725..5016676 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 e331ba2..1b66771 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/Footer.svelte b/src/lib/ui/Footer.svelte index b7f8590..f15a9e6 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 e2cd087..d57f677 100644 --- a/src/lib/ui/Navigation.svelte +++ b/src/lib/ui/Navigation.svelte @@ -1,134 +1,176 @@ - - - + + + diff --git a/src/lib/ui/RelativeTime.svelte b/src/lib/ui/RelativeTime.svelte index ccee2ba..a550309 100644 --- a/src/lib/ui/RelativeTime.svelte +++ b/src/lib/ui/RelativeTime.svelte @@ -1,21 +1,21 @@ - - -{display} + + +{display} diff --git a/src/lib/ui/ThemeSelect.svelte b/src/lib/ui/ThemeSelect.svelte index 13756f5..73d0f54 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 aa64a49..04331cb 100644 --- a/src/lib/ui/Toast.svelte +++ b/src/lib/ui/Toast.svelte @@ -1,48 +1,92 @@ - - -
1 - Math.pow(1 - t, 4) }} - out:fly={{ x: 48, duration: 200, easing: (t) => 1 - Math.pow(1 - t, 4) }} - role="alert" -> - {#if toast.type === 'success'} - - {:else if toast.type === 'error'} - - {:else} - - {/if} - {toast.message} - -
+ + +
1 - Math.pow(1 - t, 4) }} + out:fly={{ x: 48, duration: 200, easing: (t) => 1 - Math.pow(1 - t, 4) }} + role="alert" +> + {#if toast.type === 'success'} + + {:else if toast.type === 'error'} + + {:else} + + {/if} + {toast.message} + +
diff --git a/src/lib/ui/ToastContainer.svelte b/src/lib/ui/ToastContainer.svelte index 8c0246a..7ada702 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 594d472..ed0a76c 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(`Something went wrong - couldn't ${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 index fd17d5f..9f7dafa 100644 --- a/src/lib/ui/now.svelte.ts +++ b/src/lib/ui/now.svelte.ts @@ -1,23 +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; - } - }; -} +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 0ef08e0..b72cc97 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 f4035d5..6e1f336 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 ae6fe8f..d51573e 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 2a6373a..e5037b8 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,79 +1,79 @@ - - - - {pageTitle} - - -
-
- {#key page.url.pathname} -
- {@render children()} -
- {/key} -
- - {#if !page.url.pathname.startsWith('/dashboard')} -
- {/if} - -
+ + + + {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 d9f136c..c1fb185 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 60c990a..ee1d0cc 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,115 +1,127 @@ - - -
- - - - -
-
-
- - -
-

- Rezonate -

- -

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

- -

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

- - -
- - -
- -
-
- Live now -
- -
- {#each pills as pill (pill.id)} -
-
-
- {pill.initials} -
-
- {#key pill.key} - {pill.text} - {/key} -
-
- {/each} -
-
- -
-
-
-
+ + +
+ + + + +
+
+
+ +
+

+ Rezonate +

+ +

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

+ +

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

+ + +
+ + +
+ +
+
+ Live now +
+ +
+ {#each pills as pill (pill.id)} +
+
+
+ {pill.initials} +
+
+ {#key pill.key} + {pill.text} + {/key} +
+
+ {/each} +
+
+
+
+
+
diff --git a/src/routes/auth/+layout.svelte b/src/routes/auth/+layout.svelte index 6235879..8890dab 100644 --- a/src/routes/auth/+layout.svelte +++ b/src/routes/auth/+layout.svelte @@ -1,7 +1,7 @@ - - -
- {@render children()} -
+ + +
+ {@render children()} +
diff --git a/src/routes/auth/+page.server.ts b/src/routes/auth/+page.server.ts index 0843af1..2021667 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 783f0ab..23d733a 100644 --- a/src/routes/auth/+page.svelte +++ b/src/routes/auth/+page.svelte @@ -1,704 +1,702 @@ - - -
- -
- -
-

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} + + +
+ +
+ +
+

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 15d2c70..d295236 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 e5cd692..a6bf5d0 100644 --- a/src/routes/auth/error/+page.svelte +++ b/src/routes/auth/error/+page.svelte @@ -1,87 +1,100 @@ - - -
-
-
-

- 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
  • -
  • Sign-in blocked by browser settings
  • -
- {/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
  • +
  • Sign-in blocked by browser settings
  • +
+ {/if} + + +
+
+
+ + diff --git a/src/routes/auth/logout/+server.ts b/src/routes/auth/logout/+server.ts index e7108d8..c98279a 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 ccb7f41..9f504a2 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 358e887..9d307ad 100644 --- a/src/routes/auth/reset-password/+page.svelte +++ b/src/routes/auth/reset-password/+page.svelte @@ -1,212 +1,295 @@ - - -
-
-
- {#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 aa0f9ca..f297315 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 50f33f5..cea7655 100644 --- a/src/routes/dashboard/+layout.svelte +++ b/src/routes/dashboard/+layout.svelte @@ -1,54 +1,71 @@ - - - - -
- {@render children()} -
+ + + + +
+ {@render children()} +
diff --git a/src/routes/dashboard/+page.server.ts b/src/routes/dashboard/+page.server.ts index 7478523..81c3f66 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 7b3611f..92e11ad 100644 --- a/src/routes/dashboard/+page.svelte +++ b/src/routes/dashboard/+page.svelte @@ -1,284 +1,284 @@ - - -
- {#if !isReady} - - - {:else} - 0} /> - -
- -
- -
- -
- {/if} -
- - + + +
+ {#if !isReady} + + + {:else} + 0} /> + +
+ +
+ +
+ +
+ {/if} +
+ + diff --git a/src/routes/dashboard/friends/+page.svelte b/src/routes/dashboard/friends/+page.svelte index e374373..47d2669 100644 --- a/src/routes/dashboard/friends/+page.svelte +++ b/src/routes/dashboard/friends/+page.svelte @@ -1,63 +1,67 @@ - - -
- {#if isLoading} - - {:else} -
- -
- {/if} -
+ + +
+ {#if isLoading} + + {:else} +
+ +
+ {/if} +
diff --git a/src/routes/dashboard/settings/+page.server.ts b/src/routes/dashboard/settings/+page.server.ts index ccb7f41..9f504a2 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 cfd96df..cba4396 100644 --- a/src/routes/dashboard/settings/+page.svelte +++ b/src/routes/dashboard/settings/+page.svelte @@ -1,1168 +1,1168 @@ - - -
-
-

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 shortcuts 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()} -> - - - + + +
+
+

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 shortcuts 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/static/manifest.json b/static/manifest.json index 6bfd9d8..9ef823a 100644 --- a/static/manifest.json +++ b/static/manifest.json @@ -1,26 +1,26 @@ { - "name": "Rez", - "short_name": "Rez", - "description": "Share what you're up to with your friends in real time.", - "start_url": "/dashboard", - "display": "standalone", - "background_color": "#1d232a", - "theme_color": "#6419e6", - "icons": [ - { - "src": "/icon-192.png", - "sizes": "192x192", - "type": "image/png" - }, - { - "src": "/icon-512.png", - "sizes": "512x512", - "type": "image/png" - }, - { - "src": "/favicon.svg", - "sizes": "any", - "type": "image/svg+xml" - } - ] + "name": "Rez", + "short_name": "Rez", + "description": "Share what you're up to with your friends in real time.", + "start_url": "/dashboard", + "display": "standalone", + "background_color": "#1d232a", + "theme_color": "#6419e6", + "icons": [ + { + "src": "/icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "/favicon.svg", + "sizes": "any", + "type": "image/svg+xml" + } + ] } diff --git a/tsconfig.json b/tsconfig.json index 92773a9..f4d0a0e 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 62eb92f..843f27d 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'] + } +});