Initial mockup commit

This commit is contained in:
Jared Schoeny
2025-10-06 15:26:37 -10:00
parent 65cf2d5a46
commit 64ee43d409
34 changed files with 4193 additions and 111 deletions

View File

@@ -0,0 +1,139 @@
"use client";
import Image from "next/image";
import React from "react";
import useEmblaCarousel from "embla-carousel-react";
export default function Gallery({ images, title }: { images: string[]; title: string }) {
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true });
const [selectedIndex, setSelectedIndex] = React.useState(0);
const [lightboxOpen, setLightboxOpen] = React.useState(false);
React.useEffect(() => {
if (!emblaApi) return;
const onSelect = () => setSelectedIndex(emblaApi.selectedScrollSnap());
emblaApi.on("select", onSelect);
onSelect();
return () => {
if (emblaApi) emblaApi.off("select", onSelect);
};
}, [emblaApi]);
return (
<div className="card p-4">
<div className="relative aspect-[16/9] w-full overflow-hidden" ref={emblaRef}>
<div className="flex h-full">
{images.map((src, idx) => (
<div key={`${src}-${idx}`} className="relative h-full flex-[0_0_100%]">
<button onClick={() => setLightboxOpen(true)} className="absolute inset-0">
<Image src={src} alt={title} fill className="object-contain" unoptimized />
</button>
</div>
))}
</div>
<div className="pointer-events-auto absolute inset-y-0 left-0 flex items-center">
<button
aria-label="Previous image"
onClick={() => emblaApi && emblaApi.scrollPrev()}
className="m-2 rounded-full bg-[color-mix(in_oklab,black_30%,transparent)] p-2 text-white ring-1 ring-white/30 hover:bg-[color-mix(in_oklab,black_50%,transparent)] dark:bg-black/30 dark:hover:bg-black/50"
>
</button>
</div>
<div className="pointer-events-auto absolute inset-y-0 right-0 flex items-center">
<button
aria-label="Next image"
onClick={() => emblaApi && emblaApi.scrollNext()}
className="m-2 rounded-full bg-[color-mix(in_oklab,black_30%,transparent)] p-2 text-white ring-1 ring-white/30 hover:bg-[color-mix(in_oklab,black_50%,transparent)] dark:bg-black/30 dark:hover:bg-black/50"
>
</button>
</div>
</div>
<div className="mt-3 flex gap-2 overflow-x-auto">
{images.map((src, i) => (
<button
key={`${src}-${i}`}
onClick={() => emblaApi && emblaApi.scrollTo(i)}
className={`relative h-16 w-28 overflow-hidden rounded ring-1 ${
i === selectedIndex ? "ring-[var(--accent)]" : "ring-[var(--border)]"
}`}
aria-label={`Show image ${i + 1}`}
>
<Image src={src} alt={`${title} screenshot ${i + 1}`} fill className="object-cover" unoptimized />
</button>
))}
</div>
{lightboxOpen && (
<Lightbox images={images} startIndex={selectedIndex} title={title} onClose={() => setLightboxOpen(false)} />
)}
</div>
);
}
function Lightbox({ images, startIndex, title, onClose }: { images: string[]; startIndex: number; title: string; onClose: () => void }) {
const [index, setIndex] = React.useState(startIndex);
const closeRef = React.useRef<HTMLButtonElement | null>(null);
const onPrev = React.useCallback(() => setIndex((i) => (i - 1 + images.length) % images.length), [images.length]);
const onNext = React.useCallback(() => setIndex((i) => (i + 1) % images.length), [images.length]);
React.useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
if (e.key === "ArrowRight") onNext();
if (e.key === "ArrowLeft") onPrev();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose, onNext, onPrev]);
React.useEffect(() => {
closeRef.current?.focus();
}, []);
return (
<div className="fixed inset-0 z-50" role="dialog" aria-modal="true" aria-label={`Screenshots for ${title}`}>
<div className="absolute inset-0 bg-black/70 dark:bg-black/80 backdrop-blur-sm" onClick={onClose} />
<div className="relative mx-auto flex h-full max-w-6xl items-center px-4" onClick={(e) => e.stopPropagation()}>
<div className="relative w-full">
<div
className="relative aspect-[16/9] w-full overflow-hidden rounded"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<Image src={images[index]} alt={`${title} screenshot ${index + 1}`} fill className="object-contain" unoptimized />
</div>
<div className="mt-3 flex items-center justify-center">
<div className="rounded-full bg-[var(--surface-2)] px-3 py-1 text-xs text-foreground ring-1 ring-[var(--border)]" aria-live="polite">
{index + 1} / {images.length}
</div>
</div>
<button
type="button"
ref={closeRef}
className="absolute right-3 top-3 rounded bg-[var(--surface-2)] px-3 py-1 text-sm text-foreground ring-1 ring-[var(--border)] hover:brightness-95 focus:outline-none focus:ring-2 focus:ring-[var(--ring)]"
onClick={onClose}
aria-label="Close lightbox"
>
Close
</button>
</div>
<button
type="button"
aria-label="Previous image"
onClick={onPrev}
className="absolute left-4 top-1/2 -translate-y-1/2 rounded-full bg-[var(--surface-2)] p-3 text-foreground ring-1 ring-[var(--border)] hover:brightness-95 focus:outline-none focus:ring-2 focus:ring-[var(--ring)]"
>
</button>
<button
type="button"
aria-label="Next image"
onClick={onNext}
className="absolute right-4 top-1/2 -translate-y-1/2 rounded-full bg-[var(--surface-2)] p-3 text-foreground ring-1 ring-[var(--border)] hover:brightness-95 focus:outline-none focus:ring-2 focus:ring-[var(--ring)]"
>
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,55 @@
"use client";
import React from "react";
import StickyActionBar from "@/components/Hack/StickyActionBar";
import { useBaseRoms } from "@/contexts/BaseRomContext";
export default function HackActions({ title, version, author, baseRom }: { title: string; version?: string; author: string; baseRom: string }) {
const { isLinked, hasPermission, hasCached, importUploadedBlob, ensurePermission, linkRom, getFileBlob, supported } = useBaseRoms();
const [file, setFile] = React.useState<File | null>(null);
const [status, setStatus] = React.useState<"idle" | "ready" | "patching" | "done">("idle");
React.useEffect(() => {
if (isLinked(baseRom) && (hasPermission(baseRom) || hasCached(baseRom))) {
setStatus("ready");
}
}, [baseRom, isLinked, hasPermission, hasCached]);
function onSelectFile(e: React.ChangeEvent<HTMLInputElement>) {
const f = e.target.files?.[0] ?? null;
setFile(f);
setStatus(f ? "ready" : "idle");
if (f) importUploadedBlob(f);
}
async function onPatch() {
if (!file) {
if (!isLinked(baseRom) && !hasCached(baseRom)) return;
if (!hasCached(baseRom)) {
const perm = await ensurePermission(baseRom, true);
if (perm !== "granted") return;
}
const linkedFile = await getFileBlob(baseRom);
if (!linkedFile) return;
}
setStatus("patching");
setTimeout(() => setStatus("done"), 1200);
}
return (
<StickyActionBar
title={title}
version={version}
author={author}
onPatch={onPatch}
status={status}
isLinked={isLinked(baseRom)}
ready={hasPermission(baseRom) || hasCached(baseRom)}
onClickLink={() => (isLinked(baseRom) ? ensurePermission(baseRom, true) : linkRom(baseRom))}
supported={supported}
onUploadChange={onSelectFile}
/>
);
}

View File

@@ -0,0 +1,71 @@
"use client";
import React from "react";
export default function StickyActionBar({ title, version, author, onPatch, status, isLinked, ready, onClickLink, supported, onUploadChange }: {
title: string;
version?: string;
author: string;
onPatch: () => void;
status: "idle" | "ready" | "patching" | "done";
isLinked: boolean;
ready: boolean;
onClickLink: () => void;
supported: boolean;
onUploadChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
}) {
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => setMounted(true), []);
const isDisabled = status === "patching" || (mounted && !ready && !isLinked && !supported);
return (
<div className="sticky top-18 z-30">
<div className="mx-auto flex w-full lg:max-w-screen-lg items-center justify-between gap-4 rounded-md border border-[var(--border)] bg-[var(--surface-2)]/80 px-4 py-3 backdrop-blur supports-[backdrop-filter]:bg-[color-mix(in_oklab,var(--background)_70%,transparent)]">
<div className="min-w-0">
<div className="flex items-center gap-2">
<div className="truncate text-sm font-medium">{title}</div>
{version && (
<span className="shrink-0 rounded-full bg-[var(--surface-2)] px-2 py-0.5 text-[11px] font-medium text-foreground/85 ring-1 ring-[var(--border)]">{version}</span>
)}
</div>
<div className="truncate text-xs text-foreground/60">By {author}</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className={`rounded-full px-2 py-0.5 text-xs ring-1 ${
ready
? "bg-emerald-600/60 text-white ring-emerald-700/80 dark:bg-emerald-500/25 dark:text-emerald-100 dark:ring-emerald-400/90"
: isLinked
? "bg-amber-600/60 text-white ring-amber-700/80 dark:bg-amber-500/50 dark:text-amber-100 dark:ring-amber-400/90"
: "bg-red-600/60 text-white ring-red-700/80 dark:bg-red-500/50 dark:text-red-100 dark:ring-red-400/90"
}`}>
{ready ? "Ready" : isLinked ? "Permission needed" : "Base ROM needed"}
</span>
{!ready && !isLinked && (
<label className="inline-flex items-center gap-2 text-xs text-foreground/80">
<input type="file" onChange={onUploadChange} className="rounded-md bg-[var(--surface-2)] px-2 py-1 text-xs ring-1 ring-inset ring-[var(--border)]" />
<span>Upload base ROM</span>
</label>
)}
{!ready && isLinked && (
<button
type="button"
onClick={onClickLink}
disabled={!supported}
className="rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-3 py-2 text-xs disabled:cursor-not-allowed disabled:opacity-60"
>
Grant permission
</button>
)}
<button
onClick={onPatch}
disabled={isDisabled}
className="shine-wrap btn-premium h-9 min-w-[7.5rem] text-sm font-semibold disabled:cursor-not-allowed disabled:opacity-70"
>
<span>{status === "patching" ? "Patching…" : status === "done" ? "Patched" : "Patch Now"}</span>
</button>
</div>
</div>
</div>
);
}