diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-03-04 01:48:39 +0100 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-03-04 01:48:39 +0100 |
| commit | 46e55674dee7061973e155375dc4f01fb4f13c00 (patch) | |
| tree | d1391cf1d41ef3326ace60438e1ee52ef3cfc572 /apps/web | |
| parent | 8e8f1c3596df461d67d946383574a166b51e3dfc (diff) | |
| download | opencut-46e55674dee7061973e155375dc4f01fb4f13c00.tar.gz | |
feat: branding page
Diffstat (limited to 'apps/web')
| -rw-r--r-- | apps/web/src/app/base-page.tsx | 5 | ||||
| -rw-r--r-- | apps/web/src/app/branding/page.tsx | 243 |
2 files changed, 247 insertions, 1 deletions
diff --git a/apps/web/src/app/base-page.tsx b/apps/web/src/app/base-page.tsx index a3d56fe5..1841a1ee 100644 --- a/apps/web/src/app/base-page.tsx +++ b/apps/web/src/app/base-page.tsx @@ -8,7 +8,8 @@ interface BasePageProps { mainClassName?: string; maxWidth?: "3xl" | "6xl" | "full"; title?: string; - description?: string; + description?: React.ReactNode; + action?: React.ReactNode; } export function BasePage({ @@ -18,6 +19,7 @@ export function BasePage({ maxWidth = "3xl", title, description, + action, }: BasePageProps) { const maxWidthClass = { "3xl": "max-w-3xl", @@ -43,6 +45,7 @@ export function BasePage({ <p className="text-muted-foreground mx-auto max-w-2xl text-xl leading-relaxed"> {description} </p> + {action} </div> )} {children} diff --git a/apps/web/src/app/branding/page.tsx b/apps/web/src/app/branding/page.tsx new file mode 100644 index 00000000..4ebef644 --- /dev/null +++ b/apps/web/src/app/branding/page.tsx @@ -0,0 +1,243 @@ +"use client"; + +import Image from "next/image"; +import Link from "next/link"; +import { Check, Copy, Download } from "lucide-react"; +import { useState } from "react"; +import { BasePage } from "@/app/base-page"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { Separator } from "@/components/ui/separator"; +import { cn } from "@/utils/ui"; + +function downloadAsset(src: string) { + const filename = src.split("/").pop() ?? "asset.svg"; + const a = document.createElement("a"); + a.href = src; + a.download = filename; + a.click(); +} + +async function copyAsset(src: string) { + const res = await fetch(src); + const text = await res.text(); + await navigator.clipboard.writeText(text); +} + +const ALL_ASSETS = () => + ASSET_SECTIONS.flatMap((s) => s.assets); + +type AssetTheme = "dark" | "light" | "icon"; + +interface AssetVariant { + src: string; + theme: AssetTheme; + label: string; + width: number; + height: number; +} + +interface AssetSection { + title: string; + description: string; + cols: "1" | "2"; + assets: AssetVariant[]; +} + +const ASSET_SECTIONS: AssetSection[] = [ + { + title: "Symbol", + description: + "Use the symbol on its own when the OpenCut name is already present nearby or space is limited.", + cols: "2", + assets: [ + { + src: "/logos/opencut/symbol.svg", + theme: "dark", + label: "Symbol", + width: 400, + height: 400, + }, + { + src: "/logos/opencut/symbol-light.svg", + theme: "light", + label: "Symbol", + width: 400, + height: 400, + }, + ], + }, + { + title: "Lockup", + description: + "The full lockup combines the symbol and wordmark. Prefer this in most contexts where you have enough horizontal space.", + cols: "2", + assets: [ + { + src: "/logos/opencut/logo.svg", + theme: "dark", + label: "Logo", + width: 1809, + height: 400, + }, + { + src: "/logos/opencut/logo-light.svg", + theme: "light", + label: "Logo", + width: 1809, + height: 400, + }, + { + src: "/logos/opencut/text.svg", + theme: "dark", + label: "Text", + width: 1760, + height: 400, + }, + { + src: "/logos/opencut/text-light.svg", + theme: "light", + label: "Text", + width: 1760, + height: 400, + }, + ], + }, +]; + +export default function BrandingPage() { + return ( + <BasePage + maxWidth="6xl" + title="Brand" + description={ + <> + Download OpenCut brand assets for use in your projects.{" "} + <Link href="#guidelines" className="underline underline-offset-4"> + Read the brand guidelines. + </Link> + </> + } + action={ + <Button + variant="outline" + size="lg" + className="mx-auto gap-2" + onClick={() => { + ALL_ASSETS().forEach((asset, i) => { + setTimeout(() => downloadAsset(asset.src), i * 200); + }); + }} + > + <Download /> + Download all + </Button> + } + > + <div className="flex flex-col gap-10"> + {ASSET_SECTIONS.map((section) => ( + <div key={section.title} className="flex flex-col gap-4"> + <div className="flex flex-col gap-1"> + <h2 className="font-semibold text-lg">{section.title}</h2> + <p className="text-muted-foreground text-sm"> + {section.description} + </p> + </div> + <div + className={cn( + "grid gap-3", + section.cols === "2" + ? "grid-cols-1 sm:grid-cols-2" + : "grid-cols-1", + )} + > + {section.assets.map((variant) => ( + <AssetCard key={variant.src} variant={variant} /> + ))} + </div> + </div> + ))} + </div> + + <Separator /> + + <div id="guidelines" className="flex flex-col gap-8 text-sm"> + <div className="flex flex-col gap-3"> + <h2 className="font-semibold text-lg">Usage</h2> + <p className="text-muted-foreground text-base leading-relaxed"> + OpenCut is open source — the code is free to use under its license. + That license does not cover the name or logo. You can say you use + OpenCut, that your project integrates with OpenCut, or that it was + built on top of OpenCut. You cannot name your product OpenCut, imply + we made or endorse your product, or use the marks commercially + without asking first. For anything unclear, reach out at{" "} + <Link + href="mailto:brand@opencut.app" + className="underline underline-offset-4" + > + brand@opencut.app + </Link> + . + </p> + </div> + + <div className="flex flex-col gap-3"> + <h2 className="font-semibold text-lg">What's not allowed</h2> + <ul className="text-muted-foreground text-base flex flex-col gap-2 leading-relaxed"> + {[ + "Using OpenCut in the name of your product, service, or domain.", + "Implying that OpenCut made, sponsors, or endorses your work.", + "Using the logo or name on merchandise or commercial marketing.", + "Modifying the marks.", + ].map((item) => ( + <li key={item} className="flex gap-2"> + <span className="mt-0.5 shrink-0">-</span> + <span>{item}</span> + </li> + ))} + </ul> + </div> + </div> + </BasePage> + ); +} + +function AssetCard({ variant }: { variant: AssetVariant }) { + const [copied, setCopied] = useState(false); + + async function handleCopy() { + await copyAsset(variant.src); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + + return ( + <Card + className={cn( + "group relative overflow-hidden", + variant.theme === "light" ? "bg-black" : "bg-white", + )} + > + <div className="flex h-56 items-center justify-center px-12 py-8"> + <Image + src={variant.src} + alt={variant.label} + width={variant.width} + height={variant.height} + className="max-h-16 w-auto select-none object-contain" + draggable={false} + unoptimized + /> + </div> + + <Button + variant="outline" + size="icon" + className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 size-9" + onClick={handleCopy} + > + {copied ? <Check /> : <Copy />} + </Button> + </Card> + ); +} |
