Deux courbes partagées dans lib/motion.ts, pour que le CSS et Framer aient le même feeling, et quatre primitives que Tailwind ne sait pas exprimer : reflet de bouton, halo qui suit le curseur, soulignement qui se dessine, secousse d'erreur. Nouveaux composants : Stagger (les collections se distribuent une par une), Spotlight (trouve son parent tout seul, donc les cartes restent des composants serveur), ScrollProgress, BackToTop, Parallax, Spinner, CheckMark. Reveal accepte maintenant une direction. Côté rendu : le trait de nav glisse d'un onglet à l'autre, les cartes se soulèvent, le hero réagit au scroll, les piliers entrent en zig-zag, la lightbox glisse dans le sens demandé et les formulaires répondent. prefers-reduced-motion est respecté partout : Framer coupe via useReducedMotion, le CSS via la media query déjà en place. Le commit embarque aussi la réorganisation des composants par domaine qui était en cours dans l'arbre de travail — les deux étaient trop imbriquées pour être séparées proprement. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -23,23 +23,68 @@ Open [http://localhost:3000](http://localhost:3000).
|
|||||||
| `npm run start` | Serve the production build |
|
| `npm run start` | Serve the production build |
|
||||||
| `npm run lint` | Run ESLint |
|
| `npm run lint` | Run ESLint |
|
||||||
|
|
||||||
To regenerate the favicon from the studio logo (requires `sharp`):
|
Asset generation scripts (require `sharp`, already a devDependency):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
node scripts/build-favicon.mjs
|
node scripts/build-favicon.mjs # favicon + app icon from the mountain mark
|
||||||
|
node scripts/build-wordmark.mjs # transparent wordmark from "Full Logo.png"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Project structure
|
## Project structure
|
||||||
|
|
||||||
```text
|
```text
|
||||||
app/ Routes, layouts, server actions, metadata (sitemap/robots/feed/OG)
|
app/ Routes, layouts, server actions, metadata (sitemap/robots/feed/OG)
|
||||||
app/components/ Shared UI (Navbar, Footer, forms, social links)
|
app/components/ui/ Design-system primitives (Section, SectionHeading, ButtonLink, Logo…)
|
||||||
|
app/components/layout/ Navbar, Footer, SocialLinks
|
||||||
|
app/components/game/ GameCard, ScreenshotGallery
|
||||||
|
app/components/home/ Home hero
|
||||||
|
app/components/devblog/ PostList
|
||||||
|
app/components/forms/ ContactForm, Newsletter
|
||||||
content/devblog/ Devblog posts as .mdx (frontmatter: title, date, excerpt, tags, game)
|
content/devblog/ Devblog posts as .mdx (frontmatter: title, date, excerpt, tags, game)
|
||||||
lib/ Data + helpers (games registry, devblog loader, site URL)
|
lib/ Data + helpers (games registry, devblog loader, site config, formatting)
|
||||||
public/ Static assets (studio logos, hero video)
|
public/ Static assets (studio logos, game art, hero video)
|
||||||
scripts/ One-off build scripts (favicon generation)
|
scripts/ One-off asset build scripts
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Content lives in `lib/`, never in a page
|
||||||
|
|
||||||
|
- `lib/site.ts` — studio identity, navigation, contact channels, social accounts, brand assets.
|
||||||
|
- `lib/games.ts` — the games registry: title, description, genres, key art, banner **and
|
||||||
|
screenshots**.
|
||||||
|
|
||||||
|
Pages read from these. That is what keeps the home page and `/games/[slug]` in sync — the
|
||||||
|
featured block, the "From the workshop" gallery and the game page all render the same record.
|
||||||
|
|
||||||
|
### Design rule: no text over artwork
|
||||||
|
|
||||||
|
The Emberwild key art is light cream and the in-game captures are bright green, so body text
|
||||||
|
laid over them is unreadable at any gradient strength. Cards therefore put the image on one
|
||||||
|
panel and the text on a solid `bg-surface` panel; the game page shows the banner alone and
|
||||||
|
places the title underneath. Keep it that way when adding new layouts.
|
||||||
|
|
||||||
|
## Adding a game or new screenshots
|
||||||
|
|
||||||
|
Drop the files in `public/image/Games/<Game>/`, then add or edit the entry in `lib/games.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
slug: "emberwild",
|
||||||
|
title: "Emberwild",
|
||||||
|
status: "in-development", // in-development | coming-soon | released
|
||||||
|
tagline: "One readable line.",
|
||||||
|
description: "Full pitch, shown in the About block.",
|
||||||
|
genres: ["Survival", "Crafting"],
|
||||||
|
releaseWindow: "TBA",
|
||||||
|
keyArt: "/image/Games/Emberwild/Cover.png", // illustration, shown contained
|
||||||
|
banner: "/image/Games/Emberwild/Capture_1.png", // wide in-game shot, page banner
|
||||||
|
screenshots: [
|
||||||
|
{ src: "/image/Games/Emberwild/Capture_2.png", caption: "Autumn treeline" },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The gallery, the home page teaser, `/games`, the sitemap and the JSON-LD all pick it up.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Copy `.env.example` to `.env.local` and fill in the values.
|
Copy `.env.example` to `.env.local` and fill in the values.
|
||||||
@@ -86,5 +131,7 @@ Server-side prerequisites (one-time):
|
|||||||
|
|
||||||
## Known TODOs
|
## Known TODOs
|
||||||
|
|
||||||
- Wire the newsletter form to a real email provider (contact form now uses Resend).
|
See `TODO.md` for the full list. The two blockers are the 84 MB hero video
|
||||||
- Replace placeholder content (game, team members, social links).
|
(`public/Video/Header_Video.mp4`) and the social links, which are still unpublished
|
||||||
|
(`SOCIAL_LINKS` in `lib/site.ts` — entries with `href: null` are hidden rather than
|
||||||
|
rendered as dead `#` links).
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# TODO — Highland Games Studio Website
|
# TODO — Highland Games Studio Website
|
||||||
|
|
||||||
Liste des tâches restantes, classées par priorité.
|
Liste des tâches restantes, classées par priorité.
|
||||||
Dernière mise à jour : 2026-06-22
|
Dernière mise à jour : 2026-07-31
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -10,9 +10,14 @@ Dernière mise à jour : 2026-06-22
|
|||||||
- [ ] **Vidéo 84 Mo dans Git** (`public/Video/Header_Video.mp4`)
|
- [ ] **Vidéo 84 Mo dans Git** (`public/Video/Header_Video.mp4`)
|
||||||
- [ ] Compresser la vidéo (cible < 5 Mo, ~1080p) avec ffmpeg
|
- [ ] Compresser la vidéo (cible < 5 Mo, ~1080p) avec ffmpeg
|
||||||
- [ ] La sortir de l'historique Git (BFG ou `git-filter-repo`) — sinon elle reste à vie dans le repo
|
- [ ] La sortir de l'historique Git (BFG ou `git-filter-repo`) — sinon elle reste à vie dans le repo
|
||||||
- [ ] Ajouter un `poster` (image) sur la balise `<video>` de la page d'accueil
|
- [ ] Extraire une image du 1er plan et la renseigner dans `HERO_VIDEO.poster`
|
||||||
|
(`lib/site.ts`) — le champ existe déjà et attend juste un chemin
|
||||||
- [ ] Idéalement : servir le fichier hors-Git (CDN / objet statique)
|
- [ ] Idéalement : servir le fichier hors-Git (CDN / objet statique)
|
||||||
|
|
||||||
|
- [ ] **Screenshots en PNG 5 Mo** (`public/image/Games/Emberwild/Capture_*.png`)
|
||||||
|
- Next les réencode à la volée, mais le premier build/visiteur les paie plein pot.
|
||||||
|
- [ ] Les réexporter en JPEG/WebP qualité ~85 (cible < 400 Ko chacun)
|
||||||
|
|
||||||
- [ ] **Formulaires non fonctionnels** (perdent les données, juste un `console.log`)
|
- [ ] **Formulaires non fonctionnels** (perdent les données, juste un `console.log`)
|
||||||
- [x] Choisir un service email → **Resend**
|
- [x] Choisir un service email → **Resend**
|
||||||
- [x] Brancher le formulaire de contact → `app/actions/contact.ts`
|
- [x] Brancher le formulaire de contact → `app/actions/contact.ts`
|
||||||
@@ -34,16 +39,19 @@ Dernière mise à jour : 2026-06-22
|
|||||||
|
|
||||||
- [ ] **Remplacer tout le contenu placeholder**
|
- [ ] **Remplacer tout le contenu placeholder**
|
||||||
- [x] Vrai nom du jeu → **Emberwild** (`lib/games.ts`, slug `emberwild`)
|
- [x] Vrai nom du jeu → **Emberwild** (`lib/games.ts`, slug `emberwild`)
|
||||||
- [ ] Vrais noms / bios de l'équipe (au lieu de « Founder One / Two ») → `app/studio/page.tsx`
|
- [x] Vrais noms / bios de l'équipe → `app/studio/page.tsx`
|
||||||
- [ ] Vrais liens réseaux sociaux (actuellement tous en `href="#"`) → `app/components/SocialLinks.tsx`
|
- [x] Vraies images de jeu / screenshots (key art, bannière, galerie)
|
||||||
- [ ] Vraies images de jeu / screenshots (le logo sert de placeholder partout)
|
- [ ] Vrais liens réseaux sociaux → `SOCIAL_LINKS` dans `lib/site.ts`
|
||||||
|
(les entrées à `href: null` sont masquées, plus de liens morts vers `#`)
|
||||||
|
- [ ] Photos de Kevin et Axel → `app/studio/page.tsx` (le logo sert de placeholder)
|
||||||
|
|
||||||
- [ ] **Configurer l'URL du site**
|
- [ ] **Configurer l'URL du site**
|
||||||
- [x] Centralisée dans `lib/site.ts` (pilotable par `NEXT_PUBLIC_SITE_URL`)
|
- [x] Centralisée dans `lib/site.ts` (pilotable par `NEXT_PUBLIC_SITE_URL`)
|
||||||
- [ ] Confirmer le domaine final et définir la variable d'env en production
|
- [ ] Confirmer le domaine final et définir la variable d'env en production
|
||||||
|
|
||||||
- [ ] **Asset inutilisé** : `public/image/Studio/Full Logo.png` n'est référencé nulle part
|
- [x] **Asset inutilisé** : `Full Logo.png` sert maintenant de source à
|
||||||
- [ ] L'utiliser quelque part, ou le supprimer
|
`Wordmark.png` (version transparente utilisée dans le footer),
|
||||||
|
généré par `node scripts/build-wordmark.mjs`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -57,6 +65,32 @@ Dernière mise à jour : 2026-06-22
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## ✅ Refonte UX / structure (2026-07-31)
|
||||||
|
|
||||||
|
- [x] **Incohérence home ↔ page jeu corrigée** : la home affichait « PROJECT ONE »
|
||||||
|
avec ses propres textes et sa propre liste de screenshots codés en dur.
|
||||||
|
Tout vient maintenant de `lib/games.ts` (`keyArt`, `banner`, `screenshots`),
|
||||||
|
et les screenshots apparaissent enfin sur `/games/emberwild`.
|
||||||
|
- [x] **Lisibilité** : plus aucun texte posé sur une image. Le key art est crème et
|
||||||
|
les captures sont très claires — le texte crème par-dessus était illisible.
|
||||||
|
Cards = image d'un côté, texte sur panneau plein de l'autre.
|
||||||
|
- [x] **Logo** : plus de déformation (1344×768 rendu en 36×36, 120×80…), passe par
|
||||||
|
`components/ui/Logo.tsx` qui déduit la hauteur.
|
||||||
|
- [x] **Design system** : `Section`, `SectionHeading`, `PageHeader`, `ButtonLink`,
|
||||||
|
`StatusBadge`, `Reveal`, `GameCard`, `ScreenshotGallery`, `PostList` —
|
||||||
|
plus de duplication de `statusLabel` / `formatDate` / cards / boutons.
|
||||||
|
- [x] **Composants rangés** par domaine (`ui/`, `layout/`, `game/`, `home/`,
|
||||||
|
`devblog/`, `forms/`).
|
||||||
|
- [x] **Home rendue côté serveur** (elle était entièrement `"use client"`).
|
||||||
|
- [x] **Galerie avec lightbox** (clavier ←/→/Échap, scroll bloqué, légendes sur
|
||||||
|
fond plein).
|
||||||
|
- [x] **Accessibilité** : `prefers-reduced-motion` respecté (pas de vidéo ni
|
||||||
|
d'animations), page active signalée dans la navbar, focus visibles,
|
||||||
|
`formatDate` en UTC (la date d'un post ne décale plus d'un jour).
|
||||||
|
- [x] **Typo mobile** : titres 6xl/7xl qui débordaient sur 375 px réduits en 4xl/5xl.
|
||||||
|
- [x] **Contact** : emails en `text-xs` qui débordaient de leur colonne → `text-sm`
|
||||||
|
+ `break-all`, cartes en 1 colonne sur mobile.
|
||||||
|
|
||||||
## ✅ Déjà fait (2026-06-22)
|
## ✅ Déjà fait (2026-06-22)
|
||||||
|
|
||||||
- [x] Supprimé les 5 SVG par défaut de create-next-app (inutilisés)
|
- [x] Supprimé les 5 SVG par défaut de create-next-app (inutilisés)
|
||||||
|
|||||||
+44
-3
@@ -1,6 +1,8 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { Resend } from "resend";
|
import { Resend } from "resend";
|
||||||
|
import { autoReplyEmail, staffNotificationEmail } from "@/lib/emails";
|
||||||
|
import { CONTACT_CHANNELS } from "@/lib/site";
|
||||||
|
|
||||||
export type ContactState = {
|
export type ContactState = {
|
||||||
status: "idle" | "success" | "error";
|
status: "idle" | "success" | "error";
|
||||||
@@ -20,6 +22,14 @@ export async function sendContact(
|
|||||||
const message = String(formData.get("message") ?? "").trim();
|
const message = String(formData.get("message") ?? "").trim();
|
||||||
const honeypot = String(formData.get("website") ?? "");
|
const honeypot = String(formData.get("website") ?? "");
|
||||||
|
|
||||||
|
// Which desk the message is for. Never trusted as-is: it only counts when it
|
||||||
|
// matches a declared channel, so a crafted request cannot inject a label
|
||||||
|
// into the staff email subject.
|
||||||
|
const rawTopic = String(formData.get("topic") ?? "").trim();
|
||||||
|
const topic = CONTACT_CHANNELS.some((channel) => channel.label === rawTopic)
|
||||||
|
? rawTopic
|
||||||
|
: null;
|
||||||
|
|
||||||
if (honeypot) {
|
if (honeypot) {
|
||||||
return { status: "success", message: "Thanks for reaching out." };
|
return { status: "success", message: "Thanks for reaching out." };
|
||||||
}
|
}
|
||||||
@@ -54,23 +64,54 @@ export async function sendContact(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The studio inbox is a single address, so the desk is carried in the
|
||||||
|
// subject line — that is what makes it filterable.
|
||||||
|
const contact = {
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
subject: topic ? `[${topic}] ${subject}` : subject,
|
||||||
|
message,
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resend = new Resend(apiKey);
|
const resend = new Resend(apiKey);
|
||||||
|
|
||||||
|
// Critical: notify the studio staff. If this fails, surface an error.
|
||||||
|
const staff = staffNotificationEmail(contact);
|
||||||
const { error } = await resend.emails.send({
|
const { error } = await resend.emails.send({
|
||||||
from,
|
from,
|
||||||
to,
|
to,
|
||||||
replyTo: email,
|
replyTo: email,
|
||||||
subject: `[Contact] ${subject}`,
|
subject: staff.subject,
|
||||||
text: `From: ${name} <${email}>\n\n${message}`,
|
html: staff.html,
|
||||||
|
text: staff.text,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error("[contact] Resend error:", error);
|
console.error("[contact] Resend error (staff):", error);
|
||||||
return {
|
return {
|
||||||
status: "error",
|
status: "error",
|
||||||
message: "Something went wrong sending your message. Please try again.",
|
message: "Something went wrong sending your message. Please try again.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Best-effort: send an acknowledgement to the visitor. A failure here
|
||||||
|
// shouldn't fail the whole submission — the staff was already notified.
|
||||||
|
try {
|
||||||
|
const reply = autoReplyEmail(contact);
|
||||||
|
const { error: replyError } = await resend.emails.send({
|
||||||
|
from,
|
||||||
|
to: email,
|
||||||
|
subject: reply.subject,
|
||||||
|
html: reply.html,
|
||||||
|
text: reply.text,
|
||||||
|
});
|
||||||
|
if (replyError) {
|
||||||
|
console.error("[contact] Resend error (auto-reply):", replyError);
|
||||||
|
}
|
||||||
|
} catch (replyErr) {
|
||||||
|
console.error("[contact] Auto-reply failed:", replyErr);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[contact] Unexpected error:", err);
|
console.error("[contact] Unexpected error:", err);
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export async function subscribe(
|
|||||||
const honeypot = String(formData.get("website") ?? "");
|
const honeypot = String(formData.get("website") ?? "");
|
||||||
|
|
||||||
if (honeypot) {
|
if (honeypot) {
|
||||||
return { status: "success", message: "Thanks — we'll keep you posted." };
|
return { status: "success", message: "Thanks, we'll keep you posted." };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!email || !EMAIL_RE.test(email)) {
|
if (!email || !EMAIL_RE.test(email)) {
|
||||||
|
|||||||
@@ -1,141 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useActionState } from "react";
|
|
||||||
import { useFormStatus } from "react-dom";
|
|
||||||
import { sendContact, type ContactState } from "@/app/actions/contact";
|
|
||||||
|
|
||||||
const initialState: ContactState = { status: "idle", message: "" };
|
|
||||||
|
|
||||||
function SubmitButton() {
|
|
||||||
const { pending } = useFormStatus();
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={pending}
|
|
||||||
className="px-8 py-3 bg-accent text-background font-display tracking-widest text-sm hover:bg-foreground hover:scale-[1.03] transition duration-200 disabled:opacity-60 disabled:cursor-not-allowed disabled:hover:scale-100"
|
|
||||||
>
|
|
||||||
{pending ? "SENDING…" : "SEND MESSAGE"}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const inputBase =
|
|
||||||
"w-full bg-surface border px-4 py-3 text-sm text-foreground placeholder:text-foreground-muted focus:outline-none transition-colors";
|
|
||||||
|
|
||||||
export function ContactForm() {
|
|
||||||
const [state, formAction] = useActionState(sendContact, initialState);
|
|
||||||
|
|
||||||
if (state.status === "success") {
|
|
||||||
return (
|
|
||||||
<div className="border border-accent/40 bg-surface p-8 text-center">
|
|
||||||
<p className="font-display text-xs tracking-[0.3em] text-accent mb-3">
|
|
||||||
MESSAGE SENT
|
|
||||||
</p>
|
|
||||||
<p className="text-foreground-muted leading-relaxed">{state.message}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const err = state.fieldErrors ?? {};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form action={formAction} className="space-y-5">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="website"
|
|
||||||
tabIndex={-1}
|
|
||||||
autoComplete="off"
|
|
||||||
className="hidden"
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="grid sm:grid-cols-2 gap-5">
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
htmlFor="contact-name"
|
|
||||||
className="block font-display text-xs tracking-[0.3em] text-foreground-muted mb-2"
|
|
||||||
>
|
|
||||||
NAME
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="contact-name"
|
|
||||||
name="name"
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
placeholder="Your name"
|
|
||||||
className={`${inputBase} ${err.name ? "border-red-400" : "border-border focus:border-accent"}`}
|
|
||||||
/>
|
|
||||||
{err.name && (
|
|
||||||
<p className="text-xs text-red-400 mt-1.5">{err.name}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
htmlFor="contact-email"
|
|
||||||
className="block font-display text-xs tracking-[0.3em] text-foreground-muted mb-2"
|
|
||||||
>
|
|
||||||
EMAIL
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="contact-email"
|
|
||||||
name="email"
|
|
||||||
type="email"
|
|
||||||
required
|
|
||||||
placeholder="you@example.com"
|
|
||||||
className={`${inputBase} ${err.email ? "border-red-400" : "border-border focus:border-accent"}`}
|
|
||||||
/>
|
|
||||||
{err.email && (
|
|
||||||
<p className="text-xs text-red-400 mt-1.5">{err.email}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
htmlFor="contact-subject"
|
|
||||||
className="block font-display text-xs tracking-[0.3em] text-foreground-muted mb-2"
|
|
||||||
>
|
|
||||||
SUBJECT
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="contact-subject"
|
|
||||||
name="subject"
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
placeholder="What is this about?"
|
|
||||||
className={`${inputBase} ${err.subject ? "border-red-400" : "border-border focus:border-accent"}`}
|
|
||||||
/>
|
|
||||||
{err.subject && (
|
|
||||||
<p className="text-xs text-red-400 mt-1.5">{err.subject}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
htmlFor="contact-message"
|
|
||||||
className="block font-display text-xs tracking-[0.3em] text-foreground-muted mb-2"
|
|
||||||
>
|
|
||||||
MESSAGE
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
id="contact-message"
|
|
||||||
name="message"
|
|
||||||
rows={6}
|
|
||||||
required
|
|
||||||
placeholder="Your message…"
|
|
||||||
className={`${inputBase} resize-y ${err.message ? "border-red-400" : "border-border focus:border-accent"}`}
|
|
||||||
/>
|
|
||||||
{err.message && (
|
|
||||||
<p className="text-xs text-red-400 mt-1.5">{err.message}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 pt-2">
|
|
||||||
<SubmitButton />
|
|
||||||
{state.status === "error" && !Object.keys(err).length && (
|
|
||||||
<p className="text-sm text-red-400">{state.message}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
import Link from "next/link";
|
|
||||||
import Image from "next/image";
|
|
||||||
import { SocialLinks } from "./SocialLinks";
|
|
||||||
|
|
||||||
export function Footer() {
|
|
||||||
return (
|
|
||||||
<footer className="border-t border-border mt-24">
|
|
||||||
<div className="max-w-7xl mx-auto px-6 lg:px-10 py-12 grid grid-cols-1 md:grid-cols-4 gap-10">
|
|
||||||
<div className="md:col-span-2">
|
|
||||||
<Link href="/" className="group inline-flex items-center gap-3">
|
|
||||||
<Image
|
|
||||||
src="/image/Studio/Logo Flat.png"
|
|
||||||
alt="Highland Games Studio"
|
|
||||||
width={32}
|
|
||||||
height={32}
|
|
||||||
className="transition-transform duration-300 group-hover:scale-110 group-hover:-translate-y-0.5"
|
|
||||||
/>
|
|
||||||
<span className="font-display text-lg tracking-wider transition-colors duration-300 group-hover:text-accent">
|
|
||||||
Highland Games Studio
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
<p className="mt-4 text-sm text-foreground-muted max-w-sm">
|
|
||||||
Where summits become worlds. An indie studio crafting survival
|
|
||||||
adventures.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h4 className="font-display text-sm tracking-widest text-foreground-muted mb-4">
|
|
||||||
Explore
|
|
||||||
</h4>
|
|
||||||
<ul className="space-y-2 text-sm">
|
|
||||||
<li>
|
|
||||||
<Link
|
|
||||||
href="/studio"
|
|
||||||
className="group relative inline-block text-foreground-muted hover:text-foreground transition-colors"
|
|
||||||
>
|
|
||||||
Studio
|
|
||||||
<span className="absolute left-0 -bottom-0.5 h-px w-0 bg-accent transition-all duration-300 group-hover:w-full" />
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<Link
|
|
||||||
href="/games"
|
|
||||||
className="group relative inline-block text-foreground-muted hover:text-foreground transition-colors"
|
|
||||||
>
|
|
||||||
Games
|
|
||||||
<span className="absolute left-0 -bottom-0.5 h-px w-0 bg-accent transition-all duration-300 group-hover:w-full" />
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<Link
|
|
||||||
href="/devblog"
|
|
||||||
className="group relative inline-block text-foreground-muted hover:text-foreground transition-colors"
|
|
||||||
>
|
|
||||||
Devblog
|
|
||||||
<span className="absolute left-0 -bottom-0.5 h-px w-0 bg-accent transition-all duration-300 group-hover:w-full" />
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<Link
|
|
||||||
href="/contact"
|
|
||||||
className="group relative inline-block text-foreground-muted hover:text-foreground transition-colors"
|
|
||||||
>
|
|
||||||
Contact
|
|
||||||
<span className="absolute left-0 -bottom-0.5 h-px w-0 bg-accent transition-all duration-300 group-hover:w-full" />
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h4 className="font-display text-sm tracking-widest text-foreground-muted mb-4">
|
|
||||||
Follow
|
|
||||||
</h4>
|
|
||||||
<SocialLinks />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-t border-border">
|
|
||||||
<div className="max-w-7xl mx-auto px-6 lg:px-10 py-6 flex flex-col sm:flex-row items-center justify-between gap-3 text-xs text-foreground-muted">
|
|
||||||
<p>© {new Date().getFullYear()} Highland Games Studio. All rights reserved.</p>
|
|
||||||
<div className="flex items-center gap-5">
|
|
||||||
<Link
|
|
||||||
href="/legal/notice"
|
|
||||||
className="hover:text-accent transition-colors"
|
|
||||||
>
|
|
||||||
Legal Notice
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/legal/privacy"
|
|
||||||
className="hover:text-accent transition-colors"
|
|
||||||
>
|
|
||||||
Privacy
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
<p className="font-display tracking-widest hidden lg:block">WHERE SUMMITS BECOME WORLDS</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import Image from "next/image";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
|
||||||
|
|
||||||
const links = [
|
|
||||||
{ href: "/studio", label: "Studio" },
|
|
||||||
{ href: "/games", label: "Games" },
|
|
||||||
{ href: "/devblog", label: "Devblog" },
|
|
||||||
{ href: "/contact", label: "Contact" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function Navbar() {
|
|
||||||
const [scrolled, setScrolled] = useState(false);
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const onScroll = () => setScrolled(window.scrollY > 20);
|
|
||||||
onScroll();
|
|
||||||
window.addEventListener("scroll", onScroll, { passive: true });
|
|
||||||
return () => window.removeEventListener("scroll", onScroll);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
const prev = document.body.style.overflow;
|
|
||||||
document.body.style.overflow = "hidden";
|
|
||||||
return () => {
|
|
||||||
document.body.style.overflow = prev;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.header
|
|
||||||
initial={{ y: -20, opacity: 0 }}
|
|
||||||
animate={{ y: 0, opacity: 1 }}
|
|
||||||
transition={{ duration: 0.6, ease: "easeOut" }}
|
|
||||||
className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 ${
|
|
||||||
scrolled || open
|
|
||||||
? "bg-background/80 backdrop-blur-md border-b border-border"
|
|
||||||
: "bg-transparent"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<nav className="max-w-7xl mx-auto flex items-center justify-between px-6 lg:px-10 h-16 lg:h-20">
|
|
||||||
<Link href="/" className="flex items-center gap-3 group">
|
|
||||||
<Image
|
|
||||||
src="/image/Studio/Logo Flat.png"
|
|
||||||
alt="Highland Games Studio"
|
|
||||||
width={36}
|
|
||||||
height={36}
|
|
||||||
className="transition-transform group-hover:-translate-y-0.5"
|
|
||||||
priority
|
|
||||||
/>
|
|
||||||
<span className="font-display text-xl tracking-wider hidden sm:block">
|
|
||||||
Highland Games
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{/* Desktop nav */}
|
|
||||||
<ul className="hidden md:flex items-center gap-1 sm:gap-2">
|
|
||||||
{links.map((link) => (
|
|
||||||
<li key={link.href}>
|
|
||||||
<Link
|
|
||||||
href={link.href}
|
|
||||||
className="px-3 py-2 text-sm tracking-wide text-foreground-muted hover:text-foreground transition-colors"
|
|
||||||
>
|
|
||||||
{link.label}
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
{/* Mobile toggle */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={open ? "Close menu" : "Open menu"}
|
|
||||||
aria-expanded={open}
|
|
||||||
aria-controls="mobile-menu"
|
|
||||||
onClick={() => setOpen((v) => !v)}
|
|
||||||
className="md:hidden relative w-10 h-10 flex items-center justify-center text-foreground hover:text-accent transition-colors"
|
|
||||||
>
|
|
||||||
<span className="sr-only">Menu</span>
|
|
||||||
<span className="relative w-6 h-4 flex flex-col justify-between">
|
|
||||||
<motion.span
|
|
||||||
animate={open ? { rotate: 45, y: 7 } : { rotate: 0, y: 0 }}
|
|
||||||
transition={{ duration: 0.25 }}
|
|
||||||
className="block h-px bg-current origin-center"
|
|
||||||
/>
|
|
||||||
<motion.span
|
|
||||||
animate={open ? { opacity: 0 } : { opacity: 1 }}
|
|
||||||
transition={{ duration: 0.15 }}
|
|
||||||
className="block h-px bg-current"
|
|
||||||
/>
|
|
||||||
<motion.span
|
|
||||||
animate={open ? { rotate: -45, y: -7 } : { rotate: 0, y: 0 }}
|
|
||||||
transition={{ duration: 0.25 }}
|
|
||||||
className="block h-px bg-current origin-center"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{/* Mobile drawer */}
|
|
||||||
<AnimatePresence>
|
|
||||||
{open && (
|
|
||||||
<motion.div
|
|
||||||
id="mobile-menu"
|
|
||||||
initial={{ opacity: 0, y: -8 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0, y: -8 }}
|
|
||||||
transition={{ duration: 0.25, ease: [0.22, 1, 0.36, 1] }}
|
|
||||||
className="md:hidden border-t border-border bg-background/95 backdrop-blur-md"
|
|
||||||
>
|
|
||||||
<ul className="px-6 py-6 flex flex-col gap-1">
|
|
||||||
{links.map((link, i) => (
|
|
||||||
<motion.li
|
|
||||||
key={link.href}
|
|
||||||
initial={{ opacity: 0, x: -8 }}
|
|
||||||
animate={{ opacity: 1, x: 0 }}
|
|
||||||
transition={{ delay: 0.05 + i * 0.04, duration: 0.3 }}
|
|
||||||
>
|
|
||||||
<Link
|
|
||||||
href={link.href}
|
|
||||||
onClick={() => setOpen(false)}
|
|
||||||
className="block py-3 font-display text-2xl tracking-wider text-foreground hover:text-accent transition-colors"
|
|
||||||
>
|
|
||||||
{link.label.toUpperCase()}
|
|
||||||
</Link>
|
|
||||||
</motion.li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</motion.header>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { motion } from "framer-motion";
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
|
|
||||||
type Social = {
|
|
||||||
label: string;
|
|
||||||
href: string;
|
|
||||||
icon: ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
const socials: Social[] = [
|
|
||||||
{
|
|
||||||
label: "Twitter / X",
|
|
||||||
href: "#",
|
|
||||||
icon: (
|
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden>
|
|
||||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Discord",
|
|
||||||
href: "#",
|
|
||||||
icon: (
|
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden>
|
|
||||||
<path d="M20.317 4.3698a19.7913 19.7913 0 0 0-4.8851-1.5152.0741.0741 0 0 0-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 0 0-.0785-.037 19.7363 19.7363 0 0 0-4.8852 1.515.0699.0699 0 0 0-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 0 0 .0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 0 0 .0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 0 0-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 0 1-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 0 1 .0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 0 1 .0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 0 1-.0066.1276 12.2986 12.2986 0 0 1-1.873.8914.0766.0766 0 0 0-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 0 0 .0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 0 0 .0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 0 0-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1569 2.4189Zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "YouTube",
|
|
||||||
href: "#",
|
|
||||||
icon: (
|
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden>
|
|
||||||
<path d="M23.498 6.186a3.02 3.02 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.02 3.02 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.02 3.02 0 0 0 2.122 2.136C4.495 20.455 12 20.455 12 20.455s7.505 0 9.377-.505a3.02 3.02 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Steam",
|
|
||||||
href: "#",
|
|
||||||
icon: (
|
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden>
|
|
||||||
<path d="M11.979 0C5.678 0 .511 4.86.022 11.037l6.432 2.658c.545-.371 1.203-.59 1.912-.59.063 0 .125.004.188.006l2.861-4.142V8.91c0-2.495 2.028-4.524 4.524-4.524 2.494 0 4.524 2.031 4.524 4.527s-2.03 4.525-4.524 4.525h-.105l-4.076 2.911c0 .052.004.105.004.159 0 1.875-1.515 3.396-3.39 3.396-1.635 0-3.016-1.173-3.331-2.727L.436 15.27C1.862 20.307 6.486 24 11.979 24c6.627 0 11.999-5.373 11.999-12S18.605 0 11.979 0zM7.54 18.21l-1.473-.61c.262.543.714.999 1.314 1.25 1.297.539 2.793-.076 3.332-1.375.263-.63.264-1.319.005-1.949s-.75-1.121-1.377-1.383c-.624-.26-1.29-.249-1.878-.03l1.523.63c.956.4 1.409 1.5 1.009 2.456-.397.957-1.497 1.41-2.454 1.012H7.54zm11.415-9.303c0-1.662-1.353-3.015-3.015-3.015-1.665 0-3.015 1.353-3.015 3.015 0 1.665 1.35 3.015 3.015 3.015 1.663 0 3.015-1.35 3.015-3.015zm-5.273-.005c0-1.252 1.013-2.266 2.265-2.266 1.249 0 2.266 1.014 2.266 2.266 0 1.251-1.017 2.265-2.266 2.265-1.253 0-2.265-1.014-2.265-2.265z" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export function SocialLinks() {
|
|
||||||
return (
|
|
||||||
<ul className="space-y-2 text-sm">
|
|
||||||
{socials.map((s) => (
|
|
||||||
<li key={s.label}>
|
|
||||||
<motion.a
|
|
||||||
href={s.href}
|
|
||||||
initial="rest"
|
|
||||||
whileHover="hover"
|
|
||||||
animate="rest"
|
|
||||||
className="group inline-flex items-center text-foreground-muted hover:text-foreground transition-colors"
|
|
||||||
>
|
|
||||||
<motion.span
|
|
||||||
variants={{
|
|
||||||
rest: { width: 0, opacity: 0, marginRight: 0 },
|
|
||||||
hover: { width: 16, opacity: 1, marginRight: 10 },
|
|
||||||
}}
|
|
||||||
transition={{ duration: 0.35, ease: [0.22, 1, 0.36, 1] }}
|
|
||||||
className="inline-flex items-center justify-center overflow-hidden text-accent"
|
|
||||||
>
|
|
||||||
<motion.span
|
|
||||||
variants={{
|
|
||||||
rest: { x: -8 },
|
|
||||||
hover: { x: 0 },
|
|
||||||
}}
|
|
||||||
transition={{ duration: 0.35, ease: [0.22, 1, 0.36, 1] }}
|
|
||||||
className="block w-4 h-4"
|
|
||||||
>
|
|
||||||
{s.icon}
|
|
||||||
</motion.span>
|
|
||||||
</motion.span>
|
|
||||||
<span>{s.label}</span>
|
|
||||||
</motion.a>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { Spotlight } from "../ui/Spotlight";
|
||||||
|
import { formatDate } from "@/lib/format";
|
||||||
|
import type { DevblogPost } from "@/lib/devblog";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
post: DevblogPost;
|
||||||
|
/** Small label above the date. */
|
||||||
|
eyebrow?: string;
|
||||||
|
/** Heading level, so the panel fits the page outline. */
|
||||||
|
as?: "h2" | "h3";
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single post given real weight, as opposed to a row in `PostList`.
|
||||||
|
*
|
||||||
|
* Used by the home page and at the top of the devblog index: a list of one is
|
||||||
|
* indistinguishable from an empty section, and even with twenty posts the most
|
||||||
|
* recent one deserves more than the same 8rem row as the rest.
|
||||||
|
*/
|
||||||
|
export function PostFeature({ post, eyebrow = "Latest", as: Tag = "h3" }: Props) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={`/devblog/${post.slug}`}
|
||||||
|
className="group relative flex h-full flex-col overflow-hidden rounded-2xl border border-border bg-surface p-8 transition duration-300 ease-out hover:-translate-y-1 hover:border-accent/60 hover:shadow-[0_24px_50px_-30px_rgba(0,0,0,0.9)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent lg:p-10"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4 text-[0.65rem] uppercase tracking-[0.2em] text-foreground-muted">
|
||||||
|
<span className="text-accent">{eyebrow}</span>
|
||||||
|
<time dateTime={post.date}>{formatDate(post.date)}</time>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tag className="mt-5 font-display text-3xl leading-[1.05] tracking-wider transition-colors duration-300 group-hover:text-accent sm:text-4xl">
|
||||||
|
{post.title.toUpperCase()}
|
||||||
|
</Tag>
|
||||||
|
|
||||||
|
{post.excerpt && (
|
||||||
|
<p className="mt-4 leading-relaxed text-foreground-muted">
|
||||||
|
{post.excerpt}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<span className="mt-8 inline-flex items-center gap-2 font-display text-sm tracking-widest text-accent">
|
||||||
|
READ THE POST
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="inline-block transition-transform duration-300 ease-out group-hover:translate-x-1"
|
||||||
|
>
|
||||||
|
→
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<Spotlight />
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { formatDate } from "@/lib/format";
|
||||||
|
import { games } from "@/lib/games";
|
||||||
|
import type { DevblogPost } from "@/lib/devblog";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
posts: DevblogPost[];
|
||||||
|
/** Heading level for each entry, so the page outline stays valid. */
|
||||||
|
as?: "h2" | "h3";
|
||||||
|
/** Show the related game as a tag. Off on a game page (redundant there). */
|
||||||
|
showGame?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The devblog entry list. Shared by /devblog, the home page teaser and the
|
||||||
|
* "latest articles" block of a game page, so an entry looks identical
|
||||||
|
* everywhere it appears.
|
||||||
|
*/
|
||||||
|
export function PostList({ posts, as: Tag = "h2", showGame = true }: Props) {
|
||||||
|
return (
|
||||||
|
<ul className="border-t border-border">
|
||||||
|
{posts.map((post) => {
|
||||||
|
const gameTitle = post.game
|
||||||
|
? (games.find((g) => g.slug === post.game)?.title ?? post.game)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={post.slug}>
|
||||||
|
{/* The row is a large target with no border of its own, so the
|
||||||
|
hover has to be built from three small moves rather than one
|
||||||
|
box: a rule that draws down the left edge, the whole block
|
||||||
|
nudged away from it, and the rule under it turning accent. */}
|
||||||
|
<Link
|
||||||
|
href={`/devblog/${post.slug}`}
|
||||||
|
className="group relative block border-b border-border py-8 pl-0 transition-colors duration-300 hover:border-accent focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="absolute bottom-0 left-0 top-0 w-px origin-top scale-y-0 bg-accent transition-transform duration-500 ease-out group-hover:scale-y-100"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="transition-transform duration-300 ease-out group-hover:translate-x-4">
|
||||||
|
<div className="mb-3 flex flex-col gap-2 sm:flex-row sm:items-baseline sm:justify-between">
|
||||||
|
<Tag className="font-display text-2xl tracking-wider transition-colors duration-300 group-hover:text-accent sm:text-3xl">
|
||||||
|
{post.title.toUpperCase()}
|
||||||
|
</Tag>
|
||||||
|
<time
|
||||||
|
dateTime={post.date}
|
||||||
|
className="shrink-0 text-xs tracking-widest text-foreground-muted"
|
||||||
|
>
|
||||||
|
{formatDate(post.date)}
|
||||||
|
</time>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{post.excerpt && (
|
||||||
|
<p className="max-w-2xl leading-relaxed text-foreground-muted">
|
||||||
|
{post.excerpt}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-4 flex flex-wrap items-center gap-4">
|
||||||
|
<span className="inline-flex items-center gap-2 font-display text-xs tracking-widest text-foreground-muted transition-colors duration-300 group-hover:text-accent">
|
||||||
|
READ
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="inline-block transition-transform duration-300 ease-out group-hover:translate-x-1"
|
||||||
|
>
|
||||||
|
→
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{showGame && gameTitle && (
|
||||||
|
<span className="border border-accent/40 px-2 py-1 text-[0.65rem] uppercase tracking-widest text-accent transition-colors duration-300 group-hover:border-accent">
|
||||||
|
{gameTitle}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useActionState, useState } from "react";
|
||||||
|
import { useFormStatus } from "react-dom";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { CheckMark } from "../ui/CheckMark";
|
||||||
|
import { Spinner } from "../ui/Spinner";
|
||||||
|
import { sendContact, type ContactState } from "@/app/actions/contact";
|
||||||
|
import { CONTACT_CHANNELS } from "@/lib/site";
|
||||||
|
import { EASE_OUT_EXPO } from "@/lib/motion";
|
||||||
|
|
||||||
|
const initialState: ContactState = { status: "idle", message: "" };
|
||||||
|
|
||||||
|
function SubmitButton() {
|
||||||
|
const { pending } = useFormStatus();
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={pending}
|
||||||
|
className="sheen relative inline-flex items-center justify-center gap-2 overflow-hidden bg-accent px-8 py-3 font-display text-sm tracking-widest text-background transition duration-200 ease-out hover:-translate-y-0.5 hover:bg-foreground hover:shadow-[0_14px_30px_-14px_rgba(196,184,150,0.6)] active:scale-[0.97] disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:translate-y-0 disabled:hover:shadow-none"
|
||||||
|
>
|
||||||
|
{pending && <Spinner />}
|
||||||
|
{pending ? "SENDING…" : "SEND MESSAGE"}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputBase =
|
||||||
|
"w-full bg-surface border px-4 py-3 text-sm text-foreground placeholder:text-foreground-muted focus:outline-none transition-colors duration-200";
|
||||||
|
|
||||||
|
/** Field errors slide in under their input rather than appearing under it. */
|
||||||
|
function FieldError({ children, id }: { children: string; id: number }) {
|
||||||
|
return (
|
||||||
|
<motion.p
|
||||||
|
key={id}
|
||||||
|
initial={{ opacity: 0, y: -4 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.25, ease: EASE_OUT_EXPO }}
|
||||||
|
className="mt-1.5 text-xs text-red-400"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContactForm() {
|
||||||
|
const [state, formAction] = useActionState(sendContact, initialState);
|
||||||
|
|
||||||
|
// A rejected submission has to be felt even when the message is identical to
|
||||||
|
// the last one. React would keep the same paragraph mounted and replay
|
||||||
|
// nothing, so every attempt gets a number and the number is the React key.
|
||||||
|
//
|
||||||
|
// Counted during render by comparing with the previous result rather than in
|
||||||
|
// an effect: the action already returns a fresh object per submission, so
|
||||||
|
// this needs no second render pass to stay in sync.
|
||||||
|
const [seen, setSeen] = useState(state);
|
||||||
|
const [attempt, setAttempt] = useState(0);
|
||||||
|
if (seen !== state) {
|
||||||
|
setSeen(state);
|
||||||
|
setAttempt((count) => count + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.status === "success") {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.96 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ duration: 0.45, ease: EASE_OUT_EXPO }}
|
||||||
|
className="rounded-2xl border border-accent/40 bg-surface p-10 text-center"
|
||||||
|
>
|
||||||
|
<CheckMark className="mx-auto mb-6 h-12 w-12" />
|
||||||
|
<p className="mb-3 font-display text-xs tracking-[0.3em] text-accent">
|
||||||
|
MESSAGE SENT
|
||||||
|
</p>
|
||||||
|
<p className="leading-relaxed text-foreground-muted">{state.message}</p>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const err = state.fieldErrors ?? {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={formAction} className="space-y-5">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="website"
|
||||||
|
tabIndex={-1}
|
||||||
|
autoComplete="off"
|
||||||
|
className="hidden"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid sm:grid-cols-2 gap-5">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="contact-name"
|
||||||
|
className="block font-display text-xs tracking-[0.3em] text-foreground-muted mb-2"
|
||||||
|
>
|
||||||
|
NAME
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="contact-name"
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
placeholder="Your name"
|
||||||
|
className={`${inputBase} ${err.name ? "border-red-400" : "border-border focus:border-accent"}`}
|
||||||
|
/>
|
||||||
|
{err.name && <FieldError id={attempt}>{err.name}</FieldError>}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="contact-email"
|
||||||
|
className="block font-display text-xs tracking-[0.3em] text-foreground-muted mb-2"
|
||||||
|
>
|
||||||
|
EMAIL
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="contact-email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
placeholder="you@example.com"
|
||||||
|
className={`${inputBase} ${err.email ? "border-red-400" : "border-border focus:border-accent"}`}
|
||||||
|
/>
|
||||||
|
{err.email && <FieldError id={attempt}>{err.email}</FieldError>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-5 sm:grid-cols-2">
|
||||||
|
{/* The desk the message is for. It replaces the three mailto cards as
|
||||||
|
the default path: the visitor picks a reason instead of guessing
|
||||||
|
which address to write to. */}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="contact-topic"
|
||||||
|
className="mb-2 block font-display text-xs tracking-[0.3em] text-foreground-muted"
|
||||||
|
>
|
||||||
|
REASON
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="contact-topic"
|
||||||
|
name="topic"
|
||||||
|
defaultValue={CONTACT_CHANNELS[0].label}
|
||||||
|
className={`${inputBase} cursor-pointer border-border focus:border-accent`}
|
||||||
|
>
|
||||||
|
{CONTACT_CHANNELS.map((channel) => (
|
||||||
|
<option key={channel.label} value={channel.label}>
|
||||||
|
{channel.label.charAt(0) + channel.label.slice(1).toLowerCase()}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="contact-subject"
|
||||||
|
className="mb-2 block font-display text-xs tracking-[0.3em] text-foreground-muted"
|
||||||
|
>
|
||||||
|
SUBJECT
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="contact-subject"
|
||||||
|
name="subject"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
placeholder="What is this about?"
|
||||||
|
className={`${inputBase} ${err.subject ? "border-red-400" : "border-border focus:border-accent"}`}
|
||||||
|
/>
|
||||||
|
{err.subject && <FieldError id={attempt}>{err.subject}</FieldError>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="contact-message"
|
||||||
|
className="block font-display text-xs tracking-[0.3em] text-foreground-muted mb-2"
|
||||||
|
>
|
||||||
|
MESSAGE
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="contact-message"
|
||||||
|
name="message"
|
||||||
|
rows={6}
|
||||||
|
required
|
||||||
|
placeholder="Your message…"
|
||||||
|
className={`${inputBase} resize-y ${err.message ? "border-red-400" : "border-border focus:border-accent"}`}
|
||||||
|
/>
|
||||||
|
{err.message && <FieldError id={attempt}>{err.message}</FieldError>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 pt-2">
|
||||||
|
<SubmitButton />
|
||||||
|
{state.status === "error" && !Object.keys(err).length && (
|
||||||
|
// Shakes once per attempt. The key remounts it, which is what makes
|
||||||
|
// the CSS animation run again on an identical repeated error.
|
||||||
|
<p key={attempt} className="animate-shake text-sm text-red-400">
|
||||||
|
{state.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,10 @@
|
|||||||
|
|
||||||
import { useActionState } from "react";
|
import { useActionState } from "react";
|
||||||
import { useFormStatus } from "react-dom";
|
import { useFormStatus } from "react-dom";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { Spinner } from "../ui/Spinner";
|
||||||
import { subscribe, type SubscribeState } from "@/app/actions/subscribe";
|
import { subscribe, type SubscribeState } from "@/app/actions/subscribe";
|
||||||
|
import { EASE_OUT_EXPO } from "@/lib/motion";
|
||||||
|
|
||||||
const initialState: SubscribeState = { status: "idle", message: "" };
|
const initialState: SubscribeState = { status: "idle", message: "" };
|
||||||
|
|
||||||
@@ -12,8 +15,9 @@ function SubmitButton() {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={pending}
|
disabled={pending}
|
||||||
className="px-6 py-3 bg-accent text-background font-display tracking-widest text-sm hover:bg-foreground hover:scale-[1.03] transition duration-200 disabled:opacity-60 disabled:cursor-not-allowed disabled:hover:scale-100"
|
className="sheen relative inline-flex shrink-0 items-center justify-center gap-2 overflow-hidden bg-accent px-6 py-3 font-display text-sm tracking-widest text-background transition duration-200 ease-out hover:-translate-y-0.5 hover:bg-foreground hover:shadow-[0_14px_30px_-14px_rgba(196,184,150,0.6)] active:scale-[0.97] disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:translate-y-0 disabled:hover:shadow-none"
|
||||||
>
|
>
|
||||||
|
{pending && <Spinner />}
|
||||||
{pending ? "SENDING…" : "SUBSCRIBE"}
|
{pending ? "SENDING…" : "SUBSCRIBE"}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -56,11 +60,14 @@ export function Newsletter({ variant = "default" }: Props) {
|
|||||||
name="email"
|
name="email"
|
||||||
required
|
required
|
||||||
placeholder="your@email.com"
|
placeholder="your@email.com"
|
||||||
className="flex-1 min-w-0 bg-surface border border-border px-4 py-3 text-sm text-foreground placeholder:text-foreground-muted focus:outline-none focus:border-accent transition-colors"
|
className="flex-1 min-w-0 bg-surface border border-border px-4 py-3 text-sm text-foreground placeholder:text-foreground-muted focus:outline-none focus:border-accent transition-colors duration-200"
|
||||||
/>
|
/>
|
||||||
<SubmitButton />
|
<SubmitButton />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* The paragraph itself never unmounts — a live region that is replaced
|
||||||
|
on every state change is one a screen reader may not announce. Only
|
||||||
|
the text inside it is keyed, and that is what animates. */}
|
||||||
<p
|
<p
|
||||||
id="newsletter-status"
|
id="newsletter-status"
|
||||||
role="status"
|
role="status"
|
||||||
@@ -74,9 +81,17 @@ export function Newsletter({ variant = "default" }: Props) {
|
|||||||
? "text-red-400"
|
? "text-red-400"
|
||||||
: "text-foreground-muted"
|
: "text-foreground-muted"
|
||||||
}`}
|
}`}
|
||||||
|
>
|
||||||
|
<motion.span
|
||||||
|
key={state.message || "idle"}
|
||||||
|
initial={{ opacity: 0, y: -4 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3, ease: EASE_OUT_EXPO }}
|
||||||
|
className="inline-block"
|
||||||
>
|
>
|
||||||
{state.message ||
|
{state.message ||
|
||||||
"No spam. One devblog email per major update. Unsubscribe anytime."}
|
"No spam. One devblog email per major update. Unsubscribe anytime."}
|
||||||
|
</motion.span>
|
||||||
</p>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { Reveal } from "../ui/Reveal";
|
||||||
|
import { CountUp } from "../ui/CountUp";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
/** e.g. "1-6". Rendered as "1–6" with a real en dash. */
|
||||||
|
players: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one number worth shouting.
|
||||||
|
*
|
||||||
|
* A player count buried in a fact sheet is a specification; at this size it is
|
||||||
|
* an argument. Borrowed from the EmberWild site's co-op band — the page needs
|
||||||
|
* one moment where the type does the talking instead of a paragraph.
|
||||||
|
*/
|
||||||
|
export function CoopBand({ players }: Props) {
|
||||||
|
const [min, max] = players.split(/[-–]/);
|
||||||
|
|
||||||
|
// "1-6" counts up to six; a non-numeric range ("2+", "TBA") is printed as
|
||||||
|
// written rather than guessed at.
|
||||||
|
const maxValue = Number(max);
|
||||||
|
const countable = Number.isFinite(maxValue) && maxValue > Number(min);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="relative isolate overflow-hidden border-t border-border px-6 py-20 text-center sm:py-28 lg:px-10">
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className="absolute inset-0 -z-10 bg-[radial-gradient(55%_80%_at_50%_50%,rgba(196,184,150,0.12),transparent_70%)]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Reveal direction="scale" duration={0.8}>
|
||||||
|
<p className="flex items-center justify-center gap-[0.06em] font-display text-[clamp(4.5rem,14vw,10rem)] leading-[0.82] tracking-wider text-accent">
|
||||||
|
<span>{min}</span>
|
||||||
|
{max && (
|
||||||
|
<>
|
||||||
|
<span className="text-[0.4em] text-foreground-muted">–</span>
|
||||||
|
{countable ? (
|
||||||
|
<CountUp
|
||||||
|
value={maxValue}
|
||||||
|
from={Number(min)}
|
||||||
|
duration={1.1}
|
||||||
|
className="tabular-nums"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span>{max}</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 className="mt-8 font-display text-2xl uppercase tracking-wider sm:text-3xl">
|
||||||
|
Better together
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p className="mx-auto mt-4 max-w-[34ch] text-lg leading-relaxed text-foreground-muted">
|
||||||
|
Solo works. Six works better. One world, shared by all of you.
|
||||||
|
</p>
|
||||||
|
</Reveal>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { StatusBadge } from "../ui/StatusBadge";
|
||||||
|
import { Logo } from "../ui/Logo";
|
||||||
|
import { Spotlight } from "../ui/Spotlight";
|
||||||
|
import type { Game } from "@/lib/games";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
game: Game;
|
||||||
|
/** Heading level, so the card fits the page outline. */
|
||||||
|
as?: "h2" | "h3";
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Catalogue card for /games: artwork on one side, text on a solid surface on
|
||||||
|
* the other. The home page uses its own showcase band instead — see
|
||||||
|
* `components/home/FeaturedGame.tsx`.
|
||||||
|
*
|
||||||
|
* Text is deliberately NOT overlaid on the artwork. The Emberwild key art is
|
||||||
|
* light cream and the in-game captures are bright green — cream body text on
|
||||||
|
* top of either one was unreadable, whatever gradient was applied.
|
||||||
|
*/
|
||||||
|
export function GameCard({ game, as: Tag = "h2" }: Props) {
|
||||||
|
const artwork = game.keyArt ?? game.banner;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={`/games/${game.slug}`}
|
||||||
|
className="group relative grid overflow-hidden rounded-2xl border border-border bg-surface transition duration-300 ease-out hover:-translate-y-1 hover:border-accent/60 hover:shadow-[0_24px_50px_-30px_rgba(0,0,0,0.9)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent md:grid-cols-2"
|
||||||
|
>
|
||||||
|
{/* Artwork panel — no text on top of it */}
|
||||||
|
<div className="relative aspect-[16/10] overflow-hidden bg-surface-elevated md:aspect-auto md:min-h-[20rem]">
|
||||||
|
{artwork ? (
|
||||||
|
<Image
|
||||||
|
src={artwork}
|
||||||
|
alt={`${game.title} key art`}
|
||||||
|
fill
|
||||||
|
sizes="(max-width: 768px) 100vw, 50vw"
|
||||||
|
className="object-cover transition-transform duration-700 ease-out group-hover:scale-[1.04]"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
|
<Logo width={120} className="opacity-20" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content panel — solid background, guaranteed contrast */}
|
||||||
|
<div className="flex flex-col items-start justify-center gap-5 p-8 lg:p-10">
|
||||||
|
<StatusBadge status={game.status} />
|
||||||
|
|
||||||
|
<Tag className="font-display text-3xl leading-none tracking-wider transition-colors duration-300 group-hover:text-accent sm:text-4xl">
|
||||||
|
{game.title.toUpperCase()}
|
||||||
|
</Tag>
|
||||||
|
|
||||||
|
<p className="text-base leading-relaxed text-foreground-muted">
|
||||||
|
{game.tagline}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul className="flex flex-wrap gap-2" aria-label="Genres">
|
||||||
|
{game.genres.map((genre) => (
|
||||||
|
<li
|
||||||
|
key={genre}
|
||||||
|
className="border border-border px-3 py-1 text-xs uppercase tracking-widest text-foreground-muted transition-colors duration-300 group-hover:border-accent/30 group-hover:text-foreground"
|
||||||
|
>
|
||||||
|
{genre}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<span className="mt-2 inline-flex items-center gap-2 font-display text-sm tracking-widest text-accent">
|
||||||
|
DISCOVER {game.title.toUpperCase()}
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="inline-block transition-transform duration-300 group-hover:translate-x-1"
|
||||||
|
>
|
||||||
|
→
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Spotlight />
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import Image from "next/image";
|
||||||
|
import { StatusBadge } from "../ui/StatusBadge";
|
||||||
|
import { ButtonLink } from "../ui/ButtonLink";
|
||||||
|
import { Parallax } from "../ui/Parallax";
|
||||||
|
import type { Game } from "@/lib/games";
|
||||||
|
|
||||||
|
type Props = { game: Game };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Game page hero: the capture as a background, the title on top of it.
|
||||||
|
*
|
||||||
|
* The previous version showed the artwork in a bare band and put the title on
|
||||||
|
* a solid block underneath, on the grounds that the in-game captures are too
|
||||||
|
* bright to carry cream text. That is true of a raw capture — but the fix is a
|
||||||
|
* scrim, not a separate block. A flat tint plus a bottom-up gradient give the
|
||||||
|
* type a dark bed while the capture still reads, exactly as the home hero does
|
||||||
|
* over the video. Without it the page opened on an image that said nothing:
|
||||||
|
* no title, no status, no call to action above the fold.
|
||||||
|
*/
|
||||||
|
export function GameHero({ game }: Props) {
|
||||||
|
const artwork = game.banner ?? game.keyArt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The spec line. Phrased so it needs no labels: "1-6 players" says what
|
||||||
|
* "PLAYERS / 1-6" said in two words and a column. As its own band under the
|
||||||
|
* hero this was a lonely row of text floating in a dark gap; belonging to
|
||||||
|
* the title block, it reads as part of the pitch.
|
||||||
|
*/
|
||||||
|
const meta = [
|
||||||
|
game.players && `${game.players} players`,
|
||||||
|
game.platform,
|
||||||
|
game.releaseWindow && `Release ${game.releaseWindow}`,
|
||||||
|
].filter((entry): entry is string => Boolean(entry));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="relative isolate flex min-h-[70svh] items-end overflow-hidden bg-background px-6 pb-14 pt-32 sm:min-h-[78svh] sm:pb-20 lg:px-10">
|
||||||
|
{/* The capture drifts slower than the copy on top of it. Overscanned by
|
||||||
|
8% at each end so the drift never exposes the edge of the frame. */}
|
||||||
|
{artwork && (
|
||||||
|
<Parallax
|
||||||
|
distance={70}
|
||||||
|
range={600}
|
||||||
|
className="absolute -top-[8%] left-0 -z-20 h-[116%] w-full"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={artwork}
|
||||||
|
alt=""
|
||||||
|
fill
|
||||||
|
priority
|
||||||
|
sizes="100vw"
|
||||||
|
className="object-cover"
|
||||||
|
/>
|
||||||
|
</Parallax>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Scrim: dark bed under the copy, lighter over the artwork, plus a top
|
||||||
|
band so the transparent navbar stays legible. */}
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className="absolute inset-0 -z-10 bg-background/45"
|
||||||
|
style={{
|
||||||
|
backgroundImage:
|
||||||
|
"linear-gradient(to top, #0a0a0a 3%, rgba(10,10,10,0.75) 32%, rgba(10,10,10,0.15) 70%, rgba(10,10,10,0.7) 100%)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mx-auto w-full max-w-6xl">
|
||||||
|
<StatusBadge status={game.status} />
|
||||||
|
|
||||||
|
<h1 className="mt-6 font-display text-[clamp(2.75rem,1.5rem+5vw,7rem)] leading-[0.92] tracking-wider">
|
||||||
|
{game.title.toUpperCase()}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="mt-6 max-w-xl text-lg leading-relaxed text-foreground sm:text-xl">
|
||||||
|
{game.tagline}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Genres carry their own non-breaking spans: a line may only break
|
||||||
|
between them, never inside one ("CO-" / "OP" was a real thing). */}
|
||||||
|
<div className="mt-8 flex flex-wrap items-center gap-x-3 gap-y-2 text-xs uppercase tracking-[0.2em] text-foreground-muted">
|
||||||
|
{game.genres.map((genre, index) => (
|
||||||
|
<span key={genre} className="flex items-center gap-3">
|
||||||
|
{index > 0 && (
|
||||||
|
<span aria-hidden className="text-foreground-muted/40">
|
||||||
|
·
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="whitespace-nowrap">{genre}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{meta.length > 0 && (
|
||||||
|
<span aria-hidden className="mx-1 h-3 w-px bg-foreground-muted/30" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{meta.map((entry, index) => (
|
||||||
|
<span key={entry} className="flex items-center gap-3">
|
||||||
|
{index > 0 && (
|
||||||
|
<span aria-hidden className="text-foreground-muted/40">
|
||||||
|
·
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="whitespace-nowrap text-foreground">{entry}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-10 flex flex-col gap-4 sm:flex-row">
|
||||||
|
{game.steamUrl && (
|
||||||
|
<ButtonLink href={game.steamUrl} block>
|
||||||
|
WISHLIST ON STEAM
|
||||||
|
</ButtonLink>
|
||||||
|
)}
|
||||||
|
<ButtonLink
|
||||||
|
href="#gallery"
|
||||||
|
variant={game.steamUrl ? "outline" : "primary"}
|
||||||
|
block
|
||||||
|
>
|
||||||
|
SEE THE SCREENSHOTS
|
||||||
|
</ButtonLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import Image from "next/image";
|
||||||
|
import { Reveal } from "../ui/Reveal";
|
||||||
|
import type { Pillar } from "@/lib/games";
|
||||||
|
|
||||||
|
type Props = { pillars: Pillar[] };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the player does — one band per pillar, image and copy swapping sides.
|
||||||
|
*
|
||||||
|
* This is the block the game page was missing: it had a status, a fact sheet
|
||||||
|
* and a gallery, but nowhere did it say what you actually *do*. Modelled on
|
||||||
|
* the EmberWild site's pillar bands: one word, one sentence, one capture. The
|
||||||
|
* screenshot carries the selling, so the copy never repeats what it shows.
|
||||||
|
*/
|
||||||
|
export function GamePillars({ pillars }: Props) {
|
||||||
|
if (pillars.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-t border-border">
|
||||||
|
{pillars.map((pillar, index) => {
|
||||||
|
const flipped = index % 2 === 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
key={pillar.title}
|
||||||
|
className="mx-auto grid max-w-6xl items-center gap-8 px-6 py-14 sm:py-20 lg:grid-cols-2 lg:gap-16 lg:px-10"
|
||||||
|
>
|
||||||
|
{/* The capture enters from the side it sits on, so a run of
|
||||||
|
pillars reads as a zig-zag rather than as four identical
|
||||||
|
fade-ups. */}
|
||||||
|
<Reveal
|
||||||
|
direction={flipped ? "right" : "left"}
|
||||||
|
className={flipped ? "lg:order-2" : undefined}
|
||||||
|
>
|
||||||
|
<div className="relative aspect-video w-full overflow-hidden rounded-2xl border border-border bg-surface-elevated">
|
||||||
|
<Image
|
||||||
|
src={pillar.shot}
|
||||||
|
alt=""
|
||||||
|
fill
|
||||||
|
sizes="(max-width: 1024px) 100vw, 50vw"
|
||||||
|
className="object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
|
<Reveal
|
||||||
|
delay={0.1}
|
||||||
|
className={flipped ? "lg:order-1" : undefined}
|
||||||
|
>
|
||||||
|
<h3 className="font-display text-[clamp(2rem,1.4rem+2.2vw,3.25rem)] uppercase leading-none tracking-wider">
|
||||||
|
{pillar.title}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-5 max-w-md text-lg leading-relaxed text-foreground-muted">
|
||||||
|
{pillar.body}
|
||||||
|
</p>
|
||||||
|
</Reveal>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import { Stagger, StaggerItem } from "../ui/Stagger";
|
||||||
|
import { EASE_OUT_EXPO } from "@/lib/motion";
|
||||||
|
import type { Screenshot } from "@/lib/games";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
screenshots: Screenshot[];
|
||||||
|
/** Marks the captures as work in progress. */
|
||||||
|
wip?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Screenshot grid with a lightbox.
|
||||||
|
*
|
||||||
|
* Captions live under the thumbnail and on a solid bar inside the lightbox —
|
||||||
|
* never floating over the image, which is far too bright to carry text.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Which way the next capture slides in. Stored rather than derived: after the
|
||||||
|
* index has changed there is no way to tell whether it went up or wrapped
|
||||||
|
* around from the last shot to the first.
|
||||||
|
*/
|
||||||
|
const slide = {
|
||||||
|
enter: (direction: number) => ({ opacity: 0, x: direction * 48 }),
|
||||||
|
center: { opacity: 1, x: 0 },
|
||||||
|
exit: (direction: number) => ({ opacity: 0, x: direction * -48 }),
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ScreenshotGallery({ screenshots, wip = true }: Props) {
|
||||||
|
const [openIndex, setOpenIndex] = useState<number | null>(null);
|
||||||
|
const [direction, setDirection] = useState(1);
|
||||||
|
const triggersRef = useRef<(HTMLButtonElement | null)[]>([]);
|
||||||
|
const dialogRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const close = useCallback(() => {
|
||||||
|
setOpenIndex((current) => {
|
||||||
|
if (current !== null) triggersRef.current[current]?.focus();
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const step = useCallback(
|
||||||
|
(delta: number) => {
|
||||||
|
setDirection(delta);
|
||||||
|
setOpenIndex((current) =>
|
||||||
|
current === null
|
||||||
|
? current
|
||||||
|
: (current + delta + screenshots.length) % screenshots.length,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[screenshots.length],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Keyboard controls + scroll lock while the lightbox is open.
|
||||||
|
useEffect(() => {
|
||||||
|
if (openIndex === null) return;
|
||||||
|
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") close();
|
||||||
|
else if (event.key === "ArrowRight") step(1);
|
||||||
|
else if (event.key === "ArrowLeft") step(-1);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("keydown", onKeyDown);
|
||||||
|
const previousOverflow = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
dialogRef.current?.focus();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("keydown", onKeyDown);
|
||||||
|
document.body.style.overflow = previousOverflow;
|
||||||
|
};
|
||||||
|
}, [openIndex, close, step]);
|
||||||
|
|
||||||
|
if (screenshots.length === 0) return null;
|
||||||
|
|
||||||
|
const active = openIndex === null ? null : screenshots[openIndex];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stagger as="ul" step={0.06} className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{screenshots.map((shot, index) => (
|
||||||
|
<StaggerItem as="li" key={shot.src}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
ref={(node) => {
|
||||||
|
triggersRef.current[index] = node;
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
setDirection(1);
|
||||||
|
setOpenIndex(index);
|
||||||
|
}}
|
||||||
|
className="group block w-full text-left transition-transform duration-200 ease-out active:scale-[0.98] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
|
||||||
|
>
|
||||||
|
<span className="relative block aspect-video overflow-hidden rounded-2xl border border-border bg-surface-elevated transition-colors duration-300 group-hover:border-accent/60">
|
||||||
|
<Image
|
||||||
|
src={shot.src}
|
||||||
|
alt={shot.caption}
|
||||||
|
fill
|
||||||
|
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
|
||||||
|
className="object-cover transition-transform duration-700 ease-out group-hover:scale-[1.04]"
|
||||||
|
/>
|
||||||
|
{wip && (
|
||||||
|
<span className="absolute left-3 top-3 bg-background/90 px-2.5 py-1 font-display text-[0.65rem] tracking-[0.2em] text-accent">
|
||||||
|
WIP
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{/* The label rises into place rather than appearing, so the
|
||||||
|
overlay reads as one gesture instead of a flash. */}
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="absolute inset-0 flex items-center justify-center bg-background/50 opacity-0 transition-opacity duration-300 group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
<span className="translate-y-2 border border-accent px-4 py-2 font-display text-xs tracking-[0.25em] text-accent transition-transform duration-300 ease-out group-hover:translate-y-0">
|
||||||
|
VIEW
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="mt-3 block text-sm text-foreground-muted transition-colors duration-300 group-hover:text-foreground">
|
||||||
|
{shot.caption}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</StaggerItem>
|
||||||
|
))}
|
||||||
|
</Stagger>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{active && (
|
||||||
|
<motion.div
|
||||||
|
ref={dialogRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={active.caption}
|
||||||
|
tabIndex={-1}
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
onClick={close}
|
||||||
|
className="fixed inset-0 z-[100] flex flex-col items-center justify-center gap-4 bg-background/95 p-4 backdrop-blur-sm sm:p-8"
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.96, y: 8 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.98, y: 4 }}
|
||||||
|
transition={{ duration: 0.3, ease: EASE_OUT_EXPO }}
|
||||||
|
className="relative w-full max-w-6xl"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="relative aspect-video w-full overflow-hidden rounded-t-2xl border border-border bg-surface-elevated">
|
||||||
|
{/* Each capture slides in from the side you asked for. Without
|
||||||
|
it, stepping through a gallery of similar screenshots gives
|
||||||
|
no sense of having moved at all. */}
|
||||||
|
<AnimatePresence initial={false} custom={direction}>
|
||||||
|
<motion.div
|
||||||
|
key={active.src}
|
||||||
|
custom={direction}
|
||||||
|
variants={slide}
|
||||||
|
initial="enter"
|
||||||
|
animate="center"
|
||||||
|
exit="exit"
|
||||||
|
transition={{ duration: 0.3, ease: EASE_OUT_EXPO }}
|
||||||
|
className="absolute inset-0"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={active.src}
|
||||||
|
alt={active.caption}
|
||||||
|
fill
|
||||||
|
sizes="100vw"
|
||||||
|
className="object-contain"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-4 rounded-b-2xl border border-t-0 border-border bg-surface px-4 py-3">
|
||||||
|
<p className="text-sm text-foreground-muted">
|
||||||
|
{active.caption}
|
||||||
|
</p>
|
||||||
|
<p className="shrink-0 font-display text-xs tracking-[0.25em] text-foreground-muted">
|
||||||
|
{(openIndex ?? 0) + 1} / {screenshots.length}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{screenshots.length > 1 && (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-3"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => step(-1)}
|
||||||
|
aria-label="Previous screenshot"
|
||||||
|
className="group border border-border px-5 py-2 font-display text-sm tracking-widest text-foreground transition duration-200 hover:border-accent hover:text-accent active:scale-95"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="inline-block transition-transform duration-300 ease-out group-hover:-translate-x-1"
|
||||||
|
>
|
||||||
|
←
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => step(1)}
|
||||||
|
aria-label="Next screenshot"
|
||||||
|
className="group border border-border px-5 py-2 font-display text-sm tracking-widest text-foreground transition duration-200 hover:border-accent hover:text-accent active:scale-95"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="inline-block transition-transform duration-300 ease-out group-hover:translate-x-1"
|
||||||
|
>
|
||||||
|
→
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={close}
|
||||||
|
aria-label="Close gallery"
|
||||||
|
className="absolute right-4 top-4 border border-border bg-background/80 px-4 py-2 font-display text-xs tracking-[0.25em] text-foreground transition duration-200 hover:border-accent hover:text-accent active:scale-95 sm:right-8 sm:top-8"
|
||||||
|
>
|
||||||
|
CLOSE ✕
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { Section } from "../ui/Section";
|
||||||
|
import { SectionHeading } from "../ui/SectionHeading";
|
||||||
|
import { ButtonLink } from "../ui/ButtonLink";
|
||||||
|
import { Reveal } from "../ui/Reveal";
|
||||||
|
import { Newsletter } from "../forms/Newsletter";
|
||||||
|
import { PostFeature } from "../devblog/PostFeature";
|
||||||
|
import type { DevblogPost } from "@/lib/devblog";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
/** The single most recent post, or null while the devblog is empty. */
|
||||||
|
latest: DevblogPost | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The last band before the footer, and the only conversion point on the page.
|
||||||
|
*
|
||||||
|
* It replaces the old "follow the journey" block, which framed a full section
|
||||||
|
* — eyebrow, display title, lead, button — around a list that currently holds
|
||||||
|
* one entry, and so read as an empty section. Here the latest post is a single
|
||||||
|
* deliberate panel next to the newsletter form: it looks intentional with one
|
||||||
|
* post, and stays correct once there are twenty.
|
||||||
|
*/
|
||||||
|
export function Dispatch({ latest }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="relative isolate overflow-hidden border-t border-border">
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className="absolute inset-x-0 top-0 -z-10 h-[60%] bg-[radial-gradient(70%_100%_at_50%_0%,rgba(196,184,150,0.09),transparent_70%)]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Section>
|
||||||
|
<Reveal>
|
||||||
|
<SectionHeading
|
||||||
|
eyebrow="DEVBLOG"
|
||||||
|
title="BUILT IN THE OPEN"
|
||||||
|
lead="Design decisions, milestones and the occasional mistake, written down as they happen."
|
||||||
|
action={
|
||||||
|
<ButtonLink href="/devblog" variant="outline">
|
||||||
|
ALL POSTS
|
||||||
|
</ButtonLink>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
|
<Reveal delay={0.1}>
|
||||||
|
<div className="mt-12 grid gap-6 lg:grid-cols-[1.1fr_0.9fr]">
|
||||||
|
{latest && <PostFeature post={latest} />}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`flex flex-col justify-center rounded-2xl border border-border bg-surface p-8 lg:p-10 ${
|
||||||
|
latest ? "" : "lg:col-span-2"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<h3 className="font-display text-2xl tracking-wider sm:text-3xl">
|
||||||
|
GET IT BY EMAIL
|
||||||
|
</h3>
|
||||||
|
<p className="mt-4 leading-relaxed text-foreground-muted">
|
||||||
|
One mail per major update. No launch countdowns, no noise, just
|
||||||
|
the build as it happens.
|
||||||
|
</p>
|
||||||
|
<div className="mt-8">
|
||||||
|
<Newsletter variant="compact" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import Image from "next/image";
|
||||||
|
import { Section } from "../ui/Section";
|
||||||
|
import { ButtonLink } from "../ui/ButtonLink";
|
||||||
|
import { StatusBadge } from "../ui/StatusBadge";
|
||||||
|
import { Reveal } from "../ui/Reveal";
|
||||||
|
import type { Game } from "@/lib/games";
|
||||||
|
|
||||||
|
type Props = { game: Game };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The home page showcase for the studio's current game.
|
||||||
|
*
|
||||||
|
* Deliberately NOT the `GameCard` used on /games. A card is a catalogue
|
||||||
|
* object — it is right when a visitor is comparing several games, and wrong
|
||||||
|
* when there is one game and the whole page exists to sell it.
|
||||||
|
*
|
||||||
|
* The card also forced the key art into whatever height the text column
|
||||||
|
* happened to be, cropping roughly a third of a 16:9 composition. Here the
|
||||||
|
* ratio is locked, so the art is shown as it was framed.
|
||||||
|
*/
|
||||||
|
export function FeaturedGame({ game }: Props) {
|
||||||
|
const artwork = game.keyArt ?? game.banner;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Section>
|
||||||
|
{/* The artwork gets the larger column: it is the only thing on the home
|
||||||
|
page that shows the game itself. */}
|
||||||
|
<div className="grid items-center gap-10 lg:grid-cols-[0.8fr_1.2fr] lg:gap-12">
|
||||||
|
{/* Artwork first in the DOM so it also leads on mobile, where the
|
||||||
|
layout collapses to a single column. */}
|
||||||
|
<Reveal className="lg:order-2" direction="right">
|
||||||
|
<div className="relative aspect-video w-full overflow-hidden rounded-2xl border border-border bg-surface-elevated">
|
||||||
|
{artwork && (
|
||||||
|
<Image
|
||||||
|
src={artwork}
|
||||||
|
alt={`${game.title} key art`}
|
||||||
|
fill
|
||||||
|
sizes="(max-width: 1024px) 100vw, 60vw"
|
||||||
|
className="object-cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
|
<div className="lg:order-1">
|
||||||
|
<Reveal>
|
||||||
|
<p className="mb-4 font-display text-xs tracking-[0.3em] text-accent sm:text-sm">
|
||||||
|
OUR GAMES
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 className="font-display text-[clamp(2.5rem,1.5rem+3vw,4.5rem)] leading-[0.95] tracking-wider">
|
||||||
|
{game.title.toUpperCase()}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p className="mt-6 max-w-lg text-lg leading-relaxed text-foreground-muted">
|
||||||
|
{game.tagline}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Genres as one quiet line instead of a row of chips — the badge
|
||||||
|
already carries the only label that needs a border. */}
|
||||||
|
<div className="mt-8 flex flex-wrap items-center gap-x-4 gap-y-3">
|
||||||
|
<StatusBadge status={game.status} />
|
||||||
|
<p className="text-xs uppercase tracking-[0.2em] text-foreground-muted">
|
||||||
|
{game.genres.join(" · ")}
|
||||||
|
{game.releaseWindow && ` · ${game.releaseWindow}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-10 flex flex-col gap-4 sm:flex-row">
|
||||||
|
<ButtonLink href={`/games/${game.slug}`} block>
|
||||||
|
DISCOVER {game.title.toUpperCase()}
|
||||||
|
</ButtonLink>
|
||||||
|
{game.steamUrl && (
|
||||||
|
<ButtonLink href={game.steamUrl} variant="outline" block>
|
||||||
|
WISHLIST ON STEAM
|
||||||
|
</ButtonLink>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
motion,
|
||||||
|
useReducedMotion,
|
||||||
|
useScroll,
|
||||||
|
useTransform,
|
||||||
|
} from "framer-motion";
|
||||||
|
import { Logo } from "../ui/Logo";
|
||||||
|
import { ButtonLink } from "../ui/ButtonLink";
|
||||||
|
import { HERO_VIDEO, STUDIO } from "@/lib/site";
|
||||||
|
import { EASE_OUT_EXPO } from "@/lib/motion";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-height hero.
|
||||||
|
*
|
||||||
|
* The dark plate sits under the video, so the headline is readable from the
|
||||||
|
* first paint — before the background has loaded, and for visitors who have
|
||||||
|
* reduced motion enabled (they get no video at all).
|
||||||
|
*
|
||||||
|
* Layout note: everything is in normal flow — the scroll hint is NOT absolutely
|
||||||
|
* positioned. On a laptop (wide but only ~900px tall) an absolute hint sat on
|
||||||
|
* top of the buttons. Spacing and type are clamped against `vh` so the block
|
||||||
|
* shrinks with the viewport height instead of overflowing it.
|
||||||
|
*/
|
||||||
|
export function HomeHero() {
|
||||||
|
const reduced = useReducedMotion();
|
||||||
|
|
||||||
|
// Skip entrance animations entirely when reduced motion is requested.
|
||||||
|
const fade = (delay: number) =>
|
||||||
|
reduced
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
initial: { opacity: 0, y: 24 },
|
||||||
|
animate: { opacity: 1, y: 0 },
|
||||||
|
transition: { duration: 0.8, delay, ease: "easeOut" as const },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scroll response. The video drifts slower than the page and the copy
|
||||||
|
* dissolves before the section ends, so the hero hands over to the next band
|
||||||
|
* instead of sliding out of frame intact. Page scroll is read directly —
|
||||||
|
* the hero is always at offset 0, so there is nothing to measure.
|
||||||
|
*/
|
||||||
|
const { scrollY } = useScroll();
|
||||||
|
const videoY = useTransform(scrollY, [0, 800], [0, 150], { clamp: true });
|
||||||
|
const contentY = useTransform(scrollY, [0, 600], [0, 70], { clamp: true });
|
||||||
|
// Holds at full strength for the first 100px: a visitor who nudges the wheel
|
||||||
|
// to see whether the page moves should not find the headline already faded.
|
||||||
|
const contentOpacity = useTransform(scrollY, [100, 560], [1, 0], {
|
||||||
|
clamp: true,
|
||||||
|
});
|
||||||
|
const hintOpacity = useTransform(scrollY, [0, 180], [1, 0], { clamp: true });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="relative isolate flex min-h-[100svh] flex-col overflow-hidden bg-background px-6 pt-[max(6rem,10vh)] pb-[clamp(1.5rem,4vh,2.5rem)] lg:px-10">
|
||||||
|
{!reduced && (
|
||||||
|
// Taller than the section and pulled up, so the parallax never
|
||||||
|
// uncovers the bottom edge of the frame.
|
||||||
|
<motion.video
|
||||||
|
autoPlay
|
||||||
|
muted
|
||||||
|
loop
|
||||||
|
playsInline
|
||||||
|
preload="metadata"
|
||||||
|
poster={HERO_VIDEO.poster ?? undefined}
|
||||||
|
aria-hidden
|
||||||
|
tabIndex={-1}
|
||||||
|
style={{ y: videoY }}
|
||||||
|
className="absolute -top-[10%] left-0 z-0 h-[120%] w-full object-cover"
|
||||||
|
>
|
||||||
|
<source src={HERO_VIDEO.src} type="video/mp4" />
|
||||||
|
</motion.video>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Readability scrim: flat tint + vignette, in a single layer */}
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className="absolute inset-0 z-10 bg-background/65"
|
||||||
|
style={{
|
||||||
|
backgroundImage:
|
||||||
|
"radial-gradient(ellipse at 50% 45%, rgba(10,10,10,0) 25%, rgba(10,10,10,0.9) 100%)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
style={reduced ? undefined : { y: contentY, opacity: contentOpacity }}
|
||||||
|
className="relative z-20 mx-auto flex w-full max-w-4xl flex-1 flex-col items-center justify-center text-center"
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
{...(reduced
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
initial: { opacity: 0, scale: 0.94 },
|
||||||
|
animate: { opacity: 1, scale: 1 },
|
||||||
|
transition: { duration: 1, ease: EASE_OUT_EXPO },
|
||||||
|
})}
|
||||||
|
className="mb-[clamp(1.5rem,4vh,2.5rem)]"
|
||||||
|
>
|
||||||
|
<Logo
|
||||||
|
width={200}
|
||||||
|
priority
|
||||||
|
className="h-auto w-[clamp(110px,13vh,200px)] drop-shadow-[0_0_40px_rgba(196,184,150,0.2)]"
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.h1
|
||||||
|
{...fade(0.25)}
|
||||||
|
className="font-display text-[min(3rem,12vh)] leading-[0.95] tracking-wider text-balance sm:text-[min(4.5rem,12vh)] lg:text-[min(6rem,12vh)]"
|
||||||
|
>
|
||||||
|
HIGHLAND GAMES
|
||||||
|
<br />
|
||||||
|
<span className="text-accent">STUDIO</span>
|
||||||
|
</motion.h1>
|
||||||
|
|
||||||
|
<motion.p
|
||||||
|
{...fade(0.45)}
|
||||||
|
className="mt-[clamp(1.25rem,3vh,2rem)] max-w-xl text-lg tracking-wide text-foreground-muted text-balance sm:text-xl"
|
||||||
|
>
|
||||||
|
{STUDIO.tagline}
|
||||||
|
</motion.p>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
{...fade(0.65)}
|
||||||
|
className="mt-[clamp(1.75rem,4vh,3rem)] flex w-full flex-col items-center gap-4 sm:w-auto sm:flex-row"
|
||||||
|
>
|
||||||
|
<ButtonLink href="/games" block>
|
||||||
|
EXPLORE OUR GAMES
|
||||||
|
</ButtonLink>
|
||||||
|
<ButtonLink href="/studio" variant="outline" block>
|
||||||
|
MEET THE STUDIO
|
||||||
|
</ButtonLink>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{!reduced && (
|
||||||
|
// Fades on the first flick of the wheel: an invitation to scroll that
|
||||||
|
// is still there once you have is just decoration. Two layers, because
|
||||||
|
// one element cannot hold both a load-time `animate` opacity and a
|
||||||
|
// scroll-driven one.
|
||||||
|
<motion.div
|
||||||
|
style={{ opacity: hintOpacity }}
|
||||||
|
aria-hidden
|
||||||
|
className="relative z-20 mt-[clamp(1.5rem,3vh,2.5rem)] flex shrink-0 flex-col items-center self-center"
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ delay: 1.4, duration: 0.8 }}
|
||||||
|
className="flex flex-col items-center gap-2 text-xs tracking-widest text-foreground-muted"
|
||||||
|
>
|
||||||
|
<span className="font-display">SCROLL</span>
|
||||||
|
<motion.span
|
||||||
|
animate={{ y: [0, 8, 0] }}
|
||||||
|
transition={{ duration: 1.8, repeat: Infinity, ease: "easeInOut" }}
|
||||||
|
className="block h-[clamp(1.25rem,3vh,2rem)] w-px bg-foreground-muted"
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { Stagger, StaggerItem } from "../ui/Stagger";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One sentence, full width, no eyebrow, no button.
|
||||||
|
*
|
||||||
|
* The tonal break between the hero and the game showcase: the page stops
|
||||||
|
* selling for a moment and simply says what the studio is for. The type is
|
||||||
|
* fluid rather than stepped, and the band carries its own gradient so the
|
||||||
|
* page is not one uninterrupted flat black from hero to footer.
|
||||||
|
*
|
||||||
|
* The two lines arrive separately, a beat apart. It is the only piece of copy
|
||||||
|
* on the site meant to be read as speech, and the pause between them is the
|
||||||
|
* point — the second line answers the first.
|
||||||
|
*/
|
||||||
|
export function Statement() {
|
||||||
|
return (
|
||||||
|
<section className="bg-[linear-gradient(180deg,#0a0a0a_0%,#151515_50%,#0a0a0a_100%)] px-6 py-24 sm:py-36 lg:px-10">
|
||||||
|
<Stagger step={0.22} className="mx-auto max-w-6xl">
|
||||||
|
<p className="font-display text-[clamp(1.75rem,1rem+3.2vw,4rem)] leading-[1.12] tracking-wider text-balance text-foreground-muted">
|
||||||
|
<StaggerItem as="span" className="block">
|
||||||
|
A game you finish is a game you leave behind.
|
||||||
|
</StaggerItem>
|
||||||
|
<StaggerItem as="span" className="block text-foreground">
|
||||||
|
We build worlds worth staying in.
|
||||||
|
</StaggerItem>
|
||||||
|
</p>
|
||||||
|
</Stagger>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { Section } from "../ui/Section";
|
||||||
|
import { SectionHeading } from "../ui/SectionHeading";
|
||||||
|
import { ButtonLink } from "../ui/ButtonLink";
|
||||||
|
import { Reveal } from "../ui/Reveal";
|
||||||
|
import { Stagger, StaggerItem } from "../ui/Stagger";
|
||||||
|
import { Logo } from "../ui/Logo";
|
||||||
|
import { CountUp } from "../ui/CountUp";
|
||||||
|
import { STUDIO } from "@/lib/site";
|
||||||
|
import { games } from "@/lib/games";
|
||||||
|
import { TEAM } from "@/lib/studio";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Who is behind the game.
|
||||||
|
*
|
||||||
|
* Two columns rather than another full-width text block: the copy on the left,
|
||||||
|
* a brand plate on the right. The figures are counted from the real data, so
|
||||||
|
* the home page cannot claim a team size or a catalogue the rest of the site
|
||||||
|
* contradicts.
|
||||||
|
*/
|
||||||
|
export function StudioIntro() {
|
||||||
|
const foundingYear = Number(STUDIO.foundingDate);
|
||||||
|
|
||||||
|
const facts = [
|
||||||
|
{ value: TEAM.length, from: 0, label: "People" },
|
||||||
|
{
|
||||||
|
value: games.length,
|
||||||
|
from: 0,
|
||||||
|
label: games.length > 1 ? "Worlds in progress" : "World in progress",
|
||||||
|
},
|
||||||
|
// A year counting up from zero reads as a random number for most of the
|
||||||
|
// animation. Rolling the last dozen keeps it legible as a date throughout.
|
||||||
|
{ value: foundingYear, from: foundingYear - 12, label: "Founded" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Section divider>
|
||||||
|
<div className="grid items-center gap-12 lg:grid-cols-[1.1fr_0.9fr] lg:gap-20">
|
||||||
|
<Reveal>
|
||||||
|
<SectionHeading
|
||||||
|
eyebrow="THE STUDIO"
|
||||||
|
title={
|
||||||
|
<>
|
||||||
|
A SMALL TEAM,
|
||||||
|
<br />
|
||||||
|
BIG HORIZONS.
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
lead="We build worlds where every horizon hides a story. Hand-crafted games made to be lived in, not just played through. Each one a different summit, a different climb."
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* The three figures land one after the other, each one starting to
|
||||||
|
count as it arrives — three counters running at once is a slot
|
||||||
|
machine. */}
|
||||||
|
<Stagger
|
||||||
|
as="ul"
|
||||||
|
step={0.12}
|
||||||
|
className="mt-10 grid grid-cols-3 gap-6 border-t border-border pt-8"
|
||||||
|
>
|
||||||
|
{facts.map((fact) => (
|
||||||
|
<StaggerItem as="li" key={fact.label}>
|
||||||
|
<p className="font-display text-3xl tracking-wider text-accent sm:text-4xl">
|
||||||
|
<CountUp value={fact.value} from={fact.from} />
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-[0.65rem] uppercase tracking-[0.2em] text-foreground-muted">
|
||||||
|
{fact.label}
|
||||||
|
</p>
|
||||||
|
</StaggerItem>
|
||||||
|
))}
|
||||||
|
</Stagger>
|
||||||
|
|
||||||
|
<div className="mt-10">
|
||||||
|
<ButtonLink href="/studio" variant="outline">
|
||||||
|
MEET THE TEAM
|
||||||
|
</ButtonLink>
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
|
{/* Brand plate. Deliberately not a team photo: only one member has been
|
||||||
|
shot so far, and a single face cannot stand in for the studio. */}
|
||||||
|
<Reveal delay={0.1} direction="right">
|
||||||
|
<div className="group relative isolate flex aspect-[4/3] items-center justify-center overflow-hidden rounded-2xl border border-border bg-surface transition-colors duration-500 hover:border-accent/40 lg:aspect-[4/5]">
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className="absolute inset-0 -z-10 bg-[radial-gradient(60%_60%_at_50%_45%,rgba(196,184,150,0.14),transparent_70%)]"
|
||||||
|
/>
|
||||||
|
<Logo
|
||||||
|
variant="wordmark"
|
||||||
|
width={320}
|
||||||
|
alt={STUDIO.name}
|
||||||
|
className="h-auto w-[min(72%,320px)] transition-transform duration-700 ease-out group-hover:scale-[1.03]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { SocialLinks } from "./SocialLinks";
|
||||||
|
import {
|
||||||
|
BRAND,
|
||||||
|
CONTACT_CHANNELS,
|
||||||
|
LEGAL_LINKS,
|
||||||
|
NAV_LINKS,
|
||||||
|
STUDIO,
|
||||||
|
} from "@/lib/site";
|
||||||
|
|
||||||
|
export function Footer() {
|
||||||
|
return (
|
||||||
|
<footer className="mt-auto border-t border-border">
|
||||||
|
<div className="mx-auto grid max-w-7xl gap-12 px-6 py-16 md:grid-cols-4 lg:px-10">
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
aria-label={`${STUDIO.name} home`}
|
||||||
|
className="group inline-block"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={BRAND.wordmark.src}
|
||||||
|
alt={STUDIO.name}
|
||||||
|
width={220}
|
||||||
|
height={Math.round(
|
||||||
|
(220 * BRAND.wordmark.height) / BRAND.wordmark.width,
|
||||||
|
)}
|
||||||
|
className="transition duration-300 ease-out group-hover:-translate-y-0.5 group-hover:opacity-80"
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
<p className="mt-6 max-w-sm text-sm leading-relaxed text-foreground-muted">
|
||||||
|
{STUDIO.tagline} An indie studio crafting survival adventures.
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href={`mailto:${CONTACT_CHANNELS[0].email}`}
|
||||||
|
className="underline-grow mt-4 inline-block break-all text-sm text-foreground-muted transition-colors hover:text-accent"
|
||||||
|
>
|
||||||
|
{CONTACT_CHANNELS[0].email}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav aria-label="Footer">
|
||||||
|
<h2 className="mb-5 font-display text-sm tracking-[0.25em] text-foreground-muted">
|
||||||
|
EXPLORE
|
||||||
|
</h2>
|
||||||
|
<ul className="space-y-3 text-sm">
|
||||||
|
{NAV_LINKS.map((link) => (
|
||||||
|
<li key={link.href}>
|
||||||
|
<Link
|
||||||
|
href={link.href}
|
||||||
|
className="group relative inline-flex items-center gap-1.5 text-foreground-muted transition-colors hover:text-foreground"
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="-ml-1 text-xs opacity-0 transition-all duration-300 ease-out group-hover:ml-0 group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
→
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="absolute -bottom-0.5 left-0 h-px w-0 bg-accent transition-all duration-300 ease-out group-hover:w-full"
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="mb-5 font-display text-sm tracking-[0.25em] text-foreground-muted">
|
||||||
|
FOLLOW
|
||||||
|
</h2>
|
||||||
|
<SocialLinks />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-border">
|
||||||
|
<div className="mx-auto flex max-w-7xl flex-col items-center justify-between gap-4 px-6 py-6 text-xs text-foreground-muted sm:flex-row lg:px-10">
|
||||||
|
<p>
|
||||||
|
© {new Date().getFullYear()} {STUDIO.name}. All rights reserved.
|
||||||
|
</p>
|
||||||
|
<ul className="flex items-center gap-6">
|
||||||
|
{LEGAL_LINKS.map((link) => (
|
||||||
|
<li key={link.href}>
|
||||||
|
<Link
|
||||||
|
href={link.href}
|
||||||
|
className="underline-grow inline-block transition-colors hover:text-accent"
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<p className="hidden font-display tracking-[0.25em] lg:block">
|
||||||
|
WHERE SUMMITS BECOME WORLDS
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import { Logo } from "../ui/Logo";
|
||||||
|
import { ScrollProgress } from "../ui/ScrollProgress";
|
||||||
|
import { NAV_LINKS, STUDIO } from "@/lib/site";
|
||||||
|
import { EASE_OUT_EXPO } from "@/lib/motion";
|
||||||
|
|
||||||
|
export function Navbar() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const [scrolled, setScrolled] = useState(false);
|
||||||
|
|
||||||
|
// The drawer stores the route it was opened on, so any navigation closes it
|
||||||
|
// by derivation — no effect syncing state to the router.
|
||||||
|
const [openedOn, setOpenedOn] = useState<string | null>(null);
|
||||||
|
const open = openedOn === pathname;
|
||||||
|
const close = () => setOpenedOn(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onScroll = () => setScrolled(window.scrollY > 20);
|
||||||
|
onScroll();
|
||||||
|
window.addEventListener("scroll", onScroll, { passive: true });
|
||||||
|
return () => window.removeEventListener("scroll", onScroll);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Lock the page and allow Escape to dismiss while the drawer is open.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") setOpenedOn(null);
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", onKeyDown);
|
||||||
|
const previousOverflow = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("keydown", onKeyDown);
|
||||||
|
document.body.style.overflow = previousOverflow;
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const isActive = (href: string) =>
|
||||||
|
pathname === href || pathname.startsWith(`${href}/`);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header
|
||||||
|
className={`fixed inset-x-0 top-0 z-50 transition-colors duration-300 ${
|
||||||
|
scrolled || open
|
||||||
|
? "border-b border-border bg-background/85 backdrop-blur-md"
|
||||||
|
: "border-b border-transparent bg-transparent"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<nav className="mx-auto flex h-16 max-w-7xl items-center justify-between px-6 lg:h-20 lg:px-10">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
aria-label={`${STUDIO.name} home`}
|
||||||
|
className="group flex items-center gap-3"
|
||||||
|
>
|
||||||
|
<Logo
|
||||||
|
width={44}
|
||||||
|
priority
|
||||||
|
className="transition-transform duration-300 ease-out group-hover:-translate-y-0.5 group-hover:scale-105"
|
||||||
|
/>
|
||||||
|
<span className="hidden font-display text-xl tracking-wider transition-colors group-hover:text-accent sm:block">
|
||||||
|
{STUDIO.shortName}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<ul className="hidden items-center gap-1 md:flex">
|
||||||
|
{NAV_LINKS.map((link) => {
|
||||||
|
const active = isActive(link.href);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={link.href}>
|
||||||
|
<Link
|
||||||
|
href={link.href}
|
||||||
|
aria-current={active ? "page" : undefined}
|
||||||
|
className={`group/nav relative px-3 py-2 text-sm tracking-wide transition-colors ${
|
||||||
|
active
|
||||||
|
? "text-accent"
|
||||||
|
: "text-foreground-muted hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
|
||||||
|
{/* Hover hairline for the pages you are not on. Sits under
|
||||||
|
the active marker, so the two never stack visibly. */}
|
||||||
|
{!active && (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="absolute inset-x-3 -bottom-0.5 h-px origin-left scale-x-0 bg-foreground-muted/60 transition-transform duration-300 ease-out group-hover/nav:scale-x-100"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* One shared element for every link: Framer moves it from
|
||||||
|
the old tab to the new one instead of cross-fading two
|
||||||
|
separate underlines. */}
|
||||||
|
{active && (
|
||||||
|
<motion.span
|
||||||
|
layoutId="nav-active"
|
||||||
|
aria-hidden
|
||||||
|
transition={{ type: "spring", stiffness: 420, damping: 34 }}
|
||||||
|
className="absolute inset-x-3 -bottom-0.5 h-px bg-accent"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={open ? "Close menu" : "Open menu"}
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-controls="mobile-menu"
|
||||||
|
onClick={() => setOpenedOn(open ? null : pathname)}
|
||||||
|
className="relative flex h-10 w-10 items-center justify-center text-foreground transition-transform duration-200 hover:text-accent active:scale-90 md:hidden"
|
||||||
|
>
|
||||||
|
<span className="relative flex h-4 w-6 flex-col justify-between">
|
||||||
|
<motion.span
|
||||||
|
animate={open ? { rotate: 45, y: 7 } : { rotate: 0, y: 0 }}
|
||||||
|
transition={{ duration: 0.25 }}
|
||||||
|
className="block h-px origin-center bg-current"
|
||||||
|
/>
|
||||||
|
<motion.span
|
||||||
|
animate={{ opacity: open ? 0 : 1 }}
|
||||||
|
transition={{ duration: 0.15 }}
|
||||||
|
className="block h-px bg-current"
|
||||||
|
/>
|
||||||
|
<motion.span
|
||||||
|
animate={open ? { rotate: -45, y: -7 } : { rotate: 0, y: 0 }}
|
||||||
|
transition={{ duration: 0.25 }}
|
||||||
|
className="block h-px origin-center bg-current"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{open && (
|
||||||
|
<motion.div
|
||||||
|
id="mobile-menu"
|
||||||
|
initial={{ opacity: 0, y: -8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -8 }}
|
||||||
|
transition={{ duration: 0.25, ease: EASE_OUT_EXPO }}
|
||||||
|
className="border-t border-border bg-background/95 backdrop-blur-md md:hidden"
|
||||||
|
>
|
||||||
|
{/* The rows deal out one by one. The drawer is the only place on
|
||||||
|
the site where a whole navigation appears at once, and a block
|
||||||
|
of four identical lines arriving together reads as a flash. */}
|
||||||
|
<motion.ul
|
||||||
|
initial="hidden"
|
||||||
|
animate="visible"
|
||||||
|
variants={{
|
||||||
|
visible: { transition: { staggerChildren: 0.06, delayChildren: 0.05 } },
|
||||||
|
}}
|
||||||
|
className="flex flex-col px-6 py-4"
|
||||||
|
>
|
||||||
|
{NAV_LINKS.map((link) => (
|
||||||
|
<motion.li
|
||||||
|
key={link.href}
|
||||||
|
variants={{
|
||||||
|
hidden: { opacity: 0, x: -12 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
x: 0,
|
||||||
|
transition: { duration: 0.3, ease: EASE_OUT_EXPO },
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href={link.href}
|
||||||
|
onClick={close}
|
||||||
|
aria-current={isActive(link.href) ? "page" : undefined}
|
||||||
|
className={`group flex items-center justify-between border-b border-border py-4 font-display text-2xl tracking-wider transition-colors last:border-b-0 ${
|
||||||
|
isActive(link.href)
|
||||||
|
? "text-accent"
|
||||||
|
: "text-foreground hover:text-accent"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{link.label.toUpperCase()}
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="text-base opacity-0 transition-all duration-300 ease-out group-hover:translate-x-1 group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
→
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</motion.li>
|
||||||
|
))}
|
||||||
|
</motion.ul>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
<ScrollProgress />
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { SOCIAL_LINKS } from "@/lib/site";
|
||||||
|
|
||||||
|
const icons: Record<string, ReactNode> = {
|
||||||
|
x: (
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden>
|
||||||
|
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
discord: (
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden>
|
||||||
|
<path d="M20.317 4.3698a19.7913 19.7913 0 0 0-4.8851-1.5152.0741.0741 0 0 0-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 0 0-.0785-.037 19.7363 19.7363 0 0 0-4.8852 1.515.0699.0699 0 0 0-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 0 0 .0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 0 0 .0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 0 0-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 0 1-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 0 1 .0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 0 1 .0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 0 1-.0066.1276 12.2986 12.2986 0 0 1-1.873.8914.0766.0766 0 0 0-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 0 0 .0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 0 0 .0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 0 0-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1569 2.4189Zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
youtube: (
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden>
|
||||||
|
<path d="M23.498 6.186a3.02 3.02 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.02 3.02 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.02 3.02 0 0 0 2.122 2.136C4.495 20.455 12 20.455 12 20.455s7.505 0 9.377-.505a3.02 3.02 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
steam: (
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden>
|
||||||
|
<path d="M11.979 0C5.678 0 .511 4.86.022 11.037l6.432 2.658c.545-.371 1.203-.59 1.912-.59.063 0 .125.004.188.006l2.861-4.142V8.91c0-2.495 2.028-4.524 4.524-4.524 2.494 0 4.524 2.031 4.524 4.527s-2.03 4.525-4.524 4.525h-.105l-4.076 2.911c0 .052.004.105.004.159 0 1.875-1.515 3.396-3.39 3.396-1.635 0-3.016-1.173-3.331-2.727L.436 15.27C1.862 20.307 6.486 24 11.979 24c6.627 0 11.999-5.373 11.999-12S18.605 0 11.979 0zM7.54 18.21l-1.473-.61c.262.543.714.999 1.314 1.25 1.297.539 2.793-.076 3.332-1.375.263-.63.264-1.319.005-1.949s-.75-1.121-1.377-1.383c-.624-.26-1.29-.249-1.878-.03l1.523.63c.956.4 1.409 1.5 1.009 2.456-.397.957-1.497 1.41-2.454 1.012H7.54zm11.415-9.303c0-1.662-1.353-3.015-3.015-3.015-1.665 0-3.015 1.353-3.015 3.015 0 1.665 1.35 3.015 3.015 3.015 1.663 0 3.015-1.35 3.015-3.015zm-5.273-.005c0-1.252 1.013-2.266 2.265-2.266 1.249 0 2.266 1.014 2.266 2.266 0 1.251-1.017 2.265-2.266 2.265-1.253 0-2.265-1.014-2.265-2.265z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every channel is listed, whether or not the account exists yet: the studio
|
||||||
|
* wants visitors to know where it will be. Accounts without a URL render as
|
||||||
|
* plain text marked "soon" rather than as a link to `#`, which would look
|
||||||
|
* functional and go nowhere. Fill in `href` in `lib/site.ts` and the entry
|
||||||
|
* becomes a real link with no change here.
|
||||||
|
*/
|
||||||
|
export function SocialLinks() {
|
||||||
|
return (
|
||||||
|
<ul className="space-y-3 text-sm">
|
||||||
|
{SOCIAL_LINKS.map((social) => {
|
||||||
|
const icon = (
|
||||||
|
<span className="block h-4 w-4 shrink-0 transition duration-300 ease-out group-hover:scale-110 group-hover:-rotate-6">
|
||||||
|
{icons[social.key]}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={social.key}>
|
||||||
|
{social.href ? (
|
||||||
|
<a
|
||||||
|
href={social.href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="group inline-flex items-center gap-3 text-foreground-muted transition-all duration-300 ease-out hover:translate-x-1 hover:text-foreground [&>span:first-child]:hover:text-accent"
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
{social.label}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-3 text-foreground-muted/55">
|
||||||
|
{icon}
|
||||||
|
{social.label}
|
||||||
|
<span className="text-[0.6rem] uppercase tracking-[0.2em]">
|
||||||
|
Soon
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { EASE_OUT_EXPO } from "@/lib/motion";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Appears once the visitor is far enough down that the navbar is a long way
|
||||||
|
* back — a game page is five full bands tall, and the only way up was the
|
||||||
|
* scrollbar.
|
||||||
|
*
|
||||||
|
* Threshold is in viewport heights rather than pixels, so it does not pop up
|
||||||
|
* on the second scroll tick of a tall desktop screen.
|
||||||
|
*/
|
||||||
|
export function BackToTop() {
|
||||||
|
const reduced = useReducedMotion();
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onScroll = () => setVisible(window.scrollY > window.innerHeight * 1.5);
|
||||||
|
onScroll();
|
||||||
|
window.addEventListener("scroll", onScroll, { passive: true });
|
||||||
|
return () => window.removeEventListener("scroll", onScroll);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{visible && (
|
||||||
|
<motion.button
|
||||||
|
type="button"
|
||||||
|
aria-label="Back to top"
|
||||||
|
onClick={() =>
|
||||||
|
window.scrollTo({ top: 0, behavior: reduced ? "auto" : "smooth" })
|
||||||
|
}
|
||||||
|
initial={{ opacity: 0, scale: 0.8, y: 12 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.8, y: 12 }}
|
||||||
|
transition={{ duration: 0.3, ease: EASE_OUT_EXPO }}
|
||||||
|
whileHover={{ y: -3 }}
|
||||||
|
whileTap={{ scale: 0.92 }}
|
||||||
|
className="group fixed bottom-6 right-6 z-40 flex h-11 w-11 items-center justify-center rounded-full border border-border bg-background/80 text-foreground-muted backdrop-blur-md transition-colors hover:border-accent hover:text-accent focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent lg:bottom-10 lg:right-10"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="text-lg leading-none transition-transform duration-300 group-hover:-translate-y-0.5"
|
||||||
|
>
|
||||||
|
↑
|
||||||
|
</span>
|
||||||
|
</motion.button>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
type Variant = "primary" | "outline" | "quiet";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
href: string;
|
||||||
|
children: ReactNode;
|
||||||
|
variant?: Variant;
|
||||||
|
/** Stretch to full width on mobile — avoids cramped side-by-side buttons. */
|
||||||
|
block?: boolean;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `active:scale` is on the base so every variant answers a click, including
|
||||||
|
* the text-only one. The lift is small on purpose: a button that jumps reads
|
||||||
|
* as a card, and these sit in rows of two.
|
||||||
|
*/
|
||||||
|
const base =
|
||||||
|
"group relative inline-flex items-center justify-center gap-2 font-display tracking-widest text-sm transition duration-200 ease-out active:scale-[0.97] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent";
|
||||||
|
|
||||||
|
const variants: Record<Variant, string> = {
|
||||||
|
primary:
|
||||||
|
"sheen overflow-hidden px-8 py-3 bg-accent text-background hover:bg-foreground hover:-translate-y-0.5 hover:shadow-[0_14px_30px_-14px_rgba(196,184,150,0.6)]",
|
||||||
|
outline:
|
||||||
|
"px-8 py-3 border border-border text-foreground hover:border-accent hover:text-accent hover:-translate-y-0.5 hover:bg-accent/[0.06]",
|
||||||
|
quiet: "underline-grow text-accent hover:text-foreground",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every call-to-action on the site. Automatically renders an `<a>` with the
|
||||||
|
* right rel attributes for external URLs, and a Next `<Link>` otherwise.
|
||||||
|
*/
|
||||||
|
export function ButtonLink({
|
||||||
|
href,
|
||||||
|
children,
|
||||||
|
variant = "primary",
|
||||||
|
block = false,
|
||||||
|
className = "",
|
||||||
|
}: Props) {
|
||||||
|
const classes = `${base} ${variants[variant]} ${
|
||||||
|
block ? "w-full sm:w-auto" : ""
|
||||||
|
} ${className}`;
|
||||||
|
|
||||||
|
const isExternal = /^https?:\/\//.test(href) || href.startsWith("mailto:");
|
||||||
|
|
||||||
|
if (isExternal) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
target={href.startsWith("mailto:") ? undefined : "_blank"}
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={classes}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link href={href} className={classes}>
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion, useReducedMotion } from "framer-motion";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tick that confirms a message went out. Drawn rather than faded in: a
|
||||||
|
* stroke that completes is the one moment on the site worth animating for its
|
||||||
|
* own sake, and it gives the success panel something to arrive on.
|
||||||
|
*/
|
||||||
|
export function CheckMark({ className = "" }: { className?: string }) {
|
||||||
|
const reduced = useReducedMotion();
|
||||||
|
const draw = (delay: number) =>
|
||||||
|
reduced
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
initial: { pathLength: 0, opacity: 0 },
|
||||||
|
animate: { pathLength: 1, opacity: 1 },
|
||||||
|
transition: { duration: 0.5, delay, ease: "easeOut" as const },
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 52 52" fill="none" aria-hidden className={className}>
|
||||||
|
<motion.circle
|
||||||
|
cx="26"
|
||||||
|
cy="26"
|
||||||
|
r="24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
className="text-accent/35"
|
||||||
|
{...draw(0)}
|
||||||
|
/>
|
||||||
|
<motion.path
|
||||||
|
d="M15.5 26.5 L23 34 L36.5 19"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className="text-accent"
|
||||||
|
{...draw(0.35)}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { animate, useInView, useReducedMotion } from "framer-motion";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
/** The number to land on. */
|
||||||
|
value: number;
|
||||||
|
/** Where the count starts. A year should roll a few digits, not run up
|
||||||
|
* from zero, so the caller sets its own starting point. */
|
||||||
|
from?: number;
|
||||||
|
/** Seconds. */
|
||||||
|
duration?: number;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Counts up to a number once, the first time it scrolls into view.
|
||||||
|
*
|
||||||
|
* The animated digits are hidden from assistive tech and the real value is
|
||||||
|
* exposed alongside them, so a screen reader announces "3" rather than every
|
||||||
|
* intermediate frame. Honours `prefers-reduced-motion`: the final value is
|
||||||
|
* rendered immediately.
|
||||||
|
*/
|
||||||
|
export function CountUp({
|
||||||
|
value,
|
||||||
|
from = 0,
|
||||||
|
duration = 1.4,
|
||||||
|
className = "",
|
||||||
|
}: Props) {
|
||||||
|
const ref = useRef<HTMLSpanElement>(null);
|
||||||
|
const inView = useInView(ref, { once: true, margin: "-80px" });
|
||||||
|
const reduced = useReducedMotion();
|
||||||
|
const [count, setCount] = useState(from);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (reduced || !inView) return;
|
||||||
|
|
||||||
|
const controls = animate(from, value, {
|
||||||
|
duration,
|
||||||
|
ease: [0.22, 1, 0.36, 1],
|
||||||
|
onUpdate: (latest) => setCount(Math.round(latest)),
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => controls.stop();
|
||||||
|
}, [inView, reduced, from, value, duration]);
|
||||||
|
|
||||||
|
// Derived rather than stored: with reduced motion there is no animation to
|
||||||
|
// run, so the final value is simply what renders.
|
||||||
|
const display = reduced ? value : count;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span ref={ref} className={`tabular-nums ${className}`}>
|
||||||
|
<span aria-hidden>{display}</span>
|
||||||
|
<span className="sr-only">{value}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import Image from "next/image";
|
||||||
|
import { BRAND } from "@/lib/site";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
/** `mark` = mountain only, `wordmark` = mountain + studio name. */
|
||||||
|
variant?: "mark" | "wordmark";
|
||||||
|
/** Rendered width in px. Height is derived from the file's real ratio. */
|
||||||
|
width?: number;
|
||||||
|
priority?: boolean;
|
||||||
|
className?: string;
|
||||||
|
/** Decorative by default — pass a label when the logo carries meaning. */
|
||||||
|
alt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a brand asset at the correct aspect ratio.
|
||||||
|
* Previously the 1344×768 mark was hard-coded as 36×36, 120×80, 180×120…
|
||||||
|
* in six different files, which squashed the mountain differently each time.
|
||||||
|
*/
|
||||||
|
export function Logo({
|
||||||
|
variant = "mark",
|
||||||
|
width = 40,
|
||||||
|
priority = false,
|
||||||
|
className = "",
|
||||||
|
alt = "",
|
||||||
|
}: Props) {
|
||||||
|
const asset = BRAND[variant];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Image
|
||||||
|
src={asset.src}
|
||||||
|
alt={alt}
|
||||||
|
width={width}
|
||||||
|
height={Math.round((width * asset.height) / asset.width)}
|
||||||
|
priority={priority}
|
||||||
|
className={className}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { SectionHeading } from "./SectionHeading";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
eyebrow?: string;
|
||||||
|
title: ReactNode;
|
||||||
|
lead?: ReactNode;
|
||||||
|
width?: "narrow" | "default" | "wide";
|
||||||
|
children?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
const widths = {
|
||||||
|
narrow: "max-w-3xl",
|
||||||
|
default: "max-w-6xl",
|
||||||
|
wide: "max-w-7xl",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Top block of an inner page. Carries the offset for the fixed navbar
|
||||||
|
* (previously a `pt-32` copy-pasted into every page).
|
||||||
|
*/
|
||||||
|
export function PageHeader({
|
||||||
|
eyebrow,
|
||||||
|
title,
|
||||||
|
lead,
|
||||||
|
width = "default",
|
||||||
|
children,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<section className="px-6 pb-12 pt-32 sm:pb-16 sm:pt-40 lg:px-10">
|
||||||
|
<div className={`mx-auto w-full ${widths[width]}`}>
|
||||||
|
<SectionHeading
|
||||||
|
level="page"
|
||||||
|
eyebrow={eyebrow}
|
||||||
|
title={title}
|
||||||
|
lead={lead}
|
||||||
|
/>
|
||||||
|
{children && <div className="mt-10">{children}</div>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion, useReducedMotion, useScroll, useTransform } from "framer-motion";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
children: ReactNode;
|
||||||
|
/** Pixels travelled by the end of `range`. Positive = drifts down. */
|
||||||
|
distance?: number;
|
||||||
|
/** Pixels of page scroll over which the movement happens. */
|
||||||
|
range?: number;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Slow drift on a hero background as the page scrolls away from it.
|
||||||
|
*
|
||||||
|
* Reads the raw page scroll rather than measuring its own position: this is
|
||||||
|
* only ever used at the top of a document, and measuring an element that is
|
||||||
|
* itself being transformed feeds its own output back into the input.
|
||||||
|
*/
|
||||||
|
export function Parallax({
|
||||||
|
children,
|
||||||
|
distance = 90,
|
||||||
|
range = 700,
|
||||||
|
className,
|
||||||
|
}: Props) {
|
||||||
|
const reduced = useReducedMotion();
|
||||||
|
const { scrollY } = useScroll();
|
||||||
|
const y = useTransform(scrollY, [0, range], [0, distance], { clamp: true });
|
||||||
|
|
||||||
|
if (reduced) {
|
||||||
|
return <div className={className}>{children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div style={{ y }} className={className}>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion, useReducedMotion } from "framer-motion";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { EASE_OUT_EXPO, VIEWPORT } from "@/lib/motion";
|
||||||
|
|
||||||
|
/** Where the element travels from. `up` is the site default. */
|
||||||
|
type Direction = "up" | "down" | "left" | "right" | "scale" | "fade";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
children: ReactNode;
|
||||||
|
/** Stagger helper — seconds added before the animation starts. */
|
||||||
|
delay?: number;
|
||||||
|
direction?: Direction;
|
||||||
|
/** Seconds. Longer for large blocks, shorter for small ones. */
|
||||||
|
duration?: number;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const from: Record<Direction, Record<string, number>> = {
|
||||||
|
up: { opacity: 0, y: 24 },
|
||||||
|
down: { opacity: 0, y: -24 },
|
||||||
|
left: { opacity: 0, x: -36 },
|
||||||
|
right: { opacity: 0, x: 36 },
|
||||||
|
scale: { opacity: 0, scale: 0.94 },
|
||||||
|
fade: { opacity: 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scroll-triggered entrance. The single animation primitive used across the
|
||||||
|
* site so every section enters the same way — the direction only changes when
|
||||||
|
* the layout gives it a reason to (a panel that sits on the right enters from
|
||||||
|
* the right).
|
||||||
|
* Honours `prefers-reduced-motion`: content is then rendered statically.
|
||||||
|
*/
|
||||||
|
export function Reveal({
|
||||||
|
children,
|
||||||
|
delay = 0,
|
||||||
|
direction = "up",
|
||||||
|
duration = 0.6,
|
||||||
|
className,
|
||||||
|
}: Props) {
|
||||||
|
const reduced = useReducedMotion();
|
||||||
|
|
||||||
|
if (reduced) {
|
||||||
|
return <div className={className}>{children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={from[direction]}
|
||||||
|
whileInView={{ opacity: 1, x: 0, y: 0, scale: 1 }}
|
||||||
|
viewport={VIEWPORT}
|
||||||
|
transition={{ duration, delay, ease: EASE_OUT_EXPO }}
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion, useReducedMotion, useScroll, useSpring } from "framer-motion";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reading progress, rendered as the navbar's bottom hairline.
|
||||||
|
*
|
||||||
|
* Deliberately not a bar of its own: the header already draws a 1px border
|
||||||
|
* once the page is scrolled, so the progress line replaces something that was
|
||||||
|
* there rather than adding furniture to the top of every page. At the top of
|
||||||
|
* the document it has zero width and is invisible.
|
||||||
|
*/
|
||||||
|
export function ScrollProgress() {
|
||||||
|
const reduced = useReducedMotion();
|
||||||
|
const { scrollYProgress } = useScroll();
|
||||||
|
|
||||||
|
// The spring smooths the jitter of a trackpad; with reduced motion the raw
|
||||||
|
// value is used, which still tracks the scroll exactly — just without easing.
|
||||||
|
const smoothed = useSpring(scrollYProgress, {
|
||||||
|
stiffness: 120,
|
||||||
|
damping: 30,
|
||||||
|
restDelta: 0.001,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
aria-hidden
|
||||||
|
style={{ scaleX: reduced ? scrollYProgress : smoothed }}
|
||||||
|
className="absolute inset-x-0 bottom-0 h-px origin-left bg-accent/70"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
type Spacing = "default" | "tight" | "none";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
children: ReactNode;
|
||||||
|
id?: string;
|
||||||
|
/** Content max-width. `wide` for galleries, `narrow` for reading columns. */
|
||||||
|
width?: "narrow" | "default" | "wide";
|
||||||
|
/** Top hairline separating this section from the previous one. */
|
||||||
|
divider?: boolean;
|
||||||
|
/** `none` when the section continues the previous block without a break. */
|
||||||
|
top?: Spacing;
|
||||||
|
bottom?: Spacing;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const widths = {
|
||||||
|
narrow: "max-w-3xl",
|
||||||
|
default: "max-w-6xl",
|
||||||
|
wide: "max-w-7xl",
|
||||||
|
};
|
||||||
|
|
||||||
|
const topPadding: Record<Spacing, string> = {
|
||||||
|
default: "pt-20 sm:pt-28",
|
||||||
|
tight: "pt-10 sm:pt-14",
|
||||||
|
none: "pt-0",
|
||||||
|
};
|
||||||
|
|
||||||
|
const bottomPadding: Record<Spacing, string> = {
|
||||||
|
default: "pb-20 sm:pb-28",
|
||||||
|
tight: "pb-10 sm:pb-14",
|
||||||
|
none: "pb-0",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Consistent vertical rhythm and horizontal gutters for every page section.
|
||||||
|
* Replaces the ad-hoc `py-24 px-6 lg:px-10 max-w-6xl mx-auto` that used to be
|
||||||
|
* repeated in every page.
|
||||||
|
*/
|
||||||
|
export function Section({
|
||||||
|
children,
|
||||||
|
id,
|
||||||
|
width = "default",
|
||||||
|
divider = false,
|
||||||
|
top = "default",
|
||||||
|
bottom = "default",
|
||||||
|
className = "",
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
id={id}
|
||||||
|
className={`px-6 lg:px-10 ${topPadding[top]} ${bottomPadding[bottom]} ${
|
||||||
|
divider ? "border-t border-border" : ""
|
||||||
|
} ${className}`}
|
||||||
|
>
|
||||||
|
<div className={`mx-auto w-full ${widths[width]}`}>{children}</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
/** Small uppercase kicker above the title. */
|
||||||
|
eyebrow?: string;
|
||||||
|
title: ReactNode;
|
||||||
|
/** Supporting paragraph under the title. */
|
||||||
|
lead?: ReactNode;
|
||||||
|
/** Optional link/button aligned to the right on wide screens. */
|
||||||
|
action?: ReactNode;
|
||||||
|
align?: "left" | "center";
|
||||||
|
/** `page` for h1-sized headings, `section` for h2. */
|
||||||
|
level?: "page" | "section";
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The heading pattern used at the top of every section: eyebrow, title, lead.
|
||||||
|
* Sizes are deliberately smaller on mobile — the previous 6xl/7xl display
|
||||||
|
* type overflowed narrow screens.
|
||||||
|
*/
|
||||||
|
export function SectionHeading({
|
||||||
|
eyebrow,
|
||||||
|
title,
|
||||||
|
lead,
|
||||||
|
action,
|
||||||
|
align = "left",
|
||||||
|
level = "section",
|
||||||
|
className = "",
|
||||||
|
}: Props) {
|
||||||
|
const Tag = level === "page" ? "h1" : "h2";
|
||||||
|
const size =
|
||||||
|
level === "page"
|
||||||
|
? "text-4xl sm:text-6xl lg:text-7xl"
|
||||||
|
: "text-3xl sm:text-4xl lg:text-5xl";
|
||||||
|
const centered = align === "center";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex flex-col gap-6 ${
|
||||||
|
action ? "lg:flex-row lg:items-end lg:justify-between" : ""
|
||||||
|
} ${centered ? "items-center text-center" : ""} ${className}`}
|
||||||
|
>
|
||||||
|
<div className={centered ? "max-w-2xl" : ""}>
|
||||||
|
{eyebrow && (
|
||||||
|
<p className="font-display text-xs sm:text-sm tracking-[0.3em] text-accent mb-4">
|
||||||
|
{eyebrow}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Tag
|
||||||
|
className={`font-display ${size} tracking-wider leading-[1.05] text-balance`}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Tag>
|
||||||
|
{lead && (
|
||||||
|
<p
|
||||||
|
className={`mt-6 text-base sm:text-lg text-foreground-muted leading-relaxed max-w-2xl ${
|
||||||
|
centered ? "mx-auto" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{lead}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{action && <div className="shrink-0">{action}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/** Pending indicator for form submit buttons. Decorative — the button label
|
||||||
|
* already says "SENDING…", so a screen reader gets the state from the text. */
|
||||||
|
export function Spinner({ className = "" }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
aria-hidden
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
className={`h-3.5 w-3.5 animate-spin ${className}`}
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="9"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2.5"
|
||||||
|
opacity="0.25"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M21 12a9 9 0 0 0-9-9"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A faint glow that follows the pointer across a card.
|
||||||
|
*
|
||||||
|
* Drop it as the last child of any positioned element carrying Tailwind's
|
||||||
|
* `group` class — it finds its own parent, so the card itself can stay a
|
||||||
|
* server component and needs no props threaded through it:
|
||||||
|
*
|
||||||
|
* <Link className="group relative …">
|
||||||
|
* …
|
||||||
|
* <Spotlight />
|
||||||
|
* </Link>
|
||||||
|
*
|
||||||
|
* The paint lives in `.spotlight` (globals.css); this only writes the pointer
|
||||||
|
* position. Nothing runs until the pointer is actually over the card, and the
|
||||||
|
* effect is disabled on touch screens, where `:hover` would keep it lit.
|
||||||
|
*/
|
||||||
|
export function Spotlight() {
|
||||||
|
const ref = useRef<HTMLSpanElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const card = ref.current?.parentElement;
|
||||||
|
if (!card || !window.matchMedia("(hover: hover)").matches) return;
|
||||||
|
|
||||||
|
const onMove = (event: PointerEvent) => {
|
||||||
|
const rect = card.getBoundingClientRect();
|
||||||
|
card.style.setProperty("--spot-x", `${event.clientX - rect.left}px`);
|
||||||
|
card.style.setProperty("--spot-y", `${event.clientY - rect.top}px`);
|
||||||
|
};
|
||||||
|
|
||||||
|
card.addEventListener("pointermove", onMove);
|
||||||
|
return () => card.removeEventListener("pointermove", onMove);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return <span ref={ref} aria-hidden className="spotlight" />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion, useReducedMotion, type Variants } from "framer-motion";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { EASE_OUT_EXPO, VIEWPORT } from "@/lib/motion";
|
||||||
|
|
||||||
|
const containers = {
|
||||||
|
div: motion.div,
|
||||||
|
ul: motion.ul,
|
||||||
|
ol: motion.ol,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const items = {
|
||||||
|
div: motion.div,
|
||||||
|
li: motion.li,
|
||||||
|
span: motion.span,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type ContainerTag = keyof typeof containers;
|
||||||
|
type ItemTag = keyof typeof items;
|
||||||
|
|
||||||
|
const item: Variants = {
|
||||||
|
hidden: { opacity: 0, y: 18 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
transition: { duration: 0.5, ease: EASE_OUT_EXPO },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
type StaggerProps = {
|
||||||
|
children: ReactNode;
|
||||||
|
/** Seconds between two children. */
|
||||||
|
step?: number;
|
||||||
|
/** Seconds before the first child moves. */
|
||||||
|
delay?: number;
|
||||||
|
as?: ContainerTag;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A list or grid whose children enter one after the other instead of all at
|
||||||
|
* once — the difference between "a block appeared" and "these items were
|
||||||
|
* dealt out".
|
||||||
|
*
|
||||||
|
* Use it in place of `Reveal` wherever the content is a collection: cards,
|
||||||
|
* figures, thumbnails. A single block stays on `Reveal`.
|
||||||
|
* Pair with `StaggerItem` — a plain child is not animated.
|
||||||
|
*/
|
||||||
|
export function Stagger({
|
||||||
|
children,
|
||||||
|
step = 0.08,
|
||||||
|
delay = 0,
|
||||||
|
as = "div",
|
||||||
|
className,
|
||||||
|
}: StaggerProps) {
|
||||||
|
const reduced = useReducedMotion();
|
||||||
|
const Tag = containers[as];
|
||||||
|
|
||||||
|
if (reduced) {
|
||||||
|
const Plain = as;
|
||||||
|
return <Plain className={className}>{children}</Plain>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tag
|
||||||
|
initial="hidden"
|
||||||
|
whileInView="visible"
|
||||||
|
viewport={VIEWPORT}
|
||||||
|
variants={{
|
||||||
|
hidden: {},
|
||||||
|
visible: { transition: { staggerChildren: step, delayChildren: delay } },
|
||||||
|
}}
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type StaggerItemProps = {
|
||||||
|
children: ReactNode;
|
||||||
|
as?: ItemTag;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StaggerItem({
|
||||||
|
children,
|
||||||
|
as = "div",
|
||||||
|
className,
|
||||||
|
}: StaggerItemProps) {
|
||||||
|
const reduced = useReducedMotion();
|
||||||
|
const Tag = items[as];
|
||||||
|
|
||||||
|
if (reduced) {
|
||||||
|
const Plain = as;
|
||||||
|
return <Plain className={className}>{children}</Plain>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tag variants={item} className={className}>
|
||||||
|
{children}
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { STATUS_LABEL, type GameStatus } from "@/lib/games";
|
||||||
|
|
||||||
|
const tones: Record<GameStatus, string> = {
|
||||||
|
"in-development": "border-accent/50 text-accent",
|
||||||
|
"coming-soon": "border-accent/50 text-accent",
|
||||||
|
released: "border-foreground/40 text-foreground",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A game still being worked on gets a live dot; a shipped one does not. */
|
||||||
|
const live: Record<GameStatus, boolean> = {
|
||||||
|
"in-development": true,
|
||||||
|
"coming-soon": true,
|
||||||
|
released: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Production status pill. Always on a solid surface, never over artwork. */
|
||||||
|
export function StatusBadge({ status }: { status: GameStatus }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center gap-2 border px-3 py-1 font-display text-[0.7rem] tracking-[0.25em] ${tones[status]}`}
|
||||||
|
>
|
||||||
|
{live[status] && (
|
||||||
|
<span aria-hidden className="relative flex h-1.5 w-1.5">
|
||||||
|
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-current opacity-50" />
|
||||||
|
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-current" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{STATUS_LABEL[status]}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
+75
-66
@@ -1,92 +1,101 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { ContactForm } from "../components/ContactForm";
|
import { PageHeader } from "../components/ui/PageHeader";
|
||||||
|
import { Section } from "../components/ui/Section";
|
||||||
|
import { ButtonLink } from "../components/ui/ButtonLink";
|
||||||
|
import { ContactForm } from "../components/forms/ContactForm";
|
||||||
|
import { SocialLinks } from "../components/layout/SocialLinks";
|
||||||
|
import { CONTACT_CHANNELS } from "@/lib/site";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Contact",
|
title: "Contact",
|
||||||
description: "Get in touch with Highland Games Studio.",
|
description: "Get in touch with Highland Games Studio.",
|
||||||
};
|
};
|
||||||
|
|
||||||
const channels = [
|
const pressEmail =
|
||||||
{
|
CONTACT_CHANNELS.find((channel) => channel.label === "PRESS")?.email ??
|
||||||
label: "GENERAL",
|
CONTACT_CHANNELS[0].email;
|
||||||
value: "hello@highlandgamesstudio.com",
|
|
||||||
href: "mailto:hello@highlandgamesstudio.com",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "PRESS",
|
|
||||||
value: "press@highlandgamesstudio.com",
|
|
||||||
href: "mailto:press@highlandgamesstudio.com",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "BUSINESS",
|
|
||||||
value: "business@highlandgamesstudio.com",
|
|
||||||
href: "mailto:business@highlandgamesstudio.com",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contact.
|
||||||
|
*
|
||||||
|
* The page used to stack three sections of identical weight — three mailto
|
||||||
|
* cards, then the form, then a press-kit block — which offered four ways to do
|
||||||
|
* one thing and ranked none of them. Now the form is the page, and every
|
||||||
|
* alternative sits in a column beside it: same information, one obvious
|
||||||
|
* default.
|
||||||
|
*/
|
||||||
export default function ContactPage() {
|
export default function ContactPage() {
|
||||||
return (
|
return (
|
||||||
<div className="pt-32 pb-24 px-6 lg:px-10">
|
<>
|
||||||
<div className="max-w-4xl mx-auto">
|
<PageHeader
|
||||||
<p className="font-display text-sm tracking-[0.3em] text-accent mb-4">
|
eyebrow="GET IN TOUCH"
|
||||||
— GET IN TOUCH
|
title="SAY HELLO."
|
||||||
</p>
|
lead="Press inquiries, business opportunities, fan messages. All are welcome. We're three people, so answers take a few days, but they always come."
|
||||||
<h1 className="font-display text-5xl sm:text-6xl lg:text-7xl tracking-wider leading-[0.95] mb-8">
|
/>
|
||||||
SAY HELLO.
|
|
||||||
</h1>
|
|
||||||
<p className="text-lg text-foreground-muted leading-relaxed max-w-2xl mb-16">
|
|
||||||
Press inquiries, business opportunities, fan messages — all are
|
|
||||||
welcome. Pick a channel and reach out.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="grid sm:grid-cols-3 gap-6 mb-24">
|
<Section top="none">
|
||||||
{channels.map((c) => (
|
<div className="grid gap-12 lg:grid-cols-[1.35fr_0.65fr] lg:gap-16">
|
||||||
<a
|
<div>
|
||||||
key={c.label}
|
<h2 className="mb-8 font-display text-2xl tracking-wider sm:text-3xl">
|
||||||
href={c.href}
|
|
||||||
className="border border-border p-8 hover:border-accent transition-colors group"
|
|
||||||
>
|
|
||||||
<p className="font-display text-xs tracking-[0.3em] text-accent mb-3">
|
|
||||||
{c.label}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-foreground group-hover:text-accent transition-colors">
|
|
||||||
{c.value}
|
|
||||||
</p>
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-t border-border pt-16">
|
|
||||||
<p className="font-display text-xs tracking-[0.3em] text-accent mb-3">
|
|
||||||
— OR
|
|
||||||
</p>
|
|
||||||
<h2 className="font-display text-3xl sm:text-4xl tracking-wider mb-4">
|
|
||||||
SEND US A MESSAGE
|
SEND US A MESSAGE
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-foreground-muted leading-relaxed max-w-2xl mb-10">
|
|
||||||
Prefer not to use email? Drop your message here and we'll get
|
|
||||||
back to you.
|
|
||||||
</p>
|
|
||||||
<ContactForm />
|
<ContactForm />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-24 border-t border-border pt-12">
|
<aside className="flex flex-col gap-6">
|
||||||
<h2 className="font-display text-2xl tracking-wider mb-4">
|
{/* Direct addresses, for anyone who would rather use their own mail
|
||||||
PRESS KIT
|
client than a form. Secondary by placement, not hidden. */}
|
||||||
|
<div className="rounded-2xl border border-border bg-surface p-8 transition-colors duration-300 hover:border-accent/40">
|
||||||
|
<h2 className="font-display text-sm tracking-[0.25em] text-foreground-muted">
|
||||||
|
DIRECT EMAIL
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-foreground-muted leading-relaxed mb-6 max-w-xl">
|
<ul className="mt-6 space-y-6">
|
||||||
Logos, screenshots and fact sheets for our games will be available
|
{CONTACT_CHANNELS.map((channel) => (
|
||||||
here soon. In the meantime, drop us an email and we'll send
|
<li key={channel.label}>
|
||||||
assets directly.
|
<p className="font-display text-xs tracking-[0.3em] text-accent">
|
||||||
|
{channel.label}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-sm leading-relaxed text-foreground-muted">
|
||||||
|
{channel.description}
|
||||||
</p>
|
</p>
|
||||||
<a
|
<a
|
||||||
href="mailto:press@highlandgamesstudio.com?subject=Press%20Kit%20Request"
|
href={`mailto:${channel.email}`}
|
||||||
className="inline-block px-8 py-3 border border-border text-foreground font-display tracking-widest text-sm hover:border-accent hover:text-accent hover:scale-[1.03] transition duration-200"
|
className="underline-grow mt-2 inline-block break-words text-sm text-foreground transition-colors hover:text-accent"
|
||||||
>
|
>
|
||||||
REQUEST PRESS KIT →
|
{channel.email}
|
||||||
</a>
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-border bg-surface p-8 transition-colors duration-300 hover:border-accent/40">
|
||||||
|
<h2 className="font-display text-sm tracking-[0.25em] text-foreground-muted">
|
||||||
|
PRESS KIT
|
||||||
|
</h2>
|
||||||
|
<p className="mt-4 text-sm leading-relaxed text-foreground-muted">
|
||||||
|
Logos, screenshots and fact sheets will live here soon. Until
|
||||||
|
then, ask and we send the assets directly.
|
||||||
|
</p>
|
||||||
|
<div className="mt-6">
|
||||||
|
<ButtonLink
|
||||||
|
href={`mailto:${pressEmail}?subject=Press%20Kit%20Request`}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
REQUEST PRESS KIT
|
||||||
|
</ButtonLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-border bg-surface p-8 transition-colors duration-300 hover:border-accent/40">
|
||||||
|
<h2 className="mb-6 font-display text-sm tracking-[0.25em] text-foreground-muted">
|
||||||
|
FOLLOW
|
||||||
|
</h2>
|
||||||
|
<SocialLinks />
|
||||||
</div>
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+152
-40
@@ -3,8 +3,11 @@ import { notFound } from "next/navigation";
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { MDXRemote } from "next-mdx-remote/rsc";
|
import { MDXRemote } from "next-mdx-remote/rsc";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
|
import { Newsletter } from "../../components/forms/Newsletter";
|
||||||
import { getAdjacentPosts, getAllPosts, getPost } from "@/lib/devblog";
|
import { getAdjacentPosts, getAllPosts, getPost } from "@/lib/devblog";
|
||||||
import { SITE_URL, LOGO_URL } from "@/lib/site";
|
import { formatDate, readingTime } from "@/lib/format";
|
||||||
|
import { games } from "@/lib/games";
|
||||||
|
import { SITE_URL, LOGO_URL, STUDIO } from "@/lib/site";
|
||||||
|
|
||||||
type Props = { params: Promise<{ slug: string }> };
|
type Props = { params: Promise<{ slug: string }> };
|
||||||
|
|
||||||
@@ -38,31 +41,25 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(date: string) {
|
|
||||||
return new Date(date).toLocaleDateString("en-US", {
|
|
||||||
year: "numeric",
|
|
||||||
month: "long",
|
|
||||||
day: "numeric",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const mdxComponents = {
|
const mdxComponents = {
|
||||||
|
// The page title is already the document's h1. A `#` heading inside a post
|
||||||
|
// renders as an h2 so there is never a second one.
|
||||||
h1: (props: React.HTMLAttributes<HTMLHeadingElement>) => (
|
h1: (props: React.HTMLAttributes<HTMLHeadingElement>) => (
|
||||||
<h1
|
<h2
|
||||||
{...props}
|
{...props}
|
||||||
className="font-display text-4xl sm:text-5xl tracking-wider mt-12 mb-6 first:mt-0"
|
className="mt-12 mb-6 font-display text-3xl tracking-wider first:mt-0 sm:text-4xl"
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
h2: (props: React.HTMLAttributes<HTMLHeadingElement>) => (
|
h2: (props: React.HTMLAttributes<HTMLHeadingElement>) => (
|
||||||
<h2
|
<h2 {...props} className="mt-12 mb-4 font-display text-3xl tracking-wider" />
|
||||||
{...props}
|
|
||||||
className="font-display text-3xl tracking-wider mt-12 mb-4"
|
|
||||||
/>
|
|
||||||
),
|
),
|
||||||
h3: (props: React.HTMLAttributes<HTMLHeadingElement>) => (
|
h3: (props: React.HTMLAttributes<HTMLHeadingElement>) => (
|
||||||
<h3
|
<h3 {...props} className="mt-8 mb-3 font-display text-2xl tracking-wider" />
|
||||||
|
),
|
||||||
|
h4: (props: React.HTMLAttributes<HTMLHeadingElement>) => (
|
||||||
|
<h4
|
||||||
{...props}
|
{...props}
|
||||||
className="font-display text-2xl tracking-wider mt-8 mb-3"
|
className="mt-8 mb-3 font-display text-xl tracking-wider text-foreground"
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
p: (props: React.HTMLAttributes<HTMLParagraphElement>) => (
|
p: (props: React.HTMLAttributes<HTMLParagraphElement>) => (
|
||||||
@@ -95,10 +92,57 @@ const mdxComponents = {
|
|||||||
className="border-l-2 border-accent pl-6 my-6 italic text-foreground"
|
className="border-l-2 border-accent pl-6 my-6 italic text-foreground"
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
code: (props: React.HTMLAttributes<HTMLElement>) => (
|
// Inline code gets a pill; code inside a fence must not, or every block
|
||||||
|
// paints a bordered chip on top of its own container. Fenced code is the
|
||||||
|
// case that carries a `language-*` class.
|
||||||
|
code: (props: React.HTMLAttributes<HTMLElement>) => {
|
||||||
|
const fenced =
|
||||||
|
typeof props.className === "string" &&
|
||||||
|
props.className.includes("language-");
|
||||||
|
|
||||||
|
return fenced ? (
|
||||||
|
<code {...props} className="font-mono text-sm text-foreground" />
|
||||||
|
) : (
|
||||||
<code
|
<code
|
||||||
{...props}
|
{...props}
|
||||||
className="bg-surface-elevated border border-border px-1.5 py-0.5 rounded text-sm text-accent"
|
className="rounded border border-border bg-surface-elevated px-1.5 py-0.5 text-sm text-accent"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
pre: (props: React.HTMLAttributes<HTMLPreElement>) => (
|
||||||
|
<pre
|
||||||
|
{...props}
|
||||||
|
className="my-6 overflow-x-auto rounded-2xl border border-border bg-surface-elevated p-5 text-sm"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
// `remarkGfm` is enabled, so a post can contain a table. Without these it
|
||||||
|
// rendered as unstyled browser-default HTML.
|
||||||
|
table: (props: React.HTMLAttributes<HTMLTableElement>) => (
|
||||||
|
<div className="my-6 overflow-x-auto">
|
||||||
|
<table
|
||||||
|
{...props}
|
||||||
|
className="w-full border-collapse text-sm text-foreground-muted"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
th: (props: React.ThHTMLAttributes<HTMLTableCellElement>) => (
|
||||||
|
<th
|
||||||
|
{...props}
|
||||||
|
className="border border-border bg-surface px-4 py-2 text-left font-display text-xs tracking-[0.2em] text-foreground"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
td: (props: React.TdHTMLAttributes<HTMLTableCellElement>) => (
|
||||||
|
<td {...props} className="border border-border px-4 py-2 align-top" />
|
||||||
|
),
|
||||||
|
img: (props: React.ImgHTMLAttributes<HTMLImageElement>) => (
|
||||||
|
// Dimensions are unknown at author time, so this stays a plain <img>
|
||||||
|
// rather than next/image. Lazy by default; it is always below the fold.
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
{...props}
|
||||||
|
alt={props.alt ?? ""}
|
||||||
|
loading="lazy"
|
||||||
|
className="my-8 w-full rounded-2xl border border-border"
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
hr: () => <hr className="my-12 border-border" />,
|
hr: () => <hr className="my-12 border-border" />,
|
||||||
@@ -110,6 +154,10 @@ export default async function DevblogPost({ params }: Props) {
|
|||||||
if (!post) notFound();
|
if (!post) notFound();
|
||||||
|
|
||||||
const { newer, older } = getAdjacentPosts(slug);
|
const { newer, older } = getAdjacentPosts(slug);
|
||||||
|
const relatedGame = post.game
|
||||||
|
? games.find((entry) => entry.slug === post.game)
|
||||||
|
: undefined;
|
||||||
|
const minutes = readingTime(post.content);
|
||||||
const url = `${SITE_URL}/devblog/${post.slug}`;
|
const url = `${SITE_URL}/devblog/${post.slug}`;
|
||||||
const jsonLd = {
|
const jsonLd = {
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
@@ -120,11 +168,11 @@ export default async function DevblogPost({ params }: Props) {
|
|||||||
dateModified: post.date,
|
dateModified: post.date,
|
||||||
author: {
|
author: {
|
||||||
"@type": "Organization",
|
"@type": "Organization",
|
||||||
name: post.author ?? "Highland Games Studio",
|
name: post.author ?? STUDIO.name,
|
||||||
},
|
},
|
||||||
publisher: {
|
publisher: {
|
||||||
"@type": "Organization",
|
"@type": "Organization",
|
||||||
name: "Highland Games Studio",
|
name: STUDIO.name,
|
||||||
logo: {
|
logo: {
|
||||||
"@type": "ImageObject",
|
"@type": "ImageObject",
|
||||||
url: LOGO_URL,
|
url: LOGO_URL,
|
||||||
@@ -136,42 +184,80 @@ export default async function DevblogPost({ params }: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="pt-32 pb-24 px-6 lg:px-10">
|
<article className="px-6 pb-24 pt-32 sm:pt-40 lg:px-10">
|
||||||
<script
|
<script
|
||||||
type="application/ld+json"
|
type="application/ld+json"
|
||||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||||
/>
|
/>
|
||||||
<div className="max-w-3xl mx-auto">
|
<div className="mx-auto max-w-3xl">
|
||||||
<Link
|
<Link
|
||||||
href="/devblog"
|
href="/devblog"
|
||||||
className="inline-block font-display text-xs tracking-widest text-foreground-muted hover:text-accent transition-colors mb-12"
|
className="group mb-12 inline-flex items-center gap-2 font-display text-xs tracking-widest text-foreground-muted transition-colors hover:text-accent"
|
||||||
>
|
>
|
||||||
← BACK TO DEVBLOG
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="inline-block transition-transform duration-300 ease-out group-hover:-translate-x-1"
|
||||||
|
>
|
||||||
|
←
|
||||||
|
</span>
|
||||||
|
BACK TO DEVBLOG
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<header className="mb-16 pb-8 border-b border-border">
|
<header className="mb-16 border-b border-border pb-8">
|
||||||
<time
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 font-display text-xs tracking-[0.3em] text-accent">
|
||||||
dateTime={post.date}
|
<time dateTime={post.date}>
|
||||||
className="font-display text-xs tracking-[0.3em] text-accent"
|
|
||||||
>
|
|
||||||
{formatDate(post.date).toUpperCase()}
|
{formatDate(post.date).toUpperCase()}
|
||||||
</time>
|
</time>
|
||||||
<h1 className="font-display text-4xl sm:text-5xl lg:text-6xl tracking-wider leading-[0.95] mt-4 mb-6">
|
<span aria-hidden className="text-border">
|
||||||
|
·
|
||||||
|
</span>
|
||||||
|
<span className="text-foreground-muted">{minutes} MIN READ</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="mt-4 mb-6 font-display text-4xl leading-[0.95] tracking-wider sm:text-5xl lg:text-6xl">
|
||||||
{post.title.toUpperCase()}
|
{post.title.toUpperCase()}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
{post.excerpt && (
|
{post.excerpt && (
|
||||||
<p className="text-lg text-foreground-muted leading-relaxed">
|
<p className="text-lg leading-relaxed text-foreground-muted">
|
||||||
{post.excerpt}
|
{post.excerpt}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="mt-8 flex flex-wrap items-center gap-x-6 gap-y-3">
|
||||||
{post.author && (
|
{post.author && (
|
||||||
<p className="text-xs tracking-widest text-foreground-muted mt-6 uppercase">
|
<p className="text-xs uppercase tracking-widest text-foreground-muted">
|
||||||
By {post.author}
|
By {post.author}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* The post declares a game in its frontmatter but the page never
|
||||||
|
showed it, so a reader had no way back to what it is about. */}
|
||||||
|
{relatedGame && (
|
||||||
|
<Link
|
||||||
|
href={`/games/${relatedGame.slug}`}
|
||||||
|
className="border border-accent/40 px-2 py-1 text-[0.65rem] uppercase tracking-widest text-accent transition duration-200 ease-out hover:-translate-y-0.5 hover:border-accent hover:bg-accent hover:text-background active:scale-95"
|
||||||
|
>
|
||||||
|
{relatedGame.title}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{post.tags && post.tags.length > 0 && (
|
||||||
|
<ul className="flex flex-wrap gap-2" aria-label="Tags">
|
||||||
|
{post.tags.map((tag) => (
|
||||||
|
<li
|
||||||
|
key={tag}
|
||||||
|
className="text-[0.65rem] uppercase tracking-widest text-foreground-muted"
|
||||||
|
>
|
||||||
|
#{tag}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="prose-custom">
|
<div>
|
||||||
<MDXRemote
|
<MDXRemote
|
||||||
source={post.content}
|
source={post.content}
|
||||||
components={mdxComponents}
|
components={mdxComponents}
|
||||||
@@ -191,10 +277,16 @@ export default async function DevblogPost({ params }: Props) {
|
|||||||
{newer ? (
|
{newer ? (
|
||||||
<Link
|
<Link
|
||||||
href={`/devblog/${newer.slug}`}
|
href={`/devblog/${newer.slug}`}
|
||||||
className="group block border border-border p-6 hover:border-accent transition-colors"
|
className="group block border border-border p-6 transition duration-300 ease-out hover:-translate-y-1 hover:border-accent"
|
||||||
>
|
>
|
||||||
<p className="font-display text-xs tracking-[0.3em] text-foreground-muted group-hover:text-accent transition-colors mb-2">
|
<p className="mb-2 flex items-center gap-2 font-display text-xs tracking-[0.3em] text-foreground-muted transition-colors group-hover:text-accent">
|
||||||
← NEWER
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="inline-block transition-transform duration-300 ease-out group-hover:-translate-x-1"
|
||||||
|
>
|
||||||
|
←
|
||||||
|
</span>
|
||||||
|
NEWER
|
||||||
</p>
|
</p>
|
||||||
<p className="font-display text-lg tracking-wider group-hover:text-accent transition-colors">
|
<p className="font-display text-lg tracking-wider group-hover:text-accent transition-colors">
|
||||||
{newer.title.toUpperCase()}
|
{newer.title.toUpperCase()}
|
||||||
@@ -206,10 +298,16 @@ export default async function DevblogPost({ params }: Props) {
|
|||||||
{older ? (
|
{older ? (
|
||||||
<Link
|
<Link
|
||||||
href={`/devblog/${older.slug}`}
|
href={`/devblog/${older.slug}`}
|
||||||
className="group block border border-border p-6 hover:border-accent transition-colors sm:text-right"
|
className="group block border border-border p-6 transition duration-300 ease-out hover:-translate-y-1 hover:border-accent sm:text-right"
|
||||||
>
|
>
|
||||||
<p className="font-display text-xs tracking-[0.3em] text-foreground-muted group-hover:text-accent transition-colors mb-2">
|
<p className="mb-2 flex items-center gap-2 font-display text-xs tracking-[0.3em] text-foreground-muted transition-colors group-hover:text-accent sm:justify-end">
|
||||||
OLDER →
|
OLDER
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="inline-block transition-transform duration-300 ease-out group-hover:translate-x-1"
|
||||||
|
>
|
||||||
|
→
|
||||||
|
</span>
|
||||||
</p>
|
</p>
|
||||||
<p className="font-display text-lg tracking-wider group-hover:text-accent transition-colors">
|
<p className="font-display text-lg tracking-wider group-hover:text-accent transition-colors">
|
||||||
{older.title.toUpperCase()}
|
{older.title.toUpperCase()}
|
||||||
@@ -220,6 +318,20 @@ export default async function DevblogPost({ params }: Props) {
|
|||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* A post used to end on its own navigation. This is the moment a
|
||||||
|
reader is most likely to want the next one. */}
|
||||||
|
<aside className="mt-16 rounded-2xl border border-border bg-surface p-8 lg:p-10">
|
||||||
|
<h2 className="font-display text-2xl tracking-wider sm:text-3xl">
|
||||||
|
GET THE NEXT ONE BY EMAIL
|
||||||
|
</h2>
|
||||||
|
<p className="mt-4 leading-relaxed text-foreground-muted">
|
||||||
|
One mail per major update. No launch countdowns, no noise.
|
||||||
|
</p>
|
||||||
|
<div className="mt-8">
|
||||||
|
<Newsletter variant="compact" />
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
|
|||||||
+84
-111
@@ -1,8 +1,13 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import { PageHeader } from "../components/ui/PageHeader";
|
||||||
|
import { Section } from "../components/ui/Section";
|
||||||
|
import { SectionHeading } from "../components/ui/SectionHeading";
|
||||||
|
import { PostList } from "../components/devblog/PostList";
|
||||||
|
import { PostFeature } from "../components/devblog/PostFeature";
|
||||||
|
import { Newsletter } from "../components/forms/Newsletter";
|
||||||
import { getAllPosts } from "@/lib/devblog";
|
import { getAllPosts } from "@/lib/devblog";
|
||||||
import { games as gameRegistry } from "@/lib/games";
|
import { games } from "@/lib/games";
|
||||||
import { Newsletter } from "../components/Newsletter";
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Devblog",
|
title: "Devblog",
|
||||||
@@ -12,14 +17,6 @@ export const metadata: Metadata = {
|
|||||||
|
|
||||||
const PAGE_SIZE = 10;
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
function formatDate(date: string) {
|
|
||||||
return new Date(date).toLocaleDateString("en-US", {
|
|
||||||
year: "numeric",
|
|
||||||
month: "long",
|
|
||||||
day: "numeric",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildHref(params: { game?: string; page?: number }) {
|
function buildHref(params: { game?: string; page?: number }) {
|
||||||
const search = new URLSearchParams();
|
const search = new URLSearchParams();
|
||||||
if (params.game) search.set("game", params.game);
|
if (params.game) search.set("game", params.game);
|
||||||
@@ -28,6 +25,13 @@ function buildHref(params: { game?: string; page?: number }) {
|
|||||||
return qs ? `/devblog?${qs}` : "/devblog";
|
return qs ? `/devblog?${qs}` : "/devblog";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const filterClass = (active: boolean) =>
|
||||||
|
`border px-3 py-1.5 text-xs uppercase tracking-widest transition duration-200 ease-out active:scale-95 ${
|
||||||
|
active
|
||||||
|
? "border-accent text-accent"
|
||||||
|
: "border-border text-foreground-muted hover:-translate-y-0.5 hover:border-accent hover:text-accent"
|
||||||
|
}`;
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
searchParams: Promise<{ game?: string; page?: string }>;
|
searchParams: Promise<{ game?: string; page?: string }>;
|
||||||
};
|
};
|
||||||
@@ -36,138 +40,103 @@ export default async function DevblogPage({ searchParams }: Props) {
|
|||||||
const { game, page } = await searchParams;
|
const { game, page } = await searchParams;
|
||||||
const allPosts = getAllPosts();
|
const allPosts = getAllPosts();
|
||||||
|
|
||||||
const usedGameSlugs = Array.from(
|
// Only offer a filter for games that actually have posts.
|
||||||
new Set(allPosts.map((p) => p.game).filter((g): g is string => Boolean(g))),
|
const gameOptions = Array.from(
|
||||||
);
|
new Set(allPosts.map((post) => post.game).filter(Boolean) as string[]),
|
||||||
const gameOptions = usedGameSlugs
|
)
|
||||||
.map((slug) => ({
|
.map((slug) => ({
|
||||||
slug,
|
slug,
|
||||||
title: gameRegistry.find((g) => g.slug === slug)?.title ?? slug,
|
title: games.find((g) => g.slug === slug)?.title ?? slug,
|
||||||
}))
|
}))
|
||||||
.sort((a, b) => a.title.localeCompare(b.title));
|
.sort((a, b) => a.title.localeCompare(b.title));
|
||||||
|
|
||||||
const filtered = allPosts.filter((p) => {
|
const filtered = game
|
||||||
if (game && p.game !== game) return false;
|
? allPosts.filter((post) => post.game === game)
|
||||||
return true;
|
: allPosts;
|
||||||
});
|
|
||||||
|
|
||||||
const currentPage = Math.max(1, parseInt(page ?? "1", 10) || 1);
|
|
||||||
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
|
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
|
||||||
|
const currentPage = Math.max(1, Number.parseInt(page ?? "1", 10) || 1);
|
||||||
const safePage = Math.min(currentPage, totalPages);
|
const safePage = Math.min(currentPage, totalPages);
|
||||||
const visible = filtered.slice(
|
const visible = filtered.slice(
|
||||||
(safePage - 1) * PAGE_SIZE,
|
(safePage - 1) * PAGE_SIZE,
|
||||||
safePage * PAGE_SIZE,
|
safePage * PAGE_SIZE,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// The newest post leads the page, but only where "newest" means anything:
|
||||||
|
// not on page two, and not inside a filtered view.
|
||||||
|
const featured = safePage === 1 && !game ? visible[0] : undefined;
|
||||||
|
const listed = featured ? visible.slice(1) : visible;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pt-32 pb-24 px-6 lg:px-10">
|
<>
|
||||||
<div className="max-w-4xl mx-auto">
|
<PageHeader
|
||||||
<p className="font-display text-sm tracking-[0.3em] text-accent mb-4">
|
eyebrow="DEVBLOG"
|
||||||
— DEVBLOG
|
width="narrow"
|
||||||
</p>
|
title={
|
||||||
<h1 className="font-display text-5xl sm:text-6xl lg:text-7xl tracking-wider leading-[0.95] mb-8">
|
<>
|
||||||
THE JOURNEY,
|
THE JOURNEY,
|
||||||
<br />
|
<br />
|
||||||
ONE STEP AT A TIME.
|
ONE STEP AT A TIME.
|
||||||
</h1>
|
</>
|
||||||
<p className="text-lg text-foreground-muted leading-relaxed max-w-2xl mb-12">
|
}
|
||||||
Honest updates from the studio. Design decisions, prototypes, art
|
lead="Honest updates from the studio: design decisions, prototypes, art milestones, and everything else that goes into building our worlds."
|
||||||
milestones everything that goes into building our worlds.
|
/>
|
||||||
</p>
|
|
||||||
|
|
||||||
{gameOptions.length > 0 && (
|
<Section top="none" width="narrow">
|
||||||
<div className="flex flex-wrap items-center gap-2 mb-12">
|
{/* One game means the filter offers "All" and "All, again". */}
|
||||||
<span className="font-display text-xs tracking-[0.3em] text-foreground-muted mr-2">
|
{gameOptions.length > 1 && (
|
||||||
|
<div className="mb-10 flex flex-wrap items-center gap-2">
|
||||||
|
<span className="mr-2 font-display text-xs tracking-[0.3em] text-foreground-muted">
|
||||||
GAME
|
GAME
|
||||||
</span>
|
</span>
|
||||||
<Link
|
<Link href={buildHref({})} className={filterClass(!game)}>
|
||||||
href={buildHref({})}
|
|
||||||
className={`text-xs tracking-widest uppercase border px-3 py-1.5 transition-colors ${
|
|
||||||
!game
|
|
||||||
? "border-accent text-accent"
|
|
||||||
: "border-border text-foreground-muted hover:border-accent hover:text-accent"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
All
|
All
|
||||||
</Link>
|
</Link>
|
||||||
{gameOptions.map((g) => (
|
{gameOptions.map((option) => (
|
||||||
<Link
|
<Link
|
||||||
key={g.slug}
|
key={option.slug}
|
||||||
href={buildHref({ game: g.slug })}
|
href={buildHref({ game: option.slug })}
|
||||||
className={`text-xs tracking-widest uppercase border px-3 py-1.5 transition-colors ${
|
className={filterClass(game === option.slug)}
|
||||||
game === g.slug
|
|
||||||
? "border-accent text-accent"
|
|
||||||
: "border-border text-foreground-muted hover:border-accent hover:text-accent"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{g.title}
|
{option.title}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{visible.length === 0 ? (
|
{visible.length === 0 && (
|
||||||
<p className="text-foreground-muted">
|
<p className="text-foreground-muted">
|
||||||
{game
|
{game
|
||||||
? "No posts match these filters yet."
|
? "No posts for this game yet."
|
||||||
: "No posts yet. Check back soon."}
|
: "No posts yet. Check back soon."}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
|
||||||
<div className="space-y-1">
|
|
||||||
{visible.map((post) => {
|
|
||||||
const postGame = post.game
|
|
||||||
? gameRegistry.find((g) => g.slug === post.game)?.title ??
|
|
||||||
post.game
|
|
||||||
: null;
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={post.slug}
|
|
||||||
href={`/devblog/${post.slug}`}
|
|
||||||
className="group block py-8 border-t border-border hover:border-accent transition-colors"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-baseline sm:justify-between gap-2 mb-3">
|
|
||||||
<h2 className="font-display text-2xl sm:text-3xl tracking-wider group-hover:text-accent transition-colors">
|
|
||||||
{post.title.toUpperCase()}
|
|
||||||
</h2>
|
|
||||||
<time
|
|
||||||
dateTime={post.date}
|
|
||||||
className="text-xs tracking-widest text-foreground-muted shrink-0"
|
|
||||||
>
|
|
||||||
{formatDate(post.date)}
|
|
||||||
</time>
|
|
||||||
</div>
|
|
||||||
{post.excerpt && (
|
|
||||||
<p className="text-foreground-muted leading-relaxed max-w-2xl">
|
|
||||||
{post.excerpt}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
{postGame && (
|
|
||||||
<div className="flex flex-wrap items-center gap-2 mt-4">
|
{featured && <PostFeature post={featured} eyebrow="Latest" as="h2" />}
|
||||||
<span className="text-[0.65rem] tracking-widest uppercase border border-accent/40 text-accent px-2 py-1">
|
|
||||||
{postGame}
|
{listed.length > 0 && (
|
||||||
</span>
|
<div className={featured ? "mt-12" : undefined}>
|
||||||
</div>
|
<PostList posts={listed} />
|
||||||
)}
|
|
||||||
<span className="inline-block mt-4 font-display text-xs tracking-widest text-foreground-muted group-hover:text-accent transition-colors">
|
|
||||||
READ →
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
<nav
|
<nav
|
||||||
aria-label="Pagination"
|
aria-label="Pagination"
|
||||||
className="flex items-center justify-between mt-12 pt-8 border-t border-border"
|
className="mt-12 flex items-center justify-between"
|
||||||
>
|
>
|
||||||
{safePage > 1 ? (
|
{safePage > 1 ? (
|
||||||
<Link
|
<Link
|
||||||
href={buildHref({ game, page: safePage - 1 })}
|
href={buildHref({ game, page: safePage - 1 })}
|
||||||
className="font-display text-xs tracking-widest text-foreground-muted hover:text-accent transition-colors"
|
className="group inline-flex items-center gap-2 font-display text-xs tracking-widest text-foreground-muted transition-colors hover:text-accent"
|
||||||
>
|
>
|
||||||
← NEWER
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="inline-block transition-transform duration-300 ease-out group-hover:-translate-x-1"
|
||||||
|
>
|
||||||
|
←
|
||||||
|
</span>
|
||||||
|
NEWER
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<span aria-hidden />
|
<span aria-hidden />
|
||||||
@@ -178,29 +147,33 @@ export default async function DevblogPage({ searchParams }: Props) {
|
|||||||
{safePage < totalPages ? (
|
{safePage < totalPages ? (
|
||||||
<Link
|
<Link
|
||||||
href={buildHref({ game, page: safePage + 1 })}
|
href={buildHref({ game, page: safePage + 1 })}
|
||||||
className="font-display text-xs tracking-widest text-foreground-muted hover:text-accent transition-colors"
|
className="group inline-flex items-center gap-2 font-display text-xs tracking-widest text-foreground-muted transition-colors hover:text-accent"
|
||||||
>
|
>
|
||||||
OLDER →
|
OLDER
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="inline-block transition-transform duration-300 ease-out group-hover:translate-x-1"
|
||||||
|
>
|
||||||
|
→
|
||||||
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<span aria-hidden />
|
<span aria-hidden />
|
||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
)}
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
<section className="mt-24 border-t border-border pt-16">
|
<Section divider width="narrow">
|
||||||
<p className="font-display text-xs tracking-[0.3em] text-accent mb-3">
|
<SectionHeading
|
||||||
— NEWSLETTER
|
eyebrow="NEWSLETTER"
|
||||||
</p>
|
title="DON'T MISS A SUMMIT"
|
||||||
<h2 className="font-display text-3xl sm:text-4xl tracking-wider mb-4">
|
lead="Subscribe to get devblog highlights and release news in your inbox."
|
||||||
DON'T MISS A SUMMIT
|
/>
|
||||||
</h2>
|
<div className="mt-10">
|
||||||
<p className="text-foreground-muted leading-relaxed max-w-2xl mb-6">
|
|
||||||
Subscribe to get devblog highlights and release news in your inbox.
|
|
||||||
</p>
|
|
||||||
<Newsletter variant="compact" />
|
<Newsletter variant="compact" />
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</Section>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-17
@@ -1,8 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import Image from "next/image";
|
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
import { Logo } from "./components/ui/Logo";
|
||||||
|
import { ButtonLink } from "./components/ui/ButtonLink";
|
||||||
|
|
||||||
export default function Error({
|
export default function Error({
|
||||||
error,
|
error,
|
||||||
@@ -36,18 +36,12 @@ export default function Error({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="relative z-10 max-w-2xl w-full text-center pt-24 pb-12">
|
<div className="relative z-10 max-w-2xl w-full text-center pt-24 pb-12">
|
||||||
<Image
|
<Logo width={120} className="mx-auto mb-10 opacity-40" />
|
||||||
src="/image/Studio/Logo Flat.png"
|
|
||||||
alt=""
|
|
||||||
width={120}
|
|
||||||
height={80}
|
|
||||||
className="mx-auto mb-10 opacity-40"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<p className="font-display text-sm tracking-[0.3em] text-accent mb-4">
|
<p className="font-display text-sm tracking-[0.3em] text-accent mb-4">
|
||||||
ERROR
|
ERROR
|
||||||
</p>
|
</p>
|
||||||
<h1 className="font-display text-5xl sm:text-6xl lg:text-7xl tracking-wider leading-[0.95] mb-8">
|
<h1 className="font-display text-4xl sm:text-6xl lg:text-7xl tracking-wider leading-[0.95] mb-8">
|
||||||
AVALANCHE
|
AVALANCHE
|
||||||
<br />
|
<br />
|
||||||
ON THE PATH.
|
ON THE PATH.
|
||||||
@@ -62,20 +56,17 @@ export default function Error({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row gap-4 justify-center mt-8">
|
<div className="mt-8 flex flex-col items-center gap-4 sm:flex-row sm:justify-center">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={reset}
|
onClick={reset}
|
||||||
className="px-8 py-3 bg-accent text-background font-display tracking-widest text-sm hover:bg-foreground transition-colors"
|
className="sheen relative w-full overflow-hidden bg-accent px-8 py-3 font-display text-sm tracking-widest text-background transition duration-200 ease-out hover:-translate-y-0.5 hover:bg-foreground hover:shadow-[0_14px_30px_-14px_rgba(196,184,150,0.6)] active:scale-[0.97] sm:w-auto"
|
||||||
>
|
>
|
||||||
TRY AGAIN
|
TRY AGAIN
|
||||||
</button>
|
</button>
|
||||||
<Link
|
<ButtonLink href="/" variant="outline" block>
|
||||||
href="/"
|
|
||||||
className="px-8 py-3 border border-border text-foreground font-display tracking-widest text-sm hover:border-accent hover:text-accent transition-colors"
|
|
||||||
>
|
|
||||||
BACK TO BASE CAMP
|
BACK TO BASE CAMP
|
||||||
</Link>
|
</ButtonLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+130
-145
@@ -1,41 +1,45 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import { Section } from "../../components/ui/Section";
|
||||||
|
import { SectionHeading } from "../../components/ui/SectionHeading";
|
||||||
|
import { ButtonLink } from "../../components/ui/ButtonLink";
|
||||||
|
import { Reveal } from "../../components/ui/Reveal";
|
||||||
|
import { GameHero } from "../../components/game/GameHero";
|
||||||
|
import { GamePillars } from "../../components/game/GamePillars";
|
||||||
|
import { CoopBand } from "../../components/game/CoopBand";
|
||||||
|
import { ScreenshotGallery } from "../../components/game/ScreenshotGallery";
|
||||||
|
import { PostList } from "../../components/devblog/PostList";
|
||||||
|
import { Newsletter } from "../../components/forms/Newsletter";
|
||||||
import { games, getGame } from "@/lib/games";
|
import { games, getGame } from "@/lib/games";
|
||||||
import { getPostsByGame } from "@/lib/devblog";
|
import { getPostsByGame } from "@/lib/devblog";
|
||||||
import { SITE_URL } from "@/lib/site";
|
import { SITE_URL, STUDIO } from "@/lib/site";
|
||||||
|
|
||||||
function formatDate(date: string) {
|
|
||||||
return new Date(date).toLocaleDateString("en-US", {
|
|
||||||
year: "numeric",
|
|
||||||
month: "long",
|
|
||||||
day: "numeric",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
type Props = { params: Promise<{ slug: string }> };
|
type Props = { params: Promise<{ slug: string }> };
|
||||||
|
|
||||||
export function generateStaticParams() {
|
export function generateStaticParams() {
|
||||||
return games.map((g) => ({ slug: g.slug }));
|
return games.map((game) => ({ slug: game.slug }));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
const game = getGame(slug);
|
const game = getGame(slug);
|
||||||
if (!game) return {};
|
if (!game) return {};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: game.title,
|
title: game.title,
|
||||||
description: game.tagline,
|
description: game.tagline,
|
||||||
|
alternates: { canonical: `${SITE_URL}/games/${game.slug}` },
|
||||||
|
openGraph: {
|
||||||
|
type: "website",
|
||||||
|
title: game.title,
|
||||||
|
description: game.tagline,
|
||||||
|
url: `${SITE_URL}/games/${game.slug}`,
|
||||||
|
images: game.keyArt ? [`${SITE_URL}${game.keyArt}`] : undefined,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusLabel: Record<string, string> = {
|
|
||||||
"in-development": "IN DEVELOPMENT",
|
|
||||||
released: "RELEASED",
|
|
||||||
"coming-soon": "COMING SOON",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async function GamePage({ params }: Props) {
|
export default async function GamePage({ params }: Props) {
|
||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
const game = getGame(slug);
|
const game = getGame(slug);
|
||||||
@@ -43,167 +47,148 @@ export default async function GamePage({ params }: Props) {
|
|||||||
|
|
||||||
const relatedPosts = getPostsByGame(slug).slice(0, 3);
|
const relatedPosts = getPostsByGame(slug).slice(0, 3);
|
||||||
|
|
||||||
|
// "1-6" → { minValue: 1, maxValue: 6 }, so search engines get the player
|
||||||
|
// count as data rather than as a string buried in the description.
|
||||||
|
const [minPlayers, maxPlayers] = (game.players ?? "")
|
||||||
|
.split(/[-–]/)
|
||||||
|
.map((part) => Number(part.trim()));
|
||||||
|
|
||||||
const jsonLd = {
|
const jsonLd = {
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "VideoGame",
|
"@type": "VideoGame",
|
||||||
name: game.title,
|
name: game.title,
|
||||||
description: game.description,
|
description: game.description,
|
||||||
genre: game.genres,
|
genre: game.genres,
|
||||||
|
gamePlatform: game.platform,
|
||||||
|
numberOfPlayers: minPlayers
|
||||||
|
? {
|
||||||
|
"@type": "QuantitativeValue",
|
||||||
|
minValue: minPlayers,
|
||||||
|
maxValue: maxPlayers ?? minPlayers,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
playMode:
|
||||||
|
maxPlayers && maxPlayers > 1
|
||||||
|
? ["SinglePlayer", "CoOp"]
|
||||||
|
: minPlayers
|
||||||
|
? "SinglePlayer"
|
||||||
|
: undefined,
|
||||||
publisher: {
|
publisher: {
|
||||||
"@type": "Organization",
|
"@type": "Organization",
|
||||||
name: "Highland Games Studio",
|
name: STUDIO.name,
|
||||||
url: SITE_URL,
|
url: SITE_URL,
|
||||||
},
|
},
|
||||||
url: `${SITE_URL}/games/${game.slug}`,
|
url: `${SITE_URL}/games/${game.slug}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="pt-24 pb-24">
|
<article>
|
||||||
<script
|
<script
|
||||||
type="application/ld+json"
|
type="application/ld+json"
|
||||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||||
/>
|
/>
|
||||||
{/* HERO */}
|
|
||||||
<section className="relative aspect-[21/9] sm:aspect-[21/8] bg-surface border-b border-border overflow-hidden">
|
|
||||||
{game.cover ? (
|
|
||||||
<Image
|
|
||||||
src={game.cover}
|
|
||||||
alt={game.title}
|
|
||||||
fill
|
|
||||||
priority
|
|
||||||
sizes="100vw"
|
|
||||||
className="object-cover"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
|
||||||
<Image
|
|
||||||
src="/image/Studio/Logo Flat.png"
|
|
||||||
alt=""
|
|
||||||
width={220}
|
|
||||||
height={148}
|
|
||||||
className="opacity-20"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-background via-background/40 to-transparent" />
|
|
||||||
<div className="absolute bottom-0 left-0 right-0 px-6 lg:px-10 pb-10">
|
|
||||||
<div className="max-w-6xl mx-auto">
|
|
||||||
<p className="font-display text-xs tracking-[0.3em] text-accent mb-2">
|
|
||||||
{statusLabel[game.status]}
|
|
||||||
</p>
|
|
||||||
<h1 className="font-display text-5xl sm:text-7xl lg:text-8xl tracking-wider leading-[0.95]">
|
|
||||||
{game.title.toUpperCase()}
|
|
||||||
</h1>
|
|
||||||
<p className="mt-4 text-lg sm:text-xl text-foreground-muted max-w-2xl">
|
|
||||||
{game.tagline}
|
|
||||||
</p>
|
|
||||||
{game.steamUrl && (
|
|
||||||
<a
|
|
||||||
href={game.steamUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="inline-block mt-6 px-8 py-3 bg-accent text-background font-display tracking-widest text-sm hover:bg-foreground hover:scale-[1.03] transition duration-200"
|
|
||||||
>
|
|
||||||
WISHLIST ON STEAM →
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* CONTENT */}
|
<GameHero game={game} />
|
||||||
<div className="max-w-6xl mx-auto px-6 lg:px-10 mt-16 grid lg:grid-cols-3 gap-12">
|
|
||||||
<div className="lg:col-span-2">
|
{/* ABOUT — the pitch gets its own reading column, the key art sits
|
||||||
<h2 className="font-display text-3xl tracking-wider mb-6">ABOUT</h2>
|
beside it at its real ratio instead of being stretched to match. */}
|
||||||
<p className="text-foreground-muted leading-relaxed text-lg">
|
<Section>
|
||||||
|
<div className="grid items-center gap-10 lg:grid-cols-2 lg:gap-16">
|
||||||
|
<Reveal>
|
||||||
|
<SectionHeading eyebrow="THE GAME" title="ABOUT" />
|
||||||
|
<p className="mt-8 text-base leading-relaxed text-foreground-muted sm:text-lg">
|
||||||
{game.description}
|
{game.description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</Reveal>
|
||||||
|
|
||||||
<aside className="space-y-8">
|
{game.keyArt && (
|
||||||
<div>
|
<Reveal delay={0.1}>
|
||||||
<h3 className="font-display text-xs tracking-[0.3em] text-accent mb-3">
|
<div className="relative aspect-video w-full overflow-hidden rounded-2xl border border-border bg-surface-elevated">
|
||||||
GENRES
|
<Image
|
||||||
</h3>
|
src={game.keyArt}
|
||||||
<div className="flex flex-wrap gap-2">
|
alt={`${game.title} key art`}
|
||||||
{game.genres.map((g) => (
|
fill
|
||||||
<span
|
sizes="(max-width: 1024px) 100vw, 45vw"
|
||||||
key={g}
|
className="object-cover"
|
||||||
className="text-xs tracking-widest uppercase border border-border px-3 py-1.5"
|
/>
|
||||||
>
|
|
||||||
{g}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
{game.releaseWindow && (
|
{/* PILLARS — what you actually do */}
|
||||||
<div>
|
<GamePillars pillars={game.pillars} />
|
||||||
<h3 className="font-display text-xs tracking-[0.3em] text-accent mb-3">
|
|
||||||
RELEASE
|
{game.players && <CoopBand players={game.players} />}
|
||||||
</h3>
|
|
||||||
<p className="text-foreground">{game.releaseWindow}</p>
|
{/* SCREENSHOTS */}
|
||||||
|
{game.screenshots.length > 0 && (
|
||||||
|
<Section id="gallery" divider width="wide">
|
||||||
|
<Reveal>
|
||||||
|
<SectionHeading
|
||||||
|
eyebrow="GALLERY"
|
||||||
|
title="SCREENSHOTS"
|
||||||
|
lead="Captured in engine during development. Everything is subject to change."
|
||||||
|
/>
|
||||||
|
</Reveal>
|
||||||
|
{/* No `Reveal` here: the gallery deals its own thumbnails out one by
|
||||||
|
one, and fading the whole grid in first would animate the same
|
||||||
|
pixels twice. */}
|
||||||
|
<div className="mt-12">
|
||||||
|
<ScreenshotGallery screenshots={game.screenshots} />
|
||||||
</div>
|
</div>
|
||||||
|
</Section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{game.steamUrl && (
|
{/* DEVBLOG */}
|
||||||
<Link
|
|
||||||
href={game.steamUrl}
|
|
||||||
className="block px-6 py-3 bg-accent text-background font-display tracking-widest text-sm text-center hover:bg-foreground hover:scale-[1.03] transition duration-200"
|
|
||||||
>
|
|
||||||
WISHLIST ON STEAM
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{relatedPosts.length > 0 && (
|
{relatedPosts.length > 0 && (
|
||||||
<section className="max-w-6xl mx-auto px-6 lg:px-10 mt-24">
|
<Section divider>
|
||||||
<div className="flex items-baseline justify-between mb-8">
|
<Reveal>
|
||||||
<div>
|
<SectionHeading
|
||||||
<p className="font-display text-xs tracking-[0.3em] text-accent mb-2">
|
eyebrow="DEVBLOG"
|
||||||
— DEVBLOG
|
title="LATEST ARTICLES"
|
||||||
</p>
|
action={
|
||||||
<h2 className="font-display text-3xl sm:text-4xl tracking-wider">
|
<ButtonLink href="/devblog" variant="outline">
|
||||||
LATEST ARTICLES
|
ALL POSTS
|
||||||
</h2>
|
</ButtonLink>
|
||||||
</div>
|
}
|
||||||
<Link
|
/>
|
||||||
href="/devblog"
|
</Reveal>
|
||||||
className="hidden sm:inline-block font-display text-xs tracking-widest text-foreground-muted hover:text-accent transition-colors"
|
<Reveal delay={0.1} className="mt-12">
|
||||||
>
|
<PostList posts={relatedPosts} as="h3" showGame={false} />
|
||||||
ALL POSTS →
|
</Reveal>
|
||||||
</Link>
|
</Section>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
<div className="space-y-1">
|
{/* CLOSING — the page used to end on a list of links. A game in
|
||||||
{relatedPosts.map((post) => (
|
development needs one clear ask before the footer. */}
|
||||||
<Link
|
<div className="relative isolate overflow-hidden border-t border-border">
|
||||||
key={post.slug}
|
<div
|
||||||
href={`/devblog/${post.slug}`}
|
aria-hidden
|
||||||
className="group block py-6 border-t border-border hover:border-accent transition-colors"
|
className="absolute inset-x-0 top-0 -z-10 h-[70%] bg-[radial-gradient(70%_100%_at_50%_0%,rgba(196,184,150,0.09),transparent_70%)]"
|
||||||
>
|
/>
|
||||||
<div className="flex flex-col sm:flex-row sm:items-baseline sm:justify-between gap-2 mb-2">
|
<Section width="narrow">
|
||||||
<h3 className="font-display text-xl sm:text-2xl tracking-wider group-hover:text-accent transition-colors">
|
<Reveal>
|
||||||
{post.title.toUpperCase()}
|
<SectionHeading
|
||||||
</h3>
|
eyebrow="STAY CLOSE"
|
||||||
<time
|
title={`FOLLOW ${game.title.toUpperCase()}`}
|
||||||
dateTime={post.date}
|
lead="One mail per major update, covering new builds, milestones and the occasional mistake. Nothing else."
|
||||||
className="text-xs tracking-widest text-foreground-muted shrink-0"
|
align="center"
|
||||||
>
|
/>
|
||||||
{formatDate(post.date)}
|
<div className="mt-10 flex justify-center">
|
||||||
</time>
|
<Newsletter />
|
||||||
</div>
|
</div>
|
||||||
{post.excerpt && (
|
{game.steamUrl && (
|
||||||
<p className="text-foreground-muted leading-relaxed max-w-2xl">
|
<div className="mt-10 flex justify-center">
|
||||||
{post.excerpt}
|
<ButtonLink href={game.steamUrl} variant="outline">
|
||||||
</p>
|
WISHLIST ON STEAM
|
||||||
)}
|
</ButtonLink>
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
)}
|
)}
|
||||||
|
</Reveal>
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-56
@@ -1,6 +1,8 @@
|
|||||||
import Link from "next/link";
|
|
||||||
import Image from "next/image";
|
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import { PageHeader } from "../components/ui/PageHeader";
|
||||||
|
import { Section } from "../components/ui/Section";
|
||||||
|
import { GameCard } from "../components/game/GameCard";
|
||||||
|
import { Stagger, StaggerItem } from "../components/ui/Stagger";
|
||||||
import { games } from "@/lib/games";
|
import { games } from "@/lib/games";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -8,64 +10,30 @@ export const metadata: Metadata = {
|
|||||||
description: "Games crafted by Highland Games Studio.",
|
description: "Games crafted by Highland Games Studio.",
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusLabel: Record<string, string> = {
|
|
||||||
"in-development": "IN DEVELOPMENT",
|
|
||||||
released: "RELEASED",
|
|
||||||
"coming-soon": "COMING SOON",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function GamesPage() {
|
export default function GamesPage() {
|
||||||
return (
|
return (
|
||||||
<div className="pt-32 pb-24 px-6 lg:px-10">
|
<>
|
||||||
<div className="max-w-6xl mx-auto">
|
<PageHeader
|
||||||
<p className="font-display text-sm tracking-[0.3em] text-accent mb-4">
|
eyebrow="OUR WORLDS"
|
||||||
— OUR WORLDS
|
title="GAMES"
|
||||||
</p>
|
lead="Every project we ship is a different summit. Here is what we are climbing right now."
|
||||||
<h1 className="font-display text-5xl sm:text-6xl lg:text-7xl tracking-wider leading-[0.95] mb-16">
|
/>
|
||||||
GAMES
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<div className="grid gap-8">
|
<Section top="none">
|
||||||
{games.map((game) => (
|
{games.length === 0 ? (
|
||||||
<Link
|
<p className="text-foreground-muted">
|
||||||
key={game.slug}
|
Nothing announced yet. Follow the devblog to hear it first.
|
||||||
href={`/games/${game.slug}`}
|
</p>
|
||||||
className="group relative aspect-[16/9] sm:aspect-[21/9] bg-surface border border-border overflow-hidden block hover:border-accent transition-colors"
|
|
||||||
>
|
|
||||||
{game.cover ? (
|
|
||||||
<Image
|
|
||||||
src={game.cover}
|
|
||||||
alt={game.title}
|
|
||||||
fill
|
|
||||||
sizes="(max-width: 1024px) 100vw, 1024px"
|
|
||||||
className="object-cover group-hover:scale-105 transition-transform duration-700"
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
<Stagger as="ul" step={0.12} className="grid gap-8">
|
||||||
<Image
|
{games.map((game) => (
|
||||||
src="/image/Studio/Logo Flat.png"
|
<StaggerItem as="li" key={game.slug}>
|
||||||
alt=""
|
<GameCard game={game} />
|
||||||
width={140}
|
</StaggerItem>
|
||||||
height={94}
|
|
||||||
className="opacity-15 group-hover:opacity-25 group-hover:scale-105 transition-all duration-700"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="absolute bottom-0 left-0 right-0 p-8 bg-gradient-to-t from-background via-background/80 to-transparent">
|
|
||||||
<p className="font-display text-xs tracking-[0.3em] text-accent mb-2">
|
|
||||||
{statusLabel[game.status]}
|
|
||||||
</p>
|
|
||||||
<h2 className="font-display text-3xl sm:text-4xl tracking-wider mb-3">
|
|
||||||
{game.title.toUpperCase()}
|
|
||||||
</h2>
|
|
||||||
<p className="text-foreground-muted max-w-2xl">
|
|
||||||
{game.tagline}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</Stagger>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</Section>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+132
@@ -8,6 +8,12 @@
|
|||||||
--surface-elevated: #1c1c1c;
|
--surface-elevated: #1c1c1c;
|
||||||
--border: #2a2a2a;
|
--border: #2a2a2a;
|
||||||
--accent: #c4b896;
|
--accent: #c4b896;
|
||||||
|
|
||||||
|
/* Two curves for the whole site. `expo` for anything that enters or leaves
|
||||||
|
(it lands hard, then settles), `soft` for hover states that must feel
|
||||||
|
immediate. Framer Motion uses the same values as arrays. */
|
||||||
|
--ease-out-expo: cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
--ease-out-soft: cubic-bezier(0.33, 1, 0.68, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
@@ -28,6 +34,15 @@
|
|||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* In-page anchors must clear the fixed navbar. */
|
||||||
|
[id] {
|
||||||
|
scroll-margin-top: 6rem;
|
||||||
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
body {
|
body {
|
||||||
background: var(--background);
|
background: var(--background);
|
||||||
@@ -35,6 +50,10 @@ body {
|
|||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
text-wrap: pretty;
|
||||||
|
}
|
||||||
|
|
||||||
::selection {
|
::selection {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: var(--background);
|
color: var(--background);
|
||||||
@@ -81,3 +100,116 @@ body {
|
|||||||
.animate-orb-pulse {
|
.animate-orb-pulse {
|
||||||
animation: orb-pulse 6s ease-in-out infinite;
|
animation: orb-pulse 6s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
Micro-interactions
|
||||||
|
Four primitives Tailwind cannot express in a utility. Everything else
|
||||||
|
(lift, press, arrow nudge) stays inline as `hover:` / `active:` classes.
|
||||||
|
--------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
/* A single light sweep across a filled button on hover. Transition rather than
|
||||||
|
an animation, so moving the pointer away reverses it instead of finishing. */
|
||||||
|
.sheen::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translateX(-135%) skewX(-18deg);
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent,
|
||||||
|
rgba(255, 255, 255, 0.35),
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
transition: transform 750ms var(--ease-out-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheen:hover::after,
|
||||||
|
.sheen:focus-visible::after {
|
||||||
|
transform: translateX(135%) skewX(-18deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pointer-following glow on cards. `Spotlight` writes the two custom
|
||||||
|
properties; the paint is entirely CSS, so it costs nothing when idle. */
|
||||||
|
.spotlight {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 400ms var(--ease-out-soft);
|
||||||
|
background: radial-gradient(
|
||||||
|
20rem circle at var(--spot-x, 50%) var(--spot-y, 50%),
|
||||||
|
rgba(196, 184, 150, 0.1),
|
||||||
|
transparent 68%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Only where a pointer exists: on a touch screen `:hover` sticks after a tap
|
||||||
|
and the glow would stay lit on the last card the visitor touched. */
|
||||||
|
@media (hover: hover) {
|
||||||
|
.group:hover > .spotlight,
|
||||||
|
.group:focus-visible > .spotlight {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Underline drawn from the left. Used for text links that have no other
|
||||||
|
hover affordance than a colour change. */
|
||||||
|
.underline-grow {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.underline-grow::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
bottom: -2px;
|
||||||
|
width: 100%;
|
||||||
|
height: 1px;
|
||||||
|
background: currentColor;
|
||||||
|
transform: scaleX(0);
|
||||||
|
transform-origin: left;
|
||||||
|
transition: transform 300ms var(--ease-out-expo);
|
||||||
|
}
|
||||||
|
|
||||||
|
.underline-grow:hover::after,
|
||||||
|
.underline-grow:focus-visible::after {
|
||||||
|
transform: scaleX(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Rejected-submission feedback. Short and small — a form that jumps reads as
|
||||||
|
broken rather than as invalid. */
|
||||||
|
@keyframes shake {
|
||||||
|
20% {
|
||||||
|
transform: translateX(-5px);
|
||||||
|
}
|
||||||
|
40% {
|
||||||
|
transform: translateX(5px);
|
||||||
|
}
|
||||||
|
60% {
|
||||||
|
transform: translateX(-3px);
|
||||||
|
}
|
||||||
|
80% {
|
||||||
|
transform: translateX(3px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-shake {
|
||||||
|
animation: shake 420ms var(--ease-out-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Framer Motion honours the preference on its own (`useReducedMotion`);
|
||||||
|
this covers the CSS-only animations and transitions. */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
html {
|
||||||
|
scroll-behavior: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+21
-21
@@ -1,9 +1,10 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Bebas_Neue, Inter } from "next/font/google";
|
import { Bebas_Neue, Inter } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { Navbar } from "./components/Navbar";
|
import { Navbar } from "./components/layout/Navbar";
|
||||||
import { Footer } from "./components/Footer";
|
import { Footer } from "./components/layout/Footer";
|
||||||
import { SITE_URL, LOGO_URL } from "@/lib/site";
|
import { BackToTop } from "./components/ui/BackToTop";
|
||||||
|
import { CONTACT_CHANNELS, LOGO_URL, SITE_URL, STUDIO } from "@/lib/site";
|
||||||
|
|
||||||
const bebas = Bebas_Neue({
|
const bebas = Bebas_Neue({
|
||||||
variable: "--font-bebas",
|
variable: "--font-bebas",
|
||||||
@@ -18,27 +19,26 @@ const inter = Inter({
|
|||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: {
|
title: {
|
||||||
default: "Highland Games Studio — Where summits become worlds",
|
default: `${STUDIO.name} — Where summits become worlds`,
|
||||||
template: "%s — Highland Games Studio",
|
template: `%s — ${STUDIO.name}`,
|
||||||
},
|
},
|
||||||
description:
|
description: STUDIO.description,
|
||||||
"Indie game studio crafting survival adventures. Where summits become worlds.",
|
|
||||||
metadataBase: new URL(SITE_URL),
|
metadataBase: new URL(SITE_URL),
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: "Highland Games Studio",
|
title: STUDIO.name,
|
||||||
description: "Where summits become worlds.",
|
description: STUDIO.tagline,
|
||||||
type: "website",
|
type: "website",
|
||||||
siteName: "Highland Games Studio",
|
siteName: STUDIO.name,
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
card: "summary_large_image",
|
card: "summary_large_image",
|
||||||
title: "Highland Games Studio",
|
title: STUDIO.name,
|
||||||
description: "Where summits become worlds.",
|
description: STUDIO.tagline,
|
||||||
},
|
},
|
||||||
alternates: {
|
alternates: {
|
||||||
types: {
|
types: {
|
||||||
"application/rss+xml": [
|
"application/rss+xml": [
|
||||||
{ url: "/feed.xml", title: "Highland Games Studio — Devblog" },
|
{ url: "/feed.xml", title: `${STUDIO.name} — Devblog` },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -47,14 +47,13 @@ export const metadata: Metadata = {
|
|||||||
const organizationJsonLd = {
|
const organizationJsonLd = {
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "Organization",
|
"@type": "Organization",
|
||||||
name: "Highland Games Studio",
|
name: STUDIO.name,
|
||||||
url: SITE_URL,
|
url: SITE_URL,
|
||||||
logo: LOGO_URL,
|
logo: LOGO_URL,
|
||||||
description:
|
description: STUDIO.description,
|
||||||
"Indie game studio crafting survival adventures. Where summits become worlds.",
|
|
||||||
slogan: "Where summits become worlds",
|
slogan: "Where summits become worlds",
|
||||||
foundingDate: "2026",
|
foundingDate: STUDIO.foundingDate,
|
||||||
email: "hello@highlandgamesstudio.com",
|
email: CONTACT_CHANNELS[0].email,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@@ -67,18 +66,19 @@ export default function RootLayout({
|
|||||||
lang="en"
|
lang="en"
|
||||||
className={`${bebas.variable} ${inter.variable} h-full antialiased`}
|
className={`${bebas.variable} ${inter.variable} h-full antialiased`}
|
||||||
>
|
>
|
||||||
<body className="min-h-full flex flex-col bg-background text-foreground">
|
<body className="flex min-h-full flex-col bg-background text-foreground">
|
||||||
<a
|
<a
|
||||||
href="#main"
|
href="#main"
|
||||||
className="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-[100] focus:px-4 focus:py-2 focus:bg-accent focus:text-background focus:font-display focus:tracking-widest focus:text-sm"
|
className="sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-[100] focus:bg-accent focus:px-4 focus:py-2 focus:font-display focus:text-sm focus:tracking-widest focus:text-background"
|
||||||
>
|
>
|
||||||
Skip to content
|
Skip to content
|
||||||
</a>
|
</a>
|
||||||
<Navbar />
|
<Navbar />
|
||||||
<main id="main" className="flex-1 flex flex-col">
|
<main id="main" className="flex-1">
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
|
<BackToTop />
|
||||||
<script
|
<script
|
||||||
type="application/ld+json"
|
type="application/ld+json"
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export default function LegalNoticePage() {
|
|||||||
<div className="pt-32 pb-24 px-6 lg:px-10">
|
<div className="pt-32 pb-24 px-6 lg:px-10">
|
||||||
<div className="max-w-3xl mx-auto">
|
<div className="max-w-3xl mx-auto">
|
||||||
<p className="font-display text-sm tracking-[0.3em] text-accent mb-4">
|
<p className="font-display text-sm tracking-[0.3em] text-accent mb-4">
|
||||||
— LEGAL
|
LEGAL
|
||||||
</p>
|
</p>
|
||||||
<h1 className="font-display text-5xl sm:text-6xl tracking-wider leading-[0.95] mb-12">
|
<h1 className="font-display text-5xl sm:text-6xl tracking-wider leading-[0.95] mb-12">
|
||||||
LEGAL NOTICE
|
LEGAL NOTICE
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export default function PrivacyPage() {
|
|||||||
<div className="pt-32 pb-24 px-6 lg:px-10">
|
<div className="pt-32 pb-24 px-6 lg:px-10">
|
||||||
<div className="max-w-3xl mx-auto">
|
<div className="max-w-3xl mx-auto">
|
||||||
<p className="font-display text-sm tracking-[0.3em] text-accent mb-4">
|
<p className="font-display text-sm tracking-[0.3em] text-accent mb-4">
|
||||||
— LEGAL
|
LEGAL
|
||||||
</p>
|
</p>
|
||||||
<h1 className="font-display text-5xl sm:text-6xl tracking-wider leading-[0.95] mb-6">
|
<h1 className="font-display text-5xl sm:text-6xl tracking-wider leading-[0.95] mb-6">
|
||||||
PRIVACY POLICY
|
PRIVACY POLICY
|
||||||
|
|||||||
+10
-22
@@ -1,10 +1,10 @@
|
|||||||
import Link from "next/link";
|
|
||||||
import Image from "next/image";
|
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import { Logo } from "./components/ui/Logo";
|
||||||
|
import { ButtonLink } from "./components/ui/ButtonLink";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Lost on the climb",
|
title: "Lost on the climb",
|
||||||
description: "This summit doesn't exist — yet.",
|
description: "This summit doesn't exist yet.",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function NotFound() {
|
export default function NotFound() {
|
||||||
@@ -31,18 +31,12 @@ export default function NotFound() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="relative z-10 max-w-2xl w-full text-center pt-24 pb-12">
|
<div className="relative z-10 max-w-2xl w-full text-center pt-24 pb-12">
|
||||||
<Image
|
<Logo width={120} className="mx-auto mb-10 opacity-40" />
|
||||||
src="/image/Studio/Logo Flat.png"
|
|
||||||
alt=""
|
|
||||||
width={120}
|
|
||||||
height={80}
|
|
||||||
className="mx-auto mb-10 opacity-40"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<p className="font-display text-sm tracking-[0.3em] text-accent mb-4">
|
<p className="font-display text-sm tracking-[0.3em] text-accent mb-4">
|
||||||
404
|
404
|
||||||
</p>
|
</p>
|
||||||
<h1 className="font-display text-5xl sm:text-6xl lg:text-7xl tracking-wider leading-[0.95] mb-8">
|
<h1 className="font-display text-4xl sm:text-6xl lg:text-7xl tracking-wider leading-[0.95] mb-8">
|
||||||
LOST ON
|
LOST ON
|
||||||
<br />
|
<br />
|
||||||
THE CLIMB.
|
THE CLIMB.
|
||||||
@@ -52,19 +46,13 @@ export default function NotFound() {
|
|||||||
may have drifted off the map.
|
may have drifted off the map.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
<div className="flex flex-col items-center gap-4 sm:flex-row sm:justify-center">
|
||||||
<Link
|
<ButtonLink href="/" block>
|
||||||
href="/"
|
|
||||||
className="px-8 py-3 bg-accent text-background font-display tracking-widest text-sm hover:bg-foreground transition-colors"
|
|
||||||
>
|
|
||||||
BACK TO BASE CAMP
|
BACK TO BASE CAMP
|
||||||
</Link>
|
</ButtonLink>
|
||||||
<Link
|
<ButtonLink href="/games" variant="outline" block>
|
||||||
href="/games"
|
|
||||||
className="px-8 py-3 border border-border text-foreground font-display tracking-widest text-sm hover:border-accent hover:text-accent transition-colors"
|
|
||||||
>
|
|
||||||
EXPLORE OUR GAMES
|
EXPLORE OUR GAMES
|
||||||
</Link>
|
</ButtonLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export default async function OpengraphImage() {
|
|||||||
display: "flex",
|
display: "flex",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
— HIGHLAND GAMES STUDIO
|
HIGHLAND GAMES STUDIO
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|||||||
+27
-353
@@ -1,364 +1,38 @@
|
|||||||
"use client";
|
import { HomeHero } from "./components/home/HomeHero";
|
||||||
|
import { Statement } from "./components/home/Statement";
|
||||||
import Image from "next/image";
|
import { FeaturedGame } from "./components/home/FeaturedGame";
|
||||||
import Link from "next/link";
|
import { StudioIntro } from "./components/home/StudioIntro";
|
||||||
import { motion } from "framer-motion";
|
import { Dispatch } from "./components/home/Dispatch";
|
||||||
import { games } from "@/lib/games";
|
import { featuredGame } from "@/lib/games";
|
||||||
|
import { getAllPosts } from "@/lib/devblog";
|
||||||
const featured = games[0];
|
|
||||||
|
|
||||||
// Screenshots WIP du projet.
|
|
||||||
// Pour en ajouter : dépose l'image dans /public/image/Emberwild/
|
|
||||||
// puis renseigne `src` (ex: "/image/Emberwild/screen-01.jpg").
|
|
||||||
// Laisse `src: null` pour afficher un emplacement placeholder.
|
|
||||||
const screenshots: { src: string | null; label: string; span: string }[] = [
|
|
||||||
{
|
|
||||||
src: "/image/Games/Emberwild/Capture_1.png",
|
|
||||||
label: "In-game",
|
|
||||||
span: "col-span-2 row-span-2",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
src: "/image/Games/Emberwild/Capture_2.png",
|
|
||||||
label: "Environment",
|
|
||||||
span: "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
src: "/image/Games/Emberwild/Capture_3.png",
|
|
||||||
label: "Exploration",
|
|
||||||
span: "",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Home page.
|
||||||
|
*
|
||||||
|
* Read top to bottom it answers four questions in order: what is this
|
||||||
|
* (hero), why should I care (statement), what are you making (featured game),
|
||||||
|
* who are you (studio), how do I follow it (dispatch). Each band changes
|
||||||
|
* rhythm — full-bleed, quiet, card, split, conversion — so the page never
|
||||||
|
* reads as one long column of identical text blocks.
|
||||||
|
*
|
||||||
|
* Game data reads from `lib/games.ts`, so the home page can never advertise
|
||||||
|
* something the game page doesn't show. Screenshots deliberately live only on
|
||||||
|
* /games/[slug] — the home page teases the game, the game page shows it.
|
||||||
|
*/
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
|
const latest = getAllPosts()[0] ?? null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* HERO */}
|
<HomeHero />
|
||||||
<section className="relative isolate min-h-screen flex items-center justify-center overflow-hidden">
|
|
||||||
{/* Background video */}
|
|
||||||
<video
|
|
||||||
autoPlay
|
|
||||||
muted
|
|
||||||
loop
|
|
||||||
playsInline
|
|
||||||
preload="auto"
|
|
||||||
aria-hidden
|
|
||||||
className="absolute inset-0 z-0 w-full h-full object-cover"
|
|
||||||
>
|
|
||||||
<source src="/Video/Header_Video.mp4" type="video/mp4" />
|
|
||||||
</video>
|
|
||||||
{/* Dark overlay for text readability */}
|
|
||||||
<div
|
|
||||||
aria-hidden
|
|
||||||
className="absolute inset-0 z-10 bg-background/60"
|
|
||||||
/>
|
|
||||||
{/* Vignette / radial fade to deepen the edges */}
|
|
||||||
<div
|
|
||||||
aria-hidden
|
|
||||||
className="absolute inset-0 z-10"
|
|
||||||
style={{
|
|
||||||
background:
|
|
||||||
"radial-gradient(ellipse at 50% 50%, rgba(10,10,10,0) 30%, rgba(10,10,10,0.85) 100%)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{/* Grain / noise overlay via CSS gradient */}
|
|
||||||
<div
|
|
||||||
aria-hidden
|
|
||||||
className="absolute inset-0 z-10 opacity-[0.03]"
|
|
||||||
style={{
|
|
||||||
backgroundImage:
|
|
||||||
"repeating-linear-gradient(0deg, #fff 0, #fff 1px, transparent 1px, transparent 3px)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="relative z-20 max-w-5xl w-full px-6 lg:px-10 flex flex-col items-center text-center pt-20">
|
<Statement />
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, scale: 0.92, y: 20 }}
|
|
||||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
||||||
transition={{ duration: 1.1, ease: [0.22, 1, 0.36, 1] }}
|
|
||||||
className="mb-10"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
src="/image/Studio/Logo Flat.png"
|
|
||||||
alt="Highland Games Studio"
|
|
||||||
width={180}
|
|
||||||
height={120}
|
|
||||||
priority
|
|
||||||
className="drop-shadow-[0_0_40px_rgba(196,184,150,0.15)]"
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<motion.h1
|
{featuredGame && <FeaturedGame game={featuredGame} />}
|
||||||
initial={{ opacity: 0, y: 30 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.9, delay: 0.3, ease: "easeOut" }}
|
|
||||||
className="font-display text-6xl sm:text-7xl lg:text-8xl tracking-wider leading-[0.95] text-balance"
|
|
||||||
>
|
|
||||||
HIGHLAND GAMES
|
|
||||||
<br />
|
|
||||||
<span className="text-accent">STUDIO</span>
|
|
||||||
</motion.h1>
|
|
||||||
|
|
||||||
<motion.p
|
<StudioIntro />
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.9, delay: 0.6, ease: "easeOut" }}
|
|
||||||
className="mt-8 text-lg sm:text-xl text-foreground-muted tracking-wide max-w-xl text-balance"
|
|
||||||
>
|
|
||||||
Where summits become worlds.
|
|
||||||
</motion.p>
|
|
||||||
|
|
||||||
<motion.div
|
<Dispatch latest={latest} />
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.9, delay: 0.9, ease: "easeOut" }}
|
|
||||||
className="mt-12 flex flex-col sm:flex-row gap-4"
|
|
||||||
>
|
|
||||||
<Link
|
|
||||||
href="/games"
|
|
||||||
className="px-8 py-3 bg-accent text-background font-display tracking-widest text-sm hover:bg-foreground hover:scale-[1.03] transition duration-200"
|
|
||||||
>
|
|
||||||
EXPLORE OUR GAMES
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/studio"
|
|
||||||
className="px-8 py-3 border border-border text-foreground font-display tracking-widest text-sm hover:border-accent hover:text-accent hover:scale-[1.03] transition duration-200"
|
|
||||||
>
|
|
||||||
MEET THE STUDIO
|
|
||||||
</Link>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Scroll indicator - positioned relative to the section */}
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
transition={{ delay: 1.5, duration: 1 }}
|
|
||||||
className="absolute z-20 bottom-10 left-1/2 -translate-x-1/2 flex flex-col items-center gap-2 text-foreground-muted text-xs tracking-widest"
|
|
||||||
>
|
|
||||||
<span className="font-display">SCROLL</span>
|
|
||||||
<motion.div
|
|
||||||
animate={{ y: [0, 8, 0] }}
|
|
||||||
transition={{ duration: 1.8, repeat: Infinity, ease: "easeInOut" }}
|
|
||||||
className="w-px h-8 bg-foreground-muted"
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* STUDIO INTRO */}
|
|
||||||
<section className="py-24 px-6 lg:px-10">
|
|
||||||
<div className="max-w-6xl mx-auto">
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 30 }}
|
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
|
||||||
viewport={{ once: true, margin: "-100px" }}
|
|
||||||
transition={{ duration: 0.8 }}
|
|
||||||
>
|
|
||||||
<p className="font-display text-sm tracking-[0.3em] text-accent mb-4">
|
|
||||||
— THE STUDIO
|
|
||||||
</p>
|
|
||||||
<h2 className="font-display text-4xl sm:text-5xl lg:text-6xl tracking-wider leading-tight mb-8">
|
|
||||||
Worlds beyond
|
|
||||||
<br />
|
|
||||||
the summit.
|
|
||||||
</h2>
|
|
||||||
<p className="text-lg text-foreground-muted leading-relaxed max-w-2xl">
|
|
||||||
We build worlds where every horizon hides a story. Hand-crafted
|
|
||||||
games made to be lived in, not just played through. Each one a
|
|
||||||
different summit, a different climb.
|
|
||||||
</p>
|
|
||||||
<Link
|
|
||||||
href="/studio"
|
|
||||||
className="inline-block mt-8 font-display text-sm tracking-widest text-accent hover:text-foreground transition-colors"
|
|
||||||
>
|
|
||||||
LEARN MORE →
|
|
||||||
</Link>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* FEATURED GAME */}
|
|
||||||
<section className="py-24 px-6 lg:px-10 border-t border-border">
|
|
||||||
<div className="max-w-6xl mx-auto">
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 30 }}
|
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
|
||||||
viewport={{ once: true, margin: "-100px" }}
|
|
||||||
transition={{ duration: 0.8 }}
|
|
||||||
>
|
|
||||||
<p className="font-display text-sm tracking-[0.3em] text-accent mb-4">
|
|
||||||
— IN DEVELOPMENT
|
|
||||||
</p>
|
|
||||||
<h2 className="font-display text-4xl sm:text-5xl lg:text-6xl tracking-wider mb-12">
|
|
||||||
OUR FIRST WORLD
|
|
||||||
</h2>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 40 }}
|
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
|
||||||
viewport={{ once: true, margin: "-100px" }}
|
|
||||||
transition={{ duration: 0.9, delay: 0.1 }}
|
|
||||||
>
|
|
||||||
<Link
|
|
||||||
href="/games/emberwild"
|
|
||||||
className="group relative aspect-[16/9] bg-surface border border-border overflow-hidden block hover:border-accent transition-colors duration-500"
|
|
||||||
>
|
|
||||||
{featured?.cover ? (
|
|
||||||
<Image
|
|
||||||
src={featured.cover}
|
|
||||||
alt={featured.title}
|
|
||||||
fill
|
|
||||||
sizes="(max-width: 1152px) 100vw, 1152px"
|
|
||||||
className="object-cover group-hover:scale-105 transition-transform duration-700 ease-out"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
|
||||||
<Image
|
|
||||||
src="/image/Studio/Logo Flat.png"
|
|
||||||
alt="Game placeholder"
|
|
||||||
width={120}
|
|
||||||
height={80}
|
|
||||||
className="opacity-20 group-hover:opacity-30 group-hover:scale-105 transition-all duration-700 ease-out"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div
|
|
||||||
aria-hidden
|
|
||||||
className="absolute inset-0 bg-accent/0 group-hover:bg-accent/[0.03] transition-colors duration-500"
|
|
||||||
/>
|
|
||||||
<div className="absolute bottom-0 left-0 right-0 p-8 bg-gradient-to-t from-background via-background/70 to-transparent">
|
|
||||||
<p className="font-display text-xs tracking-[0.3em] text-accent mb-2">
|
|
||||||
SURVIVAL · CRAFTING · EXPLORATION
|
|
||||||
</p>
|
|
||||||
<h3 className="font-display text-3xl sm:text-4xl tracking-wider mb-4 group-hover:text-accent transition-colors duration-300">
|
|
||||||
PROJECT ONE
|
|
||||||
</h3>
|
|
||||||
<p className="text-foreground-muted max-w-xl mb-6">
|
|
||||||
Inspired by Raft and Aloft. Build, explore, and survive across
|
|
||||||
shifting horizons. A new survival adventure, currently in early
|
|
||||||
development.
|
|
||||||
</p>
|
|
||||||
<span className="inline-flex items-center gap-2 font-display text-sm tracking-widest text-foreground group-hover:text-accent transition-colors">
|
|
||||||
LEARN MORE
|
|
||||||
<span className="inline-block transition-transform duration-300 group-hover:translate-x-1">
|
|
||||||
→
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* GALLERY */}
|
|
||||||
<section className="py-24 px-6 lg:px-10 border-t border-border">
|
|
||||||
<div className="max-w-6xl mx-auto">
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 30 }}
|
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
|
||||||
viewport={{ once: true, margin: "-100px" }}
|
|
||||||
transition={{ duration: 0.8 }}
|
|
||||||
>
|
|
||||||
<p className="font-display text-sm tracking-[0.3em] text-accent mb-4">
|
|
||||||
— GLIMPSES
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-12">
|
|
||||||
<h2 className="font-display text-4xl sm:text-5xl lg:text-6xl tracking-wider">
|
|
||||||
FROM THE WORKSHOP
|
|
||||||
</h2>
|
|
||||||
<p className="text-sm text-foreground-muted max-w-sm">
|
|
||||||
Raw captures straight from development. Everything here is work
|
|
||||||
in progress and will keep evolving.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-3 gap-4 auto-rows-[160px] sm:auto-rows-[220px]">
|
|
||||||
{screenshots.map((shot, i) => (
|
|
||||||
<motion.div
|
|
||||||
key={i}
|
|
||||||
initial={{ opacity: 0, scale: 0.96 }}
|
|
||||||
whileInView={{ opacity: 1, scale: 1 }}
|
|
||||||
viewport={{ once: true, margin: "-60px" }}
|
|
||||||
transition={{ duration: 0.6, delay: i * 0.08 }}
|
|
||||||
className={`group relative overflow-hidden rounded-2xl border border-border bg-surface ${shot.span}`}
|
|
||||||
>
|
|
||||||
{shot.src ? (
|
|
||||||
<Image
|
|
||||||
src={shot.src}
|
|
||||||
alt={shot.label}
|
|
||||||
fill
|
|
||||||
sizes="(max-width: 1024px) 50vw, 33vw"
|
|
||||||
className="object-cover transition-transform duration-700 ease-out group-hover:scale-105"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex h-full w-full flex-col items-center justify-center gap-3">
|
|
||||||
<Image
|
|
||||||
src="/image/Studio/Logo Flat.png"
|
|
||||||
alt=""
|
|
||||||
width={64}
|
|
||||||
height={44}
|
|
||||||
className="opacity-[0.12]"
|
|
||||||
/>
|
|
||||||
<span className="font-display text-[11px] tracking-[0.3em] text-foreground-muted uppercase">
|
|
||||||
{shot.label}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Subtle gradient + label on hover for real screenshots */}
|
|
||||||
{shot.src && (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
aria-hidden
|
|
||||||
className="absolute inset-0 bg-gradient-to-t from-background/70 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500"
|
|
||||||
/>
|
|
||||||
<span className="absolute bottom-3 left-4 font-display text-sm tracking-widest text-foreground opacity-0 translate-y-2 group-hover:opacity-100 group-hover:translate-y-0 transition-all duration-500">
|
|
||||||
{shot.label}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* WIP badge */}
|
|
||||||
<div className="absolute top-3 left-3 rounded-full border border-border/70 bg-background/70 px-3 py-1 backdrop-blur-sm">
|
|
||||||
<span className="font-display text-[10px] tracking-[0.2em] text-accent">
|
|
||||||
WIP
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* DEVBLOG TEASER */}
|
|
||||||
<section className="py-24 px-6 lg:px-10 border-t border-border">
|
|
||||||
<div className="max-w-4xl mx-auto text-center">
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 30 }}
|
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
|
||||||
viewport={{ once: true, margin: "-100px" }}
|
|
||||||
transition={{ duration: 0.8 }}
|
|
||||||
>
|
|
||||||
<p className="font-display text-sm tracking-[0.3em] text-accent mb-4">
|
|
||||||
— DEVBLOG
|
|
||||||
</p>
|
|
||||||
<h2 className="font-display text-4xl sm:text-5xl tracking-wider mb-6">
|
|
||||||
FOLLOW THE JOURNEY
|
|
||||||
</h2>
|
|
||||||
<p className="text-lg text-foreground-muted leading-relaxed max-w-2xl mx-auto mb-8">
|
|
||||||
Behind-the-scenes updates, design decisions, and milestones from
|
|
||||||
the studio. Every step of the climb, documented.
|
|
||||||
</p>
|
|
||||||
<Link
|
|
||||||
href="/devblog"
|
|
||||||
className="inline-block px-8 py-3 border border-border text-foreground font-display tracking-widest text-sm hover:border-accent hover:text-accent hover:scale-[1.03] transition duration-200"
|
|
||||||
>
|
|
||||||
READ THE DEVBLOG
|
|
||||||
</Link>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+63
-82
@@ -1,5 +1,13 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import { PageHeader } from "../components/ui/PageHeader";
|
||||||
|
import { Section } from "../components/ui/Section";
|
||||||
|
import { SectionHeading } from "../components/ui/SectionHeading";
|
||||||
|
import { ButtonLink } from "../components/ui/ButtonLink";
|
||||||
|
import { Logo } from "../components/ui/Logo";
|
||||||
|
import { Spotlight } from "../components/ui/Spotlight";
|
||||||
|
import { Stagger, StaggerItem } from "../components/ui/Stagger";
|
||||||
|
import { TEAM } from "@/lib/studio";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Studio",
|
title: "Studio",
|
||||||
@@ -7,107 +15,80 @@ export const metadata: Metadata = {
|
|||||||
"Highland Games Studio is a small indie team crafting survival adventures.",
|
"Highland Games Studio is a small indie team crafting survival adventures.",
|
||||||
};
|
};
|
||||||
|
|
||||||
const team = [
|
|
||||||
{
|
|
||||||
name: "Mathew Simon",
|
|
||||||
role: "CEO · Developer",
|
|
||||||
bio: "Builds the systems, gameplay loops and tools that bring our worlds to life.",
|
|
||||||
photo: "/image/Studio/Teams/CEO_Dev.jpg",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Kevin Tostivin",
|
|
||||||
role: "3D Artist · Art Director",
|
|
||||||
bio: "Shapes the visual identity, environments and overall look of every Highland Games world.",
|
|
||||||
photo: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Axel",
|
|
||||||
role: "3D Animator",
|
|
||||||
bio: "Brings characters and creatures to life, giving motion and feel to everything on screen.",
|
|
||||||
photo: null,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function StudioPage() {
|
export default function StudioPage() {
|
||||||
return (
|
return (
|
||||||
<div className="pt-32 pb-24 px-6 lg:px-10">
|
<>
|
||||||
{/* HERO */}
|
<PageHeader
|
||||||
<div className="max-w-4xl mx-auto">
|
eyebrow="THE STUDIO"
|
||||||
<p className="font-display text-sm tracking-[0.3em] text-accent mb-4">
|
width="narrow"
|
||||||
— THE STUDIO
|
title={
|
||||||
</p>
|
<>
|
||||||
<h1 className="font-display text-5xl sm:text-6xl lg:text-7xl tracking-wider leading-[0.95] mb-8">
|
|
||||||
A SMALL TEAM,
|
A SMALL TEAM,
|
||||||
<br />
|
<br />
|
||||||
BIG HORIZONS.
|
BIG HORIZONS.
|
||||||
</h1>
|
</>
|
||||||
<p className="text-lg text-foreground-muted leading-relaxed max-w-2xl">
|
}
|
||||||
Highland Games Studio is an independent game studio building survival
|
lead="Highland Games Studio is an independent game studio building survival worlds we'd want to lose ourselves in. We believe small teams can craft big experiences, where every detail is made by someone who cares."
|
||||||
worlds we'd want to lose ourselves in. We believe small teams can
|
|
||||||
craft big experiences, where every detail is made by someone who
|
|
||||||
cares.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* TEAM */}
|
|
||||||
<section className="max-w-5xl mx-auto mt-28">
|
|
||||||
<div className="mb-12 text-center">
|
|
||||||
<p className="font-display text-sm tracking-[0.3em] text-accent mb-4">
|
|
||||||
— THE PEOPLE
|
|
||||||
</p>
|
|
||||||
<h2 className="font-display text-4xl sm:text-5xl tracking-wider">
|
|
||||||
THE TEAM
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-8">
|
|
||||||
{team.map((m) => (
|
|
||||||
<div
|
|
||||||
key={m.name}
|
|
||||||
className="group relative flex flex-col items-center rounded-[2rem] border border-border bg-surface/60 px-8 py-10 text-center transition-all duration-300 hover:-translate-y-1 hover:border-accent/60 hover:bg-surface"
|
|
||||||
>
|
|
||||||
{/* Avatar */}
|
|
||||||
<div className="relative mb-6">
|
|
||||||
<div
|
|
||||||
aria-hidden
|
|
||||||
className="absolute -inset-1 rounded-full bg-gradient-to-b from-accent/40 to-transparent opacity-0 blur-md transition-opacity duration-300 group-hover:opacity-100"
|
|
||||||
/>
|
/>
|
||||||
<div className="relative h-32 w-32 overflow-hidden rounded-full border border-border bg-surface-elevated ring-2 ring-transparent transition-all duration-300 group-hover:ring-accent/40">
|
|
||||||
{m.photo ? (
|
<Section divider>
|
||||||
|
<SectionHeading eyebrow="THE PEOPLE" title="THE TEAM" align="center" />
|
||||||
|
|
||||||
|
<Stagger
|
||||||
|
as="ul"
|
||||||
|
step={0.1}
|
||||||
|
className="mt-14 grid gap-8 sm:grid-cols-2 lg:grid-cols-3"
|
||||||
|
>
|
||||||
|
{TEAM.map((member) => (
|
||||||
|
<StaggerItem as="li" key={member.name}>
|
||||||
|
<article className="group relative flex h-full flex-col items-center overflow-hidden border border-border bg-surface px-8 py-10 text-center transition duration-300 ease-out hover:-translate-y-1 hover:border-accent/60 hover:shadow-[0_24px_50px_-30px_rgba(0,0,0,0.9)]">
|
||||||
|
<div className="relative mb-6 h-32 w-32 overflow-hidden rounded-full border border-border bg-surface-elevated transition-colors duration-300 group-hover:border-accent/50">
|
||||||
|
{member.photo ? (
|
||||||
<Image
|
<Image
|
||||||
src={m.photo}
|
src={member.photo}
|
||||||
alt={m.name}
|
alt={member.name}
|
||||||
width={128}
|
fill
|
||||||
height={128}
|
sizes="128px"
|
||||||
className="h-full w-full object-cover"
|
className="object-cover transition-transform duration-700 ease-out group-hover:scale-110"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full w-full items-center justify-center">
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
<Image
|
<Logo
|
||||||
src="/image/Studio/Logo Flat.png"
|
width={64}
|
||||||
alt=""
|
className="opacity-25 transition-opacity duration-500 group-hover:opacity-40"
|
||||||
width={56}
|
|
||||||
height={40}
|
|
||||||
className="opacity-30"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3 className="font-display text-2xl tracking-wider mb-2">
|
<h3 className="mb-2 font-display text-2xl tracking-wider transition-colors duration-300 group-hover:text-accent">
|
||||||
{m.name}
|
{member.name}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs tracking-widest text-accent mb-4 uppercase">
|
<p className="mb-4 text-xs uppercase tracking-widest text-accent">
|
||||||
{m.role}
|
{member.role}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-foreground-muted leading-relaxed max-w-xs">
|
<p className="text-sm leading-relaxed text-foreground-muted">
|
||||||
{m.bio}
|
{member.bio}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
|
||||||
|
<Spotlight />
|
||||||
|
</article>
|
||||||
|
</StaggerItem>
|
||||||
))}
|
))}
|
||||||
|
</Stagger>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section divider width="narrow">
|
||||||
|
<SectionHeading
|
||||||
|
eyebrow="WORK WITH US"
|
||||||
|
title="LET'S TALK"
|
||||||
|
lead="Press, publishing, or simply curious about what we're building. The door is open."
|
||||||
|
align="center"
|
||||||
|
/>
|
||||||
|
<div className="mt-10 flex justify-center">
|
||||||
|
<ButtonLink href="/contact">GET IN TOUCH</ButtonLink>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</Section>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ We're a two-person indie studio, and today we're putting our first pin on the ma
|
|||||||
|
|
||||||
## Why we're here
|
## Why we're here
|
||||||
|
|
||||||
We grew up loving survival games. **Raft**, **Aloft**, **Subnautica** — games where the world feels alive and your decisions echo across hours of play. We wanted to build that, in our own way.
|
We grew up loving survival games. **Raft**, **Aloft**, **Subnautica**. Games where the world feels alive and your decisions echo across hours of play. We wanted to build that, in our own way.
|
||||||
|
|
||||||
## What we're working on
|
## What we're working on
|
||||||
|
|
||||||
@@ -27,4 +27,4 @@ Our first project is in early development. The core loop is forming: build, expl
|
|||||||
|
|
||||||
If you want to follow the climb, this is the place. See you at the next summit.
|
If you want to follow the climb, this is the place. See you at the next summit.
|
||||||
|
|
||||||
— Highland Games Studio
|
Highland Games Studio
|
||||||
|
|||||||
+181
@@ -0,0 +1,181 @@
|
|||||||
|
// Email templates for the contact form.
|
||||||
|
// Brand-forward but email-client-safe: black header/footer bands (logo + gold,
|
||||||
|
// the site's identity) wrapping a LIGHT body with dark text. Dark text on a
|
||||||
|
// light body is the one combination Gmail dark mode never inverts, so it stays
|
||||||
|
// readable everywhere — unlike a fully dark email, whose light text Gmail flips
|
||||||
|
// to dark and hides.
|
||||||
|
// Table layout + inline styles + bgcolor attributes for client compatibility.
|
||||||
|
|
||||||
|
import { SITE_URL } from "@/lib/site";
|
||||||
|
|
||||||
|
const COLORS = {
|
||||||
|
band: "#0a0a0a", // black brand bands (header/footer) — the site's black
|
||||||
|
page: "#e9e4d8", // warm parchment page background
|
||||||
|
card: "#fbfaf6", // near-white body card
|
||||||
|
border: "#ddd6c6", // warm hairline border
|
||||||
|
rule: "#c4b896", // gold accent rule
|
||||||
|
ink: "#1a1714", // near-black body text
|
||||||
|
inkSoft: "#5a5346", // secondary text
|
||||||
|
accent: "#8a7338", // gold, darkened so it reads on the light body
|
||||||
|
accentOnDark: "#c4b896", // site gold, used on the black bands
|
||||||
|
box: "#f1ece0", // quoted-message background
|
||||||
|
};
|
||||||
|
|
||||||
|
const DISPLAY_FONT = '"Arial Narrow", "Helvetica Neue Condensed", "Helvetica Neue", Arial, sans-serif';
|
||||||
|
const SANS_FONT = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif';
|
||||||
|
|
||||||
|
const STUDIO_NAME = "Highland Games Studio";
|
||||||
|
const TAGLINE = "Where summits become worlds.";
|
||||||
|
// Loaded from the live site — the asset must be deployed there to render.
|
||||||
|
const LOGO_EMAIL_URL = `${SITE_URL}/image/Studio/Full%20Logo.png`;
|
||||||
|
|
||||||
|
function escapeHtml(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
function nl2br(value: string): string {
|
||||||
|
return escapeHtml(value).replace(/\n/g, "<br />");
|
||||||
|
}
|
||||||
|
|
||||||
|
type ContactInput = {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
subject: string;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Small gold, wide-tracked eyebrow label — the site's signature accent.
|
||||||
|
function eyebrow(text: string): string {
|
||||||
|
return `<div style="font-family:${DISPLAY_FONT};font-size:12px;letter-spacing:5px;color:${COLORS.accent};text-transform:uppercase;">${text}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Big condensed uppercase heading, in near-black ink.
|
||||||
|
function heading(text: string): string {
|
||||||
|
return `<div style="font-family:${DISPLAY_FONT};font-size:36px;line-height:1.05;letter-spacing:1px;color:${COLORS.ink};text-transform:uppercase;margin:10px 0 0;">${text}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shell(innerHtml: string): string {
|
||||||
|
return `<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="color-scheme" content="light" />
|
||||||
|
<meta name="supported-color-schemes" content="light" />
|
||||||
|
</head>
|
||||||
|
<body style="margin:0;padding:0;background:${COLORS.page};">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" bgcolor="${COLORS.page}" style="background:${COLORS.page};padding:32px 12px;">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" bgcolor="${COLORS.card}" style="max-width:560px;background:${COLORS.card};border:1px solid ${COLORS.border};">
|
||||||
|
<!-- black logo band -->
|
||||||
|
<tr>
|
||||||
|
<td align="center" bgcolor="${COLORS.band}" style="background:${COLORS.band};padding:36px 32px 30px;">
|
||||||
|
<img src="${LOGO_EMAIL_URL}" alt="${STUDIO_NAME}" width="240" style="display:block;width:240px;max-width:72%;height:auto;border:0;" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<!-- gold accent rule -->
|
||||||
|
<tr>
|
||||||
|
<td bgcolor="${COLORS.rule}" style="background:${COLORS.rule};height:3px;line-height:3px;font-size:0;"> </td>
|
||||||
|
</tr>
|
||||||
|
<!-- light body -->
|
||||||
|
${innerHtml}
|
||||||
|
<!-- black footer band -->
|
||||||
|
<tr>
|
||||||
|
<td bgcolor="${COLORS.band}" style="background:${COLORS.band};padding:24px 32px;">
|
||||||
|
<div style="font-family:${DISPLAY_FONT};font-size:14px;letter-spacing:3px;color:${COLORS.accentOnDark};text-transform:uppercase;">${STUDIO_NAME}</div>
|
||||||
|
<div style="font-family:${SANS_FONT};font-size:12px;color:#8f867a;margin-top:4px;">${TAGLINE}</div>
|
||||||
|
<div style="font-family:${SANS_FONT};font-size:11px;color:#6f6a5f;margin-top:12px;line-height:1.6;">Sent automatically from the studio website.</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sent to the studio staff with the submitted message.
|
||||||
|
export function staffNotificationEmail(input: ContactInput): {
|
||||||
|
subject: string;
|
||||||
|
html: string;
|
||||||
|
text: string;
|
||||||
|
} {
|
||||||
|
const { name, email, subject, message } = input;
|
||||||
|
|
||||||
|
const row = (label: string, value: string) => `
|
||||||
|
<tr>
|
||||||
|
<td style="padding:0 0 14px;">
|
||||||
|
<div style="font-family:${DISPLAY_FONT};font-size:11px;letter-spacing:3px;color:${COLORS.accent};text-transform:uppercase;">${label}</div>
|
||||||
|
<div style="font-family:${SANS_FONT};font-size:15px;color:${COLORS.ink};margin-top:4px;">${value}</div>
|
||||||
|
</td>
|
||||||
|
</tr>`;
|
||||||
|
|
||||||
|
const inner = `
|
||||||
|
<tr>
|
||||||
|
<td bgcolor="${COLORS.card}" style="background:${COLORS.card};padding:36px 32px;">
|
||||||
|
${eyebrow("New message")}
|
||||||
|
${heading("Someone reached out")}
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin-top:28px;">
|
||||||
|
${row("From", `${escapeHtml(name)} <${escapeHtml(email)}>`)}
|
||||||
|
${row("Subject", escapeHtml(subject))}
|
||||||
|
</table>
|
||||||
|
<div style="font-family:${DISPLAY_FONT};font-size:11px;letter-spacing:3px;color:${COLORS.accent};text-transform:uppercase;margin:8px 0 10px;">Message</div>
|
||||||
|
<div style="font-family:${SANS_FONT};font-size:15px;color:${COLORS.ink};line-height:1.7;background:${COLORS.box};border:1px solid ${COLORS.border};padding:18px;">${nl2br(message)}</div>
|
||||||
|
<!-- reply button (filled black, gold text) -->
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" style="margin-top:28px;">
|
||||||
|
<tr>
|
||||||
|
<td bgcolor="${COLORS.band}" style="background:${COLORS.band};">
|
||||||
|
<a href="mailto:${encodeURIComponent(email)}?subject=${encodeURIComponent("Re: " + subject)}"
|
||||||
|
style="display:inline-block;font-family:${DISPLAY_FONT};font-size:14px;letter-spacing:3px;text-transform:uppercase;color:${COLORS.accentOnDark};text-decoration:none;padding:14px 30px;">
|
||||||
|
Reply to ${escapeHtml(name)} →
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
subject: `[Contact] ${subject}`,
|
||||||
|
html: shell(inner),
|
||||||
|
text: `New message for ${STUDIO_NAME}\n\nFrom: ${name} <${email}>\nSubject: ${subject}\n\n${message}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-reply sent to the person who submitted the form.
|
||||||
|
export function autoReplyEmail(input: ContactInput): {
|
||||||
|
subject: string;
|
||||||
|
html: string;
|
||||||
|
text: string;
|
||||||
|
} {
|
||||||
|
const { name, message } = input;
|
||||||
|
const firstName = name.split(/\s+/)[0] || name;
|
||||||
|
|
||||||
|
const inner = `
|
||||||
|
<tr>
|
||||||
|
<td bgcolor="${COLORS.card}" style="background:${COLORS.card};padding:36px 32px;">
|
||||||
|
${eyebrow("Message received")}
|
||||||
|
${heading("Thanks for<br />reaching out")}
|
||||||
|
<div style="font-family:${SANS_FONT};font-size:15px;color:${COLORS.ink};line-height:1.8;margin-top:26px;">
|
||||||
|
<p style="margin:0 0 16px;">Hi ${escapeHtml(firstName)},</p>
|
||||||
|
<p style="margin:0 0 16px;">Thanks for getting in touch with ${STUDIO_NAME}. We've received your message and a real human will get back to you soon.</p>
|
||||||
|
<p style="margin:0 0 10px;color:${COLORS.inkSoft};font-size:13px;">For reference, here's what you sent us:</p>
|
||||||
|
</div>
|
||||||
|
<div style="font-family:${SANS_FONT};font-size:14px;color:${COLORS.inkSoft};line-height:1.7;background:${COLORS.box};border:1px solid ${COLORS.border};padding:18px;">${nl2br(message)}</div>
|
||||||
|
<div style="font-family:${DISPLAY_FONT};font-size:18px;letter-spacing:2px;color:${COLORS.accent};text-transform:uppercase;margin-top:30px;">The ${STUDIO_NAME} team</div>
|
||||||
|
</td>
|
||||||
|
</tr>`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
subject: `We got your message`,
|
||||||
|
html: shell(inner),
|
||||||
|
text: `Hi ${firstName},\n\nThanks for getting in touch with ${STUDIO_NAME}. We've received your message and a real human will get back to you soon.\n\nFor reference, here's what you sent us:\n\n${message}\n\nThe ${STUDIO_NAME} team`,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* Formats an ISO date (`YYYY-MM-DD`) for display.
|
||||||
|
* Forced to UTC so a post dated 2026-05-09 never renders as May 8 for
|
||||||
|
* visitors west of Greenwich.
|
||||||
|
*/
|
||||||
|
export function formatDate(date: string): string {
|
||||||
|
return new Date(date).toLocaleDateString("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
timeZone: "UTC",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rough reading time in minutes, from raw MDX.
|
||||||
|
*
|
||||||
|
* Frontmatter is already stripped by `lib/devblog`, but markdown syntax is
|
||||||
|
* not: fences, link targets and heading markers are dropped so a post is not
|
||||||
|
* credited for characters nobody reads. 200 wpm, floored at one minute.
|
||||||
|
*/
|
||||||
|
export function readingTime(content: string): number {
|
||||||
|
const words = content
|
||||||
|
.replace(/```[\s\S]*?```/g, "")
|
||||||
|
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
|
||||||
|
.replace(/[#>*_`~-]/g, " ")
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(Boolean).length;
|
||||||
|
|
||||||
|
return Math.max(1, Math.round(words / 200));
|
||||||
|
}
|
||||||
+108
-9
@@ -1,16 +1,71 @@
|
|||||||
|
export type GameStatus = "in-development" | "released" | "coming-soon";
|
||||||
|
|
||||||
|
export type Screenshot = {
|
||||||
|
src: string;
|
||||||
|
/** Short caption. Also used as the image alt text. */
|
||||||
|
caption: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A gameplay pillar: what the player actually *does*.
|
||||||
|
*
|
||||||
|
* Copy rules, borrowed from the EmberWild site so both properties describe the
|
||||||
|
* game the same way:
|
||||||
|
* 1. No story. No lore, no factions, no twists. A visitor should arrive
|
||||||
|
* knowing what they will do, not what they will find out.
|
||||||
|
* 2. The title is one word. The body is one sentence.
|
||||||
|
* 3. If the screenshot already says it, do not write it.
|
||||||
|
*/
|
||||||
|
export type Pillar = {
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
/** The capture that illustrates it. */
|
||||||
|
shot: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type Game = {
|
export type Game = {
|
||||||
slug: string;
|
slug: string;
|
||||||
title: string;
|
title: string;
|
||||||
status: "in-development" | "released" | "coming-soon";
|
status: GameStatus;
|
||||||
|
/** One readable line. Shown under the title on cards and on the game page. */
|
||||||
tagline: string;
|
tagline: string;
|
||||||
|
/** Full pitch. Shown in the "About" block of the game page. */
|
||||||
description: string;
|
description: string;
|
||||||
genres: string[];
|
genres: string[];
|
||||||
releaseWindow?: string;
|
releaseWindow?: string;
|
||||||
steamUrl?: string;
|
steamUrl?: string;
|
||||||
// Image de couverture affichée sur les cards (home, liste, page projet).
|
/** Player count, e.g. "1-6". Drives the co-op block on the game page. */
|
||||||
// Dépose l'image dans /public/image/Emberwild/ puis renseigne le chemin,
|
players?: string;
|
||||||
// ex: "/image/Emberwild/cover.jpg". Laisse vide pour un placeholder.
|
platform?: string;
|
||||||
cover?: string;
|
|
||||||
|
/** What the player does, one band each on the game page. */
|
||||||
|
pillars: Pillar[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Illustrated key art / cover (portrait or square-ish artwork).
|
||||||
|
* Rendered on its own panel — never used as a background behind text,
|
||||||
|
* because the artwork is light and text would become unreadable.
|
||||||
|
*/
|
||||||
|
keyArt?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wide in-game capture used as the page banner. Prefer a 16:9 screenshot.
|
||||||
|
* Text is never laid over it either; the title block sits underneath.
|
||||||
|
*/
|
||||||
|
banner?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gallery. This is the ONLY place screenshots are declared: the game page
|
||||||
|
* shows them all, the home page shows the first few of the featured game.
|
||||||
|
* Drop new files in /public/image/Games/<Game>/ and add them here.
|
||||||
|
*/
|
||||||
|
screenshots: Screenshot[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const STATUS_LABEL: Record<GameStatus, string> = {
|
||||||
|
"in-development": "IN DEVELOPMENT",
|
||||||
|
released: "RELEASED",
|
||||||
|
"coming-soon": "COMING SOON",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const games: Game[] = [
|
export const games: Game[] = [
|
||||||
@@ -18,15 +73,59 @@ export const games: Game[] = [
|
|||||||
slug: "emberwild",
|
slug: "emberwild",
|
||||||
title: "Emberwild",
|
title: "Emberwild",
|
||||||
status: "in-development",
|
status: "in-development",
|
||||||
tagline: "A survival adventure across shifting horizons.",
|
tagline: "Co-op survival in a world gone wild.",
|
||||||
description:
|
description:
|
||||||
"Inspired by Raft and Aloft. Build, explore, and survive across drifting worlds. A new survival adventure currently in early development. The first world from Highland Games Studio.",
|
"Emberwild is a 1-6 player co-op survival craft game. Drive the blight out of a handcrafted world, farm and build on the ground you take back, and watch the colour return with you. Five regions, nothing generated, nothing repeated.",
|
||||||
genres: ["Survival", "Crafting", "Exploration"],
|
genres: ["Survival", "Crafting", "Co-op", "Exploration"],
|
||||||
releaseWindow: "TBA",
|
releaseWindow: "TBA",
|
||||||
cover: "/image/Games/Emberwild/Cover.png",
|
players: "1-6",
|
||||||
|
platform: "PC",
|
||||||
|
keyArt: "/image/Games/Emberwild/Cover.png",
|
||||||
|
banner: "/image/Games/Emberwild/Capture_1.png",
|
||||||
|
// Only three captures exist so far, so "Build" borrows the key art. Swap
|
||||||
|
// it for a real in-game shot of a base as soon as there is one.
|
||||||
|
pillars: [
|
||||||
|
{
|
||||||
|
title: "Restore",
|
||||||
|
body: "Drive the blight out and the colour comes back with it. What you clear stays clear.",
|
||||||
|
shot: "/image/Games/Emberwild/Capture_1.png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Survive",
|
||||||
|
body: "Farm, cook, fish, hunt. Clean ground grows more and sleeps quieter.",
|
||||||
|
shot: "/image/Games/Emberwild/Capture_2.png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Explore",
|
||||||
|
body: "One handcrafted world, five regions. Nothing generated, nothing repeated.",
|
||||||
|
shot: "/image/Games/Emberwild/Capture_3.png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Build",
|
||||||
|
body: "Raise a home. Push outposts into everything you take back.",
|
||||||
|
shot: "/image/Games/Emberwild/Cover.png",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
screenshots: [
|
||||||
|
{
|
||||||
|
src: "/image/Games/Emberwild/Capture_1.png",
|
||||||
|
caption: "First light over the clearing",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "/image/Games/Emberwild/Capture_2.png",
|
||||||
|
caption: "Autumn treeline",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "/image/Games/Emberwild/Capture_3.png",
|
||||||
|
caption: "The path through the meadow",
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** The game showcased on the home page. */
|
||||||
|
export const featuredGame: Game | undefined = games[0];
|
||||||
|
|
||||||
export function getGame(slug: string): Game | undefined {
|
export function getGame(slug: string): Game | undefined {
|
||||||
return games.find((g) => g.slug === slug);
|
return games.find((g) => g.slug === slug);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* Shared motion tokens.
|
||||||
|
*
|
||||||
|
* The same two curves as `--ease-out-expo` / `--ease-out-soft` in
|
||||||
|
* `globals.css`, in the tuple form Framer Motion expects. Keeping them here
|
||||||
|
* means a CSS hover and a Framer entrance land on the same feel instead of
|
||||||
|
* drifting apart file by file.
|
||||||
|
*/
|
||||||
|
export const EASE_OUT_EXPO = [0.22, 1, 0.36, 1] as const satisfies [
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
];
|
||||||
|
|
||||||
|
export const EASE_OUT_SOFT = [0.33, 1, 0.68, 1] as const satisfies [
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
];
|
||||||
|
|
||||||
|
/** How far a scroll-triggered element has to be inside the viewport to fire. */
|
||||||
|
export const VIEWPORT = { once: true, margin: "-80px" } as const;
|
||||||
+83
-3
@@ -1,6 +1,86 @@
|
|||||||
// Single source of truth for the public site URL.
|
// Single source of truth for everything "studio-level":
|
||||||
// Override per environment with NEXT_PUBLIC_SITE_URL; falls back to production.
|
// URL, identity, navigation, contact channels.
|
||||||
|
// Pages must never hard-code these values — import them from here.
|
||||||
|
|
||||||
export const SITE_URL =
|
export const SITE_URL =
|
||||||
process.env.NEXT_PUBLIC_SITE_URL ?? "https://highlandgamesstudio.com";
|
process.env.NEXT_PUBLIC_SITE_URL ?? "https://highlandgamesstudio.com";
|
||||||
|
|
||||||
export const LOGO_URL = `${SITE_URL}/image/Studio/Logo Flat.png`;
|
/** Studio identity. Reused in metadata, JSON-LD, footer, hero. */
|
||||||
|
export const STUDIO = {
|
||||||
|
name: "Highland Games Studio",
|
||||||
|
shortName: "Highland Games",
|
||||||
|
tagline: "Where summits become worlds.",
|
||||||
|
description:
|
||||||
|
"Indie game studio crafting survival adventures. Where summits become worlds.",
|
||||||
|
foundingDate: "2026",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Brand assets, with their intrinsic sizes so every `<Image>` keeps the
|
||||||
|
* correct aspect ratio (see `components/ui/Logo.tsx`).
|
||||||
|
*
|
||||||
|
* `Full Logo.png` is not referenced directly: it ships with an opaque black
|
||||||
|
* plate that would show as a rectangle over the #0a0a0a background.
|
||||||
|
* `Wordmark.png` is the transparent version, rebuilt by
|
||||||
|
* `node scripts/build-wordmark.mjs`.
|
||||||
|
*/
|
||||||
|
export const BRAND = {
|
||||||
|
/** Mountain mark alone, transparent. */
|
||||||
|
mark: { src: "/image/Studio/Logo Flat.png", width: 1344, height: 768 },
|
||||||
|
/** Mountain mark + "HIGHLAND GAMES STUDIO" lockup, transparent. */
|
||||||
|
wordmark: { src: "/image/Studio/Wordmark.png", width: 986, height: 407 },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const LOGO_URL = `${SITE_URL}${BRAND.mark.src}`;
|
||||||
|
|
||||||
|
/** Hero background video. `poster` stays null until a real still is exported. */
|
||||||
|
export const HERO_VIDEO = {
|
||||||
|
src: "/Video/Header_Video.mp4",
|
||||||
|
poster: null as string | null,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Primary navigation — shared by the navbar and the footer. */
|
||||||
|
export const NAV_LINKS = [
|
||||||
|
{ href: "/studio", label: "Studio" },
|
||||||
|
{ href: "/games", label: "Games" },
|
||||||
|
{ href: "/devblog", label: "Devblog" },
|
||||||
|
{ href: "/contact", label: "Contact" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const LEGAL_LINKS = [
|
||||||
|
{ href: "/legal/notice", label: "Legal Notice" },
|
||||||
|
{ href: "/legal/privacy", label: "Privacy" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Social accounts. Set `href` to null while an account does not exist yet —
|
||||||
|
* the footer hides those entries instead of rendering a dead `#` link.
|
||||||
|
*/
|
||||||
|
export const SOCIAL_LINKS: {
|
||||||
|
key: "x" | "discord" | "youtube" | "steam";
|
||||||
|
label: string;
|
||||||
|
href: string | null;
|
||||||
|
}[] = [
|
||||||
|
{ key: "x", label: "Twitter / X", href: null },
|
||||||
|
{ key: "discord", label: "Discord", href: null },
|
||||||
|
{ key: "youtube", label: "YouTube", href: null },
|
||||||
|
{ key: "steam", label: "Steam", href: null },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const CONTACT_CHANNELS = [
|
||||||
|
{
|
||||||
|
label: "GENERAL",
|
||||||
|
description: "Questions, feedback, or just to say hello.",
|
||||||
|
email: "hello@highlandgamesstudio.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "PRESS",
|
||||||
|
description: "Review keys, interviews and press assets.",
|
||||||
|
email: "press@highlandgamesstudio.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "BUSINESS",
|
||||||
|
description: "Publishing, partnerships and collaborations.",
|
||||||
|
email: "business@highlandgamesstudio.com",
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// The people behind the studio.
|
||||||
|
//
|
||||||
|
// Identity (name, tagline, links, brand assets) lives in `lib/site.ts` —
|
||||||
|
// this file is only the roster. It sits here rather than inside
|
||||||
|
// /studio/page.tsx because the home page counts it too: the "3 people" figure
|
||||||
|
// on the home page and the team grid on /studio can never disagree.
|
||||||
|
|
||||||
|
export type Member = {
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
bio: string;
|
||||||
|
/** null until a real photo is shot — the UI falls back to the logo mark. */
|
||||||
|
photo: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TEAM: Member[] = [
|
||||||
|
{
|
||||||
|
name: "Mathew Simon",
|
||||||
|
role: "CEO · Developer",
|
||||||
|
bio: "Builds the systems, gameplay loops and tools that bring our worlds to life.",
|
||||||
|
photo: "/image/Studio/Teams/CEO_Dev.jpg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Kevin Tostivin",
|
||||||
|
role: "3D Artist · Art Director",
|
||||||
|
bio: "Shapes the visual identity, environments and overall look of every Highland Games world.",
|
||||||
|
photo: "/image/Studio/Teams/Kevin.jpeg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Axel",
|
||||||
|
role: "3D Animator",
|
||||||
|
bio: "Brings characters and creatures to life, giving motion and feel to everything on screen.",
|
||||||
|
photo: "/image/Studio/Teams/Axel.jpg",
|
||||||
|
},
|
||||||
|
];
|
||||||
+5
-1
@@ -1,7 +1,11 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
images: {
|
||||||
|
// The source captures are 2560×1440 PNGs (~5 MB each). AVIF first keeps the
|
||||||
|
// delivered gallery an order of magnitude smaller than WebP alone.
|
||||||
|
formats: ["image/avif", "image/webp"],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 458 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 623 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
@@ -0,0 +1,39 @@
|
|||||||
|
// Derives a transparent wordmark from `Full Logo.png`.
|
||||||
|
//
|
||||||
|
// The source file has an opaque pure-black (#000000) plate, which shows up as
|
||||||
|
// a visible rectangle on the site background (#0a0a0a). The logo is two-tone
|
||||||
|
// (cream on black), so its luminance can be reused directly as an alpha mask.
|
||||||
|
//
|
||||||
|
// Run with: node scripts/build-wordmark.mjs
|
||||||
|
|
||||||
|
import sharp from "sharp";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
|
const ROOT = resolve(import.meta.dirname, "..");
|
||||||
|
const SOURCE = resolve(ROOT, "public/image/Studio/Full Logo.png");
|
||||||
|
const OUT = resolve(ROOT, "public/image/Studio/Wordmark.png");
|
||||||
|
|
||||||
|
const INK = { r: 232, g: 223, b: 204 }; // --foreground
|
||||||
|
|
||||||
|
const { width, height } = await sharp(SOURCE).metadata();
|
||||||
|
|
||||||
|
// Luminance of the source becomes the alpha channel.
|
||||||
|
const alpha = await sharp(SOURCE).greyscale().raw().toBuffer();
|
||||||
|
|
||||||
|
const tinted = await sharp({
|
||||||
|
create: { width, height, channels: 3, background: INK },
|
||||||
|
})
|
||||||
|
.joinChannel(alpha, { raw: { width, height, channels: 1 } })
|
||||||
|
.png()
|
||||||
|
.toBuffer();
|
||||||
|
|
||||||
|
// Drop the empty margins so the asset can be sized predictably.
|
||||||
|
const trimmed = await sharp(tinted)
|
||||||
|
.trim({ threshold: 2 })
|
||||||
|
.png({ compressionLevel: 9 })
|
||||||
|
.toBuffer();
|
||||||
|
|
||||||
|
const meta = await sharp(trimmed).metadata();
|
||||||
|
await sharp(trimmed).toFile(OUT);
|
||||||
|
|
||||||
|
console.log(`Wordmark.png: ${meta.width}x${meta.height}, ${trimmed.length} bytes`);
|
||||||
Reference in New Issue
Block a user