You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
415 lines
11 KiB
415 lines
11 KiB
<script lang="ts"> |
|
import { pharosInstance, SiblingSearchDirection } from "$lib/parser"; |
|
import { |
|
Button, |
|
ButtonGroup, |
|
CloseButton, |
|
Input, |
|
P, |
|
Textarea, |
|
Tooltip, |
|
} from "flowbite-svelte"; |
|
import { |
|
CaretDownSolid, |
|
CaretUpSolid, |
|
EditOutline, |
|
} from "flowbite-svelte-icons"; |
|
import Self from "./Preview.svelte"; |
|
import { |
|
contentParagraph, |
|
sectionHeading, |
|
} from "$lib/snippets/PublicationSnippets.svelte"; |
|
import BlogHeader from "$components/cards/BlogHeader.svelte"; |
|
import { getMatchingTags } from "$lib/utils/nostrUtils"; |
|
import { onMount } from "svelte"; |
|
|
|
// TODO: Fix move between parents. |
|
|
|
let { |
|
allowEditing, |
|
depth = 0, |
|
isSectionStart, |
|
needsUpdate = $bindable<boolean>(), |
|
oncursorcapture, |
|
oncursorrelease, |
|
parentId, |
|
rootId, |
|
index, |
|
sectionClass, |
|
publicationType, |
|
onBlogUpdate, |
|
} = $props<{ |
|
allowEditing?: boolean; |
|
depth?: number; |
|
isSectionStart?: boolean; |
|
needsUpdate?: boolean; |
|
oncursorcapture?: (e: MouseEvent) => void; |
|
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); |
|
|
|
let hasPreviousSibling: boolean = $state(false); |
|
let hasNextSibling: boolean = $state(false); |
|
|
|
let subtreeNeedsUpdate: boolean = $state(false); |
|
let updateCount: number = $state(0); |
|
let subtreeUpdateCount: number = $state(0); |
|
|
|
let buttonsVisible: boolean = $derived(hasCursor && !childHasCursor); |
|
|
|
$effect(() => { |
|
if (needsUpdate) { |
|
updateCount++; |
|
needsUpdate = false; |
|
title = $pharosInstance.getIndexTitle(rootId); |
|
currentContent = $pharosInstance.getContent(rootId); |
|
} |
|
|
|
if (subtreeNeedsUpdate) { |
|
subtreeUpdateCount++; |
|
subtreeNeedsUpdate = false; |
|
|
|
const prevChildCount = orderedChildren.length; |
|
orderedChildren = $pharosInstance.getOrderedChildIds(rootId); |
|
const newChildCount = orderedChildren.length; |
|
|
|
// If the number of children has changed, a child has been added or removed, and a child may |
|
// have been moved into a different subtree. Due to the `needsUpdate` binding in the |
|
// component's recursion, setting `needsUpdate` to true will force the parent to rerender its |
|
// subtree. |
|
if (newChildCount !== prevChildCount) { |
|
needsUpdate = true; |
|
} |
|
} |
|
}); |
|
|
|
$effect(() => { |
|
if (parentId && allowEditing) { |
|
// Check for previous/next siblings on load |
|
const previousSibling = $pharosInstance.getNearestSibling( |
|
rootId, |
|
depth - 1, |
|
SiblingSearchDirection.Previous, |
|
); |
|
const nextSibling = $pharosInstance.getNearestSibling( |
|
rootId, |
|
depth - 1, |
|
SiblingSearchDirection.Next, |
|
); |
|
|
|
// Hide arrows if no siblings exist |
|
hasPreviousSibling = !!previousSibling[0]; |
|
hasNextSibling = !!nextSibling[0]; |
|
} |
|
}); |
|
|
|
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 ? getMatchingTags(event, "author")[0][1] : ""; |
|
return author ?? ""; |
|
} |
|
|
|
function hasCoverImage(rootId: string, index: number) { |
|
console.log(rootId); |
|
const event = blogEntries[index][1]; |
|
const image = |
|
event && getMatchingTags(event, "image")[0] |
|
? getMatchingTags(event, "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) { |
|
oncursorcapture(e); |
|
} |
|
} |
|
|
|
function handleMouseLeave(e: MouseEvent) { |
|
hasCursor = false; |
|
if (oncursorrelease) { |
|
oncursorrelease(e); |
|
} |
|
} |
|
|
|
function handleChildCursorCaptured(e: MouseEvent) { |
|
childHasCursor = true; |
|
if (oncursorcapture) { |
|
oncursorcapture(e); |
|
} |
|
} |
|
|
|
function handleChildCursorReleased(e: MouseEvent) { |
|
childHasCursor = false; |
|
} |
|
|
|
function toggleEditing(id: string, shouldSave: boolean = true) { |
|
const editing = isEditing; |
|
|
|
if (editing && shouldSave) { |
|
if (orderedChildren.length > 0) { |
|
} |
|
|
|
$pharosInstance.updateEventContent(id, currentContent); |
|
} |
|
|
|
isEditing = !editing; |
|
} |
|
|
|
function moveUp(rootId: string, parentId: string) { |
|
// Get the previous sibling and its index |
|
const [prevSiblingId, prevIndex] = $pharosInstance.getNearestSibling( |
|
rootId, |
|
depth - 1, |
|
SiblingSearchDirection.Previous, |
|
); |
|
if (!prevSiblingId || prevIndex == null) { |
|
return; |
|
} |
|
|
|
// Move the current event before the previous sibling. |
|
$pharosInstance.moveEvent(rootId, prevSiblingId, false); |
|
needsUpdate = true; |
|
} |
|
|
|
function moveDown(rootId: string, parentId: string) { |
|
// Get the next sibling and its index |
|
const [nextSiblingId, nextIndex] = $pharosInstance.getNearestSibling( |
|
rootId, |
|
depth - 1, |
|
SiblingSearchDirection.Next, |
|
); |
|
if (!nextSiblingId || nextIndex == null) { |
|
return; |
|
} |
|
|
|
// Move the current event after the next sibling |
|
$pharosInstance.moveEvent(rootId, nextSiblingId, true); |
|
needsUpdate = true; |
|
} |
|
</script> |
|
|
|
{#snippet sectionHeading(title: string, depth: number)} |
|
{@const headingLevel = Math.min(depth + 1, 6)} |
|
{@const className = $pharosInstance.isFloatingTitle(rootId) |
|
? "discrete" |
|
: "h-leather"} |
|
|
|
<svelte:element this={`h${headingLevel}`} class={className}> |
|
{title} |
|
</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} |
|
</P> |
|
{/if} |
|
{/snippet} |
|
|
|
<!-- This component is recursively structured. The base case is single block of content. --> |
|
<section |
|
id={rootId} |
|
class={`note-leather flex space-x-2 justify-between text-wrap break-words ${sectionClass}`} |
|
onmouseenter={handleMouseEnter} |
|
onmouseleave={handleMouseLeave} |
|
aria-label="Publication section" |
|
> |
|
<!-- Zettel base case --> |
|
{#if orderedChildren.length === 0 || depth >= 4} |
|
{#key updateCount} |
|
{#if isEditing} |
|
<form class="w-full"> |
|
<Textarea |
|
class="textarea-leather w-full whitespace-normal" |
|
bind:value={currentContent} |
|
> |
|
<div slot="footer" class="flex space-x-2 justify-end"> |
|
<Button |
|
type="reset" |
|
class="btn-leather min-w-fit" |
|
size="sm" |
|
outline |
|
onclick={() => toggleEditing(rootId, false)} |
|
> |
|
Cancel |
|
</Button> |
|
<Button |
|
type="submit" |
|
class="btn-leather min-w-fit" |
|
size="sm" |
|
onclick={() => toggleEditing(rootId, true)} |
|
> |
|
Save |
|
</Button> |
|
</div> |
|
</Textarea> |
|
</form> |
|
{:else} |
|
{@render contentParagraph(currentContent, publicationType)} |
|
{/if} |
|
{/key} |
|
{:else} |
|
<div class="flex flex-col space-y-2 w-full"> |
|
{#if isEditing} |
|
<ButtonGroup class="w-full"> |
|
<Input type="text" class="input-leather" size="lg" bind:value={title}> |
|
<CloseButton |
|
slot="right" |
|
onclick={() => toggleEditing(rootId, false)} |
|
/> |
|
</Input> |
|
<Button |
|
class="btn-leather" |
|
color="primary" |
|
size="lg" |
|
onclick={() => toggleEditing(rootId, true)} |
|
> |
|
Save |
|
</Button> |
|
</ButtonGroup> |
|
{:else if !(publicationType === "blog" && depth === 1)} |
|
{@render sectionHeading(title!, depth)} |
|
{/if} |
|
<!-- Recurse on child indices and zettels --> |
|
{#if publicationType === "blog" && depth === 1} |
|
<BlogHeader |
|
event={getBlogEvent(index)} |
|
{rootId} |
|
onBlogUpdate={readBlog} |
|
active={true} |
|
/> |
|
{:else} |
|
{#key subtreeUpdateCount} |
|
{#each orderedChildren as id, index} |
|
<Self |
|
rootId={id} |
|
parentId={rootId} |
|
{index} |
|
{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} |
|
<div |
|
class={`flex flex-col space-y-2 justify-start ${buttonsVisible ? "visible" : "invisible"}`} |
|
> |
|
{#if hasPreviousSibling && parentId} |
|
<Button |
|
class="btn-leather" |
|
size="sm" |
|
outline |
|
onclick={() => moveUp(rootId, parentId)} |
|
> |
|
<CaretUpSolid /> |
|
</Button> |
|
{/if} |
|
{#if hasNextSibling && parentId} |
|
<Button |
|
class="btn-leather" |
|
size="sm" |
|
outline |
|
onclick={() => moveDown(rootId, parentId)} |
|
> |
|
<CaretDownSolid /> |
|
</Button> |
|
{/if} |
|
<Button |
|
class="btn-leather" |
|
size="sm" |
|
outline |
|
onclick={() => toggleEditing(rootId)} |
|
> |
|
<EditOutline /> |
|
</Button> |
|
<Tooltip class="tooltip-leather" type="auto" placement="top"> |
|
Edit |
|
</Tooltip> |
|
</div> |
|
{/if} |
|
</section>
|
|
|