diff --git a/app/globals.css b/app/globals.css index 6e02bcb..00cf823 100644 --- a/app/globals.css +++ b/app/globals.css @@ -131,19 +131,12 @@ } *, -*::before, -*::after { +*::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } -/* The page below the profile "more" threshold is tinted. Painting it as the - root background rather than as an element means it covers the whole document - for free, and -- unlike an oversized absolutely-positioned box -- it cannot - extend the scrollable area. `--threshold-y` is the line's distance from the - top of the document, set from ProfileMore; with no threshold on the page the - 100% fallback puts the stop at the bottom, so nothing is tinted. */ html { background-color: var(--bg); background-image: linear-gradient( @@ -220,13 +213,7 @@ nav { } -/* ---- project card selection ---- * - * - * Browsers with the View Transitions API morph the card between its grid slot - * and the full-width slot; `data-vt="off"` marks the fallback path, where the - * newly-selected card plays this enter animation instead. - */ - +/* ---- project card selection ---- */ @keyframes card-select-in { from { opacity: 0; diff --git a/app/layout.tsx b/app/layout.tsx index 7fe1621..9503a36 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -23,9 +23,7 @@ export const metadata: Metadata = { description: SITE.description }; -export default function RootLayout({ - children, -}: { +export default function RootLayout( {children}: { children: React.ReactNode; }) { return ( diff --git a/app/mods/layout.tsx b/app/mods/layout.tsx new file mode 100644 index 0000000..412f564 --- /dev/null +++ b/app/mods/layout.tsx @@ -0,0 +1,49 @@ +import Nav from "@/components/Nav"; +import Footer from "@/components/Footer"; + +export default async function ModsLayout({children}: { + children: React.ReactNode; +}) { + + return ( +
+ +
+ +
+ +
+
+ +
+ +
+ +
+ ); +} diff --git a/app/mods/page.tsx b/app/mods/page.tsx new file mode 100644 index 0000000..b5867d2 --- /dev/null +++ b/app/mods/page.tsx @@ -0,0 +1,11 @@ +"use client"; + +import ModsGrid from "@/components/ModsGrid"; + +export default function Mods() { + return ( +
+ +
+ ); +} diff --git a/app/profile/layout.tsx b/app/profile/layout.tsx index dd38e26..fd34fed 100644 --- a/app/profile/layout.tsx +++ b/app/profile/layout.tsx @@ -13,16 +13,16 @@ export default function Layout({children}: { margin: "0 auto", padding: "0 24px", transition: "all 0.5s cubic-bezier(0.16, 1, 0.3, 1)", - }} - > + }}> +
+ }}> +
+ }}> +
diff --git a/app/project/[slug]/layout.tsx b/app/project/[slug]/layout.tsx index d1918e5..83375e4 100644 --- a/app/project/[slug]/layout.tsx +++ b/app/project/[slug]/layout.tsx @@ -1,6 +1,11 @@ import { ProjectProvider } from "../../context/ProjectContext"; import { getProjectDetails } from "@/data/content"; + +/** + * Layout for all paths caught under project/[slug] + */ + export default async function SlugLayout({children, params}: { children: React.ReactNode; params: Promise<{ slug: string }>; diff --git a/app/project/[slug]/page.tsx b/app/project/[slug]/page.tsx index 82005e1..7824587 100644 --- a/app/project/[slug]/page.tsx +++ b/app/project/[slug]/page.tsx @@ -8,9 +8,8 @@ import "../../../styles/markdown-container.css"; import Link from 'next/link'; import { PROJECTS } from '@/data/content'; -export default function ProjectPage({ - params, -}: {params: Promise<{ slug: string }>}) { +export default function ProjectPage({params}: { params: Promise<{ slug: string }> }) +{ const mounted = useMountTransition(50) const { slug } = use(params); diff --git a/app/project/layout.tsx b/app/project/layout.tsx index 8678644..7f55787 100644 --- a/app/project/layout.tsx +++ b/app/project/layout.tsx @@ -9,22 +9,23 @@ export default async function ProjectLayout({children}: { return (
+
+ }}> +
+ }}> +
+ }}>
diff --git a/components/ModCard.tsx b/components/ModCard.tsx new file mode 100644 index 0000000..220eba2 --- /dev/null +++ b/components/ModCard.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { useState } from "react"; +import type { Mod } from "@/data/content"; +import "../styles/mods.css"; + +type Props = { + mod: Mod; + visible?: boolean; +}; + +/** One mod: the list item card inside a game's mod list. */ +export default function ModCard({ mod, visible = true }: Props) { + const [hovered, setHovered] = useState(false); + + return ( + setHovered(true)} + onMouseLeave={() => setHovered(false)} + style={{ + background: hovered ? "var(--surface-hover)" : "var(--surface)", + border: `1px solid ${hovered ? "var(--accent)" : "var(--border)"}`, + transform: visible ? (hovered ? "translateY(-2px)" : "none") : "translateY(8px)", + opacity: visible ? 1 : 0, + boxShadow: hovered ? "var(--shadow-md)" : "none", + }} + > +
+ + {mod.title} + + + {mod.version && v{mod.version}} +
+ +

{mod.description}

+ +
+ {mod.tags && mod.tags.length > 0 && ( +
+ {mod.tags.map((tag) => ( + + {tag} + + ))} +
+ )} + + + {mod.linkLabel ?? "Repository"} + + +
+
+ ); +} diff --git a/components/ModGameSection.tsx b/components/ModGameSection.tsx new file mode 100644 index 0000000..d6b2bfe --- /dev/null +++ b/components/ModGameSection.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { useState } from "react"; +import type { ModdedGame } from "@/data/content"; +import ModList from "@/components/ModList"; +import "../styles/mods.css"; + +type Props = { + game: ModdedGame; + visible?: boolean; + /** Collapsed by default on the smaller layout, where the full list is a long scroll. */ + defaultOpen?: boolean; +}; + +/** One game and everything built for it: header, then the game's mod list. */ +export default function ModGameSection({ game, visible = true, defaultOpen = true }: Props) { + const [open, setOpen] = useState(defaultOpen); + const [hovered, setHovered] = useState(false); + + const count = game.mods.length; + + return ( +
+ + + {game.description &&

{game.description}

} + + {open && } +
+ ); +} diff --git a/components/ModList.tsx b/components/ModList.tsx new file mode 100644 index 0000000..ee899c7 --- /dev/null +++ b/components/ModList.tsx @@ -0,0 +1,25 @@ +"use client"; + +import type { Mod } from "@/data/content"; +import ModCard from "@/components/ModCard"; +import "../styles/mods.css"; + +type Props = { + mods: Mod[]; + visible?: boolean; +}; + +/** The list of mods belonging to a single game. Two columns on desktop, one on mobile. */ +export default function ModList({ mods, visible = true }: Props) { + if (mods.length === 0) { + return

Nothing published here yet.

; + } + + return ( +
+ {mods.map((mod) => ( + + ))} +
+ ); +} diff --git a/components/ModsGrid.tsx b/components/ModsGrid.tsx new file mode 100644 index 0000000..f2baaca --- /dev/null +++ b/components/ModsGrid.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { MOD_GAMES } from "@/data/content"; +import { useStaggerReveal } from "@/hooks/useAnimations"; +import ModGameSection from "@/components/ModGameSection"; +import "../styles/mods.css"; + +/** The mods tab: every game I've modded, each with its own list. */ +export default function ModsGrid() { + const visible = useStaggerReveal(MOD_GAMES.length, 100); + + return ( +
+
+ {"# mods"} +
+

+ {"Games I've modded"} +

+ +
+ {MOD_GAMES.map((game, i) => ( + + ))} +
+
+ ); +} diff --git a/components/Nav.tsx b/components/Nav.tsx index 384c10d..5350128 100644 --- a/components/Nav.tsx +++ b/components/Nav.tsx @@ -7,7 +7,7 @@ type Props = { section: string; }; -const items = ["profile", "project"]; +const items = ["profile", "project", "mods"]; export default function Nav({ section }: Props) { diff --git a/components/SimpleGallery.tsx b/components/SimpleGallery.tsx index e1e7f10..b8d3bc5 100644 --- a/components/SimpleGallery.tsx +++ b/components/SimpleGallery.tsx @@ -18,6 +18,9 @@ export default function SimpleGallery({ const [active, setActive] = useState(0); const [isEnlarged, setIsEnlarged] = useState(false); + // Hovering the media itself mirrors the expand button's own hover state, so + // the button reads as the affordance for the click the media now accepts. + const [mediaHovered, setMediaHovered] = useState(false); const timerRef = useRef | null>(null); const videoRef = useRef(null); const fsPortalRef = useRef(null); @@ -39,8 +42,8 @@ export default function SimpleGallery({ function expandButton() { return ( -
- Expand
); } @@ -127,7 +129,8 @@ export default function SimpleGallery({ alt={`${title} screenshot ${active + 1}`} width={800} height={600} - style={imageStyle} + onClick={() => setIsEnlarged(true)} + style={{ ...imageStyle, cursor: "zoom-in" }} /> )} @@ -316,7 +319,21 @@ export default function SimpleGallery({ return ( <>
-
+ {/* The border is always present but transparent when idle, so lighting it + up on hover can't nudge the surrounding layout. */} +
setMediaHovered(true)} + onMouseLeave={() => setMediaHovered(false)} + style={{ + position: "relative", + display: "inline-block", + width: "100%", + padding: 3, + borderRadius: 7, + border: `1px solid ${mediaHovered ? "var(--border-strong)" : "transparent"}`, + transition: "border-color 0.15s ease", + }} + > {inlineMedia()}
{thumbnailStrip()} diff --git a/data/content.ts b/data/content.ts index f634b2f..2804612 100644 --- a/data/content.ts +++ b/data/content.ts @@ -29,6 +29,24 @@ export type Project = { year: string; }; +export type Mod = { + id: number; + title: string; + description: string; + link: string; + linkLabel?: string; + version?: string; + tags?: string[]; +}; + +export type ModdedGame = { + id: number; + slug: string; + title: string; + platform?: string; + description?: string; + mods: Mod[]; +}; export const PROFILE = { name: "Hunter", @@ -297,10 +315,15 @@ A real-world solution for event staff and volunteer management. id: 2, slug: "clean-space", title: "Clean Space", - description: "Clean Space is an architecture, proof of concept, and an in-development tool for Space Engineers and Space Engineers 2 server owners featuring client and server side .", + description: "Clean Space is a proof of concept and in-development tool for (Space Engineers) Torch server owners featuring client and server side plugin hashing, identification, and filtering controls.", tags: ["C#", ".NET", "WPF", "Netcode", "Security"], link: "https://github.com/FerrenF/CleanSpace", year: "2025", + images: [ + "img/projects/clean/cleanspace.png", + "img/projects/clean/ss1.png", + "img/projects/clean/ss2.png", + "img/projects/clean/ss3.png"], },{ id: 3, slug: "ue4ss-explorer", @@ -321,6 +344,25 @@ A real-world solution for event staff and volunteer management. }]; +export const MOD_GAMES: ModdedGame[] = [{ + id: 1, + slug: "palworld", + title: "Palworld", + platform: "UE4SS / Dedicated server", + description: "Server-side tooling and quality-of-life changes for dedicated Palworld servers.", + mods: [{ + id: 1, + title: "Got Your Number", + description: "Adds paldex numbers to the Palbox pal details screen, and the main menu pal status screen.", + link: "https://steamcommunity.com/sharedfiles/filedetails/?id=3780862038", + linkLabel: "Steam", + version: "0.2.0", + tags: ["Client", "Unreal Engine", "Palworld", "UE4SS", "C++"], + }] + } + ]; + + import { remark } from "remark"; import remarkParse from "remark-parse"; import rehypeRaw from "rehype-raw"; diff --git a/public/img/projects/clean/cleanspace.png b/public/img/projects/clean/cleanspace.png new file mode 100644 index 0000000..30c465c Binary files /dev/null and b/public/img/projects/clean/cleanspace.png differ diff --git a/public/img/projects/clean/ss1.png b/public/img/projects/clean/ss1.png new file mode 100644 index 0000000..ee472ff Binary files /dev/null and b/public/img/projects/clean/ss1.png differ diff --git a/public/img/projects/clean/ss2.png b/public/img/projects/clean/ss2.png new file mode 100644 index 0000000..b58b391 Binary files /dev/null and b/public/img/projects/clean/ss2.png differ diff --git a/public/img/projects/clean/ss3.png b/public/img/projects/clean/ss3.png new file mode 100644 index 0000000..1109181 Binary files /dev/null and b/public/img/projects/clean/ss3.png differ diff --git a/public/img/projects/clean/ss4.png b/public/img/projects/clean/ss4.png new file mode 100644 index 0000000..3b13c7d Binary files /dev/null and b/public/img/projects/clean/ss4.png differ diff --git a/styles/gallery.css b/styles/gallery.css index 491d40f..ea314cd 100644 --- a/styles/gallery.css +++ b/styles/gallery.css @@ -11,8 +11,9 @@ padding: 0; } .gallery-action-button button { - width: 28; - height: 28; + width: 28px; + height: 28px; + display: flex; border-radius: var(--radius-sm); border: 1px solid var(--accent); background-color: var(--surface); @@ -21,11 +22,15 @@ color: var(--fg); z-index: 1001; cursor: pointer; - transition: background 0.15s ease, border-color 0.15s ease; - margin-right: 0.3em; + transition: background 0.15s ease, border-color 0.15s ease, filter 0.15s ease; } -.gallery-action-button button:hover { - filter:brightness(120%) +/* `.is-hovered` is set while the media itself is hovered, so the button shows + the same state as a direct hover on it. */ +.gallery-action-button button:hover, +.gallery-action-button.is-hovered button { + filter: brightness(120%); + border-color: var(--accent-hover); + background-color: var(--surface-hover); } .action-button-svg { diff --git a/styles/mods.css b/styles/mods.css new file mode 100644 index 0000000..42b5f8b --- /dev/null +++ b/styles/mods.css @@ -0,0 +1,258 @@ + +/* ---- mods tab ---- */ + +.mod-games { + display: flex; + flex-direction: column; + gap: 20px; +} + +/* ---- game section ---- */ + +.mod-game { + background: var(--bg-raised); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 20px 20px 22px; + transition: opacity 0.35s var(--ease-out), transform 0.35s var(--ease-out), + border-color 0.2s ease; + min-width: 0; +} + +.mod-game-head { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 0; + background: transparent; + border: none; + cursor: pointer; + text-align: left; + color: inherit; + font: inherit; +} + +.mod-game-chevron { + display: flex; + flex: 0 0 auto; + color: var(--muted); + transition: transform 0.2s var(--ease-out); +} + +.mod-game-heading { + display: flex; + align-items: baseline; + gap: 10px; + flex: 1 1 auto; + min-width: 0; + flex-wrap: wrap; +} + +.mod-game-title { + font-family: var(--mono); + font-size: var(--text-lg); + font-weight: 700; + transition: color 0.2s ease; +} + +.mod-game-platform { + font-family: var(--mono); + font-size: var(--text-xs); + color: var(--muted); +} + +.mod-game-count { + flex: 0 0 auto; + font-family: var(--mono); + font-size: var(--text-xs); + color: var(--muted); + padding: 2px 8px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + white-space: nowrap; +} + +.mod-game-description { + font-family: var(--sans); + font-size: 13px; + line-height: var(--leading-normal); + color: var(--fg-secondary); + margin: 10px 0 0 20px; +} + +/* ---- mod list ---- */ + +.mod-list { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + margin: 16px 0 0 20px; +} + +.mod-list-empty { + font-family: var(--mono); + font-size: var(--text-xs); + color: var(--muted); + margin: 16px 0 0 20px; +} + +/* ---- mod card ---- */ + +.mod-card { + display: flex; + flex-direction: column; + min-width: 0; + padding: 16px; + border-radius: var(--radius-md); + text-decoration: none; + cursor: pointer; + transition: all 0.3s var(--ease-out); +} + +.mod-card-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; + margin-bottom: 8px; +} + +.mod-card-title { + font-family: var(--mono); + font-size: 15px; + font-weight: 700; + transition: color 0.2s ease; + min-width: 0; +} + +.mod-card-version { + flex: 0 0 auto; + font-family: var(--mono); + font-size: var(--text-xs); + color: var(--muted); + white-space: nowrap; +} + +.mod-card-description { + font-family: var(--sans); + font-size: 13px; + line-height: var(--leading-normal); + color: var(--fg-secondary); + margin: 0 0 14px 0; + + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + line-clamp: 3; + overflow: hidden; + text-overflow: ellipsis; +} + +.mod-card-foot { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + flex-wrap: wrap; + /* Keeps the link pinned to the bottom when neighbouring cards are taller. */ + margin-top: auto; +} + +.mod-card-tags { + display: flex; + gap: 6px; + flex-wrap: wrap; + min-width: 0; +} + +.mod-tag { + font-family: var(--mono); + font-size: var(--text-xs); + font-weight: 500; + color: var(--accent); + background: var(--accent-bg); + padding: 2px 8px; + border-radius: var(--radius-sm); + letter-spacing: 0.02em; +} + +.mod-card-link { + display: inline-flex; + align-items: center; + gap: 6px; + flex: 0 0 auto; + font-family: var(--mono); + font-size: var(--text-xs); + padding: 5px 10px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg); + white-space: nowrap; + transition: all 0.2s ease; +} + +/* ---- narrow layout: single column, less per card ---- */ + +@media (max-width: 768px) { + .mod-games { + gap: 14px; + } + + .mod-game { + padding: 16px 14px 18px; + border-radius: var(--radius-md); + } + + .mod-game-title { + font-size: var(--text-base); + } + + /* The platform line and the game blurb are context, not content -- the + narrow layout drops them and keeps the mod list itself. */ + .mod-game-platform, + .mod-game-description { + display: none; + } + + .mod-list, + .mod-list-empty { + grid-template-columns: minmax(0, 1fr); + margin-left: 0; + margin-top: 12px; + } + + .mod-card { + padding: 12px 14px; + } + + .mod-card-head { + margin-bottom: 6px; + } + + .mod-card-description { + -webkit-line-clamp: 2; + line-clamp: 2; + margin-bottom: 10px; + } + + /* Tags are the first thing to go: the title, blurb and link carry the card. */ + .mod-card-tags { + display: none; + } + + .mod-card-link { + width: 100%; + justify-content: center; + padding: 8px 10px; + } +} + +@media (prefers-reduced-motion: reduce) { + .mod-game, + .mod-card, + .mod-game-chevron { + transition: none; + } +}