diff --git a/src/lib/admins.svelte.ts b/src/lib/admins.svelte.ts index 0e9fe89..5377389 100644 --- a/src/lib/admins.svelte.ts +++ b/src/lib/admins.svelte.ts @@ -1,5 +1,6 @@ import { SimplePool } from "@nostr/tools"; -import { RELAY_URL } from "$lib/config"; +import { RELAY_URL, MODE } from "$lib/config"; +import { groupStore } from "$lib/group.svelte"; // room id -> admin pubkeys (NIP-29 kind 39001 `p` tags), across all rooms. let byRoom = $state>({}); @@ -15,6 +16,19 @@ export const roomAdminsStore = { }, }; +// Single source of truth for "who is an admin": the union of every room's +// admins in full mode, or the one group's admins in simple mode. +export const adminPubkeys = { + get list(): string[] { + return MODE === "full" + ? [...new Set(Object.values(byRoom).flat())] + : (groupStore.data?.admins ?? []); + }, + get loaded(): boolean { + return MODE === "full" ? loaded : groupStore.loaded; + }, +}; + export async function loadRoomAdmins(roomIds: string[]) { if (roomIds.length === 0) return; const key = [...roomIds].sort().join(","); diff --git a/src/lib/components/LatestDiscussions.svelte b/src/lib/components/LatestDiscussions.svelte index 959bbf7..cd2bd5d 100644 --- a/src/lib/components/LatestDiscussions.svelte +++ b/src/lib/components/LatestDiscussions.svelte @@ -1,8 +1,19 @@
; threadEventAuthors?: Record; + // Added to each heading level. Thread posts demote by 1 (the post title is + // a separate h1, so content headings start at h2); standalone articles + // pass 0 to keep their real levels. + headingOffset?: number; }; - let { content, profiles = {}, threadEventAuthors = {} }: Props = $props(); + let { + content, + profiles = {}, + threadEventAuthors = {}, + headingOffset = 1, + }: Props = $props(); // Curated TLD list: gTLDs, popular new gTLDs, common ccTLDs const TLDS = [ @@ -675,7 +684,7 @@ {@render renderBlocks(block.blocks)} {:else if block.type === "heading"} - {@const tag = `h${Math.min(block.level + 2, 6)}`} + {@const tag = `h${Math.max(1, Math.min(block.level + headingOffset, 6))}`} {@render renderInlines(block.inlines)} diff --git a/src/lib/group.svelte.ts b/src/lib/group.svelte.ts index 0721ea5..ce70c66 100644 --- a/src/lib/group.svelte.ts +++ b/src/lib/group.svelte.ts @@ -11,11 +11,15 @@ export type GroupMetadata = { }; let group = $state(null); +let loaded = $state(false); export const groupStore = { get data() { return group; }, + get loaded() { + return loaded; + }, }; export async function loadGroup() { @@ -40,6 +44,7 @@ export async function loadGroup() { admins, }; } finally { + loaded = true; pool.close([RELAY_URL]); } } diff --git a/src/lib/resources.svelte.ts b/src/lib/resources.svelte.ts new file mode 100644 index 0000000..4740f63 --- /dev/null +++ b/src/lib/resources.svelte.ts @@ -0,0 +1,74 @@ +import { SimplePool } from "@nostr/tools"; +import { RELAY_URL } from "$lib/config"; +import { adminPubkeys } from "$lib/admins.svelte"; + +export type Resource = { + id: string; + slug: string; // NIP-23 `d` tag — also the resource URL slug + title: string; + content: string; // markdown body + position?: number; + pubkey: string; + createdAt: number; +}; + +let all = $state([]); +let loaded = $state(false); + +export const resourcesStore = { + // Only resources authored by an admin are surfaced; the relay query is + // open, so the trusted admin set is the gate. + get list() { + const admins = new Set(adminPubkeys.list); + return all.filter((r) => admins.has(r.pubkey)); + }, + get loaded() { + return loaded; + }, +}; + +// Positioned resources come first by ascending `position`; the rest follow +// alphabetically by title. Equal positions fall back to title order. +function compare(a: Resource, b: Resource): number { + const ap = a.position; + const bp = b.position; + if (ap !== undefined && bp !== undefined) + return ap !== bp ? ap - bp : a.title.localeCompare(b.title); + if (ap !== undefined) return -1; + if (bp !== undefined) return 1; + return a.title.localeCompare(b.title); +} + +// Resources are kind 30023 (NIP-23 long-form) tagged ["t", "squalk-resource"]. +export async function loadResources() { + const pool = new SimplePool(); + try { + const events = await pool.querySync([RELAY_URL], { + kinds: [30023], + "#t": ["squalk-resource"], + }); + // Addressable events: keep the newest per `d` slug. + const bySlug = new Map(); + for (const e of events) { + const slug = e.tags.find((t) => t[0] === "d")?.[1]; + if (!slug) continue; + const existing = bySlug.get(slug); + if (existing && existing.createdAt >= e.created_at) continue; + const posTag = e.tags.find((t) => t[0] === "position")?.[1]; + const pos = posTag !== undefined ? Number(posTag) : NaN; + bySlug.set(slug, { + id: e.id, + slug, + title: e.tags.find((t) => t[0] === "title")?.[1] ?? slug, + content: e.content, + position: Number.isFinite(pos) ? pos : undefined, + pubkey: e.pubkey, + createdAt: e.created_at, + }); + } + all = [...bySlug.values()].sort(compare); + } finally { + loaded = true; + pool.close([RELAY_URL]); + } +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 183e34c..c85ac31 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -13,7 +13,9 @@ import { onMount } from "svelte"; import { auth, restoreSession } from "$lib/auth.svelte"; import { loadGroup } from "$lib/group.svelte"; - import { loadGroups } from "$lib/groups.svelte"; + import { loadGroups, groupsStore } from "$lib/groups.svelte"; + import { loadResources } from "$lib/resources.svelte"; + import { loadRoomAdmins } from "$lib/admins.svelte"; import { seedProfiles } from "$lib/profiles.svelte"; import { startChat } from "$lib/chat.svelte"; import { activeGroup, setActiveGroup } from "$lib/active.svelte"; @@ -25,9 +27,13 @@ const chatEnabled = true; onMount(async () => { + loadResources(); const tasks = [restoreSession(), loadGroup()]; if (mode === "full") tasks.push(loadGroups()); await Promise.all(tasks); + // Resources are filtered by the admin set; in full mode that means every + // room's admins, needed on every page for the sidebar. + if (mode === "full") loadRoomAdmins(groupsStore.list.map((g) => g.id)); seedProfiles(auth.user?.pubkey ?? null); }); @@ -53,10 +59,14 @@ (page.url.pathname.startsWith("/thread/") ? activeGroup.id : ""), ); - // The full-mode landing page renders its own right column (latest - // discussions) and has no single room to chat in, so suppress room chat there. - const isHomeFull = $derived(mode === "full" && page.url.pathname === "/"); - const showChat = $derived(chatEnabled && !isHomeFull); + // Article pages (both modes) and the full-mode landing have no single room to + // chat in, so they render the latest-discussions panel instead of room chat. + // The simple-mode home keeps its room chat, like every full-mode room. + const isArticle = $derived(page.url.pathname.startsWith("/resource/")); + const showDiscussions = $derived( + isArticle || (mode === "full" && page.url.pathname === "/"), + ); + const showChat = $derived(chatEnabled && !showDiscussions); // On mobile a route change should always land on the forum pane, so opening // a thread or room from the menu never leaves the user stranded on chat. @@ -85,7 +95,7 @@ >
(chatExpanded = !chatExpanded)} mobileActive={mobileView === "chat"} /> - {:else if isHomeFull} - + {:else if showDiscussions} +
diff --git a/src/routes/about/+page.svelte b/src/routes/about/+page.svelte deleted file mode 100644 index 2c8824e..0000000 --- a/src/routes/about/+page.svelte +++ /dev/null @@ -1,21 +0,0 @@ - - - - About — {name} - - - -
-

About {name}

- {#if about} -

{about}

- {:else} -

No description available.

- {/if} -
diff --git a/src/routes/contacts/+page.svelte b/src/routes/contacts/+page.svelte index 1995b2e..0177758 100644 --- a/src/routes/contacts/+page.svelte +++ b/src/routes/contacts/+page.svelte @@ -2,7 +2,11 @@ import * as nip19 from "@nostr/tools/nip19"; import { groupStore } from "$lib/group.svelte"; import { groupsStore } from "$lib/groups.svelte"; - import { roomAdminsStore, loadRoomAdmins } from "$lib/admins.svelte"; + import { + roomAdminsStore, + loadRoomAdmins, + adminPubkeys, + } from "$lib/admins.svelte"; import { MODE } from "$lib/config"; import { profileStore, @@ -17,15 +21,9 @@ if (ids.length > 0) loadRoomAdmins(ids); }); - const adminPubkeys = $derived( - MODE === "full" - ? [...new Set(Object.values(roomAdminsStore.byRoom).flat())] - : (groupStore.data?.admins ?? []), - ); - // Group data is loaded by the layout; profiles stream in reactively. $effect(() => { - for (const pk of adminPubkeys) ensureProfile(pk); + for (const pk of adminPubkeys.list) ensureProfile(pk); }); // Rooms a given admin manages (full mode only). @@ -45,7 +43,7 @@ type Contact = { pubkey: string; npub: string; entry?: ProfileEntry }; const contacts = $derived( - adminPubkeys.map((pk) => { + adminPubkeys.list.map((pk) => { const entry = profileStore.profiles.get(pk); return { pubkey: pk, npub: entry?.npub ?? nip19.npubEncode(pk), entry }; }), diff --git a/src/routes/layout.css b/src/routes/layout.css index 81997b9..7a3e8d7 100644 --- a/src/routes/layout.css +++ b/src/routes/layout.css @@ -17,6 +17,24 @@ body { display: none; } +/* Tighter heading rhythm for all rendered (prose) content. Overrides the + typography plugin's em-based heading margins; the first block stays flush. */ +.prose :is(h1, h2, h3, h4, h5, h6) { + margin-top: 0.8em; + margin-bottom: 0.3em; + font-weight: normal; +} +.prose :is(h1) { + font-size: 1.65rem; + color: var(--color-brand); +} +.prose :is(p) { + margin-top: 0; +} +.prose > :first-child { + margin-top: 0; +} + @theme { --color-brand: #e32a6d; --color-brand-hover: #c4205a; diff --git a/src/routes/resource/[slug]/+page.svelte b/src/routes/resource/[slug]/+page.svelte new file mode 100644 index 0000000..4a258fb --- /dev/null +++ b/src/routes/resource/[slug]/+page.svelte @@ -0,0 +1,63 @@ + + + + {resource?.title ?? "Resource"} + {#if description} + + {/if} + + +
+ + + Home + + + {#if resource} +
+ +
+ {:else if notFound} +

Resource not found.

+ {:else} +

Loading…

+ {/if} +