From 45d6debfe52ab4baad1012d042c8347262696265 Mon Sep 17 00:00:00 2001 From: Junwei Zhao Date: Sat, 20 Jun 2026 21:49:44 +1000 Subject: [PATCH] update UI --- src/routes/LinkDetailPage.tsx | 56 +++++++- worker/lib/custom-link.ts | 218 ++++++++++++++++++++++++++---- worker/routes/link-go.ts | 7 +- worker/routes/private-redirect.ts | 2 +- worker/routes/redirect.ts | 2 +- 5 files changed, 249 insertions(+), 36 deletions(-) diff --git a/src/routes/LinkDetailPage.tsx b/src/routes/LinkDetailPage.tsx index f7dc947..812711e 100644 --- a/src/routes/LinkDetailPage.tsx +++ b/src/routes/LinkDetailPage.tsx @@ -63,6 +63,31 @@ const PERIODS: { id: StatsPeriod; label: string }[] = [ ]; const COPY_SUCCESS_NOTICE_KEY = 'heygo.copy-success-notice'; +const EDIT_QUERY_PARAM = 'edit'; + +function isEditRequestedInHash(): boolean { + const hash = window.location.hash; + const queryStart = hash.indexOf('?'); + if (queryStart === -1) return false; + return new URLSearchParams(hash.slice(queryStart + 1)).has(EDIT_QUERY_PARAM); +} + +function setEditInHash(edit: boolean) { + const hash = window.location.hash || '#/'; + const queryStart = hash.indexOf('?'); + const path = queryStart === -1 ? hash : hash.slice(0, queryStart); + const params = new URLSearchParams(queryStart === -1 ? '' : hash.slice(queryStart + 1)); + if (edit) { + params.set(EDIT_QUERY_PARAM, '1'); + } else { + params.delete(EDIT_QUERY_PARAM); + } + const query = params.toString(); + const nextHash = query ? `${path}?${query}` : path; + if (window.location.hash !== nextHash) { + window.history.replaceState(null, '', `${window.location.pathname}${window.location.search}${nextHash}`); + } +} export default function LinkDetailPage({ linkId, linkScope, currentUser, linkBaseUrls, onBack }: LinkDetailPageProps) { const [link, setLink] = useState(null); @@ -80,7 +105,7 @@ export default function LinkDetailPage({ linkId, linkScope, currentUser, linkBas const [shortLinkNotice, setShortLinkNotice] = useState(null); const [showPropose, setShowPropose] = useState(false); const [proposeNotice, setProposeNotice] = useState(null); - const [editing, setEditing] = useState(false); + const [editing, setEditing] = useState(() => isEditRequestedInHash()); const [editInput, setEditInput] = useState(null); const [editError, setEditError] = useState(null); const [saving, setSaving] = useState(false); @@ -105,6 +130,20 @@ export default function LinkDetailPage({ linkId, linkScope, currentUser, linkBas }); }, [linkId, linkScope]); + // Seed the edit form when arriving with ?edit in the URL and the link has + // just finished loading (covers a page refresh while editing). + useEffect(() => { + if (editing && link && !editInput) { + setEditInput({ + alias: link.alias, + linkType: link.linkType, + targetUrl: link.targetUrl ?? '', + contentMarkdown: link.contentMarkdown ?? '', + description: link.description ?? '', + }); + } + }, [editing, link, editInput]); + useEffect(() => { setCopyError(null); setShowCopyDialog(false); @@ -114,12 +153,21 @@ export default function LinkDetailPage({ linkId, linkScope, currentUser, linkBas useEffect(() => { setShowPropose(false); setProposeNotice(null); - setEditing(false); + const editRequested = isEditRequestedInHash(); + setEditing(editRequested); setEditInput(null); setEditError(null); setTemplateValues({}); }, [linkId, linkScope]); + // Keep editing state in sync when the hash query changes (e.g. browser + // back/forward between ?edit and the plain detail URL) without remounting. + useEffect(() => { + const onHashChange = () => setEditing(isEditRequestedInHash()); + window.addEventListener('hashchange', onHashChange); + return () => window.removeEventListener('hashchange', onHashChange); + }, []); + useEffect(() => { if (!shortLinkNotice) { return; @@ -388,6 +436,7 @@ export default function LinkDetailPage({ linkId, linkScope, currentUser, linkBas contentMarkdown: link.contentMarkdown ?? '', description: link.description ?? '', }); + setEditInHash(true); setEditing(true); } @@ -411,6 +460,7 @@ export default function LinkDetailPage({ linkId, linkScope, currentUser, linkBas ? await updatePrivateLink(link.id, payload) : await updatePublicLink(link.id, payload); setLink(result.link); + setEditInHash(false); setEditing(false); setEditInput(null); } catch (err) { @@ -593,7 +643,7 @@ export default function LinkDetailPage({ linkId, linkScope, currentUser, linkBas - diff --git a/worker/lib/custom-link.ts b/worker/lib/custom-link.ts index f5b9352..953ccab 100644 --- a/worker/lib/custom-link.ts +++ b/worker/lib/custom-link.ts @@ -4,6 +4,7 @@ import { escapeHtml } from './responses'; export type CustomLinkLike = { alias: string; content_markdown: string | null; + scope: 'public' | 'private'; }; marked.use({ @@ -39,42 +40,203 @@ marked.use({ export function renderCustomLinkHtml(link: CustomLinkLike): string { const content = renderMarkdown(link.content_markdown ?? ''); - const title = escapeHtml(link.alias); + const alias = escapeHtml(link.alias); + const isPrivate = link.scope === 'private'; + const scope = isPrivate ? 'private' : 'public'; + const scopeLabel = isPrivate ? 'Private link' : 'Public link'; + const scopeIcon = isPrivate ? LOCK_ICON_SVG : GLOBE_ICON_SVG; - return `${title}
${content}
`; + return `${alias} +
+
+ + ${scopeIcon} + ${scopeLabel} + + /${alias} +
+
+
+
${content}
+
+
Published with Heygo
+`; } export function renderMarkdown(markdown: string): string { return marked.parse(markdown ?? '', { async: false }) as string; } +const GLOBE_ICON_SVG = ``; + +const LOCK_ICON_SVG = ``; + const CUSTOM_PAGE_STYLES = ` - :root { color-scheme: light dark; } - body { margin: 0; font: 16px/1.6 Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; color: #0f172a; background: #f8fafc; } - main { max-width: 720px; margin: 0 auto; padding: 2.5rem 1.25rem 4rem; background: #fff; min-height: 100vh; box-sizing: border-box; } - h1, h2, h3, h4, h5, h6 { line-height: 1.25; margin: 1.6em 0 0.6em; } - h1 { font-size: 1.875rem; } - h2 { font-size: 1.5rem; } - h3 { font-size: 1.25rem; } - p { margin: 0.75em 0; } - a { color: #4338ca; } - img { max-width: 100%; height: auto; border-radius: 8px; } - ul, ol { padding-left: 1.4em; } - li { margin: 0.25em 0; } - code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.9em; background: #f1f5f9; padding: 0.15em 0.35em; border-radius: 4px; } - pre { background: #0f172a; color: #e2e8f0; padding: 1rem 1.25rem; border-radius: 10px; overflow-x: auto; } - pre code { background: transparent; color: inherit; padding: 0; } - blockquote { border-left: 3px solid #c7d2fe; margin: 1em 0; padding: 0.25em 1em; color: #475569; } - hr { border: none; border-top: 1px solid #e2e8f0; margin: 1.5em 0; } - table { border-collapse: collapse; width: 100%; margin: 1em 0; } - th, td { border: 1px solid #e2e8f0; padding: 0.5em 0.75em; text-align: left; } - th { background: #f8fafc; } + :root { + --cp-text: #0F172A; + --cp-muted: #64748B; + --cp-surface: #ffffff; + --cp-border: #E2E8F0; + --cp-code-bg: #F1F5F9; + --cp-link: #4338CA; + --cp-radius: 14px; + color-scheme: light dark; + } + html[data-scope="public"] { + --cp-accent: #15803D; + --cp-accent-soft: #F2FFF6; + --cp-accent-border: rgba(22,163,74,0.18); + --cp-accent-pill-bg: rgba(34,197,94,0.14); + --cp-canvas: linear-gradient(180deg, #F2FFF6 0%, #F8FAFC 320px); + } + html[data-scope="private"] { + --cp-accent: #B91C1C; + --cp-accent-soft: #FFF4F4; + --cp-accent-border: rgba(220,38,38,0.16); + --cp-accent-pill-bg: rgba(248,113,113,0.14); + --cp-canvas: linear-gradient(180deg, #FFF4F4 0%, #F8FAFC 320px); + } + + * { box-sizing: border-box; } + body { + margin: 0; + font: 16px/1.65 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + color: var(--cp-text); + background: var(--cp-canvas, #f8fafc); + background-attachment: fixed; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; + } + + /* ── Scope header ── */ + .cp-header { + position: sticky; + top: 0; + z-index: 10; + background: color-mix(in srgb, var(--cp-surface) 88%, transparent); + backdrop-filter: saturate(180%) blur(12px); + -webkit-backdrop-filter: saturate(180%) blur(12px); + border-bottom: 1px solid var(--cp-accent-border); + } + .cp-header__inner { + max-width: 760px; + margin: 0 auto; + padding: 0.6rem 1.25rem; + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + } + .cp-scope { + display: inline-flex; + align-items: center; + gap: 0.4rem; + background: var(--cp-accent-pill-bg); + color: var(--cp-accent); + font-size: 0.78rem; + font-weight: 600; + letter-spacing: 0.01em; + padding: 0.3rem 0.7rem; + border-radius: 999px; + border: 1px solid var(--cp-accent-border); + white-space: nowrap; + } + .cp-scope__icon { flex-shrink: 0; } + .cp-alias { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.85rem; + font-weight: 600; + color: var(--cp-muted); + background: var(--cp-code-bg); + border: 1px solid var(--cp-border); + border-radius: 999px; + padding: 0.22rem 0.7rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 50%; + } + + /* ── Content card ── */ + .cp-main { max-width: 760px; margin: 0 auto; padding: 2rem 1.25rem 3rem; } + .cp-card { + background: var(--cp-surface); + border: 1px solid var(--cp-border); + border-radius: var(--cp-radius); + box-shadow: 0 1px 2px rgba(15,23,42,0.04), 0 8px 30px rgba(15,23,42,0.06); + padding: 2.5rem 2.75rem; + min-width: 0; + max-width: 100%; + overflow-wrap: anywhere; + word-break: break-word; + } + @media (max-width: 640px) { + .cp-card { padding: 1.75rem 1.25rem; } + .cp-main { padding: 1rem 0.75rem 2rem; } + .cp-header__inner { padding: 0.5rem 0.75rem; } + .cp-scope { font-size: 0.72rem; padding: 0.25rem 0.55rem; } + } + + /* ── Markdown typography ── */ + .cp-card > :first-child { margin-top: 0; } + .cp-card > :last-child { margin-bottom: 0; } + .cp-card h1, .cp-card h2, .cp-card h3, + .cp-card h4, .cp-card h5, .cp-card h6 { + line-height: 1.25; margin: 1.6em 0 0.55em; font-weight: 700; letter-spacing: -0.015em; + } + .cp-card h1 { font-size: 1.9rem; margin-top: 0; } + .cp-card h2 { font-size: 1.45rem; } + .cp-card h3 { font-size: 1.2rem; } + .cp-card h4 { font-size: 1.02rem; } + .cp-card p { margin: 0.75em 0; overflow-wrap: anywhere; } + .cp-card a { color: var(--cp-link); text-decoration: underline; text-underline-offset: 2px; overflow-wrap: anywhere; } + .cp-card a:hover { color: var(--cp-accent); } + .cp-card img { max-width: 100%; height: auto; border-radius: 10px; margin: 0.75em 0; } + .cp-card ul, .cp-card ol { padding-left: 1.4em; margin: 0.65em 0; } + .cp-card li { margin: 0.22em 0; overflow-wrap: anywhere; } + .cp-card li::marker { color: var(--cp-accent); } + .cp-card code { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.86em; background: var(--cp-code-bg); + padding: 0.15em 0.38em; border-radius: 5px; border: 1px solid var(--cp-border); + overflow-wrap: anywhere; + } + .cp-card pre { + background: #0F172A; color: #E2E8F0; padding: 1.1rem 1.35rem; + border-radius: 10px; overflow-x: auto; margin: 1.1em 0; font-size: 0.875rem; + max-width: 100%; + } + .cp-card pre code { background: transparent; color: inherit; padding: 0; border: none; font-size: inherit; overflow-wrap: normal; } + .cp-card blockquote { + border-left: 3px solid var(--cp-accent); margin: 1.1em 0; padding: 0.3em 1.1em; + color: var(--cp-muted); background: var(--cp-accent-soft); border-radius: 0 8px 8px 0; + overflow-wrap: anywhere; + } + .cp-card hr { border: none; border-top: 1px solid var(--cp-border); margin: 1.75em 0; } + .cp-card table { border-collapse: collapse; width: 100%; margin: 1.1em 0; font-size: 0.9rem; display: block; overflow-x: auto; max-width: 100%; } + .cp-card th, .cp-card td { border: 1px solid var(--cp-border); padding: 0.5em 0.8em; text-align: left; } + .cp-card th { background: var(--cp-code-bg); font-weight: 600; } + .cp-card kbd { + font-family: ui-monospace, monospace; font-size: 0.8em; + border: 1px solid var(--cp-border); border-bottom-width: 2px; + border-radius: 5px; padding: 0.12em 0.42em; background: var(--cp-surface); + } + + /* ── Footer ── */ + .cp-footer { + max-width: 760px; margin: 0 auto; padding: 0 1.25rem 2.5rem; + text-align: center; color: var(--cp-muted); font-size: 0.78rem; + } + .cp-footer strong { color: var(--cp-accent); font-weight: 700; } + + /* ── Dark mode ── */ @media (prefers-color-scheme: dark) { - body { color: #e2e8f0; background: #0f172a; } - main { background: #1e293b; } - code { background: #334155; } - blockquote { color: #cbd5e1; border-color: #6366f1; } - th { background: #334155; } - th, td { border-color: #334155; } + :root { --cp-text: #E2E8F0; --cp-muted: #94A3B8; --cp-surface: #1E293B; --cp-border: #334155; --cp-code-bg: #334155; --cp-link: #A5B4FC; } + html[data-scope="public"] { --cp-canvas: linear-gradient(180deg, rgba(22,163,74,0.10) 0%, #0F172A 320px); } + html[data-scope="private"] { --cp-canvas: linear-gradient(180deg, rgba(220,38,38,0.10) 0%, #0F172A 320px); } + .cp-header { background: color-mix(in srgb, #1E293B 88%, transparent); } + .cp-alias { color: #94A3B8; } + .cp-card { box-shadow: 0 1px 2px rgba(0,0,0,0.2), 0 8px 30px rgba(0,0,0,0.25); } + .cp-card th { background: var(--cp-code-bg); } } `; diff --git a/worker/routes/link-go.ts b/worker/routes/link-go.ts index 876a8f2..64a8056 100644 --- a/worker/routes/link-go.ts +++ b/worker/routes/link-go.ts @@ -19,14 +19,15 @@ type LinkRow = { link_type: 'redirect' | 'custom'; target_url: string | null; content_markdown: string | null; + scope: 'public' | 'private'; }; -const PUBLIC_LINK_BY_ID_QUERY = `SELECT id, alias, link_type, target_url, content_markdown +const PUBLIC_LINK_BY_ID_QUERY = `SELECT id, alias, link_type, target_url, content_markdown, scope FROM links WHERE id=? AND scope='public' AND status='active' LIMIT 1`; -const PRIVATE_LINK_BY_ID_QUERY = `SELECT id, alias, link_type, target_url, content_markdown +const PRIVATE_LINK_BY_ID_QUERY = `SELECT id, alias, link_type, target_url, content_markdown, scope FROM links WHERE id=? AND scope='private' AND owner_user_id=? AND status='active' LIMIT 1`; @@ -104,7 +105,7 @@ async function resolveGoResponse( } if (link.link_type === 'custom') { - const html = renderCustomLinkHtml(link); + const html = renderCustomLinkHtml({ ...link, scope: isPrivate ? 'private' : 'public' }); return isPrivate ? privateHtmlResponse(html, { status: 200 }) : htmlResponse(html, { status: 200 }); } diff --git a/worker/routes/private-redirect.ts b/worker/routes/private-redirect.ts index d58d17a..a95e822 100644 --- a/worker/routes/private-redirect.ts +++ b/worker/routes/private-redirect.ts @@ -100,7 +100,7 @@ export async function handlePrivateShortlink( } if (link.link_type === 'custom') { - return privateHtmlResponse(renderCustomLinkHtml(link), { status: 200 }); + return privateHtmlResponse(renderCustomLinkHtml({ ...link, scope: 'private' }), { status: 200 }); } return privateAliasNotFoundResponse(env.APP_BASE_URL); diff --git a/worker/routes/redirect.ts b/worker/routes/redirect.ts index 11d2fe9..6ed4cc0 100644 --- a/worker/routes/redirect.ts +++ b/worker/routes/redirect.ts @@ -80,7 +80,7 @@ export async function handlePublicShortlink( } if (link.link_type === 'custom') { - return htmlResponse(renderCustomLinkHtml(link), { status: 200 }); + return htmlResponse(renderCustomLinkHtml({ ...link, scope: 'public' }), { status: 200 }); } return publicNotFoundResponse();