mirror of
https://github.com/Hackdex-App/hackdex-website.git
synced 2026-08-27 11:04:27 -05:00
Merge remote-tracking branch 'origin/main' into feat/version-management
This commit is contained in:
15
README.md
15
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
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
44
package-lock.json
generated
44
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -55,7 +55,7 @@ export async function setupProfile(username: string): Promise<UpdateState> {
|
||||
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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
<span className="rounded-full bg-[var(--surface-2)] px-3 py-1 text-xs font-medium text-foreground/85 ring-1 ring-[var(--border)]">
|
||||
{patchVersion || "Pre-release"}
|
||||
</span>
|
||||
{!isArchive && (
|
||||
<div className="inline-flex ml-auto md:hidden">
|
||||
<DownloadsBadge slug={hack.slug} initialCount={hack.downloads} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-[15px] text-foreground/70">By {isArchive ? (hack.original_author || "Unknown") : author}</p>
|
||||
<p className="mt-2 text-sm text-foreground/75">{hack.summary}</p>
|
||||
<div className={`mt-1 flex items-center gap-2 ${!isArchive ? "h-[28px]" : ""}`}>
|
||||
{!isArchive && (
|
||||
<Avatar
|
||||
uid={hack.created_by as string}
|
||||
url={profile?.avatar_url ?? null}
|
||||
size={28}
|
||||
/>
|
||||
)}
|
||||
<p className="text-[16px] md:text-[18px] text-foreground/70">By {isArchive ? (hack.original_author || "Unknown") : author}</p>
|
||||
</div>
|
||||
<p className={`${!isArchive ? "mt-4" : "mt-2"} text-sm text-foreground/75`}>{hack.summary}</p>
|
||||
</div>
|
||||
<div className="w-full mt-2 flex flex-col justify-between gap-6 md:flex-row md:items-end">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -352,7 +367,12 @@ export default async function HackDetail({ params }: HackDetailProps) {
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 self-end md:self-auto lg:min-w-[260px]">
|
||||
{!isArchive && <DownloadsBadge slug={hack.slug} initialCount={hack.downloads} />}
|
||||
{!isArchive && (
|
||||
<div className="hidden md:inline-flex mr-2">
|
||||
<DownloadsBadge slug={hack.slug} initialCount={hack.downloads} />
|
||||
</div>
|
||||
)}
|
||||
<HackShareButton title={hack.title} url={pageUrl} author={hack.original_author || profile?.username || null} />
|
||||
<HackOptionsMenu slug={hack.slug} canEdit={canEdit || isAdmin} canUploadPatch={canUploadPatch || isAdmin}>
|
||||
{isAdmin && !hack.approved && (
|
||||
<MenuItem
|
||||
|
||||
@@ -265,21 +265,6 @@ export async function presignCoverUpload(args: { slug: string; objectKey: string
|
||||
return { ok: true, presignedUrl: url } as const;
|
||||
}
|
||||
|
||||
export async function getCoverSignedUrl(objectKey: string) {
|
||||
const client = getMinioClient();
|
||||
// 5 minutes expiry for viewing
|
||||
const url = await client.presignedGetObject(COVERS_BUCKET, objectKey, 60 * 5);
|
||||
return url;
|
||||
}
|
||||
|
||||
export async function getCoverSignedUrls(objectKeys: string[]) {
|
||||
const client = getMinioClient();
|
||||
// 5 minutes expiry for viewing
|
||||
const urls = await Promise.all(
|
||||
objectKeys.map(key => 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,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
<div className="fixed inset-0 -z-10">
|
||||
<div className="aurora" />
|
||||
</div>
|
||||
<NoticeBanner />
|
||||
<Header />
|
||||
<main className="flex-1 flex flex-col">{children}</main>
|
||||
<Footer />
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { AuthError, User } from '@supabase/supabase-js'
|
||||
import { validateTurnstileToken } from 'next-turnstile'
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { createClient } from '@/utils/supabase/server'
|
||||
|
||||
@@ -27,6 +29,27 @@ export type AuthActionState = |
|
||||
null
|
||||
|
||||
export async function login(state: AuthActionState, payload: FormData) {
|
||||
// Validate Turnstile token first
|
||||
const token = payload.get('cf-turnstile-response');
|
||||
if (!token || typeof token !== 'string') {
|
||||
return { error: 'Verification failed. Please try again.', user: null, redirectTo: null };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await validateTurnstileToken({
|
||||
token,
|
||||
secretKey: process.env.TURNSTILE_SECRET_KEY!,
|
||||
idempotencyKey: v4(),
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return { error: 'Verification failed. Please try again.', user: null, redirectTo: null };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Turnstile validation error:', error);
|
||||
return { error: 'Verification failed. Please try again.', user: null, redirectTo: null };
|
||||
}
|
||||
|
||||
const supabase = await createClient()
|
||||
|
||||
const data = {
|
||||
|
||||
@@ -4,8 +4,7 @@ import { FaArrowRightLong } from "react-icons/fa6";
|
||||
import { createClient } from "@/utils/supabase/server";
|
||||
import HackCard from "@/components/HackCard";
|
||||
import Button from "@/components/Button";
|
||||
import { sortOrderedTags } from "@/utils/format";
|
||||
import { getCoverSignedUrls } from "@/app/hack/actions";
|
||||
import { sortOrderedTags, getCoverUrls } from "@/utils/format";
|
||||
import { HackCardAttributes } from "@/components/HackCard";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -42,7 +41,7 @@ export default async function Home() {
|
||||
|
||||
const coversBySlug = new Map<string, string[]>();
|
||||
if (coverRows && coverRows.length > 0) { const coverKeys = coverRows.map((c) => c.url);
|
||||
const signedUrls = await getCoverSignedUrls(coverKeys);
|
||||
const signedUrls = getCoverUrls(coverKeys);
|
||||
const urlToSignedUrl = new Map<string, string>();
|
||||
coverKeys.forEach((key, idx) => {
|
||||
urlToSignedUrl.set(key, signedUrls[idx]);
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { AuthError } from '@supabase/supabase-js'
|
||||
import { get } from '@vercel/edge-config'
|
||||
import { validateTurnstileToken } from 'next-turnstile'
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { createClient, createServiceClient } from '@/utils/supabase/server'
|
||||
import { createClient } from '@/utils/supabase/server'
|
||||
import { validateEmail, validatePassword } from '@/utils/auth'
|
||||
import { sendDiscordMessageEmbed } from '@/utils/discord'
|
||||
|
||||
@@ -33,14 +34,33 @@ export interface AuthActionState {
|
||||
}
|
||||
|
||||
export async function signup(state: AuthActionState, payload: FormData) {
|
||||
// Validate Turnstile token first
|
||||
const token = payload.get('cf-turnstile-response');
|
||||
if (!token || typeof token !== 'string') {
|
||||
return { error: 'Verification failed. Please try again.' };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await validateTurnstileToken({
|
||||
token,
|
||||
secretKey: process.env.TURNSTILE_SECRET_KEY!,
|
||||
idempotencyKey: v4(),
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return { error: 'Verification failed. Please try again.' };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Turnstile validation error:', error);
|
||||
return { error: 'Verification failed. Please try again.' };
|
||||
}
|
||||
|
||||
const supabase = await createClient()
|
||||
const service = await createServiceClient()
|
||||
|
||||
const data = {
|
||||
email: payload.get('email') as string,
|
||||
password: payload.get('password') as string,
|
||||
}
|
||||
const inviteCode = (payload.get('inviteCode') as string | null)?.trim() || ''
|
||||
|
||||
const { error: emailError } = validateEmail(data.email);
|
||||
if (emailError) {
|
||||
@@ -52,88 +72,12 @@ export async function signup(state: AuthActionState, payload: FormData) {
|
||||
return { error: passwordError };
|
||||
}
|
||||
|
||||
if (!inviteCode) {
|
||||
return { error: 'An invite code is required to sign up.' }
|
||||
}
|
||||
|
||||
// Allow static invite codes via Edge Config to bypass DB checks
|
||||
let isStaticInvite = false
|
||||
try {
|
||||
const staticCodes = (await get<string[] | null>('staticInviteCodes')) || []
|
||||
if (Array.isArray(staticCodes)) {
|
||||
isStaticInvite = staticCodes.includes(inviteCode)
|
||||
}
|
||||
} catch {}
|
||||
|
||||
if (!isStaticInvite) {
|
||||
// Pre-check: ensure invite exists and is unused before attempting signup
|
||||
const { data: availableInvite, error: inviteCheckError } = await service
|
||||
.from('invite_codes')
|
||||
.select('code')
|
||||
.eq('code', inviteCode)
|
||||
.is('used_by', null)
|
||||
.maybeSingle()
|
||||
|
||||
if (inviteCheckError || !availableInvite) {
|
||||
return { error: 'Invalid or already used invite code.' }
|
||||
}
|
||||
}
|
||||
|
||||
const { data: signUpResult, error } = await supabase.auth.signUp(data)
|
||||
const { error } = await supabase.auth.signUp(data)
|
||||
|
||||
if (error) {
|
||||
return { error: getErrorMessage(error) };
|
||||
}
|
||||
|
||||
const userId = signUpResult.user?.id || null
|
||||
if (isStaticInvite) {
|
||||
console.log('[signup] Static invite code used:', { inviteCode, userId })
|
||||
if (process.env.DISCORD_WEBHOOK_ADMIN_URL) {
|
||||
await sendDiscordMessageEmbed(process.env.DISCORD_WEBHOOK_ADMIN_URL, [
|
||||
{
|
||||
title: 'New User Signup',
|
||||
description: `A new user (\`${userId}\`) has signed up using the static invite code: \`${inviteCode}\``,
|
||||
color: 0x40f56a,
|
||||
footer: {
|
||||
text: 'A notification will be sent when this user has created their profile'
|
||||
}
|
||||
},
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
// Finalize: set used_by to the new user id iff still unused (atomic)
|
||||
const { data: finalized, error: finalizeError } = await service
|
||||
.from('invite_codes')
|
||||
.update({ used_by: userId ?? null })
|
||||
.eq('code', inviteCode)
|
||||
.is('used_by', null)
|
||||
.select('code')
|
||||
.maybeSingle()
|
||||
|
||||
if (finalizeError || !finalized) {
|
||||
// The code claim could not be finalized (race). Roll back user creation.
|
||||
if (userId) {
|
||||
try {
|
||||
await service.auth.admin.deleteUser(userId)
|
||||
} catch {}
|
||||
}
|
||||
return { error: 'Invite code is no longer available. Please try again.' }
|
||||
} else {
|
||||
if (process.env.DISCORD_WEBHOOK_ADMIN_URL) {
|
||||
await sendDiscordMessageEmbed(process.env.DISCORD_WEBHOOK_ADMIN_URL, [
|
||||
{
|
||||
title: 'New User Signup',
|
||||
description: `A new user (\`${userId}\`) has signed up using the invite code: \`${inviteCode}\``,
|
||||
color: 0x40f56a,
|
||||
footer: {
|
||||
text: 'A notification will be sent when this user has created their profile'
|
||||
}
|
||||
},
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
revalidatePath('/', 'layout');
|
||||
const redirectTo = (payload.get('redirectTo') as string | null) || null
|
||||
const isValidInternalPath = redirectTo && redirectTo.startsWith('/') && !redirectTo.startsWith('//')
|
||||
|
||||
@@ -13,6 +13,9 @@ export default async function SignupPage({ searchParams }: SignupPageProps) {
|
||||
<div className="card p-6">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Create your account</h1>
|
||||
<p className="mt-1 text-sm text-foreground/70">Sign up to submit hacks and manage your profile.</p>
|
||||
<p className="mt-4 text-sm border border-amber-200 bg-amber-50 text-amber-950/80 dark:border-amber-700/60 dark:bg-amber-950/60 dark:text-amber-50/80 p-4 rounded-md">
|
||||
Only sign up to submit hacks that <span className="text-amber-950 dark:text-amber-50 font-bold">you own</span>. Hacks not by the original creator will be rejected.
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<SignupForm />
|
||||
</div>
|
||||
|
||||
@@ -110,6 +110,24 @@ export async function prepareSubmission(formData: FormData) {
|
||||
}
|
||||
}
|
||||
|
||||
if (process.env.DISCORD_WEBHOOK_ADMIN_HACKS_URL) {
|
||||
const { data: profile } = await supabase.from('profiles').select('*').eq('id', user.id).single();
|
||||
const displayName = profile?.username ? `@${profile.username}` : user.id;
|
||||
|
||||
const embed: APIEmbed = {
|
||||
title: `Hack submission: ${title}`,
|
||||
description: `A new hack by **${displayName}** is being prepared for submission.`,
|
||||
color: 0x40f56a,
|
||||
url: `${process.env.NEXT_PUBLIC_SITE_URL}/hack/${slug}`,
|
||||
footer: {
|
||||
text: `This message brought to you by Hackdex`
|
||||
}
|
||||
}
|
||||
await sendDiscordMessageEmbed(process.env.DISCORD_WEBHOOK_ADMIN_HACKS_URL, [
|
||||
embed,
|
||||
]);
|
||||
}
|
||||
|
||||
return { ok: true, slug } as const;
|
||||
}
|
||||
|
||||
@@ -275,13 +293,14 @@ export async function confirmPatchUpload(args: { slug: string; objectKey: string
|
||||
}
|
||||
}
|
||||
|
||||
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_ADMIN_HACKS_URL) {
|
||||
const { data: profile } = await supabase.from('profiles').select('*').eq('id', hack.created_by).single();
|
||||
const displayName = profile?.username ? `@${profile.username}` : hack.created_by;
|
||||
const uploadedByDifferentUser = hack.created_by !== user.id;
|
||||
|
||||
const displayName = profile?.username ? `@${profile.username}` : user.id;
|
||||
const embed: APIEmbed = args.firstUpload ? {
|
||||
title: `:tada: ${hack.title}`,
|
||||
description: `A new hack by **${displayName}** is pending approval by an admin.`,
|
||||
description: `A new hack by **${displayName}** is pending approval by an admin.` + (uploadedByDifferentUser ? ` (Uploaded by ${user.id})` : ''),
|
||||
color: 0x40f56a,
|
||||
url: `${process.env.NEXT_PUBLIC_SITE_URL}/hack/${args.slug}`,
|
||||
footer: {
|
||||
@@ -297,7 +316,11 @@ export async function confirmPatchUpload(args: { slug: string; objectKey: string
|
||||
}
|
||||
}
|
||||
|
||||
await sendDiscordMessageEmbed(process.env.DISCORD_WEBHOOK_ADMIN_URL, [
|
||||
const webhookUrl = args.firstUpload ?
|
||||
process.env.DISCORD_WEBHOOK_ADMIN_HACKS_URL :
|
||||
process.env.DISCORD_WEBHOOK_HACKDEX_HACKS_URL || process.env.DISCORD_WEBHOOK_ADMIN_HACKS_URL;
|
||||
|
||||
await sendDiscordMessageEmbed(webhookUrl, [
|
||||
embed,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import SubmitPageClient from "@/components/Submit/SubmitPageClient";
|
||||
import { createClient } from "@/utils/supabase/server";
|
||||
import SubmitAuthOverlay from "@/components/Submit/SubmitAuthOverlay";
|
||||
import { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
alternates: {
|
||||
@@ -31,6 +32,19 @@ export default async function SubmitPage() {
|
||||
<div className="mx-auto max-w-[900px] px-6 py-10 w-full">
|
||||
<h1 className="text-3xl font-bold tracking-tight">Submit your ROM hack</h1>
|
||||
<p className="mt-2 text-[15px] text-foreground/80">Share your hack so others can discover and play it.</p>
|
||||
<div className="mt-2 text-sm border border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-700/60 dark:bg-amber-950/60 dark:text-amber-100 p-4 rounded-md">
|
||||
<p className="font-bold mb-2">Important:</p>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-amber-950/80 dark:text-amber-50/80">
|
||||
Hackdex will only accept submissions for hacks that <span className="text-amber-950 dark:text-amber-50 font-bold">you own</span>.
|
||||
If it is not your hack, try reaching out to the original creator to see if they are interested in submitting it themselves.
|
||||
All hacks must also comply with the <Link href="/terms" className="text-amber-950 dark:text-amber-50 font-bold hover:underline">Terms of Service</Link>.
|
||||
</p>
|
||||
<p className="text-amber-950/80 dark:text-amber-50/80">
|
||||
If you attempt to circumvent this policy, <span className="text-amber-950 dark:text-amber-50 font-bold italic">your submission will be rejected</span> and your account may be banned.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8">
|
||||
<SubmitPageClient canCreateArchive={canCreateArchive} dummy={!user || needsInitialSetup} />
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import React, { useActionState, useEffect} from "react";
|
||||
import Link from "next/link";
|
||||
import { FiEye, FiEyeOff } from "react-icons/fi";
|
||||
import { Turnstile } from "next-turnstile";
|
||||
import { AuthActionState, login } from "@/app/login/actions";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useAuthContext } from "@/contexts/AuthContext";
|
||||
@@ -13,6 +14,9 @@ export default function LoginForm() {
|
||||
const [email, setEmail] = React.useState("");
|
||||
const [password, setPassword] = React.useState("");
|
||||
const [showPassword, setShowPassword] = React.useState(false);
|
||||
const [turnstileToken, setTurnstileToken] = React.useState<string | undefined>(undefined);
|
||||
const [turnstileError, setTurnstileError] = React.useState<string | null>(null);
|
||||
const [turnstileKey, setTurnstileKey] = React.useState(0);
|
||||
const searchParams = useSearchParams();
|
||||
const urlError = searchParams.get("error");
|
||||
const [state, formAction] = useActionState<AuthActionState, FormData>(login, null);
|
||||
@@ -26,6 +30,16 @@ export default function LoginForm() {
|
||||
const passwordValid = password.length > 1;
|
||||
const isValid = emailValid && passwordValid;
|
||||
|
||||
// Reset Turnstile token and widget on error to allow retry
|
||||
useEffect(() => {
|
||||
if (state?.error && state.error !== null) {
|
||||
setTurnstileToken(undefined);
|
||||
setTurnstileError(null);
|
||||
// Force Turnstile widget to reset by changing key
|
||||
setTurnstileKey((prev) => prev + 1);
|
||||
}
|
||||
}, [state?.error]);
|
||||
|
||||
// Update context and immediately redirect after successful login
|
||||
useEffect(() => {
|
||||
if (state && state.error === null && !navigatedRef.current) {
|
||||
@@ -55,6 +69,11 @@ export default function LoginForm() {
|
||||
{errorMessage}
|
||||
</div>
|
||||
)}
|
||||
{turnstileError && (
|
||||
<div className="rounded-md bg-red-500/10 ring-1 ring-red-600/40 px-3 py-2 text-sm text-red-300">
|
||||
{turnstileError}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid gap-2">
|
||||
<label htmlFor="email" className="text-sm text-foreground/80">Email</label>
|
||||
<input
|
||||
@@ -117,10 +136,27 @@ export default function LoginForm() {
|
||||
) : (
|
||||
<div className="h-3" />
|
||||
)}
|
||||
<Turnstile
|
||||
key={turnstileKey}
|
||||
siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!}
|
||||
onVerify={(token) => {
|
||||
setTurnstileToken(token);
|
||||
setTurnstileError(null);
|
||||
}}
|
||||
onError={(error) => {
|
||||
setTurnstileToken(undefined);
|
||||
setTurnstileError("Verification failed. Please try again.");
|
||||
console.error("Turnstile error:", error);
|
||||
}}
|
||||
onExpire={() => {
|
||||
setTurnstileToken(undefined);
|
||||
}}
|
||||
theme="auto"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
formAction={formAction}
|
||||
disabled={!isValid}
|
||||
disabled={!isValid || !turnstileToken}
|
||||
className="shine-wrap btn-premium h-11 min-w-[7.5rem] text-sm font-semibold dark:disabled:opacity-70 disabled:cursor-not-allowed disabled:[box-shadow:0_0_0_1px_var(--border)]"
|
||||
>
|
||||
<span>Log in</span>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import React, { useActionState, useEffect } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { FiEye, FiEyeOff } from "react-icons/fi";
|
||||
import { Turnstile } from "next-turnstile";
|
||||
import { AuthActionState, signup } from "@/app/signup/actions";
|
||||
import { useAuthContext } from "@/contexts/AuthContext";
|
||||
import { validateEmail, validatePassword } from "@/utils/auth";
|
||||
@@ -14,14 +15,27 @@ export default function SignupForm() {
|
||||
const [email, setEmail] = React.useState("");
|
||||
const [password, setPassword] = React.useState("");
|
||||
const [confirm, setConfirm] = React.useState("");
|
||||
const [invite, setInvite] = React.useState<string>("");
|
||||
const [acceptedTerms, setAcceptedTerms] = React.useState(false);
|
||||
const [showPassword, setShowPassword] = React.useState(false);
|
||||
const [emailError, setEmailError] = React.useState<string | null>(null);
|
||||
const [passwordError, setPasswordError] = React.useState<string | null>(null);
|
||||
const [turnstileToken, setTurnstileToken] = React.useState<string | undefined>(undefined);
|
||||
const [turnstileError, setTurnstileError] = React.useState<string | null>(null);
|
||||
const [turnstileKey, setTurnstileKey] = React.useState(0);
|
||||
|
||||
const [state, formAction, isPending] = useActionState<AuthActionState, FormData>(signup, { error: null });
|
||||
const passwordsMatch = password === confirm;
|
||||
const isValid = !emailError && !passwordError && passwordsMatch && Boolean(invite);
|
||||
const isValid = !emailError && !passwordError && passwordsMatch && acceptedTerms;
|
||||
|
||||
// Reset Turnstile token and widget on error to allow retry
|
||||
useEffect(() => {
|
||||
if (state?.error && !isPending) {
|
||||
setTurnstileToken(undefined);
|
||||
setTurnstileError(null);
|
||||
// Force Turnstile widget to reset by changing key
|
||||
setTurnstileKey((prev) => prev + 1);
|
||||
}
|
||||
}, [state?.error, isPending]);
|
||||
|
||||
useEffect(() => {
|
||||
const { error } = validateEmail(email);
|
||||
@@ -35,13 +49,6 @@ export default function SignupForm() {
|
||||
|
||||
const redirectTo = searchParams.get("redirectTo");
|
||||
|
||||
useEffect(() => {
|
||||
const inviteFromParams = searchParams.get("invite") || "";
|
||||
if (inviteFromParams) {
|
||||
setInvite(inviteFromParams);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Redirect if user already authenticated (e.g., opened signup while logged in)
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
@@ -60,27 +67,11 @@ export default function SignupForm() {
|
||||
{state?.error}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid gap-2">
|
||||
<label htmlFor="inviteCode" className="text-sm text-foreground/80">Invite code</label>
|
||||
<input
|
||||
id="inviteCode"
|
||||
name="inviteCode"
|
||||
type="text"
|
||||
value={invite}
|
||||
onChange={(e) => setInvite(e.target.value)}
|
||||
placeholder="Enter your invite code"
|
||||
className={`h-11 rounded-md bg-[var(--surface-2)] px-3 text-sm ring-1 ring-inset ring-[var(--border)] focus:outline-none focus:ring-2 focus:ring-[var(--ring)] ${
|
||||
invite ? "bg-[var(--surface-2)] ring-[var(--border)]" : "bg-[var(--surface-2)] ring-[var(--border)]"
|
||||
}`}
|
||||
required
|
||||
inputMode="text"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
{!invite && (
|
||||
<span className="text-xs text-foreground/60">An invite code is required to create an account.</span>
|
||||
)}
|
||||
</div>
|
||||
{turnstileError && (
|
||||
<div className="rounded-md bg-red-500/10 ring-1 ring-red-600/40 px-3 py-2 text-sm text-red-300">
|
||||
{turnstileError}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid gap-2">
|
||||
<label htmlFor="email" className="text-sm text-foreground/80">Email</label>
|
||||
<input
|
||||
@@ -166,11 +157,51 @@ export default function SignupForm() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="flex items-center gap-2 text-xs text-foreground/80">
|
||||
<input
|
||||
id="accept-terms"
|
||||
name="acceptTerms"
|
||||
type="checkbox"
|
||||
checked={acceptedTerms}
|
||||
onChange={(e) => setAcceptedTerms(e.target.checked)}
|
||||
className="mt-0.5 h-4 w-4 rounded border-[var(--border)] bg-[var(--surface-2)] text-[var(--ring)] focus:outline-none focus:ring-2 focus:ring-[var(--ring)]"
|
||||
required
|
||||
/>
|
||||
<label htmlFor="accept-terms" className="space-x-1">
|
||||
<span>I agree to the</span>
|
||||
<a
|
||||
href="/terms"
|
||||
className="font-medium text-sky-500 hover:text-sky-400 underline underline-offset-2"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Terms of Service
|
||||
</a>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center gap-3 mt-2">
|
||||
<Turnstile
|
||||
key={turnstileKey}
|
||||
siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!}
|
||||
onVerify={(token) => {
|
||||
setTurnstileToken(token);
|
||||
setTurnstileError(null);
|
||||
}}
|
||||
onError={(error) => {
|
||||
setTurnstileToken(undefined);
|
||||
setTurnstileError("Verification failed. Please try again.");
|
||||
console.error("Turnstile error:", error);
|
||||
}}
|
||||
onExpire={() => {
|
||||
setTurnstileToken(undefined);
|
||||
}}
|
||||
theme="auto"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
formAction={formAction}
|
||||
disabled={!isValid || isPending}
|
||||
disabled={!isValid || isPending || !turnstileToken}
|
||||
className="shine-wrap btn-premium h-11 min-w-[7.5rem] text-sm font-semibold hover:cursor-pointer dark:disabled:opacity-70 disabled:cursor-not-allowed disabled:[box-shadow:0_0_0_1px_var(--border)]"
|
||||
>
|
||||
<span>Sign up</span>
|
||||
|
||||
@@ -11,10 +11,10 @@ import { MdTune } from "react-icons/md";
|
||||
import { BsSdCardFill } from "react-icons/bs";
|
||||
import { CATEGORY_ICONS } from "@/components/Icons/tagCategories";
|
||||
import { useBaseRoms } from "@/contexts/BaseRomContext";
|
||||
import { sortOrderedTags, OrderedTag } from "@/utils/format";
|
||||
import { getCoverSignedUrls } from "@/app/hack/actions";
|
||||
import { sortOrderedTags, OrderedTag, getCoverUrls } from "@/utils/format";
|
||||
import { HackCardAttributes } from "@/components/HackCard";
|
||||
|
||||
const HACKS_PER_PAGE = 9;
|
||||
|
||||
export default function DiscoverBrowser() {
|
||||
const supabase = createClient();
|
||||
@@ -28,6 +28,8 @@ export default function DiscoverBrowser() {
|
||||
const [loadingHacks, setLoadingHacks] = React.useState(true);
|
||||
const [loadingTags, setLoadingTags] = React.useState(true);
|
||||
const [onlyReady, setOnlyReady] = React.useState(false);
|
||||
const [currentPage, setCurrentPage] = React.useState(1);
|
||||
const listRef = React.useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const { cached, statuses, countReady } = useBaseRoms();
|
||||
const readyBaseRomIds = React.useMemo(() => {
|
||||
@@ -43,6 +45,11 @@ export default function DiscoverBrowser() {
|
||||
return set;
|
||||
}, [cached, statuses]);
|
||||
|
||||
React.useEffect(() => {
|
||||
// Reset to first page when filters or sort change
|
||||
setCurrentPage(1);
|
||||
}, [query, selectedTags, selectedBaseRoms, onlyReady, sort]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const run = async () => {
|
||||
setLoadingHacks(true);
|
||||
@@ -74,7 +81,7 @@ export default function DiscoverBrowser() {
|
||||
const coversBySlug = new Map<string, string[]>();
|
||||
if (coverRows && coverRows.length > 0) {
|
||||
const coverKeys = coverRows.map(c => c.url);
|
||||
const urls = await getCoverSignedUrls(coverKeys);
|
||||
const urls = getCoverUrls(coverKeys);
|
||||
// Map: storage object url -> signedUrl
|
||||
const urlToSignedUrl = new Map<string, string>();
|
||||
coverKeys.forEach((key, idx) => {
|
||||
@@ -104,19 +111,36 @@ export default function DiscoverBrowser() {
|
||||
tagsBySlug.set(r.hack_slug, arr);
|
||||
});
|
||||
|
||||
let mappedVersions = new Map<string, string>();
|
||||
await Promise.all((rows || []).map(async (r) => {
|
||||
if (r.current_patch) {
|
||||
const { data: currentPatch } = await supabase
|
||||
.from("patches")
|
||||
.select("version")
|
||||
.eq("id", r.current_patch)
|
||||
.maybeSingle();
|
||||
mappedVersions.set(r.slug, currentPatch?.version || "Pre-release");
|
||||
const patchIds = Array.from(
|
||||
new Set(
|
||||
(rows || [])
|
||||
.map((r: any) => r.current_patch as number | null)
|
||||
.filter((id): id is number => typeof id === "number")
|
||||
)
|
||||
);
|
||||
|
||||
const versionsByPatchId = new Map<number, string>();
|
||||
if (patchIds.length > 0) {
|
||||
const { data: patchRows } = await supabase
|
||||
.from("patches")
|
||||
.select("id,version")
|
||||
.in("id", patchIds);
|
||||
(patchRows || []).forEach((p: any) => {
|
||||
if (typeof p.id === "number") {
|
||||
versionsByPatchId.set(p.id, p.version || "Pre-release");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const mappedVersions = new Map<string, string>();
|
||||
(rows || []).forEach((r: any) => {
|
||||
if (typeof r.current_patch === "number") {
|
||||
const version = versionsByPatchId.get(r.current_patch) || "Pre-release";
|
||||
mappedVersions.set(r.slug, version);
|
||||
} else {
|
||||
mappedVersions.set(r.slug, r.original_author ? "Archive" : "Pre-release");
|
||||
}
|
||||
}));
|
||||
});
|
||||
// Fetch all tags with category to build UI groups
|
||||
const { data: allTagRows } = await supabase
|
||||
.from("tags")
|
||||
@@ -197,6 +221,55 @@ export default function DiscoverBrowser() {
|
||||
return out;
|
||||
}, [hacks, query, selectedTags, selectedBaseRoms, onlyReady, readyBaseRomIds]);
|
||||
|
||||
const totalPages = React.useMemo(
|
||||
() => Math.max(1, Math.ceil(filtered.length / HACKS_PER_PAGE)),
|
||||
[filtered.length]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
// Clamp current page if the number of results shrinks
|
||||
if (currentPage > totalPages) {
|
||||
setCurrentPage(totalPages);
|
||||
}
|
||||
}, [currentPage, totalPages]);
|
||||
|
||||
const paginationRange = React.useMemo(() => {
|
||||
const startIndex = (currentPage - 1) * HACKS_PER_PAGE;
|
||||
const endIndex = Math.min(filtered.length, startIndex + HACKS_PER_PAGE);
|
||||
return { startIndex, endIndex };
|
||||
}, [currentPage, filtered.length]);
|
||||
|
||||
const paginated = React.useMemo(
|
||||
() => filtered.slice(paginationRange.startIndex, paginationRange.endIndex),
|
||||
[filtered, paginationRange.startIndex, paginationRange.endIndex]
|
||||
);
|
||||
|
||||
const scrollToListTopOnMobile = React.useCallback(() => {
|
||||
if (typeof window === "undefined" || !listRef.current) return;
|
||||
// Only auto-scroll on small screens so desktop users aren't jolted
|
||||
if (window.innerWidth < 640) {
|
||||
const rect = listRef.current.getBoundingClientRect();
|
||||
const headerOffset = 72; // approximate navbar with padding
|
||||
const targetY = Math.max(0, rect.top + window.scrollY - headerOffset);
|
||||
window.scrollTo({ top: targetY, behavior: "smooth" });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const changePage = React.useCallback(
|
||||
(nextPage: number) => {
|
||||
setCurrentPage((prev) => {
|
||||
const clamped = Math.min(Math.max(1, nextPage), totalPages);
|
||||
// Only scroll when the page actually changes
|
||||
if (clamped !== prev) {
|
||||
// Defer scroll until after React has applied the state update
|
||||
setTimeout(scrollToListTopOnMobile, 0);
|
||||
}
|
||||
return clamped;
|
||||
});
|
||||
},
|
||||
[scrollToListTopOnMobile, totalPages]
|
||||
);
|
||||
|
||||
function toggleTag(name: string) {
|
||||
setSelectedTags((prev) => (prev.includes(name) ? prev.filter((t) => t !== name) : [...prev, name]));
|
||||
}
|
||||
@@ -376,11 +449,70 @@ export default function DiscoverBrowser() {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-8 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{filtered.map((hack) => (
|
||||
<HackCard key={hack.slug} hack={hack} />
|
||||
))}
|
||||
</div>
|
||||
<>
|
||||
<div
|
||||
ref={listRef}
|
||||
className="mt-8 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3"
|
||||
>
|
||||
{paginated.map((hack) => (
|
||||
<HackCard key={hack.slug} hack={hack} />
|
||||
))}
|
||||
</div>
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-6 flex flex-col items-center gap-3">
|
||||
<div className="text-xs text-foreground/70 text-center">
|
||||
Showing {paginationRange.startIndex + 1}-{paginationRange.endIndex} of {filtered.length} hacks
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => changePage(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
className={`h-9 rounded-full px-3 text-sm ring-1 ring-inset transition-colors min-w-[90px] ${
|
||||
currentPage === 1
|
||||
? "cursor-not-allowed bg-[var(--surface-2)] text-foreground/40 ring-[var(--border)]"
|
||||
: "bg-[var(--surface-2)] text-foreground/80 ring-[var(--border)] hover:bg-black/5 dark:hover:bg-white/10"
|
||||
}`}
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<div className="flex items-center gap-1">
|
||||
{Array.from({ length: totalPages }).map((_, i) => {
|
||||
const page = i + 1;
|
||||
const isActive = page === currentPage;
|
||||
return (
|
||||
<button
|
||||
key={page}
|
||||
type="button"
|
||||
onClick={() => changePage(page)}
|
||||
className={`h-8 min-w-8 rounded-full px-2 text-xs font-medium ring-1 ring-inset transition-colors ${
|
||||
isActive
|
||||
? "bg-[var(--accent)] text-[var(--foreground)] ring-[var(--accent)]/60"
|
||||
: "bg-[var(--surface-2)] text-foreground/70 ring-[var(--border)] hover:bg-black/5 dark:hover:bg-white/10"
|
||||
}`}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => changePage(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
className={`h-9 rounded-full px-3 text-sm ring-1 ring-inset transition-colors min-w-[90px] ${
|
||||
currentPage === totalPages
|
||||
? "cursor-not-allowed bg-[var(--surface-2)] text-foreground/40 ring-[var(--border)]"
|
||||
: "bg-[var(--surface-2)] text-foreground/80 ring-[var(--border)] hover:bg-black/5 dark:hover:bg-white/10"
|
||||
}`}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -50,30 +50,6 @@ export default function HackOptionsMenu({
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuSeparator className="my-1 h-px bg-[var(--border)]" />
|
||||
<MenuItem
|
||||
as="button"
|
||||
onClick={async () => {
|
||||
try {
|
||||
const url = window.location.href;
|
||||
const title = document?.title || "Check this out";
|
||||
if (navigator.share) {
|
||||
await navigator.share({ title, url });
|
||||
} else {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(url);
|
||||
alert("Link copied to clipboard");
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore if user cancels share; otherwise log
|
||||
if (!(e instanceof Error) || e.name !== "AbortError") {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="block w-full px-3 py-2 text-left text-sm data-focus:bg-black/5 dark:data-focus:bg-white/10">
|
||||
Share
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
as="button"
|
||||
onClick={() => {
|
||||
|
||||
33
src/components/Hack/HackShareButton.tsx
Normal file
33
src/components/Hack/HackShareButton.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { FiShare2 } from "react-icons/fi";
|
||||
import ShareModal from "@/components/Hack/ShareModal";
|
||||
|
||||
interface HackShareButtonProps {
|
||||
title: string;
|
||||
url: string;
|
||||
author: string | null;
|
||||
}
|
||||
|
||||
export default function HackShareButton({ title, url, author }: HackShareButtonProps) {
|
||||
const [showShareModal, setShowShareModal] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowShareModal(true)}
|
||||
aria-label="Share hack"
|
||||
title="Share"
|
||||
className="group inline-flex px-3 md:px-2 h-8 items-center justify-center rounded-md ring-1 ring-[var(--border)] bg-[var(--surface-2)] text-foreground/80 hover:bg-[var(--surface-3)] hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--border)]"
|
||||
>
|
||||
<FiShare2 size={18} />
|
||||
<span className="ml-2 font-semibold text-sm">Share</span>
|
||||
</button>
|
||||
{showShareModal && (
|
||||
<ShareModal title={title} url={url} author={author} onClose={() => setShowShareModal(false)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -436,9 +436,13 @@ export default function HackSubmitForm({
|
||||
fd.set('permission_from', permissionFrom);
|
||||
}
|
||||
|
||||
console.log('[HackSubmitForm] Preparing submission...');
|
||||
|
||||
const prepared = await prepareSubmission(fd);
|
||||
if (!prepared.ok) throw new Error(prepared.error || 'Failed to prepare');
|
||||
|
||||
console.log('[HackSubmitForm] Uploading covers...');
|
||||
|
||||
const uploadedCoverUrls = await uploadCovers(prepared.slug);
|
||||
|
||||
if (isArchive) {
|
||||
@@ -453,10 +457,12 @@ export default function HackSubmitForm({
|
||||
} catch {}
|
||||
window.location.href = `/hack/${prepared.slug}`;
|
||||
} else {
|
||||
console.log('[HackSubmitForm] Getting patch upload URL...');
|
||||
const presigned = await presignPatchAndSaveCovers({ slug: prepared.slug, version, coverUrls: uploadedCoverUrls });
|
||||
if (!presigned.ok) throw new Error(presigned.error || 'Failed to presign');
|
||||
|
||||
if (patchFile) {
|
||||
console.log('[HackSubmitForm] Uploading patch...');
|
||||
await fetch(presigned.presignedUrl, { method: 'PUT', body: patchFile, headers: { 'Content-Type': 'application/octet-stream' } });
|
||||
const finalized = await confirmPatchUpload({ slug: prepared.slug, objectKey: presigned.objectKey!, version, firstUpload: true });
|
||||
if (!finalized.ok) throw new Error(finalized.error || 'Failed to finalize');
|
||||
@@ -468,6 +474,7 @@ export default function HackSubmitForm({
|
||||
} catch {}
|
||||
window.location.href = finalized.redirectTo!;
|
||||
} else {
|
||||
console.log('[HackSubmitForm] No patch file, redirecting to hack page...');
|
||||
try {
|
||||
if (draftKey) {
|
||||
localStorage.removeItem(draftKey);
|
||||
@@ -477,8 +484,10 @@ export default function HackSubmitForm({
|
||||
window.location.href = `/hack/${prepared.slug}`;
|
||||
}
|
||||
}
|
||||
console.log('[HackSubmitForm] Submission successful');
|
||||
} catch (e: any) {
|
||||
alert(e.message || 'Submission failed');
|
||||
console.log('[HackSubmitForm] Submission failed', e);
|
||||
alert(e.message ? `There was an error during submission:\n\n===\n${e.message}\n===\n\nYour hack might have only been partially submitted. Try going to your dashboard and see if your hack is listed there. If not, please contact support.` : 'Submission failed');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
|
||||
398
src/components/Hack/ShareModal.tsx
Normal file
398
src/components/Hack/ShareModal.tsx
Normal file
@@ -0,0 +1,398 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { FiX, FiCheck, FiCopy, FiMail, FiShare2, FiArrowLeft } from "react-icons/fi";
|
||||
import { FaXTwitter, FaReddit, FaFacebook } from "react-icons/fa6";
|
||||
import { FaInfoCircle } from "react-icons/fa";
|
||||
import { PiBracketsSquareBold, PiBracketsAngleBold } from "react-icons/pi";
|
||||
|
||||
const BANNER_IMAGE_URL = "/img/badge-dark.png";
|
||||
const BANNER_IMAGE_WIDTH = 190;
|
||||
const BANNER_IMAGE_HEIGHT = 60;
|
||||
const BANNER_IMAGE_FULL_URL = `${process.env.NEXT_PUBLIC_SITE_URL}/img/badge-dark.png`;
|
||||
|
||||
interface ShareModalProps {
|
||||
title: string;
|
||||
url: string;
|
||||
author: string | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const ShareModal: React.FC<ShareModalProps> = ({ title, url, author, onClose }) => {
|
||||
const [urlCopied, setUrlCopied] = useState(false);
|
||||
const [codePreview, setCodePreview] = useState<{ type: string; code: string; label: string } | null>(null);
|
||||
const [codeCopied, setCodeCopied] = useState(false);
|
||||
const hasNavigatorShare = typeof navigator !== "undefined" && navigator.share;
|
||||
|
||||
useEffect(() => {
|
||||
const html = document.documentElement;
|
||||
const body = document.body;
|
||||
const previousHtmlOverflow = html.style.overflow;
|
||||
const previousBodyOverflow = body.style.overflow;
|
||||
const previousBodyPaddingRight = body.style.paddingRight;
|
||||
const scrollBarWidth = window.innerWidth - html.clientWidth;
|
||||
|
||||
html.style.overflow = "hidden";
|
||||
body.style.overflow = "hidden";
|
||||
if (scrollBarWidth > 0) {
|
||||
body.style.paddingRight = `${scrollBarWidth}px`;
|
||||
}
|
||||
|
||||
return () => {
|
||||
html.style.overflow = previousHtmlOverflow;
|
||||
body.style.overflow = previousBodyOverflow;
|
||||
body.style.paddingRight = previousBodyPaddingRight;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const copyUrl = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
setUrlCopied(true);
|
||||
setTimeout(() => setUrlCopied(false), 2000);
|
||||
} catch (e) {
|
||||
console.error("Failed to copy URL:", e);
|
||||
}
|
||||
};
|
||||
|
||||
const copyCode = async () => {
|
||||
if (!codePreview) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(codePreview.code);
|
||||
setCodeCopied(true);
|
||||
setTimeout(() => setCodeCopied(false), 2000);
|
||||
} catch (e) {
|
||||
console.error("Failed to copy code:", e);
|
||||
}
|
||||
};
|
||||
|
||||
const getCodePreview = (type: string): { code: string; label: string } | null => {
|
||||
switch (type) {
|
||||
case "bbcode":
|
||||
return { code: `[url=${url}][img width=${BANNER_IMAGE_WIDTH} height=${BANNER_IMAGE_HEIGHT}]${BANNER_IMAGE_FULL_URL}[/img][/url]`, label: "BBCode" };
|
||||
case "html":
|
||||
return { code: `<a href="${url}"><img width="${BANNER_IMAGE_WIDTH}" height="${BANNER_IMAGE_HEIGHT}" src="${BANNER_IMAGE_FULL_URL}" alt="Download now at hackdex.app" /></a>`, label: "HTML" };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleShare = async (type: string) => {
|
||||
// Show preview for code formats
|
||||
if (type === "bbcode" || type === "html") {
|
||||
const preview = getCodePreview(type);
|
||||
if (preview) {
|
||||
setCodePreview({ type, ...preview });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const socialTitle = author ? `Romhack: ${title} by ${author}` : `Romhack: ${title}`;
|
||||
|
||||
switch (type) {
|
||||
case "other": {
|
||||
try {
|
||||
await navigator.share({
|
||||
title: socialTitle,
|
||||
url,
|
||||
});
|
||||
} catch (e) {
|
||||
// Ignore if user cancels share
|
||||
if (!(e instanceof Error) || e.name !== "AbortError") {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "reddit": {
|
||||
const redditUrl = `https://www.reddit.com/submit?url=${encodeURIComponent(url)}&title=${encodeURIComponent(socialTitle)}`;
|
||||
window.open(redditUrl, "_blank", "width=1024,height=768");
|
||||
break;
|
||||
}
|
||||
case "twitter": {
|
||||
const twitterUrl = `https://twitter.com/intent/tweet?url=${encodeURIComponent(url)}&text=${encodeURIComponent(socialTitle)}`;
|
||||
window.open(twitterUrl, "_blank", "width=1024,height=768");
|
||||
break;
|
||||
}
|
||||
case "email": {
|
||||
const subject = encodeURIComponent(`Check out ${title}`);
|
||||
const body = encodeURIComponent(`I found this ROM hack that you might like: ${url}`);
|
||||
window.location.href = `mailto:?subject=${subject}&body=${body}`;
|
||||
break;
|
||||
}
|
||||
case "facebook": {
|
||||
const facebookUrl = `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(url)}`;
|
||||
window.open(facebookUrl, "_blank", "width=1024,height=768");
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const SocialIconButton = ({
|
||||
type,
|
||||
icon: Icon,
|
||||
label,
|
||||
}: {
|
||||
type: string;
|
||||
icon: React.ComponentType<{ size?: number; className?: string }>;
|
||||
label: string;
|
||||
}) => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleShare(type)}
|
||||
className="flex flex-col items-center gap-1.5 min-w-18 p-3 rounded-lg ring-1 ring-[var(--border)] bg-[var(--surface-2)] hover:bg-black/5 dark:hover:bg-white/10"
|
||||
title={label}
|
||||
>
|
||||
<div className="w-12 h-12 rounded-full flex items-center justify-center bg-[var(--surface-1)] text-foreground/80">
|
||||
<Icon size={26} />
|
||||
</div>
|
||||
<span className="text-xs font-medium text-foreground/70">
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
// Show code preview modal if codePreview is set
|
||||
if (codePreview) {
|
||||
return (
|
||||
<div className="fixed left-0 right-0 top-0 bottom-0 z-[100] flex items-center justify-center p-4">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 dark:bg-black/60 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={`${codePreview.label} code preview`}
|
||||
className="relative z-[101] card backdrop-blur-lg dark:!bg-black/70 p-6 max-w-lg max-h-[90vh] overflow-y-auto w-full rounded-lg"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close code preview"
|
||||
className="absolute top-4 right-4 p-1.5 rounded-md text-foreground/60 hover:text-foreground hover:bg-[var(--surface-2)] focus:outline-none focus:ring-2 focus:ring-[var(--accent)]"
|
||||
>
|
||||
<FiX size={20} />
|
||||
</button>
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCodePreview(null)}
|
||||
className="inline-flex items-center gap-2 text-sm text-foreground/70 hover:text-foreground mb-3"
|
||||
aria-label="Back to share options"
|
||||
>
|
||||
<FiArrowLeft size={16} />
|
||||
<span>Back</span>
|
||||
</button>
|
||||
<h2 className="text-xl font-semibold">{codePreview.label} Code</h2>
|
||||
<p className="mt-1 text-sm text-foreground/70">
|
||||
Copy the code below to share this hack with the badge.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Badge Preview */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-xs text-foreground/60 font-medium">Preview:</p>
|
||||
<div className="flex justify-center p-3 rounded-md bg-[var(--surface-1)] border border-[var(--border)]">
|
||||
<img
|
||||
src={BANNER_IMAGE_URL}
|
||||
width={BANNER_IMAGE_WIDTH}
|
||||
height={BANNER_IMAGE_HEIGHT}
|
||||
alt="Download now at hackdex.app"
|
||||
className="max-w-full h-auto rounded"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Code Section */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-semibold text-foreground/90">Code</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<textarea
|
||||
readOnly
|
||||
value={codePreview.code}
|
||||
rows={3}
|
||||
className="flex-1 px-3 py-2.5 rounded-md border border-[var(--border)] bg-[var(--surface-1)] text-sm text-foreground/80 font-mono resize-none focus:outline-none focus:ring-2 focus:ring-[var(--accent)]"
|
||||
onClick={(e) => (e.target as HTMLTextAreaElement).select()}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyCode}
|
||||
className="inline-flex items-center gap-1.5 self-end mr-1 text-sm text-foreground/70 hover:text-foreground focus:outline-none focus:ring-2 focus:ring-[var(--accent)] rounded"
|
||||
title="Copy to clipboard"
|
||||
aria-label="Copy code to clipboard"
|
||||
>
|
||||
{codeCopied ? (
|
||||
<>
|
||||
<FiCheck size={16} className="text-green-500" />
|
||||
<span className="text-green-500">Copied</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FiCopy size={16} />
|
||||
<span>Copy</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed left-0 right-0 top-0 bottom-0 z-[100] flex items-center justify-center p-4">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 dark:bg-black/60 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Share hack"
|
||||
className="relative z-[101] card backdrop-blur-lg dark:!bg-black/70 p-6 max-w-lg max-h-[90vh] overflow-y-auto w-full rounded-lg"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close share modal"
|
||||
className="absolute top-4 right-4 p-1.5 rounded-md text-foreground/60 hover:text-foreground hover:bg-[var(--surface-2)] focus:outline-none focus:ring-2 focus:ring-[var(--accent)]"
|
||||
>
|
||||
<FiX size={20} />
|
||||
</button>
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">Share Hack</h2>
|
||||
<p className="mt-1 text-sm text-foreground/70">
|
||||
Choose how you want to share this hack.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Share Directly Section */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<h3 className="text-sm font-semibold text-foreground/90">Share Directly</h3>
|
||||
|
||||
{/* Share Icons Row */}
|
||||
<div className="flex items-center gap-3 overflow-x-auto p-1 pb-2 scroll-smooth scrollbar-thin scrollbar-color-muted"
|
||||
style={{ scrollbarWidth: 'thin' }}
|
||||
>
|
||||
<SocialIconButton
|
||||
type="facebook"
|
||||
icon={FaFacebook}
|
||||
label="Facebook"
|
||||
/>
|
||||
<SocialIconButton
|
||||
type="reddit"
|
||||
icon={FaReddit}
|
||||
label="Reddit"
|
||||
/>
|
||||
<SocialIconButton
|
||||
type="twitter"
|
||||
icon={FaXTwitter}
|
||||
label="Twitter/X"
|
||||
/>
|
||||
<SocialIconButton
|
||||
type="email"
|
||||
icon={FiMail}
|
||||
label="Email"
|
||||
/>
|
||||
{hasNavigatorShare && (
|
||||
<SocialIconButton
|
||||
type="other"
|
||||
icon={FiShare2}
|
||||
label="Other"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Share as Badge Section */}
|
||||
<div className="flex flex-col gap-4 pt-4 border-t border-[var(--border)]">
|
||||
<h3 className="text-sm font-semibold text-foreground/90">Share as Badge</h3>
|
||||
|
||||
{/* Badge Preview */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-xs text-foreground/60 font-medium">Preview:</p>
|
||||
<div className="flex justify-center p-3 rounded-md bg-[var(--surface-1)] border border-[var(--border)]">
|
||||
<img
|
||||
src={BANNER_IMAGE_URL}
|
||||
width={BANNER_IMAGE_WIDTH}
|
||||
height={BANNER_IMAGE_HEIGHT}
|
||||
alt="Download now at hackdex.app"
|
||||
className="max-w-full h-auto rounded"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SEO Tip */}
|
||||
<div className="flex items-start justify-center gap-2 rounded-md border border-[var(--border)]/70 bg-[var(--surface-2)]/20 px-3 py-2.5">
|
||||
<FaInfoCircle size={13} className="text-foreground/80 shrink-0 self-center" />
|
||||
<p className="text-xs text-foreground/60 leading-relaxed">
|
||||
<span className="font-semibold text-foreground/80">Creator Tip:</span> Sharing your hack on other platforms helps boost this page's SEO to potentially outrank unauthorized mirror sites.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Badge Share Buttons */}
|
||||
<div className="flex items-center gap-3 overflow-x-auto p-1 pb-2 scroll-smooth scrollbar-thin scrollbar-color-muted"
|
||||
style={{ scrollbarWidth: 'thin' }}
|
||||
>
|
||||
<SocialIconButton
|
||||
type="bbcode"
|
||||
icon={PiBracketsSquareBold}
|
||||
label="BBCode"
|
||||
/>
|
||||
<SocialIconButton
|
||||
type="html"
|
||||
icon={PiBracketsAngleBold}
|
||||
label="HTML"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* URL Copy Section */}
|
||||
<div className="pt-4 border-t border-[var(--border)]">
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-xs text-foreground/60 font-medium mb-1">Or share with link</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={url}
|
||||
className="flex-1 px-3 py-2.5 rounded-md border border-[var(--border)] bg-[var(--surface-1)] text-sm text-foreground/80 focus:outline-none focus:ring-2 focus:ring-[var(--accent)]"
|
||||
onClick={(e) => (e.target as HTMLInputElement).select()}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyUrl}
|
||||
className="inline-flex items-center justify-center w-10 h-10 rounded-md ring-1 ring-[var(--border)] bg-[var(--surface-2)] hover:bg-[var(--surface-3)] focus:outline-none focus:ring-2 focus:ring-[var(--accent)]"
|
||||
title="Copy to clipboard"
|
||||
aria-label="Copy URL to clipboard"
|
||||
>
|
||||
{urlCopied ? (
|
||||
<FiCheck size={18} className="text-green-500" />
|
||||
) : (
|
||||
<FiCopy size={18} className="text-foreground/80" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShareModal;
|
||||
36
src/components/NoticeBanner.tsx
Normal file
36
src/components/NoticeBanner.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { get } from "@vercel/edge-config";
|
||||
|
||||
const NOTICE_KEY = process.env.NEXT_PUBLIC_NOTICE_KEY ?? "global_notice_message";
|
||||
|
||||
export default async function NoticeBanner() {
|
||||
let message: string | null = null;
|
||||
|
||||
try {
|
||||
const value = await get<string | null>(NOTICE_KEY);
|
||||
if (typeof value === "string" && value.trim().length > 0) {
|
||||
message = value.trim();
|
||||
}
|
||||
} catch (error) {
|
||||
// Fail silently if Edge Config is unavailable or misconfigured
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!message) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full border-b border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-700/60 dark:bg-amber-950/60 dark:text-amber-100">
|
||||
<div className="mx-auto flex max-w-screen-2xl items-center px-6 py-2 text-sm">
|
||||
<span className="mr-2 inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-amber-500 text-[11px] font-bold text-amber-50 dark:bg-amber-400 dark:text-amber-950">
|
||||
!
|
||||
</span>
|
||||
<p className="line-clamp-3">
|
||||
{message}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -31,3 +31,25 @@ export function slugify(text: string) {
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the public URL for a single cover image
|
||||
*/
|
||||
export function getCoverUrl(objectKey: string): string {
|
||||
const domain = process.env.NEXT_PUBLIC_HACK_COVERS_DOMAIN;
|
||||
if (!domain) {
|
||||
throw new Error("NEXT_PUBLIC_HACK_COVERS_DOMAIN environment variable is not set");
|
||||
}
|
||||
return `${domain}/${objectKey}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get public URLs for multiple cover images
|
||||
*/
|
||||
export function getCoverUrls(objectKeys: string[]): string[] {
|
||||
const domain = process.env.NEXT_PUBLIC_HACK_COVERS_DOMAIN;
|
||||
if (!domain) {
|
||||
throw new Error("NEXT_PUBLIC_HACK_COVERS_DOMAIN environment variable is not set");
|
||||
}
|
||||
return objectKeys.map(key => `${domain}/${key}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user