mirror of
https://github.com/Hackdex-App/hackdex-website.git
synced 2026-09-08 00:46:38 -05:00
Initial mockup commit
This commit is contained in:
139
src/components/Hack/Gallery.tsx
Normal file
139
src/components/Hack/Gallery.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
55
src/components/Hack/HackActions.tsx
Normal file
55
src/components/Hack/HackActions.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
71
src/components/Hack/StickyActionBar.tsx
Normal file
71
src/components/Hack/StickyActionBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user