feat: adopt @unom/ui + add Projects section to the landing page
- 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:
@@ -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.",
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user