Move the article composer to a full page
This commit is contained in:
parent
784072229b
commit
89da2ac96a
23 changed files with 895 additions and 352 deletions
10
src/app/articles.ts
Normal file
10
src/app/articles.ts
Normal 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"}`
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
91
src/app/components/ArticleAuthorSidebar.svelte
Normal file
91
src/app/components/ArticleAuthorSidebar.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
40
src/app/components/EventActionBar.svelte
Normal file
40
src/app/components/EventActionBar.svelte
Normal 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>
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
35
src/app/components/EventReactButtons.svelte
Normal file
35
src/app/components/EventReactButtons.svelte
Normal 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>
|
||||
47
src/app/components/ReadingProgress.svelte
Normal file
47
src/app/components/ReadingProgress.svelte
Normal 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>
|
||||
|
|
@ -19,6 +19,8 @@
|
|||
children: Snippet
|
||||
replaceState?: boolean
|
||||
class?: string
|
||||
"data-tip"?: string
|
||||
"aria-label"?: string
|
||||
}
|
||||
|
||||
const {url, event, children, replaceState, ...props}: Props = $props()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[]>()
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
276
src/routes/spaces/[relay]/articles/create/+page.svelte
Normal file
276
src/routes/spaces/[relay]/articles/create/+page.svelte
Normal 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>
|
||||
Loading…
Reference in a new issue