diff --git a/data/brand-kit.json b/data/brand-kit.json new file mode 100644 index 0000000..1a4628f --- /dev/null +++ b/data/brand-kit.json @@ -0,0 +1,74 @@ +[ + { + "id": "brand_logo_primary_svg", + "name": "Primary Wordmark", + "type": "logo", + "description": "Main horizontal wordmark for headers, docs, and website hero sections.", + "url": "https://cdn.foragents.dev/brand/primary-wordmark.svg", + "format": "svg", + "updatedAt": "2026-02-10T09:10:00.000Z" + }, + { + "id": "brand_logo_mark_png", + "name": "Icon Mark", + "type": "logo", + "description": "Square icon for avatars, social profiles, and app launchers.", + "url": "https://cdn.foragents.dev/brand/icon-mark.png", + "format": "png", + "updatedAt": "2026-02-10T09:11:00.000Z" + }, + { + "id": "brand_color_primary", + "name": "Agent Green", + "type": "color", + "description": "Primary action color used for CTAs, highlights, and active states.", + "url": "https://cdn.foragents.dev/brand/colors/agent-green.ase", + "format": "ase", + "updatedAt": "2026-02-10T09:12:00.000Z" + }, + { + "id": "brand_color_neutral", + "name": "Midnight Surface", + "type": "color", + "description": "Dark background token for panels and full-bleed surfaces.", + "url": "https://cdn.foragents.dev/brand/colors/midnight-surface.ase", + "format": "ase", + "updatedAt": "2026-02-10T09:13:00.000Z" + }, + { + "id": "brand_font_heading", + "name": "Space Grotesk Heading Spec", + "type": "font", + "description": "Approved heading font stack with weight and spacing recommendations.", + "url": "https://cdn.foragents.dev/brand/fonts/space-grotesk-spec.pdf", + "format": "pdf", + "updatedAt": "2026-02-10T09:14:00.000Z" + }, + { + "id": "brand_font_body", + "name": "Inter Body Spec", + "type": "font", + "description": "Body typography usage guide for paragraphs, lists, and UI copy.", + "url": "https://cdn.foragents.dev/brand/fonts/inter-body-spec.pdf", + "format": "pdf", + "updatedAt": "2026-02-10T09:15:00.000Z" + }, + { + "id": "brand_guideline_logo_clearspace", + "name": "Logo Clearspace Rules", + "type": "guideline", + "description": "Defines safe spacing, minimum sizes, and prohibited logo manipulations.", + "url": "https://cdn.foragents.dev/brand/guidelines/logo-clearspace.pdf", + "format": "pdf", + "updatedAt": "2026-02-10T09:16:00.000Z" + }, + { + "id": "brand_guideline_press_usage", + "name": "Press Usage Checklist", + "type": "guideline", + "description": "Editorial and attribution requirements for media and partner coverage.", + "url": "https://cdn.foragents.dev/brand/guidelines/press-usage-checklist.pdf", + "format": "pdf", + "updatedAt": "2026-02-10T09:17:00.000Z" + } +] diff --git a/src/app/api/brand-kit/route.ts b/src/app/api/brand-kit/route.ts new file mode 100644 index 0000000..c8b7df9 --- /dev/null +++ b/src/app/api/brand-kit/route.ts @@ -0,0 +1,83 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + brandKitTypes, + createBrandKitEntry, + isBrandKitType, + queryBrandKit, + type BrandKitType, +} from "@/lib/brandKit"; + +export const runtime = "nodejs"; + +export async function GET(request: NextRequest) { + try { + const typeParam = request.nextUrl.searchParams.get("type"); + const search = request.nextUrl.searchParams.get("search")?.trim() ?? ""; + + if (typeParam && !isBrandKitType(typeParam)) { + return NextResponse.json( + { error: `Invalid type. Use one of: ${brandKitTypes.join(", ")}` }, + { status: 400 } + ); + } + + const items = await queryBrandKit({ + type: (typeParam as BrandKitType | null) ?? undefined, + search: search || undefined, + }); + + return NextResponse.json({ + items, + filters: { + type: typeParam ?? null, + search, + availableTypes: brandKitTypes, + }, + total: items.length, + }); + } catch (error) { + console.error("Failed to load brand kit", error); + return NextResponse.json({ error: "Failed to load brand kit" }, { status: 500 }); + } +} + +export async function POST(request: NextRequest) { + try { + const body = (await request.json()) as Partial<{ + id: string; + name: string; + type: BrandKitType; + description: string; + url: string; + format: string; + }>; + + if (!body.name || !body.type || !body.description || !body.url || !body.format) { + return NextResponse.json( + { error: "name, type, description, url, and format are required" }, + { status: 400 } + ); + } + + if (!isBrandKitType(body.type)) { + return NextResponse.json( + { error: `Invalid type. Use one of: ${brandKitTypes.join(", ")}` }, + { status: 400 } + ); + } + + const created = await createBrandKitEntry({ + id: body.id, + name: body.name, + type: body.type, + description: body.description, + url: body.url, + format: body.format, + }); + + return NextResponse.json({ ok: true, item: created }, { status: 201 }); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to create brand kit item"; + return NextResponse.json({ error: message }, { status: 400 }); + } +} diff --git a/src/app/brand-kit/page.tsx b/src/app/brand-kit/page.tsx new file mode 100644 index 0000000..0a97d8b --- /dev/null +++ b/src/app/brand-kit/page.tsx @@ -0,0 +1,125 @@ +/* eslint-disable react/no-unescaped-entities */ +import type { Metadata } from "next"; +import Link from "next/link"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Separator } from "@/components/ui/separator"; +import { brandKitTypes, queryBrandKit, type BrandKitType } from "@/lib/brandKit"; + +export const metadata: Metadata = { + title: "Brand Kit — forAgents.dev", + description: "Persistent brand assets and guidelines for logos, colors, fonts, and press usage.", + openGraph: { + title: "Brand Kit — forAgents.dev", + description: "Persistent brand assets and guidelines for logos, colors, fonts, and press usage.", + url: "https://foragents.dev/brand-kit", + siteName: "forAgents.dev", + type: "website", + }, +}; + +export const dynamic = "force-dynamic"; + +type SearchParams = { + type?: string; + search?: string; +}; + +function titleForType(type: BrandKitType) { + if (type === "logo") return "Logos"; + if (type === "color") return "Colors"; + if (type === "font") return "Fonts"; + return "Guidelines"; +} + +export default async function BrandKitPage({ + searchParams, +}: { + searchParams: Promise; +}) { + const params = await searchParams; + const selectedType = brandKitTypes.includes((params.type ?? "") as BrandKitType) + ? ((params.type ?? "") as BrandKitType) + : undefined; + const search = params.search?.trim() ?? ""; + + const items = await queryBrandKit({ + type: selectedType, + search: search || undefined, + }); + + return ( +
+
+ + Persistent JSON + API + +

forAgents.dev Brand Kit

+

+ Official downloadable assets for logos, colors, fonts, and brand guidelines. +

+
+ + + +
+
+ + {brandKitTypes.map((type) => ( + + ))} +
+ +
+ {selectedType ? : null} + + +
+ +

+ Showing {items.length} result{items.length === 1 ? "" : "s"} + {selectedType ? ` in ${titleForType(selectedType)}` : ""} + {search ? ` matching "${search}"` : ""} +

+ +
+ {items.map((item) => ( + + + + {item.name} + + {item.type} + + + + +

{item.description}

+

Format: {item.format.toUpperCase()}

+

Updated: {new Date(item.updatedAt).toLocaleString()}

+ +
+
+ ))} +
+
+
+ ); +} diff --git a/src/app/brand/page.tsx b/src/app/brand/page.tsx index 248421e..5627a58 100644 --- a/src/app/brand/page.tsx +++ b/src/app/brand/page.tsx @@ -1,19 +1,6 @@ /* eslint-disable react/no-unescaped-entities */ -import { Metadata } from "next"; -import { BrandClientPage } from "./brand-client"; - -export const metadata: Metadata = { - title: "Brand Guidelines — forAgents.dev", - description: "Logo usage, color palette, typography, and press kit resources for forAgents.dev", - openGraph: { - title: "Brand Guidelines — forAgents.dev", - description: "Logo usage, color palette, typography, and press kit resources for forAgents.dev", - url: "https://foragents.dev/brand", - siteName: "forAgents.dev", - type: "website", - }, -}; +import { redirect } from "next/navigation"; export default function BrandPage() { - return ; + redirect("/brand-kit"); } diff --git a/src/data/brand-kit.json b/src/data/brand-kit.json new file mode 100644 index 0000000..1a4628f --- /dev/null +++ b/src/data/brand-kit.json @@ -0,0 +1,74 @@ +[ + { + "id": "brand_logo_primary_svg", + "name": "Primary Wordmark", + "type": "logo", + "description": "Main horizontal wordmark for headers, docs, and website hero sections.", + "url": "https://cdn.foragents.dev/brand/primary-wordmark.svg", + "format": "svg", + "updatedAt": "2026-02-10T09:10:00.000Z" + }, + { + "id": "brand_logo_mark_png", + "name": "Icon Mark", + "type": "logo", + "description": "Square icon for avatars, social profiles, and app launchers.", + "url": "https://cdn.foragents.dev/brand/icon-mark.png", + "format": "png", + "updatedAt": "2026-02-10T09:11:00.000Z" + }, + { + "id": "brand_color_primary", + "name": "Agent Green", + "type": "color", + "description": "Primary action color used for CTAs, highlights, and active states.", + "url": "https://cdn.foragents.dev/brand/colors/agent-green.ase", + "format": "ase", + "updatedAt": "2026-02-10T09:12:00.000Z" + }, + { + "id": "brand_color_neutral", + "name": "Midnight Surface", + "type": "color", + "description": "Dark background token for panels and full-bleed surfaces.", + "url": "https://cdn.foragents.dev/brand/colors/midnight-surface.ase", + "format": "ase", + "updatedAt": "2026-02-10T09:13:00.000Z" + }, + { + "id": "brand_font_heading", + "name": "Space Grotesk Heading Spec", + "type": "font", + "description": "Approved heading font stack with weight and spacing recommendations.", + "url": "https://cdn.foragents.dev/brand/fonts/space-grotesk-spec.pdf", + "format": "pdf", + "updatedAt": "2026-02-10T09:14:00.000Z" + }, + { + "id": "brand_font_body", + "name": "Inter Body Spec", + "type": "font", + "description": "Body typography usage guide for paragraphs, lists, and UI copy.", + "url": "https://cdn.foragents.dev/brand/fonts/inter-body-spec.pdf", + "format": "pdf", + "updatedAt": "2026-02-10T09:15:00.000Z" + }, + { + "id": "brand_guideline_logo_clearspace", + "name": "Logo Clearspace Rules", + "type": "guideline", + "description": "Defines safe spacing, minimum sizes, and prohibited logo manipulations.", + "url": "https://cdn.foragents.dev/brand/guidelines/logo-clearspace.pdf", + "format": "pdf", + "updatedAt": "2026-02-10T09:16:00.000Z" + }, + { + "id": "brand_guideline_press_usage", + "name": "Press Usage Checklist", + "type": "guideline", + "description": "Editorial and attribution requirements for media and partner coverage.", + "url": "https://cdn.foragents.dev/brand/guidelines/press-usage-checklist.pdf", + "format": "pdf", + "updatedAt": "2026-02-10T09:17:00.000Z" + } +] diff --git a/src/lib/brandKit.ts b/src/lib/brandKit.ts new file mode 100644 index 0000000..7bb198e --- /dev/null +++ b/src/lib/brandKit.ts @@ -0,0 +1,159 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; +import seedEntries from "@/data/brand-kit.json"; + +export const brandKitTypes = ["logo", "color", "font", "guideline"] as const; + +export type BrandKitType = (typeof brandKitTypes)[number]; + +export type BrandKitEntry = { + id: string; + name: string; + type: BrandKitType; + description: string; + url: string; + format: string; + updatedAt: string; +}; + +export type BrandKitQuery = { + type?: BrandKitType; + search?: string; +}; + +export type CreateBrandKitInput = { + name: string; + type: BrandKitType; + description: string; + url: string; + format: string; + id?: string; +}; + +const BRAND_KIT_PATH = path.join(process.cwd(), "data", "brand-kit.json"); +const SRC_BRAND_KIT_PATH = path.join(process.cwd(), "src", "data", "brand-kit.json"); + +function isBrandKitType(value: unknown): value is BrandKitType { + return typeof value === "string" && brandKitTypes.includes(value as BrandKitType); +} + +function isValidIsoDate(value: unknown): value is string { + return typeof value === "string" && !Number.isNaN(Date.parse(value)); +} + +function normalizeEntry(input: unknown): BrandKitEntry | null { + if (!input || typeof input !== "object" || Array.isArray(input)) { + return null; + } + + const row = input as Record; + + if ( + typeof row.id !== "string" || + typeof row.name !== "string" || + !isBrandKitType(row.type) || + typeof row.description !== "string" || + typeof row.url !== "string" || + typeof row.format !== "string" + ) { + return null; + } + + return { + id: row.id, + name: row.name, + type: row.type, + description: row.description, + url: row.url, + format: row.format, + updatedAt: isValidIsoDate(row.updatedAt) ? row.updatedAt : new Date().toISOString(), + }; +} + +function normalizeData(raw: unknown): BrandKitEntry[] { + if (!Array.isArray(raw)) { + return []; + } + + return raw + .map((entry) => normalizeEntry(entry)) + .filter((entry): entry is BrandKitEntry => entry !== null) + .sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt)); +} + +async function writeBothPaths(entries: BrandKitEntry[]) { + const payload = `${JSON.stringify(entries, null, 2)}\n`; + + await fs.mkdir(path.dirname(BRAND_KIT_PATH), { recursive: true }); + await fs.mkdir(path.dirname(SRC_BRAND_KIT_PATH), { recursive: true }); + + await Promise.all([ + fs.writeFile(BRAND_KIT_PATH, payload, "utf-8"), + fs.writeFile(SRC_BRAND_KIT_PATH, payload, "utf-8"), + ]); +} + +export async function readBrandKit(): Promise { + try { + const raw = await fs.readFile(BRAND_KIT_PATH, "utf-8"); + return normalizeData(JSON.parse(raw)); + } catch { + return normalizeData(seedEntries as unknown[]); + } +} + +export async function queryBrandKit(query: BrandKitQuery = {}): Promise { + const entries = await readBrandKit(); + + return entries.filter((entry) => { + if (query.type && entry.type !== query.type) { + return false; + } + + if (query.search) { + const needle = query.search.toLowerCase(); + const haystack = `${entry.name} ${entry.description} ${entry.format} ${entry.url}`.toLowerCase(); + if (!haystack.includes(needle)) { + return false; + } + } + + return true; + }); +} + +export async function createBrandKitEntry(input: CreateBrandKitInput): Promise { + if (!isBrandKitType(input.type)) { + throw new Error("Invalid brand kit type"); + } + + const name = input.name.trim(); + const description = input.description.trim(); + const url = input.url.trim(); + const format = input.format.trim(); + + if (!name || !description || !url || !format) { + throw new Error("name, description, url, and format are required"); + } + + const entries = await readBrandKit(); + + const created: BrandKitEntry = { + id: + input.id?.trim().length + ? input.id.trim() + : `brand_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, + name, + type: input.type, + description, + url, + format, + updatedAt: new Date().toISOString(), + }; + + await writeBothPaths([created, ...entries]); + + return created; +} + +export { isBrandKitType };