feat: initial @unom/app-ui — shared app-level blocks, starting with the footer

Composite UI blocks shared across the unom/punktfunk apps, one layer up from
@unom/ui (primitives). Follows the @played/app-ui pattern: per-block subpath
exports, tsdown ESM+dts build, themed through @unom/style semantic tokens.

The ./footer block exports a presentational FooterView (link sections, socials,
tagline) with a resolveHref hook so the docs can rebase root-relative links onto
the marketing-site origin. Both the punktfunk marketing site and docs render it,
so the footer no longer drifts between hand-mirrored copies.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
enricobuehler
2026-06-28 14:32:48 +00:00
commit e10aa082ec
12 changed files with 539 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
export { Socials } from "./socials";
export type {
FooterData,
FooterEntry,
FooterSection,
Social,
} from "./types";
export { FooterView, type FooterViewProps } from "./view";
+87
View File
@@ -0,0 +1,87 @@
import {
SiBluesky,
SiDiscord,
SiGithub,
SiMastodon,
SiReddit,
SiX,
SiYoutube,
} from "@icons-pack/react-simple-icons";
import type { ComponentType } from "react";
import type { Social } from "./types";
type IconComp = ComponentType<{ size?: number; color?: string }>;
// Branded glyphs per platform; `website`/unknown falls back to a globe.
const ICONS: Record<string, IconComp> = {
discord: SiDiscord,
reddit: SiReddit,
github: SiGithub,
twitter: SiX,
youtube: SiYoutube,
mastodon: SiMastodon,
bluesky: SiBluesky,
};
const NAMES: Record<string, string> = {
discord: "Discord",
reddit: "Reddit",
github: "GitHub",
twitter: "X",
youtube: "YouTube",
mastodon: "Mastodon",
bluesky: "Bluesky",
website: "Website",
};
function GlobeIcon({ size = 22 }: { size?: number }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<circle cx="12" cy="12" r="10" />
<path d="M2 12h20" />
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
</svg>
);
}
export function Socials({ socials }: { socials?: Social[] | null }) {
const items = (socials ?? []).filter((s) => s?.url && s?.platform);
if (items.length === 0) return null;
return (
<div className="flex flex-row items-center gap-3">
{items.map((s, i) => {
const key = s.platform ?? "";
const Icon = ICONS[key];
const name = s.label?.trim() || NAMES[key] || key;
return (
<a
key={s.id ?? `${key}-${i}`}
href={s.url ?? "#"}
target="_blank"
rel="noreferrer noopener"
aria-label={name}
title={name}
className="text-main opacity-70 transition-opacity hover:opacity-100"
>
{Icon ? (
<Icon size={22} color="currentColor" />
) : (
<GlobeIcon size={22} />
)}
</a>
);
})}
</div>
);
}
+29
View File
@@ -0,0 +1,29 @@
// Shape of the punktfunk footer global in the shared unom Payload CMS. Kept
// structural (every field optional + nullable) so both consumers can hand the
// view their CMS payload directly — the marketing site via the typed
// `@unom/cms` SDK, the docs via a plain `fetch` — without a type dance.
export interface FooterEntry {
id?: string | null;
label?: string | null;
to?: string | null;
}
export interface FooterSection {
id?: string | null;
title?: string | null;
entries?: FooterEntry[] | null;
}
export interface Social {
id?: string | null;
platform?: string | null;
url?: string | null;
label?: string | null;
}
export interface FooterData {
tagline?: string | null;
sections?: FooterSection[] | null;
socials?: Social[] | null;
}
+87
View File
@@ -0,0 +1,87 @@
import { Socials } from "./socials";
import type { FooterSection, Social } from "./types";
const cn = (...parts: Array<string | false | null | undefined>) =>
parts.filter(Boolean).join(" ");
export interface FooterViewProps {
sections?: FooterSection[] | null;
tagline?: string | null;
socials?: Social[] | null;
/** Heading shown above the social icons, e.g. a localized "Socials". */
socialsLabel?: string;
/**
* Maps an entry's `to` onto its final href. Defaults to identity. The docs
* site passes a resolver that rebases root-relative links onto the marketing
* site origin — it doesn't host /legal/* etc. itself.
*/
resolveHref?: (to: string) => string;
/** Extra classes merged onto the <footer> element (e.g. a top border). */
className?: string;
}
const identity = (to: string) => to;
// Presentational footer shared by the marketing site and the docs. Themed
// entirely through @unom/style's semantic tokens (`neutral-accent`, `main`),
// which both apps map to their own surfaces — so the markup stays identical
// while each site keeps its palette. Data fetching stays in each app's route
// loader; this component only renders what it's handed.
export function FooterView({
sections,
tagline,
socials,
socialsLabel,
resolveHref = identity,
className,
}: FooterViewProps) {
const groups = sections ?? [];
const socialItems = (socials ?? []).filter((s) => s?.url && s?.platform);
const trimmedTagline = tagline?.trim();
if (!groups.length && !socialItems.length && !trimmedTagline) return null;
return (
<footer className={cn("w-full bg-neutral-accent", className)}>
<div className="mx-auto flex w-full max-w-6xl flex-row flex-wrap gap-12 px-6 py-12">
{groups.map((group, gi) => (
<div key={group.id ?? gi}>
{group.title && (
<h3 className="mb-2 text-sm font-semibold text-main">
{group.title}
</h3>
)}
<div className="flex flex-col gap-1">
{(group.entries ?? []).map((entry, i) => (
<a
key={entry.id ?? `${entry.to}-${i}`}
href={entry.to ? resolveHref(entry.to) : "#"}
className="text-main/60 text-sm no-underline transition-colors hover:text-main"
>
{entry.label}
</a>
))}
</div>
</div>
))}
{socialItems.length > 0 && (
<div>
{socialsLabel && (
<h3 className="mb-2 text-sm font-semibold text-main">
{socialsLabel}
</h3>
)}
<Socials socials={socialItems} />
</div>
)}
{trimmedTagline && (
<p className="mr-0 ml-auto self-end text-main/60 text-sm">
{trimmedTagline}
</p>
)}
</div>
</footer>
);
}