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:
@@ -0,0 +1,8 @@
|
||||
export { Socials } from "./socials";
|
||||
export type {
|
||||
FooterData,
|
||||
FooterEntry,
|
||||
FooterSection,
|
||||
Social,
|
||||
} from "./types";
|
||||
export { FooterView, type FooterViewProps } from "./view";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user