summaryrefslogtreecommitdiff
path: root/apps/web/app/shared
diff options
context:
space:
mode:
authorFuwn <[email protected]>2026-02-07 01:42:57 -0800
committerFuwn <[email protected]>2026-02-07 01:42:57 -0800
commit5c5b1993edd890a80870ee05607ac5f088191d4e (patch)
treea721b76bcd49ba10826c53efc87302c7a689512f /apps/web/app/shared
downloadasa.news-5c5b1993edd890a80870ee05607ac5f088191d4e.tar.xz
asa.news-5c5b1993edd890a80870ee05607ac5f088191d4e.zip
feat: asa.news RSS reader with developer tier, REST API, and webhooks
Full-stack RSS reader SaaS: Supabase + Next.js + Go worker. Includes three subscription tiers (free/pro/developer), API key auth, read-only REST API, webhook push notifications, Stripe billing with proration, and PWA support.
Diffstat (limited to 'apps/web/app/shared')
-rw-r--r--apps/web/app/shared/[token]/page.tsx165
1 files changed, 165 insertions, 0 deletions
diff --git a/apps/web/app/shared/[token]/page.tsx b/apps/web/app/shared/[token]/page.tsx
new file mode 100644
index 0000000..222c1c8
--- /dev/null
+++ b/apps/web/app/shared/[token]/page.tsx
@@ -0,0 +1,165 @@
+import type { Metadata } from "next"
+import { createSupabaseAdminClient } from "@/lib/supabase/admin"
+import { sanitizeEntryContent } from "@/lib/sanitize"
+
+interface SharedPageProperties {
+ params: Promise<{ token: string }>
+}
+
+interface SharedEntryRow {
+ entry_id: string
+ expires_at: string | null
+ entries: {
+ id: string
+ title: string | null
+ url: string | null
+ author: string | null
+ summary: string | null
+ content_html: string | null
+ published_at: string | null
+ enclosure_url: string | null
+ feeds: {
+ title: string | null
+ }
+ }
+}
+
+async function fetchSharedEntry(token: string) {
+ const adminClient = createSupabaseAdminClient()
+
+ 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))"
+ )
+ .eq("share_token", token)
+ .maybeSingle()
+
+ if (error || !data) return null
+
+ const row = data as unknown as SharedEntryRow
+
+ if (row.expires_at && new Date(row.expires_at) < new Date()) {
+ return { expired: true as const }
+ }
+
+ return { expired: false as const, entry: row.entries }
+}
+
+export async function generateMetadata({
+ params,
+}: SharedPageProperties): Promise<Metadata> {
+ const { token } = await params
+ const result = await fetchSharedEntry(token)
+
+ if (!result || result.expired) {
+ return { title: "shared entry — asa.news" }
+ }
+
+ return {
+ title: `${result.entry.title ?? "untitled"} — asa.news`,
+ description: result.entry.summary?.slice(0, 200) ?? undefined,
+ openGraph: {
+ title: result.entry.title ?? "shared entry",
+ description: result.entry.summary?.slice(0, 200) ?? undefined,
+ siteName: "asa.news",
+ },
+ }
+}
+
+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)
+
+ if (!result) {
+ return (
+ <div className="mx-auto max-w-2xl px-6 py-16 text-center">
+ <h1 className="mb-4 text-text-primary">shared entry not found</h1>
+ <p className="text-text-secondary">
+ this shared link is no longer available or has been removed.
+ </p>
+ </div>
+ )
+ }
+
+ if (result.expired) {
+ return (
+ <div className="mx-auto max-w-2xl px-6 py-16 text-center">
+ <h1 className="mb-4 text-text-primary">this share has expired</h1>
+ <p className="text-text-secondary">
+ shared links expire after a set period. the owner may share it again if needed.
+ </p>
+ </div>
+ )
+ }
+
+ const entry = result.entry
+ const formattedDate = entry.published_at
+ ? new Date(entry.published_at).toLocaleDateString("en-GB", {
+ day: "numeric",
+ month: "long",
+ year: "numeric",
+ })
+ : null
+
+ return (
+ <div className="mx-auto max-w-2xl px-6 py-8">
+ <article>
+ <h1 className="mb-2 text-lg text-text-primary">{entry.title}</h1>
+ <div className="mb-6 text-text-dim">
+ {entry.feeds?.title && <span>{entry.feeds.title}</span>}
+ {entry.author && <span> &middot; {entry.author}</span>}
+ {formattedDate && <span> &middot; {formattedDate}</span>}
+ </div>
+ {entry.enclosure_url && (
+ <div className="mb-4 border border-border p-3">
+ <audio
+ controls
+ preload="none"
+ src={entry.enclosure_url}
+ className="w-full"
+ />
+ </div>
+ )}
+ <SanitisedContent
+ htmlContent={entry.content_html || entry.summary || ""}
+ />
+ </article>
+ <footer className="mt-12 border-t border-border pt-4 text-text-dim">
+ <p>
+ shared from{" "}
+ <a
+ href="/"
+ className="text-text-secondary transition-colors hover:text-text-primary"
+ >
+ asa.news
+ </a>
+ </p>
+ {entry.url && (
+ <p className="mt-1">
+ <a
+ href={entry.url}
+ target="_blank"
+ rel="noopener noreferrer"
+ className="text-text-secondary transition-colors hover:text-text-primary"
+ >
+ view original
+ </a>
+ </p>
+ )}
+ </footer>
+ </div>
+ )
+}