diff options
| author | Fuwn <[email protected]> | 2026-02-08 07:07:59 -0800 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-02-08 07:07:59 -0800 |
| commit | a33dbfa6a1cb1d34ce9a5286efdb25818ff7b6c1 (patch) | |
| tree | 7d44bdcb94cc1b69fbc201a4757f27f3751c5adb | |
| parent | chore: gate Vercel analytics and speed insights to production only (diff) | |
| download | asa.news-a33dbfa6a1cb1d34ce9a5286efdb25818ff7b6c1.tar.xz asa.news-a33dbfa6a1cb1d34ce9a5286efdb25818ff7b6c1.zip | |
feat: share with highlighted excerpt and fix auth redirect URLs
Add "share" button to text selection toolbar so users can share an entry
with a highlighted passage visible to visitors. The public share page
renders the highlight and scrolls to it on load.
Also fix magic link and password reset redirects to use NEXT_PUBLIC_APP_URL
instead of window.location.origin so emails link to the production domain.
| -rw-r--r-- | apps/web/app/(auth)/forgot-password/page.tsx | 2 | ||||
| -rw-r--r-- | apps/web/app/(auth)/sign-in/page.tsx | 2 | ||||
| -rw-r--r-- | apps/web/app/api/share/route.ts | 39 | ||||
| -rw-r--r-- | apps/web/app/reader/_components/entry-detail-panel.tsx | 58 | ||||
| -rw-r--r-- | apps/web/app/reader/_components/highlight-selection-toolbar.tsx | 9 | ||||
| -rw-r--r-- | apps/web/app/shared/[token]/page.tsx | 51 | ||||
| -rw-r--r-- | apps/web/app/shared/[token]/shared-entry-content.tsx | 57 | ||||
| -rw-r--r-- | supabase/schema.sql | 7 |
8 files changed, 205 insertions, 20 deletions
diff --git a/apps/web/app/(auth)/forgot-password/page.tsx b/apps/web/app/(auth)/forgot-password/page.tsx index 748ba47..ad302bc 100644 --- a/apps/web/app/(auth)/forgot-password/page.tsx +++ b/apps/web/app/(auth)/forgot-password/page.tsx @@ -20,7 +20,7 @@ export default function ForgotPasswordPage() { const { error } = await supabaseClient.auth.resetPasswordForEmail( emailAddress, { - redirectTo: `${window.location.origin}/auth/callback?next=/reset-password`, + redirectTo: `${process.env.NEXT_PUBLIC_APP_URL || window.location.origin}/auth/callback?next=/reset-password`, }, ) diff --git a/apps/web/app/(auth)/sign-in/page.tsx b/apps/web/app/(auth)/sign-in/page.tsx index b7426d2..b300470 100644 --- a/apps/web/app/(auth)/sign-in/page.tsx +++ b/apps/web/app/(auth)/sign-in/page.tsx @@ -49,7 +49,7 @@ export default function SignInPage() { email: emailAddress, options: { shouldCreateUser: false, - emailRedirectTo: `${window.location.origin}/auth/callback?next=/reader`, + emailRedirectTo: `${process.env.NEXT_PUBLIC_APP_URL || window.location.origin}/auth/callback?next=/reader`, }, }) diff --git a/apps/web/app/api/share/route.ts b/apps/web/app/api/share/route.ts index b6a9c88..58d0c2a 100644 --- a/apps/web/app/api/share/route.ts +++ b/apps/web/app/api/share/route.ts @@ -64,6 +64,26 @@ export async function POST(request: Request) { note = rawNote.trim() || null } + let highlightedText: string | null = null + let highlightTextOffset: number | null = null + let highlightTextLength: number | null = null + let highlightTextPrefix: string = "" + let highlightTextSuffix: string = "" + + if (body.highlightedText && typeof body.highlightedText === "string") { + if (typeof body.highlightTextOffset !== "number" || typeof body.highlightTextLength !== "number") { + return NextResponse.json( + { error: "highlightTextOffset and highlightTextLength are required with highlightedText" }, + { status: 400 } + ) + } + highlightedText = body.highlightedText + highlightTextOffset = body.highlightTextOffset + highlightTextLength = body.highlightTextLength + highlightTextPrefix = typeof body.highlightTextPrefix === "string" ? body.highlightTextPrefix : "" + highlightTextSuffix = typeof body.highlightTextSuffix === "string" ? body.highlightTextSuffix : "" + } + const { data: entryAccess } = await supabaseClient .from("entries") .select("id, feed_id") @@ -95,12 +115,24 @@ export async function POST(request: Request) { const { data: existingShare } = await supabaseClient .from("shared_entries") - .select("share_token") + .select("share_token, id") .eq("entry_id", entryIdentifier) .eq("user_id", user.id) .maybeSingle() if (existingShare) { + if (highlightedText !== null) { + await supabaseClient + .from("shared_entries") + .update({ + highlighted_text: highlightedText, + highlight_text_offset: highlightTextOffset, + highlight_text_length: highlightTextLength, + highlight_text_prefix: highlightTextPrefix, + highlight_text_suffix: highlightTextSuffix, + }) + .eq("id", existingShare.id) + } const shareUrl = `${origin}/shared/${existingShare.share_token}` return NextResponse.json({ shareToken: existingShare.share_token, @@ -116,6 +148,11 @@ export async function POST(request: Request) { share_token: shareToken, expires_at: expiresAt, note, + highlighted_text: highlightedText, + highlight_text_offset: highlightTextOffset, + highlight_text_length: highlightTextLength, + highlight_text_prefix: highlightTextPrefix, + highlight_text_suffix: highlightTextSuffix, }) if (error) { diff --git a/apps/web/app/reader/_components/entry-detail-panel.tsx b/apps/web/app/reader/_components/entry-detail-panel.tsx index b67ae2a..b772ea1 100644 --- a/apps/web/app/reader/_components/entry-detail-panel.tsx +++ b/apps/web/app/reader/_components/entry-detail-panel.tsx @@ -341,6 +341,63 @@ export function EntryDetailPanel({ setSelectionToolbarState(null) } + function handleShareFromSelection() { + const container = proseContainerReference.current + if (!container || !selectionToolbarState) return + + const serialized = serializeSelectionRange( + container, + selectionToolbarState.range + ) + if (!serialized) return + + window.getSelection()?.removeAllRanges() + setSelectionToolbarState(null) + setIsSharePending(true) + + const shareUrlPromise = fetch("/api/share", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + entryIdentifier, + highlightedText: serialized.highlightedText, + highlightTextOffset: serialized.textOffset, + highlightTextLength: serialized.textLength, + highlightTextPrefix: serialized.textPrefix, + highlightTextSuffix: serialized.textSuffix, + }), + }) + .then((response) => { + if (!response.ok) throw new Error("failed to create share") + return response.json() + }) + .then((data: { shareUrl: string }) => data.shareUrl) + + try { + const clipboardItem = new ClipboardItem({ + "text/plain": shareUrlPromise.then( + (url) => new Blob([url], { type: "text/plain" }) + ), + }) + navigator.clipboard.write([clipboardItem]).then( + () => notify("link copied with highlight"), + () => shareUrlPromise.then((url) => notify("shared — " + url)) + ) + } catch { + shareUrlPromise.then((url) => notify("shared — " + url)) + } + + shareUrlPromise.then( + () => { + queryClient.invalidateQueries({ + queryKey: queryKeys.entryShare.single(entryIdentifier), + }) + queryClient.invalidateQueries({ queryKey: ["shared-entries"] }) + }, + (error) => notify(error.message) + ).finally(() => setIsSharePending(false)) + } + function handleUpdateHighlightNote(note: string | null) { if (!highlightPopoverState) return updateHighlightNote.mutate({ @@ -514,6 +571,7 @@ export function EntryDetailPanel({ selectionRect={selectionToolbarState.selectionRect} containerRect={selectionToolbarState.containerRect} onHighlight={handleCreateHighlight} + onShare={handleShareFromSelection} onDismiss={() => setSelectionToolbarState(null)} /> )} diff --git a/apps/web/app/reader/_components/highlight-selection-toolbar.tsx b/apps/web/app/reader/_components/highlight-selection-toolbar.tsx index 42522bf..f7eb8f8 100644 --- a/apps/web/app/reader/_components/highlight-selection-toolbar.tsx +++ b/apps/web/app/reader/_components/highlight-selection-toolbar.tsx @@ -6,12 +6,14 @@ interface HighlightSelectionToolbarProperties { selectionRect: DOMRect containerRect: DOMRect onHighlight: (note: string | null) => void + onShare: () => void onDismiss: () => void } export function HighlightSelectionToolbar({ selectionRect, onHighlight, + onShare, onDismiss, }: HighlightSelectionToolbarProperties) { const [showNoteInput, setShowNoteInput] = useState(false) @@ -72,6 +74,13 @@ export function HighlightSelectionToolbar({ > + note </button> + <button + type="button" + onClick={onShare} + className="px-2 py-1 text-xs text-text-dim transition-colors hover:bg-background-tertiary hover:text-text-secondary" + > + share + </button> </div> )} </div> diff --git a/apps/web/app/shared/[token]/page.tsx b/apps/web/app/shared/[token]/page.tsx index 222c1c8..7c7a463 100644 --- a/apps/web/app/shared/[token]/page.tsx +++ b/apps/web/app/shared/[token]/page.tsx @@ -1,14 +1,28 @@ import type { Metadata } from "next" import { createSupabaseAdminClient } from "@/lib/supabase/admin" import { sanitizeEntryContent } from "@/lib/sanitize" +import { SharedEntryContent } from "./shared-entry-content" interface SharedPageProperties { params: Promise<{ token: string }> } +interface SharedHighlightData { + highlightedText: string + textOffset: number + textLength: number + textPrefix: string + textSuffix: string +} + interface SharedEntryRow { entry_id: string expires_at: string | null + highlighted_text: string | null + highlight_text_offset: number | null + highlight_text_length: number | null + highlight_text_prefix: string | null + highlight_text_suffix: string | null entries: { id: string title: string | null @@ -30,7 +44,7 @@ async function fetchSharedEntry(token: string) { const { data, error } = await adminClient .from("shared_entries") .select( - "entry_id, expires_at, entries!inner(id, title, url, author, summary, content_html, published_at, enclosure_url, feeds!inner(title))" + "entry_id, expires_at, highlighted_text, highlight_text_offset, highlight_text_length, highlight_text_prefix, highlight_text_suffix, entries!inner(id, title, url, author, summary, content_html, published_at, enclosure_url, feeds!inner(title))" ) .eq("share_token", token) .maybeSingle() @@ -43,7 +57,22 @@ async function fetchSharedEntry(token: string) { return { expired: true as const } } - return { expired: false as const, entry: row.entries } + let highlightData: SharedHighlightData | null = null + if ( + row.highlighted_text && + row.highlight_text_offset !== null && + row.highlight_text_length !== null + ) { + highlightData = { + highlightedText: row.highlighted_text, + textOffset: row.highlight_text_offset, + textLength: row.highlight_text_length, + textPrefix: row.highlight_text_prefix ?? "", + textSuffix: row.highlight_text_suffix ?? "", + } + } + + return { expired: false as const, entry: row.entries, highlightData } } export async function generateMetadata({ @@ -67,18 +96,6 @@ export async function generateMetadata({ } } -function SanitisedContent({ htmlContent }: { htmlContent: string }) { - // Content is sanitised via sanitize-html before rendering - const sanitisedHtml = sanitizeEntryContent(htmlContent) - return ( - <div - className="prose-reader text-text-secondary" - // eslint-disable-next-line react/no-danger -- content sanitised by sanitize-html - dangerouslySetInnerHTML={{ __html: sanitisedHtml }} - /> - ) -} - export default async function SharedPage({ params }: SharedPageProperties) { const { token } = await params const result = await fetchSharedEntry(token) @@ -106,6 +123,7 @@ export default async function SharedPage({ params }: SharedPageProperties) { } const entry = result.entry + const sanitisedHtml = sanitizeEntryContent(entry.content_html || entry.summary || "") const formattedDate = entry.published_at ? new Date(entry.published_at).toLocaleDateString("en-GB", { day: "numeric", @@ -133,8 +151,9 @@ export default async function SharedPage({ params }: SharedPageProperties) { /> </div> )} - <SanitisedContent - htmlContent={entry.content_html || entry.summary || ""} + <SharedEntryContent + sanitisedHtml={sanitisedHtml} + highlightData={result.highlightData} /> </article> <footer className="mt-12 border-t border-border pt-4 text-text-dim"> diff --git a/apps/web/app/shared/[token]/shared-entry-content.tsx b/apps/web/app/shared/[token]/shared-entry-content.tsx new file mode 100644 index 0000000..aaa9892 --- /dev/null +++ b/apps/web/app/shared/[token]/shared-entry-content.tsx @@ -0,0 +1,57 @@ +"use client" + +import { useEffect, useRef } from "react" +import { + deserializeHighlightRange, + applyHighlightToRange, +} from "@/lib/highlight-positioning" + +interface SharedHighlightData { + highlightedText: string + textOffset: number + textLength: number + textPrefix: string + textSuffix: string +} + +interface SharedEntryContentProperties { + sanitisedHtml: string + highlightData: SharedHighlightData | null +} + +export function SharedEntryContent({ + sanitisedHtml, + highlightData, +}: SharedEntryContentProperties) { + const containerReference = useRef<HTMLDivElement>(null) + + useEffect(() => { + const container = containerReference.current + if (!container) return + + container.innerHTML = sanitisedHtml + + if (!highlightData) return + + const highlightRange = deserializeHighlightRange(container, highlightData) + if (!highlightRange) return + + applyHighlightToRange(highlightRange, "shared-highlight", "yellow", false) + + requestAnimationFrame(() => { + const markElement = container.querySelector( + 'mark[data-highlight-identifier="shared-highlight"]' + ) + if (markElement) { + markElement.scrollIntoView({ behavior: "smooth", block: "center" }) + } + }) + }, [sanitisedHtml, highlightData]) + + return ( + <div + ref={containerReference} + className="prose-reader text-text-secondary" + /> + ) +} diff --git a/supabase/schema.sql b/supabase/schema.sql index 5260035..1db41f9 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -146,7 +146,12 @@ CREATE TABLE public.shared_entries ( share_token text NOT NULL, created_at timestamp with time zone NOT NULL DEFAULT now(), expires_at timestamp with time zone, - note text + note text, + highlighted_text text, + highlight_text_offset integer, + highlight_text_length integer, + highlight_text_prefix text DEFAULT ''::text, + highlight_text_suffix text DEFAULT ''::text ); CREATE TABLE public.user_highlights ( |