From 89da2ac96aab5fe30c936aca50e325b7e9b7f159 Mon Sep 17 00:00:00 2001 From: userAdityaa Date: Wed, 19 Aug 2026 02:48:10 +0530 Subject: [PATCH] Move the article composer to a full page --- src/app/articles.ts | 10 + src/app/components/ArticleActions.svelte | 86 ++++-- .../components/ArticleAuthorSidebar.svelte | 91 ++++++ src/app/components/ArticleCreate.svelte | 215 -------------- src/app/components/CommentCompose.svelte | 4 +- src/app/components/CommentTree.svelte | 76 +++-- src/app/components/ComposeMenu.svelte | 5 +- src/app/components/ContentMarkdown.svelte | 5 +- src/app/components/EventActionBar.svelte | 40 +++ src/app/components/EventActions.svelte | 37 +-- src/app/components/EventActivity.svelte | 33 ++- src/app/components/EventComments.svelte | 49 +++- src/app/components/EventReactButtons.svelte | 35 +++ src/app/components/ReadingProgress.svelte | 47 +++ src/app/components/ZapButton.svelte | 2 + src/app/routes.ts | 20 ++ src/app/title.ts | 1 + src/lib/components/EmojiButton.svelte | 8 +- src/lib/components/base.css | 8 + src/lib/components/content-markdown.css | 69 +++++ .../spaces/[relay]/articles/+page.svelte | 6 +- .../[relay]/articles/[address]/+page.svelte | 124 ++++++-- .../[relay]/articles/create/+page.svelte | 276 ++++++++++++++++++ 23 files changed, 895 insertions(+), 352 deletions(-) create mode 100644 src/app/articles.ts create mode 100644 src/app/components/ArticleAuthorSidebar.svelte delete mode 100644 src/app/components/ArticleCreate.svelte create mode 100644 src/app/components/EventActionBar.svelte create mode 100644 src/app/components/EventReactButtons.svelte create mode 100644 src/app/components/ReadingProgress.svelte create mode 100644 src/routes/spaces/[relay]/articles/create/+page.svelte diff --git a/src/app/articles.ts b/src/app/articles.ts new file mode 100644 index 00000000..bc02a2d0 --- /dev/null +++ b/src/app/articles.ts @@ -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"}` +} diff --git a/src/app/components/ArticleActions.svelte b/src/app/components/ArticleActions.svelte index 004fd113..74fdbb01 100644 --- a/src/app/components/ArticleActions.svelte +++ b/src/app/components/ArticleActions.svelte @@ -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}) -
- {#if h && showRoom} - - Posted in # - - {/if} -
- {#each uniq(topics) as topic (topic)} - - {/each} -
- - - {#if showActivity} - +{#if detail} +
+ + + + {#if !$deleted} + + {#snippet leading()} + {#if showActivity} + + {/if} + {/snippet} + {/if} - - -
+
+{:else} +
+
+ {#if h && showRoom} + + Posted in # + + {/if} +
+ {#each uniq(topics) as topic (topic)} + + {/each} +
+ + + {#if showActivity} + + {/if} + +
+ {#if !$deleted} + + {/if} +
+{/if} diff --git a/src/app/components/ArticleAuthorSidebar.svelte b/src/app/components/ArticleAuthorSidebar.svelte new file mode 100644 index 00000000..fa9db59e --- /dev/null +++ b/src/app/components/ArticleAuthorSidebar.svelte @@ -0,0 +1,91 @@ + + + diff --git a/src/app/components/ArticleCreate.svelte b/src/app/components/ArticleCreate.svelte deleted file mode 100644 index 0d1ab32c..00000000 --- a/src/app/components/ArticleCreate.svelte +++ /dev/null @@ -1,215 +0,0 @@ - - - - - - Write an Article - Publish a long-form post to this space. - -
- - {#snippet label()} -

Title*

- {/snippet} - {#snippet input()} - - {/snippet} -
- - {#snippet label()} -

Summary

- {/snippet} - {#snippet input()} - - {/snippet} -
- - {#snippet label()} -

Topics

- {/snippet} - {#snippet input()} - - {/snippet} -
- - {#snippet label()} -

Content*

- {/snippet} - {#snippet input()} -
- -
- {/snippet} -
- -
-
- - - - -
diff --git a/src/app/components/CommentCompose.svelte b/src/app/components/CommentCompose.svelte index 63f1c200..bd208218 100644 --- a/src/app/components/CommentCompose.svelte +++ b/src/app/components/CommentCompose.svelte @@ -89,7 +89,9 @@ }) -
+
diff --git a/src/app/components/CommentTree.svelte b/src/app/components/CommentTree.svelte index 9df5b981..f6d9d0f2 100644 --- a/src/app/components/CommentTree.svelte +++ b/src/app/components/CommentTree.svelte @@ -1,14 +1,20 @@ -
- -
- - {#if url} -
- - -
- {/if} + +
+ {#if muted} +
+
+ +

You have muted this person.

+
+
- + {:else} +
+ +
+
+ + + {formatTimestamp(node.comment.created_at)} + +
+ + {#if url} +
+ + +
+ {/if} +
+
+ {/if} {#if composing && url} -
+
{/if} {#if node.children.length > 0} -
+ +
{#each node.children as child (child.comment.id)} {/each} diff --git a/src/app/components/ComposeMenu.svelte b/src/app/components/ComposeMenu.svelte index 0fba2249..ce105a5f 100644 --- a/src/app/components/ComposeMenu.svelte +++ b/src/app/components/ComposeMenu.svelte @@ -1,5 +1,6 @@ + +
+
+ + {@render leading?.()} +
+
+ {#if isRoot} + + {/if} +
+
diff --git a/src/app/components/EventActions.svelte b/src/app/components/EventActions.svelte index 029edf4e..95bbe332 100644 --- a/src/app/components/EventActions.svelte +++ b/src/app/components/EventActions.svelte @@ -1,21 +1,14 @@ +
- {#if ENABLE_ZAPS && !hideZap} - - - - {/if} - - - + +
+ +
+
+

+ {nodes.length === 0 + ? "Discussion about this post" + : `${$comments.length} ${$comments.length === 1 ? "comment" : "comments"}`} +

+ {#if url && replyTo?.id !== event.id} + + {/if}
- {/if} -{/if} +
+
+
+ {#if url && replyTo?.id === event.id} +
+ +
+ {/if} + {#each nodes as node (node.comment.id)} + + {/each} + {#if nodes.length === 0 && replyTo?.id !== event.id} +

No comments yet.

+ {/if} +
+
+
diff --git a/src/app/components/EventReactButtons.svelte b/src/app/components/EventReactButtons.svelte new file mode 100644 index 00000000..0cf5df5c --- /dev/null +++ b/src/app/components/EventReactButtons.svelte @@ -0,0 +1,35 @@ + + +{#if ENABLE_ZAPS && !hideZap} + + + +{/if} + + + diff --git a/src/app/components/ReadingProgress.svelte b/src/app/components/ReadingProgress.svelte new file mode 100644 index 00000000..6fe644ed --- /dev/null +++ b/src/app/components/ReadingProgress.svelte @@ -0,0 +1,47 @@ + + + diff --git a/src/app/components/ZapButton.svelte b/src/app/components/ZapButton.svelte index eb39aca8..39b9e9ce 100644 --- a/src/app/components/ZapButton.svelte +++ b/src/app/components/ZapButton.svelte @@ -19,6 +19,8 @@ children: Snippet replaceState?: boolean class?: string + "data-tip"?: string + "aria-label"?: string } const {url, event, children, replaceState, ...props}: Props = $props() diff --git a/src/app/routes.ts b/src/app/routes.ts index 984a833f..8e828090 100644 --- a/src/app/routes.ts +++ b/src/app/routes.ts @@ -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) diff --git a/src/app/title.ts b/src/app/title.ts index 11880e38..41735fd5 100644 --- a/src/app/title.ts +++ b/src/app/title.ts @@ -20,6 +20,7 @@ const staticTitles = new Map([ ["/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"], diff --git a/src/lib/components/EmojiButton.svelte b/src/lib/components/EmojiButton.svelte index ca7aa495..c0c48d38 100644 --- a/src/lib/components/EmojiButton.svelte +++ b/src/lib/components/EmojiButton.svelte @@ -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 @@ - diff --git a/src/lib/components/base.css b/src/lib/components/base.css index d036cafc..b46cb330 100644 --- a/src/lib/components/base.css +++ b/src/lib/components/base.css @@ -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; diff --git a/src/lib/components/content-markdown.css b/src/lib/components/content-markdown.css index 9aaea59f..bc8869fb 100644 --- a/src/lib/components/content-markdown.css +++ b/src/lib/components/content-markdown.css @@ -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; + } } diff --git a/src/routes/spaces/[relay]/articles/+page.svelte b/src/routes/spaces/[relay]/articles/+page.svelte index c4351143..590844c3 100644 --- a/src/routes/spaces/[relay]/articles/+page.svelte +++ b/src/routes/spaces/[relay]/articles/+page.svelte @@ -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 = $state(readable([])) - const createArticle = () => pushModal(ArticleCreate, {url}) + const createArticle = () => goto(makeArticleCreatePath(url)) const articles = $derived.by(() => { const scores = new Map() diff --git a/src/routes/spaces/[relay]/articles/[address]/+page.svelte b/src/routes/spaces/[relay]/articles/[address]/+page.svelte index 0d2cf656..7ee82a63 100644 --- a/src/routes/spaces/[relay]/articles/[address]/+page.svelte +++ b/src/routes/spaces/[relay]/articles/[address]/+page.svelte @@ -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 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() + onDestroy(context.cleanup) - + {#snippet title()} -

{$article?.title() ?? ""}

+
+

{$article?.title() ?? ""}

+ {#if $article && h} +

+ # +

+ {/if} +
+ {/snippet} + {#snippet action()} + {#if $event} + + {/if} {/snippet}
- + + + {#if $event && $article} - -
- {#if $article.image()} - - {/if} -
-

{$article.title() ?? "Untitled"}

-

- Published {formatTimestamp($article.publishedAt())} -

+ +
+
+
+
+
+

+ {$article.title() || "Untitled"} +

+ {#if $article.summary()} +

+ {$article.summary()} +

+ {/if} +
+ + + {formatTimestamp($article.publishedAt())} · {displayReadingTime($event.content)} + +
+
+ {#if $article.image()} + + {/if} +
+ +
+
+ {#if topics.length > 0} +
+
+ {#each topics as topic (topic)} + + #{topic} + + {/each} +
+
+ {/if} +
+
+
+ +
+
+ +
+
+

Written by

+ +
+
+
+
- {#if $article.summary()} -

{$article.summary()}

- {/if} - -
- - + +
{:else} -
+
{#await sleep(5000)} Loading article... {:then} diff --git a/src/routes/spaces/[relay]/articles/create/+page.svelte b/src/routes/spaces/[relay]/articles/create/+page.svelte new file mode 100644 index 00000000..6ffb9db9 --- /dev/null +++ b/src/routes/spaces/[relay]/articles/create/+page.svelte @@ -0,0 +1,276 @@ + + + + {#snippet leading()} + + {/snippet} + {#snippet title()} + Write an Article + {/snippet} + {#snippet action()} + + {/snippet} + + + + +
+ + {#if image} +
+ Article cover + +
+ {:else} + + {/if} +
+ + +
+
+
+ +
+ +
+ + {#snippet label()} +

Topics

+ {/snippet} + {#snippet input()} + + {/snippet} +
+
+