aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorPinapelz <yukais@pinapelz.com>2025-11-23 22:58:48 -0800
committerPinapelz <yukais@pinapelz.com>2025-11-23 22:58:48 -0800
commitcd84e5668401bbb15ed00f447b1edc9934396baa (patch)
tree7715adae2eeb29e53236b9134bc86fc1b4fc3cb9
parent3e1e18263eb79029303b0933e60623b5860f475c (diff)
feat: new permalink feature
-rw-r--r--middleware/src/app/[gameName]/page.tsx542
-rw-r--r--site/public/locales/en/translation.json2
-rw-r--r--site/public/locales/ja/translation.json2
-rw-r--r--site/src/components/NewsFeed.tsx4
4 files changed, 527 insertions, 23 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 });
+ 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 (postId && mainNewsUrl && !ua.isBot) {
- const { redirect } = await import("next/navigation");
- if(gameName === "news"){
- redirect(`${mainNewsUrl}/#${postId}`);
+ // 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 (
+ <NewsPostPage
+ newsPost={newsPost}
+ lang={lang}
+ gameName={gameName}
+ postId={postId}
+ mainNewsUrl={mainNewsUrl}
+ />
+ );
}
- 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 (
<main className="main">
@@ -110,15 +294,333 @@ export default async function GamePage({
alt="Updates image"
className="updates-image"
/>
+ {postId && !redirectUrl && (
+ <p style={{ color: "red", margin: "20px 0" }}>Post not found</p>
+ )}
{redirectUrl && (
<>
- <br/>
- <a href={redirectUrl} className="redirect-link">
- click here if not redirected
- </a>
+ <br />
+ <a href={redirectUrl} className="redirect-link">
+ click here if not redirected
+ </a>
</>
)}
</div>
</main>
);
}
+
+// 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 (
+ <main
+ style={{
+ minHeight: "100vh",
+ color: "white",
+ fontFamily: "system-ui, -apple-system, sans-serif",
+ }}
+ >
+ <div style={{
+ width: '100%',
+ maxWidth: '600px',
+ margin: '0 auto',
+ padding: '10px',
+ paddingTop: '20px',
+ paddingBottom: '20px',
+ boxSizing: 'border-box'
+ }}>
+ <div style={{
+ backgroundColor: '#1e293b',
+ border: '1px solid #334155',
+ borderRadius: '8px',
+ boxShadow: '0 10px 20px -5px rgba(0, 0, 0, 0.3)',
+ overflow: 'hidden',
+ width: '100%',
+ boxSizing: 'border-box'
+ }}>
+ {/* Post Header */}
+ <div style={{
+ padding: '12px',
+ borderBottom: '1px solid #475569'
+ }}>
+ <div style={{
+ fontSize: '13px',
+ opacity: 0.8,
+ marginBottom: '6px',
+ color: '#94a3b8'
+ }}>
+ {date}
+ </div>
+ {newsPost.type && (
+ <div style={{
+ fontSize: '12px',
+ fontStyle: 'italic',
+ opacity: 0.8,
+ color: '#64748b',
+ backgroundColor: '#334155',
+ padding: '3px 6px',
+ borderRadius: '3px',
+ display: 'inline-block'
+ }}>
+ {newsPost.type}
+ </div>
+ )}
+ </div>
+
+ {/* Content */}
+ <div style={{
+ padding: '12px',
+ minHeight: '120px'
+ }}>
+ {displayHeadline && (
+ <h2 style={{
+ fontWeight: '700',
+ fontSize: '16px',
+ marginBottom: '12px',
+ margin: '0 0 12px 0',
+ lineHeight: '1.3',
+ color: '#f1f5f9',
+ wordWrap: 'break-word',
+ overflowWrap: 'break-word'
+ }}>
+ {displayHeadline}
+ </h2>
+ )}
+ <div style={{
+ fontSize: '13px',
+ whiteSpace: 'pre-line',
+ marginBottom: '12px',
+ margin: '0 0 12px 0',
+ lineHeight: '1.5',
+ color: '#e2e8f0'
+ }}>
+ {displayContent
+ .split(/(\[.*?\]\(.*?\)|https?:\/\/[^\s]+)/g)
+ .map((part, idx) => {
+ const linkMatch = part.match(/\[(.*?)\]\((.*?)\)/);
+ const urlMatch = part.match(/https?:\/\/[^\s]+/);
+ if (linkMatch) {
+ return (
+ <Link
+ key={idx}
+ href={linkMatch[2]}
+ target="_blank"
+ rel="noopener noreferrer"
+ style={{
+ color: "#60a5fa",
+ textDecoration: "underline",
+ textDecorationColor: "#3b82f6",
+ fontWeight: "500",
+ }}
+ >
+ {linkMatch[1]}
+ </Link>
+ );
+ }
+ if (urlMatch) {
+ return (
+ <Link
+ key={idx}
+ href={urlMatch[0]}
+ target="_blank"
+ rel="noopener noreferrer"
+ style={{
+ color: "#60a5fa",
+ textDecoration: "underline",
+ textDecorationColor: "#3b82f6",
+ fontWeight: "500",
+ }}
+ >
+ {urlMatch[0]}
+ </Link>
+ );
+ }
+ return part;
+ })}
+ </div>
+ </div>
+
+ {/* AI Disclaimer */}
+ {newsPost.is_ai_summary && (
+ <div
+ style={{
+ backgroundColor: "#475569",
+ padding: "10px 16px",
+ fontSize: "12px",
+ textAlign: "center",
+ color: "#cbd5e1",
+ }}
+ >
+ This content was generated using AI and may contain inaccuracies
+ </div>
+ )}
+
+ {/* Machine Translation Disclaimer */}
+ {(newsPost.en_headline || newsPost.en_content) && lang === "en" && (
+ <div
+ style={{
+ backgroundColor: "#475569",
+ padding: "10px 16px",
+ fontSize: "12px",
+ textAlign: "center",
+ color: "#cbd5e1",
+ }}
+ >
+ This is a machine translation and may contain errors
+ </div>
+ )}
+
+ {/* Images */}
+ {newsPost.images && newsPost.images.length > 0 && (
+ <div
+ style={{
+ width: "100%",
+ overflow: "hidden",
+ }}
+ >
+ <img
+ src={newsPost.images[0].image}
+ alt="News visual"
+ style={{
+ width: "100%",
+ height: "auto",
+ maxHeight: "400px",
+ objectFit: "contain",
+ display: "block",
+ }}
+ />
+ </div>
+ )}
+
+ {/* Read More Link */}
+ {newsPost.url && (
+ <div
+ style={{
+ backgroundColor: "#475569",
+ padding: "12px 16px",
+ textAlign: "center",
+ }}
+ >
+ <Link
+ href={newsPost.url}
+ target="_blank"
+ rel="noopener noreferrer"
+ style={{
+ fontSize: "15px",
+ textDecoration: "underline",
+ textDecorationColor: "#60a5fa",
+ fontWeight: "600",
+ color: "#60a5fa",
+ }}
+ >
+ READ MORE
+ </Link>
+ </div>
+ )}
+ </div>
+
+ {/* About 573 UPDATES */}
+ <div style={{
+ textAlign: 'center',
+ marginTop: '24px',
+ marginBottom: '16px',
+ padding: '12px',
+ backgroundColor: '#334155',
+ borderRadius: '6px',
+ border: '1px solid #475569'
+ }}>
+ <h3 style={{
+ fontSize: '15px',
+ fontWeight: '600',
+ margin: '0 0 6px 0',
+ color: '#f1f5f9'
+ }}>
+ This is a perma-link hosted on 573 UPDATES
+ </h3>
+ <p style={{
+ fontSize: '12px',
+ color: '#cbd5e1',
+ margin: 0,
+ lineHeight: '1.4'
+ }}>
+ 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.
+ </p>
+ </div>
+
+ {/* Navigation Buttons */}
+ <div style={{
+ textAlign: 'center',
+ marginTop: '12px',
+ display: 'flex',
+ flexDirection: 'column',
+ gap: '10px',
+ alignItems: 'center'
+ }}>
+ <Link
+ href="/"
+ style={{
+ display: 'block',
+ background: 'linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)',
+ color: 'white',
+ padding: '14px 20px',
+ borderRadius: '6px',
+ textDecoration: 'none',
+ fontSize: '14px',
+ fontWeight: '600',
+ boxShadow: '0 4px 12px rgba(59, 130, 246, 0.3)',
+ border: 'none',
+ transition: 'all 0.2s ease',
+ width: '100%',
+ maxWidth: '280px',
+ textAlign: 'center'
+ }}
+ >
+ Back to 573 UPDATES
+ </Link>
+ </div>
+ </div>
+ </main>
+ );
+}
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<NewsFeedProps> = ({ 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 =
send patches to the email below
yukais@pinapelz.com
include the subject [PATCH repo_name]
pinapelz.com
homepage