Move the article composer to a full page

This commit is contained in:
userAdityaa 2026-08-19 02:48:10 +05:30 committed by Jon Staab
parent 784072229b
commit 89da2ac96a
23 changed files with 895 additions and 352 deletions

10
src/app/articles.ts Normal file
View file

@ -0,0 +1,10 @@
const WORDS_PER_MINUTE = 200
// Articles are markdown, so this counts syntax as words too. That's close enough for an estimate,
// and cheaper than parsing the content a second time just to strip it.
export const displayReadingTime = (content: string) => {
const words = content.trim().split(/\s+/).filter(Boolean).length
const minutes = Math.max(1, Math.round(words / WORDS_PER_MINUTE))
return `${minutes} minute${minutes === 1 ? "" : "s"}`
}

View file

@ -11,7 +11,9 @@
import ThunkStatusOrDeleted from "@app/components/ThunkStatusOrDeleted.svelte"
import EventActivity from "@app/components/EventActivity.svelte"
import EventActions from "@app/components/EventActions.svelte"
import EventActionBar from "@app/components/EventActionBar.svelte"
import {reader} from "@app/core"
import {deriveIsDeleted} from "@app/repository"
import {makeArticlePath, makeSpacePath} from "@app/routes"
type Props = {
@ -20,44 +22,74 @@
showRoom?: boolean
showActivity?: boolean
context: FeedContext
// The article's own page, where this is the primary action bar rather than a card footer.
detail?: boolean
}
const {url, event, showRoom, showActivity, context}: Props = $props()
const {url, event, showRoom, showActivity, context, detail}: Props = $props()
const article = $derived(reader(Article)(event))
const h = $derived(article.room())
const topics = $derived(article.topics())
const path = $derived(makeArticlePath(url, getAddress(event)))
const deleted = $derived(deriveIsDeleted(event))
const deleteReaction = (reaction: TrustedEvent) => retractReaction(reaction, {url, h})
const createReaction = (values: EventContent) => publishReaction(event, values, {url, h})
</script>
<div class="flex grow flex-wrap justify-end gap-2">
{#if h && showRoom}
<Link href={makeSpacePath(url, h)} class="button button-neutral button-xs rounded-full">
Posted in #<RoomName {h} {url} />
</Link>
{/if}
<div class="flex min-w-0 flex-wrap gap-2">
{#each uniq(topics) as topic (topic)}
<button type="button" class="button button-xs rounded-full font-normal">
#{topic}
</button>
{/each}
</div>
<ThunkStatusOrDeleted {event} {context}>
<ReactionSummary
{url}
{event}
{context}
{deleteReaction}
{createReaction}
reactionClass="tip-left" />
{#if showActivity}
<EventActivity {path} {event} {context} />
{#if detail}
<div class="flex w-full min-w-0 flex-col gap-3">
<ThunkStatusOrDeleted {event} {context}>
<ReactionSummary
{url}
{event}
{context}
{deleteReaction}
{createReaction}
reactionClass="tip-top" />
</ThunkStatusOrDeleted>
{#if !$deleted}
<EventActionBar {url} {event} noun="Article">
{#snippet leading()}
{#if showActivity}
<EventActivity {path} {event} {context} size="sm" hideLastActive />
{/if}
{/snippet}
</EventActionBar>
{/if}
<EventActions {url} {event} noun="Article" />
</ThunkStatusOrDeleted>
</div>
</div>
{:else}
<div class="flex grow flex-wrap items-center justify-end gap-2">
<div class="flex min-w-0 flex-wrap items-center gap-2">
{#if h && showRoom}
<Link href={makeSpacePath(url, h)} class="button button-neutral button-xs rounded-full">
Posted in #<RoomName {h} {url} />
</Link>
{/if}
<div class="flex min-w-0 flex-wrap gap-2">
{#each uniq(topics) as topic (topic)}
<button type="button" class="button button-xs rounded-full font-normal">
#{topic}
</button>
{/each}
</div>
<ThunkStatusOrDeleted {event} {context}>
<ReactionSummary
{url}
{event}
{context}
{deleteReaction}
{createReaction}
reactionClass="tip-left" />
{#if showActivity}
<EventActivity {path} {event} {context} />
{/if}
</ThunkStatusOrDeleted>
</div>
{#if !$deleted}
<EventActions {url} {event} noun="Article" />
{/if}
</div>
{/if}

View file

@ -0,0 +1,91 @@
<script lang="ts">
import {onMount} from "svelte"
import {sortBy} from "@welshman/lib"
import {formatTimestamp} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import {LONG_FORM, getAddress} from "@welshman/util"
import {Article} from "@welshman/domain"
import Link from "@lib/components/Link.svelte"
import Profile from "@app/components/Profile.svelte"
import ProfileName from "@app/components/ProfileName.svelte"
import ProfileAbout from "@app/components/ProfileAbout.svelte"
import {network, reader} from "@app/core"
import {deriveEventsForUrl} from "@app/repository"
import {displayReadingTime} from "@app/articles"
import {makeArticlePath} from "@app/routes"
type Props = {
url: string
event: TrustedEvent
}
const {url, event}: Props = $props()
const MAX_ARTICLES = 6
const filters = [{kinds: [LONG_FORM], authors: [event.pubkey], limit: 20}]
const events = deriveEventsForUrl(url, filters)
const address = getAddress(event)
const articleCount = $derived($events.length)
const others = $derived(
sortBy(
e => -reader(Article)(e).publishedAt(),
$events.filter(e => getAddress(e) !== address),
).slice(0, MAX_ARTICLES),
)
onMount(() => {
const controller = new AbortController()
$network.request({relays: [url], filters, signal: controller.signal})
return () => controller.abort()
})
</script>
<aside
class="bg-surface flex w-full shrink-0 flex-col border-t lg:w-80 lg:border-t-0"
style="border-color: var(--line)">
<div class="flex flex-col lg:sticky lg:top-0">
<section class="flex flex-col gap-3 border-b px-4 py-4" style="border-color: var(--line)">
<Profile pubkey={event.pubkey} {url} />
<div class="text-content-muted line-clamp-3 text-sm leading-snug">
<ProfileAbout pubkey={event.pubkey} {url} />
</div>
<p class="text-content-subtle text-xs font-bold tracking-wide uppercase">
{articleCount}
{articleCount === 1 ? "article" : "articles"} in this space
</p>
</section>
{#if others.length > 0}
<section class="flex flex-col gap-2 px-4 py-4">
<h2 class="text-sm font-bold tracking-wide uppercase opacity-60">
More from <ProfileName pubkey={event.pubkey} {url} />
</h2>
<div class="flex flex-col">
{#each others as other (getAddress(other))}
{@const article = reader(Article)(other)}
<Link
href={makeArticlePath(url, getAddress(other))}
class="hover:bg-surface-less -mx-2 flex items-start gap-3 rounded-xl px-2 py-2 transition-colors">
<div class="flex min-w-0 grow flex-col gap-1">
<p class="line-clamp-2 text-sm font-bold">{article.title() || "Untitled"}</p>
<p class="text-xs opacity-75">
{formatTimestamp(article.publishedAt())} · {displayReadingTime(other.content)}
</p>
</div>
{#if article.image()}
<img
src={article.image()}
alt=""
class="h-12 w-16 shrink-0 rounded-lg object-cover" />
{/if}
</Link>
{/each}
</div>
</section>
{/if}
</div>
</aside>

View file

@ -1,215 +0,0 @@
<script lang="ts">
import {writable} from "svelte/store"
import {now, randomId} from "@welshman/lib"
import {Article} from "@welshman/domain"
import {publish} from "@welshman/app"
import {isMobile, preventDefault} from "@lib/html"
import Paperclip from "@assets/icons/paperclip-2.svg?dataurl"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Field from "@lib/components/Field.svelte"
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import TopicMultiSelect from "@app/components/TopicMultiSelect.svelte"
import EditorContent from "@app/editor/EditorContent.svelte"
import {command, relays, writer} from "@app/core"
import {DraftKey} from "@app/drafts"
import {makeEditor} from "@app/editor"
import {publishRoomQuote} from "@app/rooms"
import {pushToast} from "@app/toast"
type Values = {
d?: string
content?: string | object
title?: string
summary?: string
topics?: string[]
}
type Props = {
url: string
h?: string
shareToChat?: boolean
}
const {url, h, shareToChat = false}: Props = $props()
const draftKey = new DraftKey<Values>(`article:${url}:${h ?? ""}`)
const initialValues = draftKey.get()
const shouldProtect = $relays.hasNip(url, 70)
const uploading = writable(false)
const back = () => history.back()
const selectFiles = () => editor.then(ed => ed.commands.selectFiles())
const submit = async () => {
if ($uploading || loading) return
if (!title) {
return pushToast({
theme: "error",
message: "Please provide a title for your article.",
})
}
const ed = await editor
const content = ed.getText({blockSeparator: "\n"}).trim()
if (!content) {
return pushToast({
theme: "error",
message: "Please write something for your article.",
})
}
loading = true
try {
const protect = await shouldProtect
const eventWriter = writer(Article)
.setIdentifier(d)
.setContent(content)
.setTitle(title)
.setTopics(topics)
.setPublishedAt(now())
.setProtected(protect)
.addTags(...ed.storage.nostr.getEditorTags())
.forceRelays(url)
if (summary) {
eventWriter.setSummary(summary)
}
if (h) {
eventWriter.setRoom(url, h)
}
const thunk = await command(eventWriter).then(publish)
const error = await thunk.waitForError()
if (error) {
return pushToast({theme: "error", message: error})
}
draftKey.clear()
history.back()
if (shareToChat) {
publishRoomQuote({url, h, parent: thunk.event, protect})
}
} finally {
loading = false
}
}
let loading = $state(false)
const d = $state(initialValues?.d ?? randomId())
let title = $state(initialValues?.title ?? "")
let summary = $state(initialValues?.summary ?? "")
let topics = $state(initialValues?.topics ?? [])
let content = $state(initialValues?.content ?? "")
const onChange = (json: object) => {
content = json
}
const editor = makeEditor({
url,
submit,
uploading,
onChange,
placeholder: "Write your article...",
content,
})
$effect(() => {
draftKey.update({d, title, summary, topics, content})
})
</script>
<Modal tag="form" onsubmit={preventDefault(submit)}>
<ModalBody>
<ModalHeader>
<ModalTitle>Write an Article</ModalTitle>
<ModalSubtitle>Publish a long-form post to this space.</ModalSubtitle>
</ModalHeader>
<div class="flex flex-col gap-8 relative">
<Field>
{#snippet label()}
<p>Title*</p>
{/snippet}
{#snippet input()}
<label class="input flex w-full items-center gap-2">
<!-- svelte-ignore a11y_autofocus -->
<input
autofocus={!isMobile}
bind:value={title}
class="grow"
type="text"
placeholder="What is this article about?" />
</label>
{/snippet}
</Field>
<Field>
{#snippet label()}
<p>Summary</p>
{/snippet}
{#snippet input()}
<label class="input flex w-full items-center gap-2">
<input
bind:value={summary}
class="grow"
type="text"
placeholder="A one line teaser for the article list" />
</label>
{/snippet}
</Field>
<Field>
{#snippet label()}
<p>Topics</p>
{/snippet}
{#snippet input()}
<TopicMultiSelect bind:value={topics} />
{/snippet}
</Field>
<Field>
{#snippet label()}
<p>Content*</p>
{/snippet}
{#snippet input()}
<div class="note-editor grow overflow-hidden">
<EditorContent {editor} />
</div>
{/snippet}
</Field>
<Button
data-tip="Add an image"
class="tip tip-left absolute bottom-1 right-2"
onclick={selectFiles}
disabled={loading}>
{#if $uploading}
<Spinner size="xs" />
{:else}
<Icon icon={Paperclip} size={3} />
{/if}
</Button>
</div>
</ModalBody>
<ModalFooter>
<Button class="button button-link" onclick={back} disabled={loading}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button type="submit" class="button button-primary" disabled={$uploading || loading}>
<Spinner {loading}>Publish Article</Spinner>
</Button>
</ModalFooter>
</Modal>

View file

@ -89,7 +89,9 @@
})
</script>
<form onsubmit={preventDefault(submit)} class="card card-sm flex flex-col gap-2">
<form
onsubmit={preventDefault(submit)}
class="border-line flex flex-col gap-2 rounded-2xl border border-solid p-3">
<div class="relative">
<div class="note-editor grow overflow-hidden">
<EditorContent autofocus={!isMobile} {editor} />

View file

@ -1,14 +1,20 @@
<script lang="ts">
import {formatTimestamp} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import Reply from "@assets/icons/reply-2.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import CommentTree from "@app/components/CommentTree.svelte"
import CommentCompose from "@app/components/CommentCompose.svelte"
import NoteCard from "@app/components/NoteCard.svelte"
import NoteContent from "@app/components/NoteContent.svelte"
import ProfileCircle from "@app/components/ProfileCircle.svelte"
import ProfileName from "@app/components/ProfileName.svelte"
import ProfileDetail from "@app/components/ProfileDetail.svelte"
import Content from "@app/components/Content.svelte"
import CommentActions from "@app/components/CommentActions.svelte"
import type {FeedContext} from "@app/feeds"
import Danger from "@assets/icons/danger-triangle.svg?dataurl"
import {pushModal} from "@app/modal"
import {isEventMuted} from "@app/social"
import type {CommentNode} from "@app/social"
type Props = {
@ -27,25 +33,56 @@
const reply = () => setReplyTo(node.comment)
const clearReplyTo = () => setReplyTo(undefined)
const openProfile = () => pushModal(ProfileDetail, {pubkey: node.comment.pubkey, url})
const ignoreMute = () => {
muted = false
}
let muted = $state($isEventMuted(node.comment))
</script>
<div class="flex flex-col gap-3">
<NoteCard event={node.comment} {url} class="card z-feature w-full">
<div class="flex flex-col gap-3 ml-12">
<NoteContent showEntire event={node.comment} {url} />
{#if url}
<div class="flex flex-wrap items-center justify-between gap-2">
<Button class="button button-neutral button-sm" onclick={reply}>
<Icon icon={Reply} />
Reply
</Button>
<CommentActions event={node.comment} {url} {context} />
</div>
{/if}
<!-- Vertical rhythm lives on the individual blocks rather than the wrapper, so nesting
doesn't compound padding at every level. -->
<div class="flex flex-col">
{#if muted}
<div class="flex flex-wrap items-center justify-between gap-2 py-3">
<div class="text-content-muted flex items-center gap-2 text-sm">
<Icon icon={Danger} size={4} />
<p>You have muted this person.</p>
</div>
<Button class="button button-neutral button-xs" onclick={ignoreMute}>Show anyway</Button>
</div>
</NoteCard>
{:else}
<div class="flex min-w-0 gap-3 py-3">
<Button onclick={openProfile} class="shrink-0 self-start">
<ProfileCircle pubkey={node.comment.pubkey} {url} size={8} />
</Button>
<div class="flex min-w-0 grow flex-col gap-1.5">
<div class="flex flex-wrap items-baseline gap-x-2">
<Button onclick={openProfile} class="text-sm font-bold hover:underline">
<ProfileName pubkey={node.comment.pubkey} {url} />
</Button>
<span class="text-content-subtle text-xs">
{formatTimestamp(node.comment.created_at)}
</span>
</div>
<Content showEntire event={node.comment} {url} />
{#if url}
<div class="mt-1 flex flex-wrap items-center justify-between gap-2">
<Button class="button button-neutral button-xs" onclick={reply}>
<Icon icon={Reply} size={4} />
Reply
</Button>
<CommentActions event={node.comment} {url} {context} />
</div>
{/if}
</div>
</div>
{/if}
{#if composing && url}
<div class="ml-4 pl-4">
<div class="ml-11 pb-3">
<CommentCompose
{url}
event={root}
@ -55,7 +92,10 @@
</div>
{/if}
{#if node.children.length > 0}
<div class="flex flex-col gap-3 border-l border-solid border-line ml-4 pl-4">
<!-- The thread line runs under the avatar's center and indents replies to line up with
this comment's text column; it brightens while the subtree is hovered. -->
<div
class="border-line-less hover:border-line ml-4 flex flex-col border-l pl-7 transition-colors">
{#each node.children as child (child.comment.id)}
<CommentTree node={child} {root} {replyTo} {setReplyTo} {url} {context} />
{/each}

View file

@ -1,5 +1,6 @@
<script lang="ts">
import {onMount} from "svelte"
import {goto} from "$app/navigation"
import CalendarMinimalistic from "@assets/icons/calendar-minimalistic.svg?dataurl"
import StarFallMinimalistic from "@assets/icons/star-fall-minimalistic.svg?dataurl"
import NotesMinimalistic from "@assets/icons/notes-minimalistic.svg?dataurl"
@ -9,9 +10,9 @@
import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte"
import {pushModal} from "@app/modal"
import {makeArticleCreatePath} from "@app/routes"
import CalendarEventCreate from "@app/components/CalendarEventCreate.svelte"
import ThreadCreate from "@app/components/ThreadCreate.svelte"
import ArticleCreate from "@app/components/ArticleCreate.svelte"
import ClassifiedCreate from "@app/components/ClassifiedCreate.svelte"
import GoalCreate from "@app/components/GoalCreate.svelte"
import PollCreate from "@app/components/PollCreate.svelte"
@ -34,7 +35,7 @@
const createPoll = () => pushModal(PollCreate, {url, h, shareToChat: true})
const createArticle = () => pushModal(ArticleCreate, {url, h, shareToChat: true})
const createArticle = () => goto(makeArticleCreatePath(url, {h, shareToChat: true}))
let ul: Element

View file

@ -64,9 +64,10 @@
type Props = {
event: TrustedEvent
url?: string
class?: string
}
const {event, url}: Props = $props()
const {event, url, class: className}: Props = $props()
const entityPattern = /(nostr:)?n(event|ote|pub|profile|addr)\w{10,1000}/g
@ -125,6 +126,6 @@
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div {onclick} class="content-markdown flex flex-col gap-4 overflow-hidden leading-6">
<div {onclick} class="content-markdown flex flex-col gap-4 overflow-hidden leading-6 {className}">
{@html html}
</div>

View file

@ -0,0 +1,40 @@
<script lang="ts">
import type {Snippet} from "svelte"
import type {TrustedEvent} from "@welshman/util"
import {COMMENT} from "@welshman/util"
import ShareCircle from "@assets/icons/share-circle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import EventReactButtons from "@app/components/EventReactButtons.svelte"
import {shareEvent} from "@app/share"
type Props = {
url: string
noun: string
event: TrustedEvent
leading?: Snippet
}
const {url, noun, event, leading}: Props = $props()
const buttonClass = "button button-neutral button-sm rounded-full"
const isRoot = event.kind !== COMMENT
const share = () => shareEvent(url, noun, event)
</script>
<div class="flex w-full min-w-0 items-start justify-between gap-x-4">
<div class="flex min-w-0 grow flex-wrap items-center gap-2">
<EventReactButtons {url} {event} class="{buttonClass} tip tip-top" />
{@render leading?.()}
</div>
<div class="flex shrink-0 items-center gap-2">
{#if isRoot}
<Button aria-label="Share to Chat" class={buttonClass} onclick={share}>
<Icon icon={ShareCircle} size={4} />
Share
</Button>
{/if}
</div>
</div>

View file

@ -1,21 +1,14 @@
<script lang="ts">
import type {Snippet} from "svelte"
import type {Maybe} from "@welshman/lib"
import type {NativeEmoji} from "emoji-picker-element/shared"
import type {TrustedEvent} from "@welshman/util"
import {tagSpec, tagValue} from "@welshman/util"
import Bolt from "@assets/icons/bolt.svg?dataurl"
import SmileCircle from "@assets/icons/smile-circle.svg?dataurl"
import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Tippy from "@lib/components/Tippy.svelte"
import type {TippyController} from "@lib/components/Tippy.svelte"
import Button from "@lib/components/Button.svelte"
import EmojiButton from "@lib/components/EmojiButton.svelte"
import ZapButton from "@app/components/ZapButton.svelte"
import EventMenu from "@app/components/EventMenu.svelte"
import {reactions, relays} from "@app/core"
import {ENABLE_ZAPS} from "@app/env"
import EventReactButtons from "@app/components/EventReactButtons.svelte"
type Props = {
url: string
@ -27,39 +20,17 @@
const {url, noun, event, hideZap, customActions}: Props = $props()
const shouldProtect = $relays.hasNip(url, 70)
const showPopover = () => tippy?.show()
const hidePopover = () => tippy?.hide()
const onEmoji = async (emoji: NativeEmoji) => {
const protect = await shouldProtect
const room = tagValue(tagSpec("h"), event.tags)
const command = await $reactions.react(event, emoji.unicode, writer => {
writer.setProtected(protect)
if (room) {
writer.setRoom(url, room)
}
})
return command.publishToRelays([url])
}
let tippy: Maybe<TippyController> = $state()
</script>
<!-- The compact segmented control a card footer gets. EventActionBar is the full-width
version a detail page gets. -->
<div class="items-center join">
{#if ENABLE_ZAPS && !hideZap}
<ZapButton {url} {event} class="button button-neutral button-xs join-item">
<Icon icon={Bolt} size={4} />
</ZapButton>
{/if}
<EmojiButton {onEmoji} class="button button-neutral button-xs join-item">
<Icon icon={SmileCircle} size={4} />
</EmojiButton>
<EventReactButtons {url} {event} {hideZap} class="button button-neutral button-xs join-item" />
<Button onclick={showPopover} class="flex join-item button button-neutral button-xs">
<Tippy
bind:controller={tippy}

View file

@ -8,7 +8,22 @@
import type {FeedContext} from "@app/feeds"
import {deriveChecked} from "@app/notifications"
const {path, event, context}: {path: string; event: TrustedEvent; context: FeedContext} = $props()
// hideLastActive: redundant next to a detail page's own comments below.
const {
path,
event,
context,
size = "xs",
hideLastActive,
}: {
path: string
event: TrustedEvent
context: FeedContext
size?: "xs" | "sm"
hideLastActive?: boolean
} = $props()
const buttonClass = `button button-neutral button-${size} rounded-full`
const checked = deriveChecked(path)
const related = context.related(event)
@ -16,13 +31,15 @@
const lastActive = $derived(max([...$replies, event].map(e => e.created_at)))
</script>
<div class="flex-inline button button-neutral button-xs gap-1 rounded-full">
<div class="flex-inline {buttonClass} gap-1">
<Icon icon={Reply} />
<span>{$replies.length} {$replies.length === 1 ? "reply" : "replies"}</span>
</div>
<div class="button button-neutral button-xs relative rounded-full">
{#if gt(lastActive, $checked)}
<div class="h-2 w-2 rounded-full bg-primary text-primary-content"></div>
{/if}
Active {formatTimestampRelative(lastActive)}
</div>
{#if !hideLastActive}
<div class="{buttonClass} relative">
{#if gt(lastActive, $checked)}
<div class="h-2 w-2 rounded-full bg-primary text-primary-content"></div>
{/if}
Active {formatTimestampRelative(lastActive)}
</div>
{/if}

View file

@ -49,20 +49,37 @@
})
</script>
<div class="flex flex-col gap-3">
{#each nodes as node (node.comment.id)}
<CommentTree {node} root={event} {replyTo} {setReplyTo} {url} {context} />
{/each}
</div>
{#if url}
{#if replyTo?.id === event.id}
<CommentCompose {url} {event} onCancel={clearReplyTo} onSubmit={clearReplyTo} />
{:else}
<div class="flex justify-end">
<Button class="button button-primary" onclick={commentOnRoot}>
<Icon icon={Reply} />
Add a comment
</Button>
<section class="flex flex-col">
<!-- The heading rule is a page-level divider, so it runs full width; the comments below it
stay in the article's reading column. -->
<div class="border-b px-5 sm:px-8" style="border-color: var(--line)">
<div class="mx-auto flex w-full max-w-[68ch] flex-wrap items-center justify-between gap-2 py-3">
<h2 class="text-lg font-bold">
{nodes.length === 0
? "Discussion about this post"
: `${$comments.length} ${$comments.length === 1 ? "comment" : "comments"}`}
</h2>
{#if url && replyTo?.id !== event.id}
<Button class="button button-primary button-sm" onclick={commentOnRoot}>
<Icon icon={Reply} size={4} />
Add a comment
</Button>
{/if}
</div>
{/if}
{/if}
</div>
<div class="px-5 sm:px-8">
<div class="mx-auto flex w-full max-w-[68ch] flex-col pb-10">
{#if url && replyTo?.id === event.id}
<div class="py-4">
<CommentCompose {url} {event} onCancel={clearReplyTo} onSubmit={clearReplyTo} />
</div>
{/if}
{#each nodes as node (node.comment.id)}
<CommentTree {node} root={event} {replyTo} {setReplyTo} {url} {context} />
{/each}
{#if nodes.length === 0 && replyTo?.id !== event.id}
<p class="py-6 text-sm opacity-60">No comments yet.</p>
{/if}
</div>
</div>
</section>

View file

@ -0,0 +1,35 @@
<script lang="ts">
import type {NativeEmoji} from "emoji-picker-element/shared"
import type {TrustedEvent} from "@welshman/util"
import {tagSpec, tagValue} from "@welshman/util"
import Bolt from "@assets/icons/bolt.svg?dataurl"
import SmileCircle from "@assets/icons/smile-circle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import EmojiButton from "@lib/components/EmojiButton.svelte"
import ZapButton from "@app/components/ZapButton.svelte"
import {publishReaction} from "@app/reactions"
import {ENABLE_ZAPS} from "@app/env"
type Props = {
url: string
event: TrustedEvent
hideZap?: boolean
class?: string
}
const {url, event, hideZap, class: className = ""}: Props = $props()
const h = tagValue(tagSpec("h"), event.tags)
const onEmoji = (emoji: NativeEmoji) =>
publishReaction(event, {content: emoji.unicode, tags: []}, {url, h})
</script>
{#if ENABLE_ZAPS && !hideZap}
<ZapButton {url} {event} aria-label="Send a zap" data-tip="Zap" class={className}>
<Icon icon={Bolt} size={4} />
</ZapButton>
{/if}
<EmojiButton {onEmoji} aria-label="Add a reaction" data-tip="React" class={className}>
<Icon icon={SmileCircle} size={4} />
</EmojiButton>

View file

@ -0,0 +1,47 @@
<script lang="ts">
// The scroll lives on the page's content element rather than the window, so it's handed in.
type Props = {
element?: Element
}
const {element}: Props = $props()
let progress = $state(0)
$effect(() => {
if (!element) return
const update = () => {
const {scrollTop, scrollHeight, clientHeight} = element as HTMLElement
const scrollable = scrollHeight - clientHeight
// A non-scrollable page reads as zero rather than a misleading full bar.
progress = scrollable > 0 ? Math.min(1, Math.max(0, scrollTop / scrollable)) : 0
}
update()
element.addEventListener("scroll", update, {passive: true})
// Content (article, then comments) keeps arriving after mount, resizing the scroll range.
const observer = new ResizeObserver(update)
observer.observe(element)
for (const child of element.children) {
observer.observe(child)
}
return () => {
element.removeEventListener("scroll", update)
observer.disconnect()
}
})
</script>
<div class="bg-line relative h-0.5 w-full shrink-0" aria-hidden="true">
<div
class="bg-primary h-full origin-left transition-transform duration-150 ease-out"
style="transform: scaleX({progress})">
</div>
</div>

View file

@ -19,6 +19,8 @@
children: Snippet
replaceState?: boolean
class?: string
"data-tip"?: string
"aria-label"?: string
}
const {url, event, children, replaceState, ...props}: Props = $props()

View file

@ -134,6 +134,26 @@ export const makeClassifiedPath = (url: string, address?: string) =>
export const makeArticlePath = (url: string, address?: string) =>
makeSpacePath(url, "articles", address)
// shareToChat is separate from h because the space-level chat has no room to scope an article to
export const makeArticleCreatePath = (
url: string,
{h, shareToChat}: {h?: string; shareToChat?: boolean} = {},
) => {
const params = new URLSearchParams()
if (h) {
params.set("h", h)
}
if (shareToChat) {
params.set("shareToChat", "1")
}
const query = params.toString()
return makeSpacePath(url, "articles", "create") + (query ? "?" + query : "")
}
export const makeCalendarPath = (url: string, address?: string) =>
makeSpacePath(url, "calendar", address)

View file

@ -20,6 +20,7 @@ const staticTitles = new Map<string, string>([
["/spaces/[relay]/threads", "Threads"],
["/spaces/[relay]/classifieds", "Classifieds"],
["/spaces/[relay]/articles", "Articles"],
["/spaces/[relay]/articles/create", "Write an Article"],
["/spaces/[relay]/calendar", "Calendar"],
["/spaces/[relay]/goals", "Goals"],
["/spaces/[relay]/polls", "Polls"],

View file

@ -7,12 +7,12 @@
import type {TippyController} from "@lib/components/Tippy.svelte"
import EmojiPicker from "@lib/components/EmojiPicker.svelte"
const {tippyParams = {}, ...props} = $props()
const {tippyParams = {}, onEmoji, class: className, children, ...restProps} = $props()
const open = () => tippy?.show()
const onClick = (emoji: NativeEmoji) => {
props.onEmoji(emoji)
onEmoji(emoji)
tippy?.hide()
}
@ -33,13 +33,13 @@
<svelte:document onmousemove={tippy?.visible ? onMouseMove : undefined} />
<Button onclick={open} class={props.class}>
<Button onclick={open} class={className} {...restProps}>
<Tippy
bind:controller={tippy}
class="flex"
component={EmojiPicker}
props={{onClick}}
params={{trigger: "manual", interactive: true, ...tippyParams}}>
{@render props.children?.()}
{@render children?.()}
</Tippy>
</Button>

View file

@ -276,6 +276,14 @@ body.keyboard-open .room {
padding: 0.65rem;
padding-bottom: 1.5rem;
}
/* the full-page article composer gets a taller writing surface than the note editor's default */
.article-editor .tiptap {
@apply min-h-96;
max-height: none;
}
.article-editor .tiptap p.is-editor-empty:first-child::before {
@apply text-content-muted opacity-100;
}
.input-editor .tiptap {
--tiptap-object-bg: var(--surface);
@apply bg-surface block h-auto rounded-2xl;

View file

@ -69,4 +69,73 @@
@apply border-line border border-solid px-3 py-2 text-left;
}
}
/* A note is skimmed, an article is read: larger type, looser leading. */
.content-markdown--article {
font-size: 1.075rem;
line-height: 1.75;
gap: 1.35em;
h1 {
@apply text-3xl;
}
h2 {
@apply text-2xl;
}
h3 {
@apply text-xl;
}
h1,
h2,
h3,
h4 {
@apply leading-tight;
margin-top: 0.75em;
}
/* nothing to separate from at the very top */
:is(h1, h2, h3, h4):first-child {
margin-top: 0;
}
/* hanging bullets keep the text block flush with the paragraphs around it */
ul,
ol {
@apply flex list-outside flex-col gap-2;
padding-left: 1.3em;
}
li {
@apply my-0;
}
/* Pull-quote: resets the base rule's left border/indent for a centered line. */
blockquote {
@apply text-content border-line my-2 border-0 border-y py-6 pl-0 text-xl leading-snug font-bold text-balance;
p {
@apply m-0;
}
}
img {
@apply w-full;
}
pre code {
@apply p-4 text-sm leading-relaxed;
}
hr {
@apply my-4;
}
}
/* Only when there's no summary/deck above it — the opening line stands in for one instead. */
.content-markdown--lead > p:first-child {
@apply text-content-muted text-xl leading-snug font-normal;
}
}

View file

@ -3,6 +3,7 @@
import {readable} from "svelte/store"
import type {Readable} from "svelte/store"
import {page} from "$app/stores"
import {goto} from "$app/navigation"
import {sortBy, partition, spec, max, pushToMapKey} from "@welshman/lib"
import type {Maybe} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
@ -17,13 +18,12 @@
import Spinner from "@lib/components/Spinner.svelte"
import SpaceBar from "@app/components/SpaceBar.svelte"
import ArticleItem from "@app/components/ArticleItem.svelte"
import ArticleCreate from "@app/components/ArticleCreate.svelte"
import ArticleSidebar from "@app/components/ArticleSidebar.svelte"
import {reader} from "@app/core"
import {decodeRelay} from "@app/relays"
import {makeCommentFilter} from "@app/content"
import {isFeedLoading, makeFeed, makeFeedContext, makeScrollLoader} from "@app/feeds"
import {pushModal} from "@app/modal"
import {makeArticleCreatePath} from "@app/routes"
const url = decodeRelay($page.params.relay!)
const context = makeFeedContext({relays: [url]})
@ -39,7 +39,7 @@
let element: HTMLElement | undefined = $state()
let events: Readable<TrustedEvent[]> = $state(readable([]))
const createArticle = () => pushModal(ArticleCreate, {url})
const createArticle = () => goto(makeArticleCreatePath(url))
const articles = $derived.by(() => {
const scores = new Map<string, number[]>()

View file

@ -2,61 +2,139 @@
import {onDestroy} from "svelte"
import {derived} from "svelte/store"
import {page} from "$app/stores"
import {formatTimestamp, sleep} from "@welshman/lib"
import {formatTimestamp, sleep, uniq} from "@welshman/lib"
import type {MakeNonOptional} from "@welshman/lib"
import {Article} from "@welshman/domain"
import Link from "@lib/components/Link.svelte"
import PageContent from "@lib/components/PageContent.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import MenuButton from "@lib/components/MenuButton.svelte"
import SpaceBar from "@app/components/SpaceBar.svelte"
import ContentMarkdown from "@app/components/ContentMarkdown.svelte"
import NoteCard from "@app/components/NoteCard.svelte"
import Profile from "@app/components/Profile.svelte"
import RoomName from "@app/components/RoomName.svelte"
import ArticleActions from "@app/components/ArticleActions.svelte"
import ArticleAuthorSidebar from "@app/components/ArticleAuthorSidebar.svelte"
import EventComments from "@app/components/EventComments.svelte"
import EventMenu from "@app/components/EventMenu.svelte"
import ReadingProgress from "@app/components/ReadingProgress.svelte"
import {reader} from "@app/core"
import {deriveEvent} from "@app/repository"
import {makeFeedContext} from "@app/feeds"
import {decodeRelay} from "@app/relays"
import {displayReadingTime} from "@app/articles"
import {makeSpacePath} from "@app/routes"
const {relay, address} = $page.params as MakeNonOptional<typeof $page.params>
const url = decodeRelay(relay)
const context = makeFeedContext({relays: [url]})
const event = deriveEvent(address, [url])
const article = derived(event, $event => ($event ? reader(Article)($event) : undefined))
const h = $derived($article?.room())
const topics = $derived(uniq($article?.topics() ?? []))
const back = () => history.back()
let contentElement = $state<Element>()
onDestroy(context.cleanup)
</script>
<SpaceBar {back}>
<SpaceBar {back} class="!h-auto min-h-20 py-3">
{#snippet title()}
<h1 class="truncate text-xl">{$article?.title() ?? ""}</h1>
<div class="flex min-w-0 flex-col gap-0.5">
<h1 class="truncate min-w-0 font-bold sm:text-xl">{$article?.title() ?? ""}</h1>
{#if $article && h}
<p class="text-xs opacity-75">
<Link href={makeSpacePath(url, h)} class="link">#<RoomName {url} {h} /></Link>
</p>
{/if}
</div>
{/snippet}
{#snippet action()}
{#if $event}
<MenuButton
component={EventMenu}
componentProps={{url, noun: "Article", event: $event}}
aria-label="Article options" />
{/if}
{/snippet}
</SpaceBar>
<PageContent class="flex flex-col gap-2 p-2 sm:gap-4 sm:p-4">
<ReadingProgress element={contentElement} />
<PageContent noPad bind:element={contentElement} class="bg-surface flex flex-col !gap-0">
{#if $event && $article}
<NoteCard event={$event} {url} class="card z-feature w-full">
<div class="flex flex-col gap-3 ml-12">
{#if $article.image()}
<img src={$article.image()} alt="" class="h-56 w-full rounded-2xl object-cover" />
{/if}
<div class="flex flex-col gap-1">
<h2 class="text-2xl">{$article.title() ?? "Untitled"}</h2>
<p class="text-sm opacity-75">
Published {formatTimestamp($article.publishedAt())}
</p>
<!-- PageContent is the scroll container and has a definite height, so a flex row placed
directly in it would size its columns to one viewport. This wrapper is sized by its
content instead, which lets the column/sidebar divider run the whole way down. -->
<div class="flex min-h-full shrink-0 flex-col lg:flex-row">
<div class="flex min-w-0 grow flex-col lg:border-r" style="border-color: var(--line)">
<article class="flex flex-col">
<div class="flex flex-col px-5 sm:px-8">
<header class="mx-auto flex w-full max-w-[68ch] flex-col gap-4 pt-8 sm:pt-12">
<h2 class="text-3xl leading-[1.15] font-bold text-balance sm:text-5xl">
{$article.title() || "Untitled"}
</h2>
{#if $article.summary()}
<p class="text-content-muted text-lg leading-snug text-pretty sm:text-xl">
{$article.summary()}
</p>
{/if}
<div class="flex flex-wrap items-center gap-x-4 gap-y-2 pt-2">
<Profile pubkey={$event.pubkey} {url} />
<span class="text-content-subtle text-sm">
{formatTimestamp($article.publishedAt())} · {displayReadingTime($event.content)}
</span>
</div>
</header>
{#if $article.image()}
<img
src={$article.image()}
alt=""
class="mx-auto mt-8 max-h-[24rem] w-full max-w-[76ch] rounded-2xl object-cover" />
{/if}
<div class="mx-auto flex w-full max-w-[68ch] flex-col gap-6 py-8">
<ContentMarkdown
event={$event}
{url}
class="content-markdown--article {$article.summary()
? ''
: 'content-markdown--lead'}" />
</div>
</div>
{#if topics.length > 0}
<div class="border-t px-5 py-6 sm:px-8" style="border-color: var(--line)">
<div class="mx-auto flex w-full max-w-[68ch] flex-wrap gap-2">
{#each topics as topic (topic)}
<span class="button button-neutral button-xs rounded-full font-normal">
#{topic}
</span>
{/each}
</div>
</div>
{/if}
</article>
<div class="border-t px-5 py-4 sm:px-8" style="border-color: var(--line)">
<div class="mx-auto flex w-full max-w-[68ch]">
<ArticleActions event={$event} {url} {context} showActivity detail />
</div>
</div>
<!-- lg carries this in the sidebar instead, which sits below the comments here. -->
<div class="border-t px-5 py-6 sm:px-8 lg:hidden" style="border-color: var(--line)">
<div class="mx-auto flex w-full max-w-[68ch] flex-col gap-2">
<p class="text-content-subtle text-xs font-bold tracking-wide uppercase">Written by</p>
<Profile pubkey={$event.pubkey} {url} />
</div>
</div>
<div class="border-t" style="border-color: var(--line)">
<EventComments event={$event} {url} {context} />
</div>
{#if $article.summary()}
<p class="text-lg opacity-75">{$article.summary()}</p>
{/if}
<ContentMarkdown event={$event} {url} />
<ArticleActions showRoom event={$event} {url} {context} />
</div>
</NoteCard>
<EventComments event={$event} {url} {context} />
<ArticleAuthorSidebar {url} event={$event} />
</div>
{:else}
<div class="flex justify-center py-20">
<div class="flex grow justify-center py-20">
{#await sleep(5000)}
<Spinner loading>Loading article...</Spinner>
{:then}

View file

@ -0,0 +1,276 @@
<script lang="ts">
import {onMount} from "svelte"
import {writable} from "svelte/store"
import {page} from "$app/stores"
import {goto} from "$app/navigation"
import {now, randomId} from "@welshman/lib"
import {getAddress} from "@welshman/util"
import {Article} from "@welshman/domain"
import {publish} from "@welshman/app"
import {isMobile} from "@lib/html"
import Paperclip from "@assets/icons/paperclip-2.svg?dataurl"
import DocumentText from "@assets/icons/document-text.svg?dataurl"
import GallerySend from "@assets/icons/gallery-send.svg?dataurl"
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Field from "@lib/components/Field.svelte"
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import PageContent from "@lib/components/PageContent.svelte"
import SpaceBar from "@app/components/SpaceBar.svelte"
import TopicMultiSelect from "@app/components/TopicMultiSelect.svelte"
import EditorContent from "@app/editor/EditorContent.svelte"
import {command, relays, writer} from "@app/core"
import {DraftKey} from "@app/drafts"
import {makeEditor} from "@app/editor"
import {decodeRelay} from "@app/relays"
import {publishRoomQuote} from "@app/rooms"
import {makeArticlePath} from "@app/routes"
import {compressFileForUpload, uploadFile} from "@app/uploads"
import {pushToast} from "@app/toast"
type Values = {
d?: string
content?: string | object
title?: string
summary?: string
image?: string
topics?: string[]
}
const url = decodeRelay($page.params.relay!)
const h = $page.url.searchParams.get("h") ?? undefined
const shareToChat = $page.url.searchParams.has("shareToChat")
const draftKey = new DraftKey<Values>(`article:${url}:${h ?? ""}`)
const initialValues = draftKey.get()
const shouldProtect = $relays.hasNip(url, 70)
const coverInputId = randomId()
const uploading = writable(false)
const back = () => goto(makeArticlePath(url))
const selectFiles = () => editor.then(ed => ed.commands.selectFiles())
const clearCover = () => {
image = ""
}
const onCoverChange = async (event: Event & {currentTarget: HTMLInputElement}) => {
const input = event.currentTarget
const file = input.files?.[0]
// Clear the input so picking the same file again after removing the cover still fires
input.value = ""
if (file) {
uploadingCover = true
try {
const {result, error} = await uploadFile(await compressFileForUpload(file), {url})
if (result?.url) {
image = result.url
} else {
pushToast({theme: "error", message: error ?? "Failed to upload your cover image."})
}
} catch (e) {
// compressFileForUpload rejects outside uploadFile's own error handling
console.error("Error caught when uploading cover image:", e)
pushToast({theme: "error", message: "Failed to upload your cover image."})
} finally {
uploadingCover = false
}
}
}
const submit = async () => {
if ($uploading || uploadingCover || loading) return
if (!title) {
return pushToast({
theme: "error",
message: "Please provide a title for your article.",
})
}
const ed = await editor
const content = ed.getText({blockSeparator: "\n"}).trim()
if (!content) {
return pushToast({
theme: "error",
message: "Please write something for your article.",
})
}
loading = true
try {
const protect = await shouldProtect
const eventWriter = writer(Article)
.setIdentifier(d)
.setContent(content)
.setTitle(title)
.setTopics(topics)
.setPublishedAt(now())
.setProtected(protect)
.addTags(...ed.storage.nostr.getEditorTags())
.forceRelays(url)
if (summary) {
eventWriter.setSummary(summary)
}
if (image) {
eventWriter.setImage(image)
}
if (h) {
eventWriter.setRoom(url, h)
}
const thunk = await command(eventWriter).then(publish)
const error = await thunk.waitForError()
if (error) {
return pushToast({theme: "error", message: error})
}
draftKey.clear()
goto(makeArticlePath(url, getAddress(thunk.event)))
if (shareToChat) {
publishRoomQuote({url, h, parent: thunk.event, protect})
}
} finally {
loading = false
}
}
let loading = $state(false)
let uploadingCover = $state(false)
let titleInput = $state<HTMLInputElement>()
// The autofocus attribute is ignored while the button that navigated here still holds focus
onMount(() => {
if (!isMobile) {
titleInput?.focus()
}
})
const d = $state(initialValues?.d ?? randomId())
let title = $state(initialValues?.title ?? "")
let summary = $state(initialValues?.summary ?? "")
let image = $state(initialValues?.image ?? "")
let topics = $state(initialValues?.topics ?? [])
let content = $state(initialValues?.content ?? "")
const onChange = (json: object) => {
content = json
}
const editor = makeEditor({
url,
submit,
uploading,
onChange,
placeholder: "Write your article...",
content,
})
$effect(() => {
draftKey.update({d, title, summary, image, topics, content})
})
</script>
<SpaceBar {back}>
{#snippet leading()}
<Icon icon={DocumentText} />
{/snippet}
{#snippet title()}
<strong>Write an Article</strong>
{/snippet}
{#snippet action()}
<Button
class="button button-primary button-sm"
onclick={submit}
disabled={$uploading || uploadingCover || loading}>
<Spinner {loading}>Publish</Spinner>
</Button>
{/snippet}
</SpaceBar>
<!-- column flex: justify-center would put the top of a tall page out of scroll reach -->
<PageContent class="items-center">
<div class="flex w-full max-w-3xl flex-col gap-6">
<input
id={coverInputId}
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
onchange={onCoverChange}
class="hidden" />
{#if image}
<div class="relative">
<img src={image} alt="Article cover" class="h-56 w-full rounded-2xl object-cover" />
<Button
data-tip="Remove cover image"
aria-label="Remove cover image"
class="tip tip-left button button-neutral button-sm button-circle absolute right-2 top-2"
onclick={clearCover}>
<Icon icon={CloseCircle} />
</Button>
</div>
{:else}
<label
for={coverInputId}
class="flex h-28 cursor-pointer items-center justify-center gap-2 rounded-2xl border border-dashed border-line text-content-muted transition-colors hover:text-content">
{#if uploadingCover}
<Spinner loading>Uploading cover image...</Spinner>
{:else}
<Icon icon={GallerySend} />
Add a cover image
{/if}
</label>
{/if}
<div class="flex flex-col gap-2">
<input
bind:this={titleInput}
bind:value={title}
class="bg-transparent text-3xl font-bold outline-none placeholder:text-content-muted"
type="text"
placeholder="Title" />
<input
bind:value={summary}
class="placeholder:text-content-muted bg-transparent text-lg outline-none"
type="text"
placeholder="Add a one line teaser for the article list" />
</div>
<div class="relative">
<div class="article-editor note-editor">
<EditorContent {editor} />
</div>
<Button
data-tip="Add an image"
class="tip tip-left absolute bottom-1 right-2"
onclick={selectFiles}
disabled={loading}>
{#if $uploading}
<Spinner size="xs" />
{:else}
<Icon icon={Paperclip} size={3} />
{/if}
</Button>
</div>
<Field>
{#snippet label()}
<p>Topics</p>
{/snippet}
{#snippet input()}
<TopicMultiSelect bind:value={topics} />
{/snippet}
</Field>
</div>
</PageContent>