summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorFuwn <[email protected]>2026-02-08 07:07:59 -0800
committerFuwn <[email protected]>2026-02-08 07:07:59 -0800
commita33dbfa6a1cb1d34ce9a5286efdb25818ff7b6c1 (patch)
tree7d44bdcb94cc1b69fbc201a4757f27f3751c5adb
parentchore: gate Vercel analytics and speed insights to production only (diff)
downloadasa.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.tsx2
-rw-r--r--apps/web/app/(auth)/sign-in/page.tsx2
-rw-r--r--apps/web/app/api/share/route.ts39
-rw-r--r--apps/web/app/reader/_components/entry-detail-panel.tsx58
-rw-r--r--apps/web/app/reader/_components/highlight-selection-toolbar.tsx9
-rw-r--r--apps/web/app/shared/[token]/page.tsx51
-rw-r--r--apps/web/app/shared/[token]/shared-entry-content.tsx57
-rw-r--r--supabase/schema.sql7
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 (