{!noSeals &&
}
-
+
@@ -58,6 +64,11 @@ const fullTitle = title === "Greg Kilmer" ? title : `${title} · Greg Kilmer`;
position: relative;
min-height: 100vh;
overflow-x: clip;
+ /* The page's nebula: violet from the top right, teal from the bottom left. */
+ background:
+ radial-gradient(ellipse at 100% 0%, rgba(106, 47, 224, 0.28), transparent 50%),
+ radial-gradient(ellipse at 0% 100%, rgba(20, 163, 154, 0.2), transparent 55%),
+ var(--gk-sky);
}
.gk-main {
position: relative;
diff --git a/packages/ui/src/components/SiteHeader.astro b/packages/ui/src/components/SiteHeader.astro
index 2d53b4f..0b8abd0 100644
--- a/packages/ui/src/components/SiteHeader.astro
+++ b/packages/ui/src/components/SiteHeader.astro
@@ -2,65 +2,89 @@
/**
* The site header: nebula and filament banner behind the primary nav, an
* optional page hero (the `hero` slot), and the header divider. The filament
- * banner and divider appear here only, never elsewhere on a page.
+ * banner and divider appear here only, never elsewhere on a page. Store pages
+ * pass `store` and get the Store's own header: Store.GregKilmer.com, its
+ * product lines, Order request and a way back to the main site.
*/
import monogram from "../assets/brand/gk-monogram.svg?url";
import Divider from "./Divider.astro";
import Star from "./Star.astro";
-import { main, tail, world, type NavKey } from "./nav.ts";
+import { main, tail, world, type NavKey, type StoreNav } from "./nav.ts";
import { filamentBanner } from "./seals.ts";
interface Props {
/** Which nav entry the page belongs to. */
current?: NavKey;
+ store?: StoreNav;
+ /** Whether the page has a hero; PageShell always fills the slot, so it says. */
+ hero?: boolean;
}
-const { current } = Astro.props;
+const { current, store, hero = Astro.slots.has("default") } = Astro.props;
const inWorld = world.some((w) => w.key === current);
const aria = (key: NavKey) => (key === current ? "page" : undefined);
---
diff --git a/services/site/src/components/library/Constellation.astro b/services/site/src/components/library/Constellation.astro
index 2e16eaa..ca712a4 100644
--- a/services/site/src/components/library/Constellation.astro
+++ b/services/site/src/components/library/Constellation.astro
@@ -1,9 +1,11 @@
---
/**
* The favorites constellation: round covers strung on a dotted gold line,
- * each linking to its row in the list.
+ * each linking to its row in the list. A favorite without a cover gets
+ * star-chart art in its circle.
*/
import Star from "@gk/ui/components/Star.astro";
+import StarChartArt from "@gk/ui/components/StarChartArt.astro";
import TickScale from "@gk/ui/components/TickScale.astro";
import { mediaSrc } from "../../lib/site.ts";
import type { Item } from "../../lib/library.ts";
@@ -35,7 +37,7 @@ const points = items.map((_, i) => `${(((i + 0.5) / n) * 1000).toFixed(1)},${lif
items.map((item, i) => (
-
+
{item.title}
@@ -104,18 +106,32 @@ const points = items.map((_, i) => `${(((i + 0.5) / n) * 1000).toFixed(1)},${lif
color: var(--gk-text);
text-decoration: none;
}
- img {
+ a :global(.orb) {
+ flex: none;
width: 80px;
height: 80px;
border-radius: 50%;
- object-fit: cover;
- background: var(--gk-midnight);
box-shadow:
0 0 0 2px var(--gk-gold),
0 0 22px rgba(201, 162, 74, 0.45);
transition: box-shadow 0.2s;
}
- a:hover img {
+ a :global(.orb .mat) {
+ display: none;
+ }
+ a :global(.orb .seal) {
+ width: 54px;
+ height: 54px;
+ opacity: 0.8;
+ }
+ a :global(.orb .photo) {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ }
+ a:hover :global(.orb) {
box-shadow:
0 0 0 2px var(--gk-gold-bright),
0 0 30px rgba(201, 162, 74, 0.7);
@@ -137,7 +153,7 @@ const points = items.map((_, i) => `${(((i + 0.5) / n) * 1000).toFixed(1)},${lif
li {
padding-top: 0;
}
- img {
+ a :global(.orb) {
width: 64px;
height: 64px;
}
diff --git a/services/site/src/components/library/GameCard.astro b/services/site/src/components/library/GameCard.astro
index 07decc6..c81f343 100644
--- a/services/site/src/components/library/GameCard.astro
+++ b/services/site/src/components/library/GameCard.astro
@@ -11,7 +11,7 @@ import StarChartArt from "@gk/ui/components/StarChartArt.astro";
import StatusPill from "@gk/ui/components/StatusPill.astro";
import StatusChip from "../StatusChip.astro";
import { formatDate, mediaSrc, type SiteIndex } from "../../lib/site.ts";
-import { gamePlayers, gamePlayTime, type Game } from "../../lib/library.ts";
+import { gamePlayers, gamePlayTime, minutesOf, playersOf, type Game } from "../../lib/library.ts";
interface Props {
game: Game;
@@ -24,6 +24,8 @@ const plays = d.howItPlays ?? [];
const status = site.statusFor(game);
// Players and play time sync from BoardGameGeek; nothing shows until they have.
const facts = [gamePlayers(game), gamePlayTime(game)].filter(Boolean);
+const seats = playersOf(game);
+const hasPost = site.backlinks(game, { type: "post" }).length > 0;
---
diff --git a/services/site/src/components/world/CtaBand.astro b/services/site/src/components/world/CtaBand.astro
index 10b45d4..5214396 100644
--- a/services/site/src/components/world/CtaBand.astro
+++ b/services/site/src/components/world/CtaBand.astro
@@ -1,5 +1,8 @@
---
-/** The closing band on a world page: a line, a note and one or two buttons. */
+/**
+ * The closing band on a world page: a full-width strip with a line, a note and
+ * one or two buttons, like the one on Work.
+ */
interface Props {
title: string;
note?: string;
@@ -7,25 +10,32 @@ interface Props {
const { title, note } = Astro.props;
---
-
-
{title}
- {note &&
{note}
}
+
+
+
{title}
+ {note &&
{note}
}
+
+
-
diff --git a/services/site/src/lib/library.ts b/services/site/src/lib/library.ts
index 91e5cd1..833c065 100644
--- a/services/site/src/lib/library.ts
+++ b/services/site/src/lib/library.ts
@@ -128,6 +128,7 @@ export const sorts = [
{ key: "played", label: "Last played" },
{ key: "az", label: "A to Z" },
{ key: "added", label: "Recently added" },
+ { key: "time", label: "Play time" },
] as const;
export type SortKey = (typeof sorts)[number]["key"];
@@ -138,6 +139,8 @@ export function sortGames(games: Game[], sort: SortKey): Game[] {
played: (a, b) => desc(a.data.lastPlayed, b.data.lastPlayed) || az(a, b),
az,
added: (a, b) => desc(a.data.created, b.data.created) || az(a, b),
+ // Shortest first; games BoardGameGeek has not timed go last.
+ time: (a, b) => (minutesOf(a) ?? Infinity) - (minutesOf(b) ?? Infinity) || az(a, b),
};
return [...games].sort(cmp[sort]);
}
@@ -148,9 +151,29 @@ export interface ShelfFilter {
owned: boolean;
want: boolean;
favorites: boolean;
+ blog: boolean;
+ players: PlayersKey | "";
+ time: TimeKey | "";
sort: SortKey;
}
+/** The Players pills: who a game has to seat. */
+export const playerOptions = [
+ { key: "1", label: "1", lo: 1, hi: 1 },
+ { key: "2", label: "2", lo: 2, hi: 2 },
+ { key: "3-4", label: "3–4", lo: 3, hi: 4 },
+ { key: "5+", label: "5+", lo: 5, hi: Infinity },
+] as const;
+export type PlayersKey = (typeof playerOptions)[number]["key"];
+
+/** The Play time pills, in minutes. */
+export const timeOptions = [
+ { key: "short", label: "Under 30 min", lo: 0, hi: 29 },
+ { key: "mid", label: "30–60", lo: 30, hi: 60 },
+ { key: "long", label: "60+", lo: 61, hi: Infinity },
+] as const;
+export type TimeKey = (typeof timeOptions)[number]["key"];
+
export function shelfFilter(params: URLSearchParams): ShelfFilter {
const submitted = params.has("f");
return {
@@ -159,19 +182,27 @@ export function shelfFilter(params: URLSearchParams): ShelfFilter {
owned: submitted ? params.get("owned") === "1" : true,
want: params.get("want") === "1",
favorites: params.get("fav") === "1",
+ blog: params.get("blog") === "1",
+ players: playerOptions.find((o) => o.key === params.get("players"))?.key ?? "",
+ time: timeOptions.find((o) => o.key === params.get("time"))?.key ?? "",
sort: sorts.find((s) => s.key === params.get("sort"))?.key ?? "played",
};
}
/**
* Owned and Want to play add games to the list (either one); with neither
- * ticked, every game shows. Favorites only and How it plays narrow it.
+ * ticked, every game shows. Favorites only, Has a blog post, How it plays,
+ * Players and Play time narrow it. A game BoardGameGeek has not synced yet
+ * drops out while Players or Play time is set.
*/
-export function gameShows(g: Game, f: ShelfFilter, text: string): boolean {
+export function gameShows(g: Game, f: ShelfFilter, text: string, hasPost = false): boolean {
const owned = !!g.data.owned;
const want = !!g.data.wantToPlay;
if ((f.owned || f.want) && !((f.owned && owned) || (f.want && want))) return false;
if (f.favorites && !g.data.favorite) return false;
+ if (f.blog && !hasPost) return false;
+ if (f.players && !seats(g, playerOptions.find((o) => o.key === f.players)!)) return false;
+ if (f.time && !lasts(g, timeOptions.find((o) => o.key === f.time)!)) return false;
if (f.plays.size && !(g.data.howItPlays ?? []).some((p) => f.plays.has(p.toLowerCase()))) return false;
return !f.q || text.includes(f.q);
}
@@ -185,6 +216,30 @@ interface GameSynced {
maxPlayTime?: number;
}
+/** The player range BoardGameGeek gives, when it has synced. */
+export function playersOf(g: Game): [number, number] | undefined {
+ const d = g.data as GameSynced;
+ const lo = d.minPlayers || d.maxPlayers;
+ const hi = d.maxPlayers || d.minPlayers;
+ return lo && hi ? [lo, hi] : undefined;
+}
+
+/** A game's usual length in minutes: the listed play time, else the longest. */
+export function minutesOf(g: Game): number | undefined {
+ const d = g.data as GameSynced;
+ return d.playTime || d.maxPlayTime || d.minPlayTime || undefined;
+}
+
+/** Whether a game plays at some count in the option's range. */
+const seats = (g: Game, o: { lo: number; hi: number }) => {
+ const r = playersOf(g);
+ return !!r && r[0] <= o.hi && r[1] >= o.lo;
+};
+const lasts = (g: Game, o: { lo: number; hi: number }) => {
+ const m = minutesOf(g);
+ return m !== undefined && m >= o.lo && m <= o.hi;
+};
+
const range = (lo?: number, hi?: number) => (lo && hi && lo !== hi ? `${lo}–${hi}` : String(lo || hi || ""));
/** "2–4 players", "1 player" or undefined when BoardGameGeek has not been synced. */
diff --git a/services/site/src/lib/resume-pdf.ts b/services/site/src/lib/resume-pdf.ts
index 7e71561..5ab616b 100644
--- a/services/site/src/lib/resume-pdf.ts
+++ b/services/site/src/lib/resume-pdf.ts
@@ -43,7 +43,21 @@ interface Col {
w: number;
}
-function renderBody(r: ResumeData): Promise
{
+interface Body {
+ pdf: Buffer;
+ pages: number;
+ /** Where the last line ended on the last page, in points from the top. */
+ end: number;
+}
+
+/**
+ * Lays the resume out with every gap between blocks multiplied by `spread`
+ * (and line spacing by a little less), so a short resume can be opened up to
+ * fill its one page instead of leaving the bottom empty.
+ */
+function renderBody(r: ResumeData, spread = 1): Promise {
+ const gapDown = (n: number) => doc.moveDown(n * spread);
+ const lg = (n: number) => n + (spread - 1) * 1.2;
const doc = new PDFDocument({
size: PAGE.size,
margins: { top: PAGE.top, bottom: PAGE.bottom, left: PAGE.left, right: PAGE.right },
@@ -57,8 +71,10 @@ function renderBody(r: ResumeData): Promise {
const full: Col = { x: doc.page.margins.left, w: doc.page.width - doc.page.margins.left - doc.page.margins.right };
const chunks: Buffer[] = [];
doc.on("data", (c: Buffer) => chunks.push(c));
- const done = new Promise((resolve, reject) => {
- doc.on("end", () => resolve(Buffer.concat(chunks)));
+ let pages = 1;
+ let end = 0;
+ const done = new Promise((resolve, reject) => {
+ doc.on("end", () => resolve({ pdf: Buffer.concat(chunks), pages, end }));
doc.on("error", reject);
});
@@ -73,7 +89,7 @@ function renderBody(r: ResumeData): Promise {
const section = (title: string, c: Col = full) => {
room(50);
- doc.moveDown(0.4);
+ gapDown(0.4);
const y = doc.y;
doc.font("heading").fontSize(10.5).fillColor(GOLD).text(title.toUpperCase(), c.x, y, { characterSpacing: 1.6, width: c.w });
const ruleY = doc.y + 1;
@@ -85,16 +101,16 @@ function renderBody(r: ResumeData): Promise {
const indent = 11;
const w = c.w - indent;
doc.font("body").fontSize(TEXT);
- room(doc.heightOfString(lead ? `${lead} ${text}` : text, { width: w, lineGap: 1 }) + 3);
+ room(doc.heightOfString(lead ? `${lead} ${text}` : text, { width: w, lineGap: lg(1) }) + 3);
const y = doc.y;
diamond(c.x + 3.5, y + 5.5, 2.1);
if (lead) {
- doc.font("strong").fillColor(INK).text(lead, c.x + indent, y, { width: w, continued: true, lineGap: 1 });
- doc.font("body").text(` ${text}`, { lineGap: 1 });
+ doc.font("strong").fillColor(INK).text(lead, c.x + indent, y, { width: w, continued: true, lineGap: lg(1) });
+ doc.font("body").text(` ${text}`, { lineGap: lg(1) });
} else {
- doc.fillColor(INK).text(text, c.x + indent, y, { width: w, lineGap: 1 });
+ doc.fillColor(INK).text(text, c.x + indent, y, { width: w, lineGap: lg(1) });
}
- doc.moveDown(0.12);
+ gapDown(0.12);
};
/** A title on the left with its dates set flush right on the same line. */
@@ -122,20 +138,20 @@ function renderBody(r: ResumeData): Promise {
const profiles = r.socials.filter((s) => /github|gitea|linkedin/i.test(`${s.label} ${s.url}`));
const updated = r.updated ? `Updated ${month(r.updated.slice(0, 7))}` : "";
{
- const y = doc.moveDown(0.15).y;
+ const y = gapDown(0.15).y;
doc.font("body").fontSize(8).fillColor(MUTED);
if (updated) doc.text(updated, full.x + full.w - doc.widthOfString(updated), y, { lineBreak: false });
const links = profiles.map((s) => s.url.replace(/^https?:\/\/(www\.)?/, "").replace(/\/$/, "")).join(" · ");
doc.text(links, full.x, y, { width: full.w - 120 });
}
- doc.moveDown(0.6);
+ gapDown(0.6);
if (r.summary) {
- doc.font("body").fontSize(9).fillColor(INK).text(r.summary.replace(/\s*\n\s*/g, " "), full.x, doc.y, { width: full.w, lineGap: 1.4 });
+ doc.font("body").fontSize(9).fillColor(INK).text(r.summary.replace(/\s*\n\s*/g, " "), full.x, doc.y, { width: full.w, lineGap: lg(1.4) });
}
if (r.stats.length) {
- doc.moveDown(0.6);
+ gapDown(0.6);
const cols = Math.min(r.stats.length, 4);
const colW = full.w / cols;
const top = doc.y;
@@ -156,14 +172,14 @@ function renderBody(r: ResumeData): Promise {
section("Experience");
r.roles.forEach((role, i) => {
const d = role.data;
- if (i > 0) doc.moveDown(0.5);
+ if (i > 0) gapDown(0.5);
titled(d.role, period(role), 10.5);
const at = [d.company, d.location].filter(Boolean).join(" · ");
doc.font("medium").fontSize(9).fillColor(TEAL).text(at, full.x, doc.y, { width: full.w });
if (d.previousRoles?.length) doc.font("body").fontSize(8.5).fillColor(MUTED).text(`Previously ${listOf(d.previousRoles)}`, { width: full.w });
// The focus line repeats what the bullets say, so paper skips it when there are bullets.
if (d.focus && !d.bullets?.length) doc.font("body").fontSize(8.5).fillColor(MUTED).text(d.focus, { width: full.w });
- doc.moveDown(0.25);
+ gapDown(0.25);
for (const b of d.bullets ?? []) bullet(b);
doc.x = full.x;
});
@@ -173,7 +189,7 @@ function renderBody(r: ResumeData): Promise {
section("Earlier");
r.earlier.forEach((role, i) => {
const d = role.data;
- if (i > 0) doc.moveDown(0.3);
+ if (i > 0) gapDown(0.3);
titled(d.role, period(role), 9.5);
doc.font("medium").fontSize(8.5).fillColor(TEAL).text(d.company, full.x, doc.y, { width: full.w });
if (d.focus) doc.font("body").fontSize(8).fillColor(MUTED).text(d.focus, { width: full.w });
@@ -193,14 +209,14 @@ function renderBody(r: ResumeData): Promise {
doc.font("strong");
const lh = doc.heightOfString(g.label, { width: labelW });
doc.font("body");
- return h + Math.max(lh, doc.heightOfString(g.items, { width: leftCol.w - labelW, lineGap: 0.6 })) + 2;
+ return h + Math.max(lh, doc.heightOfString(g.items, { width: leftCol.w - labelW, lineGap: lg(0.6) })) + 2;
}, 24);
const entries = (r.education?.degrees.length ?? 0) + (r.education?.certifications?.length ?? 0);
doc.font("body");
const rightH =
(entries ? 24 + entries * 26 : 0) +
(r.projects.length ? 24 : 0) +
- r.projects.reduce((h, p) => h + doc.heightOfString(`${p.title} ${p.detail}`, { width: rightCol.w - 11, lineGap: 1 }) + 3, 0);
+ r.projects.reduce((h, p) => h + doc.heightOfString(`${p.title} ${p.detail}`, { width: rightCol.w - 11, lineGap: lg(1) }) + 3, 0);
if (doc.y + Math.max(leftH, rightH) > doc.page.height - doc.page.margins.bottom) doc.addPage();
const start = doc.y;
let leftEnd = start;
@@ -211,7 +227,7 @@ function renderBody(r: ResumeData): Promise {
const y = doc.y;
doc.font("strong").fontSize(SKILL).fillColor(INK).text(label, leftCol.x, y, { width: labelW });
const afterLabel = doc.y;
- doc.font("body").fontSize(SKILL).fillColor(INK).text(items, leftCol.x + labelW, y, { width: leftCol.w - labelW, lineGap: 0.6 });
+ doc.font("body").fontSize(SKILL).fillColor(INK).text(items, leftCol.x + labelW, y, { width: leftCol.w - labelW, lineGap: lg(0.6) });
doc.y = Math.max(doc.y, afterLabel) + 2;
}
leftEnd = doc.y;
@@ -224,12 +240,12 @@ function renderBody(r: ResumeData): Promise {
for (const dg of edu?.degrees ?? []) {
titled(dg.credential, dg.period, 9.5, rightCol);
doc.font("medium").fontSize(8.5).fillColor(TEAL).text(dg.institution, rightCol.x, doc.y, { width: rightCol.w });
- doc.moveDown(0.3);
+ gapDown(0.3);
}
for (const c of edu?.certifications ?? []) {
titled(c.name, c.year ?? "", 9.5, rightCol);
if (c.issuer) doc.font("medium").fontSize(8.5).fillColor(TEAL).text(c.issuer, rightCol.x, doc.y, { width: rightCol.w });
- doc.moveDown(0.3);
+ gapDown(0.3);
}
}
if (r.projects.length) {
@@ -239,8 +255,10 @@ function renderBody(r: ResumeData): Promise {
doc.x = full.x;
doc.y = Math.max(doc.y, leftEnd);
+ end = doc.y;
// Page numbers sit just above the letterhead's footer rule, only when there is more than one page.
const range = doc.bufferedPageRange();
+ pages = range.count;
if (range.count > 1) {
for (let i = range.start; i < range.start + range.count; i++) {
doc.switchToPage(i);
@@ -257,9 +275,29 @@ function renderBody(r: ResumeData): Promise {
return done;
}
+/** The page's lowest writing line, in points from the top. */
+const LIMIT = 792 - PAGE.bottom;
+
+/**
+ * A one-page resume that ends well above the footer is laid out again with
+ * wider gaps, up to twice as wide, so the page reads as full; the widest
+ * spread that still fits on one page wins. Longer resumes are left as they are.
+ */
+export async function fitBody(r: ResumeData): Promise {
+ const first = await renderBody(r);
+ if (first.pages > 1 || LIMIT - first.end < 24) return first;
+ let best = first;
+ for (let spread = 1.1; spread <= 2.001; spread += 0.1) {
+ const next = await renderBody(r, spread);
+ if (next.pages > 1 || next.end > LIMIT - 4) break;
+ best = next;
+ }
+ return best;
+}
+
/** The resume laid over the letterhead: each page draws the letterhead, then the resume on top, both as vectors. */
export async function renderResumePdf(r: ResumeData): Promise {
- const body = await PdfLib.load(await renderBody(r));
+ const body = await PdfLib.load((await fitBody(r)).pdf);
const out = await PdfLib.create();
out.setTitle(`${r.name} – Resume`, { showInWindowTitleBar: true });
out.setAuthor(r.name);
diff --git a/services/site/src/lib/store.ts b/services/site/src/lib/store.ts
index d0e853b..223efce 100644
--- a/services/site/src/lib/store.ts
+++ b/services/site/src/lib/store.ts
@@ -164,3 +164,12 @@ const CATEGORY_ICONS: Record = { Tabletop: "dice", Paper: "boo
export function lineIcon(line: Line): string {
return brandIcon(line.data.icon, CATEGORY_ICONS[line.data.category]);
}
+
+/**
+ * The Store's own header: the Store, each line sold here (by its menu name),
+ * and Order request. Lines sold elsewhere stay on the Store page only.
+ */
+export function storeNav(st: StoreState, current: string): { links: { label: string; href: string }[]; current: string } {
+ const lines = st.lines.filter((l) => channelOf(l) === "here").map((l) => ({ label: l.data.menuName ?? l.title, href: `/store/${l.slug}` }));
+ return { links: [{ label: "Store", href: "/store" }, ...lines, { label: "Order request", href: "/store/order" }], current };
+}
diff --git a/services/site/src/pages/about.astro b/services/site/src/pages/about.astro
index af06330..d7f42d3 100644
--- a/services/site/src/pages/about.astro
+++ b/services/site/src/pages/about.astro
@@ -7,9 +7,9 @@
import GiltCorners from "@gk/ui/components/GiltCorners.astro";
import Icon from "@gk/ui/components/Icon.astro";
import PageShell from "@gk/ui/components/PageShell.astro";
-import Seal from "@gk/ui/components/Seal.astro";
import SectionHeading from "@gk/ui/components/SectionHeading.astro";
import Star from "@gk/ui/components/Star.astro";
+import StarChartArt from "@gk/ui/components/StarChartArt.astro";
import TickScale from "@gk/ui/components/TickScale.astro";
import PageHero from "../components/PageHero.astro";
import DoorLink from "../components/home/DoorLink.astro";
@@ -17,7 +17,8 @@ import Portrait from "../components/home/Portrait.astro";
import { splitIntro, viewStatus } from "../lib/home.ts";
import { brandIcon } from "../lib/site.ts";
import { site } from "../lib/page.ts";
-import { formatDate } from "../lib/site.ts";
+import { formatDate, mediaSrc } from "../lib/site.ts";
+import scorpius from "@gk/ui/assets/motifs/scorpius.svg?url";
const s = await site();
const text = s.pageText("/about");
@@ -25,6 +26,7 @@ const settings = s.settings();
const { intro, sections } = splitIntro(text?.body ?? "");
const introHtml = s.render(intro);
const facts = text?.data.quickFacts ?? [];
+const photos = text?.data.photos ?? [];
const now = s.statuses().slice(0, 4).map((st) => viewStatus(s, st));
const nowUpdated = now.map((v) => v.date).sort().pop();
const socials = settings?.socials ?? [];
@@ -43,7 +45,7 @@ const SOCIAL_ICONS: Record = { github: "web", linkedin: "briefca
{introHtml ? : [PLACEHOLDER]
}
@@ -60,6 +62,18 @@ const SOCIAL_ICONS: Record = { github: "web", linkedin: "briefca
))
}
+ {
+ photos.length > 0 && (
+
+ {photos.map((ph) => (
+
+
+ {ph.caption && {ph.caption} }
+
+ ))}
+
+ )
+ }