Browse Source

Merges pull request #31

Issue#199#202
master
silberengel 10 months ago
parent
commit
aa5f61b0f1
No known key found for this signature in database
GPG Key ID: 962BEC8725790894
  1. 54
      src/app.css
  2. 2
      src/lib/components/Login.svelte
  3. 2
      src/lib/components/Navigation.svelte
  4. 115
      src/lib/components/Preview.svelte
  5. 349
      src/lib/components/Publication.svelte
  6. 2
      src/lib/components/PublicationSection.svelte
  7. 70
      src/lib/components/blog/BlogHeader.svelte
  8. 149
      src/lib/components/util/ArticleNav.svelte
  9. 64
      src/lib/components/util/CardActions.svelte
  10. 109
      src/lib/components/util/Details.svelte
  11. 2
      src/lib/components/util/InlineProfile.svelte
  12. 93
      src/lib/components/util/Interactions.svelte
  13. 143
      src/lib/components/util/TocToggle.svelte
  14. 19
      src/lib/components/util/ZapOutline.svelte
  15. 35
      src/lib/parser.ts
  16. 23
      src/lib/stores.ts
  17. 17
      src/routes/+layout.svelte
  18. 11
      src/routes/+page.svelte
  19. 3
      src/routes/new/compose/+page.svelte
  20. 3
      src/routes/new/edit/+page.svelte
  21. 63
      src/routes/publication/+page.svelte
  22. 15
      src/routes/start/+page.svelte
  23. 8
      src/styles/base.css
  24. 60
      src/styles/publications.css
  25. 20
      src/styles/scrollbar.css
  26. BIN
      static/screenshots/ToC_blog.png
  27. BIN
      static/screenshots/ToC_normal.png
  28. 18
      tailwind.config.cjs

54
src/app.css

@ -1,4 +1,5 @@
@import './styles/base.css'; @import './styles/base.css';
@import './styles/scrollbar.css';
@import './styles/publications.css'; @import './styles/publications.css';
@import './styles/visualize.css'; @import './styles/visualize.css';
@ -51,9 +52,18 @@
} }
main { main {
@apply max-w-full; @apply max-w-full flex;
} }
main.publication {
@apply mt-[70px];
}
/* To scroll columns independently */
main.publication.blog {
@apply w-full sm:w-auto min-h-full;
}
main.main-leather, main.main-leather,
article.article-leather { article.article-leather {
@apply bg-primary-0 dark:bg-primary-1000 text-gray-800 dark:text-gray-300; @apply bg-primary-0 dark:bg-primary-1000 text-gray-800 dark:text-gray-300;
@ -65,9 +75,9 @@
@apply bg-primary-0 dark:bg-primary-1000 text-gray-800 dark:text-gray-300 p-2 rounded; @apply bg-primary-0 dark:bg-primary-1000 text-gray-800 dark:text-gray-300 p-2 rounded;
} }
div.note-leather:hover:not(:has(.note-leather:hover)), .edit div.note-leather:hover:not(:has(.note-leather:hover)),
p.note-leather:hover:not(:has(.note-leather:hover)), .edit p.note-leather:hover:not(:has(.note-leather:hover)),
section.note-leather:hover:not(:has(.note-leather:hover)) { section.edit.note-leather:hover:not(:has(.note-leather:hover)) {
@apply hover:bg-primary-100 dark:hover:bg-primary-800; @apply hover:bg-primary-100 dark:hover:bg-primary-800;
} }
@ -121,6 +131,11 @@
@apply bg-primary-0 hover:bg-primary-0 dark:bg-primary-950 dark:hover:bg-primary-950 text-gray-800 hover:text-primary-400 dark:text-gray-300 dark:hover:text-primary-500; @apply bg-primary-0 hover:bg-primary-0 dark:bg-primary-950 dark:hover:bg-primary-950 text-gray-800 hover:text-primary-400 dark:text-gray-300 dark:hover:text-primary-500;
} }
/* Navbar */
nav.Navbar.navbar-main {
@apply z-30;
}
nav.navbar-leather { nav.navbar-leather {
@apply bg-primary-0 dark:bg-primary-1000 z-10; @apply bg-primary-0 dark:bg-primary-1000 z-10;
} }
@ -138,12 +153,18 @@
@apply text-gray-800 hover:text-primary-400 dark:text-gray-300 dark:hover:text-primary-500; @apply text-gray-800 hover:text-primary-400 dark:text-gray-300 dark:hover:text-primary-500;
} }
aside.sidebar-leather>div { /* Sidebar */
@apply bg-primary-0 dark:bg-primary-1000; aside.sidebar-leather {
@apply fixed md:sticky top-[130px] sm:top-[146px] h-[calc(100vh-130px)] sm:h-[calc(100vh-146px)] z-10;
@apply bg-primary-0 dark:bg-primary-1000 px-5 w-full sm:w-auto sm:max-w-xl;
}
aside.sidebar-leather > div {
@apply bg-primary-50 dark:bg-gray-800 h-full px-5 py-0;
} }
a.sidebar-item-leather { a.sidebar-item-leather {
@apply hover:bg-primary-100 dark:hover:bg-primary-800; @apply hover:bg-primary-100 dark:hover:bg-gray-800;
} }
div.skeleton-leather div { div.skeleton-leather div {
@ -229,6 +250,21 @@
.link { .link {
@apply underline cursor-pointer hover:text-primary-400 dark:hover:text-primary-500; @apply underline cursor-pointer hover:text-primary-400 dark:hover:text-primary-500;
} }
/* Card with transition */
.ArticleBox.grid .ArticleBoxImage {
@apply max-h-0;
transition: max-height 0.5s ease;
}
.ArticleBox.grid.active .ArticleBoxImage {
@apply max-h-72;
}
.tags span {
@apply bg-primary-50 text-primary-800 text-sm font-medium me-2 px-2.5 py-0.5 rounded-sm dark:bg-primary-900 dark:text-primary-200;
}
} }
@layer components { @layer components {
@ -381,6 +417,10 @@
padding-left: 1rem; padding-left: 1rem;
} }
.line-ellipsis {
overflow: hidden;
text-overflow: ellipsis;
}
.footnotes li { .footnotes li {
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }

2
src/lib/components/Login.svelte

@ -50,7 +50,7 @@
{#if $ndkSignedIn} {#if $ndkSignedIn}
<Profile pubkey={$activePubkey} isNav={true} /> <Profile pubkey={$activePubkey} isNav={true} />
{:else} {:else}
<Avatar rounded class='h-6 w-6 cursor-pointer' id='avatar' /> <Avatar rounded class='h-6 w-6 cursor-pointer bg-transparent' id='avatar' />
<Popover <Popover
class='popover-leather w-fit' class='popover-leather w-fit'
placement='bottom' placement='bottom'

2
src/lib/components/Navigation.svelte

@ -14,7 +14,7 @@
let leftMenuOpen = $state(false); let leftMenuOpen = $state(false);
</script> </script>
<Navbar class={`Navbar navbar-leather ${className}`}> <Navbar class={`Navbar navbar-leather navbar-main ${className}`}>
<div class="flex flex-grow justify-between"> <div class="flex flex-grow justify-between">
<NavBrand href="/"> <NavBrand href="/">
<h1>Alexandria</h1> <h1>Alexandria</h1>

115
src/lib/components/Preview.svelte

@ -4,6 +4,7 @@
import { CaretDownSolid, CaretUpSolid, EditOutline } from 'flowbite-svelte-icons'; import { CaretDownSolid, CaretUpSolid, EditOutline } from 'flowbite-svelte-icons';
import Self from './Preview.svelte'; import Self from './Preview.svelte';
import { contentParagraph, sectionHeading } from '$lib/snippets/PublicationSnippets.svelte'; import { contentParagraph, sectionHeading } from '$lib/snippets/PublicationSnippets.svelte';
import BlogHeader from "./blog/BlogHeader.svelte";
// TODO: Fix move between parents. // TODO: Fix move between parents.
@ -16,8 +17,10 @@
oncursorrelease, oncursorrelease,
parentId, parentId,
rootId, rootId,
index,
sectionClass, sectionClass,
publicationType, publicationType,
onBlogUpdate
} = $props<{ } = $props<{
allowEditing?: boolean; allowEditing?: boolean;
depth?: number; depth?: number;
@ -27,14 +30,19 @@
oncursorrelease?: (e: MouseEvent) => void; oncursorrelease?: (e: MouseEvent) => void;
parentId?: string | null | undefined; parentId?: string | null | undefined;
rootId: string; rootId: string;
index: number;
sectionClass?: string; sectionClass?: string;
publicationType?: string; publicationType?: string;
onBlogUpdate?: any;
}>(); }>();
let currentContent: string = $state($pharosInstance.getContent(rootId)); let currentContent: string = $state($pharosInstance.getContent(rootId));
let title: string | undefined = $state($pharosInstance.getIndexTitle(rootId)); let title: string | undefined = $state($pharosInstance.getIndexTitle(rootId));
let orderedChildren: string[] = $state($pharosInstance.getOrderedChildIds(rootId)); let orderedChildren: string[] = $state($pharosInstance.getOrderedChildIds(rootId));
let blogEntries = $state(Array.from($pharosInstance.getBlogEntries()));
let metadata = $state($pharosInstance.getIndexMetadata());
let isEditing: boolean = $state(false); let isEditing: boolean = $state(false);
let hasCursor: boolean = $state(false); let hasCursor: boolean = $state(false);
let childHasCursor: boolean = $state(false); let childHasCursor: boolean = $state(false);
@ -86,6 +94,48 @@
} }
}); });
function getBlogEvent(index: number) {
return blogEntries[index][1];
}
function byline(rootId: string, index: number) {
console.log(rootId, index, blogEntries);
const event = blogEntries[index][1];
const author = event ? event.getMatchingTags("author")[0][1] : '';
return author ?? "";
}
function hasCoverImage(rootId: string, index: number) {
console.log(rootId);
const event = blogEntries[index][1];
const image = event && event.getMatchingTags("image")[0] ? event.getMatchingTags("image")[0][1] : '';
return image ?? '';
}
function publishedAt(rootId: string, index: number) {
console.log(rootId, index);
console.log(blogEntries[index]);
const event = blogEntries[index][1];
const date = event.created_at ? new Date(event.created_at * 1000) : '';
if (date !== '') {
const formattedDate = new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "short",
day: "2-digit",
}).format(date);
return formattedDate ?? "";
}
return '';
}
function readBlog(rootId:string) {
onBlogUpdate?.(rootId);
}
function propagateBlogUpdate(rootId:string) {
onBlogUpdate?.(rootId);
}
function handleMouseEnter(e: MouseEvent) { function handleMouseEnter(e: MouseEvent) {
hasCursor = true; hasCursor = true;
if (oncursorcapture) { if (oncursorcapture) {
@ -153,17 +203,38 @@
{#snippet sectionHeading(title: string, depth: number)} {#snippet sectionHeading(title: string, depth: number)}
{@const headingLevel = Math.min(depth + 1, 6)} {@const headingLevel = Math.min(depth + 1, 6)}
{@const className = $pharosInstance.isFloatingTitle(rootId) ? 'discrete' : 'h-leather'} {@const className = $pharosInstance.isFloatingTitle(rootId) ? 'discrete' : 'h-leather'}
<svelte:element this={`h${headingLevel}`} class={className}> <svelte:element this={`h${headingLevel}`} class={className}>
{title} {title}
</svelte:element> </svelte:element>
{/snippet} {/snippet}
{#snippet coverImage(rootId: string, index: number, depth: number)}
{#if hasCoverImage(rootId, index)}
<div class="coverImage depth-{depth}">
<img src={hasCoverImage(rootId, index)} alt={title} />
</div>
{/if}
{/snippet}
{#snippet blogMetadata(rootId: string, index: number)}
<p class='h-leather'>
by {byline(rootId, index)}
</p>
<p class='h-leather italic text-sm'>
{publishedAt(rootId, index)}
</p>
{/snippet}
{#snippet contentParagraph(content: string, publicationType: string)} {#snippet contentParagraph(content: string, publicationType: string)}
{#if publicationType === 'novel'} {#if publicationType === 'novel'}
<P class='whitespace-normal' firstupper={isSectionStart}> <P class='whitespace-normal' firstupper={isSectionStart}>
{@html content} {@html content}
</P> </P>
{:else if publicationType === 'blog'}
<P class='whitespace-normal' firstupper={false}>
{@html content}
</P>
{:else} {:else}
<P class='whitespace-normal' firstupper={false}> <P class='whitespace-normal' firstupper={false}>
{@html content} {@html content}
@ -222,25 +293,33 @@
</Button> </Button>
</ButtonGroup> </ButtonGroup>
{:else} {:else}
{@render sectionHeading(title!, depth)} {#if !(publicationType === 'blog' && depth === 1)}
{@render sectionHeading(title!, depth)}
{/if}
{/if} {/if}
<!-- Recurse on child indices and zettels --> <!-- Recurse on child indices and zettels -->
{#key subtreeUpdateCount} {#if publicationType === 'blog' && depth === 1}
{#each orderedChildren as id, index} <BlogHeader event={getBlogEvent(index)} rootId={rootId} onBlogUpdate={readBlog} active={true} />
<Self {:else }
rootId={id} {#key subtreeUpdateCount}
parentId={rootId} {#each orderedChildren as id, index}
publicationType={publicationType} <Self
depth={depth + 1} rootId={id}
{allowEditing} parentId={rootId}
{sectionClass} index={index}
isSectionStart={index === 0} publicationType={publicationType}
bind:needsUpdate={subtreeNeedsUpdate} depth={depth + 1}
oncursorcapture={handleChildCursorCaptured} {allowEditing}
oncursorrelease={handleChildCursorReleased} {sectionClass}
/> isSectionStart={index === 0}
{/each} bind:needsUpdate={subtreeNeedsUpdate}
{/key} oncursorcapture={handleChildCursorCaptured}
oncursorrelease={handleChildCursorReleased}
onBlogUpdate={propagateBlogUpdate}
/>
{/each}
{/key}
{/if}
</div> </div>
{/if} {/if}
{#if allowEditing && depth > 0} {#if allowEditing && depth > 0}

349
src/lib/components/Publication.svelte

@ -2,28 +2,34 @@
import { import {
Alert, Alert,
Button, Button,
Card,
Sidebar, Sidebar,
SidebarGroup, SidebarGroup,
SidebarItem,
SidebarWrapper, SidebarWrapper,
Skeleton, Heading,
TextPlaceholder,
Tooltip,
} from "flowbite-svelte"; } from "flowbite-svelte";
import { getContext, onMount } from "svelte"; import { getContext, onDestroy, onMount } from "svelte";
import { BookOutline, ExclamationCircleOutline } from "flowbite-svelte-icons"; import {
import { page } from "$app/state"; CloseOutline,
ExclamationCircleOutline,
} from "flowbite-svelte-icons";
import type { NDKEvent } from "@nostr-dev-kit/ndk"; import type { NDKEvent } from "@nostr-dev-kit/ndk";
import PublicationSection from "./PublicationSection.svelte"; import PublicationSection from "./PublicationSection.svelte";
import type { PublicationTree } from "$lib/data_structures/publication_tree"; import type { PublicationTree } from "$lib/data_structures/publication_tree";
import Details from "$components/util/Details.svelte";
let { rootAddress, publicationType, indexEvent } = $props<{ import { publicationColumnVisibility } from "$lib/stores";
rootAddress: string, import BlogHeader from "$components/blog/BlogHeader.svelte";
publicationType: string, import Interactions from "$components/util/Interactions.svelte";
indexEvent: NDKEvent import TocToggle from "$components/util/TocToggle.svelte";
import { pharosInstance } from '$lib/parser';
let { rootAddress, publicationType, indexEvent } = $props<{
rootAddress: string;
publicationType: string;
indexEvent: NDKEvent;
}>(); }>();
const publicationTree = getContext('publicationTree') as PublicationTree; const publicationTree = getContext("publicationTree") as PublicationTree;
// #region Loading // #region Loading
@ -76,158 +82,221 @@
// #endregion // #endregion
// #region ToC // region Columns visibility
let currentBlog: null | string = $state(null);
const tocBreakpoint = 1140; let currentBlogEvent: null | NDKEvent = $state(null);
const isLeaf = $derived(indexEvent.kind === 30041);
let activeHash = $state(page.url.hash); function isInnerActive() {
let showToc: boolean = $state(true); return currentBlog !== null && $publicationColumnVisibility.inner;
let showTocButton: boolean = $state(false); }
function normalizeHashPath(str: string): string { function closeDiscussion() {
return str publicationColumnVisibility.update((v) => ({ ...v, discussion: false }));
.toLowerCase()
.replace(/\s+/g, "-")
.replace(/[^\w-]/g, "");
} }
function scrollToElementWithOffset() { function loadBlog(rootId: string) {
const hash = window.location.hash; // depending on the size of the screen, also toggle blog list & discussion visibility
if (hash) { publicationColumnVisibility.update((current) => {
const targetElement = document.querySelector(hash); const updated = current;
if (targetElement) { if (window.innerWidth < 1024) {
const headerOffset = 80; updated.blog = false;
const elementPosition = targetElement.getBoundingClientRect().top; updated.discussion = false;
const offsetPosition = elementPosition + window.scrollY - headerOffset;
window.scrollTo({
top: offsetPosition,
behavior: "auto",
});
} }
updated.inner = true;
return updated;
});
currentBlog = rootId;
// set current blog values for publication render
if (leaves.length > 0) {
currentBlogEvent =
leaves.find((i) => i && i.tagAddress() === currentBlog) ?? null;
} }
} }
/** function showBlogHeader() {
* Hides the table of contents sidebar when the window shrinks below a certain size. This return currentBlog && currentBlogEvent && window.innerWidth < 1140;
* prevents the sidebar from occluding the article content.
*/
function setTocVisibilityOnResize() {
showToc = window.innerWidth >= tocBreakpoint;
showTocButton = window.innerWidth < tocBreakpoint;
} }
/** onDestroy(() => {
* Hides the table of contents sidebar when the user clicks outside of it. // reset visibility
*/ publicationColumnVisibility.reset();
function hideTocOnClick(ev: MouseEvent) { });
const target = ev.target as HTMLElement;
if (target.closest(".sidebar-leather") || target.closest(".btn-leather")) {
return;
}
if (showToc) {
showToc = false;
}
}
// #endregion
onMount(() => { onMount(() => {
// Always check whether the TOC sidebar should be visible. // Set current columns depending on the publication type
setTocVisibilityOnResize(); const isBlog = publicationType === "blog";
window.addEventListener("hashchange", scrollToElementWithOffset); publicationColumnVisibility.update((v) => ({
// Also handle the case where the user lands on the page with a hash in the URL ...v,
scrollToElementWithOffset(); main: !isBlog,
window.addEventListener("resize", setTocVisibilityOnResize); blog: isBlog,
window.addEventListener("click", hideTocOnClick); }));
if (isLeaf || isBlog) {
publicationColumnVisibility.update((v) => ({ ...v, toc: false }));
}
// Set up the intersection observer. // Set up the intersection observer.
observer = new IntersectionObserver((entries) => { observer = new IntersectionObserver(
entries.forEach((entry) => { (entries) => {
if (entry.isIntersecting && !isLoading && !isDone) { entries.forEach((entry) => {
loadMore(1); if (entry.isIntersecting && !isLoading && !isDone) {
} loadMore(1);
}); }
}, { threshold: 0.5 }); });
},
{ threshold: 0.5 },
);
loadMore(8); loadMore(8);
return () => { return () => {
window.removeEventListener("hashchange", scrollToElementWithOffset);
window.removeEventListener("resize", setTocVisibilityOnResize);
window.removeEventListener("click", hideTocOnClick);
observer.disconnect(); observer.disconnect();
}; };
}); });
// Whenever the publication changes, update rootId
let rootId = $derived($pharosInstance.getRootIndexId());
</script> </script>
<!-- TODO: Keep track of already-loaded leaves. --> <!-- Table of contents -->
<!-- TODO: Handle entering mid-document and scrolling up. --> {#if publicationType !== "blog" || !isLeaf}
<TocToggle {rootId} />
{#if showTocButton && !showToc} {/if}
<!-- <Button
class="btn-leather fixed top-20 left-4 h-6 w-6" <!-- Default publications -->
outline={true} {#if $publicationColumnVisibility.main}
on:click={(ev) => { <div class="flex flex-col p-4 space-y-4 overflow-auto max-w-2xl flex-grow-2">
showToc = true; <div
ev.stopPropagation(); class="card-leather bg-highlight dark:bg-primary-800 p-4 mb-4 rounded-lg border"
}} >
<Details event={indexEvent} />
</div>
<!-- Publication sections/cards -->
{#each leaves as leaf, i}
{#if leaf == null}
<Alert class="flex space-x-2">
<ExclamationCircleOutline class="w-5 h-5" />
Error loading content. One or more events could not be loaded.
</Alert>
{:else}
<PublicationSection
{rootAddress}
{leaves}
address={leaf.tagAddress()}
ref={(el) => setLastElementRef(el, i)}
/>
{/if}
{/each}
<div class="flex justify-center my-4">
{#if isLoading}
<Button disabled color="primary">Loading...</Button>
{:else if !isDone}
<Button color="primary" on:click={() => loadMore(1)}>Show More</Button>
{:else}
<p class="text-gray-500 dark:text-gray-400">
You've reached the end of the publication.
</p>
{/if}
</div>
</div>
{/if}
<!-- Blog list -->
{#if $publicationColumnVisibility.blog}
<div
class="flex flex-col p-4 space-y-4 overflow-auto max-w-xl flex-grow-1
{isInnerActive() ? 'discreet' : ''}
"
> >
<BookOutline /> <div
</Button> class="card-leather bg-highlight dark:bg-primary-800 p-4 mb-4 rounded-lg border"
<Tooltip>Show Table of Contents</Tooltip> --> >
<Details event={indexEvent} />
</div>
<!-- List blog excerpts -->
{#each leaves as leaf, i}
{#if leaf}
<BlogHeader
rootId={leaf.tagAddress()}
event={leaf}
onBlogUpdate={loadBlog}
active={!isInnerActive()}
/>
{/if}
{/each}
</div>
{/if} {/if}
<!-- TODO: Use loader to build ToC. -->
<!-- {#if showToc} {#if isInnerActive()}
<Sidebar class='sidebar-leather fixed top-20 left-0 px-4 w-60' {activeHash}> {#key currentBlog}
<SidebarWrapper> <div
<SidebarGroup class='sidebar-group-leather overflow-y-scroll'> class="flex flex-col p-4 max-w-3xl overflow-auto flex-grow-2 max-h-[calc(100vh-146px)] sticky top-[146px]"
{#each events as event} >
<SidebarItem {#each leaves as leaf, i}
class='sidebar-item-leather' {#if leaf && leaf.tagAddress() === currentBlog}
label={event.getMatchingTags('title')[0][1]} <div
href={`${$page.url.pathname}#${normalizeHashPath(event.getMatchingTags('title')[0][1])}`} class="card-leather bg-highlight dark:bg-primary-800 p-4 mb-4 rounded-lg border"
>
<Details event={leaf} />
</div>
<PublicationSection
{rootAddress}
{leaves}
address={leaf.tagAddress()}
ref={(el) => setLastElementRef(el, i)}
/> />
{/each}
<Card class="ArticleBox !hidden card-leather min-w-full mt-4">
<Interactions rootId={currentBlog} />
</Card>
{/if}
{/each}
</div>
{/key}
{/if}
{#if $publicationColumnVisibility.discussion}
<Sidebar class="sidebar-leather right-0 md:!pl-8">
<SidebarWrapper>
<SidebarGroup class="sidebar-group-leather">
<div class="flex justify-between items-baseline">
<Heading tag="h1" class="h-leather !text-lg">Discussion</Heading>
<Button
class="btn-leather hidden sm:flex z-30 !p-1 bg-primary-50 dark:bg-gray-800"
outline
onclick={closeDiscussion}
>
<CloseOutline />
</Button>
</div>
<div class="flex flex-col space-y-4">
<!-- TODO
alternative for other publications and
when blog is not opened, but discussion is opened from the list
-->
{#if showBlogHeader() && currentBlog && currentBlogEvent}
<BlogHeader
rootId={currentBlog}
event={currentBlogEvent}
onBlogUpdate={loadBlog}
active={true}
/>
{/if}
<div class="flex flex-col w-full space-y-4">
<Card class="ArticleBox card-leather w-full grid max-w-xl">
<div class="flex flex-col my-2">
<span>Unknown</span>
<span class="text-gray-500">1.1.1970</span>
</div>
<div class="flex flex-col flex-grow space-y-4">
This is a very intelligent comment placeholder that applies to
all the content equally well.
</div>
</Card>
</div>
</div>
</SidebarGroup> </SidebarGroup>
</SidebarWrapper> </SidebarWrapper>
</Sidebar> </Sidebar>
{/if} --> {/if}
<div class="flex flex-col space-y-4 max-w-2xl pb-10 px-4 sm:px-6 md:px-8">
{#each leaves as leaf, i}
{#if leaf == null}
<Alert class='flex space-x-2'>
<ExclamationCircleOutline class='w-5 h-5' />
Error loading content. One or more events could not be loaded.
</Alert>
{:else}
<PublicationSection
rootAddress={rootAddress}
leaves={leaves}
address={leaf.tagAddress()}
ref={(el) => setLastElementRef(el, i)}
/>
{/if}
{/each}
<div class="flex justify-center my-4">
{#if isLoading}
<Button disabled color="primary">
Loading...
</Button>
{:else if !isDone}
<Button color="primary" on:click={() => loadMore(1)}>
Show More
</Button>
{:else}
<p class="text-gray-500 dark:text-gray-400">You've reached the end of the publication.</p>
{/if}
</div>
</div>
<style>
:global(.sidebar-group-leather) {
max-height: calc(100vh - 8rem);
}
</style>

2
src/lib/components/PublicationSection.svelte

@ -104,7 +104,7 @@
}); });
</script> </script>
<section bind:this={sectionRef} class='publication-leather content-visibility-auto'> <section id={address} bind:this={sectionRef} class='publication-leather content-visibility-auto'>
{#await Promise.all([leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches])} {#await Promise.all([leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches])}
<TextPlaceholder size='xxl' /> <TextPlaceholder size='xxl' />
{:then [leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches]} {:then [leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches]}

70
src/lib/components/blog/BlogHeader.svelte

@ -0,0 +1,70 @@
<script lang="ts">
import type { NDKEvent } from '@nostr-dev-kit/ndk';
import { scale } from 'svelte/transition';
import { Card, Img } from "flowbite-svelte";
import InlineProfile from "$components/util/InlineProfile.svelte";
import Interactions from "$components/util/Interactions.svelte";
import { quintOut } from "svelte/easing";
import CardActions from "$components/util/CardActions.svelte";
const { rootId, event, onBlogUpdate, active = true } = $props<{ rootId: string, event: NDKEvent, onBlogUpdate?: any, active: boolean }>();
let title: string = $derived(event.getMatchingTags('title')[0]?.[1]);
let author: string = $derived(event.getMatchingTags('author')[0]?.[1] ?? 'unknown');
let image: string = $derived(event.getMatchingTags('image')[0]?.[1] ?? null);
let authorPubkey: string = $derived(event.getMatchingTags('p')[0]?.[1] ?? null);
let hashtags: string = $derived(event.getMatchingTags('t') ?? null);
function publishedAt() {
const date = event.created_at ? new Date(event.created_at * 1000) : '';
if (date !== '') {
const formattedDate = new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "short",
day: "2-digit",
}).format(date);
return formattedDate ?? "";
}
return '';
}
function showBlog() {
onBlogUpdate?.(rootId);
}
</script>
{#if title != null}
<Card class="ArticleBox card-leather w-full grid max-w-xl {active ? 'active' : ''}">
<div class='space-y-4'>
<div class="flex flex-row justify-between my-2">
<div class="flex flex-col">
<InlineProfile pubkey={authorPubkey} title={author} />
<span class='text-gray-500'>{publishedAt()}</span>
</div>
<CardActions event={event} />
</div>
{#if image && active}
<div class="ArticleBoxImage flex col justify-center"
in:scale={{ start: 0.8, duration: 500, delay: 100, easing: quintOut }}
>
<Img src={image} class="rounded w-full max-h-72 object-cover"/>
</div>
{/if}
<div class='flex flex-col flex-grow space-y-4'>
<button onclick={() => showBlog()} class='text-left'>
<h2 class='text-lg font-bold line-clamp-2' title="{title}">{title}</h2>
</button>
{#if hashtags}
<div class="tags">
{#each hashtags as tag}
<span>{tag}</span>
{/each}
</div>
{/if}
</div>
{#if active}
<Interactions rootId={rootId} event={event} />
{/if}
</div>
</Card>
{/if}

149
src/lib/components/util/ArticleNav.svelte

@ -0,0 +1,149 @@
<script lang="ts">
import { BookOutline, CaretLeftOutline, CloseOutline, GlobeOutline } from "flowbite-svelte-icons";
import { Button } from "flowbite-svelte";
import { publicationColumnVisibility } from "$lib/stores";
import InlineProfile from "$components/util/InlineProfile.svelte";
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { onDestroy, onMount } from "svelte";
let {
rootId,
publicationType,
indexEvent
} = $props<{
rootId: any,
publicationType: string,
indexEvent: NDKEvent
}>();
let title: string = $derived(indexEvent.getMatchingTags('title')[0]?.[1]);
let author: string = $derived(indexEvent.getMatchingTags('author')[0]?.[1] ?? 'unknown');
let pubkey: string = $derived(indexEvent.getMatchingTags('p')[0]?.[1] ?? null);
let isLeaf: boolean = $derived(indexEvent.kind === 30041);
let lastScrollY = $state(0);
let isVisible = $state(true);
// Function to toggle column visibility
function toggleColumn(column: 'toc' | 'blog' | 'inner' | 'discussion') {
publicationColumnVisibility.update(current => {
const newValue = !current[column];
const updated = { ...current, [column]: newValue };
if (window.innerWidth < 1400 && column === 'blog' && newValue) {
updated.discussion = false;
}
return updated;
});
}
function shouldShowBack() {
const vis = $publicationColumnVisibility;
return ['discussion', 'toc', 'inner'].some(key => vis[key as keyof typeof vis]);
}
function backToMain() {
publicationColumnVisibility.update(current => {
const updated = { ...current };
// if current is 'inner', just go back to blog
if (current.inner && !(current.discussion || current.toc)) {
updated.inner = false;
updated.blog = true;
return updated;
}
updated.discussion = false;
updated.toc = false;
if (publicationType === 'blog') {
updated.inner = true;
updated.blog = false;
} else {
updated.main = true;
}
return updated;
});
}
function backToBlog() {
publicationColumnVisibility.update(current => {
const updated = { ...current };
updated.inner = false;
updated.discussion = false;
updated.blog = true;
return updated;
})
}
function handleScroll() {
if (window.innerWidth < 768) {
const currentScrollY = window.scrollY;
// Hide on scroll down
if (currentScrollY > lastScrollY && currentScrollY > 50) {
isVisible = false;
}
// Show on scroll up
else if (currentScrollY < lastScrollY) {
isVisible = true;
}
lastScrollY = currentScrollY;
}
}
let unsubscribe: () => void;
onMount(() => {
window.addEventListener('scroll', handleScroll);
unsubscribe = publicationColumnVisibility.subscribe(() => {
isVisible = true; // show navbar when store changes
});
});
onDestroy(() => {
window.removeEventListener('scroll', handleScroll);
unsubscribe();
});
</script>
<nav class="Navbar navbar-leather flex fixed top-[60px] sm:top-[76px] w-full min-h-[70px] px-2 sm:px-4 py-2.5 z-10 transition-transform duration-300 {isVisible ? 'translate-y-0' : '-translate-y-full'}">
<div class="mx-auto flex space-x-2 container">
<div class="flex items-center space-x-2 md:min-w-52 min-w-8">
{#if shouldShowBack()}
<Button class='btn-leather !w-auto sm:hidden' outline={true} onclick={backToMain}>
<CaretLeftOutline class="!fill-none inline mr-1" /><span class="hidden sm:inline">Back</span>
</Button>
{/if}
{#if !isLeaf}
{#if publicationType === 'blog'}
<Button class="btn-leather hidden sm:flex !w-auto {$publicationColumnVisibility.blog ? 'active' : ''}"
outline={true} onclick={() => toggleColumn('blog')} >
<BookOutline class="!fill-none inline mr-1" /><span class="hidden sm:inline">Table of Contents</span>
</Button>
{:else if !$publicationColumnVisibility.discussion && !$publicationColumnVisibility.toc}
<Button class='btn-leather !w-auto' outline={true} onclick={() => toggleColumn('toc')}>
<BookOutline class="!fill-none inline mr-1" /><span class="hidden sm:inline">Table of Contents</span>
</Button>
{/if}
{/if}
</div>
<div class="flex flex-grow text justify-center items-center">
<p class="max-w-[60vw] line-ellipsis"><b class="text-nowrap">{title}</b> <span class="whitespace-nowrap">by <InlineProfile pubkey={pubkey} title={author} /></span></p>
</div>
<div class="flex justify-end items-center space-x-2 md:min-w-52 min-w-8">
{#if $publicationColumnVisibility.inner}
<Button class='btn-leather !w-auto hidden sm:flex' outline={true} onclick={backToBlog}>
<CloseOutline class="!fill-none inline mr-1" /><span class="hidden sm:inline">Close</span>
</Button>
{/if}
{#if publicationType !== 'blog' && !$publicationColumnVisibility.discussion}
<Button class="btn-leather !hidden sm:flex !w-auto" outline={true} onclick={() => toggleColumn('discussion')} >
<GlobeOutline class="!fill-none inline mr-1" /><span class="hidden sm:inline">Discussion</span>
</Button>
{/if}
</div>
</div>
</nav>

64
src/lib/components/util/CardActions.svelte

@ -10,7 +10,7 @@
import { Button, Modal, Popover } from "flowbite-svelte"; import { Button, Modal, Popover } from "flowbite-svelte";
import { standardRelays } from "$lib/consts"; import { standardRelays } from "$lib/consts";
import { neventEncode, naddrEncode } from "$lib/utils"; import { neventEncode, naddrEncode } from "$lib/utils";
import InlineProfile from "$components/util/InlineProfile.svelte"; import Details from "./Details.svelte";
let { event } = $props(); let { event } = $props();
@ -18,17 +18,6 @@
let detailsModalOpen: boolean = $state(false); let detailsModalOpen: boolean = $state(false);
let eventIdCopied: boolean = $state(false); let eventIdCopied: boolean = $state(false);
let shareLinkCopied: boolean = $state(false); let shareLinkCopied: boolean = $state(false);
let title: string = $derived(event.getMatchingTags('title')[0]?.[1]);
let author: string = $derived(event.getMatchingTags('author')[0]?.[1] ?? 'unknown');
let version: string = $derived(event.getMatchingTags('version')[0]?.[1] ?? '1');
let image: string = $derived(event.getMatchingTags('image')[0]?.[1] ?? null);
let originalAuthor: string = $derived(event.getMatchingTags('p')[0]?.[1] ?? null);
let summary: string = $derived(event.getMatchingTags('summary')[0]?.[1] ?? null);
let type: string = $derived(event.getMatchingTags('type')[0]?.[1] ?? null);
let language: string = $derived(event.getMatchingTags('l')[0]?.[1] ?? null);
let source: string = $derived(event.getMatchingTags('source')[0]?.[1] ?? null);
let publisher: string = $derived(event.getMatchingTags('published_by')[0]?.[1] ?? null);
let identifier: string = $derived(event.getMatchingTags('i')[0]?.[1] ?? null);
let isOpen = $state(false); let isOpen = $state(false);
@ -84,7 +73,7 @@
</script> </script>
<div class="group" role="group" onmouseenter={openPopover}> <div class="group bg-highlight dark:bg-primary-1000 rounded" role="group" onmouseenter={openPopover}>
<!-- Main button --> <!-- Main button -->
<Button type="button" <Button type="button"
id="dots-{event.id}" id="dots-{event.id}"
@ -145,53 +134,6 @@
</Modal> </Modal>
<!-- Event details --> <!-- Event details -->
<Modal class='modal-leather' title='Publication details' bind:open={detailsModalOpen} autoclose outsideclose size='sm'> <Modal class='modal-leather' title='Publication details' bind:open={detailsModalOpen} autoclose outsideclose size='sm'>
<div class="flex flex-row space-x-4"> <Details event={event} isModal={true} />
{#if image}
<div class="flex col">
<img class="max-w-48" src={image} alt="Publication cover" />
</div>
{/if}
<div class="flex flex-col col space-y-5 justify-center align-middle">
<h1 class="text-3xl font-bold mt-5">{title}</h1>
<h2 class="text-base font-bold">by
{#if originalAuthor !== null}
<InlineProfile pubkey={originalAuthor} title={author} />
{:else}
{author}
{/if}
</h2>
<h4 class='text-base font-thin mt-2'>Version: {version}</h4>
</div>
</div>
{#if summary}
<div class="flex flex-row ">
<p class='text-base text-primary-900 dark:text-highlight'>{summary}</p>
</div>
{/if}
<div class="flex flex-row ">
<h4 class='text-base font-normal mt-2'>Index author: <InlineProfile pubkey={event.pubkey} /></h4>
</div>
<div class="flex flex-col pb-4 space-y-1">
{#if source !== null}
<h5 class="text-sm">Source: <a class="underline" href={source} target="_blank">{source}</a></h5>
{/if}
{#if type !== null}
<h5 class="text-sm">Publication type: {type}</h5>
{/if}
{#if language !== null}
<h5 class="text-sm">Language: {language}</h5>
{/if}
{#if publisher !== null}
<h5 class="text-sm">Published by: {publisher}</h5>
{/if}
{#if identifier !== null}
<h5 class="text-sm">{identifier}</h5>
{/if}
</div>
</Modal> </Modal>
</div> </div>

109
src/lib/components/util/Details.svelte

@ -0,0 +1,109 @@
<script lang="ts">
import InlineProfile from "$components/util/InlineProfile.svelte";
import CardActions from "$components/util/CardActions.svelte";
import Interactions from "$components/util/Interactions.svelte";
import { P } from "flowbite-svelte";
// isModal
// - don't show interactions in modal view
// - don't show all the details when _not_ in modal view
let { event, isModal = false } = $props();
let title: string = $derived(event.getMatchingTags('title')[0]?.[1]);
let author: string = $derived(event.getMatchingTags('author')[0]?.[1] ?? 'unknown');
let version: string = $derived(event.getMatchingTags('version')[0]?.[1] ?? '1');
let image: string = $derived(event.getMatchingTags('image')[0]?.[1] ?? null);
let originalAuthor: string = $derived(event.getMatchingTags('p')[0]?.[1] ?? null);
let summary: string = $derived(event.getMatchingTags('summary')[0]?.[1] ?? null);
let type: string = $derived(event.getMatchingTags('type')[0]?.[1] ?? null);
let language: string = $derived(event.getMatchingTags('l')[0]?.[1] ?? null);
let source: string = $derived(event.getMatchingTags('source')[0]?.[1] ?? null);
let publisher: string = $derived(event.getMatchingTags('published_by')[0]?.[1] ?? null);
let identifier: string = $derived(event.getMatchingTags('i')[0]?.[1] ?? null);
let hashtags: [] = $derived(event.getMatchingTags('t') ?? []);
let rootId: string = $derived(event.getMatchingTags('d')[0]?.[1] ?? null);
let kind = $derived(event.kind);
</script>
<div class="flex flex-col relative mb-2">
{#if !isModal}
<div class="flex flex-row justify-between items-center">
<P class='text-base font-normal'><InlineProfile pubkey={event.pubkey} /></P>
<CardActions event={event}></CardActions>
</div>
{/if}
<div class="flex-grow grid grid-cols-1 md:grid-cols-[auto_1fr] gap-4 items-center">
{#if image}
<div class="my-2">
<img class="w-full md:max-w-48 object-contain rounded" alt={title} src={image} />
</div>
{/if}
<div class="space-y-4 my-4">
<h1 class="text-3xl font-bold">{title}</h1>
<h2 class="text-base font-bold">
by
{#if originalAuthor !== null}
<InlineProfile pubkey={originalAuthor} title={author} />
{:else}
{author}
{/if}
</h2>
{#if version !== '1' }
<h4 class="text-base font-thin">Version: {version}</h4>
{/if}
</div>
</div>
</div>
{#if summary}
<div class="flex flex-row my-2">
<p class='text-base text-primary-900 dark:text-highlight'>{summary}</p>
</div>
{/if}
{#if hashtags.length}
<div class="tags my-2">
{#each hashtags as tag}
<span class="text-sm">#{tag[1]}</span>
{/each}
</div>
{/if}
{#if isModal}
<div class="flex flex-row my-4">
<h4 class='text-base font-normal mt-2'>
{#if kind === 30040}
<span>Index author:</span>
{:else}
<span>Author:</span>
{/if}
<InlineProfile pubkey={event.pubkey} />
</h4>
</div>
<div class="flex flex-col pb-4 space-y-1">
{#if source !== null}
<h5 class="text-sm">Source: <a class="underline break-all" href={source} target="_blank">{source}</a></h5>
{/if}
{#if type !== null}
<h5 class="text-sm">Publication type: {type}</h5>
{/if}
{#if language !== null}
<h5 class="text-sm">Language: {language}</h5>
{/if}
{#if publisher !== null}
<h5 class="text-sm">Published by: {publisher}</h5>
{/if}
{#if identifier !== null}
<h5 class="text-sm">{identifier}</h5>
{/if}
</div>
{/if}
{#if !isModal}
<Interactions event={event} rootId={rootId} direction="row"/>
{/if}

2
src/lib/components/util/InlineProfile.svelte

@ -49,7 +49,7 @@
{:else if npub } {:else if npub }
<a href='{externalProfileDestination}{npub}' title={title ?? username} target='_blank'> <a href='{externalProfileDestination}{npub}' title={title ?? username} target='_blank'>
<Avatar rounded <Avatar rounded
class='h-6 w-6 mx-1 cursor-pointer inline' class='h-6 w-6 mx-1 cursor-pointer inline bg-transparent'
src={pfp} src={pfp}
alt={username} /> alt={username} />
<span class='underline'>{username ?? shortenNpub(npub)}</span> <span class='underline'>{username ?? shortenNpub(npub)}</span>

93
src/lib/components/util/Interactions.svelte

@ -0,0 +1,93 @@
<script lang="ts">
import {
Button, Modal, P
} from "flowbite-svelte";
import { HeartOutline, FilePenOutline, AnnotationOutline } from 'flowbite-svelte-icons';
import ZapOutline from "$components/util/ZapOutline.svelte";
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { onMount } from "svelte";
import { ndkInstance } from '$lib/ndk';
import { publicationColumnVisibility } from "$lib/stores";
const { rootId, event, direction = 'row' } = $props<{ rootId: string, event?: NDKEvent, direction?: string }>();
// Reactive arrays to hold incoming events
let likes: NDKEvent[] = [];
let zaps: NDKEvent[] = [];
let highlights: NDKEvent[] = [];
let comments: NDKEvent[] = [];
let interactionOpen: boolean = $state(false);
// Reactive counts derived from array lengths
// Derived counts from store values
const likeCount = $derived(likes.length);
const zapCount = $derived(zaps.length);
const highlightCount = $derived(highlights.length);
const commentCount = $derived(comments.length);
/**
* Subscribe to Nostr events of a given kind that reference our root event via e-tag.
* Push new events into the provided array if not already present.
* Returns the subscription for later cleanup.
*/
function subscribeCount(kind: number, targetArray: NDKEvent[]) {
const sub = $ndkInstance.subscribe({
kinds: [kind],
'#a': [rootId] // Will this work?
});
sub.on('event', (evt: NDKEvent) => {
// Only add if we haven't seen this event ID yet
if (!targetArray.find(e => e.id === evt.id)) {
targetArray.push(evt);
}
});
return sub;
}
let subs: any[] = [];
onMount(() => {
// Subscribe to each kind; store subs for cleanup
subs.push(subscribeCount(7, likes)); // likes (Reaction)
subs.push(subscribeCount(9735, zaps)); // zaps (Zap Receipts)
subs.push(subscribeCount(30023, highlights)); // highlights (custom kind)
subs.push(subscribeCount(1, comments)); // comments (Text Notes)
});
function showDiscussion() {
publicationColumnVisibility.update(v => {
const updated = { ...v, discussion: true};
// hide blog, unless the only column
if (v.inner) {
updated.blog = (v.blog && window.innerWidth >= 1400 );
}
return updated;
});
}
function doLike() {
interactionOpen = true;
}
function doHighlight() {
interactionOpen = true;
}
function doZap() {
interactionOpen = true;
}
</script>
<div class='InteractiveMenu !hidden flex-{direction} justify-around align-middle text-primary-700 dark:text-gray-500'>
<Button color="none" class='flex flex-{direction} shrink-0 md:min-w-11 min-h-11 items-center p-0' onclick={doLike}><HeartOutline class="mx-2" size="lg" /><span>{likeCount}</span></Button>
<Button color="none" class='flex flex-{direction} shrink-0 md:min-w-11 min-h-11 items-center p-0' onclick={doZap}><ZapOutline className="mx-2" /><span>{zapCount}</span></Button>
<Button color="none" class='flex flex-{direction} shrink-0 md:min-w-11 min-h-11 items-center p-0' onclick={doHighlight}><FilePenOutline class="mx-2" size="lg"/><span>{highlightCount}</span></Button>
<Button color="none" class='flex flex-{direction} shrink-0 md:min-w-11 min-h-11 items-center p-0' onclick={showDiscussion}><AnnotationOutline class="mx-2" size="lg"/><span>{commentCount}</span></Button>
</div>
<Modal class='modal-leather' title='Interaction' bind:open={interactionOpen} autoclose outsideclose size='sm'>
<P>Can't like, zap or highlight yet.</P>
<P>You should totally check out the discussion though.</P>
</Modal>

143
src/lib/components/util/TocToggle.svelte

@ -0,0 +1,143 @@
<script lang="ts">
import {
Heading,
Sidebar,
SidebarGroup,
SidebarItem,
SidebarWrapper,
} from "flowbite-svelte";
import { onMount } from "svelte";
import { pharosInstance, tocUpdate } from "$lib/parser";
import { publicationColumnVisibility } from "$lib/stores";
let { rootId } = $props<{ rootId: string }>();
if (rootId !== $pharosInstance.getRootIndexId()) {
console.error("Root ID does not match parser root index ID");
}
const tocBreakpoint = 1140;
let activeHash = $state(window.location.hash);
interface TocItem {
label: string;
hash: string;
}
// Get TOC items from parser
let tocItems = $state<TocItem[]>([]);
$effect(() => {
// This will re-run whenever tocUpdate changes
tocUpdate;
const items: TocItem[] = [];
const childIds = $pharosInstance.getChildIndexIds(rootId);
console.log('TOC rootId:', rootId, 'childIds:', childIds);
const processNode = (nodeId: string) => {
const title = $pharosInstance.getIndexTitle(nodeId);
if (title) {
items.push({
label: title,
hash: `#${nodeId}`
});
}
const children = $pharosInstance.getChildIndexIds(nodeId);
children.forEach(processNode);
};
childIds.forEach(processNode);
tocItems = items;
});
function normalizeHashPath(str: string): string {
return str
.toLowerCase()
.replace(/\s+/g, "-")
.replace(/[^\w-]/g, "");
}
function scrollToElementWithOffset() {
const hash = window.location.hash;
if (hash) {
const targetElement = document.querySelector(hash);
if (targetElement) {
const headerOffset = 80;
const elementPosition = targetElement.getBoundingClientRect().top;
const offsetPosition = elementPosition + window.scrollY - headerOffset;
window.scrollTo({
top: offsetPosition,
behavior: "auto",
});
}
}
}
function updateActiveHash() {
activeHash = window.location.hash;
}
/**
* Hides the table of contents sidebar when the window shrinks below a certain size. This
* prevents the sidebar from occluding the article content.
*/
function setTocVisibilityOnResize() {
// Always show TOC on laptop and larger screens, collapsible only on small/medium
publicationColumnVisibility.update(v => ({ ...v, toc: window.innerWidth >= tocBreakpoint }));
}
/**
* Hides the table of contents sidebar when the user clicks outside of it.
*/
function hideTocOnClick(ev: MouseEvent) {
const target = ev.target as HTMLElement;
if (target.closest(".sidebar-leather") || target.closest(".btn-leather")) {
return;
}
// Only allow hiding TOC on screens smaller than tocBreakpoint
if (window.innerWidth < tocBreakpoint && $publicationColumnVisibility.toc) {
publicationColumnVisibility.update(v => ({ ...v, toc: false}));
}
}
onMount(() => {
// Always check whether the TOC sidebar should be visible.
setTocVisibilityOnResize();
window.addEventListener("hashchange", updateActiveHash);
window.addEventListener("hashchange", scrollToElementWithOffset);
// Also handle the case where the user lands on the page with a hash in the URL
scrollToElementWithOffset();
window.addEventListener("resize", setTocVisibilityOnResize);
window.addEventListener("click", hideTocOnClick);
return () => {
window.removeEventListener("hashchange", updateActiveHash);
window.removeEventListener("hashchange", scrollToElementWithOffset);
window.removeEventListener("resize", setTocVisibilityOnResize);
window.removeEventListener("click", hideTocOnClick);
};
});
</script>
<!-- TODO: Get TOC from parser. -->
{#if $publicationColumnVisibility.toc}
<Sidebar class='sidebar-leather left-0'>
<SidebarWrapper>
<SidebarGroup class='sidebar-group-leather'>
<Heading tag="h1" class="h-leather !text-lg">Table of contents</Heading>
<p>(This ToC is only for demo purposes, and is not fully-functional.)</p>
{#each tocItems as item}
<SidebarItem
class="sidebar-item-leather {activeHash === item.hash ? 'bg-primary-200 font-bold' : ''}"
label={item.label}
href={item.hash}
/>
{/each}
</SidebarGroup>
</SidebarWrapper>
</Sidebar>
{/if}

19
src/lib/components/util/ZapOutline.svelte

@ -0,0 +1,19 @@
<script>
export let size = 24; // default size
export let className = '';
</script>
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class={className}
viewBox="0 0 24 24"
>
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>
</svg>

35
src/lib/parser.ts

@ -123,6 +123,11 @@ export default class Pharos {
*/ */
private eventsByLevelMap: Map<number, string[]> = new Map<number, string[]>(); private eventsByLevelMap: Map<number, string[]> = new Map<number, string[]>();
/**
* A map of blog entries
*/
private blogEntries: Map<string, NDKEvent> = new Map<string, NDKEvent>();
/** /**
* When `true`, `getEvents()` should regenerate the event tree to propagate updates. * When `true`, `getEvents()` should regenerate the event tree to propagate updates.
*/ */
@ -180,6 +185,14 @@ export default class Pharos {
this.parse(content); this.parse(content);
} }
getBlogEntries() {
return this.blogEntries;
}
getIndexMetadata(): IndexMetadata {
return this.rootIndexMetadata;
}
/** /**
* Generates and stores Nostr events from the parsed AsciiDoc document. The events can be * Generates and stores Nostr events from the parsed AsciiDoc document. The events can be
* modified via the parser's API and retrieved via the `getEvents()` method. * modified via the parser's API and retrieved via the `getEvents()` method.
@ -635,6 +648,23 @@ export default class Pharos {
tags.map(tag => this.ndk.fetchEventFromTag(tag, event)) tags.map(tag => this.ndk.fetchEventFromTag(tag, event))
); );
// if a blog, save complete events for later
if (event.getMatchingTags("type").length > 0 && event.getMatchingTags("type")[0][1] === 'blog') {
childEvents.forEach(child => {
if (child) {
this.blogEntries.set(child?.getMatchingTags("d")?.[0]?.[1], child);
}
})
}
// populate metadata
if (event.created_at) {
this.rootIndexMetadata.publicationDate = new Date(event.created_at * 1000).toDateString();
}
if (event.getMatchingTags('image').length > 0) {
this.rootIndexMetadata.coverImage = event.getMatchingTags('image')[0][1];
}
// Michael J - 15 December 2024 - This could be further parallelized by recursively fetching // Michael J - 15 December 2024 - This could be further parallelized by recursively fetching
// children of index events before processing them for content. We won't make that change now, // children of index events before processing them for content. We won't make that change now,
// as it would increase complexity, but if performance suffers, we can revisit this option. // as it would increase complexity, but if performance suffers, we can revisit this option.
@ -1084,3 +1114,8 @@ export default class Pharos {
} }
export const pharosInstance: Writable<Pharos> = writable(); export const pharosInstance: Writable<Pharos> = writable();
export const tocUpdate = writable(0);
// Whenever you update the publication tree, call:
tocUpdate.update(n => n + 1);

23
src/lib/stores.ts

@ -6,3 +6,26 @@ export let idList = writable<string[]>([]);
export let alexandriaKinds = readable<number[]>([30040, 30041, 30818]); export let alexandriaKinds = readable<number[]>([30040, 30041, 30818]);
export let feedType = writable<FeedType>(FeedType.StandardRelays); export let feedType = writable<FeedType>(FeedType.StandardRelays);
const defaultVisibility = {
toc: false,
blog: true,
main: true,
inner: false,
discussion: false,
editing: false
};
function createVisibilityStore() {
const { subscribe, set, update } = writable({ ...defaultVisibility });
return {
subscribe,
set,
update,
reset: () => set({ ...defaultVisibility })
};
}
export const publicationColumnVisibility = createVisibilityStore();

17
src/routes/+layout.svelte

@ -6,9 +6,6 @@
import { Alert } from "flowbite-svelte"; import { Alert } from "flowbite-svelte";
import { HammerSolid } from "flowbite-svelte-icons"; import { HammerSolid } from "flowbite-svelte-icons";
// Compute viewport height.
$: displayHeight = window.innerHeight;
// Get standard metadata for OpenGraph tags // Get standard metadata for OpenGraph tags
let title = 'Library of Alexandria'; let title = 'Library of Alexandria';
let currentUrl = $page.url.href; let currentUrl = $page.url.href;
@ -18,7 +15,8 @@
let summary = 'Alexandria is a digital library, utilizing Nostr events for curated publications and wiki pages.'; let summary = 'Alexandria is a digital library, utilizing Nostr events for curated publications and wiki pages.';
onMount(() => { onMount(() => {
document.body.style.height = `${displayHeight}px`; const rect = document.body.getBoundingClientRect();
// document.body.style.height = `${rect.height}px`;
}); });
</script> </script>
@ -42,14 +40,7 @@
<meta name="twitter:image" content="{image}" /> <meta name="twitter:image" content="{image}" />
</svelte:head> </svelte:head>
<div class={'leather min-h-full w-full flex flex-col items-center'}> <div class={'leather mt-[76px] h-full w-full flex flex-col items-center'}>
<Navigation class='sticky top-0' /> <Navigation class='fixed top-0' />
<Alert rounded={false} class='border-t-4 border-primary-500 text-gray-900 dark:text-gray-100 dark:border-primary-500 flex justify-left mb-4'>
<HammerSolid class='mr-2 h-5 w-5 text-primary-500 dark:text-primary-500' />
<span class='font-medium'>
<p>Pardon our dust! The publication view is currently using an experimental loader, and may be unstable.</p>
<p>New to Alexandria? Check out our <a href="/start" class='text-primary-600 dark:text-primary-400 hover:underline'>Getting Started guide</a> to learn more about using the library.</p>
</span>
</Alert>
<slot /> <slot />
</div> </div>

11
src/routes/+page.svelte

@ -1,7 +1,7 @@
<script lang='ts'> <script lang='ts'>
import { FeedType, feedTypeStorageKey, standardRelays } from '$lib/consts'; import { FeedType, feedTypeStorageKey, standardRelays } from '$lib/consts';
import { Button, Dropdown, Radio } from 'flowbite-svelte'; import { Alert, Button, Dropdown, Radio } from "flowbite-svelte";
import { ChevronDownOutline } from 'flowbite-svelte-icons'; import { ChevronDownOutline, HammerSolid } from "flowbite-svelte-icons";
import { inboxRelays, ndkSignedIn } from '$lib/ndk'; import { inboxRelays, ndkSignedIn } from '$lib/ndk';
import PublicationFeed from '$lib/components/PublicationFeed.svelte'; import PublicationFeed from '$lib/components/PublicationFeed.svelte';
import { feedType } from '$lib/stores'; import { feedType } from '$lib/stores';
@ -22,6 +22,13 @@
}; };
</script> </script>
<Alert rounded={false} id="alert-experimental" class='border-t-4 border-primary-500 text-gray-900 dark:text-gray-100 dark:border-primary-500 flex justify-left mb-2'>
<HammerSolid class='mr-2 h-5 w-5 text-primary-500 dark:text-primary-500' />
<span class='font-medium'>
Pardon our dust! The publication view is currently using an experimental loader, and may be unstable.
</span>
</Alert>
<main class='leather flex flex-col flex-grow-0 space-y-4 p-4'> <main class='leather flex flex-col flex-grow-0 space-y-4 p-4'>
{#if !$ndkSignedIn} {#if !$ndkSignedIn}
<PublicationFeed relays={standardRelays} /> <PublicationFeed relays={standardRelays} />

3
src/routes/new/compose/+page.svelte

@ -5,6 +5,7 @@
let treeNeedsUpdate: boolean = false; let treeNeedsUpdate: boolean = false;
let treeUpdateCount: number = 0; let treeUpdateCount: number = 0;
let someIndexValue = 0;
$: { $: {
if (treeNeedsUpdate) { if (treeNeedsUpdate) {
@ -17,7 +18,7 @@
<main class='main-leather flex flex-col space-y-4 max-w-2xl w-full mt-4 mb-4'> <main class='main-leather flex flex-col space-y-4 max-w-2xl w-full mt-4 mb-4'>
<Heading tag='h1' class='h-leather mb-2'>Compose</Heading> <Heading tag='h1' class='h-leather mb-2'>Compose</Heading>
{#key treeUpdateCount} {#key treeUpdateCount}
<Preview rootId={$pharosInstance.getRootIndexId()} allowEditing={true} bind:needsUpdate={treeNeedsUpdate} /> <Preview rootId={$pharosInstance.getRootIndexId()} allowEditing={true} bind:needsUpdate={treeNeedsUpdate} index={someIndexValue} />
{/key} {/key}
</main> </main>
</div> </div>

3
src/routes/new/edit/+page.svelte

@ -5,6 +5,7 @@
import Pharos, { pharosInstance } from "$lib/parser"; import Pharos, { pharosInstance } from "$lib/parser";
import { ndkInstance } from "$lib/ndk"; import { ndkInstance } from "$lib/ndk";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
let someIndexValue = 0;
// TODO: Prompt user to sign in before editing. // TODO: Prompt user to sign in before editing.
@ -80,7 +81,7 @@
</ToolbarButton> </ToolbarButton>
</Toolbar> </Toolbar>
{#if rootIndexId} {#if rootIndexId}
<Preview sectionClass='m-2' rootId={rootIndexId} /> <Preview sectionClass='m-2' rootId={rootIndexId} index={someIndexValue} />
{/if} {/if}
</form> </form>
{/if} {/if}

63
src/routes/publication/+page.svelte

@ -5,22 +5,33 @@
import { onDestroy, setContext } from "svelte"; import { onDestroy, setContext } from "svelte";
import { PublicationTree } from "$lib/data_structures/publication_tree"; import { PublicationTree } from "$lib/data_structures/publication_tree";
import Processor from "asciidoctor"; import Processor from "asciidoctor";
import ArticleNav from "$components/util/ArticleNav.svelte";
let { data }: PageProps = $props(); let { data }: PageProps = $props();
const publicationTree = new PublicationTree(data.indexEvent, data.ndk); const publicationTree = new PublicationTree(data.indexEvent, data.ndk);
setContext('publicationTree', publicationTree); setContext("publicationTree", publicationTree);
setContext('asciidoctor', Processor()); setContext("asciidoctor", Processor());
// Get publication metadata for OpenGraph tags // Get publication metadata for OpenGraph tags
let title = $derived(data.indexEvent?.getMatchingTags('title')[0]?.[1] || data.parser?.getIndexTitle(data.parser?.getRootIndexId()) || 'Alexandria Publication'); let title = $derived(
let currentUrl = data.url?.href ?? ''; data.indexEvent?.getMatchingTags("title")[0]?.[1] ||
data.parser?.getIndexTitle(data.parser?.getRootIndexId()) ||
"Alexandria Publication",
);
let currentUrl = data.url?.href ?? "";
// Get image and summary from the event tags if available // Get image and summary from the event tags if available
// If image unavailable, use the Alexandria default pic. // If image unavailable, use the Alexandria default pic.
let image = $derived(data.indexEvent?.getMatchingTags('image')[0]?.[1] || '/screenshots/old_books.jpg'); let image = $derived(
let summary = $derived(data.indexEvent?.getMatchingTags('summary')[0]?.[1] || 'Alexandria is a digital library, utilizing Nostr events for curated publications and wiki pages.'); data.indexEvent?.getMatchingTags("image")[0]?.[1] ||
"/screenshots/old_books.jpg",
);
let summary = $derived(
data.indexEvent?.getMatchingTags("summary")[0]?.[1] ||
"Alexandria is a digital library, utilizing Nostr events for curated publications and wiki pages.",
);
onDestroy(() => data.parser.reset()); onDestroy(() => data.parser.reset());
</script> </script>
@ -28,30 +39,38 @@
<svelte:head> <svelte:head>
<!-- Basic meta tags --> <!-- Basic meta tags -->
<title>{title}</title> <title>{title}</title>
<meta name="description" content="{summary}" /> <meta name="description" content={summary} />
<!-- OpenGraph meta tags --> <!-- OpenGraph meta tags -->
<meta property="og:title" content="{title}" /> <meta property="og:title" content={title} />
<meta property="og:description" content="{summary}" /> <meta property="og:description" content={summary} />
<meta property="og:url" content="{currentUrl}" /> <meta property="og:url" content={currentUrl} />
<meta property="og:type" content="article" /> <meta property="og:type" content="article" />
<meta property="og:site_name" content="Alexandria" /> <meta property="og:site_name" content="Alexandria" />
<meta property="og:image" content="{image}" /> <meta property="og:image" content={image} />
<!-- Twitter Card meta tags --> <!-- Twitter Card meta tags -->
<meta name="twitter:card" content="summary_large_image" /> <meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="{title}" /> <meta name="twitter:title" content={title} />
<meta name="twitter:description" content="{summary}" /> <meta name="twitter:description" content={summary} />
<meta name="twitter:image" content="{image}" /> <meta name="twitter:image" content={image} />
</svelte:head> </svelte:head>
<main> {#key data}
<ArticleNav
publicationType={data.publicationType}
rootId={data.parser.getRootIndexId()}
indexEvent={data.indexEvent}
/>
{/key}
<main class="publication {data.publicationType}">
{#await data.waitable} {#await data.waitable}
<TextPlaceholder divClass='skeleton-leather w-full' size="xxl" /> <TextPlaceholder divClass="skeleton-leather w-full" size="xxl" />
{:then} {:then}
<Publication <Publication
rootAddress={data.indexEvent.tagAddress()} rootAddress={data.indexEvent.tagAddress()}
publicationType={data.publicationType} publicationType={data.publicationType}
indexEvent={data.indexEvent} indexEvent={data.indexEvent}
/> />
{/await} {/await}

15
src/routes/start/+page.svelte

@ -53,20 +53,23 @@
<P class="mb-3"> <P class="mb-3">
Each content section (30041 or 30818) is also a level in the table of Each content section (30041 or 30818) is also a level in the table of
contents, which can be accessed from the floating icon top-left in the contents, which can be accessed from the floating icon top-left in the
reading view. This allows for navigation within the publication. (This reading view. This allows for navigation within the publication.
functionality has been temporarily disabled.) Publications of type "blog" have a ToC which emphasizes that each entry
is a blog post.
(This functionality has been temporarily disabled, but the TOC is visible.)
</P> </P>
<div class="flex flex-col items-center space-y-4 my-4"> <div class="flex flex-col items-center space-y-4 my-4">
<Img <Img
src="/screenshots/ToC_icon.png" src="/screenshots/ToC_normal.png"
alt="ToC icon" alt="ToC basic"
class="image-border rounded-lg" class="image-border rounded-lg"
width="400" width="400"
/> />
<Img <Img
src="/screenshots/TableOfContents.png" src="/screenshots/ToC_blog.png"
alt="Table of contents example" alt="ToC blog"
class="image-border rounded-lg" class="image-border rounded-lg"
width="400" width="400"
/> />

8
src/styles/base.css

@ -1,3 +1,9 @@
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
@layer components {
body {
@apply bg-primary-0 dark:bg-primary-1000;
}
}

60
src/styles/publications.css

@ -1,55 +1,55 @@
@layer components { @layer components {
/* AsciiDoc content */ /* AsciiDoc content */
.note-leather p a { .publication-leather p a {
@apply underline hover:text-primary-500 dark:hover:text-primary-400; @apply underline hover:text-primary-500 dark:hover:text-primary-400;
} }
.note-leather section p { .publication-leather section p {
@apply w-full; @apply w-full;
} }
.note-leather section p table { .publication-leather section p table {
@apply w-full table-fixed space-x-2 space-y-2; @apply w-full table-fixed space-x-2 space-y-2;
} }
.note-leather section p table td { .publication-leather section p table td {
@apply p-2; @apply p-2;
} }
.note-leather section p table td .content:has(> .imageblock) { .publication-leather section p table td .content:has(> .imageblock) {
@apply flex flex-col items-center; @apply flex flex-col items-center;
} }
.note-leather .imageblock { .publication-leather .imageblock {
@apply flex flex-col space-y-2; @apply flex flex-col space-y-2;
} }
.note-leather .imageblock .content { .publication-leather .imageblock .content {
@apply flex justify-center; @apply flex justify-center;
} }
.note-leather .imageblock .title { .publication-leather .imageblock .title {
@apply text-center; @apply text-center;
} }
.note-leather .imageblock.left .content { .publication-leather .imageblock.left .content {
@apply justify-start; @apply justify-start;
} }
.note-leather .imageblock.left .title { .publication-leather .imageblock.left .title {
@apply text-left; @apply text-left;
} }
.note-leather .imageblock.right .content { .publication-leather .imageblock.right .content {
@apply justify-end; @apply justify-end;
} }
.note-leather .imageblock.right .title { .publication-leather .imageblock.right .title {
@apply text-right; @apply text-right;
} }
.note-leather section p table td .literalblock { .publication-leather section p table td .literalblock {
@apply my-2 p-2 border rounded border-gray-400 dark:border-gray-600; @apply my-2 p-2 border rounded border-gray-400 dark:border-gray-600;
} }
.note-leather .literalblock pre { .publication-leather .literalblock pre {
@apply p-3 text-wrap break-words; @apply p-3 text-wrap break-words;
} }
@ -58,7 +58,7 @@
} }
/* lists */ /* lists */
.note-leather .ulist ul { .publication-leather .ulist ul {
@apply space-y-1 list-disc list-inside; @apply space-y-1 list-disc list-inside;
} }
@ -104,7 +104,7 @@
} }
.publication-leather .verseblock pre.content { .publication-leather .verseblock pre.content {
@apply text-base font-sans; @apply text-base font-sans overflow-x-scroll py-1;
} }
.publication-leather .attribution { .publication-leather .attribution {
@ -234,6 +234,34 @@
@apply w-full; @apply w-full;
} }
.coverImage {
@apply max-h-[230px] overflow-hidden;
}
.coverImage.depth-0 {
@apply max-h-[460px] overflow-hidden;
}
.coverImage img {
@apply object-contain w-full;
}
.coverImage.depth-0 img {
@apply m-auto w-auto;
}
/** blog */
@screen lg {
@media (hover: hover) {
.blog .discreet .card-leather:not(:hover) {
@apply bg-primary-50 dark:bg-primary-1000 opacity-75 transition duration-500 ease-in-out ;
}
.blog .discreet .group {
@apply bg-transparent;
}
}
}
/* Discrete headers */ /* Discrete headers */
h3.discrete, h3.discrete,
h4.discrete, h4.discrete,

20
src/styles/scrollbar.css

@ -0,0 +1,20 @@
@layer components {
/* Global scrollbar styles */
* {
scrollbar-color: rgba(87, 66, 41, 0.8) transparent; /* Transparent track, default scrollbar thumb */
}
/* Webkit Browsers (Chrome, Safari, Edge) */
*::-webkit-scrollbar {
width: 12px; /* Thin scrollbar */
}
*::-webkit-scrollbar-track {
background: transparent; /* Fully transparent track */
}
*::-webkit-scrollbar-thumb {
@apply bg-primary-500 dark:bg-primary-600 hover:bg-primary-600 dark:hover:bg-primary-800;;
border-radius: 6px; /* Rounded scrollbar */
}
}

BIN
static/screenshots/ToC_blog.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 385 KiB

BIN
static/screenshots/ToC_normal.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

18
tailwind.config.cjs

@ -22,11 +22,11 @@ const config = {
400: '#ad8351', 400: '#ad8351',
500: '#c6a885', 500: '#c6a885',
600: '#795c39', 600: '#795c39',
700: '#574229', 700: '#564a3e',
800: '#342718', 800: '#3c352c',
900: '#231a10', 900: '#2a241c',
950: '#17110A', 950: '#1d1812',
1000: '#110d08', 1000: '#15110d',
}, },
success: { success: {
50: '#e3f2e7', 50: '#e3f2e7',
@ -80,6 +80,14 @@ const config = {
listStyleType: { listStyleType: {
'upper-alpha': 'upper-alpha', // Uppercase letters 'upper-alpha': 'upper-alpha', // Uppercase letters
'lower-alpha': 'lower-alpha', // Lowercase letters 'lower-alpha': 'lower-alpha', // Lowercase letters
},
flexGrow: {
'1': '1',
'2': '2',
'3': '3',
},
hueRotate: {
20: '20deg',
} }
}, },
}, },

Loading…
Cancel
Save