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. 113
      src/lib/components/Preview.svelte
  5. 345
      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. 51
      src/routes/publication/+page.svelte
  22. 15
      src/routes/start/+page.svelte
  23. 6
      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 @@ @@ -1,4 +1,5 @@
@import './styles/base.css';
@import './styles/scrollbar.css';
@import './styles/publications.css';
@import './styles/visualize.css';
@ -51,9 +52,18 @@ @@ -51,9 +52,18 @@
}
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,
article.article-leather {
@apply bg-primary-0 dark:bg-primary-1000 text-gray-800 dark:text-gray-300;
@ -65,9 +75,9 @@ @@ -65,9 +75,9 @@
@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)),
p.note-leather:hover:not(:has(.note-leather:hover)),
section.note-leather:hover:not(:has(.note-leather:hover)) {
.edit div.note-leather:hover:not(:has(.note-leather:hover)),
.edit p.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;
}
@ -121,6 +131,11 @@ @@ -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;
}
/* Navbar */
nav.Navbar.navbar-main {
@apply z-30;
}
nav.navbar-leather {
@apply bg-primary-0 dark:bg-primary-1000 z-10;
}
@ -138,12 +153,18 @@ @@ -138,12 +153,18 @@
@apply text-gray-800 hover:text-primary-400 dark:text-gray-300 dark:hover:text-primary-500;
}
aside.sidebar-leather>div {
@apply bg-primary-0 dark:bg-primary-1000;
/* Sidebar */
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 {
@apply hover:bg-primary-100 dark:hover:bg-primary-800;
@apply hover:bg-primary-100 dark:hover:bg-gray-800;
}
div.skeleton-leather div {
@ -229,6 +250,21 @@ @@ -229,6 +250,21 @@
.link {
@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 {
@ -381,6 +417,10 @@ @@ -381,6 +417,10 @@
padding-left: 1rem;
}
.line-ellipsis {
overflow: hidden;
text-overflow: ellipsis;
}
.footnotes li {
margin-bottom: 0.5rem;
}

2
src/lib/components/Login.svelte

@ -50,7 +50,7 @@ @@ -50,7 +50,7 @@
{#if $ndkSignedIn}
<Profile pubkey={$activePubkey} isNav={true} />
{: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
class='popover-leather w-fit'
placement='bottom'

2
src/lib/components/Navigation.svelte

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

113
src/lib/components/Preview.svelte

@ -4,6 +4,7 @@ @@ -4,6 +4,7 @@
import { CaretDownSolid, CaretUpSolid, EditOutline } from 'flowbite-svelte-icons';
import Self from './Preview.svelte';
import { contentParagraph, sectionHeading } from '$lib/snippets/PublicationSnippets.svelte';
import BlogHeader from "./blog/BlogHeader.svelte";
// TODO: Fix move between parents.
@ -16,8 +17,10 @@ @@ -16,8 +17,10 @@
oncursorrelease,
parentId,
rootId,
index,
sectionClass,
publicationType,
onBlogUpdate
} = $props<{
allowEditing?: boolean;
depth?: number;
@ -27,14 +30,19 @@ @@ -27,14 +30,19 @@
oncursorrelease?: (e: MouseEvent) => void;
parentId?: string | null | undefined;
rootId: string;
index: number;
sectionClass?: string;
publicationType?: string;
onBlogUpdate?: any;
}>();
let currentContent: string = $state($pharosInstance.getContent(rootId));
let title: string | undefined = $state($pharosInstance.getIndexTitle(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 hasCursor: boolean = $state(false);
let childHasCursor: boolean = $state(false);
@ -86,6 +94,48 @@ @@ -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) {
hasCursor = true;
if (oncursorcapture) {
@ -159,11 +209,32 @@ @@ -159,11 +209,32 @@
</svelte:element>
{/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)}
{#if publicationType === 'novel'}
<P class='whitespace-normal' firstupper={isSectionStart}>
{@html content}
</P>
{:else if publicationType === 'blog'}
<P class='whitespace-normal' firstupper={false}>
{@html content}
</P>
{:else}
<P class='whitespace-normal' firstupper={false}>
{@html content}
@ -222,25 +293,33 @@ @@ -222,25 +293,33 @@
</Button>
</ButtonGroup>
{:else}
{@render sectionHeading(title!, depth)}
{#if !(publicationType === 'blog' && depth === 1)}
{@render sectionHeading(title!, depth)}
{/if}
{/if}
<!-- Recurse on child indices and zettels -->
{#key subtreeUpdateCount}
{#each orderedChildren as id, index}
<Self
rootId={id}
parentId={rootId}
publicationType={publicationType}
depth={depth + 1}
{allowEditing}
{sectionClass}
isSectionStart={index === 0}
bind:needsUpdate={subtreeNeedsUpdate}
oncursorcapture={handleChildCursorCaptured}
oncursorrelease={handleChildCursorReleased}
/>
{/each}
{/key}
{#if publicationType === 'blog' && depth === 1}
<BlogHeader event={getBlogEvent(index)} rootId={rootId} onBlogUpdate={readBlog} active={true} />
{:else }
{#key subtreeUpdateCount}
{#each orderedChildren as id, index}
<Self
rootId={id}
parentId={rootId}
index={index}
publicationType={publicationType}
depth={depth + 1}
{allowEditing}
{sectionClass}
isSectionStart={index === 0}
bind:needsUpdate={subtreeNeedsUpdate}
oncursorcapture={handleChildCursorCaptured}
oncursorrelease={handleChildCursorReleased}
onBlogUpdate={propagateBlogUpdate}
/>
{/each}
{/key}
{/if}
</div>
{/if}
{#if allowEditing && depth > 0}

345
src/lib/components/Publication.svelte

@ -2,28 +2,34 @@ @@ -2,28 +2,34 @@
import {
Alert,
Button,
Card,
Sidebar,
SidebarGroup,
SidebarItem,
SidebarWrapper,
Skeleton,
TextPlaceholder,
Tooltip,
Heading,
} from "flowbite-svelte";
import { getContext, onMount } from "svelte";
import { BookOutline, ExclamationCircleOutline } from "flowbite-svelte-icons";
import { page } from "$app/state";
import { getContext, onDestroy, onMount } from "svelte";
import {
CloseOutline,
ExclamationCircleOutline,
} from "flowbite-svelte-icons";
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import PublicationSection from "./PublicationSection.svelte";
import type { PublicationTree } from "$lib/data_structures/publication_tree";
import Details from "$components/util/Details.svelte";
import { publicationColumnVisibility } from "$lib/stores";
import BlogHeader from "$components/blog/BlogHeader.svelte";
import Interactions from "$components/util/Interactions.svelte";
import TocToggle from "$components/util/TocToggle.svelte";
import { pharosInstance } from '$lib/parser';
let { rootAddress, publicationType, indexEvent } = $props<{
rootAddress: string,
publicationType: string,
indexEvent: NDKEvent
rootAddress: string;
publicationType: string;
indexEvent: NDKEvent;
}>();
const publicationTree = getContext('publicationTree') as PublicationTree;
const publicationTree = getContext("publicationTree") as PublicationTree;
// #region Loading
@ -76,158 +82,221 @@ @@ -76,158 +82,221 @@
// #endregion
// #region ToC
const tocBreakpoint = 1140;
// region Columns visibility
let currentBlog: null | string = $state(null);
let currentBlogEvent: null | NDKEvent = $state(null);
const isLeaf = $derived(indexEvent.kind === 30041);
let activeHash = $state(page.url.hash);
let showToc: boolean = $state(true);
let showTocButton: boolean = $state(false);
function isInnerActive() {
return currentBlog !== null && $publicationColumnVisibility.inner;
}
function normalizeHashPath(str: string): string {
return str
.toLowerCase()
.replace(/\s+/g, "-")
.replace(/[^\w-]/g, "");
function closeDiscussion() {
publicationColumnVisibility.update((v) => ({ ...v, discussion: false }));
}
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 loadBlog(rootId: string) {
// depending on the size of the screen, also toggle blog list & discussion visibility
publicationColumnVisibility.update((current) => {
const updated = current;
if (window.innerWidth < 1024) {
updated.blog = false;
updated.discussion = false;
}
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;
}
}
/**
* 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() {
showToc = window.innerWidth >= tocBreakpoint;
showTocButton = window.innerWidth < tocBreakpoint;
function showBlogHeader() {
return currentBlog && currentBlogEvent && window.innerWidth < 1140;
}
/**
* 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;
}
if (showToc) {
showToc = false;
}
}
// #endregion
onDestroy(() => {
// reset visibility
publicationColumnVisibility.reset();
});
onMount(() => {
// Always check whether the TOC sidebar should be visible.
setTocVisibilityOnResize();
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);
// Set current columns depending on the publication type
const isBlog = publicationType === "blog";
publicationColumnVisibility.update((v) => ({
...v,
main: !isBlog,
blog: isBlog,
}));
if (isLeaf || isBlog) {
publicationColumnVisibility.update((v) => ({ ...v, toc: false }));
}
// Set up the intersection observer.
observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting && !isLoading && !isDone) {
loadMore(1);
}
});
}, { threshold: 0.5 });
observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting && !isLoading && !isDone) {
loadMore(1);
}
});
},
{ threshold: 0.5 },
);
loadMore(8);
return () => {
window.removeEventListener("hashchange", scrollToElementWithOffset);
window.removeEventListener("resize", setTocVisibilityOnResize);
window.removeEventListener("click", hideTocOnClick);
observer.disconnect();
};
});
// Whenever the publication changes, update rootId
let rootId = $derived($pharosInstance.getRootIndexId());
</script>
<!-- TODO: Keep track of already-loaded leaves. -->
<!-- TODO: Handle entering mid-document and scrolling up. -->
{#if showTocButton && !showToc}
<!-- <Button
class="btn-leather fixed top-20 left-4 h-6 w-6"
outline={true}
on:click={(ev) => {
showToc = true;
ev.stopPropagation();
}}
<!-- Table of contents -->
{#if publicationType !== "blog" || !isLeaf}
<TocToggle {rootId} />
{/if}
<!-- Default publications -->
{#if $publicationColumnVisibility.main}
<div class="flex flex-col p-4 space-y-4 overflow-auto max-w-2xl flex-grow-2">
<div
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 />
</Button>
<Tooltip>Show Table of Contents</Tooltip> -->
<div
class="card-leather bg-highlight dark:bg-primary-800 p-4 mb-4 rounded-lg border"
>
<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}
<!-- TODO: Use loader to build ToC. -->
<!-- {#if showToc}
<Sidebar class='sidebar-leather fixed top-20 left-0 px-4 w-60' {activeHash}>
<SidebarWrapper>
<SidebarGroup class='sidebar-group-leather overflow-y-scroll'>
{#each events as event}
<SidebarItem
class='sidebar-item-leather'
label={event.getMatchingTags('title')[0][1]}
href={`${$page.url.pathname}#${normalizeHashPath(event.getMatchingTags('title')[0][1])}`}
{#if isInnerActive()}
{#key currentBlog}
<div
class="flex flex-col p-4 max-w-3xl overflow-auto flex-grow-2 max-h-[calc(100vh-146px)] sticky top-[146px]"
>
{#each leaves as leaf, i}
{#if leaf && leaf.tagAddress() === currentBlog}
<div
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>
</SidebarWrapper>
</Sidebar>
{/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>
{/if}

2
src/lib/components/PublicationSection.svelte

@ -104,7 +104,7 @@ @@ -104,7 +104,7 @@
});
</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])}
<TextPlaceholder size='xxl' />
{:then [leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches]}

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

@ -0,0 +1,70 @@ @@ -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 @@ @@ -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 @@ @@ -10,7 +10,7 @@
import { Button, Modal, Popover } from "flowbite-svelte";
import { standardRelays } from "$lib/consts";
import { neventEncode, naddrEncode } from "$lib/utils";
import InlineProfile from "$components/util/InlineProfile.svelte";
import Details from "./Details.svelte";
let { event } = $props();
@ -18,17 +18,6 @@ @@ -18,17 +18,6 @@
let detailsModalOpen: boolean = $state(false);
let eventIdCopied: 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);
@ -84,7 +73,7 @@ @@ -84,7 +73,7 @@
</script>
<div class="group" role="group" onmouseenter={openPopover}>
<div class="group bg-highlight dark:bg-primary-1000 rounded" role="group" onmouseenter={openPopover}>
<!-- Main button -->
<Button type="button"
id="dots-{event.id}"
@ -145,53 +134,6 @@ @@ -145,53 +134,6 @@
</Modal>
<!-- Event details -->
<Modal class='modal-leather' title='Publication details' bind:open={detailsModalOpen} autoclose outsideclose size='sm'>
<div class="flex flex-row space-x-4">
{#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>
<Details event={event} isModal={true} />
</Modal>
</div>

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

@ -0,0 +1,109 @@ @@ -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 @@ @@ -49,7 +49,7 @@
{:else if npub }
<a href='{externalProfileDestination}{npub}' title={title ?? username} target='_blank'>
<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}
alt={username} />
<span class='underline'>{username ?? shortenNpub(npub)}</span>

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

@ -0,0 +1,93 @@ @@ -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 @@ @@ -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 @@ @@ -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 { @@ -123,6 +123,11 @@ export default class Pharos {
*/
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.
*/
@ -180,6 +185,14 @@ export default class Pharos { @@ -180,6 +185,14 @@ export default class Pharos {
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
* modified via the parser's API and retrieved via the `getEvents()` method.
@ -635,6 +648,23 @@ export default class Pharos { @@ -635,6 +648,23 @@ export default class Pharos {
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
// 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.
@ -1084,3 +1114,8 @@ export default class Pharos { @@ -1084,3 +1114,8 @@ export default class Pharos {
}
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[]>([]); @@ -6,3 +6,26 @@ export let idList = writable<string[]>([]);
export let alexandriaKinds = readable<number[]>([30040, 30041, 30818]);
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 @@ @@ -6,9 +6,6 @@
import { Alert } from "flowbite-svelte";
import { HammerSolid } from "flowbite-svelte-icons";
// Compute viewport height.
$: displayHeight = window.innerHeight;
// Get standard metadata for OpenGraph tags
let title = 'Library of Alexandria';
let currentUrl = $page.url.href;
@ -18,7 +15,8 @@ @@ -18,7 +15,8 @@
let summary = 'Alexandria is a digital library, utilizing Nostr events for curated publications and wiki pages.';
onMount(() => {
document.body.style.height = `${displayHeight}px`;
const rect = document.body.getBoundingClientRect();
// document.body.style.height = `${rect.height}px`;
});
</script>
@ -42,14 +40,7 @@ @@ -42,14 +40,7 @@
<meta name="twitter:image" content="{image}" />
</svelte:head>
<div class={'leather min-h-full w-full flex flex-col items-center'}>
<Navigation class='sticky 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>
<div class={'leather mt-[76px] h-full w-full flex flex-col items-center'}>
<Navigation class='fixed top-0' />
<slot />
</div>

11
src/routes/+page.svelte

@ -1,7 +1,7 @@ @@ -1,7 +1,7 @@
<script lang='ts'>
import { FeedType, feedTypeStorageKey, standardRelays } from '$lib/consts';
import { Button, Dropdown, Radio } from 'flowbite-svelte';
import { ChevronDownOutline } from 'flowbite-svelte-icons';
import { Alert, Button, Dropdown, Radio } from "flowbite-svelte";
import { ChevronDownOutline, HammerSolid } from "flowbite-svelte-icons";
import { inboxRelays, ndkSignedIn } from '$lib/ndk';
import PublicationFeed from '$lib/components/PublicationFeed.svelte';
import { feedType } from '$lib/stores';
@ -22,6 +22,13 @@ @@ -22,6 +22,13 @@
};
</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'>
{#if !$ndkSignedIn}
<PublicationFeed relays={standardRelays} />

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

@ -5,6 +5,7 @@ @@ -5,6 +5,7 @@
let treeNeedsUpdate: boolean = false;
let treeUpdateCount: number = 0;
let someIndexValue = 0;
$: {
if (treeNeedsUpdate) {
@ -17,7 +18,7 @@ @@ -17,7 +18,7 @@
<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>
{#key treeUpdateCount}
<Preview rootId={$pharosInstance.getRootIndexId()} allowEditing={true} bind:needsUpdate={treeNeedsUpdate} />
<Preview rootId={$pharosInstance.getRootIndexId()} allowEditing={true} bind:needsUpdate={treeNeedsUpdate} index={someIndexValue} />
{/key}
</main>
</div>

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

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

51
src/routes/publication/+page.svelte

@ -5,22 +5,33 @@ @@ -5,22 +5,33 @@
import { onDestroy, setContext } from "svelte";
import { PublicationTree } from "$lib/data_structures/publication_tree";
import Processor from "asciidoctor";
import ArticleNav from "$components/util/ArticleNav.svelte";
let { data }: PageProps = $props();
const publicationTree = new PublicationTree(data.indexEvent, data.ndk);
setContext('publicationTree', publicationTree);
setContext('asciidoctor', Processor());
setContext("publicationTree", publicationTree);
setContext("asciidoctor", Processor());
// Get publication metadata for OpenGraph tags
let title = $derived(data.indexEvent?.getMatchingTags('title')[0]?.[1] || data.parser?.getIndexTitle(data.parser?.getRootIndexId()) || 'Alexandria Publication');
let currentUrl = data.url?.href ?? '';
let title = $derived(
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
// If image unavailable, use the Alexandria default pic.
let image = $derived(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.');
let image = $derived(
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());
</script>
@ -28,26 +39,34 @@ @@ -28,26 +39,34 @@
<svelte:head>
<!-- Basic meta tags -->
<title>{title}</title>
<meta name="description" content="{summary}" />
<meta name="description" content={summary} />
<!-- OpenGraph meta tags -->
<meta property="og:title" content="{title}" />
<meta property="og:description" content="{summary}" />
<meta property="og:url" content="{currentUrl}" />
<meta property="og:title" content={title} />
<meta property="og:description" content={summary} />
<meta property="og:url" content={currentUrl} />
<meta property="og:type" content="article" />
<meta property="og:site_name" content="Alexandria" />
<meta property="og:image" content="{image}" />
<meta property="og:image" content={image} />
<!-- Twitter Card meta tags -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="{title}" />
<meta name="twitter:description" content="{summary}" />
<meta name="twitter:image" content="{image}" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={summary} />
<meta name="twitter:image" content={image} />
</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}
<TextPlaceholder divClass='skeleton-leather w-full' size="xxl" />
<TextPlaceholder divClass="skeleton-leather w-full" size="xxl" />
{:then}
<Publication
rootAddress={data.indexEvent.tagAddress()}

15
src/routes/start/+page.svelte

@ -53,20 +53,23 @@ @@ -53,20 +53,23 @@
<P class="mb-3">
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
reading view. This allows for navigation within the publication. (This
functionality has been temporarily disabled.)
reading view. This allows for navigation within the publication.
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>
<div class="flex flex-col items-center space-y-4 my-4">
<Img
src="/screenshots/ToC_icon.png"
alt="ToC icon"
src="/screenshots/ToC_normal.png"
alt="ToC basic"
class="image-border rounded-lg"
width="400"
/>
<Img
src="/screenshots/TableOfContents.png"
alt="Table of contents example"
src="/screenshots/ToC_blog.png"
alt="ToC blog"
class="image-border rounded-lg"
width="400"
/>

6
src/styles/base.css

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

60
src/styles/publications.css

@ -1,55 +1,55 @@ @@ -1,55 +1,55 @@
@layer components {
/* AsciiDoc content */
.note-leather p a {
.publication-leather p a {
@apply underline hover:text-primary-500 dark:hover:text-primary-400;
}
.note-leather section p {
.publication-leather section p {
@apply w-full;
}
.note-leather section p table {
.publication-leather section p table {
@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;
}
.note-leather section p table td .content:has(> .imageblock) {
.publication-leather section p table td .content:has(> .imageblock) {
@apply flex flex-col items-center;
}
.note-leather .imageblock {
.publication-leather .imageblock {
@apply flex flex-col space-y-2;
}
.note-leather .imageblock .content {
.publication-leather .imageblock .content {
@apply flex justify-center;
}
.note-leather .imageblock .title {
.publication-leather .imageblock .title {
@apply text-center;
}
.note-leather .imageblock.left .content {
.publication-leather .imageblock.left .content {
@apply justify-start;
}
.note-leather .imageblock.left .title {
.publication-leather .imageblock.left .title {
@apply text-left;
}
.note-leather .imageblock.right .content {
.publication-leather .imageblock.right .content {
@apply justify-end;
}
.note-leather .imageblock.right .title {
.publication-leather .imageblock.right .title {
@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;
}
.note-leather .literalblock pre {
.publication-leather .literalblock pre {
@apply p-3 text-wrap break-words;
}
@ -58,7 +58,7 @@ @@ -58,7 +58,7 @@
}
/* lists */
.note-leather .ulist ul {
.publication-leather .ulist ul {
@apply space-y-1 list-disc list-inside;
}
@ -104,7 +104,7 @@ @@ -104,7 +104,7 @@
}
.publication-leather .verseblock pre.content {
@apply text-base font-sans;
@apply text-base font-sans overflow-x-scroll py-1;
}
.publication-leather .attribution {
@ -234,6 +234,34 @@ @@ -234,6 +234,34 @@
@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 */
h3.discrete,
h4.discrete,

20
src/styles/scrollbar.css

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

Loading…
Cancel
Save