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",
"projects_heading": "Projekte",
"projects_intro": "Eigene Produkte und Marken aus dem Hause unom.",
"blog_title": "Blog",
"blog_meta_description": "Notizen, Gedanken und Tutorials aus der Werkstatt von unom.",
"blog_empty": "Noch keine Beiträge.",
+3
View File
@@ -6,6 +6,9 @@
"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_meta_description": "Notes, thoughts, and tutorials from the unom workshop.",
"blog_empty": "No posts yet.",
+9 -2
View File
@@ -22,14 +22,21 @@
"@tanstack/react-router": "latest",
"@tanstack/react-start": "latest",
"@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",
"clsx": "^2.1.1",
"lucide-react": "^1.21.0",
"motion": "^12.40.0",
"nitro": "^3.0.260429-beta",
"radix-ui": "^1.6.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.0"
"tailwindcss": "^4.3.0",
"tw-animate-css": "^1.4.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@biomejs/biome": "2.4.15",
+19 -9
View File
@@ -1,4 +1,6 @@
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 { getLocale } from "@/paraglide/runtime";
@@ -11,19 +13,27 @@ export default function PostCard({ post }: { post: Post }) {
});
return (
<Link
to="/blog/$slug"
params={{ slug: post.slug }}
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"
>
<AnimatedCard interactive colorVariant="accent" className="relative">
{/* Full-card click target — kept as an overlay so the card stays a
motion node (asChild would disable its animation). */}
<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
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))}
</time>
<h3 className="mb-2">{post.title}</h3>
<p className="text-secondary mb-0">{post.summary}</p>
</Link>
<AnimatedText tag="h3" className="mb-2">
{post.title}
</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 type { HTMLAttributes } from "react";
import UnomSection from "@unom/ui/section";
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
const section = cva("relative w-full", {
variants: {
padding: {
true: "px-section-main-x py-section-main-y",
false: "p-0",
},
maxWidth: {
true: "max-w-max-section mx-auto",
false: "",
},
},
defaultVariants: {
padding: true,
maxWidth: true,
},
});
type Props = HTMLAttributes<HTMLElement> & VariantProps<typeof section>;
// Thin wrapper over @unom/ui's Section so every section on the site doubles as
// an in-view animation orchestrator: its descendants' Animated* components fade
// in, staggered, when the section scrolls into view. Keeps the site's own
// section padding/width tokens and the previous call-site API.
type Props = {
children: ReactNode;
className?: string;
padding?: boolean;
maxWidth?: boolean;
name?: string;
height?: string;
};
export default function Section({
padding,
maxWidth,
children,
className,
...props
padding = true,
maxWidth = true,
name,
height,
}: Props) {
return (
<section
{...props}
className={cn(section({ padding, maxWidth }), className)}
/>
<UnomSection
name={name}
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
// Config from `@unom/cms/payload-types` for autocomplete + return-type safety.
// Typed PayloadCMS client. The unom site reuses the shared unom CMS (one
// 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 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";
const CMS_URL = "https://cms.unom.io";
// This project's tenant in the shared CMS.
const TENANT = "unom";
export const cmsClient = new PayloadSDK<Config>({
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
// config; getLocale() on the website can return any locale paraglide knows
// about. Narrow to the set the CMS supports.
@@ -25,7 +34,7 @@ export async function findPageBySlug(
): Promise<Page | null> {
const res = await cmsClient.find({
collection: "pages",
where: { slug: { equals: slug } },
where: { and: [{ slug: { equals: slug } }, byTenant] },
limit: 1,
locale: cmsLocale(locale),
depth: 1,
@@ -36,6 +45,7 @@ export async function findPageBySlug(
export async function findPosts(locale?: Locale, limit = 50) {
return cmsClient.find({
collection: "posts",
where: byTenant,
limit,
sort: "-createdAt",
locale: cmsLocale(locale),
@@ -48,7 +58,7 @@ export async function findPostBySlug(
): Promise<Post | null> {
const res = await cmsClient.find({
collection: "posts",
where: { slug: { equals: slug } },
where: { and: [{ slug: { equals: slug } }, byTenant] },
limit: 1,
locale: cmsLocale(locale),
depth: 1,
@@ -56,23 +66,53 @@ export async function findPostBySlug(
return res.docs[0] ?? null;
}
export async function findFooter(locale?: Locale) {
return cmsClient.findGlobal({ slug: "footer", locale: cmsLocale(locale) });
// Projects owned by this tenant — surfaced on the landing page. depth:1 so the
// `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) {
return cmsClient.findGlobal({ slug: "header", locale: cmsLocale(locale) });
export async function findFooter(locale?: Locale): Promise<Footer | null> {
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.
// Pull a few aliases out for the renderer + block typing.
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 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 NavigationLink = NonNullable<NavigationSection["entries"]>[number];
+15 -9
View File
@@ -1,18 +1,23 @@
import { createFileRoute } from "@tanstack/react-router";
import { findPosts } from "@/lib/cms";
import { findPosts, findProjects } from "@/lib/cms";
import { m } from "@/paraglide/messages";
import Landing from "@/sections/Landing";
import LatestPosts from "@/sections/LatestPosts";
import Projects from "@/sections/Projects";
export const Route = createFileRoute("/")({
loader: async () => {
// Soft-fail on CMS hiccups so the hero still renders.
try {
const { docs } = await findPosts(undefined, 2);
return { posts: docs };
} catch {
return { posts: [] };
}
// Soft-fail each query independently so the hero still renders even if
// the CMS hiccups on one of them.
const [posts, projects] = await Promise.all([
findPosts(undefined, 2)
.then((r) => r.docs)
.catch(() => []),
findProjects(undefined)
.then((r) => r.docs)
.catch(() => []),
]);
return { posts, projects };
},
component: HomePage,
head: () => ({
@@ -21,10 +26,11 @@ export const Route = createFileRoute("/")({
});
function HomePage() {
const { posts } = Route.useLoaderData();
const { posts, projects } = Route.useLoaderData();
return (
<>
<Landing />
<Projects projects={projects} />
<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 LogoQuadBG from "@/components/LogoQuadBG";
import Section from "@/components/Section";
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() {
return (
<section className="relative w-full h-[90vh] overflow-hidden">
<Section
name="hero"
padding={false}
maxWidth={false}
height="90vh"
className="relative overflow-hidden"
>
<img
className="absolute inset-0 h-full w-full object-cover"
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="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 />
</div>
<p className="max-w-md text-lg md:text-xl text-main/90">
</motion.div>
<AnimatedText
tag="p"
className="max-w-md text-lg md:text-xl text-main/90"
>
{m.site_tagline()}
</p>
</AnimatedText>
</div>
<div
@@ -29,6 +47,6 @@ export default function Landing() {
>
</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 Section from "@/components/Section";
import type { Post } from "@/lib/cms";
import { Link } from "@tanstack/react-router";
import { m } from "@/paraglide/messages";
export default function LatestPosts({ posts }: { posts: Post[] }) {
if (posts.length === 0) return null;
return (
<Section>
<div className="flex items-baseline justify-between mb-6">
<h2>Aus dem Blog</h2>
<Link to="/blog" className="text-secondary text-sm">
Alle Beiträge
</Link>
</div>
<Section name="latest-posts">
<Heading>{m.blog_latest_heading()}</Heading>
<div className="grid gap-6 md:grid-cols-2">
{posts.map((post) => (
<PostCard key={post.id} post={post} />
))}
</div>
<div className="mt-8">
<AnimatedButton asChild variant="secondary" size="sm">
<Link to="/blog">{m.blog_all()} </Link>
</AnimatedButton>
</div>
</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 "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 {
--main: oklch(1 0 0);
--secondary: oklch(0.78 0 0);
/* Brand palette — unom violet. */
--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-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);
--error: oklch(67.36% 0.2339 0.92);
@@ -18,21 +41,37 @@
Bitstream Vera Sans Mono, Courier New, monospace;
--radius: 0.625rem;
/* Floor for nested-card radius (see @unom/ui card.tsx). */
--radius-card-min: var(--radius);
}
@theme inline static {
--color-main: var(--main);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--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-accent: var(--neutral-accent);
--color-neutral-highlight: var(--neutral-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-error: var(--error);
--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-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
@@ -41,7 +80,8 @@
@theme {
--spacing-main: 20px;
--spacing-card: 1rem;
--spacing-card: 1.5rem;
--spacing-input-height: 3rem;
--spacing-section-main-x: 25px;
--spacing-section-main-y: 45px;