Add a preview feature to the post editor

This commit is contained in:
dtonon 2026-04-15 17:32:46 +01:00
parent e96e99cb36
commit 42ed029dfc
4 changed files with 263 additions and 209 deletions

View file

@ -2,6 +2,7 @@
import { tick } from "svelte";
import { uploadImage } from "$lib/blossom";
import { BLOSSOM_URL } from "$lib/config";
import PostContent from "$lib/components/PostContent.svelte";
type Props = {
value: string;
@ -24,11 +25,16 @@
let textareaEl = $state<HTMLTextAreaElement | null>(null);
let fileInputEl = $state<HTMLInputElement | null>(null);
let uploadError = $state<string | null>(null);
let previewing = $state(false);
export function focus() {
textareaEl?.focus();
}
function togglePreview() {
previewing = !previewing;
}
function onUploadClick() {
fileInputEl?.click();
}
@ -66,22 +72,35 @@
}
</script>
<div class="flex flex-col">
<textarea
bind:this={textareaEl}
bind:value
{disabled}
{rows}
{placeholder}
class="w-full rounded-t border border-gray-200 px-3 py-2 focus:outline-none focus:ring-1 focus:ring-brand disabled:opacity-50 resize-none {minHeightClass}"
></textarea>
<div class="flex flex-col {previewing ? 'flex-1 min-h-0' : ''}">
{#if previewing}
<div
class="w-full rounded-t border border-gray-200 px-3 py-2 overflow-auto flex-1 min-h-0 max-h-[70vh] {minHeightClass}"
aria-label="Preview"
>
{#if value.trim()}
<PostContent content={value} />
{:else}
<p class="text-gray-400 italic">Nothing to preview</p>
{/if}
</div>
{:else}
<textarea
bind:this={textareaEl}
bind:value
{disabled}
{rows}
{placeholder}
class="w-full rounded-t border border-gray-200 px-3 py-2 focus:outline-none focus:ring-1 focus:ring-brand disabled:opacity-50 resize-none {minHeightClass}"
></textarea>
{/if}
<div
class="flex items-center gap-4 rounded-b border border-t-0 border-gray-200 bg-gray-50 px-3 py-2 text-sm"
class="flex flex-shrink-0 items-center gap-4 rounded-b border border-t-0 border-gray-200 bg-gray-50 px-3 py-2 text-sm"
>
<button
type="button"
onclick={onUploadClick}
disabled={uploading || disabled || !BLOSSOM_URL}
disabled={uploading || disabled || previewing || !BLOSSOM_URL}
title={!BLOSSOM_URL ? "Blossom server not configured" : ""}
class="inline-flex items-center gap-1.5 text-gray-600 hover:text-gray-900 disabled:opacity-50 disabled:cursor-not-allowed"
>
@ -105,6 +124,29 @@
{#if uploadError}
<span class="text-xs text-red-600">{uploadError}</span>
{/if}
<button
type="button"
onclick={togglePreview}
disabled={disabled || uploading}
aria-pressed={previewing}
class="ml-auto inline-flex items-center gap-1.5 text-gray-600 hover:text-gray-900 disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7z" />
<circle cx="12" cy="12" r="3" />
</svg>
{previewing ? "Edit" : "Preview"}
</button>
</div>
<input
type="file"

View file

@ -130,7 +130,7 @@
onclick={iconizeDraft}
></button>
<div
class="relative w-full max-w-2xl rounded-lg bg-white p-6 shadow-xl flex flex-col gap-4 max-h-[90vh]"
class="relative w-full max-w-2xl rounded-lg bg-white p-6 shadow-xl flex flex-col gap-4 max-h-[90vh] overflow-hidden"
role="dialog"
aria-modal="true"
aria-labelledby="newdisc-title"

View file

@ -0,0 +1,206 @@
<script lang="ts">
import { loadNostrUser, type NostrUser } from "@nostr/gadgets/metadata";
import * as nip19 from "@nostr/tools/nip19";
type Props = {
content: string;
profiles?: Record<string, NostrUser>;
};
let { content, profiles = {} }: Props = $props();
// Curated TLD list: gTLDs, popular new gTLDs, common ccTLDs
const TLDS = [
"com","org","net","edu","gov","mil","int","info","biz","name","pro",
"io","co","app","dev","ai","sh","me","ly","tv","fm","lol","club",
"online","site","store","blog","tech","xyz","art","design","news",
"media","page","link","fun","gg","gl","st","to","run","life","world",
"space","cloud","email","social","chat","wtf","cafe","zone","studio",
"us","uk","de","fr","it","es","nl","ru","jp","cn","ca","au","br","in",
"mx","se","no","fi","dk","ch","at","be","pl","pt","gr","cz","ie","nz",
"kr","sg","hk","tw","za","cc","ws","eu","tr","ua","il","ar","cl","pe",
].join("|");
const URL_RE = new RegExp(
`nostr:(?:npub1|nprofile1|note1|nevent1|naddr1)[a-z0-9]+|https?:\\/\\/[^\\s<>"']+|(?<![\\w@.\\/])(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\\.)+(?:${TLDS})\\b(?:[\\/?#][^\\s<>"']*)?`,
"gi",
);
const IMG_EXT_RE = /\.(?:jpg|jpeg|png|gif|webp|avif|svg|bmp)(?:\?.*)?$/i;
// Trailing punctuation that is usually not part of the URL
const TRAILING_PUNCT_RE = /[).,;:!?'"]+$/;
function shortEntity(entity: string): string {
const m = entity.match(/^(npub|nprofile|note|nevent|naddr)1/);
if (!m) return entity;
const prefix = m[0];
const rest = entity.slice(prefix.length);
if (rest.length <= 12) return entity;
return `${prefix}${rest.slice(0, 6)}…${rest.slice(-4)}`;
}
type Inline =
| { type: "text"; value: string }
| { type: "link"; href: string; label: string }
| { type: "mention"; pubkey: string; entity: string; fallback: string }
| { type: "entity"; entity: string; label: string };
type Block =
| { type: "image"; value: string }
| { type: "para"; inlines: Inline[] };
function tokenize(text: string): Block[] {
const blocks: Block[] = [];
let inlines: Inline[] = [];
const flush = () => {
if (
inlines.some(
(i) =>
i.type === "link" ||
i.type === "mention" ||
i.type === "entity" ||
i.value.trim(),
)
)
blocks.push({ type: "para", inlines });
inlines = [];
};
let last = 0;
for (const m of text.matchAll(URL_RE)) {
const start = m.index ?? 0;
let url = m[0];
const isNostr = /^nostr:/i.test(url);
let trailing = "";
if (!isNostr) {
const trail = url.match(TRAILING_PUNCT_RE);
if (trail) {
trailing = trail[0];
url = url.slice(0, -trailing.length);
}
}
if (start > last)
inlines.push({ type: "text", value: text.slice(last, start) });
if (isNostr) {
const entity = url.slice(6).toLowerCase();
let handled = false;
try {
const decoded = nip19.decode(entity);
if (decoded.type === "npub") {
inlines.push({
type: "mention",
pubkey: decoded.data,
entity,
fallback: shortEntity(entity),
});
handled = true;
} else if (decoded.type === "nprofile") {
inlines.push({
type: "mention",
pubkey: decoded.data.pubkey,
entity,
fallback: shortEntity(entity),
});
handled = true;
} else if (
decoded.type === "note" ||
decoded.type === "nevent" ||
decoded.type === "naddr"
) {
inlines.push({
type: "entity",
entity,
label: shortEntity(entity),
});
handled = true;
}
} catch {
// Fall through to text
}
if (!handled) inlines.push({ type: "text", value: m[0] });
} else {
const href = /^https?:\/\//i.test(url) ? url : `https://${url}`;
if (IMG_EXT_RE.test(url)) {
flush();
blocks.push({ type: "image", value: href });
if (trailing) inlines.push({ type: "text", value: trailing });
} else {
inlines.push({ type: "link", href, label: url });
if (trailing) inlines.push({ type: "text", value: trailing });
}
}
last = start + m[0].length;
}
if (last < text.length)
inlines.push({ type: "text", value: text.slice(last) });
flush();
return blocks;
}
const paragraphs = $derived(
content.split(/\n{2,}/).map((para) => tokenize(para)),
);
let resolvedUsers = $state<Record<string, NostrUser>>({});
$effect(() => {
const seen = new Set<string>();
for (const blocks of paragraphs) {
for (const block of blocks) {
if (block.type !== "para") continue;
for (const inline of block.inlines) {
if (inline.type === "mention") seen.add(inline.pubkey);
}
}
}
for (const pubkey of seen) {
if (resolvedUsers[pubkey] || profiles[pubkey]) continue;
loadNostrUser(pubkey).then((u) => {
resolvedUsers = { ...resolvedUsers, [pubkey]: u };
});
}
});
</script>
<div class="prose leading-5 max-w-none text-gray-700 [&_p]:my-3 [&_img]:my-3">
{#each paragraphs as blocks}
{#each blocks as block}
{#if block.type === "image"}
<img
src={block.value}
alt=""
loading="lazy"
class="block mx-auto w-full max-h-[80vh] object-contain rounded"
/>
{:else}
<p>
{#each block.inlines as inline}
{#if inline.type === "link"}
<a
href={inline.href}
target="_blank"
rel="noopener noreferrer"
class="text-brand hover:underline break-all">{inline.label}</a
>
{:else if inline.type === "mention"}
{@const u =
profiles[inline.pubkey] ?? resolvedUsers[inline.pubkey]}
<a
href="https://njump.me/{inline.entity}"
target="_blank"
rel="noopener noreferrer"
class="text-brand hover:underline"
>@{u?.shortName ?? inline.fallback}</a
>
{:else if inline.type === "entity"}
<a
href="https://njump.me/{inline.entity}"
target="_blank"
rel="noopener noreferrer"
class="text-brand hover:underline break-all">{inline.label}</a
>
{:else}{inline.value}{/if}
{/each}
</p>
{/if}
{/each}
{/each}
</div>