diff --git a/README.md b/README.md index 9566eec..d2604ea 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Hackdex is a community hub for discovering and sharing Pokémon romhack patches. - **Discover**: curated hacks with screenshots, tags, versions, and summaries - **Submit**: metadata, screenshots, social links, and a BPS patch file - **Patch in the browser**: Powered by [RomPatcher.js](https://github.com/marcrobledo/RomPatcher.js); linked base roms stay on the user's device -- **Safe delivery**: short-lived signed URLs for assets and downloads; no rom storage required +- **Safe delivery**: public urls for cover images, short-lived signed URLs for patch downloads and other assets; no rom storage required ## Tech stack @@ -28,7 +28,7 @@ Hackdex is a community hub for discovering and sharing Pokémon romhack patches. ## High-level architecture - UI: Next.js App Router with a mix of server and client components -- Data: Supabase tables for hacks, tags, covers, patches; signed URLs for `hack-covers` +- Data: Supabase tables for hacks, tags, patches; cover images stored in `covers` S3-compatible bucket - Patches: stored in an S3-compatible bucket `patches`; downloads use short-lived signed URLs via an API route - Auth: Supabase SSR helpers manage cookies; client SDK for browser calls @@ -66,6 +66,8 @@ NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY= NEXT_PUBLIC_SITE_URL= NEXT_PUBLIC_SITE_DOMAIN= +NEXT_PUBLIC_HACK_COVERS_DOMAIN= + S3_ENDPOINT= S3_PORT= S3_ACCESS_KEY_ID= @@ -84,12 +86,15 @@ Typical flow: 1) Initialize and start services using the CLI 2) Note the printed API URL and publishable key; set them in `.env.local` as shown above 3) Apply this repository’s migrations in `supabase/migrations` -4) Create a Supabase Storage bucket named `hack-covers` (private is fine; the app uses signed URLs) +4) Create a Supabase Storage bucket named `hack-covers` and make it public. Set the `NEXT_PUBLIC_HACK_COVERS_DOMAIN` environment variable to the public URL of your covers bucket (e.g., `https://your-project.supabase.co/storage/v1/object/public/hack-covers`) -### S3‑compatible storage for patches +### S3‑compatible storage for patches and covers -- Create a bucket named `patches` +- Create a bucket named `patches` and a public bucket named `covers` - Point the `S3_*` environment variables to your S3 endpoint (Minio locally or your vendor) +- Set the `COVERS_BUCKET` environment variable to the name of your covers bucket (e.g., `covers`) +- Set the `PATCHES_BUCKET` environment variable to the name of your patches bucket (e.g., `patches`) +- Set the `NEXT_PUBLIC_HACK_COVERS_DOMAIN` environment variable to the public URL of your covers bucket (e.g., `http://localhost:9000/covers`) ### Install & run diff --git a/next.config.ts b/next.config.ts index 9712f9d..223de60 100644 --- a/next.config.ts +++ b/next.config.ts @@ -10,8 +10,22 @@ const nextConfig: NextConfig = { port: '54321', pathname: '/storage/v1/**', }, + { + protocol: 'https', + hostname: 'euma9sxl9y.ufs.sh', + pathname: '/f/**', + } ], }, + redirects: async () => { + return [ + { + source: '/img/badge-dark.png', + destination: 'https://euma9sxl9y.ufs.sh/f/zxX0gGr1fg9cnaso9bhNGy79goWrVw5OTBFjMdbiXvASpLRE', + permanent: false, + }, + ]; + }, turbopack: { resolveExtensions: ['.mdx', '.md', '.tsx', '.ts', '.jsx', '.js', '.mjs', '.json'], rules: { diff --git a/package-lock.json b/package-lock.json index 5f855ff..9fc67df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,8 @@ "embla-carousel-react": "8.6.0", "mdast-util-to-hast": "^13.2.1", "minio": "^8.0.6", - "next": "^15.5.7", + "next": "^15.5.8", + "next-turnstile": "^1.0.7", "nodemailer": "^7.0.11", "react": "^19.1.2", "react-chartjs-2": "^5.3.1", @@ -32,7 +33,8 @@ "remark-gfm": "4.0.0", "rom-patcher-js": "github:Hackdex-App/RomPatcher.js", "schema-dts": "^1.1.5", - "serialize-javascript": "^7.0.0" + "serialize-javascript": "^7.0.0", + "uuid": "^13.0.0" }, "devDependencies": { "@tailwindcss/postcss": "^4", @@ -1450,9 +1452,9 @@ "license": "MIT" }, "node_modules/@next/env": { - "version": "15.5.7", - "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.7.tgz", - "integrity": "sha512-4h6Y2NyEkIEN7Z8YxkA27pq6zTkS09bUSYC0xjd0NpwFxjnIKeZEeH591o5WECSmjpUhLn3H2QLJcDye3Uzcvg==", + "version": "15.5.9", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.9.tgz", + "integrity": "sha512-4GlTZ+EJM7WaW2HEZcyU317tIQDjkQIyENDLxYJfSWlfqguN+dHkZgyQTV/7ykvobU7yEH5gKvreNrH4B6QgIg==", "license": "MIT" }, "node_modules/@next/swc-darwin-arm64": { @@ -5881,12 +5883,12 @@ "peer": true }, "node_modules/next": { - "version": "15.5.7", - "resolved": "https://registry.npmjs.org/next/-/next-15.5.7.tgz", - "integrity": "sha512-+t2/0jIJ48kUpGKkdlhgkv+zPTEOoXyr60qXe68eB/pl3CMJaLeIGjzp5D6Oqt25hCBiBTt8wEeeAzfJvUKnPQ==", + "version": "15.5.9", + "resolved": "https://registry.npmjs.org/next/-/next-15.5.9.tgz", + "integrity": "sha512-agNLK89seZEtC5zUHwtut0+tNrc0Xw4FT/Dg+B/VLEo9pAcS9rtTKpek3V6kVcVwsB2YlqMaHdfZL4eLEVYuCg==", "license": "MIT", "dependencies": { - "@next/env": "15.5.7", + "@next/env": "15.5.9", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", @@ -5932,6 +5934,17 @@ } } }, + "node_modules/next-turnstile": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/next-turnstile/-/next-turnstile-1.0.7.tgz", + "integrity": "sha512-hN0fr5fwOiK0o8draaJmcYElDqsiYBd4OOCy+0IKMlJgjpPzxtoyagojPBoHsyw9i3jVoBpusPZcUMuKuKQsNw==", + "license": "MIT", + "peerDependencies": { + "next": ">=12", + "react": ">=16", + "react-dom": ">=16" + } + }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", @@ -7164,6 +7177,19 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/uuid": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", + "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", diff --git a/package.json b/package.json index e127894..8e89d42 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,8 @@ "embla-carousel-react": "8.6.0", "mdast-util-to-hast": "^13.2.1", "minio": "^8.0.6", - "next": "^15.5.7", + "next": "^15.5.8", + "next-turnstile": "^1.0.7", "nodemailer": "^7.0.11", "react": "^19.1.2", "react-chartjs-2": "^5.3.1", @@ -33,7 +34,8 @@ "remark-gfm": "4.0.0", "rom-patcher-js": "github:Hackdex-App/RomPatcher.js", "schema-dts": "^1.1.5", - "serialize-javascript": "^7.0.0" + "serialize-javascript": "^7.0.0", + "uuid": "^13.0.0" }, "devDependencies": { "@tailwindcss/postcss": "^4", diff --git a/src/app/account/actions.ts b/src/app/account/actions.ts index 6931281..ba5089e 100644 --- a/src/app/account/actions.ts +++ b/src/app/account/actions.ts @@ -55,7 +55,7 @@ export async function setupProfile(username: string): Promise { await sendDiscordMessageEmbed(process.env.DISCORD_WEBHOOK_ADMIN_URL, [ { title: 'New Profile Setup', - description: `A new user (\`${user.id}\`) has setup their profile with the username: \`@${username}\``, + description: `A new user (\`${user.id}\`) has created an account with the username: \`@${username}\``, color: 0x40f56a, footer: { text: 'This message brought to you by Hackdex' diff --git a/src/app/hack/[slug]/edit/page.tsx b/src/app/hack/[slug]/edit/page.tsx index f29d0da..b17fba0 100644 --- a/src/app/hack/[slug]/edit/page.tsx +++ b/src/app/hack/[slug]/edit/page.tsx @@ -3,7 +3,7 @@ import HackForm from "@/components/Hack/HackForm"; import { createClient } from "@/utils/supabase/server"; import { FaChevronLeft, FaChevronRight } from "react-icons/fa6"; import Link from "next/link"; -import { getCoverSignedUrls } from "@/app/hack/actions"; +import { getCoverUrls } from "@/utils/format"; import { checkEditPermission } from "@/utils/hack"; interface EditPageProps { @@ -42,7 +42,7 @@ export default async function EditHackPage({ params }: EditPageProps) { .order("position", { ascending: true }); if (covers && covers.length > 0) { coverKeys = covers.map((c: any) => c.url); - signedCoverUrls = await getCoverSignedUrls(coverKeys); + signedCoverUrls = getCoverUrls(coverKeys); } const { data: tagRows } = await supabase diff --git a/src/app/hack/[slug]/page.tsx b/src/app/hack/[slug]/page.tsx index 29c72d6..8db968f 100644 --- a/src/app/hack/[slug]/page.tsx +++ b/src/app/hack/[slug]/page.tsx @@ -12,15 +12,16 @@ import PokeCommunityIcon from "@/components/Icons/PokeCommunityIcon"; import { createClient, createServiceClient } from "@/utils/supabase/server"; import HackOptionsMenu from "@/components/Hack/HackOptionsMenu"; import DownloadsBadge from "@/components/Hack/DownloadsBadge"; +import HackShareButton from "@/components/Hack/HackShareButton"; import type { CreativeWork, WithContext } from "schema-dts"; import serialize from "serialize-javascript"; import { headers } from "next/headers"; import { MenuItem } from "@headlessui/react"; import { FaCircleCheck } from "react-icons/fa6"; -import { sortOrderedTags } from "@/utils/format"; +import { sortOrderedTags, getCoverUrls } from "@/utils/format"; import { RiArchiveStackFill } from "react-icons/ri"; -import { getCoverSignedUrls } from "@/app/hack/actions"; import { isInformationalArchiveHack, isDownloadableArchiveHack, isArchiveHack, checkEditPermission } from "@/utils/hack"; +import Avatar from "@/components/Account/Avatar"; interface HackDetailProps { params: Promise<{ slug: string }>; @@ -130,7 +131,7 @@ export default async function HackDetail({ params }: HackDetailProps) { .eq("hack_slug", slug) .order("position", { ascending: true }); if (covers && covers.length > 0) { - images = await getCoverSignedUrls(covers.map(c => c.url)); + images = getCoverUrls(covers.map(c => c.url)); } const { data: tagRows } = await supabase @@ -148,7 +149,7 @@ export default async function HackDetail({ params }: HackDetailProps) { const { data: profile } = await supabase .from("profiles") - .select("username") + .select("username,avatar_url") .eq("id", hack.created_by as string) .maybeSingle(); const author = profile?.username ? `@${profile.username}` : "Unknown"; @@ -339,9 +340,23 @@ export default async function HackDetail({ params }: HackDetailProps) { {patchVersion || "Pre-release"} + {!isArchive && ( +
+ +
+ )} -

By {isArchive ? (hack.original_author || "Unknown") : author}

-

{hack.summary}

+
+ {!isArchive && ( + + )} +

By {isArchive ? (hack.original_author || "Unknown") : author}

+
+

{hack.summary}

@@ -352,7 +367,12 @@ export default async function HackDetail({ params }: HackDetailProps) { ))}
- {!isArchive && } + {!isArchive && ( +
+ +
+ )} + {isAdmin && !hack.approved && ( client.presignedGetObject(COVERS_BUCKET, key, 60 * 5)) - ); - return urls; -} export async function approveHack(slug: string) { const supabase = await createClient(); @@ -295,7 +280,7 @@ export async function approveHack(slug: string) { // Check if hack exists const { data: hack, error: hErr } = await supabase .from("hacks") - .select("slug, approved, title") + .select("slug, approved, title, created_by") .eq("slug", slug) .maybeSingle(); if (hErr) return { ok: false, error: hErr.message } as const; @@ -319,8 +304,8 @@ export async function approveHack(slug: string) { if (updateErr) return { ok: false, error: updateErr.message } as const; - if (process.env.DISCORD_WEBHOOK_ADMIN_URL) { - const { data: profile } = await supabase.from('profiles').select('*').eq('id', user.id).single(); + if (process.env.DISCORD_WEBHOOK_HACKDEX_HACKS_URL) { + const { data: profile } = await supabase.from('profiles').select('*').eq('id', hack.created_by).single(); const displayName = profile?.username ? `@${profile.username}` : user.id; const embed: APIEmbed = { title: `:tada: ${hack.title} :tada:`, @@ -331,7 +316,7 @@ export async function approveHack(slug: string) { text: `This message brought to you by Hackdex` } } - await sendDiscordMessageEmbed(process.env.DISCORD_WEBHOOK_ADMIN_URL, [ + await sendDiscordMessageEmbed(process.env.DISCORD_WEBHOOK_HACKDEX_HACKS_URL, [ embed, ]); } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 0969661..2b05c0d 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -6,6 +6,7 @@ import Footer from "@/components/Footer"; import MobileFooterSpacer from "@/components/MobileFooterSpacer"; import { BaseRomProvider } from "@/contexts/BaseRomContext"; import { AuthProvider } from "@/contexts/AuthContext"; +import NoticeBanner from "@/components/NoticeBanner"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -41,6 +42,7 @@ export default function RootLayout({
+
{children}