Support resources with kind:30023 articles
This commit is contained in:
parent
f6bea700f0
commit
b901215e1a
13 changed files with 285 additions and 75 deletions
|
|
@ -1,5 +1,6 @@
|
||||||
import { SimplePool } from "@nostr/tools";
|
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.
|
// room id -> admin pubkeys (NIP-29 kind 39001 `p` tags), across all rooms.
|
||||||
let byRoom = $state<Record<string, string[]>>({});
|
let byRoom = $state<Record<string, string[]>>({});
|
||||||
|
|
@ -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[]) {
|
export async function loadRoomAdmins(roomIds: string[]) {
|
||||||
if (roomIds.length === 0) return;
|
if (roomIds.length === 0) return;
|
||||||
const key = [...roomIds].sort().join(",");
|
const key = [...roomIds].sort().join(",");
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,19 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { overviewStore } from "$lib/overview.svelte";
|
import { overviewStore, loadOverview } from "$lib/overview.svelte";
|
||||||
import { groupsStore } from "$lib/groups.svelte";
|
import { groupsStore } from "$lib/groups.svelte";
|
||||||
|
import { MODE, GROUP_ID } from "$lib/config";
|
||||||
import type { NostrUser } from "@nostr/gadgets/metadata";
|
import type { NostrUser } from "@nostr/gadgets/metadata";
|
||||||
|
|
||||||
|
// Self-load so the panel works wherever it's mounted (home, article pages).
|
||||||
|
// Simple mode has the single configured group; full mode spans every room.
|
||||||
|
// loadOverview dedupes on the room-id set, so this is safe alongside the
|
||||||
|
// landing page's own call.
|
||||||
|
$effect(() => {
|
||||||
|
const ids =
|
||||||
|
MODE === "full" ? groupsStore.list.map((g) => g.id) : [GROUP_ID];
|
||||||
|
if (ids.length > 0) loadOverview(ids);
|
||||||
|
});
|
||||||
|
|
||||||
function roomName(id: string) {
|
function roomName(id: string) {
|
||||||
return groupsStore.list.find((g) => g.id === id)?.name ?? id;
|
return groupsStore.list.find((g) => g.id === id)?.name ?? id;
|
||||||
}
|
}
|
||||||
|
|
@ -51,8 +62,10 @@
|
||||||
{author.name[0].toUpperCase()}
|
{author.name[0].toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if MODE === "full"}
|
||||||
<span aria-hidden="true">in</span>
|
<span aria-hidden="true">in</span>
|
||||||
<span class="truncate">{roomName(t.groupId)}</span>
|
<span class="truncate">{roomName(t.groupId)}</span>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
import { auth, openLogin, logout } from "$lib/auth.svelte";
|
import { auth, openLogin, logout } from "$lib/auth.svelte";
|
||||||
import { groupStore } from "$lib/group.svelte";
|
import { groupStore } from "$lib/group.svelte";
|
||||||
import { groupsStore } from "$lib/groups.svelte";
|
import { groupsStore } from "$lib/groups.svelte";
|
||||||
|
import { resourcesStore } from "$lib/resources.svelte";
|
||||||
import { draftState, resumeDraft } from "$lib/draft.svelte";
|
import { draftState, resumeDraft } from "$lib/draft.svelte";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
|
@ -54,27 +55,33 @@
|
||||||
</nav>
|
</nav>
|
||||||
{#if activeAbout}
|
{#if activeAbout}
|
||||||
<div class="mt-6">
|
<div class="mt-6">
|
||||||
|
<p
|
||||||
|
class="pb-1 text-xs font-semibold uppercase tracking-wider text-neutral-400"
|
||||||
|
>
|
||||||
|
About
|
||||||
|
</p>
|
||||||
<p class="text-sm text-neutral-500">{activeAbout}</p>
|
<p class="text-sm text-neutral-500">{activeAbout}</p>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<nav class="flex-auto mt-6">
|
<nav class="flex-auto mt-6" aria-label="Resources">
|
||||||
<div class="">
|
|
||||||
<p
|
<p
|
||||||
class="pb-1 text-xs font-semibold uppercase tracking-wider text-neutral-400"
|
class="pb-1 text-xs font-semibold uppercase tracking-wider text-neutral-400"
|
||||||
>
|
>
|
||||||
More
|
Resources
|
||||||
</p>
|
</p>
|
||||||
|
{#each resourcesStore.list as r (r.slug)}
|
||||||
<a
|
<a
|
||||||
href="/about"
|
href="/resource/{r.slug}"
|
||||||
class="block py-1 text-neutral-500 hover:text-neutral-900">About</a
|
class="block py-1 text-neutral-500 hover:text-neutral-900"
|
||||||
|
>{r.title}</a
|
||||||
>
|
>
|
||||||
|
{/each}
|
||||||
<a
|
<a
|
||||||
href="/contacts"
|
href="/contacts"
|
||||||
class="block py-1 text-neutral-500 hover:text-neutral-900">Contacts</a
|
class="block py-1 text-neutral-500 hover:text-neutral-900">Contacts</a
|
||||||
>
|
>
|
||||||
</div>
|
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { fly, fade } from "svelte/transition";
|
import { fly, fade } from "svelte/transition";
|
||||||
import { groupsStore } from "$lib/groups.svelte";
|
import { groupsStore } from "$lib/groups.svelte";
|
||||||
|
import { resourcesStore } from "$lib/resources.svelte";
|
||||||
import { auth, openLogin, logout } from "$lib/auth.svelte";
|
import { auth, openLogin, logout } from "$lib/auth.svelte";
|
||||||
import { draftState, resumeDraft } from "$lib/draft.svelte";
|
import { draftState, resumeDraft } from "$lib/draft.svelte";
|
||||||
|
|
||||||
|
|
@ -115,7 +116,7 @@
|
||||||
{#if mode === "full"}
|
{#if mode === "full"}
|
||||||
<nav class="mt-4" aria-label="Rooms">
|
<nav class="mt-4" aria-label="Rooms">
|
||||||
<p
|
<p
|
||||||
class="pb-1 text-xs font-semibold uppercase tracking-wider text-neutral-400"
|
class="pb-1 font-semibold uppercase tracking-wider text-neutral-400"
|
||||||
>
|
>
|
||||||
Rooms
|
Rooms
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -126,7 +127,7 @@
|
||||||
<a
|
<a
|
||||||
href="/room/{room.id}"
|
href="/room/{room.id}"
|
||||||
onclick={onClose}
|
onclick={onClose}
|
||||||
class="block py-1.5 text-lg
|
class="block py-1.5 text-xl
|
||||||
{activeRoom === room.id ? 'text-brand' : 'text-neutral-700 hover:text-brand'}"
|
{activeRoom === room.id ? 'text-brand' : 'text-neutral-700 hover:text-brand'}"
|
||||||
>
|
>
|
||||||
{room.name}
|
{room.name}
|
||||||
|
|
@ -135,16 +136,27 @@
|
||||||
</nav>
|
</nav>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<a
|
<nav class="mt-4" aria-label="Resources">
|
||||||
href="/about"
|
<p
|
||||||
onclick={onClose}
|
class="pb-1 font-semibold uppercase tracking-wider text-neutral-400"
|
||||||
class="py-2 text-xl text-neutral-700 hover:text-brand">About</a
|
|
||||||
>
|
>
|
||||||
|
Resources
|
||||||
|
</p>
|
||||||
|
{#each resourcesStore.list as r (r.slug)}
|
||||||
|
<a
|
||||||
|
href="/resource/{r.slug}"
|
||||||
|
onclick={onClose}
|
||||||
|
class="block py-1.5 text-xl text-neutral-700 hover:text-brand"
|
||||||
|
>{r.title}</a
|
||||||
|
>
|
||||||
|
{/each}
|
||||||
<a
|
<a
|
||||||
href="/contacts"
|
href="/contacts"
|
||||||
onclick={onClose}
|
onclick={onClose}
|
||||||
class="py-2 text-xl text-neutral-700 hover:text-brand">Contacts</a
|
class="block py-1.5 text-lg text-neutral-700 hover:text-brand"
|
||||||
|
>Contacts</a
|
||||||
>
|
>
|
||||||
|
</nav>
|
||||||
|
|
||||||
<div class="mt-4 flex flex-col gap-2 border-t border-neutral-100 pt-4">
|
<div class="mt-4 flex flex-col gap-2 border-t border-neutral-100 pt-4">
|
||||||
{#if draftState.iconized}
|
{#if draftState.iconized}
|
||||||
|
|
@ -177,9 +189,12 @@
|
||||||
{auth.user.shortName.slice(0, 1).toUpperCase()}
|
{auth.user.shortName.slice(0, 1).toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
<span class="truncate text-lg font-medium">{auth.user.shortName}</span
|
<span class="truncate text-lg font-medium"
|
||||||
|
>{auth.user.shortName}</span
|
||||||
|
>
|
||||||
|
<span class="ml-auto shrink-0 text-sm text-neutral-400"
|
||||||
|
>Log out</span
|
||||||
>
|
>
|
||||||
<span class="ml-auto shrink-0 text-sm text-neutral-400">Log out</span>
|
|
||||||
</button>
|
</button>
|
||||||
{:else}
|
{:else}
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,15 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { groupStore } from "$lib/group.svelte";
|
import { groupStore } from "$lib/group.svelte";
|
||||||
import { GROUP_ID, TITLE } from "$lib/config";
|
import { GROUP_ID, TITLE, MODE } from "$lib/config";
|
||||||
|
|
||||||
type Props = { onMenuToggle: () => void };
|
type Props = { onMenuToggle: () => void };
|
||||||
let { onMenuToggle }: Props = $props();
|
let { onMenuToggle }: Props = $props();
|
||||||
|
|
||||||
const name = $derived(TITLE || groupStore.data?.name || GROUP_ID);
|
// PUBLIC_TITLE always wins when set. Without it, simple mode shows the room's
|
||||||
|
// own name; full mode has no single room, so it falls back to GROUP_ID.
|
||||||
|
const name = $derived(
|
||||||
|
TITLE || (MODE === "simple" ? (groupStore.data?.name ?? GROUP_ID) : GROUP_ID),
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<header
|
<header
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,18 @@
|
||||||
content: string;
|
content: string;
|
||||||
profiles?: Record<string, NostrUser>;
|
profiles?: Record<string, NostrUser>;
|
||||||
threadEventAuthors?: Record<string, string>;
|
threadEventAuthors?: Record<string, string>;
|
||||||
|
// 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
|
// Curated TLD list: gTLDs, popular new gTLDs, common ccTLDs
|
||||||
const TLDS = [
|
const TLDS = [
|
||||||
|
|
@ -675,7 +684,7 @@
|
||||||
{@render renderBlocks(block.blocks)}
|
{@render renderBlocks(block.blocks)}
|
||||||
</blockquote>
|
</blockquote>
|
||||||
{:else if block.type === "heading"}
|
{: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))}`}
|
||||||
<svelte:element this={tag}>
|
<svelte:element this={tag}>
|
||||||
{@render renderInlines(block.inlines)}
|
{@render renderInlines(block.inlines)}
|
||||||
</svelte:element>
|
</svelte:element>
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,15 @@ export type GroupMetadata = {
|
||||||
};
|
};
|
||||||
|
|
||||||
let group = $state<GroupMetadata | null>(null);
|
let group = $state<GroupMetadata | null>(null);
|
||||||
|
let loaded = $state(false);
|
||||||
|
|
||||||
export const groupStore = {
|
export const groupStore = {
|
||||||
get data() {
|
get data() {
|
||||||
return group;
|
return group;
|
||||||
},
|
},
|
||||||
|
get loaded() {
|
||||||
|
return loaded;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function loadGroup() {
|
export async function loadGroup() {
|
||||||
|
|
@ -40,6 +44,7 @@ export async function loadGroup() {
|
||||||
admins,
|
admins,
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
|
loaded = true;
|
||||||
pool.close([RELAY_URL]);
|
pool.close([RELAY_URL]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
74
src/lib/resources.svelte.ts
Normal file
74
src/lib/resources.svelte.ts
Normal file
|
|
@ -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<Resource[]>([]);
|
||||||
|
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<string, Resource>();
|
||||||
|
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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -13,7 +13,9 @@
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { auth, restoreSession } from "$lib/auth.svelte";
|
import { auth, restoreSession } from "$lib/auth.svelte";
|
||||||
import { loadGroup } from "$lib/group.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 { seedProfiles } from "$lib/profiles.svelte";
|
||||||
import { startChat } from "$lib/chat.svelte";
|
import { startChat } from "$lib/chat.svelte";
|
||||||
import { activeGroup, setActiveGroup } from "$lib/active.svelte";
|
import { activeGroup, setActiveGroup } from "$lib/active.svelte";
|
||||||
|
|
@ -25,9 +27,13 @@
|
||||||
const chatEnabled = true;
|
const chatEnabled = true;
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
|
loadResources();
|
||||||
const tasks = [restoreSession(), loadGroup()];
|
const tasks = [restoreSession(), loadGroup()];
|
||||||
if (mode === "full") tasks.push(loadGroups());
|
if (mode === "full") tasks.push(loadGroups());
|
||||||
await Promise.all(tasks);
|
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);
|
seedProfiles(auth.user?.pubkey ?? null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -53,10 +59,14 @@
|
||||||
(page.url.pathname.startsWith("/thread/") ? activeGroup.id : ""),
|
(page.url.pathname.startsWith("/thread/") ? activeGroup.id : ""),
|
||||||
);
|
);
|
||||||
|
|
||||||
// The full-mode landing page renders its own right column (latest
|
// Article pages (both modes) and the full-mode landing have no single room to
|
||||||
// discussions) and has no single room to chat in, so suppress room chat there.
|
// chat in, so they render the latest-discussions panel instead of room chat.
|
||||||
const isHomeFull = $derived(mode === "full" && page.url.pathname === "/");
|
// The simple-mode home keeps its room chat, like every full-mode room.
|
||||||
const showChat = $derived(chatEnabled && !isHomeFull);
|
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
|
// 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.
|
// a thread or room from the menu never leaves the user stranded on chat.
|
||||||
|
|
@ -85,7 +95,7 @@
|
||||||
>
|
>
|
||||||
<LeftSidebar {mode} {activeRoom} />
|
<LeftSidebar {mode} {activeRoom} />
|
||||||
<main
|
<main
|
||||||
class="min-h-[calc(100dvh_-_4rem)] bg-white px-6 pt-8 pb-20 shadow-lg md:min-h-0 md:overflow-y-auto md:rounded-t-xl md:px-10 md:pt-6 {isHomeFull
|
class="min-h-[calc(100dvh_-_4rem)] bg-white px-6 pt-8 pb-20 shadow-lg md:min-h-0 md:overflow-y-auto md:rounded-t-xl md:px-10 md:pt-6 {showDiscussions
|
||||||
? 'md:flex-[3]'
|
? 'md:flex-[3]'
|
||||||
: 'md:flex-1'}
|
: 'md:flex-1'}
|
||||||
{mobileView === 'chat' ? 'hidden md:block' : 'block'}"
|
{mobileView === 'chat' ? 'hidden md:block' : 'block'}"
|
||||||
|
|
@ -99,10 +109,11 @@
|
||||||
onToggle={() => (chatExpanded = !chatExpanded)}
|
onToggle={() => (chatExpanded = !chatExpanded)}
|
||||||
mobileActive={mobileView === "chat"}
|
mobileActive={mobileView === "chat"}
|
||||||
/>
|
/>
|
||||||
{:else if isHomeFull}
|
{:else if showDiscussions}
|
||||||
<!-- Own panel (40%) so the gray gutter matches the main↔chat gap. -->
|
<!-- Own panel (40%) so the gray gutter matches the main↔chat gap.
|
||||||
|
Hidden on mobile — it's supplementary to the main column. -->
|
||||||
<div
|
<div
|
||||||
class="mt-2 min-w-0 bg-white px-6 pt-8 pb-20 shadow-lg md:mt-0 md:flex-[2] md:overflow-y-auto md:rounded-t-xl md:px-8 md:pt-6"
|
class="hidden min-w-0 bg-white px-6 pt-8 pb-20 shadow-lg md:mt-0 md:block md:flex-[2] md:overflow-y-auto md:rounded-t-xl md:px-8 md:pt-6"
|
||||||
>
|
>
|
||||||
<LatestDiscussions />
|
<LatestDiscussions />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { groupStore } from "$lib/group.svelte";
|
|
||||||
import { GROUP_ID } from "$lib/config";
|
|
||||||
|
|
||||||
const name = $derived(groupStore.data?.name ?? GROUP_ID);
|
|
||||||
const about = $derived(groupStore.data?.about ?? "");
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<title>About — {name}</title>
|
|
||||||
<meta name="description" content={about} />
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<div class="mx-auto max-w-6xl">
|
|
||||||
<h1 class="py-2 text-[1.65rem] text-brand">About {name}</h1>
|
|
||||||
{#if about}
|
|
||||||
<p class="leading-relaxed whitespace-pre-wrap text-neutral-600">{about}</p>
|
|
||||||
{:else}
|
|
||||||
<p class="text-neutral-400">No description available.</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
@ -2,7 +2,11 @@
|
||||||
import * as nip19 from "@nostr/tools/nip19";
|
import * as nip19 from "@nostr/tools/nip19";
|
||||||
import { groupStore } from "$lib/group.svelte";
|
import { groupStore } from "$lib/group.svelte";
|
||||||
import { groupsStore } from "$lib/groups.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 { MODE } from "$lib/config";
|
||||||
import {
|
import {
|
||||||
profileStore,
|
profileStore,
|
||||||
|
|
@ -17,15 +21,9 @@
|
||||||
if (ids.length > 0) loadRoomAdmins(ids);
|
if (ids.length > 0) loadRoomAdmins(ids);
|
||||||
});
|
});
|
||||||
|
|
||||||
const adminPubkeys = $derived<string[]>(
|
|
||||||
MODE === "full"
|
|
||||||
? [...new Set(Object.values(roomAdminsStore.byRoom).flat())]
|
|
||||||
: (groupStore.data?.admins ?? []),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Group data is loaded by the layout; profiles stream in reactively.
|
// Group data is loaded by the layout; profiles stream in reactively.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
for (const pk of adminPubkeys) ensureProfile(pk);
|
for (const pk of adminPubkeys.list) ensureProfile(pk);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Rooms a given admin manages (full mode only).
|
// Rooms a given admin manages (full mode only).
|
||||||
|
|
@ -45,7 +43,7 @@
|
||||||
type Contact = { pubkey: string; npub: string; entry?: ProfileEntry };
|
type Contact = { pubkey: string; npub: string; entry?: ProfileEntry };
|
||||||
|
|
||||||
const contacts = $derived<Contact[]>(
|
const contacts = $derived<Contact[]>(
|
||||||
adminPubkeys.map((pk) => {
|
adminPubkeys.list.map((pk) => {
|
||||||
const entry = profileStore.profiles.get(pk);
|
const entry = profileStore.profiles.get(pk);
|
||||||
return { pubkey: pk, npub: entry?.npub ?? nip19.npubEncode(pk), entry };
|
return { pubkey: pk, npub: entry?.npub ?? nip19.npubEncode(pk), entry };
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,24 @@ body {
|
||||||
display: none;
|
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 {
|
@theme {
|
||||||
--color-brand: #e32a6d;
|
--color-brand: #e32a6d;
|
||||||
--color-brand-hover: #c4205a;
|
--color-brand-hover: #c4205a;
|
||||||
|
|
|
||||||
63
src/routes/resource/[slug]/+page.svelte
Normal file
63
src/routes/resource/[slug]/+page.svelte
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { page } from "$app/state";
|
||||||
|
import { resourcesStore } from "$lib/resources.svelte";
|
||||||
|
import { adminPubkeys } from "$lib/admins.svelte";
|
||||||
|
import PostContent from "$lib/components/PostContent.svelte";
|
||||||
|
|
||||||
|
const slug = $derived(page.params.slug ?? "");
|
||||||
|
const resource = $derived(resourcesStore.list.find((r) => r.slug === slug));
|
||||||
|
// The list is admin-filtered, so a verdict needs both fetches in.
|
||||||
|
const ready = $derived(resourcesStore.loaded && adminPubkeys.loaded);
|
||||||
|
const notFound = $derived(ready && !resource);
|
||||||
|
|
||||||
|
// First non-empty line of the body, trimmed of markdown markers, for SEO.
|
||||||
|
const description = $derived(
|
||||||
|
(resource?.content ?? "")
|
||||||
|
.replace(/^#+\s*/gm, "")
|
||||||
|
.split("\n")
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.find(Boolean)
|
||||||
|
?.slice(0, 160) ?? "",
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{resource?.title ?? "Resource"}</title>
|
||||||
|
{#if description}
|
||||||
|
<meta name="description" content={description} />
|
||||||
|
{/if}
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-6xl">
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
class="mb-1 inline-flex items-center gap-1 text-sm text-neutral-400 hover:text-brand"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.8"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M15.75 19.5 8.25 12l7.5-7.5"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Home
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{#if resource}
|
||||||
|
<div class="mt-2">
|
||||||
|
<PostContent content={resource.content} headingOffset={0} />
|
||||||
|
</div>
|
||||||
|
{:else if notFound}
|
||||||
|
<p class="py-12 text-center text-neutral-400">Resource not found.</p>
|
||||||
|
{:else}
|
||||||
|
<p class="py-12 text-center text-neutral-400">Loading…</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue