diff --git a/CLAUDE.md b/CLAUDE.md index b038a574..c78515c2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -107,15 +107,25 @@ pnpm test # Vitest ``` src/ ├── lib/ -│ ├── api/ # API clients (Lemmy, PieFed) -│ ├── feature/ # Feature modules (post, comment, community, etc.) -│ ├── ui/ # UI components -│ │ └── shared/ # mono-svelte component library -│ └── settings/ # App settings and theme -├── routes/ # SvelteKit routes -└── app.html # HTML template +│ ├── api/ # Coves API client, sort/image-proxy mappings (bottom layer) +│ ├── app/ +│ │ ├── state/ # auth, settings, instance, session, theme, i18n +│ │ └── util/ # pure helpers, one concern per file (links, url, text…) +│ ├── ui/ # presentational components; may use app/ + api/, never feature/ +│ │ └── kit/ # hard fork of mono-svelte — imports nothing from app/feature/api +│ ├── feature/ # feature modules (post, comment, community, markdown, shell…) +│ ├── server/ # server-only code +│ └── types/ # shared types, importable from every layer +├── routes/ # SvelteKit routes +└── app.html # HTML template ``` +**Layering** (enforced by `no-restricted-imports` in `eslint.config.js`): +`routes → feature → ui → app → api`, with `ui/kit` as a leaf below `ui`. +Kit components take app state via props/snippets (e.g. `ToastContainer`'s +`content` snippet, `ModalContainer`'s `closeLabel`), never by importing it. +Import the kit as `$lib/ui/kit` — there is no `mono-svelte` alias. + ## Browser Testing (Playwright MCP) - Always use **Firefox** as the browser — Chrome is not installed - Dev environment runs on `http://localhost:8080` (Caddy proxy) with Go backend on :8081 and Vite on :5173 diff --git a/docs/ENVIRONMENT.md b/docs/ENVIRONMENT.md index 59adb938..0ea9b5ce 100644 --- a/docs/ENVIRONMENT.md +++ b/docs/ENVIRONMENT.md @@ -1,7 +1,7 @@ # Environment variables The single reference for every variable the frontend reads. Code resolves -instance URLs in exactly one place — `src/lib/app/instance/resolve.ts` — and +instance URLs in exactly one place — `src/lib/app/state/instance/resolve.ts` — and the tables below describe that behaviour. If you change a default or add a variable, update this file; `Dockerfile`, `.github/README.md` and `.env.development` only point here. @@ -43,7 +43,7 @@ Resolution precedence: ## Appearance and default settings All optional. Booleans accept `true`/`false`. They seed a new user's settings -(`src/lib/app/settings.svelte.ts`); users can change them afterwards. +(`src/lib/app/state/settings.svelte.ts`); users can change them afterwards. | Variable | Default | Description | | --- | --- | --- | diff --git a/eslint.config.js b/eslint.config.js index 758943d3..bb8c9ac5 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -12,7 +12,7 @@ export default ts.config( includeIgnoreFile(gitignorePath), // Ignore legacy code that will be replaced. // - // src/lib/app/markdown is deliberately NOT ignored: it renders untrusted + // src/lib/feature/markdown is deliberately NOT ignored: it renders untrusted // post and comment bodies, and svelte/no-at-html-tags below is the tripwire // for XSS sinks there. A directory-wide ignore would switch that off silently. { @@ -75,4 +75,96 @@ export default ts.config( }, }, }, + // --------------------------------------------------------------------------- + // Layering. Each layer may import only from the layers below it: + // + // routes/ → feature/ → ui/ → app/ (state, util) → api/ + // ui/kit/ (imports none of the above) + // + // `$lib/types` is shared by every layer, and `$lib/server` is off-limits to + // every client-shipped layer (type-only imports of it are allowed — they are + // erased). `api/client.svelte.ts` reaching into `app/state` for the active + // profile is a known, tolerated exception — it is why `api/` is not + // restricted from `app/` here. + // + // Known limits: the rule is lexical, so `./../x` or `$lib/../x` spellings + // and dynamic `import()` expressions are not caught. Nothing in the tree + // uses either; src/lib/ui/kit/layering.test.ts pins the covered cases. + // --------------------------------------------------------------------------- + ...layerRules([ + { + files: ['src/lib/ui/kit/**'], + forbid: ['app', 'feature', 'api', 'server'], + // The kit may only import itself: no non-kit ui/ via the alias, and no + // parent-relative path at all (kit-internal imports are `./` or `$lib/ui/kit`). + alsoForbid: ['^\\$lib/ui/(?!kit(/|$))', '^\\.\\.(/|$)'], + why: 'the kit is the leaf UI layer; pass app state in via props or snippets', + }, + { + files: ['src/lib/ui/**'], + ignores: ['src/lib/ui/kit/**'], + forbid: ['feature', 'server'], + why: 'ui/ is below feature/; page compositions belong in feature/shell', + }, + { + files: ['src/lib/app/**'], + forbid: ['feature', 'ui', 'server'], + why: 'app/ holds state and utilities only', + }, + { + files: ['src/lib/api/**'], + forbid: ['feature', 'ui', 'server'], + why: 'api/ is the bottom layer', + }, + { + files: ['src/lib/feature/**'], + forbid: ['server'], + why: 'feature/ ships to the client', + }, + { + files: ['src/routes/**'], + ignores: [ + 'src/routes/**/*.server.ts', + 'src/routes/**/+server.ts', + // API routes are server-only end to end, helpers included. + 'src/routes/api/**', + 'src/routes/**/*.test.ts', + ], + forbid: ['server'], + why: 'only server-side route files (*.server.ts, +server.ts, routes/api) may use $lib/server', + }, + ]), ) + +/** + * Builds a `no-restricted-imports` config block per layer. `forbid` lists + * top-level `src/lib` directories; each is blocked both as `$lib/` and as + * a parent-relative path (`../..//x`). `alsoForbid` takes raw regex + * sources for anything finer-grained. The typescript-eslint flavour of the + * rule is used so `import type` from a forbidden layer stays legal. + */ +function layerRules(layers) { + return layers.map(({ files, ignores, forbid, alsoForbid = [], why }) => ({ + files, + ...(ignores ? { ignores } : {}), + rules: { + '@typescript-eslint/no-restricted-imports': [ + 'error', + { + patterns: [ + { + regex: [ + `^(\\$lib/|(\\.\\./)+)(${forbid.join('|')})(/|$)`, + ...alsoForbid, + ] + .map((source) => `(?:${source})`) + .join('|'), + message: `Layering violation: ${why}.`, + allowTypeImports: true, + }, + ], + }, + ], + }, + })) +} diff --git a/src/lib/api/client.svelte.ts b/src/lib/api/client.svelte.ts index 62678637..c0a821ab 100644 --- a/src/lib/api/client.svelte.ts +++ b/src/lib/api/client.svelte.ts @@ -1,7 +1,7 @@ import { browser } from '$app/environment' -import { profile } from '$lib/app/auth.svelte' -import { DEFAULT_INSTANCE_URL } from '$lib/app/instance.svelte' -import { instanceToURL } from '$lib/app/util.svelte' +import { profile } from '$lib/app/state/auth.svelte' +import { DEFAULT_INSTANCE_URL } from '$lib/app/state/instance.svelte' +import { instanceToURL } from '$lib/app/util/url' import { error } from '@sveltejs/kit' import { BaseClient, DEFAULT_CLIENT_TYPE, type ClientType } from './base' import { CovesClient } from './coves' diff --git a/src/lib/feature/post/image-proxy.test.ts b/src/lib/api/coves/image-proxy.test.ts similarity index 100% rename from src/lib/feature/post/image-proxy.test.ts rename to src/lib/api/coves/image-proxy.test.ts diff --git a/src/lib/feature/post/image-proxy.ts b/src/lib/api/coves/image-proxy.ts similarity index 100% rename from src/lib/feature/post/image-proxy.ts rename to src/lib/api/coves/image-proxy.ts diff --git a/src/lib/app/sort.test.ts b/src/lib/api/coves/sort.test.ts similarity index 100% rename from src/lib/app/sort.test.ts rename to src/lib/api/coves/sort.test.ts diff --git a/src/lib/app/sort.ts b/src/lib/api/coves/sort.ts similarity index 100% rename from src/lib/app/sort.ts rename to src/lib/api/coves/sort.ts diff --git a/src/lib/api/piefed/rewrite.ts b/src/lib/api/piefed/rewrite.ts index 94f19646..0c5bdce6 100644 --- a/src/lib/api/piefed/rewrite.ts +++ b/src/lib/api/piefed/rewrite.ts @@ -1,5 +1,5 @@ // @ts-nocheck -import { Search } from 'mono-svelte' +import { Search } from '$lib/ui/kit' import type { Comment, CommentReplyView, diff --git a/src/lib/api/upload.ts b/src/lib/api/upload.ts new file mode 100644 index 00000000..6bdcc1e8 --- /dev/null +++ b/src/lib/api/upload.ts @@ -0,0 +1,16 @@ +import { client } from '$lib/api/client.svelte' + +export async function uploadImage( + image: File | null | undefined, + instance: string, + jwt: string, +): Promise { + if (!image) return + + const res = await client({ auth: jwt, instanceURL: instance }).uploadImage({ + image: image, + }) + + if (res.url) return res.url + else throw new Error(`Failed to upload image. ${res.msg}`) +} diff --git a/src/lib/app/auth.svelte.test.ts b/src/lib/app/state/auth.svelte.test.ts similarity index 92% rename from src/lib/app/auth.svelte.test.ts rename to src/lib/app/state/auth.svelte.test.ts index 19d01940..2e358c9d 100644 --- a/src/lib/app/auth.svelte.test.ts +++ b/src/lib/app/state/auth.svelte.test.ts @@ -9,21 +9,18 @@ vi.mock('./instance.svelte', () => ({ DEFAULT_INSTANCE_URL: 'https://coves.social', })) -vi.mock('./util.svelte', () => ({ - moveItem: (arr: T[], from: number, to: number): T[] => { - const result = [...arr] - const [item] = result.splice(from, 1) - result.splice(to, 0, item) - return result - }, -})) - vi.mock('$lib/server/session', () => ({ // Types are re-exported as empty since they're only used for type checking })) // Import actual functions AFTER mocks are set up -import { isAuthenticated, isGuest, type ProfileInfo, type GuestProfile, type AuthenticatedProfile } from './auth.svelte' +import { + isAuthenticated, + isGuest, + type ProfileInfo, + type GuestProfile, + type AuthenticatedProfile, +} from './auth.svelte' describe('isAuthenticated type guard', () => { it('should return true for authenticated profiles', () => { diff --git a/src/lib/app/auth.svelte.ts b/src/lib/app/state/auth.svelte.ts similarity index 99% rename from src/lib/app/auth.svelte.ts rename to src/lib/app/state/auth.svelte.ts index f604f529..7bb199f7 100644 --- a/src/lib/app/auth.svelte.ts +++ b/src/lib/app/state/auth.svelte.ts @@ -1,6 +1,6 @@ import { browser } from '$app/environment' import { DEFAULT_INSTANCE_URL } from './instance.svelte' -import { moveItem } from './util.svelte' +import { moveItem } from '../util/array' import type { ClientSession, DID, diff --git a/src/lib/app/i18n/ar.json b/src/lib/app/state/i18n/ar.json similarity index 100% rename from src/lib/app/i18n/ar.json rename to src/lib/app/state/i18n/ar.json diff --git a/src/lib/app/i18n/bg.json b/src/lib/app/state/i18n/bg.json similarity index 100% rename from src/lib/app/i18n/bg.json rename to src/lib/app/state/i18n/bg.json diff --git a/src/lib/app/i18n/ca.json b/src/lib/app/state/i18n/ca.json similarity index 100% rename from src/lib/app/i18n/ca.json rename to src/lib/app/state/i18n/ca.json diff --git a/src/lib/app/i18n/de.json b/src/lib/app/state/i18n/de.json similarity index 100% rename from src/lib/app/i18n/de.json rename to src/lib/app/state/i18n/de.json diff --git a/src/lib/app/i18n/en.json b/src/lib/app/state/i18n/en.json similarity index 100% rename from src/lib/app/i18n/en.json rename to src/lib/app/state/i18n/en.json diff --git a/src/lib/app/i18n/es.json b/src/lib/app/state/i18n/es.json similarity index 100% rename from src/lib/app/i18n/es.json rename to src/lib/app/state/i18n/es.json diff --git a/src/lib/app/i18n/et.json b/src/lib/app/state/i18n/et.json similarity index 100% rename from src/lib/app/i18n/et.json rename to src/lib/app/state/i18n/et.json diff --git a/src/lib/app/i18n/fi.json b/src/lib/app/state/i18n/fi.json similarity index 100% rename from src/lib/app/i18n/fi.json rename to src/lib/app/state/i18n/fi.json diff --git a/src/lib/app/i18n/fr.json b/src/lib/app/state/i18n/fr.json similarity index 100% rename from src/lib/app/i18n/fr.json rename to src/lib/app/state/i18n/fr.json diff --git a/src/lib/app/i18n/he.json b/src/lib/app/state/i18n/he.json similarity index 100% rename from src/lib/app/i18n/he.json rename to src/lib/app/state/i18n/he.json diff --git a/src/lib/app/i18n/hu.json b/src/lib/app/state/i18n/hu.json similarity index 100% rename from src/lib/app/i18n/hu.json rename to src/lib/app/state/i18n/hu.json diff --git a/src/lib/app/i18n/index.ts b/src/lib/app/state/i18n/index.ts similarity index 100% rename from src/lib/app/i18n/index.ts rename to src/lib/app/state/i18n/index.ts diff --git a/src/lib/app/i18n/ja.json b/src/lib/app/state/i18n/ja.json similarity index 100% rename from src/lib/app/i18n/ja.json rename to src/lib/app/state/i18n/ja.json diff --git a/src/lib/app/i18n/nl.json b/src/lib/app/state/i18n/nl.json similarity index 100% rename from src/lib/app/i18n/nl.json rename to src/lib/app/state/i18n/nl.json diff --git a/src/lib/app/i18n/pl.json b/src/lib/app/state/i18n/pl.json similarity index 100% rename from src/lib/app/i18n/pl.json rename to src/lib/app/state/i18n/pl.json diff --git a/src/lib/app/i18n/pt-BR.json b/src/lib/app/state/i18n/pt-BR.json similarity index 100% rename from src/lib/app/i18n/pt-BR.json rename to src/lib/app/state/i18n/pt-BR.json diff --git a/src/lib/app/i18n/pt.json b/src/lib/app/state/i18n/pt.json similarity index 100% rename from src/lib/app/i18n/pt.json rename to src/lib/app/state/i18n/pt.json diff --git a/src/lib/app/i18n/ru.json b/src/lib/app/state/i18n/ru.json similarity index 100% rename from src/lib/app/i18n/ru.json rename to src/lib/app/state/i18n/ru.json diff --git a/src/lib/app/i18n/tok.json b/src/lib/app/state/i18n/tok.json similarity index 100% rename from src/lib/app/i18n/tok.json rename to src/lib/app/state/i18n/tok.json diff --git a/src/lib/app/i18n/tr.json b/src/lib/app/state/i18n/tr.json similarity index 100% rename from src/lib/app/i18n/tr.json rename to src/lib/app/state/i18n/tr.json diff --git a/src/lib/app/i18n/zh-Hans.json b/src/lib/app/state/i18n/zh-Hans.json similarity index 100% rename from src/lib/app/i18n/zh-Hans.json rename to src/lib/app/state/i18n/zh-Hans.json diff --git a/src/lib/app/i18n/zh-Hant.json b/src/lib/app/state/i18n/zh-Hant.json similarity index 100% rename from src/lib/app/i18n/zh-Hant.json rename to src/lib/app/state/i18n/zh-Hant.json diff --git a/src/lib/app/instance.svelte.ts b/src/lib/app/state/instance.svelte.ts similarity index 100% rename from src/lib/app/instance.svelte.ts rename to src/lib/app/state/instance.svelte.ts diff --git a/src/lib/app/instance/resolve.test.ts b/src/lib/app/state/instance/resolve.test.ts similarity index 100% rename from src/lib/app/instance/resolve.test.ts rename to src/lib/app/state/instance/resolve.test.ts diff --git a/src/lib/app/instance/resolve.ts b/src/lib/app/state/instance/resolve.ts similarity index 98% rename from src/lib/app/instance/resolve.ts rename to src/lib/app/state/instance/resolve.ts index 2e9e5237..1216e6d1 100644 --- a/src/lib/app/instance/resolve.ts +++ b/src/lib/app/state/instance/resolve.ts @@ -16,7 +16,7 @@ * * The functions are pure and take their env as a parameter so the same rules * are unit-testable and shareable between the browser-facing module - * (`$lib/app/instance.svelte`) and the server-only one (`$lib/server/instance`), + * (`$lib/app/state/instance.svelte`) and the server-only one (`$lib/server/instance`), * which is the only place private env may be read. * * See docs/ENVIRONMENT.md for the operator-facing reference. diff --git a/src/lib/app/session.ts b/src/lib/app/state/session.ts similarity index 100% rename from src/lib/app/session.ts rename to src/lib/app/state/session.ts diff --git a/src/lib/app/settings.svelte.ts b/src/lib/app/state/settings.svelte.ts similarity index 99% rename from src/lib/app/settings.svelte.ts rename to src/lib/app/state/settings.svelte.ts index 0ed23440..981c0ac2 100644 --- a/src/lib/app/settings.svelte.ts +++ b/src/lib/app/state/settings.svelte.ts @@ -1,7 +1,7 @@ import { browser } from '$app/environment' import { env } from '$env/dynamic/public' import { locale } from './i18n' -import { mergeDeep } from './merge' +import { mergeDeep } from '../util/merge' import { normalizeCommentSort, normalizeListing, @@ -10,7 +10,7 @@ import { type CovesListingType, type CovesSortType, type CovesTimeframe, -} from './sort' +} from '$lib/api/coves/sort' export type View = 'cozy' | 'compact' diff --git a/src/lib/app/settings.test.ts b/src/lib/app/state/settings.test.ts similarity index 100% rename from src/lib/app/settings.test.ts rename to src/lib/app/state/settings.test.ts diff --git a/src/lib/app/theme/presets.ts b/src/lib/app/state/theme/presets.ts similarity index 100% rename from src/lib/app/theme/presets.ts rename to src/lib/app/state/theme/presets.ts diff --git a/src/lib/app/theme/theme.svelte.test.ts b/src/lib/app/state/theme/theme.svelte.test.ts similarity index 100% rename from src/lib/app/theme/theme.svelte.test.ts rename to src/lib/app/state/theme/theme.svelte.test.ts diff --git a/src/lib/app/theme/theme.svelte.ts b/src/lib/app/state/theme/theme.svelte.ts similarity index 100% rename from src/lib/app/theme/theme.svelte.ts rename to src/lib/app/state/theme/theme.svelte.ts diff --git a/src/lib/app/util.svelte.ts b/src/lib/app/util.svelte.ts deleted file mode 100644 index 9461bec3..00000000 --- a/src/lib/app/util.svelte.ts +++ /dev/null @@ -1,275 +0,0 @@ -import { browser } from '$app/environment' -import { goto } from '$app/navigation' -import { client } from '$lib/api/client.svelte' -import type { - CommunityRef, - CommunityView as CovesCommunityView, -} from '$lib/api/coves/types' -import { SvelteURL } from 'svelte/reactivity' -import { t } from './i18n' - -// Finds the smallest number in `numbers` that is >= target. -// Falls back to the closest number below target if none are >=. -export const findClosestNumber = (numbers: number[], target: number): number => - numbers.reduce((prev, curr) => - curr >= target && (prev < target || curr < prev) ? curr : prev, - ) - -export const searchParam = async ( - url: URL, - key: string, - value: string, - ...deleteKeys: string[] -): Promise => { - url.searchParams.set(key, value) - deleteKeys.forEach((k) => url.searchParams.delete(k)) - try { - await goto(url, { - invalidateAll: true, - }) - } catch (err) { - console.error('[searchParam] Navigation failed:', err) - } -} - -export const fullCommunityName = (name: string, actorId: string): string => { - try { - return `${name}@${new SvelteURL(actorId).hostname}` - } catch { - return name - } -} - -/** - * Strips the "c-" prefix from a community handle to produce its canonical form. - * - * Communities provisioned on a Coves instance use a "c-" prefix convention - * (e.g. "c-mycommunity.coves.social") to distinguish community actors from - * user actors in the ATProto namespace. Communities bridged in from other - * platforms keep their source handle and carry no prefix, so this strips the - * prefix only when it is actually there. - * - * The prefix is internal plumbing: route params, URLs, and any handle shown to - * a user all use the bare form. There is deliberately no inverse — a slug is - * sent to the API as-is, because the bare form is ambiguous (both - * "gardening.coves.social" and a bridged "linux.lemmy-ml.tdpl.io" are - * prefix-free) and only the AppView knows which stored handle it maps to. It - * resolves either form. - */ -export function communitySlug(handle: string): string { - return handle.startsWith('c-') ? handle.slice(2) : handle -} - -export const placeholders = { - get: (type: 'url' | 'post' | 'body' | 'comment') => { - switch (type) { - case 'post': - return Math.random() < 0.01 - ? 'A C E C* B* G D E E F G F E D C E' - : t.get('placeholders.title') - case 'body': - return t.get('placeholders.body') - case 'comment': - return t.get('placeholders.comments') - case 'url': - return 'https://example.com' - } - }, -} - -export function moveItem( - array: T[], - currentIndex: number, - newIndex: number, -): T[] { - if ( - currentIndex < 0 || - currentIndex >= array.length || - newIndex < 0 || - newIndex >= array.length - ) { - throw new Error('Invalid index') - } - - const newArray = [...array] - - // Remove the item from the current index - const [item] = newArray.splice(currentIndex, 1) - - // Insert the item at the new index - newArray.splice(newIndex, 0, item) - - return newArray -} - -export const DOMAIN_REGEX = - /^(http(s)?:\/\/)?((?!-)[A-Za-z0-9-]{1,63}\.)+[A-Za-z]{2,63}(:[0-9]{0,5})?$/g -export const DOMAIN_REGEX_FORMS = - '(http(s)?://)?((?!-)[A-Za-z0-9]{1,63}.)+[A-Za-z]{2,63}(:[0-9]{0,5})?' - -export async function uploadImage( - image: File | null | undefined, - instance: string, - jwt: string, -): Promise { - if (!image) return - - const formData = new FormData() - formData.append('images[]', image) - - const res = await client({ auth: jwt, instanceURL: instance }).uploadImage({ - image: image, - }) - - if (res.url) return res.url - else throw new Error(`Failed to upload image. ${res.msg}`) -} - -export const instanceToURL = (input: string) => - input.startsWith('http://') || input.startsWith('https://') - ? input - : `https://${input}` - -export function canParseUrl(url: string): boolean { - try { - new SvelteURL(url) - return true - } catch { - return false - } -} - -export function escapeHtml(input: string): string { - return input - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, ''') -} - -export function fuzzySearch(text: string, pattern: string): number { - const textLower = text.toLowerCase() - const patternLower = pattern.toLowerCase() - let score = 0 - let lastIndex = -1 - let consecutiveBonus = 0 - - for (let i = 0; i < patternLower.length; i++) { - const index = textLower.indexOf(patternLower[i], lastIndex + 1) - if (index === -1) return 0 - - score += 1 - if (index === lastIndex + 1) { - consecutiveBonus++ - score += consecutiveBonus - } else { - consecutiveBonus = 0 - } - - lastIndex = index - } - - // Bonus for matching start of words - if (textLower.startsWith(patternLower)) { - score += 2 - } else if (textLower.includes(' ' + patternLower)) { - score += 1 - } - - return score -} - -export const awaitIfServer = async ( - promise: Promise, -): Promise<{ - data: Promise | T -}> => ({ data: browser ? promise : await promise }) - -export class ReactiveState { - value = $state()! - - constructor(initialValue: T) { - this.value = initialValue as NonNullable - } -} - -export function snapshot(item: T) { - return $state.snapshot(item) -} - -export const isImage = (url: string | undefined): boolean => { - if (!url) return false - return /\.(jpeg|jpg|gif|png|svg|bmp|webp|avif)/i.test(url) -} - -export const isVideo = (url: string | undefined): boolean => { - if (!url) return false - return /\.(mp4|mov|webm|mkv|avi)/i.test(url) -} - -/** - * Generate a link path for a community. - * Accepts a Coves CommunityRef or CommunityView. - * - * Falls back to the community DID when the handle is missing — the - * `[handle=handle]` route matcher accepts handles and DIDs but not bare - * community names, so a `name`-based URL would 404 at routing. - */ -export function communityLink( - community: CommunityRef | CovesCommunityView, - prefix: string = '', -): string { - if ('handle' in community && community.handle) { - return `${prefix}/c/${encodeURIComponent(communitySlug(community.handle))}` - } - return `${prefix}/c/${encodeURIComponent(community.did)}` -} - -/** - * Generate a link path for a user profile. - * - * Takes any user-shaped view: `handle` is optional on some of them - * (`ProfileViewDetailed`) and the DID route is the fallback either way. - */ -export function userLink( - user: { did: string; handle?: string }, - prefix: string = '', -): string { - if (user.handle) { - return `${prefix}/profile/${encodeURIComponent(user.handle)}` - } - return `${prefix}/profile/${encodeURIComponent(user.did)}` -} - -/** - * Basic types only, don't use for anything more than basic equality - */ -export function recursiveEqual(a: T, b: T): boolean { - if (a === b) return true - if (typeof a !== 'object' || typeof b !== 'object') return false - - if (a == null || b == null) { - if (a == null && b == null) return true - else return false - } - - const keysA = Object.keys(a) as (keyof typeof a)[] - const keysB = Object.keys(b) as (keyof typeof b)[] - - if (keysA.length != keysB.length) return false - - for (const key of keysA) { - const valA = a[key] - const valB = b[key] - - if (typeof valA == 'object' && typeof valB == 'object') { - const result = recursiveEqual(valA!, valB!) - if (!result) return false - } else { - if (valA != valB) return false - } - } - - return true -} diff --git a/src/lib/app/util/array.test.ts b/src/lib/app/util/array.test.ts new file mode 100644 index 00000000..1460a06b --- /dev/null +++ b/src/lib/app/util/array.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect } from 'vitest' +import { findClosestNumber, moveItem, recursiveEqual } from './array' + +describe('findClosestNumber', () => { + it('rounds up to nearest value at or above target', () => { + expect(findClosestNumber([8, 16, 32, 64, 128], 76)).toBe(128) + }) + + it('returns exact match when present', () => { + expect(findClosestNumber([8, 16, 32, 64, 128], 64)).toBe(64) + }) + + it('returns smallest value above target', () => { + expect(findClosestNumber([128, 256, 512, 1024], 200)).toBe(256) + }) +}) + +describe('moveItem', () => { + it('moves an item forward and backward without mutating the input', () => { + const source = ['a', 'b', 'c'] + expect(moveItem(source, 0, 2)).toEqual(['b', 'c', 'a']) + expect(moveItem(source, 2, 0)).toEqual(['c', 'a', 'b']) + expect(source).toEqual(['a', 'b', 'c']) + }) + + // auth.svelte.ts reorders the account list through this; an out-of-range + // index must throw rather than silently corrupt the list. + it.each([ + [-1, 0], + [0, 3], + [3, 0], + ])('throws on an out-of-range index (%i -> %i)', (from, to) => { + expect(() => moveItem(['a', 'b', 'c'], from, to)).toThrow('Invalid index') + }) +}) + +// recursiveEqual gates the feed cache in feature/feeds/feed.svelte.ts. +describe('recursiveEqual', () => { + it('compares nested objects structurally', () => { + expect(recursiveEqual({ a: { b: 1 } }, { a: { b: 1 } })).toBe(true) + expect(recursiveEqual({ a: { b: 1 } }, { a: { b: 2 } })).toBe(false) + }) + + it('treats a differing key count as unequal', () => { + expect(recursiveEqual({ a: 1 }, { a: 1, b: 2 })).toBe(false) + }) + + it('handles null leaves without recursing into them', () => { + expect(recursiveEqual({ a: null }, { a: null })).toBe(true) + expect(recursiveEqual({ a: null }, { a: {} })).toBe(false) + }) + + it('compares primitives and identical references directly', () => { + expect(recursiveEqual(1, 1)).toBe(true) + expect(recursiveEqual('a', 'b')).toBe(false) + const same = { x: 1 } + expect(recursiveEqual(same, same)).toBe(true) + }) + + // Documents the loose leaf comparison. If this ever needs to be strict, + // Feed.load's cache-invalidation check is the caller to re-verify. + it('does not distinguish a numeric leaf from its string form', () => { + expect( + recursiveEqual({ limit: 20 }, { limit: '20' as unknown as number }), + ).toBe(true) + }) +}) diff --git a/src/lib/app/util/array.ts b/src/lib/app/util/array.ts new file mode 100644 index 00000000..2693fd3e --- /dev/null +++ b/src/lib/app/util/array.ts @@ -0,0 +1,63 @@ +// Finds the smallest number in `numbers` that is >= target. +// Falls back to the closest number below target if none are >=. +export const findClosestNumber = (numbers: number[], target: number): number => + numbers.reduce((prev, curr) => + curr >= target && (prev < target || curr < prev) ? curr : prev, + ) + +export function moveItem( + array: T[], + currentIndex: number, + newIndex: number, +): T[] { + if ( + currentIndex < 0 || + currentIndex >= array.length || + newIndex < 0 || + newIndex >= array.length + ) { + throw new Error('Invalid index') + } + + const newArray = [...array] + + // Remove the item from the current index + const [item] = newArray.splice(currentIndex, 1) + + // Insert the item at the new index + newArray.splice(newIndex, 0, item) + + return newArray +} + +/** + * Basic types only, don't use for anything more than basic equality + */ +export function recursiveEqual(a: T, b: T): boolean { + if (a === b) return true + if (typeof a !== 'object' || typeof b !== 'object') return false + + if (a == null || b == null) { + if (a == null && b == null) return true + else return false + } + + const keysA = Object.keys(a) as (keyof typeof a)[] + const keysB = Object.keys(b) as (keyof typeof b)[] + + if (keysA.length != keysB.length) return false + + for (const key of keysA) { + const valA = a[key] + const valB = b[key] + + if (typeof valA == 'object' && typeof valB == 'object') { + const result = recursiveEqual(valA!, valB!) + if (!result) return false + } else { + if (valA != valB) return false + } + } + + return true +} diff --git a/src/lib/app/error.ts b/src/lib/app/util/error.ts similarity index 63% rename from src/lib/app/error.ts rename to src/lib/app/util/error.ts index 50eb49b2..6869bed8 100644 --- a/src/lib/app/error.ts +++ b/src/lib/app/util/error.ts @@ -1,5 +1,10 @@ -import { t } from './i18n' +import { t } from '$lib/app/state/i18n' +/** + * Turns anything an API call or loader can throw into a user-facing string. + * Consumers render the result directly (toasts, error pages), so this must + * always return a readable message — never a raw object or "undefined". + */ // eslint-disable-next-line export function errorMessage(error: any, instance?: string): string { try { @@ -12,7 +17,13 @@ export function errorMessage(error: any, instance?: string): string { } if (error?.body?.message) { - error = JSON.parse(error?.body?.message) + // Lemmy-style errors nest a JSON document in `body.message`; a plain + // text message there is already the message. + try { + error = JSON.parse(error.body.message) + } catch { + error = error.body.message + } if (typeof error?.message == 'string') { // probably piefed weird error format @@ -25,7 +36,7 @@ export function errorMessage(error: any, instance?: string): string { if (error?.error && typeof error?.error === 'string') { error = error.error } - if (!error) throw error + if (!error) return t.get('error.unknown') // Only key-shaped messages (error codes like "couldnt_find_post") can be // translation keys — free-text messages would just make t.get log a @@ -37,15 +48,13 @@ export function errorMessage(error: any, instance?: string): string { const key = `error.${error}` const translated = t.get(key) return translated === key ? String(error) : translated - } catch { - // Consumers render this (e.g. inside toast markdown), so it must always - // be a string — a raw object here crashes the toast into an empty box. + } catch (formatError) { + console.error('[errorMessage] failed to format error', { + error, + formatError, + }) if (typeof error === 'string') return error if (error instanceof Error) return error.message - try { - return JSON.stringify(error) ?? String(error) - } catch { - return String(error) - } + return t.get('error.unknown') } } diff --git a/src/lib/app/util.svelte.test.ts b/src/lib/app/util/links.test.ts similarity index 60% rename from src/lib/app/util.svelte.test.ts rename to src/lib/app/util/links.test.ts index 32ddd33b..3031940a 100644 --- a/src/lib/app/util.svelte.test.ts +++ b/src/lib/app/util/links.test.ts @@ -1,16 +1,7 @@ import { describe, it, expect } from 'vitest' import type { AuthorView, CommunityRef } from '$lib/api/coves/types' import type { DID, Handle } from '$lib/types/atproto' -import { - canParseUrl, - communityLink, - communitySlug, - escapeHtml, - findClosestNumber, - isImage, - isVideo, - userLink, -} from './util.svelte' +import { communityLink, communitySlug, userLink } from './links' // --------------------------------------------------------------------------- // communityLink() @@ -147,101 +138,3 @@ describe('userLink', () => { expect(userLink(author, '/app')).toBe('/app/profile/alice.coves.social') }) }) - -// --------------------------------------------------------------------------- -// isImage() -// --------------------------------------------------------------------------- - -describe('isImage', () => { - it('returns false for undefined', () => { - expect(isImage(undefined)).toBe(false) - }) - - it('returns true for .jpg URL', () => { - expect(isImage('https://example.com/photo.jpg')).toBe(true) - }) - - it('returns true for .png URL (case-insensitive)', () => { - expect(isImage('https://example.com/PHOTO.PNG')).toBe(true) - }) - - it('returns false for .mp4 URL', () => { - expect(isImage('https://example.com/video.mp4')).toBe(false) - }) - - it('returns false for URL without image extension', () => { - expect(isImage('https://example.com/page')).toBe(false) - }) -}) - -// --------------------------------------------------------------------------- -// isVideo() -// --------------------------------------------------------------------------- - -describe('isVideo', () => { - it('returns false for undefined', () => { - expect(isVideo(undefined)).toBe(false) - }) - - it('returns true for .mp4 URL', () => { - expect(isVideo('https://example.com/clip.mp4')).toBe(true) - }) - - it('returns true for .webm URL', () => { - expect(isVideo('https://example.com/clip.webm')).toBe(true) - }) - - it('returns false for .jpg URL', () => { - expect(isVideo('https://example.com/photo.jpg')).toBe(false) - }) -}) - -// --------------------------------------------------------------------------- -// findClosestNumber() -// --------------------------------------------------------------------------- - -describe('findClosestNumber', () => { - it('rounds up to nearest value at or above target', () => { - expect(findClosestNumber([8, 16, 32, 64, 128], 76)).toBe(128) - }) - - it('returns exact match when present', () => { - expect(findClosestNumber([8, 16, 32, 64, 128], 64)).toBe(64) - }) - - it('returns smallest value above target', () => { - expect(findClosestNumber([128, 256, 512, 1024], 200)).toBe(256) - }) -}) - -// --------------------------------------------------------------------------- -// canParseUrl() -// --------------------------------------------------------------------------- - -describe('canParseUrl', () => { - it('returns true for valid URL', () => { - expect(canParseUrl('https://example.com')).toBe(true) - }) - - it('returns false for invalid URL', () => { - expect(canParseUrl('not a url')).toBe(false) - }) -}) - -// --------------------------------------------------------------------------- -// escapeHtml() -// --------------------------------------------------------------------------- - -describe('escapeHtml', () => { - it('escapes angle brackets', () => { - expect(escapeHtml('')).toBe( - '<script>alert("xss")</script>', - ) - }) - - it('escapes ampersands and quotes', () => { - expect(escapeHtml('rock & roll\'s "best"')).toBe( - 'rock & roll's "best"', - ) - }) -}) diff --git a/src/lib/app/util/links.ts b/src/lib/app/util/links.ts new file mode 100644 index 00000000..824a17ac --- /dev/null +++ b/src/lib/app/util/links.ts @@ -0,0 +1,58 @@ +import type { + CommunityRef, + CommunityView as CovesCommunityView, +} from '$lib/api/coves/types' + +/** + * Strips the "c-" prefix from a community handle to produce its canonical form. + * + * Communities provisioned on a Coves instance use a "c-" prefix convention + * (e.g. "c-mycommunity.coves.social") to distinguish community actors from + * user actors in the ATProto namespace. Communities bridged in from other + * platforms keep their source handle and carry no prefix, so this strips the + * prefix only when it is actually there. + * + * The prefix is internal plumbing: route params, URLs, and any handle shown to + * a user all use the bare form. There is deliberately no inverse — a slug is + * sent to the API as-is, because the bare form is ambiguous (both + * "gardening.coves.social" and a bridged "linux.lemmy-ml.tdpl.io" are + * prefix-free) and only the AppView knows which stored handle it maps to. It + * resolves either form. + */ +export function communitySlug(handle: string): string { + return handle.startsWith('c-') ? handle.slice(2) : handle +} + +/** + * Generate a link path for a community. + * Accepts a Coves CommunityRef or CommunityView. + * + * Falls back to the community DID when the handle is missing — the + * `[handle=handle]` route matcher accepts handles and DIDs but not bare + * community names, so a `name`-based URL would 404 at routing. + */ +export function communityLink( + community: CommunityRef | CovesCommunityView, + prefix: string = '', +): string { + if ('handle' in community && community.handle) { + return `${prefix}/c/${encodeURIComponent(communitySlug(community.handle))}` + } + return `${prefix}/c/${encodeURIComponent(community.did)}` +} + +/** + * Generate a link path for a user profile. + * + * Takes any user-shaped view: `handle` is optional on some of them + * (`ProfileViewDetailed`) and the DID route is the fallback either way. + */ +export function userLink( + user: { did: string; handle?: string }, + prefix: string = '', +): string { + if (user.handle) { + return `${prefix}/profile/${encodeURIComponent(user.handle)}` + } + return `${prefix}/profile/${encodeURIComponent(user.did)}` +} diff --git a/src/lib/app/merge.test.ts b/src/lib/app/util/merge.test.ts similarity index 100% rename from src/lib/app/merge.test.ts rename to src/lib/app/util/merge.test.ts diff --git a/src/lib/app/merge.ts b/src/lib/app/util/merge.ts similarity index 100% rename from src/lib/app/merge.ts rename to src/lib/app/util/merge.ts diff --git a/src/lib/app/util/navigation.ts b/src/lib/app/util/navigation.ts new file mode 100644 index 00000000..a3da9978 --- /dev/null +++ b/src/lib/app/util/navigation.ts @@ -0,0 +1,22 @@ +import { goto } from '$app/navigation' + +/** + * Sets `key=value` on `url` (dropping `deleteKeys`) and navigates to it with + * a full data reload. + */ +export const searchParam = async ( + url: URL, + key: string, + value: string, + ...deleteKeys: string[] +): Promise => { + url.searchParams.set(key, value) + deleteKeys.forEach((k) => url.searchParams.delete(k)) + try { + await goto(url, { + invalidateAll: true, + }) + } catch (err) { + console.error('[searchParam] Navigation failed:', err) + } +} diff --git a/src/lib/app/util/placeholders.ts b/src/lib/app/util/placeholders.ts new file mode 100644 index 00000000..6c7214df --- /dev/null +++ b/src/lib/app/util/placeholders.ts @@ -0,0 +1,18 @@ +import { t } from '$lib/app/state/i18n' + +export const placeholders = { + get: (type: 'url' | 'post' | 'body' | 'comment'): string => { + switch (type) { + case 'post': + return Math.random() < 0.01 + ? 'A C E C* B* G D E E F G F E D C E' + : t.get('placeholders.title') + case 'body': + return t.get('placeholders.body') + case 'comment': + return t.get('placeholders.comments') + case 'url': + return 'https://example.com' + } + }, +} diff --git a/src/lib/app/util/reactive.svelte.ts b/src/lib/app/util/reactive.svelte.ts new file mode 100644 index 00000000..5a313f54 --- /dev/null +++ b/src/lib/app/util/reactive.svelte.ts @@ -0,0 +1,14 @@ +/** + * A `$state`-backed box for a single value. + * + * Load functions cannot return runes directly, so route loaders wrap values + * that components need to mutate reactively (e.g. a paginated list the page + * appends to) in a `ReactiveState` instead. + */ +export class ReactiveState { + value: T = $state()! + + constructor(initialValue: T) { + this.value = initialValue + } +} diff --git a/src/lib/app/util/ssr.ts b/src/lib/app/util/ssr.ts new file mode 100644 index 00000000..6996f45c --- /dev/null +++ b/src/lib/app/util/ssr.ts @@ -0,0 +1,11 @@ +import { browser } from '$app/environment' + +/** + * Streams `promise` to the client when running in the browser, but awaits it + * during SSR so the server never renders a pending placeholder. + */ +export const awaitIfServer = async ( + promise: Promise, +): Promise<{ + data: Promise | T +}> => ({ data: browser ? promise : await promise }) diff --git a/src/lib/app/util/text.test.ts b/src/lib/app/util/text.test.ts new file mode 100644 index 00000000..e86f985b --- /dev/null +++ b/src/lib/app/util/text.test.ts @@ -0,0 +1,16 @@ +import { describe, it, expect } from 'vitest' +import { escapeHtml } from './text' + +describe('escapeHtml', () => { + it('escapes angle brackets', () => { + expect(escapeHtml('')).toBe( + '<script>alert("xss")</script>', + ) + }) + + it('escapes ampersands and quotes', () => { + expect(escapeHtml('rock & roll\'s "best"')).toBe( + 'rock & roll's "best"', + ) + }) +}) diff --git a/src/lib/app/util/text.ts b/src/lib/app/util/text.ts new file mode 100644 index 00000000..edb0c1cd --- /dev/null +++ b/src/lib/app/util/text.ts @@ -0,0 +1,40 @@ +export function escapeHtml(input: string): string { + return input + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + +export function fuzzySearch(text: string, pattern: string): number { + const textLower = text.toLowerCase() + const patternLower = pattern.toLowerCase() + let score = 0 + let lastIndex = -1 + let consecutiveBonus = 0 + + for (let i = 0; i < patternLower.length; i++) { + const index = textLower.indexOf(patternLower[i], lastIndex + 1) + if (index === -1) return 0 + + score += 1 + if (index === lastIndex + 1) { + consecutiveBonus++ + score += consecutiveBonus + } else { + consecutiveBonus = 0 + } + + lastIndex = index + } + + // Bonus for matching start of words + if (textLower.startsWith(patternLower)) { + score += 2 + } else if (textLower.includes(' ' + patternLower)) { + score += 1 + } + + return score +} diff --git a/src/lib/app/util/url.test.ts b/src/lib/app/util/url.test.ts new file mode 100644 index 00000000..fbd1a8e5 --- /dev/null +++ b/src/lib/app/util/url.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect } from 'vitest' +import { canParseUrl, isImage, isVideo } from './url' + +describe('isImage', () => { + it('returns false for undefined', () => { + expect(isImage(undefined)).toBe(false) + }) + + it('returns true for .jpg URL', () => { + expect(isImage('https://example.com/photo.jpg')).toBe(true) + }) + + it('returns true for .png URL (case-insensitive)', () => { + expect(isImage('https://example.com/PHOTO.PNG')).toBe(true) + }) + + it('returns false for .mp4 URL', () => { + expect(isImage('https://example.com/video.mp4')).toBe(false) + }) + + it('returns false for URL without image extension', () => { + expect(isImage('https://example.com/page')).toBe(false) + }) +}) + +// --------------------------------------------------------------------------- +// isVideo() +// --------------------------------------------------------------------------- + +describe('isVideo', () => { + it('returns false for undefined', () => { + expect(isVideo(undefined)).toBe(false) + }) + + it('returns true for .mp4 URL', () => { + expect(isVideo('https://example.com/clip.mp4')).toBe(true) + }) + + it('returns true for .webm URL', () => { + expect(isVideo('https://example.com/clip.webm')).toBe(true) + }) + + it('returns false for .jpg URL', () => { + expect(isVideo('https://example.com/photo.jpg')).toBe(false) + }) +}) + +// --------------------------------------------------------------------------- +// canParseUrl() +// --------------------------------------------------------------------------- + +describe('canParseUrl', () => { + it('returns true for valid URL', () => { + expect(canParseUrl('https://example.com')).toBe(true) + }) + + it('returns false for invalid URL', () => { + expect(canParseUrl('not a url')).toBe(false) + }) +}) diff --git a/src/lib/app/util/url.ts b/src/lib/app/util/url.ts new file mode 100644 index 00000000..06716a25 --- /dev/null +++ b/src/lib/app/util/url.ts @@ -0,0 +1,28 @@ +export const DOMAIN_REGEX = + /^(http(s)?:\/\/)?((?!-)[A-Za-z0-9-]{1,63}\.)+[A-Za-z]{2,63}(:[0-9]{0,5})?$/g +export const DOMAIN_REGEX_FORMS = + '(http(s)?://)?((?!-)[A-Za-z0-9]{1,63}.)+[A-Za-z]{2,63}(:[0-9]{0,5})?' + +export const instanceToURL = (input: string): string => + input.startsWith('http://') || input.startsWith('https://') + ? input + : `https://${input}` + +export function canParseUrl(url: string): boolean { + try { + new URL(url) + return true + } catch { + return false + } +} + +export const isImage = (url: string | undefined): boolean => { + if (!url) return false + return /\.(jpeg|jpg|gif|png|svg|bmp|webp|avif)/i.test(url) +} + +export const isVideo = (url: string | undefined): boolean => { + if (!url) return false + return /\.(mp4|mov|webm|mkv|avi)/i.test(url) +} diff --git a/src/lib/feature/comment/Comment.svelte b/src/lib/feature/comment/Comment.svelte index 84ad3c9a..18b2f95a 100644 --- a/src/lib/feature/comment/Comment.svelte +++ b/src/lib/feature/comment/Comment.svelte @@ -4,17 +4,17 @@ import type { StrongRef } from '$lib/api/coves/types' import { parseAtUri } from '$lib/api/coves/types' import type { DID } from '$lib/types/atproto' - import { profile } from '$lib/app/auth.svelte' - import { errorMessage } from '$lib/app/error' - import { t } from '$lib/app/i18n' - import Markdown from '$lib/app/markdown/Markdown.svelte' - import RichText from '$lib/app/richtext/RichText.svelte' - import { hasFacets } from '$lib/app/richtext/facets' - import { settings } from '$lib/app/settings.svelte' + import { profile } from '$lib/app/state/auth.svelte' + import { errorMessage } from '$lib/app/util/error' + import { t } from '$lib/app/state/i18n' + import Markdown from '$lib/feature/markdown/Markdown.svelte' + import RichText from '$lib/feature/richtext/RichText.svelte' + import { hasFacets } from '$lib/feature/richtext/facets' + import { settings } from '$lib/app/state/settings.svelte' import type { PostLinkRef } from '$lib/feature/post' import { publishedToDate } from '$lib/ui/util/date' - import { Button, Modal, toast } from 'mono-svelte' - import RelativeDate from 'mono-svelte/util/RelativeDate.svelte' + import { Button, Modal, toast } from '$lib/ui/kit' + import RelativeDate from '$lib/ui/util/RelativeDate.svelte' import { Icon, Microphone, Minus, Plus, Trash } from 'svelte-hero-icons/dist' import { expoOut } from 'svelte/easing' import type { ClassValue } from 'svelte/elements' diff --git a/src/lib/feature/comment/CommentActions.svelte b/src/lib/feature/comment/CommentActions.svelte index 694b7b77..1ae502b6 100644 --- a/src/lib/feature/comment/CommentActions.svelte +++ b/src/lib/feature/comment/CommentActions.svelte @@ -1,17 +1,17 @@ + + diff --git a/src/lib/ui/shared/forms/select/Select.svelte b/src/lib/ui/kit/forms/select/Select.svelte similarity index 85% rename from src/lib/ui/shared/forms/select/Select.svelte rename to src/lib/ui/kit/forms/select/Select.svelte index 809418d6..f72bce51 100644 --- a/src/lib/ui/shared/forms/select/Select.svelte +++ b/src/lib/ui/kit/forms/select/Select.svelte @@ -1,16 +1,16 @@ @@ -35,7 +42,7 @@ {#if action.icon} {/if} - {action.content} + {action.content ?? closeLabel} {/each} diff --git a/src/lib/ui/shared/modal/modal.ts b/src/lib/ui/kit/modal/modal.ts similarity index 53% rename from src/lib/ui/shared/modal/modal.ts rename to src/lib/ui/kit/modal/modal.ts index b70e2004..8c1e45d9 100644 --- a/src/lib/ui/shared/modal/modal.ts +++ b/src/lib/ui/kit/modal/modal.ts @@ -1,4 +1,3 @@ -import { t } from '$lib/app/i18n' import type { IconSource } from '@xylightdev/svelte-hero-icons' import type { Snippet } from 'svelte' import { writable } from 'svelte/store' @@ -17,34 +16,49 @@ interface Modal { type: 'error' | 'info' | 'success' } -type Action = { +export interface Action { /** * What function to run when this action is clicked. If undefined, it'll close the window. */ action: () => void type: 'danger' | 'secondary' | 'primary' - content: string + /** + * Button label. Undefined means "use the ModalContainer's `closeLabel`", + * which is how the default close action gets a localized label without the + * kit depending on the app's i18n. + */ + content?: string icon?: IconSource - close: boolean } -export const action = (action?: { +interface ActionInput { action?: () => void type?: 'danger' | 'secondary' | 'primary' content?: string icon?: IconSource + /** Close the modal after `action` runs. Defaults to true. */ close?: boolean -}): Action => ({ - action: action?.action - ? () => { - action?.action?.() - if (action.close) shownModal.set(undefined) - } - : () => shownModal.set(undefined), - type: action?.type ?? 'secondary', - content: action?.content || t.get('common.back'), - close: action?.close ?? true, -}) +} + +/** The implicit dismiss button: no handler, labelled by `ModalContainer`. */ +export function action(): Action +/** A labelled action. Closes the modal afterwards unless `close: false`. */ +export function action(input: ActionInput & { content: string }): Action +export function action(input?: ActionInput): Action { + const closeAfter = input?.close ?? true + return { + action: input?.action + ? () => { + input.action?.() + if (closeAfter) shownModal.set(undefined) + } + : () => shownModal.set(undefined), + type: input?.type ?? 'secondary', + // Empty labels fall back to `closeLabel` too, never a blank button. + content: input?.content || undefined, + icon: input?.icon, + } +} export function modal(inputModal: { actions?: Action[] diff --git a/src/lib/ui/shared/note/Note.svelte b/src/lib/ui/kit/note/Note.svelte similarity index 95% rename from src/lib/ui/shared/note/Note.svelte rename to src/lib/ui/kit/note/Note.svelte index f5efd6bc..c62eb9c8 100644 --- a/src/lib/ui/shared/note/Note.svelte +++ b/src/lib/ui/kit/note/Note.svelte @@ -1,5 +1,5 @@
{toast.title} {/if} - + {#if content} + {@render content(toast)} + {:else} +

+ {toast.content} +

+ {/if}
{#if toast.action} diff --git a/src/lib/ui/shared/toast/ToastContainer.svelte b/src/lib/ui/kit/toast/ToastContainer.svelte similarity index 62% rename from src/lib/ui/shared/toast/ToastContainer.svelte rename to src/lib/ui/kit/toast/ToastContainer.svelte index 22979257..a1b0ca2a 100644 --- a/src/lib/ui/shared/toast/ToastContainer.svelte +++ b/src/lib/ui/kit/toast/ToastContainer.svelte @@ -1,8 +1,16 @@
- +
{/each}
diff --git a/src/lib/ui/shared/toast/toasts.ts b/src/lib/ui/kit/toast/toasts.ts similarity index 100% rename from src/lib/ui/shared/toast/toasts.ts rename to src/lib/ui/kit/toast/toasts.ts diff --git a/src/lib/ui/shared/util/RelativeDate.svelte b/src/lib/ui/kit/util/RelativeDate.svelte similarity index 67% rename from src/lib/ui/shared/util/RelativeDate.svelte rename to src/lib/ui/kit/util/RelativeDate.svelte index 3ec071fb..109eb27a 100644 --- a/src/lib/ui/shared/util/RelativeDate.svelte +++ b/src/lib/ui/kit/util/RelativeDate.svelte @@ -1,18 +1,28 @@ diff --git a/src/lib/ui/kit/util/RelativeDate.test.ts b/src/lib/ui/kit/util/RelativeDate.test.ts new file mode 100644 index 00000000..cfdf0cfd --- /dev/null +++ b/src/lib/ui/kit/util/RelativeDate.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' +import { formatRelativeDate } from './RelativeDate.svelte' + +const NOW = new Date('2026-01-01T12:00:00Z') +const at = (iso: string) => new Date(iso) +const opts: Intl.RelativeTimeFormatOptions = { + numeric: 'always', + style: 'long', +} + +describe('formatRelativeDate', () => { + it('renders a past date as elapsed time', () => { + expect( + formatRelativeDate(at('2026-01-01T09:00:00Z'), opts, 'en', NOW), + ).toBe('3 hours ago') + }) + + it('renders a future date as time remaining, not "Now"', () => { + // Regression guard: before the layering refactor this returned "Now". + expect( + formatRelativeDate(at('2026-01-01T15:00:00Z'), opts, 'en', NOW), + ).toBe('in 3 hours') + }) + + it('treats a future date within clock-skew tolerance as "Now"', () => { + // A server clock a few seconds ahead of the client is ordinary skew. + const skewed = new Date(NOW.getTime() + 15_000) + expect(formatRelativeDate(skewed, opts, 'en', NOW)).toBe('Now') + }) + + it('returns "Now" inside the sub-second window', () => { + expect( + formatRelativeDate(new Date(NOW.getTime() - 500), opts, 'en', NOW), + ).toBe('Now') + }) + + it('formats in the given locale and falls back to English', () => { + const date = at('2026-01-01T09:00:00Z') + expect(formatRelativeDate(date, opts, 'fr', NOW)).toBe('il y a 3 heures') + expect(formatRelativeDate(date, opts, undefined, NOW)).toBe('3 hours ago') + }) + + it('renders an absolute date/time when absolute is set', () => { + const out = formatRelativeDate( + at('2026-01-01T09:00:00Z'), + opts, + 'en', + NOW, + true, + ) + expect(out).not.toContain('ago') + expect(out).toMatch(/2026|26/) + }) + + it('returns "Invalid Date" rather than throwing', () => { + expect(formatRelativeDate(new Date('nope'), opts, 'en', NOW)).toBe( + 'Invalid Date', + ) + }) +}) diff --git a/src/lib/ui/shared/util/time.ts b/src/lib/ui/kit/util/time.ts similarity index 100% rename from src/lib/ui/shared/util/time.ts rename to src/lib/ui/kit/util/time.ts diff --git a/src/lib/ui/layout/Pageination.svelte b/src/lib/ui/layout/Pageination.svelte index d355276a..431c76e1 100644 --- a/src/lib/ui/layout/Pageination.svelte +++ b/src/lib/ui/layout/Pageination.svelte @@ -1,8 +1,8 @@ - -{#key $locale} - -{/key} diff --git a/src/lib/ui/sidebar/SidebarButton.svelte b/src/lib/ui/sidebar/SidebarButton.svelte index e701e9b3..56e9b1a8 100644 --- a/src/lib/ui/sidebar/SidebarButton.svelte +++ b/src/lib/ui/sidebar/SidebarButton.svelte @@ -1,7 +1,7 @@ - + diff --git a/src/lib/ui/util/debug/DebugObject.svelte b/src/lib/ui/util/debug/DebugObject.svelte index 6e5b11bd..e042cd14 100644 --- a/src/lib/ui/util/debug/DebugObject.svelte +++ b/src/lib/ui/util/debug/DebugObject.svelte @@ -1,5 +1,5 @@ diff --git a/src/routes/accounts/login/guest/+page.svelte b/src/routes/accounts/login/guest/+page.svelte index cec57979..b4c3d024 100644 --- a/src/routes/accounts/login/guest/+page.svelte +++ b/src/routes/accounts/login/guest/+page.svelte @@ -1,6 +1,6 @@ diff --git a/src/routes/api/auth/login/+server.ts b/src/routes/api/auth/login/+server.ts index 3d279fb3..68570c81 100644 --- a/src/routes/api/auth/login/+server.ts +++ b/src/routes/api/auth/login/+server.ts @@ -2,7 +2,7 @@ import { json } from '@sveltejs/kit' import type { RequestHandler } from './$types' import { PENDING_AUTH_COOKIE_OPTIONS } from '$lib/server/cookies' import { generateOAuthState } from '$lib/server/csrf' -import { normalizeInstanceUrl } from '$lib/app/instance/resolve' +import { normalizeInstanceUrl } from '$lib/app/state/instance/resolve' interface LoginRequest { handle: string diff --git a/src/routes/api/proxy/[...path]/+server.ts b/src/routes/api/proxy/[...path]/+server.ts index dad666a1..dddfc452 100644 --- a/src/routes/api/proxy/[...path]/+server.ts +++ b/src/routes/api/proxy/[...path]/+server.ts @@ -1,5 +1,5 @@ import type { RequestHandler } from './$types' -import { normalizeInstanceUrl } from '$lib/app/instance/resolve' +import { normalizeInstanceUrl } from '$lib/app/state/instance/resolve' import { upstreamInstanceUrl, upstreamSchemeAllowed, @@ -116,7 +116,7 @@ async function handler({ // In production, only allow HTTPS URLs to prevent MITM attacks, unless the // operator opted a specific private-network origin into plaintext via - // ALLOW_HTTP_INTERNAL_INSTANCE (policy in $lib/app/instance/resolve). + // ALLOW_HTTP_INTERNAL_INSTANCE (policy in $lib/app/state/instance/resolve). if (import.meta.env.PROD && !upstreamSchemeAllowed(baseUrl)) { return new Response( JSON.stringify({ diff --git a/src/routes/c/[handle=handle]/+page.svelte b/src/routes/c/[handle=handle]/+page.svelte index efd42b5d..378d69ef 100644 --- a/src/routes/c/[handle=handle]/+page.svelte +++ b/src/routes/c/[handle=handle]/+page.svelte @@ -1,11 +1,11 @@

Constants

diff --git a/src/routes/util/functions/+page.svelte b/src/routes/util/functions/+page.svelte index 6143acfc..b950c809 100644 --- a/src/routes/util/functions/+page.svelte +++ b/src/routes/util/functions/+page.svelte @@ -1,6 +1,6 @@ diff --git a/src/routes/util/photonify/+page.svelte b/src/routes/util/photonify/+page.svelte index 076e7701..6969f540 100644 --- a/src/routes/util/photonify/+page.svelte +++ b/src/routes/util/photonify/+page.svelte @@ -1,6 +1,6 @@