(Feat) Init WebSite

This commit is contained in:
2026-06-22 23:03:02 +02:00
commit ea71039071
51 changed files with 11995 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
{
"permissions": {
"allow": [
"Bash(npx -p typescript tsc --noEmit)",
"Bash(node_modules/.bin/tsc --noEmit)",
"PowerShell(Get-ChildItem public/video/ | Select-Object Name, @{N='SizeMB';E={[math]::Round\\($_.Length/1MB,2\\)}})",
"PowerShell($f = Get-Item \"public/Video/Header_Video.mp4\"; Write-Output \\(\"Taille: {0:N2} MB\" -f \\($f.Length/1MB\\)\\))",
"Read(//c/Users/mathe/Videos/**)",
"PowerShell(Get-ChildItem \"public/Video/\" | Select-Object Name, @{N='SizeMB';E={[math]::Round\\($_.Length/1MB,2\\)}}, LastWriteTime)",
"PowerShell($f = Get-ChildItem \"public\\\\Video\\\\\"; foreach \\($i in $f\\) { Write-Output \\(\"{0} - {1:N2} MB\" -f $i.Name, \\($i.Length/1MB\\)\\) })"
]
}
}
+27
View File
@@ -0,0 +1,27 @@
{
"permissions": {
"allow": [
"PowerShell(Move-Item public _backup_public)",
"PowerShell(npm install)",
"PowerShell(npm install framer-motion @next/mdx @mdx-js/loader @mdx-js/react @types/mdx gray-matter remark-gfm)",
"PowerShell(Move-Item ../_studio_logos_backup/Studio public/image -Force)",
"PowerShell(Move-Item ../_studio_logos_backup/image public/image -Force)",
"PowerShell(npm install next-mdx-remote)",
"PowerShell(npm run build)",
"PowerShell(npm run dev)",
"PowerShell(Copy-Item \"public/image/Studio/favicon.ico\" \"app/favicon.ico\" -Force)",
"Bash(npm run *)",
"Bash(npx tsc *)",
"Bash(echo \"exit=$?\")",
"Bash(npx next *)",
"Bash(npx eslint *)",
"Bash(identify public/image/Studio/favicon.png)",
"Bash(node -e \"console.log\\(require\\('child_process'\\).execSync\\('npm root -g'\\).toString\\(\\)\\)\")",
"Bash(magick --version)",
"Bash(convert --version)",
"Bash(node -e \"try { require\\('sharp'\\); console.log\\('sharp ok'\\); } catch\\(e\\) { console.log\\('no sharp'\\); }\")",
"Bash(node -e ' *)",
"Bash(node scripts/build-favicon.mjs)"
]
}
}
+30
View File
@@ -0,0 +1,30 @@
name: Deploy to VPS
on:
push:
branches: [main]
workflow_dispatch:
concurrency:
group: deploy-${{ github.ref }}
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Deploy via SSH
uses: appleboy/ssh-action@v1.2.0
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
port: ${{ secrets.SSH_PORT }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
set -e
cd /var/www/highland
git pull origin main
npm ci
npm run build
pm2 restart highland --update-env
pm2 save
+78
View File
@@ -0,0 +1,78 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
.env
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+36
View File
@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
+49
View File
@@ -0,0 +1,49 @@
"use server";
export type ContactState = {
status: "idle" | "success" | "error";
message: string;
fieldErrors?: Partial<Record<"name" | "email" | "subject" | "message", string>>;
};
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export async function sendContact(
_prev: ContactState,
formData: FormData,
): Promise<ContactState> {
const name = String(formData.get("name") ?? "").trim();
const email = String(formData.get("email") ?? "").trim();
const subject = String(formData.get("subject") ?? "").trim();
const message = String(formData.get("message") ?? "").trim();
const honeypot = String(formData.get("website") ?? "");
if (honeypot) {
return { status: "success", message: "Thanks for reaching out." };
}
const fieldErrors: ContactState["fieldErrors"] = {};
if (!name) fieldErrors.name = "Required.";
if (!email || !EMAIL_RE.test(email)) fieldErrors.email = "Valid email required.";
if (!subject) fieldErrors.subject = "Required.";
if (!message || message.length < 10) {
fieldErrors.message = "Tell us a bit more (10+ chars).";
}
if (Object.keys(fieldErrors).length > 0) {
return {
status: "error",
message: "Please fix the highlighted fields.",
fieldErrors,
};
}
// TODO: wire to Resend / Postmark / SendGrid to actually deliver the email.
// For now we log so messages are visible in server logs.
console.log("[contact]", { name, email, subject, message });
return {
status: "success",
message: "Message sent. We'll get back to you soon.",
};
}
+36
View File
@@ -0,0 +1,36 @@
"use server";
export type SubscribeState = {
status: "idle" | "success" | "error";
message: string;
};
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export async function subscribe(
_prev: SubscribeState,
formData: FormData,
): Promise<SubscribeState> {
const email = String(formData.get("email") ?? "").trim();
const honeypot = String(formData.get("website") ?? "");
if (honeypot) {
return { status: "success", message: "Thanks — we'll keep you posted." };
}
if (!email || !EMAIL_RE.test(email)) {
return {
status: "error",
message: "Please enter a valid email address.",
};
}
// TODO: wire to a real provider (Buttondown, ConvertKit, Mailchimp, Resend Audiences).
// For now we just log the signup so you can collect addresses from server logs.
console.log(`[newsletter] new signup: ${email}`);
return {
status: "success",
message: "Welcome to the climb. Check your inbox soon.",
};
}
+141
View File
@@ -0,0 +1,141 @@
"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>
);
}
+102
View File
@@ -0,0 +1,102 @@
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>
);
}
+145
View File
@@ -0,0 +1,145 @@
"use client";
import Link from "next/link";
import Image from "next/image";
import { useEffect, useState } from "react";
import { usePathname } from "next/navigation";
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);
const pathname = usePathname();
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 20);
onScroll();
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
useEffect(() => {
setOpen(false);
}, [pathname]);
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}
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>
);
}
+83
View File
@@ -0,0 +1,83 @@
"use client";
import { useActionState } from "react";
import { useFormStatus } from "react-dom";
import { subscribe, type SubscribeState } from "@/app/actions/subscribe";
const initialState: SubscribeState = { status: "idle", message: "" };
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button
type="submit"
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"
>
{pending ? "SENDING…" : "SUBSCRIBE"}
</button>
);
}
type Props = {
variant?: "default" | "compact";
};
export function Newsletter({ variant = "default" }: Props) {
const [state, formAction] = useActionState(subscribe, initialState);
const wrapperClass =
variant === "compact"
? "w-full max-w-lg"
: "w-full max-w-lg mx-auto";
return (
<form
action={formAction}
className={wrapperClass}
aria-describedby="newsletter-status"
>
{/* honeypot for bots */}
<input
type="text"
name="website"
tabIndex={-1}
autoComplete="off"
className="hidden"
aria-hidden
/>
<label htmlFor="newsletter-email" className="sr-only">
Email address
</label>
<div className="flex flex-col sm:flex-row gap-3">
<input
id="newsletter-email"
type="email"
name="email"
required
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"
/>
<SubmitButton />
</div>
<p
id="newsletter-status"
role="status"
aria-live="polite"
className={`text-xs tracking-widest mt-3 ${
variant === "compact" ? "" : "text-center"
} ${
state.status === "success"
? "text-accent"
: state.status === "error"
? "text-red-400"
: "text-foreground-muted"
}`}
>
{state.message ||
"No spam. One devblog email per major update. Unsubscribe anytime."}
</p>
</form>
);
}
+88
View File
@@ -0,0 +1,88 @@
"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>
);
}
+92
View File
@@ -0,0 +1,92 @@
import type { Metadata } from "next";
import { ContactForm } from "../components/ContactForm";
export const metadata: Metadata = {
title: "Contact",
description: "Get in touch with Highland Games Studio.",
};
const channels = [
{
label: "GENERAL",
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",
},
];
export default function ContactPage() {
return (
<div className="pt-32 pb-24 px-6 lg:px-10">
<div className="max-w-4xl mx-auto">
<p className="font-display text-sm tracking-[0.3em] text-accent mb-4">
GET IN TOUCH
</p>
<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">
{channels.map((c) => (
<a
key={c.label}
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
</h2>
<p className="text-foreground-muted leading-relaxed max-w-2xl mb-10">
Prefer not to use email? Drop your message here and we&apos;ll get
back to you.
</p>
<ContactForm />
</div>
<div className="mt-24 border-t border-border pt-12">
<h2 className="font-display text-2xl tracking-wider mb-4">
PRESS KIT
</h2>
<p className="text-foreground-muted leading-relaxed mb-6 max-w-xl">
Logos, screenshots and fact sheets for our games will be available
here soon. In the meantime, drop us an email and we&apos;ll send
assets directly.
</p>
<a
href="mailto:press@highlandgamesstudio.com?subject=Press%20Kit%20Request"
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"
>
REQUEST PRESS KIT
</a>
</div>
</div>
</div>
);
}
+225
View File
@@ -0,0 +1,225 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import type { Metadata } from "next";
import { MDXRemote } from "next-mdx-remote/rsc";
import remarkGfm from "remark-gfm";
import { getAdjacentPosts, getAllPosts, getPost } from "@/lib/devblog";
type Props = { params: Promise<{ slug: string }> };
export function generateStaticParams() {
return getAllPosts().map((p) => ({ slug: p.slug }));
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const post = getPost(slug);
if (!post) return {};
const url = `https://highlandgamesstudio.com/devblog/${post.slug}`;
return {
title: post.title,
description: post.excerpt,
alternates: { canonical: url },
openGraph: {
type: "article",
title: post.title,
description: post.excerpt,
url,
publishedTime: post.date,
authors: post.author ? [post.author] : undefined,
tags: post.tags,
},
twitter: {
card: "summary_large_image",
title: post.title,
description: post.excerpt,
},
};
}
function formatDate(date: string) {
return new Date(date).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
}
const mdxComponents = {
h1: (props: React.HTMLAttributes<HTMLHeadingElement>) => (
<h1
{...props}
className="font-display text-4xl sm:text-5xl tracking-wider mt-12 mb-6 first:mt-0"
/>
),
h2: (props: React.HTMLAttributes<HTMLHeadingElement>) => (
<h2
{...props}
className="font-display text-3xl tracking-wider mt-12 mb-4"
/>
),
h3: (props: React.HTMLAttributes<HTMLHeadingElement>) => (
<h3
{...props}
className="font-display text-2xl tracking-wider mt-8 mb-3"
/>
),
p: (props: React.HTMLAttributes<HTMLParagraphElement>) => (
<p {...props} className="text-foreground-muted leading-relaxed mb-5" />
),
ul: (props: React.HTMLAttributes<HTMLUListElement>) => (
<ul
{...props}
className="text-foreground-muted leading-relaxed mb-5 ml-6 list-disc space-y-2"
/>
),
ol: (props: React.HTMLAttributes<HTMLOListElement>) => (
<ol
{...props}
className="text-foreground-muted leading-relaxed mb-5 ml-6 list-decimal space-y-2"
/>
),
a: (props: React.AnchorHTMLAttributes<HTMLAnchorElement>) => (
<a
{...props}
className="text-accent hover:text-foreground underline underline-offset-4 transition-colors"
/>
),
strong: (props: React.HTMLAttributes<HTMLElement>) => (
<strong {...props} className="text-foreground font-semibold" />
),
blockquote: (props: React.HTMLAttributes<HTMLQuoteElement>) => (
<blockquote
{...props}
className="border-l-2 border-accent pl-6 my-6 italic text-foreground"
/>
),
code: (props: React.HTMLAttributes<HTMLElement>) => (
<code
{...props}
className="bg-surface-elevated border border-border px-1.5 py-0.5 rounded text-sm text-accent"
/>
),
hr: () => <hr className="my-12 border-border" />,
};
export default async function DevblogPost({ params }: Props) {
const { slug } = await params;
const post = getPost(slug);
if (!post) notFound();
const { newer, older } = getAdjacentPosts(slug);
const url = `https://highlandgamesstudio.com/devblog/${post.slug}`;
const jsonLd = {
"@context": "https://schema.org",
"@type": "BlogPosting",
headline: post.title,
description: post.excerpt,
datePublished: post.date,
dateModified: post.date,
author: {
"@type": "Organization",
name: post.author ?? "Highland Games Studio",
},
publisher: {
"@type": "Organization",
name: "Highland Games Studio",
logo: {
"@type": "ImageObject",
url: "https://highlandgamesstudio.com/image/Studio/Logo Flat.png",
},
},
mainEntityOfPage: { "@type": "WebPage", "@id": url },
url,
keywords: post.tags?.join(", "),
};
return (
<article className="pt-32 pb-24 px-6 lg:px-10">
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<div className="max-w-3xl mx-auto">
<Link
href="/devblog"
className="inline-block font-display text-xs tracking-widest text-foreground-muted hover:text-accent transition-colors mb-12"
>
BACK TO DEVBLOG
</Link>
<header className="mb-16 pb-8 border-b border-border">
<time
dateTime={post.date}
className="font-display text-xs tracking-[0.3em] text-accent"
>
{formatDate(post.date).toUpperCase()}
</time>
<h1 className="font-display text-4xl sm:text-5xl lg:text-6xl tracking-wider leading-[0.95] mt-4 mb-6">
{post.title.toUpperCase()}
</h1>
{post.excerpt && (
<p className="text-lg text-foreground-muted leading-relaxed">
{post.excerpt}
</p>
)}
{post.author && (
<p className="text-xs tracking-widest text-foreground-muted mt-6 uppercase">
By {post.author}
</p>
)}
</header>
<div className="prose-custom">
<MDXRemote
source={post.content}
components={mdxComponents}
options={{
mdxOptions: {
remarkPlugins: [remarkGfm],
},
}}
/>
</div>
{(newer || older) && (
<nav
aria-label="Post navigation"
className="mt-20 pt-10 border-t border-border grid sm:grid-cols-2 gap-6"
>
{newer ? (
<Link
href={`/devblog/${newer.slug}`}
className="group block border border-border p-6 hover:border-accent transition-colors"
>
<p className="font-display text-xs tracking-[0.3em] text-foreground-muted group-hover:text-accent transition-colors mb-2">
NEWER
</p>
<p className="font-display text-lg tracking-wider group-hover:text-accent transition-colors">
{newer.title.toUpperCase()}
</p>
</Link>
) : (
<span aria-hidden />
)}
{older ? (
<Link
href={`/devblog/${older.slug}`}
className="group block border border-border p-6 hover:border-accent transition-colors sm:text-right"
>
<p className="font-display text-xs tracking-[0.3em] text-foreground-muted group-hover:text-accent transition-colors mb-2">
OLDER
</p>
<p className="font-display text-lg tracking-wider group-hover:text-accent transition-colors">
{older.title.toUpperCase()}
</p>
</Link>
) : (
<span aria-hidden />
)}
</nav>
)}
</div>
</article>
);
}
+206
View File
@@ -0,0 +1,206 @@
import Link from "next/link";
import type { Metadata } from "next";
import { getAllPosts } from "@/lib/devblog";
import { games as gameRegistry } from "@/lib/games";
import { Newsletter } from "../components/Newsletter";
export const metadata: Metadata = {
title: "Devblog",
description:
"Behind-the-scenes updates, design decisions, and milestones from Highland Games Studio.",
};
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 }) {
const search = new URLSearchParams();
if (params.game) search.set("game", params.game);
if (params.page && params.page > 1) search.set("page", String(params.page));
const qs = search.toString();
return qs ? `/devblog?${qs}` : "/devblog";
}
type Props = {
searchParams: Promise<{ game?: string; page?: string }>;
};
export default async function DevblogPage({ searchParams }: Props) {
const { game, page } = await searchParams;
const allPosts = getAllPosts();
const usedGameSlugs = Array.from(
new Set(allPosts.map((p) => p.game).filter((g): g is string => Boolean(g))),
);
const gameOptions = usedGameSlugs
.map((slug) => ({
slug,
title: gameRegistry.find((g) => g.slug === slug)?.title ?? slug,
}))
.sort((a, b) => a.title.localeCompare(b.title));
const filtered = allPosts.filter((p) => {
if (game && p.game !== game) return false;
return true;
});
const currentPage = Math.max(1, parseInt(page ?? "1", 10) || 1);
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
const safePage = Math.min(currentPage, totalPages);
const visible = filtered.slice(
(safePage - 1) * PAGE_SIZE,
safePage * PAGE_SIZE,
);
return (
<div className="pt-32 pb-24 px-6 lg:px-10">
<div className="max-w-4xl mx-auto">
<p className="font-display text-sm tracking-[0.3em] text-accent mb-4">
DEVBLOG
</p>
<h1 className="font-display text-5xl sm:text-6xl lg:text-7xl tracking-wider leading-[0.95] mb-8">
THE JOURNEY,
<br />
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
milestones everything that goes into building our worlds.
</p>
{gameOptions.length > 0 && (
<div className="flex flex-wrap items-center gap-2 mb-12">
<span className="font-display text-xs tracking-[0.3em] text-foreground-muted mr-2">
GAME
</span>
<Link
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
</Link>
{gameOptions.map((g) => (
<Link
key={g.slug}
href={buildHref({ game: g.slug })}
className={`text-xs tracking-widest uppercase border px-3 py-1.5 transition-colors ${
game === g.slug
? "border-accent text-accent"
: "border-border text-foreground-muted hover:border-accent hover:text-accent"
}`}
>
{g.title}
</Link>
))}
</div>
)}
{visible.length === 0 ? (
<p className="text-foreground-muted">
{game
? "No posts match these filters yet."
: "No posts yet. Check back soon."}
</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">
<span className="text-[0.65rem] tracking-widest uppercase border border-accent/40 text-accent px-2 py-1">
{postGame}
</span>
</div>
)}
<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>
)}
{totalPages > 1 && (
<nav
aria-label="Pagination"
className="flex items-center justify-between mt-12 pt-8 border-t border-border"
>
{safePage > 1 ? (
<Link
href={buildHref({ game, page: safePage - 1 })}
className="font-display text-xs tracking-widest text-foreground-muted hover:text-accent transition-colors"
>
NEWER
</Link>
) : (
<span aria-hidden />
)}
<span className="font-display text-xs tracking-widest text-foreground-muted">
PAGE {safePage} / {totalPages}
</span>
{safePage < totalPages ? (
<Link
href={buildHref({ game, page: safePage + 1 })}
className="font-display text-xs tracking-widest text-foreground-muted hover:text-accent transition-colors"
>
OLDER
</Link>
) : (
<span aria-hidden />
)}
</nav>
)}
<section className="mt-24 border-t border-border pt-16">
<p className="font-display text-xs tracking-[0.3em] text-accent mb-3">
NEWSLETTER
</p>
<h2 className="font-display text-3xl sm:text-4xl tracking-wider mb-4">
DON&apos;T MISS A SUMMIT
</h2>
<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" />
</section>
</div>
</div>
);
}
+82
View File
@@ -0,0 +1,82 @@
"use client";
import Link from "next/link";
import Image from "next/image";
import { useEffect } from "react";
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error("[app error]", error);
}, [error]);
return (
<div className="relative min-h-screen flex items-center justify-center px-6 lg:px-10 overflow-hidden isolate">
<div
aria-hidden
className="absolute inset-0 z-0"
style={{
background:
"radial-gradient(ellipse at 50% 60%, rgba(196,184,150,0.08) 0%, rgba(10,10,10,0) 60%)",
}}
/>
<div
aria-hidden
className="animate-orb-pulse absolute left-1/2 top-1/2 z-0 w-[500px] h-[500px] sm:w-[700px] sm:h-[700px] rounded-full pointer-events-none"
style={{
background:
"radial-gradient(circle, rgba(196,184,150,0.35) 0%, rgba(196,184,150,0.15) 30%, rgba(196,184,150,0) 70%)",
filter: "blur(80px)",
}}
/>
<div className="relative z-10 max-w-2xl w-full text-center pt-24 pb-12">
<Image
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">
ERROR
</p>
<h1 className="font-display text-5xl sm:text-6xl lg:text-7xl tracking-wider leading-[0.95] mb-8">
AVALANCHE
<br />
ON THE PATH.
</h1>
<p className="text-lg text-foreground-muted leading-relaxed max-w-xl mx-auto mb-4">
Something went wrong on our side. The team has been notified.
</p>
{error.digest && (
<p className="text-xs tracking-widest text-foreground-muted mb-12 uppercase">
Ref: {error.digest}
</p>
)}
<div className="flex flex-col sm:flex-row gap-4 justify-center mt-8">
<button
type="button"
onClick={reset}
className="px-8 py-3 bg-accent text-background font-display tracking-widest text-sm hover:bg-foreground transition-colors"
>
TRY AGAIN
</button>
<Link
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
</Link>
</div>
</div>
</div>
);
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

+59
View File
@@ -0,0 +1,59 @@
import { getAllPosts } from "@/lib/devblog";
const SITE_URL = "https://highlandgamesstudio.com";
const SITE_TITLE = "Highland Games Studio — Devblog";
const SITE_DESCRIPTION =
"Behind-the-scenes updates, design decisions, and milestones from Highland Games Studio.";
function escapeXml(input: string): string {
return input
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
export async function GET() {
const posts = getAllPosts();
const lastBuildDate = new Date().toUTCString();
const latest = posts[0];
const pubDate = latest
? new Date(latest.date).toUTCString()
: lastBuildDate;
const items = posts
.map((post) => {
const url = `${SITE_URL}/devblog/${post.slug}`;
return `
<item>
<title>${escapeXml(post.title)}</title>
<link>${url}</link>
<guid isPermaLink="true">${url}</guid>
<pubDate>${new Date(post.date).toUTCString()}</pubDate>
${post.author ? `<author>${escapeXml(post.author)}</author>` : ""}
<description>${escapeXml(post.excerpt || "")}</description>
</item>`;
})
.join("");
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>${escapeXml(SITE_TITLE)}</title>
<link>${SITE_URL}/devblog</link>
<description>${escapeXml(SITE_DESCRIPTION)}</description>
<language>en</language>
<lastBuildDate>${lastBuildDate}</lastBuildDate>
<pubDate>${pubDate}</pubDate>
<atom:link href="${SITE_URL}/feed.xml" rel="self" type="application/rss+xml" />${items}
</channel>
</rss>`;
return new Response(xml, {
headers: {
"Content-Type": "application/rss+xml; charset=utf-8",
"Cache-Control": "public, max-age=0, s-maxage=3600, stale-while-revalidate=86400",
},
});
}
+197
View File
@@ -0,0 +1,197 @@
import Image from "next/image";
import Link from "next/link";
import { notFound } from "next/navigation";
import type { Metadata } from "next";
import { games, getGame } from "@/lib/games";
import { getPostsByGame } from "@/lib/devblog";
function formatDate(date: string) {
return new Date(date).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
}
type Props = { params: Promise<{ slug: string }> };
export function generateStaticParams() {
return games.map((g) => ({ slug: g.slug }));
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const game = getGame(slug);
if (!game) return {};
return {
title: game.title,
description: game.tagline,
};
}
const statusLabel: Record<string, string> = {
"in-development": "IN DEVELOPMENT",
released: "RELEASED",
"coming-soon": "COMING SOON",
};
export default async function GamePage({ params }: Props) {
const { slug } = await params;
const game = getGame(slug);
if (!game) notFound();
const relatedPosts = getPostsByGame(slug).slice(0, 3);
const jsonLd = {
"@context": "https://schema.org",
"@type": "VideoGame",
name: game.title,
description: game.description,
genre: game.genres,
publisher: {
"@type": "Organization",
name: "Highland Games Studio",
url: "https://highlandgamesstudio.com",
},
url: `https://highlandgamesstudio.com/games/${game.slug}`,
};
return (
<article className="pt-24 pb-24">
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
{/* HERO */}
<section className="relative aspect-[21/9] sm:aspect-[21/8] bg-surface border-b border-border overflow-hidden">
<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 */}
<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">
<h2 className="font-display text-3xl tracking-wider mb-6">ABOUT</h2>
<p className="text-foreground-muted leading-relaxed text-lg">
{game.description}
</p>
</div>
<aside className="space-y-8">
<div>
<h3 className="font-display text-xs tracking-[0.3em] text-accent mb-3">
GENRES
</h3>
<div className="flex flex-wrap gap-2">
{game.genres.map((g) => (
<span
key={g}
className="text-xs tracking-widest uppercase border border-border px-3 py-1.5"
>
{g}
</span>
))}
</div>
</div>
{game.releaseWindow && (
<div>
<h3 className="font-display text-xs tracking-[0.3em] text-accent mb-3">
RELEASE
</h3>
<p className="text-foreground">{game.releaseWindow}</p>
</div>
)}
{game.steamUrl && (
<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 && (
<section className="max-w-6xl mx-auto px-6 lg:px-10 mt-24">
<div className="flex items-baseline justify-between mb-8">
<div>
<p className="font-display text-xs tracking-[0.3em] text-accent mb-2">
DEVBLOG
</p>
<h2 className="font-display text-3xl sm:text-4xl tracking-wider">
LATEST ARTICLES
</h2>
</div>
<Link
href="/devblog"
className="hidden sm:inline-block font-display text-xs tracking-widest text-foreground-muted hover:text-accent transition-colors"
>
ALL POSTS
</Link>
</div>
<div className="space-y-1">
{relatedPosts.map((post) => (
<Link
key={post.slug}
href={`/devblog/${post.slug}`}
className="group block py-6 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-2">
<h3 className="font-display text-xl sm:text-2xl tracking-wider group-hover:text-accent transition-colors">
{post.title.toUpperCase()}
</h3>
<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>
)}
</Link>
))}
</div>
</section>
)}
</article>
);
}
+61
View File
@@ -0,0 +1,61 @@
import Link from "next/link";
import Image from "next/image";
import type { Metadata } from "next";
import { games } from "@/lib/games";
export const metadata: Metadata = {
title: "Games",
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() {
return (
<div className="pt-32 pb-24 px-6 lg:px-10">
<div className="max-w-6xl mx-auto">
<p className="font-display text-sm tracking-[0.3em] text-accent mb-4">
OUR WORLDS
</p>
<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">
{games.map((game) => (
<Link
key={game.slug}
href={`/games/${game.slug}`}
className="group relative aspect-[16/9] sm:aspect-[21/9] bg-surface border border-border overflow-hidden block hover:border-accent transition-colors"
>
<div className="absolute inset-0 flex items-center justify-center">
<Image
src="/image/Studio/Logo Flat.png"
alt=""
width={140}
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>
</div>
</div>
);
}
+83
View File
@@ -0,0 +1,83 @@
@import "tailwindcss";
:root {
--background: #0a0a0a;
--foreground: #e8dfcc;
--foreground-muted: #a89e8a;
--surface: #141414;
--surface-elevated: #1c1c1c;
--border: #2a2a2a;
--accent: #c4b896;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-foreground-muted: var(--foreground-muted);
--color-surface: var(--surface);
--color-surface-elevated: var(--surface-elevated);
--color-border: var(--border);
--color-accent: var(--accent);
--font-display: var(--font-bebas), "Bebas Neue", "Arial Narrow", sans-serif;
--font-sans: var(--font-inter), system-ui, -apple-system, sans-serif;
}
* {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
html,
body {
background: var(--background);
color: var(--foreground);
font-family: var(--font-sans);
}
::selection {
background: var(--accent);
color: var(--background);
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: var(--background);
}
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--foreground-muted);
}
.font-display {
font-family: var(--font-display);
letter-spacing: 0.02em;
}
.text-balance {
text-wrap: balance;
}
@keyframes orb-pulse {
0%, 100% {
opacity: 0.55;
transform: translate(-50%, -50%) scale(1);
}
50% {
opacity: 0.85;
transform: translate(-50%, -50%) scale(1.08);
}
}
.animate-orb-pulse {
animation: orb-pulse 6s ease-in-out infinite;
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

+90
View File
@@ -0,0 +1,90 @@
import type { Metadata } from "next";
import { Bebas_Neue, Inter } from "next/font/google";
import "./globals.css";
import { Navbar } from "./components/Navbar";
import { Footer } from "./components/Footer";
const bebas = Bebas_Neue({
variable: "--font-bebas",
weight: "400",
subsets: ["latin"],
});
const inter = Inter({
variable: "--font-inter",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: {
default: "Highland Games Studio — Where summits become worlds",
template: "%s — Highland Games Studio",
},
description:
"Indie game studio crafting survival adventures. Where summits become worlds.",
metadataBase: new URL("https://highlandgamesstudio.com"),
openGraph: {
title: "Highland Games Studio",
description: "Where summits become worlds.",
type: "website",
siteName: "Highland Games Studio",
},
twitter: {
card: "summary_large_image",
title: "Highland Games Studio",
description: "Where summits become worlds.",
},
alternates: {
types: {
"application/rss+xml": [
{ url: "/feed.xml", title: "Highland Games Studio — Devblog" },
],
},
},
};
const organizationJsonLd = {
"@context": "https://schema.org",
"@type": "Organization",
name: "Highland Games Studio",
url: "https://highlandgamesstudio.com",
logo: "https://highlandgamesstudio.com/image/Studio/Logo Flat.png",
description:
"Indie game studio crafting survival adventures. Where summits become worlds.",
slogan: "Where summits become worlds",
foundingDate: "2026",
email: "hello@highlandgamesstudio.com",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html
lang="en"
className={`${bebas.variable} ${inter.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col bg-background text-foreground">
<a
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"
>
Skip to content
</a>
<Navbar />
<main id="main" className="flex-1 flex flex-col">
{children}
</main>
<Footer />
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(organizationJsonLd),
}}
/>
</body>
</html>
);
}
+128
View File
@@ -0,0 +1,128 @@
import type { Metadata } from "next";
import Link from "next/link";
export const metadata: Metadata = {
title: "Legal Notice",
description: "Legal information about Highland Games Studio.",
robots: { index: true, follow: true },
};
export default function LegalNoticePage() {
return (
<div className="pt-32 pb-24 px-6 lg:px-10">
<div className="max-w-3xl mx-auto">
<p className="font-display text-sm tracking-[0.3em] text-accent mb-4">
LEGAL
</p>
<h1 className="font-display text-5xl sm:text-6xl tracking-wider leading-[0.95] mb-12">
LEGAL NOTICE
</h1>
<div className="space-y-10 text-foreground-muted leading-relaxed">
<section>
<h2 className="font-display text-2xl tracking-wider text-foreground mb-3">
PUBLISHER
</h2>
<p>
This website is published by{" "}
<strong>Mathew Simon</strong>, operating under the project name
{" "}<strong>Highland Games Studio</strong>. The studio is
currently pre-incorporation; this notice will be updated when the
company is registered.
</p>
<ul className="mt-4 space-y-1 text-sm">
<li>Publication director: Mathew Simon</li>
<li>
Postal address: available on request at{" "}
<a
href="mailto:hello@highlandgamesstudio.com"
className="text-accent hover:text-foreground transition-colors"
>
hello@highlandgamesstudio.com
</a>
</li>
<li>
Contact:{" "}
<a
href="mailto:hello@highlandgamesstudio.com"
className="text-accent hover:text-foreground transition-colors"
>
hello@highlandgamesstudio.com
</a>
</li>
</ul>
</section>
<section>
<h2 className="font-display text-2xl tracking-wider text-foreground mb-3">
HOSTING
</h2>
<p>
This website is hosted by <strong>Hetzner Online GmbH</strong>,
Industriestraße 25, 91710 Gunzenhausen, Germany.{" "}
<a
href="https://www.hetzner.com"
target="_blank"
rel="noopener noreferrer"
className="text-accent hover:text-foreground transition-colors"
>
hetzner.com
</a>
</p>
</section>
<section>
<h2 className="font-display text-2xl tracking-wider text-foreground mb-3">
INTELLECTUAL PROPERTY
</h2>
<p>
All content on this website (texts, logos, images, videos, source
code, game artwork) is the exclusive property of Highland Games
Studio, unless otherwise stated. Any reproduction, representation,
modification, publication or adaptation, in whole or in part, is
prohibited without prior written authorization.
</p>
<p className="mt-3">
Third-party trademarks mentioned on this site (e.g. Steam,
Discord) remain the property of their respective owners.
</p>
</section>
<section>
<h2 className="font-display text-2xl tracking-wider text-foreground mb-3">
LIABILITY
</h2>
<p>
Highland Games Studio strives to provide accurate information on
this website. However, it cannot be held liable for omissions,
inaccuracies, or any direct or indirect damage resulting from the
use of this site or external sites linked from it.
</p>
</section>
<section>
<h2 className="font-display text-2xl tracking-wider text-foreground mb-3">
APPLICABLE LAW
</h2>
<p>
This legal notice is governed by French law. Any dispute related
to the use of this website falls under the exclusive jurisdiction
of the competent French courts.
</p>
</section>
<p className="pt-8 border-t border-border text-sm">
See also our{" "}
<Link
href="/legal/privacy"
className="text-accent hover:text-foreground transition-colors"
>
Privacy Policy
</Link>
.
</p>
</div>
</div>
</div>
);
}
+164
View File
@@ -0,0 +1,164 @@
import type { Metadata } from "next";
import Link from "next/link";
export const metadata: Metadata = {
title: "Privacy Policy",
description: "How Highland Games Studio handles your personal data.",
robots: { index: true, follow: true },
};
export default function PrivacyPage() {
return (
<div className="pt-32 pb-24 px-6 lg:px-10">
<div className="max-w-3xl mx-auto">
<p className="font-display text-sm tracking-[0.3em] text-accent mb-4">
LEGAL
</p>
<h1 className="font-display text-5xl sm:text-6xl tracking-wider leading-[0.95] mb-6">
PRIVACY POLICY
</h1>
<p className="text-sm tracking-widest text-foreground-muted uppercase mb-12">
Last updated: May 2026
</p>
<div className="space-y-10 text-foreground-muted leading-relaxed">
<section>
<p>
Highland Games Studio respects your privacy. This page explains
what data we collect, why, and how you can exercise your rights
under the General Data Protection Regulation (GDPR).
</p>
</section>
<section>
<h2 className="font-display text-2xl tracking-wider text-foreground mb-3">
DATA CONTROLLER
</h2>
<p>
The data controller for this website is{" "}
<strong>Mathew Simon</strong>, operating under the project name
Highland Games Studio. Contact:{" "}
<a
href="mailto:hello@highlandgamesstudio.com"
className="text-accent hover:text-foreground transition-colors"
>
hello@highlandgamesstudio.com
</a>
.
</p>
</section>
<section>
<h2 className="font-display text-2xl tracking-wider text-foreground mb-3">
DATA WE COLLECT
</h2>
<p>
We only collect data that is strictly necessary to operate this
site:
</p>
<ul className="mt-3 space-y-2 list-disc pl-6">
<li>
<strong>Newsletter</strong>: if you sign up, we store your email
address with the sole purpose of sending you devblog updates.
</li>
<li>
<strong>Contact</strong>: when you email us, we receive the
contents of your message and your email address.
</li>
<li>
<strong>Server logs</strong>: our hosting provider may keep
technical logs (IP address, user agent, requested URL) for
security and debugging.
</li>
</ul>
</section>
<section>
<h2 className="font-display text-2xl tracking-wider text-foreground mb-3">
COOKIES & TRACKING
</h2>
<p>
This website does not use advertising or third-party tracking
cookies. No analytics tool is currently installed.
</p>
</section>
<section>
<h2 className="font-display text-2xl tracking-wider text-foreground mb-3">
LEGAL BASIS & RETENTION
</h2>
<ul className="mt-3 space-y-2 list-disc pl-6">
<li>
Newsletter: based on your <strong>consent</strong>. Stored until
you unsubscribe.
</li>
<li>
Contact emails: based on our <strong>legitimate interest</strong>
{" "}in answering you. Kept for up to 3 years after our last
exchange.
</li>
<li>
Server logs: kept for a maximum of 12 months by our hosting
provider.
</li>
</ul>
</section>
<section>
<h2 className="font-display text-2xl tracking-wider text-foreground mb-3">
YOUR RIGHTS
</h2>
<p>
Under the GDPR, you can request access, rectification, deletion,
restriction, or portability of your personal data, and object to
its processing. To exercise these rights, email us at{" "}
<a
href="mailto:hello@highlandgamesstudio.com"
className="text-accent hover:text-foreground transition-colors"
>
hello@highlandgamesstudio.com
</a>
.
</p>
<p className="mt-3">
You also have the right to lodge a complaint with the CNIL
(Commission Nationale de l&apos;Informatique et des Libertés),
the French data protection authority.{" "}
<a
href="https://www.cnil.fr"
target="_blank"
rel="noopener noreferrer"
className="text-accent hover:text-foreground transition-colors"
>
cnil.fr
</a>
</p>
</section>
<section>
<h2 className="font-display text-2xl tracking-wider text-foreground mb-3">
THIRD PARTIES
</h2>
<p>
We do not sell or rent your data. The site is hosted by{" "}
<strong>Hetzner Online GmbH</strong> (Germany), which acts as a
data processor and is bound by appropriate agreements. Server
data stays within the European Union.
</p>
</section>
<p className="pt-8 border-t border-border text-sm">
See also our{" "}
<Link
href="/legal/notice"
className="text-accent hover:text-foreground transition-colors"
>
Legal Notice
</Link>
.
</p>
</div>
</div>
</div>
);
}
+72
View File
@@ -0,0 +1,72 @@
import Link from "next/link";
import Image from "next/image";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Lost on the climb",
description: "This summit doesn't exist — yet.",
};
export default function NotFound() {
return (
<div className="relative min-h-screen flex items-center justify-center px-6 lg:px-10 overflow-hidden isolate">
<div
aria-hidden
className="absolute inset-0 z-0"
style={{
background:
"radial-gradient(ellipse at 50% 60%, rgba(196,184,150,0.08) 0%, rgba(10,10,10,0) 60%)",
}}
/>
{/* Glowing orb */}
<div
aria-hidden
className="animate-orb-pulse absolute left-1/2 top-1/2 z-0 w-[500px] h-[500px] sm:w-[700px] sm:h-[700px] rounded-full pointer-events-none"
style={{
background:
"radial-gradient(circle, rgba(196,184,150,0.35) 0%, rgba(196,184,150,0.15) 30%, rgba(196,184,150,0) 70%)",
filter: "blur(80px)",
}}
/>
<div className="relative z-10 max-w-2xl w-full text-center pt-24 pb-12">
<Image
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">
404
</p>
<h1 className="font-display text-5xl sm:text-6xl lg:text-7xl tracking-wider leading-[0.95] mb-8">
LOST ON
<br />
THE CLIMB.
</h1>
<p className="text-lg text-foreground-muted leading-relaxed max-w-xl mx-auto mb-12">
This summit doesn&apos;t exist yet. The page you&apos;re looking for
may have drifted off the map.
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Link
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
</Link>
<Link
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
</Link>
</div>
</div>
</div>
);
}
+67
View File
@@ -0,0 +1,67 @@
import { ImageResponse } from "next/og";
export const runtime = "edge";
export const alt = "Highland Games Studio — Where summits become worlds";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
export default async function OpengraphImage() {
return new ImageResponse(
(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
background:
"radial-gradient(ellipse at 50% 60%, rgba(196,184,150,0.12) 0%, #0a0a0a 60%)",
color: "#e8dfcc",
fontFamily: "sans-serif",
padding: 80,
}}
>
<div
style={{
fontSize: 24,
letterSpacing: 12,
color: "#c4b896",
marginBottom: 32,
display: "flex",
}}
>
HIGHLAND GAMES STUDIO
</div>
<div
style={{
fontSize: 120,
fontWeight: 800,
letterSpacing: 4,
lineHeight: 1,
textAlign: "center",
display: "flex",
flexDirection: "column",
}}
>
<span>WHERE SUMMITS</span>
<span style={{ color: "#c4b896" }}>BECOME WORLDS.</span>
</div>
<div
style={{
marginTop: 48,
fontSize: 28,
color: "#a89e8a",
display: "flex",
}}
>
Indie survival adventures.
</div>
</div>
),
{ ...size }
);
}
+248
View File
@@ -0,0 +1,248 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { motion } from "framer-motion";
export default function Home() {
return (
<>
{/* HERO */}
<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">
<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
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
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
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/project-one"
className="group relative aspect-[16/9] bg-surface border border-border overflow-hidden block hover:border-accent transition-colors duration-500"
>
<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>
{/* 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>
</>
);
}
+16
View File
@@ -0,0 +1,16 @@
import type { MetadataRoute } from "next";
const SITE_URL = "https://highlandgamesstudio.com";
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: "*",
allow: "/",
},
],
sitemap: `${SITE_URL}/sitemap.xml`,
host: SITE_URL,
};
}
+35
View File
@@ -0,0 +1,35 @@
import type { MetadataRoute } from "next";
import { games } from "@/lib/games";
import { getAllPosts } from "@/lib/devblog";
const SITE_URL = "https://highlandgamesstudio.com";
export default function sitemap(): MetadataRoute.Sitemap {
const now = new Date();
const staticRoutes: MetadataRoute.Sitemap = [
{ url: `${SITE_URL}/`, lastModified: now, changeFrequency: "monthly", priority: 1 },
{ url: `${SITE_URL}/studio`, lastModified: now, changeFrequency: "monthly", priority: 0.8 },
{ url: `${SITE_URL}/games`, lastModified: now, changeFrequency: "weekly", priority: 0.9 },
{ url: `${SITE_URL}/devblog`, lastModified: now, changeFrequency: "weekly", priority: 0.8 },
{ url: `${SITE_URL}/contact`, lastModified: now, changeFrequency: "yearly", priority: 0.5 },
{ url: `${SITE_URL}/legal/notice`, lastModified: now, changeFrequency: "yearly", priority: 0.2 },
{ url: `${SITE_URL}/legal/privacy`, lastModified: now, changeFrequency: "yearly", priority: 0.2 },
];
const gameRoutes: MetadataRoute.Sitemap = games.map((g) => ({
url: `${SITE_URL}/games/${g.slug}`,
lastModified: now,
changeFrequency: "monthly",
priority: 0.7,
}));
const postRoutes: MetadataRoute.Sitemap = getAllPosts().map((p) => ({
url: `${SITE_URL}/devblog/${p.slug}`,
lastModified: new Date(p.date),
changeFrequency: "yearly",
priority: 0.6,
}));
return [...staticRoutes, ...gameRoutes, ...postRoutes];
}
+115
View File
@@ -0,0 +1,115 @@
import Image from "next/image";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Studio",
description:
"Highland Games Studio is a two-person indie team crafting survival adventures.",
};
const team = [
{
name: "Founder One",
role: "Co-founder · Programming · Design",
bio: "Builds the systems, gameplay loops and tools that bring our worlds to life.",
},
{
name: "Founder Two",
role: "Co-founder · Art · Design",
bio: "Shapes the visual identity, environments and feel of every Highland Games world.",
},
];
export default function StudioPage() {
return (
<div className="pt-32 pb-24 px-6 lg:px-10">
<div className="max-w-4xl mx-auto">
<p className="font-display text-sm tracking-[0.3em] text-accent mb-4">
THE STUDIO
</p>
<h1 className="font-display text-5xl sm:text-6xl lg:text-7xl tracking-wider leading-[0.95] mb-8">
A SMALL TEAM,
<br />
BIG HORIZONS.
</h1>
<p className="text-lg text-foreground-muted leading-relaxed max-w-2xl">
Highland Games Studio is an independent game studio of two friends
building survival worlds we&apos;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>
{/* MISSION */}
<section className="max-w-4xl mx-auto mt-24 grid md:grid-cols-3 gap-8">
<div className="border border-border p-8">
<p className="font-display text-xs tracking-[0.3em] text-accent mb-4">
01
</p>
<h3 className="font-display text-2xl tracking-wider mb-3">
CRAFT
</h3>
<p className="text-sm text-foreground-muted leading-relaxed">
Every system, asset and line of dialogue is touched by hand. No
shortcuts on what matters.
</p>
</div>
<div className="border border-border p-8">
<p className="font-display text-xs tracking-[0.3em] text-accent mb-4">
02
</p>
<h3 className="font-display text-2xl tracking-wider mb-3">
SURVIVE
</h3>
<p className="text-sm text-foreground-muted leading-relaxed">
We love the genre. The tension between scarcity and discovery is at
the core of every game we make.
</p>
</div>
<div className="border border-border p-8">
<p className="font-display text-xs tracking-[0.3em] text-accent mb-4">
03
</p>
<h3 className="font-display text-2xl tracking-wider mb-3">
WONDER
</h3>
<p className="text-sm text-foreground-muted leading-relaxed">
A summit isn&apos;t the end of the climb it&apos;s the start of a
new world. We design for that feeling.
</p>
</div>
</section>
{/* TEAM */}
<section className="max-w-4xl mx-auto mt-24">
<h2 className="font-display text-3xl sm:text-4xl tracking-wider mb-12">
THE CLIMBERS
</h2>
<div className="grid sm:grid-cols-2 gap-8">
{team.map((m) => (
<div key={m.name} className="border border-border p-8 bg-surface">
<div className="w-20 h-20 bg-surface-elevated border border-border flex items-center justify-center mb-6">
<Image
src="/image/Studio/Logo Flat.png"
alt=""
width={40}
height={28}
className="opacity-40"
/>
</div>
<h3 className="font-display text-2xl tracking-wider mb-1">
{m.name}
</h3>
<p className="text-xs tracking-widest text-accent mb-4 uppercase">
{m.role}
</p>
<p className="text-sm text-foreground-muted leading-relaxed">
{m.bio}
</p>
</div>
))}
</div>
</section>
</div>
);
}
+15
View File
@@ -0,0 +1,15 @@
"use client";
import { motion } from "framer-motion";
export default function Template({ children }: { children: React.ReactNode }) {
return (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.35, ease: [0.22, 1, 0.36, 1] }}
>
{children}
</motion.div>
);
}
+30
View File
@@ -0,0 +1,30 @@
---
title: "Welcome to the Highland"
date: "2026-05-09"
author: "Highland Games Studio"
excerpt: "We're two friends building a survival game and a studio at the same time. Here's where we're starting."
tags: ["studio", "intro"]
game: "project-one"
---
# Welcome to the Highland
We're a two-person indie studio, and today we're putting our first pin on the map.
## 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.
## What we're working on
Our first project is in early development. The core loop is forming: build, explore, survive. We're not ready to share screenshots yet, but we'll be posting devlog updates here as the world takes shape.
## What you can expect from this devblog
- Honest progress updates, the good and the messy
- Design decisions and the thinking behind them
- Behind-the-scenes peeks at art, code, and prototypes
If you want to follow the climb, this is the place. See you at the next summit.
— Highland Games Studio
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
+70
View File
@@ -0,0 +1,70 @@
import fs from "node:fs";
import path from "node:path";
import matter from "gray-matter";
export type DevblogPost = {
slug: string;
title: string;
date: string;
excerpt: string;
author?: string;
tags?: string[];
game?: string;
content: string;
};
const POSTS_DIR = path.join(process.cwd(), "content", "devblog");
function ensureDir() {
if (!fs.existsSync(POSTS_DIR)) {
fs.mkdirSync(POSTS_DIR, { recursive: true });
}
}
export function getAllPosts(): DevblogPost[] {
ensureDir();
const files = fs
.readdirSync(POSTS_DIR)
.filter((f) => f.endsWith(".mdx") || f.endsWith(".md"));
const posts = files.map((file) => {
const slug = file.replace(/\.(mdx|md)$/, "");
const raw = fs.readFileSync(path.join(POSTS_DIR, file), "utf8");
const { data, content } = matter(raw);
return {
slug,
title: data.title ?? slug,
date: data.date ?? new Date().toISOString().slice(0, 10),
excerpt: data.excerpt ?? "",
author: data.author,
tags: data.tags ?? [],
game: data.game,
content,
} as DevblogPost;
});
return posts.sort((a, b) => (a.date < b.date ? 1 : -1));
}
export function getPost(slug: string): DevblogPost | undefined {
return getAllPosts().find((p) => p.slug === slug);
}
export function getPostsByGame(gameSlug: string): DevblogPost[] {
return getAllPosts().filter(
(p) => p.game === gameSlug || p.tags?.includes(gameSlug),
);
}
export function getAdjacentPosts(slug: string): {
newer?: DevblogPost;
older?: DevblogPost;
} {
const posts = getAllPosts();
const idx = posts.findIndex((p) => p.slug === slug);
if (idx === -1) return {};
return {
newer: idx > 0 ? posts[idx - 1] : undefined,
older: idx < posts.length - 1 ? posts[idx + 1] : undefined,
};
}
+27
View File
@@ -0,0 +1,27 @@
export type Game = {
slug: string;
title: string;
status: "in-development" | "released" | "coming-soon";
tagline: string;
description: string;
genres: string[];
releaseWindow?: string;
steamUrl?: string;
};
export const games: Game[] = [
{
slug: "project-one",
title: "Project One",
status: "in-development",
tagline: "A survival adventure across shifting horizons.",
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.",
genres: ["Survival", "Crafting", "Exploration"],
releaseWindow: "TBA",
},
];
export function getGame(slug: string): Game | undefined {
return games.find((g) => g.slug === slug);
}
+7
View File
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;
+8901
View File
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
{
"name": "studio",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@mdx-js/loader": "^3.1.1",
"@mdx-js/react": "^3.1.1",
"@next/mdx": "^16.2.6",
"@types/mdx": "^2.0.13",
"framer-motion": "^12.38.0",
"gray-matter": "^4.0.3",
"next": "16.2.6",
"next-mdx-remote": "^6.0.0",
"react": "19.2.4",
"react-dom": "19.2.4",
"remark-gfm": "^4.0.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.6",
"tailwindcss": "^4",
"typescript": "^5"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

+79
View File
@@ -0,0 +1,79 @@
import sharp from "sharp";
import { writeFileSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
const ROOT = resolve(import.meta.dirname, "..");
const SOURCE = resolve(ROOT, "public/image/Studio/Logo Flat.png");
const OUT_ICON_PNG = resolve(ROOT, "app/icon.png");
const OUT_FAVICON_ICO = resolve(ROOT, "app/favicon.ico");
const OUT_PUBLIC_PNG = resolve(ROOT, "public/image/Studio/favicon.png");
const SIZE = 512;
const MOUNTAIN_FILL = 0.78;
async function makeFavicon(size) {
const circle = Buffer.from(
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}">
<circle cx="${size / 2}" cy="${size / 2}" r="${size / 2}" fill="#000000"/>
</svg>`
);
const mountainTargetW = Math.round(size * MOUNTAIN_FILL);
const mountain = await sharp(SOURCE)
.resize({ width: mountainTargetW })
.toBuffer();
const mountainMeta = await sharp(mountain).metadata();
return sharp(circle)
.composite([
{
input: mountain,
top: Math.round((size - mountainMeta.height) / 2) + Math.round(size * 0.03),
left: Math.round((size - mountainMeta.width) / 2),
},
])
.png()
.toBuffer();
}
function buildIco(pngBuffers) {
const count = pngBuffers.length;
const header = Buffer.alloc(6);
header.writeUInt16LE(0, 0);
header.writeUInt16LE(1, 2);
header.writeUInt16LE(count, 4);
const dirEntries = [];
let offset = 6 + count * 16;
for (const { buffer, size } of pngBuffers) {
const entry = Buffer.alloc(16);
entry.writeUInt8(size === 256 ? 0 : size, 0);
entry.writeUInt8(size === 256 ? 0 : size, 1);
entry.writeUInt8(0, 2);
entry.writeUInt8(0, 3);
entry.writeUInt16LE(1, 4);
entry.writeUInt16LE(32, 6);
entry.writeUInt32LE(buffer.length, 8);
entry.writeUInt32LE(offset, 12);
dirEntries.push(entry);
offset += buffer.length;
}
return Buffer.concat([header, ...dirEntries, ...pngBuffers.map((p) => p.buffer)]);
}
const big = await makeFavicon(SIZE);
writeFileSync(OUT_ICON_PNG, big);
writeFileSync(OUT_PUBLIC_PNG, big);
const icoSizes = [16, 32, 48];
const icoPngs = await Promise.all(
icoSizes.map(async (s) => ({
size: s,
buffer: await sharp(big).resize(s, s).png({ compressionLevel: 9 }).toBuffer(),
}))
);
writeFileSync(OUT_FAVICON_ICO, buildIco(icoPngs));
console.log("icon.png:", big.length, "bytes");
console.log("favicon.ico:", readFileSync(OUT_FAVICON_ICO).length, "bytes");
+34
View File
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}