From cd84e5668401bbb15ed00f447b1edc9934396baa Mon Sep 17 00:00:00 2001 From: Pinapelz Date: Sun, 23 Nov 2025 22:58:48 -0800 Subject: feat: new permalink feature --- middleware/src/app/[gameName]/page.tsx | 544 ++++++++++++++++++++++++++++++-- site/public/locales/en/translation.json | 2 +- site/public/locales/ja/translation.json | 2 +- site/src/components/NewsFeed.tsx | 4 +- 4 files changed, 528 insertions(+), 24 deletions(-) diff --git a/middleware/src/app/[gameName]/page.tsx b/middleware/src/app/[gameName]/page.tsx index dc070db..bce87c8 100644 --- a/middleware/src/app/[gameName]/page.tsx +++ b/middleware/src/app/[gameName]/page.tsx @@ -1,5 +1,22 @@ import { Metadata } from "next"; -import kairosImage from '../kairos.png'; +import kairosImage from "../kairos.png"; +import { createClient } from "@libsql/client"; +import Link from 'next/link'; +interface NewsData { + identifier: string; + type: string | null; + timestamp: number; + headline: string | null; + content: string; + url: string | null; + images: Array<{ + image: string; + link: string | null; + }>; + en_headline: string | null; + en_content: string | null; + is_ai_summary: boolean | null; +} export async function generateMetadata({ params, searchParams, @@ -35,18 +52,93 @@ export async function generateMetadata({ news.content.split("").reduce((hash: number, char: string) => { return (hash << 5) + hash + char.charCodeAt(0); }, 5381) >>> 0; - const headlineHash = (news.headline || 'null').split('').reduce((hash: number, char: string) => ((hash << 5) + hash) + char.charCodeAt(0), 5381) >>> 0; + const headlineHash = + (news.headline || "null") + .split("") + .reduce( + (hash: number, char: string) => + (hash << 5) + hash + char.charCodeAt(0), + 5381, + ) >>> 0; const newsId = `${news.identifier}-${news.timestamp}-${contentHash.toString(16)}-${headlineHash.toString(16)}`; return newsId === postId; }); if (!matchingPost) { - return { title: "Post not found" }; + try { + const client = createClient({ + url: process.env.SQLITE_DB!, + authToken: process.env.REMOTE_AUTH_TOKEN!, + }); + const result = await client.execute({ + sql: `SELECT + news_id, date, identifier, type, timestamp, + headline, content, url, is_ai_summary, + en_headline, en_content + FROM news + WHERE news_id = ?`, + args: [postId], + }); + + if (result.rows.length === 0) { + return { title: "Post not found" }; + } + const row = result.rows[0]; + const imagesResult = await client.execute({ + sql: `SELECT image_url, link_url FROM news_images WHERE news_id = ?`, + args: [postId], + }); + + const images = imagesResult.rows.map((img) => ({ + image: img.image_url, + link: img.link_url, + })); + const dbPost = { + news_id: row.news_id, + date: row.date, + identifier: row.identifier, + type: row.type, + timestamp: row.timestamp, + headline: row.headline, + content: row.content, + url: row.url, + is_ai_summary: Boolean(row.is_ai_summary), + en_headline: row.en_headline, + en_content: row.en_content, + images, + }; + if (lang === "en") { + if (dbPost.en_headline !== null) { + dbPost.headline = dbPost.en_headline; + } + if (dbPost.en_content !== null) { + dbPost.content = dbPost.en_content; + } + } + + if (!dbPost.headline) { + dbPost.headline = dbPost.content; + } + return { + title: String(dbPost.headline || "Untitled"), + description: String(dbPost.content || "").slice(0, 300), + openGraph: { + title: String(dbPost.headline || "Untitled"), + description: String(dbPost.content || "").slice(0, 300), + images: dbPost.images?.[0]?.image + ? [String(dbPost.images[0].image)] + : [], + }, + }; + } catch (dbErr) { + console.error("Database fallback error:", dbErr); + return { title: "Post not found" }; + } } - if (lang === "en"){ - if(matchingPost.en_headline !== null){ + if (lang === "en") { + if (matchingPost.en_headline !== null) { matchingPost.headline = matchingPost.en_headline; } - if(matchingPost.en_content !== null){ + if (matchingPost.en_content !== null) { matchingPost.content = matchingPost.en_content; } } @@ -84,22 +176,114 @@ export default async function GamePage({ const resolvedSearchParams = await searchParams; const gameName = resolvedParams.gameName || "news"; const postId = resolvedSearchParams.post as string | undefined; + const lang = resolvedSearchParams.lang as string | undefined; const mainNewsUrl = process.env.NEXT_PUBLIC_MAIN_NEWS_URL; - const { headers } = await import("next/headers"); - const { userAgent } = await import("next/server"); - const headersList = await headers(); - const ua = userAgent({ headers: headersList }); - - if (postId && mainNewsUrl && !ua.isBot) { - const { redirect } = await import("next/navigation"); - if(gameName === "news"){ - redirect(`${mainNewsUrl}/#${postId}`); + const apiUrlBase = process.env.NEXT_PUBLIC_API_URL; + + if (postId) { + let newsPost: NewsData | null = null; + + try { + let fetchUrl = `${apiUrlBase}/${gameName}_news.json`; + if (gameName === "news") { + fetchUrl = `${apiUrlBase}/news.json`; + } + const res = await fetch(fetchUrl); + if (res.ok) { + const data = await res.json(); + const newsPosts = data.news_posts; + const matchingPost = newsPosts.find((news: any) => { + const contentHash = + news.content.split("").reduce((hash: number, char: string) => { + return (hash << 5) + hash + char.charCodeAt(0); + }, 5381) >>> 0; + const headlineHash = + (news.headline || "null") + .split("") + .reduce( + (hash: number, char: string) => + (hash << 5) + hash + char.charCodeAt(0), + 5381, + ) >>> 0; + const newsId = `${news.identifier}-${news.timestamp}-${contentHash.toString(16)}-${headlineHash.toString(16)}`; + return newsId === postId; + }); + + if (matchingPost) { + newsPost = matchingPost; + } + } + + // If not found in JSON, try database + if (!newsPost) { + const client = createClient({ + url: process.env.SQLITE_DB!, + authToken: process.env.REMOTE_AUTH_TOKEN!, + }); + + const result = await client.execute({ + sql: `SELECT + news_id, date, identifier, type, timestamp, + headline, content, url, is_ai_summary, + en_headline, en_content + FROM news + WHERE news_id = ?`, + args: [postId], + }); + + if (result.rows.length > 0) { + const row = result.rows[0]; + + // Get images for this news post + const imagesResult = await client.execute({ + sql: `SELECT image_url, link_url FROM news_images WHERE news_id = ?`, + args: [postId], + }); + + const images = imagesResult.rows.map((img) => ({ + image: img.image_url, + link: img.link_url, + })); + + newsPost = { + identifier: row.identifier as string, + type: row.type as string | null, + timestamp: row.timestamp as number, + headline: row.headline as string | null, + content: row.content as string, + url: row.url as string | null, + is_ai_summary: Boolean(row.is_ai_summary), + en_headline: row.en_headline as string | null, + en_content: row.en_content as string | null, + images, + }; + } + } + } catch (err) { + console.error("Error fetching news post:", err); + } + + // If we found the post, render it + if (newsPost) { + return ( + + ); } - redirect(`${mainNewsUrl}/game/${gameName}#${postId}`); } + // Default fallback page const redirectUrl = - postId && mainNewsUrl ? (gameName === "news" ? `${mainNewsUrl}/#${postId}` : `${mainNewsUrl}/game/${gameName}#${postId}`) : mainNewsUrl; + postId && mainNewsUrl + ? gameName === "news" + ? `${mainNewsUrl}/#${postId}` + : `${mainNewsUrl}/game/${gameName}#${postId}` + : mainNewsUrl; return (
@@ -110,15 +294,333 @@ export default async function GamePage({ alt="Updates image" className="updates-image" /> + {postId && !redirectUrl && ( +

Post not found

+ )} {redirectUrl && ( <> -
- - click here if not redirected - +
+ + click here if not redirected + )}
); } + +// Component to render a single news post +function NewsPostPage({ + newsPost, + lang, + gameName, + postId, + mainNewsUrl, +}: { + newsPost: NewsData; + lang?: string; + gameName: string; + postId: string; + mainNewsUrl?: string; +}) { + let displayHeadline = newsPost.headline; + let displayContent = newsPost.content; + + if (lang === "en") { + if (newsPost.en_headline !== null) { + displayHeadline = newsPost.en_headline; + } + if (newsPost.en_content !== null) { + displayContent = newsPost.en_content; + } + } + + if (!displayHeadline) { + displayHeadline = displayContent; + } + + const date = new Date(newsPost.timestamp * 1000).toLocaleDateString("ja-JP", { + year: "numeric", + month: "2-digit", + day: "2-digit", + }); + + const redirectUrl = mainNewsUrl + ? gameName === "news" + ? `${mainNewsUrl}/#${postId}` + : `${mainNewsUrl}/game/${gameName}#${postId}` + : null; + + return ( +
+
+
+ {/* Post Header */} +
+
+ {date} +
+ {newsPost.type && ( +
+ {newsPost.type} +
+ )} +
+ + {/* Content */} +
+ {displayHeadline && ( +

+ {displayHeadline} +

+ )} +
+ {displayContent + .split(/(\[.*?\]\(.*?\)|https?:\/\/[^\s]+)/g) + .map((part, idx) => { + const linkMatch = part.match(/\[(.*?)\]\((.*?)\)/); + const urlMatch = part.match(/https?:\/\/[^\s]+/); + if (linkMatch) { + return ( + + {linkMatch[1]} + + ); + } + if (urlMatch) { + return ( + + {urlMatch[0]} + + ); + } + return part; + })} +
+
+ + {/* AI Disclaimer */} + {newsPost.is_ai_summary && ( +
+ This content was generated using AI and may contain inaccuracies +
+ )} + + {/* Machine Translation Disclaimer */} + {(newsPost.en_headline || newsPost.en_content) && lang === "en" && ( +
+ This is a machine translation and may contain errors +
+ )} + + {/* Images */} + {newsPost.images && newsPost.images.length > 0 && ( +
+ News visual +
+ )} + + {/* Read More Link */} + {newsPost.url && ( +
+ + READ MORE + +
+ )} +
+ + {/* About 573 UPDATES */} +
+

+ This is a perma-link hosted on 573 UPDATES +

+

+ A news aggregator for some arcade (and some not-so arcade) games. + Image data is loaded from external sources, and as such may not + always be available. +

+
+ + {/* Navigation Buttons */} +
+ + Back to 573 UPDATES + +
+
+
+ ); +} diff --git a/site/public/locales/en/translation.json b/site/public/locales/en/translation.json index 9221da6..206e887 100644 --- a/site/public/locales/en/translation.json +++ b/site/public/locales/en/translation.json @@ -18,7 +18,7 @@ "ai_summary_note": "The information above is written by AI", "read_more": "READ MORE", "copy_link_to_post": "Copy Link to Post", - "copy_link_notif": "Copied Direct Link to Post (Older news are automatically culled after some time)", + "copy_link_notif": "Copied Perma Link to Post)", "subscribed_to_games_count": "Subscribed to", "games": "game(s)", "gameselector": { diff --git a/site/public/locales/ja/translation.json b/site/public/locales/ja/translation.json index ccf96b0..02c64e2 100644 --- a/site/public/locales/ja/translation.json +++ b/site/public/locales/ja/translation.json @@ -18,7 +18,7 @@ "ai_summary_note": "上記の情報はAIによって生成されました", "read_more": "続きを読む", "copy_link_to_post": "投稿へのリンクをコピー", - "copy_link_notif": "投稿への直接リンクをコピーしました(古いニュースは一定時間後に自動的に削除されます)", + "copy_link_notif": "投稿への直接リンクをコピーしました", "subscribed_to_games_count": "通知購読中", "games": "ゲーム", "gameselector": { diff --git a/site/src/components/NewsFeed.tsx b/site/src/components/NewsFeed.tsx index 2e08b16..ed854a0 100644 --- a/site/src/components/NewsFeed.tsx +++ b/site/src/components/NewsFeed.tsx @@ -18,6 +18,7 @@ export interface NewsData { en_headline: string | null; en_content: string | null; is_ai_summary: boolean | null; + archive_hash: string | null; } interface NewsFeedProps { @@ -108,7 +109,8 @@ export const NewsFeed: React.FC = ({ newsItems }) => { (hash, char) => (hash << 5) + hash + char.charCodeAt(0), 5381, ) >>> 0; - const newsId = `${news.identifier}-${news.timestamp}-${contentHash.toString(16)}-${headlineHash.toString(16)}`; + const legacyId = `${news.identifier}-${news.timestamp}-${contentHash.toString(16)}-${headlineHash.toString(16)}`; + const newsId = news.archive_hash || legacyId; const isEnglish = showEnglish[newsId]; const hasTranslation = news.en_headline || news.en_content; const displayHeadline = -- cgit v1.2.3