feat: adopt @unom/ui + add Projects section to the landing page
Build & Deploy unom website / build (push) Successful in 41s
Build & Deploy unom website / deploy (push) Successful in 5s

- Pull in @unom/ui (0.8.16) + @unom/style + peers; bump @unom/cms to 0.3.0.
- globals.css now provides the full semantic-token contract @unom/ui consumes
  (primary/accent/background/ring/radius-card/…), expressed with unom's violet
  brand + Inter/Ubuntu.
- Section wraps @unom/ui's Section (in-view animation orchestrator); PostCard,
  LatestPosts, and the hero use AnimatedCard/AnimatedText/AnimatedButton/Heading.
- lib/cms.ts moves to the multi-tenant model (tenant=unom), header/footer as
  per-tenant collections, + findProjects().
- New Projects section lists this tenant's projects on the landing page
  (soft-fails to hidden when empty). Starts with punktfunk.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-20 18:11:35 +02:00
parent 76dabef23d
commit 15130ae4df
12 changed files with 1501 additions and 129 deletions
+1215 -52
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -6,6 +6,9 @@
"home_title": "unom - Kreative Webentwicklung", "home_title": "unom - Kreative Webentwicklung",
"projects_heading": "Projekte",
"projects_intro": "Eigene Produkte und Marken aus dem Hause unom.",
"blog_title": "Blog", "blog_title": "Blog",
"blog_meta_description": "Notizen, Gedanken und Tutorials aus der Werkstatt von unom.", "blog_meta_description": "Notizen, Gedanken und Tutorials aus der Werkstatt von unom.",
"blog_empty": "Noch keine Beiträge.", "blog_empty": "Noch keine Beiträge.",
+3
View File
@@ -6,6 +6,9 @@
"home_title": "unom - Creative web development", "home_title": "unom - Creative web development",
"projects_heading": "Projects",
"projects_intro": "Our own products and brands, built in-house at unom.",
"blog_title": "Blog", "blog_title": "Blog",
"blog_meta_description": "Notes, thoughts, and tutorials from the unom workshop.", "blog_meta_description": "Notes, thoughts, and tutorials from the unom workshop.",
"blog_empty": "No posts yet.", "blog_empty": "No posts yet.",
+9 -2
View File
@@ -22,14 +22,21 @@
"@tanstack/react-router": "latest", "@tanstack/react-router": "latest",
"@tanstack/react-start": "latest", "@tanstack/react-start": "latest",
"@tanstack/router-plugin": "^1.168.9", "@tanstack/router-plugin": "^1.168.9",
"@unom/cms": "^0.1.0", "@unom/cms": "^0.3.0",
"@unom/style": "^0.4.4",
"@unom/ui": "^0.8.16",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^1.21.0",
"motion": "^12.40.0",
"nitro": "^3.0.260429-beta", "nitro": "^3.0.260429-beta",
"radix-ui": "^1.6.0",
"react": "^19.2.6", "react": "^19.2.6",
"react-dom": "^19.2.6", "react-dom": "^19.2.6",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.0" "tailwindcss": "^4.3.0",
"tw-animate-css": "^1.4.0",
"zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.4.15", "@biomejs/biome": "2.4.15",
+19 -9
View File
@@ -1,4 +1,6 @@
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
import { AnimatedCard } from "@unom/ui/card";
import AnimatedText from "@unom/ui/text";
import type { Post } from "@/lib/cms"; import type { Post } from "@/lib/cms";
import { getLocale } from "@/paraglide/runtime"; import { getLocale } from "@/paraglide/runtime";
@@ -11,19 +13,27 @@ export default function PostCard({ post }: { post: Post }) {
}); });
return ( return (
<Link <AnimatedCard interactive colorVariant="accent" className="relative">
to="/blog/$slug" {/* Full-card click target — kept as an overlay so the card stays a
params={{ slug: post.slug }} motion node (asChild would disable its animation). */}
className="block bg-neutral-accent rounded-card p-padding-card no-underline hover:no-underline transition-transform hover:-translate-y-0.5 ring-1 ring-transparent hover:ring-brand/40" <Link
> to="/blog/$slug"
params={{ slug: post.slug }}
aria-label={post.title}
className="absolute inset-0 z-10 rounded-card no-underline hover:no-underline"
/>
<time <time
dateTime={post.createdAt} dateTime={post.createdAt}
className="text-secondary text-sm block mb-3" className="mb-3 block text-sm text-secondary"
> >
{dateFmt.format(new Date(post.createdAt))} {dateFmt.format(new Date(post.createdAt))}
</time> </time>
<h3 className="mb-2">{post.title}</h3> <AnimatedText tag="h3" className="mb-2">
<p className="text-secondary mb-0">{post.summary}</p> {post.title}
</Link> </AnimatedText>
<AnimatedText tag="p" color="secondary" className="mb-0">
{post.summary}
</AnimatedText>
</AnimatedCard>
); );
} }
+31 -27
View File
@@ -1,36 +1,40 @@
import { cva, type VariantProps } from "class-variance-authority"; import UnomSection from "@unom/ui/section";
import type { HTMLAttributes } from "react"; import type { ReactNode } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const section = cva("relative w-full", { // Thin wrapper over @unom/ui's Section so every section on the site doubles as
variants: { // an in-view animation orchestrator: its descendants' Animated* components fade
padding: { // in, staggered, when the section scrolls into view. Keeps the site's own
true: "px-section-main-x py-section-main-y", // section padding/width tokens and the previous call-site API.
false: "p-0", type Props = {
}, children: ReactNode;
maxWidth: { className?: string;
true: "max-w-max-section mx-auto", padding?: boolean;
false: "", maxWidth?: boolean;
}, name?: string;
}, height?: string;
defaultVariants: { };
padding: true,
maxWidth: true,
},
});
type Props = HTMLAttributes<HTMLElement> & VariantProps<typeof section>;
export default function Section({ export default function Section({
padding, children,
maxWidth,
className, className,
...props padding = true,
maxWidth = true,
name,
height,
}: Props) { }: Props) {
return ( return (
<section <UnomSection
{...props} name={name}
className={cn(section({ padding, maxWidth }), className)} maxWidth={maxWidth}
/> noPadding
height={height ?? "fit-content"}
className={cn(
padding && "px-section-main-x py-section-main-y",
className,
)}
>
{children}
</UnomSection>
); );
} }
+52 -12
View File
@@ -1,16 +1,25 @@
// Typed PayloadCMS client. Same pattern as played/plaza — PayloadSDK with the // Typed PayloadCMS client. The unom site reuses the shared unom CMS (one
// Config from `@unom/cms/payload-types` for autocomplete + return-type safety. // Payload, one tenant per project). Every query is scoped to this project's
// tenant via `tenant.slug`; header/footer are per-tenant collections (not
// globals). Same pattern as punktfunk/played/plaza.
import { PayloadSDK } from "@payloadcms/sdk"; import { PayloadSDK } from "@payloadcms/sdk";
import type { Config, Page, Post } from "@unom/cms/payload-types"; import type { Config, Footer, Header, Page, Post } from "@unom/cms/payload-types";
import { getLocale, type Locale } from "@/paraglide/runtime"; import { getLocale, type Locale } from "@/paraglide/runtime";
const CMS_URL = "https://cms.unom.io"; const CMS_URL = "https://cms.unom.io";
// This project's tenant in the shared CMS.
const TENANT = "unom";
export const cmsClient = new PayloadSDK<Config>({ export const cmsClient = new PayloadSDK<Config>({
baseURL: `${CMS_URL}/api`, baseURL: `${CMS_URL}/api`,
}); });
// Scopes a query to this tenant. `tenant.slug` filters on the related tenant
// doc added by the multi-tenant plugin.
const byTenant = { "tenant.slug": { equals: TENANT } };
// The CMS only stores content for the locales declared in its localization // The CMS only stores content for the locales declared in its localization
// config; getLocale() on the website can return any locale paraglide knows // config; getLocale() on the website can return any locale paraglide knows
// about. Narrow to the set the CMS supports. // about. Narrow to the set the CMS supports.
@@ -25,7 +34,7 @@ export async function findPageBySlug(
): Promise<Page | null> { ): Promise<Page | null> {
const res = await cmsClient.find({ const res = await cmsClient.find({
collection: "pages", collection: "pages",
where: { slug: { equals: slug } }, where: { and: [{ slug: { equals: slug } }, byTenant] },
limit: 1, limit: 1,
locale: cmsLocale(locale), locale: cmsLocale(locale),
depth: 1, depth: 1,
@@ -36,6 +45,7 @@ export async function findPageBySlug(
export async function findPosts(locale?: Locale, limit = 50) { export async function findPosts(locale?: Locale, limit = 50) {
return cmsClient.find({ return cmsClient.find({
collection: "posts", collection: "posts",
where: byTenant,
limit, limit,
sort: "-createdAt", sort: "-createdAt",
locale: cmsLocale(locale), locale: cmsLocale(locale),
@@ -48,7 +58,7 @@ export async function findPostBySlug(
): Promise<Post | null> { ): Promise<Post | null> {
const res = await cmsClient.find({ const res = await cmsClient.find({
collection: "posts", collection: "posts",
where: { slug: { equals: slug } }, where: { and: [{ slug: { equals: slug } }, byTenant] },
limit: 1, limit: 1,
locale: cmsLocale(locale), locale: cmsLocale(locale),
depth: 1, depth: 1,
@@ -56,23 +66,53 @@ export async function findPostBySlug(
return res.docs[0] ?? null; return res.docs[0] ?? null;
} }
export async function findFooter(locale?: Locale) { // Projects owned by this tenant — surfaced on the landing page. depth:1 so the
return cmsClient.findGlobal({ slug: "footer", locale: cmsLocale(locale) }); // `logo` upload comes back populated as a Media doc (with its `url`).
export async function findProjects(locale?: Locale, limit = 50) {
return cmsClient.find({
collection: "projects",
where: byTenant,
limit,
sort: "createdAt",
locale: cmsLocale(locale),
depth: 1,
});
} }
export async function findHeader(locale?: Locale) { export async function findFooter(locale?: Locale): Promise<Footer | null> {
return cmsClient.findGlobal({ slug: "header", locale: cmsLocale(locale) }); const res = await cmsClient.find({
collection: "footers",
where: byTenant,
limit: 1,
locale: cmsLocale(locale),
depth: 1,
});
return res.docs[0] ?? null;
} }
export type { Page, Post, Footer, Header } from "@unom/cms/payload-types"; export async function findHeader(locale?: Locale): Promise<Header | null> {
const res = await cmsClient.find({
collection: "headers",
where: byTenant,
limit: 1,
locale: cmsLocale(locale),
depth: 1,
});
return res.docs[0] ?? null;
}
export type { Footer, Header, Page, Post, Project } from "@unom/cms/payload-types";
// Lexical content shape lives inside Payload's serialized editor state. // Lexical content shape lives inside Payload's serialized editor state.
// Pull a few aliases out for the renderer + block typing. // Pull a few aliases out for the renderer + block typing.
type PageBlocks = NonNullable<Page["blocks"]>; type PageBlocks = NonNullable<Page["blocks"]>;
export type RichTextBlock = Extract<PageBlocks[number], { blockType: "RichText" }>; export type RichTextBlock = Extract<
PageBlocks[number],
{ blockType: "RichText" }
>;
export type LexRoot = RichTextBlock["body"]; export type LexRoot = RichTextBlock["body"];
export type LexNode = LexRoot["root"]["children"][number]; export type LexNode = LexRoot["root"]["children"][number];
type FooterSections = NonNullable<import("@unom/cms/payload-types").Footer["sections"]>; type FooterSections = NonNullable<Footer["sections"]>;
export type NavigationSection = FooterSections[number]; export type NavigationSection = FooterSections[number];
export type NavigationLink = NonNullable<NavigationSection["entries"]>[number]; export type NavigationLink = NonNullable<NavigationSection["entries"]>[number];
+15 -9
View File
@@ -1,18 +1,23 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { findPosts } from "@/lib/cms"; import { findPosts, findProjects } from "@/lib/cms";
import { m } from "@/paraglide/messages"; import { m } from "@/paraglide/messages";
import Landing from "@/sections/Landing"; import Landing from "@/sections/Landing";
import LatestPosts from "@/sections/LatestPosts"; import LatestPosts from "@/sections/LatestPosts";
import Projects from "@/sections/Projects";
export const Route = createFileRoute("/")({ export const Route = createFileRoute("/")({
loader: async () => { loader: async () => {
// Soft-fail on CMS hiccups so the hero still renders. // Soft-fail each query independently so the hero still renders even if
try { // the CMS hiccups on one of them.
const { docs } = await findPosts(undefined, 2); const [posts, projects] = await Promise.all([
return { posts: docs }; findPosts(undefined, 2)
} catch { .then((r) => r.docs)
return { posts: [] }; .catch(() => []),
} findProjects(undefined)
.then((r) => r.docs)
.catch(() => []),
]);
return { posts, projects };
}, },
component: HomePage, component: HomePage,
head: () => ({ head: () => ({
@@ -21,10 +26,11 @@ export const Route = createFileRoute("/")({
}); });
function HomePage() { function HomePage() {
const { posts } = Route.useLoaderData(); const { posts, projects } = Route.useLoaderData();
return ( return (
<> <>
<Landing /> <Landing />
<Projects projects={projects} />
<LatestPosts posts={posts} /> <LatestPosts posts={posts} />
</> </>
); );
+25 -7
View File
@@ -1,10 +1,25 @@
import LogoQuadBG from "@/components/LogoQuadBG"; import AnimatedText from "@unom/ui/text";
import { motion } from "motion/react";
import bgDark from "@/assets/unom_Logo_5_Dark.webp"; import bgDark from "@/assets/unom_Logo_5_Dark.webp";
import LogoQuadBG from "@/components/LogoQuadBG";
import Section from "@/components/Section";
import { m } from "@/paraglide/messages"; import { m } from "@/paraglide/messages";
// Inherits the Section's "enter" state when the hero scrolls into view.
const logoVariants = {
from: { opacity: 0, y: 16 },
enter: { opacity: 1, y: 0 },
};
export default function Landing() { export default function Landing() {
return ( return (
<section className="relative w-full h-[90vh] overflow-hidden"> <Section
name="hero"
padding={false}
maxWidth={false}
height="90vh"
className="relative overflow-hidden"
>
<img <img
className="absolute inset-0 h-full w-full object-cover" className="absolute inset-0 h-full w-full object-cover"
src={bgDark} src={bgDark}
@@ -15,12 +30,15 @@ export default function Landing() {
<div className="absolute inset-0 bg-gradient-to-b from-neutral/0 via-neutral/0 to-neutral/80" /> <div className="absolute inset-0 bg-gradient-to-b from-neutral/0 via-neutral/0 to-neutral/80" />
<div className="relative h-full flex flex-col items-center justify-center gap-10 px-section-main-x text-center"> <div className="relative h-full flex flex-col items-center justify-center gap-10 px-section-main-x text-center">
<div className="w-[180px] md:w-[220px]"> <motion.div variants={logoVariants} className="w-[180px] md:w-[220px]">
<LogoQuadBG /> <LogoQuadBG />
</div> </motion.div>
<p className="max-w-md text-lg md:text-xl text-main/90"> <AnimatedText
tag="p"
className="max-w-md text-lg md:text-xl text-main/90"
>
{m.site_tagline()} {m.site_tagline()}
</p> </AnimatedText>
</div> </div>
<div <div
@@ -29,6 +47,6 @@ export default function Landing() {
> >
</div> </div>
</section> </Section>
); );
} }
+11 -8
View File
@@ -1,24 +1,27 @@
import { Link } from "@tanstack/react-router";
import { AnimatedButton } from "@unom/ui/button";
import Heading from "@unom/ui/section/heading";
import PostCard from "@/components/PostCard"; import PostCard from "@/components/PostCard";
import Section from "@/components/Section"; import Section from "@/components/Section";
import type { Post } from "@/lib/cms"; import type { Post } from "@/lib/cms";
import { Link } from "@tanstack/react-router"; import { m } from "@/paraglide/messages";
export default function LatestPosts({ posts }: { posts: Post[] }) { export default function LatestPosts({ posts }: { posts: Post[] }) {
if (posts.length === 0) return null; if (posts.length === 0) return null;
return ( return (
<Section> <Section name="latest-posts">
<div className="flex items-baseline justify-between mb-6"> <Heading>{m.blog_latest_heading()}</Heading>
<h2>Aus dem Blog</h2>
<Link to="/blog" className="text-secondary text-sm">
Alle Beiträge
</Link>
</div>
<div className="grid gap-6 md:grid-cols-2"> <div className="grid gap-6 md:grid-cols-2">
{posts.map((post) => ( {posts.map((post) => (
<PostCard key={post.id} post={post} /> <PostCard key={post.id} post={post} />
))} ))}
</div> </div>
<div className="mt-8">
<AnimatedButton asChild variant="secondary" size="sm">
<Link to="/blog">{m.blog_all()} </Link>
</AnimatedButton>
</div>
</Section> </Section>
); );
} }
+75
View File
@@ -0,0 +1,75 @@
import { AnimatedCard } from "@unom/ui/card";
import Heading from "@unom/ui/section/heading";
import AnimatedText from "@unom/ui/text";
import Section from "@/components/Section";
import type { Project } from "@/lib/cms";
import { m } from "@/paraglide/messages";
// Bare hostname for the card's call-to-action (e.g. "punktfunk.unom.io").
// `url` is admin-controlled and required, but guard anyway so a malformed
// value can't crash SSR.
function hostOf(url: string): string {
try {
return new URL(url).host;
} catch {
return url;
}
}
function ProjectCard({ project }: { project: Project }) {
const logo = typeof project.logo === "object" ? project.logo : null;
return (
<AnimatedCard interactive colorVariant="accent" className="relative">
{/* Full-card click target — overlay keeps the card a motion node. */}
<a
href={project.url}
target="_blank"
rel="noopener noreferrer"
className="absolute inset-0 z-10 rounded-card no-underline hover:no-underline"
>
<span className="sr-only">{project.name}</span>
</a>
{logo?.url ? (
<img
src={logo.url}
alt={logo.alt ?? project.name}
className="mb-4 h-10 w-auto object-contain"
/>
) : (
<AnimatedText
tag="span"
className="mb-4 block font-display text-2xl font-semibold tracking-tight text-main"
>
{project.name}
</AnimatedText>
)}
{project.tagline && (
<AnimatedText tag="p" color="secondary" className="mb-0">
{project.tagline}
</AnimatedText>
)}
<AnimatedText
tag="span"
className="mt-4 block text-sm text-brand-light"
>
{hostOf(project.url)}
</AnimatedText>
</AnimatedCard>
);
}
export default function Projects({ projects }: { projects: Project[] }) {
if (projects.length === 0) return null;
return (
<Section name="projects">
<Heading subtitle={m.projects_intro()}>{m.projects_heading()}</Heading>
<div className="grid gap-6 sm:grid-cols-2">
{projects.map((project) => (
<ProjectCard key={project.id} project={project} />
))}
</div>
</Section>
);
}
+43 -3
View File
@@ -1,13 +1,36 @@
@import "./timing-functions.css" layer(base); @import "./timing-functions.css" layer(base);
@import "tailwindcss"; @import "tailwindcss";
@import "tw-animate-css";
/* Let Tailwind generate the utility classes baked into @unom/ui components. */
@source "../../node_modules/@unom/ui/dist/**/*.{js,mjs}";
:root { :root {
--main: oklch(1 0 0); --main: oklch(1 0 0);
--secondary: oklch(0.78 0 0); --secondary: oklch(0.78 0 0);
/* Brand palette — unom violet. */
--brand: oklch(0.5609 0.2483 280.67); --brand: oklch(0.5609 0.2483 280.67);
--brand-light: oklch(0.7 0.16 282);
--highlight: oklch(0.5038 0.2937 285.38);
/* Dark-violet brand surfaces. */
--neutral: oklch(0.155 0.0395 285.68); --neutral: oklch(0.155 0.0395 285.68);
--neutral-accent: oklch(0.1 0.0395 285.68); --neutral-accent: oklch(0.1 0.0395 285.68);
--highlight: oklch(50.38% 0.293655 285.3753); --neutral-highlight: oklch(0.22 0.0395 285.68);
/* Semantic tokens consumed by @unom/ui (see its theme.css contract). */
--primary: var(--brand-light);
--primary-foreground: var(--neutral);
--accent: var(--brand);
--accent-foreground: var(--main);
--secondary-foreground: var(--main);
--background: var(--neutral);
--highlight-foreground: var(--neutral);
--destructive: var(--error);
--input: oklch(1 0 0 / 0.18);
--ring: var(--brand-light);
--success: oklch(91.1% 0.1605 148.89); --success: oklch(91.1% 0.1605 148.89);
--error: oklch(67.36% 0.2339 0.92); --error: oklch(67.36% 0.2339 0.92);
@@ -18,21 +41,37 @@
Bitstream Vera Sans Mono, Courier New, monospace; Bitstream Vera Sans Mono, Courier New, monospace;
--radius: 0.625rem; --radius: 0.625rem;
/* Floor for nested-card radius (see @unom/ui card.tsx). */
--radius-card-min: var(--radius);
} }
@theme inline static { @theme inline static {
--color-main: var(--main); --color-main: var(--main);
--color-secondary: var(--secondary); --color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-brand: var(--brand); --color-brand: var(--brand);
--color-brand-light: var(--brand-light);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-background: var(--background);
--color-neutral: var(--neutral); --color-neutral: var(--neutral);
--color-neutral-accent: var(--neutral-accent); --color-neutral-accent: var(--neutral-accent);
--color-neutral-highlight: var(--neutral-highlight);
--color-highlight: var(--highlight); --color-highlight: var(--highlight);
--color-highlight-foreground: var(--highlight-foreground);
--color-destructive: var(--destructive);
--color-input: var(--input);
--color-ring: var(--ring);
--color-success: var(--success); --color-success: var(--success);
--color-error: var(--error); --color-error: var(--error);
--font-display: var(--font-display); --font-display: var(--font-display);
--radius-card: calc(var(--radius) * 1.25); --radius-button: 9999px;
--radius-card: calc(var(--radius) * 2.5);
--radius-main: calc(var(--radius) * 2.5);
--radius-sm: calc(var(--radius) - 4px); --radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px); --radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius); --radius-lg: var(--radius);
@@ -41,7 +80,8 @@
@theme { @theme {
--spacing-main: 20px; --spacing-main: 20px;
--spacing-card: 1rem; --spacing-card: 1.5rem;
--spacing-input-height: 3rem;
--spacing-section-main-x: 25px; --spacing-section-main-x: 25px;
--spacing-section-main-y: 45px; --spacing-section-main-y: 45px;