Support resources with kind:30023 articles

This commit is contained in:
dtonon 2026-05-19 15:14:53 +01:00
parent f6bea700f0
commit b901215e1a
13 changed files with 285 additions and 75 deletions

View file

@ -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 @@
>
<LeftSidebar {mode} {activeRoom} />
<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-1'}
{mobileView === 'chat' ? 'hidden md:block' : 'block'}"
@ -99,10 +109,11 @@
onToggle={() => (chatExpanded = !chatExpanded)}
mobileActive={mobileView === "chat"}
/>
{:else if isHomeFull}
<!-- Own panel (40%) so the gray gutter matches the main↔chat gap. -->
{:else if showDiscussions}
<!-- Own panel (40%) so the gray gutter matches the main↔chat gap.
Hidden on mobile — it's supplementary to the main column. -->
<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 />
</div>

View file

@ -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>

View file

@ -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<string[]>(
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<Contact[]>(
adminPubkeys.map((pk) => {
adminPubkeys.list.map((pk) => {
const entry = profileStore.profiles.get(pk);
return { pubkey: pk, npub: entry?.npub ?? nip19.npubEncode(pk), entry };
}),

View file

@ -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;

View 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>