28 changed files with 1152 additions and 297 deletions
@ -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} |
||||||
@ -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> |
||||||
@ -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} |
||||||
@ -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> |
||||||
@ -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} |
||||||
@ -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> |
||||||
@ -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; |
||||||
|
} |
||||||
|
} |
||||||
@ -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 */ |
||||||
|
} |
||||||
|
} |
||||||
|
After Width: | Height: | Size: 385 KiB |
|
After Width: | Height: | Size: 205 KiB |
Loading…
Reference in new issue