feat: case-study template, all pages, routing, sitemap, 404
- case-study components: CsHeader, TldrBox, MiniToc, SectionRenderer, DecisionList, ArchDiagram (data-driven), Gallery, NextPrev, CaseStudyPage - standalone pages: projects index, about, experience, contact (with form), cv hub, colophon — all EN/NO compositions - full route tree: EN root + /no localised slugs (prosjekter, om-meg, erfaring, kontakt, kolofon, hjemmelab) per ROUTING_SPEC - bilingual 404 with trace-fray motif; robots.txt + sitemap.xml from slug map - Portrait atom; reusable sections gain header/eyebrow toggles for standalone pages Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,121 @@
|
|||||||
|
---
|
||||||
|
/*
|
||||||
|
Data-driven architecture diagram (COLOUR_SYSTEM §3.5): surface nodes, accent flow,
|
||||||
|
amber for external systems, mono labels. Fully legible with zero interaction; the
|
||||||
|
<title>/<desc> give it an accessible text alternative (A5).
|
||||||
|
*/
|
||||||
|
interface Node {
|
||||||
|
id: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
kind: 'internal' | 'primary' | 'external';
|
||||||
|
label: string;
|
||||||
|
sub?: string;
|
||||||
|
}
|
||||||
|
interface Edge {
|
||||||
|
d: string;
|
||||||
|
kind: 'flow' | 'external';
|
||||||
|
label?: string;
|
||||||
|
labelX?: number;
|
||||||
|
labelY?: number;
|
||||||
|
}
|
||||||
|
interface Props {
|
||||||
|
diagram: {
|
||||||
|
viewBox: string;
|
||||||
|
title: string;
|
||||||
|
desc: string;
|
||||||
|
nodes: Node[];
|
||||||
|
edges: Edge[];
|
||||||
|
};
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
const { diagram, class: cls } = Astro.props;
|
||||||
|
|
||||||
|
const nodeStroke = {
|
||||||
|
internal: 'var(--border-strong)',
|
||||||
|
primary: 'var(--accent)',
|
||||||
|
external: 'var(--amber)',
|
||||||
|
};
|
||||||
|
---
|
||||||
|
|
||||||
|
<figure class:list={['overflow-hidden rounded-md border border-line bg-surface-1 p-4', cls]}>
|
||||||
|
<svg
|
||||||
|
viewBox={diagram.viewBox}
|
||||||
|
role="img"
|
||||||
|
aria-labelledby="diagram-title diagram-desc"
|
||||||
|
class="w-full"
|
||||||
|
style="min-width: 640px;"
|
||||||
|
>
|
||||||
|
<title id="diagram-title">{diagram.title}</title>
|
||||||
|
<desc id="diagram-desc">{diagram.desc}</desc>
|
||||||
|
|
||||||
|
{/* Edges first (under nodes) */}
|
||||||
|
{
|
||||||
|
diagram.edges.map((e) => (
|
||||||
|
<g>
|
||||||
|
<path
|
||||||
|
d={e.d}
|
||||||
|
fill="none"
|
||||||
|
stroke={e.kind === 'external' ? 'var(--amber)' : 'var(--accent)'}
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-dasharray={e.kind === 'external' ? '4 4' : undefined}
|
||||||
|
/>
|
||||||
|
{e.label && e.labelX !== undefined && e.labelY !== undefined && (
|
||||||
|
<text
|
||||||
|
x={e.labelX}
|
||||||
|
y={e.labelY}
|
||||||
|
fill={e.kind === 'external' ? 'var(--amber)' : 'var(--ink-faint)'}
|
||||||
|
font-family="var(--font-mono)"
|
||||||
|
font-size="11"
|
||||||
|
text-anchor="middle"
|
||||||
|
>
|
||||||
|
{e.label}
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
{/* Nodes */}
|
||||||
|
{
|
||||||
|
diagram.nodes.map((n) => (
|
||||||
|
<g>
|
||||||
|
<rect
|
||||||
|
x={n.x}
|
||||||
|
y={n.y}
|
||||||
|
width={n.w}
|
||||||
|
height={n.h}
|
||||||
|
rx="8"
|
||||||
|
fill="var(--surface-2)"
|
||||||
|
stroke={nodeStroke[n.kind]}
|
||||||
|
stroke-opacity={n.kind === 'internal' ? '1' : '0.55'}
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x={n.x + n.w / 2}
|
||||||
|
y={n.y + n.h / 2 + (n.sub ? -4 : 5)}
|
||||||
|
fill="var(--ink)"
|
||||||
|
font-family="var(--font-body)"
|
||||||
|
font-size="14"
|
||||||
|
text-anchor="middle"
|
||||||
|
>
|
||||||
|
{n.label}
|
||||||
|
</text>
|
||||||
|
{n.sub && (
|
||||||
|
<text
|
||||||
|
x={n.x + n.w / 2}
|
||||||
|
y={n.y + n.h / 2 + 16}
|
||||||
|
fill="var(--ink-faint)"
|
||||||
|
font-family="var(--font-mono)"
|
||||||
|
font-size="11"
|
||||||
|
text-anchor="middle"
|
||||||
|
>
|
||||||
|
{n.sub}
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</svg>
|
||||||
|
</figure>
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
---
|
||||||
|
import type { Locale } from '@i18n/locales';
|
||||||
|
import type { ProjectPageId } from '@i18n/slugMap';
|
||||||
|
import Base from '@layouts/Base.astro';
|
||||||
|
import { getProject, getMeta } from '@lib/content';
|
||||||
|
import CsHeader from '@components/case-study/CsHeader.astro';
|
||||||
|
import TldrBox from '@components/case-study/TldrBox.astro';
|
||||||
|
import MiniToc from '@components/case-study/MiniToc.astro';
|
||||||
|
import SectionRenderer from '@components/case-study/SectionRenderer.astro';
|
||||||
|
import NextPrev from '@components/case-study/NextPrev.astro';
|
||||||
|
import FramedImage from '@components/ui/FramedImage.astro';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
id: ProjectPageId;
|
||||||
|
locale: Locale;
|
||||||
|
}
|
||||||
|
const { id, locale } = Astro.props;
|
||||||
|
const project = getProject(id, locale);
|
||||||
|
const m = getMeta(id, locale);
|
||||||
|
const hero = project.media[0];
|
||||||
|
const isCapability = project.template === 'capability';
|
||||||
|
const projectType = id === 'inboxintel' ? 'SoftwareSourceCode' : 'SoftwareApplication';
|
||||||
|
---
|
||||||
|
|
||||||
|
<Base
|
||||||
|
locale={locale}
|
||||||
|
pageId={id}
|
||||||
|
title={m.title}
|
||||||
|
description={m.description}
|
||||||
|
ogType="article"
|
||||||
|
jsonLdOpts={isCapability ? { projectName: project.name } : { projectName: project.name, projectType }}
|
||||||
|
>
|
||||||
|
<article class="mx-auto max-w-[--content-max] px-6 py-14">
|
||||||
|
<CsHeader locale={locale} project={project} />
|
||||||
|
|
||||||
|
{hero && <div class="mt-8"><FramedImage media={hero} priority index={0} url={`${id}.local`} /></div>}
|
||||||
|
|
||||||
|
<div class:list={['mt-12 grid gap-12', !isCapability && 'xl:grid-cols-[1fr_240px]']}>
|
||||||
|
<div>
|
||||||
|
{!isCapability && <TldrBox locale={locale} tldr={project.tldr} />}
|
||||||
|
<div>
|
||||||
|
{
|
||||||
|
project.sections.map((s) => (
|
||||||
|
<SectionRenderer
|
||||||
|
locale={locale}
|
||||||
|
section={s}
|
||||||
|
diagram={project.diagram}
|
||||||
|
media={project.media}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isCapability && <MiniToc locale={locale} sections={project.sections} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NextPrev locale={locale} currentId={id} />
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import '@scripts/lightbox';
|
||||||
|
</script>
|
||||||
|
</Base>
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
---
|
||||||
|
import type { Locale } from '@i18n/locales';
|
||||||
|
import { useDict } from '@i18n/t';
|
||||||
|
import type { ProjectVM } from '@lib/content';
|
||||||
|
import SectionLabel from '@components/ui/SectionLabel.astro';
|
||||||
|
import Chip from '@components/ui/Chip.astro';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
locale: Locale;
|
||||||
|
project: ProjectVM;
|
||||||
|
}
|
||||||
|
const { locale, project } = Astro.props;
|
||||||
|
const d = useDict(locale);
|
||||||
|
const eyebrow =
|
||||||
|
project.template === 'case-study' ? d.project.caseStudyEyebrow : d.project.capabilityEyebrow;
|
||||||
|
const linkLabel: Record<string, string> = {
|
||||||
|
repo: d.project.repository,
|
||||||
|
live: d.project.liveDemo,
|
||||||
|
docs: 'Docs',
|
||||||
|
};
|
||||||
|
---
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<SectionLabel text={eyebrow} />
|
||||||
|
<div class="mt-4 flex flex-wrap items-center gap-4">
|
||||||
|
<h1 class="text-h1">{project.name}</h1>
|
||||||
|
<Chip kind="status" status={project.status} label={d.project.status[project.status]} />
|
||||||
|
</div>
|
||||||
|
<p class="mt-4 max-w-[60ch] text-body-lg text-ink-muted">{project.valueProp}</p>
|
||||||
|
|
||||||
|
<div class="mt-5 flex flex-wrap items-center gap-2">
|
||||||
|
{project.stack.map((s) => <Chip kind="stack" label={s.name} tooltip={s.context} />)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{
|
||||||
|
project.links.length > 0 && (
|
||||||
|
<div class="mt-5 flex flex-wrap gap-4">
|
||||||
|
{project.links
|
||||||
|
.filter((l) => l.public)
|
||||||
|
.map((l) => (
|
||||||
|
<a
|
||||||
|
href={l.url}
|
||||||
|
rel="noopener"
|
||||||
|
class="inline-flex items-center gap-1 text-body text-accent hover:underline"
|
||||||
|
>
|
||||||
|
{linkLabel[l.type] ?? l.type} ↗
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</header>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
---
|
||||||
|
interface Decision {
|
||||||
|
n: number;
|
||||||
|
choice: string;
|
||||||
|
alternative: string;
|
||||||
|
rationale: string;
|
||||||
|
}
|
||||||
|
interface Props {
|
||||||
|
decisions: Decision[];
|
||||||
|
}
|
||||||
|
const { decisions } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<ol class="flex flex-col gap-5">
|
||||||
|
{
|
||||||
|
decisions.map((dec) => (
|
||||||
|
<li class="rounded-md border border-line bg-surface-1 p-5">
|
||||||
|
<p class="flex gap-3">
|
||||||
|
<span class="font-mono text-body font-semibold text-accent">
|
||||||
|
{String(dec.n).padStart(2, '0')}
|
||||||
|
</span>
|
||||||
|
<span class="font-semibold text-ink">{dec.choice}</span>
|
||||||
|
</p>
|
||||||
|
<p class="mt-2 pl-8 text-small text-ink-faint">
|
||||||
|
<span class="font-mono">alt:</span> {dec.alternative}
|
||||||
|
</p>
|
||||||
|
<p class="mt-2 pl-8 text-body text-ink-muted">{dec.rationale}</p>
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</ol>
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
---
|
||||||
|
import FramedImage from '@components/ui/FramedImage.astro';
|
||||||
|
|
||||||
|
interface Media {
|
||||||
|
src: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
alt: string;
|
||||||
|
caption: string;
|
||||||
|
}
|
||||||
|
interface Props {
|
||||||
|
media: Media[];
|
||||||
|
/** Index offset so lightbox indices stay unique across the page. */
|
||||||
|
startIndex?: number;
|
||||||
|
}
|
||||||
|
const { media, startIndex = 0 } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<div class="grid gap-6 sm:grid-cols-2">
|
||||||
|
{
|
||||||
|
media.map((m, i) => (
|
||||||
|
<figure>
|
||||||
|
<FramedImage media={m} index={startIndex + i} />
|
||||||
|
<figcaption class="mt-2 font-mono text-mono-meta text-ink-faint">{m.caption}</figcaption>
|
||||||
|
</figure>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
---
|
||||||
|
import type { Locale } from '@i18n/locales';
|
||||||
|
import { useDict } from '@i18n/t';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
locale: Locale;
|
||||||
|
sections: { heading: string; anchorId: string }[];
|
||||||
|
}
|
||||||
|
const { locale, sections } = Astro.props;
|
||||||
|
const d = useDict(locale);
|
||||||
|
---
|
||||||
|
|
||||||
|
<nav class="sticky top-24 hidden xl:block" aria-label={d.project.onThisPage}>
|
||||||
|
<p class="mono-label mb-3">{d.project.onThisPage}</p>
|
||||||
|
<ul class="flex flex-col gap-2 border-l border-line">
|
||||||
|
{
|
||||||
|
sections.map((s) => (
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href={`#${s.anchorId}`}
|
||||||
|
data-spy-link={s.anchorId}
|
||||||
|
class="toc-link -ml-px block border-l border-transparent py-1 pl-4 text-small text-ink-muted transition-colors hover:text-ink"
|
||||||
|
>
|
||||||
|
{s.heading}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.toc-link.is-active {
|
||||||
|
color: var(--ink);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
---
|
||||||
|
import type { Locale } from '@i18n/locales';
|
||||||
|
import { useDict } from '@i18n/t';
|
||||||
|
import { pathFor } from '@i18n/slugMap';
|
||||||
|
import { getProjectCards } from '@lib/content';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
locale: Locale;
|
||||||
|
currentId: string;
|
||||||
|
}
|
||||||
|
const { locale, currentId } = Astro.props;
|
||||||
|
const d = useDict(locale);
|
||||||
|
const cards = getProjectCards(locale);
|
||||||
|
const idx = cards.findIndex((c) => c.id === currentId);
|
||||||
|
const prev = idx > 0 ? cards[idx - 1] : undefined;
|
||||||
|
const next = idx < cards.length - 1 ? cards[idx + 1] : undefined;
|
||||||
|
---
|
||||||
|
|
||||||
|
<nav
|
||||||
|
class="mt-20 flex flex-col gap-4 border-t border-line pt-8 sm:flex-row sm:items-center sm:justify-between"
|
||||||
|
aria-label={d.project.all}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
{
|
||||||
|
prev && (
|
||||||
|
<a href={prev.path} class="group inline-flex items-center gap-2 text-body text-ink-muted hover:text-ink">
|
||||||
|
<span class="transition-transform group-hover:-translate-x-1">←</span>
|
||||||
|
<span>
|
||||||
|
<span class="mono-label block">{d.project.prev}</span>
|
||||||
|
{prev.name}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a href={pathFor('projects', locale)} class="text-body text-accent hover:underline">
|
||||||
|
{d.project.all}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="sm:text-right">
|
||||||
|
{
|
||||||
|
next && (
|
||||||
|
<a href={next.path} class="group inline-flex items-center gap-2 text-body text-ink-muted hover:text-ink">
|
||||||
|
<span>
|
||||||
|
<span class="mono-label block">{d.project.next}</span>
|
||||||
|
{next.name}
|
||||||
|
</span>
|
||||||
|
<span class="transition-transform group-hover:translate-x-1">→</span>
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
---
|
||||||
|
import type { Locale } from '@i18n/locales';
|
||||||
|
import ArchDiagram from '@components/case-study/ArchDiagram.astro';
|
||||||
|
import DecisionList from '@components/case-study/DecisionList.astro';
|
||||||
|
import Gallery from '@components/case-study/Gallery.astro';
|
||||||
|
|
||||||
|
interface Block {
|
||||||
|
type: 'p' | 'ul';
|
||||||
|
text?: string;
|
||||||
|
items?: string[];
|
||||||
|
}
|
||||||
|
interface Section {
|
||||||
|
kind: string;
|
||||||
|
heading: string;
|
||||||
|
anchorId: string;
|
||||||
|
blocks?: Block[];
|
||||||
|
decisions?: { n: number; choice: string; alternative: string; rationale: string }[];
|
||||||
|
}
|
||||||
|
interface Media {
|
||||||
|
src: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
alt: string;
|
||||||
|
caption: string;
|
||||||
|
}
|
||||||
|
interface Diagram {
|
||||||
|
viewBox: string;
|
||||||
|
title: string;
|
||||||
|
desc: string;
|
||||||
|
nodes: any[];
|
||||||
|
edges: any[];
|
||||||
|
}
|
||||||
|
interface Props {
|
||||||
|
locale: Locale;
|
||||||
|
section: Section;
|
||||||
|
diagram?: Diagram;
|
||||||
|
media?: Media[];
|
||||||
|
}
|
||||||
|
const { section, diagram, media = [] } = Astro.props;
|
||||||
|
const galleryMedia = media.slice(1); // media[0] is the hero shown above the article
|
||||||
|
---
|
||||||
|
|
||||||
|
<section id={section.anchorId} data-spy class="pt-14 first:pt-0">
|
||||||
|
<h2 class="text-h3">{section.heading}</h2>
|
||||||
|
|
||||||
|
{
|
||||||
|
section.blocks && (
|
||||||
|
<div class="prose-measure mt-4 flex flex-col gap-4">
|
||||||
|
{section.blocks.map((b) =>
|
||||||
|
b.type === 'p' ? (
|
||||||
|
<p class="text-body leading-relaxed text-ink-muted">{b.text}</p>
|
||||||
|
) : (
|
||||||
|
<ul class="flex flex-col gap-2">
|
||||||
|
{b.items?.map((it) => (
|
||||||
|
<li class="flex gap-3 text-body text-ink-muted">
|
||||||
|
<span class="mt-2.5 h-1 w-1 shrink-0 rounded-full bg-accent" aria-hidden="true" />
|
||||||
|
<span>{it}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
{section.kind === 'architecture' && diagram && <ArchDiagram diagram={diagram} class="mt-6" />}
|
||||||
|
|
||||||
|
{section.kind === 'decisions' && section.decisions && (
|
||||||
|
<div class="mt-6"><DecisionList decisions={section.decisions} /></div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{
|
||||||
|
section.kind === 'screenshots' &&
|
||||||
|
(galleryMedia.length > 0 ? (
|
||||||
|
<div class="mt-6"><Gallery media={galleryMedia} startIndex={1} /></div>
|
||||||
|
) : null)
|
||||||
|
}
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
---
|
||||||
|
import type { Locale } from '@i18n/locales';
|
||||||
|
import { useDict } from '@i18n/t';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
locale: Locale;
|
||||||
|
tldr: { what: string; why: string; stack: string; role: string };
|
||||||
|
}
|
||||||
|
const { locale, tldr } = Astro.props;
|
||||||
|
const d = useDict(locale);
|
||||||
|
const rows: [string, string][] = [
|
||||||
|
[d.project.tldr.what, tldr.what],
|
||||||
|
[d.project.tldr.why, tldr.why],
|
||||||
|
[d.project.tldr.stack, tldr.stack],
|
||||||
|
[d.project.tldr.role, tldr.role],
|
||||||
|
];
|
||||||
|
---
|
||||||
|
|
||||||
|
<div class="relative overflow-hidden rounded-md bg-surface-2 p-6 pl-7">
|
||||||
|
<span class="absolute inset-y-0 left-0 w-[3px] bg-accent" aria-hidden="true"></span>
|
||||||
|
<p class="mono-label mb-4">TL;DR</p>
|
||||||
|
<dl class="flex flex-col gap-2.5">
|
||||||
|
{
|
||||||
|
rows.map(([term, val]) => (
|
||||||
|
<div class="flex flex-col gap-1 sm:flex-row sm:gap-3">
|
||||||
|
<dt class="w-16 shrink-0 font-semibold text-ink">{term}</dt>
|
||||||
|
<dd class="text-ink-muted">{val}</dd>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
@@ -28,7 +28,7 @@ const d = useDict(locale);
|
|||||||
</div>
|
</div>
|
||||||
<div class="lg:col-span-4">
|
<div class="lg:col-span-4">
|
||||||
<p class="font-mono text-mono-label font-medium uppercase tracking-[0.08em] text-ink-faint">
|
<p class="font-mono text-mono-label font-medium uppercase tracking-[0.08em] text-ink-faint">
|
||||||
Languages
|
{d.about.languages}
|
||||||
</p>
|
</p>
|
||||||
<ul class="mt-3 flex flex-col gap-2">
|
<ul class="mt-3 flex flex-col gap-2">
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import SectionLabel from '@components/ui/SectionLabel.astro';
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
locale: Locale;
|
locale: Locale;
|
||||||
compact?: boolean; // when true (experience page), render heading differently
|
eyebrow?: boolean; // false on the standalone experience page (it has its own H1)
|
||||||
}
|
}
|
||||||
const { locale } = Astro.props;
|
const { locale, eyebrow = true } = Astro.props;
|
||||||
const d = useDict(locale);
|
const d = useDict(locale);
|
||||||
const items = getExperience(locale);
|
const items = getExperience(locale);
|
||||||
const firstAlongside = items.findIndex((i) => i.alongside);
|
const firstAlongside = items.findIndex((i) => i.alongside);
|
||||||
@@ -17,9 +17,19 @@ const firstAlongside = items.findIndex((i) => i.alongside);
|
|||||||
const period = (from: number, to: number | null) => `${from}–${to ?? d.home.present}`;
|
const period = (from: number, to: number | null) => `${from}–${to ?? d.home.present}`;
|
||||||
---
|
---
|
||||||
|
|
||||||
<section id={homeAnchor('experience', locale)} class="mx-auto max-w-[--content-max] px-6 pt-24" data-reveal>
|
<section
|
||||||
|
id={homeAnchor('experience', locale)}
|
||||||
|
class:list={['mx-auto max-w-[--content-max] px-6', eyebrow && 'pt-24']}
|
||||||
|
data-reveal
|
||||||
|
>
|
||||||
|
{
|
||||||
|
eyebrow && (
|
||||||
|
<>
|
||||||
<SectionLabel number="03" text={d.home.experienceLabel} />
|
<SectionLabel number="03" text={d.home.experienceLabel} />
|
||||||
<h2 class="mt-4 text-h2">{d.home.experienceHeading}</h2>
|
<h2 class="mt-4 text-h2">{d.home.experienceHeading}</h2>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
<div class="relative mt-10 pl-8">
|
<div class="relative mt-10 pl-8">
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { ProfileVM } from '@lib/content';
|
|||||||
import Chip from '@components/ui/Chip.astro';
|
import Chip from '@components/ui/Chip.astro';
|
||||||
import Button from '@components/ui/Button.astro';
|
import Button from '@components/ui/Button.astro';
|
||||||
import TraceMotif from '@components/ui/TraceMotif.astro';
|
import TraceMotif from '@components/ui/TraceMotif.astro';
|
||||||
|
import Portrait from '@components/ui/Portrait.astro';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
locale: Locale;
|
locale: Locale;
|
||||||
@@ -13,7 +14,6 @@ interface Props {
|
|||||||
}
|
}
|
||||||
const { locale, profile } = Astro.props;
|
const { locale, profile } = Astro.props;
|
||||||
const d = useDict(locale);
|
const d = useDict(locale);
|
||||||
const isPlaceholder = profile.photo.src.startsWith('placeholder:');
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<section class="relative overflow-hidden">
|
<section class="relative overflow-hidden">
|
||||||
@@ -47,26 +47,11 @@ const isPlaceholder = profile.photo.src.startsWith('placeholder:');
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="lg:col-span-5">
|
<div class="lg:col-span-5">
|
||||||
<div class="mx-auto max-w-sm">
|
<Portrait
|
||||||
<div class="overflow-hidden rounded-lg border border-line-strong bg-surface-2">
|
|
||||||
{
|
|
||||||
isPlaceholder ? (
|
|
||||||
<div class="dot-grid flex aspect-[4/5] items-center justify-center">
|
|
||||||
<span class="mono-meta text-ink-faint">[ portrait ]</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<img
|
|
||||||
src={profile.photo.src}
|
src={profile.photo.src}
|
||||||
alt={profile.photo.alt}
|
alt={profile.photo.alt}
|
||||||
width="800"
|
caption={profile.photo.caption}
|
||||||
height="1000"
|
|
||||||
class="aspect-[4/5] w-full object-cover"
|
|
||||||
/>
|
/>
|
||||||
)
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<p class="mt-2 font-mono text-mono-meta text-ink-faint">{profile.photo.caption}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -9,17 +9,30 @@ import FramedImage from '@components/ui/FramedImage.astro';
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
locale: Locale;
|
locale: Locale;
|
||||||
|
number?: string;
|
||||||
|
heading?: string;
|
||||||
|
header?: boolean; // false on the standalone projects index (it has its own H1)
|
||||||
}
|
}
|
||||||
const { locale } = Astro.props;
|
const { locale, number = '02', heading, header = true } = Astro.props;
|
||||||
const d = useDict(locale);
|
const d = useDict(locale);
|
||||||
const projects = getProjectCards(locale);
|
const projects = getProjectCards(locale);
|
||||||
const caseStudies = projects.filter((p) => p.template === 'case-study');
|
const caseStudies = projects.filter((p) => p.template === 'case-study');
|
||||||
const capabilities = projects.filter((p) => p.template === 'capability');
|
const capabilities = projects.filter((p) => p.template === 'capability');
|
||||||
---
|
---
|
||||||
|
|
||||||
<section id={homeAnchor('projects', locale)} class="mx-auto max-w-[--content-max] px-6 pt-24" data-reveal>
|
<section
|
||||||
<SectionLabel number="02" text={d.home.projectsLabel} />
|
id={homeAnchor('projects', locale)}
|
||||||
<h2 class="mt-4 text-h2">{d.home.projectsHeading}</h2>
|
class:list={['mx-auto max-w-[--content-max] px-6', header && 'pt-24']}
|
||||||
|
data-reveal
|
||||||
|
>
|
||||||
|
{
|
||||||
|
header && (
|
||||||
|
<>
|
||||||
|
<SectionLabel number={number} text={d.home.projectsLabel} />
|
||||||
|
<h2 class="mt-4 text-h2">{heading ?? d.home.projectsHeading}</h2>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
<div class="mt-8 grid gap-6 lg:grid-cols-2">
|
<div class="mt-8 grid gap-6 lg:grid-cols-2">
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
---
|
||||||
|
import type { Locale } from '@i18n/locales';
|
||||||
|
import { useDict } from '@i18n/t';
|
||||||
|
import Base from '@layouts/Base.astro';
|
||||||
|
import { getProfile, getMeta } from '@lib/content';
|
||||||
|
import SectionLabel from '@components/ui/SectionLabel.astro';
|
||||||
|
import Portrait from '@components/ui/Portrait.astro';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
locale: Locale;
|
||||||
|
}
|
||||||
|
const { locale } = Astro.props;
|
||||||
|
const d = useDict(locale);
|
||||||
|
const profile = getProfile(locale);
|
||||||
|
const m = getMeta('about', locale);
|
||||||
|
---
|
||||||
|
|
||||||
|
<Base locale={locale} pageId="about" title={m.title} description={m.description} ogType="profile">
|
||||||
|
<div class="mx-auto grid max-w-[--content-max] gap-12 px-6 py-14 lg:grid-cols-12">
|
||||||
|
<div class="lg:col-span-7">
|
||||||
|
<SectionLabel text={d.nav.about} />
|
||||||
|
<h1 class="mt-4 text-h1">{profile.aboutHeading}</h1>
|
||||||
|
<div class="mt-6 flex flex-col gap-4">
|
||||||
|
{profile.aboutParagraphs.map((para) => <p class="max-w-[64ch] text-body-lg text-ink-muted">{para}</p>)}
|
||||||
|
</div>
|
||||||
|
<p class="mt-8">
|
||||||
|
<span class="mono-label mb-2 block">{d.about.interests}</span>
|
||||||
|
<span class="text-body text-ink-muted">{profile.interestsLine}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside class="lg:col-span-5">
|
||||||
|
<Portrait src={profile.photo.src} alt={profile.photo.alt} caption={profile.photo.caption} />
|
||||||
|
<div class="mt-8">
|
||||||
|
<p class="mono-label mb-3">{d.about.languages}</p>
|
||||||
|
<ul class="flex flex-col gap-2">
|
||||||
|
{
|
||||||
|
profile.languages.map((lang) => (
|
||||||
|
<li class="text-body text-ink">
|
||||||
|
{lang.label} <span class="text-ink-muted">— {lang.level}</span>
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</Base>
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
---
|
||||||
|
/*
|
||||||
|
Colophon — "how this site works" (CONTENT_STRATEGY §4). A deliberate P2 hook: it
|
||||||
|
states the production decisions behind the site honestly. Copy authored per locale.
|
||||||
|
*/
|
||||||
|
import type { Locale } from '@i18n/locales';
|
||||||
|
import { useDict } from '@i18n/t';
|
||||||
|
import Base from '@layouts/Base.astro';
|
||||||
|
import { getMeta } from '@lib/content';
|
||||||
|
import SectionLabel from '@components/ui/SectionLabel.astro';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
locale: Locale;
|
||||||
|
}
|
||||||
|
const { locale } = Astro.props;
|
||||||
|
const d = useDict(locale);
|
||||||
|
const m = getMeta('colophon', locale);
|
||||||
|
|
||||||
|
const copy = {
|
||||||
|
en: {
|
||||||
|
lead: 'This site is a small work sample in its own right. A few of the decisions behind it:',
|
||||||
|
points: [
|
||||||
|
['Static by default', 'Built with Astro and served as static HTML — no client framework runtime, so the largest content paint is just text and CSS. The three interactive parts (menu, lightbox, contact form) are tiny progressive-enhancement modules.'],
|
||||||
|
['Bilingual from the ground up', 'English and Norwegian are equal citizens: a typed slug map is the single source of truth for routes, the language switch and hreflang, so they can never drift apart.'],
|
||||||
|
['Motion that confirms, never performs', 'One signature animation, everything else is restraint. Every effect has a reduced-motion variant, and content is never gated behind an animation.'],
|
||||||
|
['Self-hosted', 'Built and deployed from my own Gitea instance to Docker behind nginx. The contact form is a small stateless .NET service — even that is part of my own stack.'],
|
||||||
|
['No tracking', 'No analytics beacons, no third-party fonts, no cookies — which is also why there is no cookie banner.'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
no: {
|
||||||
|
lead: 'Denne siden er også et lite arbeidsprøve i seg selv. Noen av valgene bak den:',
|
||||||
|
points: [
|
||||||
|
['Statisk som standard', 'Bygget med Astro og servert som statisk HTML — ingen klient-rammeverk i drift, så det første innholdet som vises er bare tekst og CSS. De tre interaktive delene (meny, lightbox, kontaktskjema) er små moduler for progressiv forbedring.'],
|
||||||
|
['Tospråklig fra bunnen', 'Engelsk og norsk er likestilt: et typet slug-kart er én kilde til sannhet for ruter, språkbytte og hreflang, så de kan aldri komme i utakt.'],
|
||||||
|
['Bevegelse som bekrefter, aldri opptrer', 'Én signaturanimasjon, ellers tilbakeholdenhet. Hver effekt har en variant for redusert bevegelse, og innhold er aldri sperret bak en animasjon.'],
|
||||||
|
['Egendriftet', 'Bygget og rullet ut fra min egen Gitea-instans til Docker bak nginx. Kontaktskjemaet er en liten tilstandsløs .NET-tjeneste — også den er en del av min egen stack.'],
|
||||||
|
['Ingen sporing', 'Ingen analyse-beacons, ingen tredjeparts-fonter, ingen informasjonskapsler — som også er grunnen til at det ikke finnes noe cookie-banner.'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}[locale];
|
||||||
|
---
|
||||||
|
|
||||||
|
<Base locale={locale} pageId="colophon" title={m.title} description={m.description}>
|
||||||
|
<div class="mx-auto max-w-3xl px-6 py-16">
|
||||||
|
<SectionLabel text={d.footer.colophon} />
|
||||||
|
<h1 class="mt-4 text-h1">{d.footer.colophon}</h1>
|
||||||
|
<p class="mt-4 text-body-lg text-ink-muted">{copy.lead}</p>
|
||||||
|
|
||||||
|
<dl class="mt-8 flex flex-col gap-6">
|
||||||
|
{
|
||||||
|
copy.points.map(([term, desc]) => (
|
||||||
|
<div class="rounded-md border border-line bg-surface-1 p-5">
|
||||||
|
<dt class="text-h4 font-semibold text-ink">{term}</dt>
|
||||||
|
<dd class="mt-2 text-body text-ink-muted">{desc}</dd>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</Base>
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
---
|
||||||
|
import type { Locale } from '@i18n/locales';
|
||||||
|
import { useDict } from '@i18n/t';
|
||||||
|
import Base from '@layouts/Base.astro';
|
||||||
|
import { getProfile, getMeta } from '@lib/content';
|
||||||
|
import SectionLabel from '@components/ui/SectionLabel.astro';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
locale: Locale;
|
||||||
|
}
|
||||||
|
const { locale } = Astro.props;
|
||||||
|
const d = useDict(locale);
|
||||||
|
const profile = getProfile(locale);
|
||||||
|
const m = getMeta('contact', locale);
|
||||||
|
|
||||||
|
const fieldClass =
|
||||||
|
'w-full rounded-sm border border-line-strong bg-surface-1 px-3 py-2.5 text-body text-ink transition-colors focus:border-accent focus:outline-none aria-[invalid=true]:border-danger';
|
||||||
|
---
|
||||||
|
|
||||||
|
<Base locale={locale} pageId="contact" title={m.title} description={m.description}>
|
||||||
|
<div class="mx-auto grid max-w-[--content-max] gap-12 px-6 py-14 lg:grid-cols-12">
|
||||||
|
<div class="lg:col-span-7">
|
||||||
|
<SectionLabel text={d.nav.contact} />
|
||||||
|
<h1 class="mt-4 text-h1">{profile.contactHeading}</h1>
|
||||||
|
<p class="mt-3 text-body-lg text-ink-muted">{profile.contactBody}</p>
|
||||||
|
|
||||||
|
<form
|
||||||
|
id="contact-form"
|
||||||
|
class="mt-8 flex flex-col gap-5"
|
||||||
|
data-msg-required={d.form.required}
|
||||||
|
data-msg-email={d.form.invalidEmail}
|
||||||
|
novalidate
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<label for="cf-name" class="mb-1.5 block text-small font-medium text-ink">{d.form.name}</label>
|
||||||
|
<input id="cf-name" name="name" type="text" autocomplete="name" class={fieldClass} />
|
||||||
|
<p data-error-for="name" hidden role="alert" class="mt-1.5 text-small text-danger"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="cf-email" class="mb-1.5 block text-small font-medium text-ink">{d.form.email}</label>
|
||||||
|
<input id="cf-email" name="email" type="email" autocomplete="email" class={fieldClass} />
|
||||||
|
<p data-error-for="email" hidden role="alert" class="mt-1.5 text-small text-danger"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="cf-message" class="mb-1.5 block text-small font-medium text-ink">{d.form.message}</label>
|
||||||
|
<textarea id="cf-message" name="message" rows="5" class={fieldClass}></textarea>
|
||||||
|
<p data-error-for="message" hidden role="alert" class="mt-1.5 text-small text-danger"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Honeypot — hidden from users and assistive tech */}
|
||||||
|
<div class="sr-only" aria-hidden="true">
|
||||||
|
<label for="cf-company">Company</label>
|
||||||
|
<input id="cf-company" name="company" type="text" tabindex="-1" autocomplete="off" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
data-sending={d.form.sending}
|
||||||
|
class="inline-flex w-fit items-center justify-center rounded-sm bg-accent px-5 py-2.5 text-body font-semibold text-accent-ink transition-[filter] duration-[--dur-quick] hover:brightness-105"
|
||||||
|
>
|
||||||
|
<span data-label>{d.form.send}</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div id="form-success" hidden class="mt-8 rounded-md border border-accent/40 bg-surface-2 p-6">
|
||||||
|
<p class="flex items-center gap-2 text-body font-semibold text-accent">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<path class="check-path" d="M5 12l5 5 9-11" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||||
|
</svg>
|
||||||
|
{d.form.successTitle}
|
||||||
|
</p>
|
||||||
|
<p class="mt-2 text-body text-ink-muted">{d.form.successBody}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="form-error" hidden class="mt-8 rounded-md border border-danger/40 bg-surface-2 p-6">
|
||||||
|
<p class="text-body font-semibold text-danger">{d.form.errorTitle}</p>
|
||||||
|
<p class="mt-2 text-body text-ink-muted">
|
||||||
|
{d.form.errorBody}
|
||||||
|
<a class="text-accent hover:underline" href={`mailto:${profile.links.email}`}>{profile.links.email}</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside class="lg:col-span-5">
|
||||||
|
<p class="mono-label mb-3">{d.form.orEmail}</p>
|
||||||
|
<ul class="flex flex-col gap-4">
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href={`mailto:${profile.links.email}`}
|
||||||
|
data-copy={profile.links.email}
|
||||||
|
data-copied-label={d.form.copied}
|
||||||
|
class="inline-flex items-center gap-2 text-body text-ink hover:text-accent"
|
||||||
|
>
|
||||||
|
<span data-copy-label>{profile.links.email}</span>
|
||||||
|
<span class="text-ink-faint" aria-hidden="true">⧉</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li><a href={profile.links.linkedin} rel="me noopener" class="text-body text-ink hover:text-accent">LinkedIn ↗</a></li>
|
||||||
|
<li><a href={profile.links.gitea} rel="me noopener" class="text-body text-ink hover:text-accent">git.cesnimda.uk ↗</a></li>
|
||||||
|
<li><a href={profile.links.phoneHref} class="text-body text-ink hover:text-accent">{profile.links.phone}</a></li>
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import '@scripts/form';
|
||||||
|
</script>
|
||||||
|
</Base>
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
---
|
||||||
|
import type { Locale } from '@i18n/locales';
|
||||||
|
import { useDict, interpolate } from '@i18n/t';
|
||||||
|
import Base from '@layouts/Base.astro';
|
||||||
|
import { getProfile, getMeta } from '@lib/content';
|
||||||
|
import SectionLabel from '@components/ui/SectionLabel.astro';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
locale: Locale;
|
||||||
|
}
|
||||||
|
const { locale } = Astro.props;
|
||||||
|
const d = useDict(locale);
|
||||||
|
const profile = getProfile(locale);
|
||||||
|
const m = getMeta('cv', locale);
|
||||||
|
|
||||||
|
const cards = [
|
||||||
|
{ code: 'EN', label: d.cv.english, cv: profile.cv.en },
|
||||||
|
{ code: 'NO', label: d.cv.norsk, cv: profile.cv.no },
|
||||||
|
];
|
||||||
|
---
|
||||||
|
|
||||||
|
<Base locale={locale} pageId="cv" title={m.title} description={m.description}>
|
||||||
|
<div class="mx-auto max-w-3xl px-6 py-16">
|
||||||
|
<SectionLabel text="CV" />
|
||||||
|
<h1 class="mt-4 text-h1">{d.cvPage.heading}</h1>
|
||||||
|
<p class="mt-4 text-body-lg text-ink-muted">{d.cvPage.intro}</p>
|
||||||
|
|
||||||
|
<div class="mt-8 grid gap-4 sm:grid-cols-2">
|
||||||
|
{
|
||||||
|
cards.map((c) => (
|
||||||
|
<div class="flex flex-col rounded-md border border-line bg-surface-1 p-6">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="rounded-sm bg-surface-2 px-2 py-1 font-mono text-mono-label text-accent">
|
||||||
|
{c.code}
|
||||||
|
</span>
|
||||||
|
<span class="text-body font-semibold text-ink">{c.label}</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 font-mono text-mono-meta text-ink-faint">
|
||||||
|
{interpolate(d.cv.fileMeta, { size: c.cv.sizeKb })} · {d.cvPage.updated} {c.cv.updated}
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href={c.cv.path}
|
||||||
|
download
|
||||||
|
class="mt-5 inline-flex items-center justify-center gap-2 rounded-sm bg-accent px-4 py-2.5 text-body font-semibold text-accent-ink transition-[filter] duration-[--dur-quick] hover:brightness-105"
|
||||||
|
>
|
||||||
|
{d.cv.download}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="mt-6 font-mono text-mono-meta text-ink-faint">{d.cvPage.atsNote}</p>
|
||||||
|
</div>
|
||||||
|
</Base>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
---
|
||||||
|
import type { Locale } from '@i18n/locales';
|
||||||
|
import { useDict } from '@i18n/t';
|
||||||
|
import Base from '@layouts/Base.astro';
|
||||||
|
import { getMeta } from '@lib/content';
|
||||||
|
import SectionLabel from '@components/ui/SectionLabel.astro';
|
||||||
|
import ExperienceTimeline from '@components/home/ExperienceTimeline.astro';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
locale: Locale;
|
||||||
|
}
|
||||||
|
const { locale } = Astro.props;
|
||||||
|
const d = useDict(locale);
|
||||||
|
const m = getMeta('experience', locale);
|
||||||
|
---
|
||||||
|
|
||||||
|
<Base locale={locale} pageId="experience" title={m.title} description={m.description}>
|
||||||
|
<div class="mx-auto max-w-[--content-max] px-6 pt-14">
|
||||||
|
<SectionLabel text={d.home.experienceLabel} />
|
||||||
|
<h1 class="mt-4 text-h1">{d.home.experienceHeading}</h1>
|
||||||
|
<p class="mt-4 max-w-[62ch] text-body-lg text-ink-muted">{m.description}</p>
|
||||||
|
</div>
|
||||||
|
<div class="mt-8">
|
||||||
|
<ExperienceTimeline locale={locale} eyebrow={false} />
|
||||||
|
</div>
|
||||||
|
</Base>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
---
|
||||||
|
import type { Locale } from '@i18n/locales';
|
||||||
|
import { useDict } from '@i18n/t';
|
||||||
|
import Base from '@layouts/Base.astro';
|
||||||
|
import { getMeta } from '@lib/content';
|
||||||
|
import SectionLabel from '@components/ui/SectionLabel.astro';
|
||||||
|
import ProjectCards from '@components/home/ProjectCards.astro';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
locale: Locale;
|
||||||
|
}
|
||||||
|
const { locale } = Astro.props;
|
||||||
|
const d = useDict(locale);
|
||||||
|
const m = getMeta('projects', locale);
|
||||||
|
---
|
||||||
|
|
||||||
|
<Base locale={locale} pageId="projects" title={m.title} description={m.description}>
|
||||||
|
<div class="mx-auto max-w-[--content-max] px-6 pt-14">
|
||||||
|
<SectionLabel text={d.home.projectsLabel} />
|
||||||
|
<h1 class="mt-4 text-h1">{d.home.projectsHeading}</h1>
|
||||||
|
<p class="mt-4 max-w-[62ch] text-body-lg text-ink-muted">{m.description}</p>
|
||||||
|
</div>
|
||||||
|
<div class="mt-10">
|
||||||
|
<ProjectCards locale={locale} header={false} />
|
||||||
|
</div>
|
||||||
|
</Base>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
---
|
||||||
|
/* Framed portrait, placeholder-aware. Real headshot drops into the same content ref. */
|
||||||
|
interface Props {
|
||||||
|
src: string;
|
||||||
|
alt: string;
|
||||||
|
caption: string;
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
const { src, alt, caption, class: cls } = Astro.props;
|
||||||
|
const isPlaceholder = src.startsWith('placeholder:');
|
||||||
|
---
|
||||||
|
|
||||||
|
<div class:list={['mx-auto max-w-sm', cls]}>
|
||||||
|
<div class="overflow-hidden rounded-lg border border-line-strong bg-surface-2">
|
||||||
|
{
|
||||||
|
isPlaceholder ? (
|
||||||
|
<div class="dot-grid flex aspect-[4/5] items-center justify-center">
|
||||||
|
<span class="mono-meta text-ink-faint">[ portrait ]</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<img src={src} alt={alt} width="800" height="1000" class="aspect-[4/5] w-full object-cover" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 font-mono text-mono-meta text-ink-faint">{caption}</p>
|
||||||
|
</div>
|
||||||
@@ -23,6 +23,18 @@ export const en: Dictionary = {
|
|||||||
about: 'About',
|
about: 'About',
|
||||||
contact: 'Contact',
|
contact: 'Contact',
|
||||||
},
|
},
|
||||||
|
about: {
|
||||||
|
languages: 'Languages',
|
||||||
|
interests: 'Interests',
|
||||||
|
},
|
||||||
|
cvPage: {
|
||||||
|
heading: 'Download my CV',
|
||||||
|
intro:
|
||||||
|
'For a full overview of my experience, skills and work history, download my CV in either language.',
|
||||||
|
atsNote:
|
||||||
|
'These are plain, ATS-friendly PDFs — single column, real text, standard headings.',
|
||||||
|
updated: 'Updated',
|
||||||
|
},
|
||||||
lang: {
|
lang: {
|
||||||
switchTo: 'Switch to Norwegian',
|
switchTo: 'Switch to Norwegian',
|
||||||
en: 'EN',
|
en: 'EN',
|
||||||
|
|||||||
@@ -34,6 +34,18 @@ export const no: Dictionary = {
|
|||||||
about: 'Om meg',
|
about: 'Om meg',
|
||||||
contact: 'Kontakt',
|
contact: 'Kontakt',
|
||||||
},
|
},
|
||||||
|
about: {
|
||||||
|
languages: 'Språk',
|
||||||
|
interests: 'Interesser',
|
||||||
|
},
|
||||||
|
cvPage: {
|
||||||
|
heading: 'Last ned CV-en min',
|
||||||
|
intro:
|
||||||
|
'For en full oversikt over erfaring, kompetanse og arbeidshistorikk kan du laste ned CV-en min på ønsket språk.',
|
||||||
|
atsNote:
|
||||||
|
'Dette er enkle, ATS-vennlige PDF-er — én kolonne, ekte tekst og standard overskrifter.',
|
||||||
|
updated: 'Oppdatert',
|
||||||
|
},
|
||||||
lang: {
|
lang: {
|
||||||
switchTo: 'Switch to English',
|
switchTo: 'Switch to English',
|
||||||
en: 'EN',
|
en: 'EN',
|
||||||
|
|||||||
@@ -27,6 +27,16 @@ export interface Dictionary {
|
|||||||
about: string;
|
about: string;
|
||||||
contact: string;
|
contact: string;
|
||||||
};
|
};
|
||||||
|
about: {
|
||||||
|
languages: string;
|
||||||
|
interests: string;
|
||||||
|
};
|
||||||
|
cvPage: {
|
||||||
|
heading: string;
|
||||||
|
intro: string;
|
||||||
|
atsNote: string;
|
||||||
|
updated: string;
|
||||||
|
};
|
||||||
lang: {
|
lang: {
|
||||||
/** aria-label for the switch group, phrased in the target language. */
|
/** aria-label for the switch group, phrased in the target language. */
|
||||||
switchTo: string;
|
switchTo: string;
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
---
|
||||||
|
import '@fontsource-variable/space-grotesk';
|
||||||
|
import '@fontsource-variable/inter';
|
||||||
|
import '@fontsource-variable/jetbrains-mono';
|
||||||
|
import '@styles/global.css';
|
||||||
|
import ThemeScript from '@components/core/ThemeScript.astro';
|
||||||
|
import { en } from '@i18n/dictionary.en';
|
||||||
|
import { no } from '@i18n/dictionary.no';
|
||||||
|
---
|
||||||
|
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en" data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>404 — Connor Babbington</title>
|
||||||
|
<meta name="robots" content="noindex,follow" />
|
||||||
|
<ThemeScript />
|
||||||
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="mx-auto flex min-h-screen max-w-2xl flex-col justify-center px-6">
|
||||||
|
{/* Trace motif frays at the break (A10) */}
|
||||||
|
<svg width="320" height="40" viewBox="0 0 320 40" fill="none" aria-hidden="true">
|
||||||
|
<path d="M2 20 H140" stroke="var(--accent)" stroke-width="1.5" />
|
||||||
|
<path
|
||||||
|
d="M150 20 H318"
|
||||||
|
stroke="var(--accent)"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-dasharray="2 6"
|
||||||
|
opacity="0.6"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<p class="mt-6 font-mono text-mono-label uppercase tracking-[0.08em] text-accent">
|
||||||
|
{en.notFound.code} · {no.notFound.code}
|
||||||
|
</p>
|
||||||
|
<h1 class="mt-3 text-h1">{en.notFound.title}</h1>
|
||||||
|
<p class="mt-2 text-body-lg text-ink-muted">{en.notFound.body}</p>
|
||||||
|
|
||||||
|
<h2 class="mt-8 text-h3 text-ink-muted">{no.notFound.title}</h2>
|
||||||
|
<p class="mt-1 text-body text-ink-muted">{no.notFound.body}</p>
|
||||||
|
|
||||||
|
<div class="mt-8 flex flex-wrap gap-3">
|
||||||
|
<a href="/" class="rounded-sm bg-accent px-4 py-2.5 font-semibold text-accent-ink">
|
||||||
|
{en.notFound.home}
|
||||||
|
</a>
|
||||||
|
<a href="/no/" class="rounded-sm border border-line-strong px-4 py-2.5 text-ink">
|
||||||
|
{no.notFound.home}
|
||||||
|
</a>
|
||||||
|
<a href="/projects/" class="rounded-sm border border-line-strong px-4 py-2.5 text-ink">
|
||||||
|
{en.notFound.projects}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
import AboutPage from '@components/pages/AboutPage.astro';
|
||||||
|
---
|
||||||
|
|
||||||
|
<AboutPage locale="en" />
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
import ColophonPage from '@components/pages/ColophonPage.astro';
|
||||||
|
---
|
||||||
|
|
||||||
|
<ColophonPage locale="en" />
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
import ContactPage from '@components/pages/ContactPage.astro';
|
||||||
|
---
|
||||||
|
|
||||||
|
<ContactPage locale="en" />
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
import CvPage from '@components/pages/CvPage.astro';
|
||||||
|
---
|
||||||
|
|
||||||
|
<CvPage locale="en" />
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
import ExperiencePage from '@components/pages/ExperiencePage.astro';
|
||||||
|
---
|
||||||
|
|
||||||
|
<ExperiencePage locale="en" />
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
import CvPage from '@components/pages/CvPage.astro';
|
||||||
|
---
|
||||||
|
|
||||||
|
<CvPage locale="no" />
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
import ExperiencePage from '@components/pages/ExperiencePage.astro';
|
||||||
|
---
|
||||||
|
|
||||||
|
<ExperiencePage locale="no" />
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
import ColophonPage from '@components/pages/ColophonPage.astro';
|
||||||
|
---
|
||||||
|
|
||||||
|
<ColophonPage locale="no" />
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
import ContactPage from '@components/pages/ContactPage.astro';
|
||||||
|
---
|
||||||
|
|
||||||
|
<ContactPage locale="no" />
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
import AboutPage from '@components/pages/AboutPage.astro';
|
||||||
|
---
|
||||||
|
|
||||||
|
<AboutPage locale="no" />
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
import CaseStudyPage from '@components/case-study/CaseStudyPage.astro';
|
||||||
|
---
|
||||||
|
|
||||||
|
<CaseStudyPage id="homelab" locale="no" />
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
import CaseStudyPage from '@components/case-study/CaseStudyPage.astro';
|
||||||
|
---
|
||||||
|
|
||||||
|
<CaseStudyPage id="inboxintel" locale="no" />
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
import ProjectsIndexPage from '@components/pages/ProjectsIndexPage.astro';
|
||||||
|
---
|
||||||
|
|
||||||
|
<ProjectsIndexPage locale="no" />
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
import CaseStudyPage from '@components/case-study/CaseStudyPage.astro';
|
||||||
|
---
|
||||||
|
|
||||||
|
<CaseStudyPage id="jobtrack" locale="no" />
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
import CaseStudyPage from '@components/case-study/CaseStudyPage.astro';
|
||||||
|
---
|
||||||
|
|
||||||
|
<CaseStudyPage id="homelab" locale="en" />
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
import CaseStudyPage from '@components/case-study/CaseStudyPage.astro';
|
||||||
|
---
|
||||||
|
|
||||||
|
<CaseStudyPage id="inboxintel" locale="en" />
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
import ProjectsIndexPage from '@components/pages/ProjectsIndexPage.astro';
|
||||||
|
---
|
||||||
|
|
||||||
|
<ProjectsIndexPage locale="en" />
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
import CaseStudyPage from '@components/case-study/CaseStudyPage.astro';
|
||||||
|
---
|
||||||
|
|
||||||
|
<CaseStudyPage id="jobtrack" locale="en" />
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
|
||||||
|
/* robots.txt (SEO_SPEC §2): allow all, point to the sitemap. */
|
||||||
|
export const GET: APIRoute = ({ site }) => {
|
||||||
|
const origin = site?.href ?? 'https://cesnimda.co.uk/';
|
||||||
|
const body = ['User-agent: *', 'Allow: /', '', `Sitemap: ${new URL('sitemap.xml', origin).href}`, ''].join(
|
||||||
|
'\n',
|
||||||
|
);
|
||||||
|
return new Response(body, { headers: { 'Content-Type': 'text/plain; charset=utf-8' } });
|
||||||
|
};
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
import { PAGE_IDS, pathFor } from '@i18n/slugMap';
|
||||||
|
import { LOCALES } from '@i18n/locales';
|
||||||
|
import { absUrl, alternates } from '@lib/seo';
|
||||||
|
|
||||||
|
/*
|
||||||
|
Sitemap built from the slug map (ARCHITECTURE A5, SEO_SPEC §5). Every URL carries
|
||||||
|
xhtml:link alternates for both locales + x-default, mirroring the hreflang tags.
|
||||||
|
*/
|
||||||
|
export const GET: APIRoute = ({ site }) => {
|
||||||
|
const origin = site?.href ?? 'https://cesnimda.co.uk/';
|
||||||
|
|
||||||
|
const urls: string[] = [];
|
||||||
|
for (const pageId of PAGE_IDS) {
|
||||||
|
const alts = alternates(pageId, origin);
|
||||||
|
const altXml = alts
|
||||||
|
.map((a) => ` <xhtml:link rel="alternate" hreflang="${a.hreflang}" href="${a.href}" />`)
|
||||||
|
.join('\n');
|
||||||
|
for (const locale of LOCALES) {
|
||||||
|
urls.push(
|
||||||
|
` <url>\n <loc>${absUrl(pathFor(pageId, locale), origin)}</loc>\n${altXml}\n </url>`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">
|
||||||
|
${urls.join('\n')}
|
||||||
|
</urlset>
|
||||||
|
`;
|
||||||
|
|
||||||
|
return new Response(xml, { headers: { 'Content-Type': 'application/xml; charset=utf-8' } });
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user