diff --git a/apps/web/src/features/brand-studio/components/brand-studio-panel.tsx b/apps/web/src/features/brand-studio/components/brand-studio-panel.tsx index 0db6860..760150d 100644 --- a/apps/web/src/features/brand-studio/components/brand-studio-panel.tsx +++ b/apps/web/src/features/brand-studio/components/brand-studio-panel.tsx @@ -5,6 +5,7 @@ import { RbcButton } from "@/components/ui/rbc-button"; import { exportBrandJson } from "@/features/brand-studio/exporters/export-brand-json"; import { useBrandStore } from "@/features/brand-studio/store/brand-store"; import { downloadFile } from "@/features/theme-engine/exporters/download-file"; +import { LogoBuilderPanel } from "@/features/logo-builder/components/logo-builder-panel"; export function BrandStudioPanel() { const brand = useBrandStore((state) => state.brand); @@ -181,8 +182,10 @@ export function BrandStudioPanel() { - Export Brand Kit JSON - + Export Brand Kit JSON + + + ); diff --git a/apps/web/src/features/logo-builder/components/logo-builder-panel.tsx b/apps/web/src/features/logo-builder/components/logo-builder-panel.tsx new file mode 100644 index 0000000..08bde40 --- /dev/null +++ b/apps/web/src/features/logo-builder/components/logo-builder-panel.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { RbcBadge } from "@/components/ui/rbc-badge"; +import { RbcButton } from "@/components/ui/rbc-button"; +import { exportLogoSvg } from "@/features/logo-builder/exporters/export-logo-svg"; +import { LogoControls } from "@/features/logo-builder/components/logo-controls"; +import { LogoPreview } from "@/features/logo-builder/components/logo-preview"; +import { useLogoStore } from "@/features/logo-builder/store/logo-store"; +import { downloadFile } from "@/features/theme-engine/exporters/download-file"; + +export function LogoBuilderPanel() { + const logo = useLogoStore((state) => state.logo); + const resetLogo = useLogoStore((state) => state.resetLogo); + + function handleExportSvg(): void { + downloadFile("rainbowcode-logo.svg", exportLogoSvg(logo), "image/svg+xml"); + } + + return ( +
+
+
+ +
+
+
+ Logo Builder + SVG Export +
+ +

+ Visual logo system +

+ +

+ Create a text-based brand mark with typography, gradient, preview, + and exportable SVG. +

+
+ + + Reset + +
+
+ +
+ + +
+ + + Export Logo SVG + +
+
+
+ ); +} \ No newline at end of file diff --git a/apps/web/src/features/logo-builder/components/logo-controls.tsx b/apps/web/src/features/logo-builder/components/logo-controls.tsx new file mode 100644 index 0000000..80423b8 --- /dev/null +++ b/apps/web/src/features/logo-builder/components/logo-controls.tsx @@ -0,0 +1,248 @@ +"use client"; + +import type { + LogoFontFamily, + LogoGradientDirection, +} from "@/features/logo-builder/types/logo"; +import { useLogoStore } from "@/features/logo-builder/store/logo-store"; + +const fontFamilies: readonly LogoFontFamily[] = [ + "Inter", + "Poppins", + "Montserrat", + "Playfair Display", + "Space Grotesk", +]; + +const gradientDirections: readonly { + readonly label: string; + readonly value: LogoGradientDirection; +}[] = [ + { label: "Right", value: "to-right" }, + { label: "Bottom Right", value: "to-bottom-right" }, + { label: "Bottom", value: "to-bottom" }, + { label: "Top Right", value: "to-top-right" }, +]; + +function fieldClass(): string { + return "mt-1 h-11 w-full rounded-2xl border border-slate-200 bg-white px-3 text-sm font-semibold text-slate-950 outline-none transition focus:border-indigo-300 focus:ring-4 focus:ring-indigo-500/10 dark:border-slate-800 dark:bg-slate-950 dark:text-white"; +} + +export function LogoControls() { + const logo = useLogoStore((state) => state.logo); + const updateText = useLogoStore((state) => state.updateText); + const updateTagline = useLogoStore((state) => state.updateTagline); + const updateFontFamily = useLogoStore((state) => state.updateFontFamily); + const updateFontWeight = useLogoStore((state) => state.updateFontWeight); + const updateLetterSpacing = useLogoStore( + (state) => state.updateLetterSpacing, + ); + const updatePrimaryColor = useLogoStore((state) => state.updatePrimaryColor); + const updateSecondaryColor = useLogoStore( + (state) => state.updateSecondaryColor, + ); + const updateBackgroundColor = useLogoStore( + (state) => state.updateBackgroundColor, + ); + const updateGradientDirection = useLogoStore( + (state) => state.updateGradientDirection, + ); + const updateRadius = useLogoStore((state) => state.updateRadius); + + return ( +
+
+ + updateText(event.currentTarget.value)} + className={fieldClass()} + /> +
+ +
+ + updateTagline(event.currentTarget.value)} + className={fieldClass()} + /> +
+ +
+ + +
+ +
+
+ + + updateFontWeight(Number(event.currentTarget.value)) + } + className={fieldClass()} + /> +
+ +
+ + + updateLetterSpacing(Number(event.currentTarget.value)) + } + className={fieldClass()} + /> +
+
+ +
+ + +
+ +
+ {[ + { + id: "logo-primary-color", + label: "Primary", + value: logo.primaryColor, + onChange: updatePrimaryColor, + }, + { + id: "logo-secondary-color", + label: "Secondary", + value: logo.secondaryColor, + onChange: updateSecondaryColor, + }, + { + id: "logo-background-color", + label: "Background", + value: logo.backgroundColor, + onChange: updateBackgroundColor, + }, + ].map((field) => ( +
+ + +
+ field.onChange(event.currentTarget.value)} + className="size-11 cursor-pointer rounded-2xl border border-slate-200 bg-white p-1 dark:border-slate-800 dark:bg-slate-950" + /> + field.onChange(event.currentTarget.value)} + className={fieldClass()} + /> +
+
+ ))} +
+ +
+ + updateRadius(Number(event.currentTarget.value))} + className="mt-3 w-full accent-indigo-600" + /> +
+
+ ); +} \ No newline at end of file diff --git a/apps/web/src/features/logo-builder/components/logo-preview.tsx b/apps/web/src/features/logo-builder/components/logo-preview.tsx new file mode 100644 index 0000000..25b4898 --- /dev/null +++ b/apps/web/src/features/logo-builder/components/logo-preview.tsx @@ -0,0 +1,88 @@ +"use client"; + +import type { LogoConfig } from "@/features/logo-builder/types/logo"; + +function getGradientClass(direction: LogoConfig["gradientDirection"]): string { + if (direction === "to-right") { + return "bg-gradient-to-r"; + } + + if (direction === "to-bottom") { + return "bg-gradient-to-b"; + } + + if (direction === "to-top-right") { + return "bg-gradient-to-tr"; + } + + return "bg-gradient-to-br"; +} + +type LogoPreviewProps = { + readonly logo: LogoConfig; +}; + +export function LogoPreview({ logo }: LogoPreviewProps) { + const initials = (logo.text.trim() || "RBC").slice(0, 3).toUpperCase(); + + return ( +
+
+
+
+ +
+
+ {initials} +
+ +
+

+ {logo.text || "Logo"} +

+ +

+ {logo.tagline} +

+
+
+
+
+ ); +} \ No newline at end of file diff --git a/apps/web/src/features/logo-builder/exporters/export-logo-svg.ts b/apps/web/src/features/logo-builder/exporters/export-logo-svg.ts new file mode 100644 index 0000000..cd31a98 --- /dev/null +++ b/apps/web/src/features/logo-builder/exporters/export-logo-svg.ts @@ -0,0 +1,51 @@ +import type { LogoConfig } from "@/features/logo-builder/types/logo"; + +function escapeSvgText(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function getGradientCoordinates(direction: LogoConfig["gradientDirection"]): { + readonly x1: string; + readonly y1: string; + readonly x2: string; + readonly y2: string; +} { + if (direction === "to-right") { + return { x1: "0%", y1: "50%", x2: "100%", y2: "50%" }; + } + + if (direction === "to-bottom") { + return { x1: "50%", y1: "0%", x2: "50%", y2: "100%" }; + } + + if (direction === "to-top-right") { + return { x1: "0%", y1: "100%", x2: "100%", y2: "0%" }; + } + + return { x1: "0%", y1: "0%", x2: "100%", y2: "100%" }; +} + +export function exportLogoSvg(logo: LogoConfig): string { + const gradient = getGradientCoordinates(logo.gradientDirection); + const safeText = escapeSvgText(logo.text.trim() || "Logo"); + const safeTagline = escapeSvgText(logo.tagline.trim()); + + return ` + + + + + + + + + ${safeText.slice(0, 3).toUpperCase()} + ${safeText} + ${safeTagline} +`; +} \ No newline at end of file diff --git a/apps/web/src/features/logo-builder/store/logo-store.ts b/apps/web/src/features/logo-builder/store/logo-store.ts new file mode 100644 index 0000000..928ff76 --- /dev/null +++ b/apps/web/src/features/logo-builder/store/logo-store.ts @@ -0,0 +1,138 @@ +"use client"; + +import { create } from "zustand"; +import type { + LogoConfig, + LogoFontFamily, + LogoGradientDirection, +} from "@/features/logo-builder/types/logo"; + +export const defaultLogoConfig: LogoConfig = { + text: "RainbowCode", + tagline: "Design visually. Ship clean code.", + fontFamily: "Inter", + fontWeight: 800, + letterSpacing: -2, + primaryColor: "#4f46e5", + secondaryColor: "#db2777", + backgroundColor: "#020617", + gradientDirection: "to-bottom-right", + radius: 32, +}; + +type LogoStoreState = { + readonly logo: LogoConfig; + readonly updateText: (text: string) => void; + readonly updateTagline: (tagline: string) => void; + readonly updateFontFamily: (fontFamily: LogoFontFamily) => void; + readonly updateFontWeight: (fontWeight: number) => void; + readonly updateLetterSpacing: (letterSpacing: number) => void; + readonly updatePrimaryColor: (primaryColor: string) => void; + readonly updateSecondaryColor: (secondaryColor: string) => void; + readonly updateBackgroundColor: (backgroundColor: string) => void; + readonly updateGradientDirection: ( + gradientDirection: LogoGradientDirection, + ) => void; + readonly updateRadius: (radius: number) => void; + readonly resetLogo: () => void; +}; + +export const useLogoStore = create((set) => ({ + logo: defaultLogoConfig, + + updateText: (text) => { + set((state) => ({ + logo: { + ...state.logo, + text, + }, + })); + }, + + updateTagline: (tagline) => { + set((state) => ({ + logo: { + ...state.logo, + tagline, + }, + })); + }, + + updateFontFamily: (fontFamily) => { + set((state) => ({ + logo: { + ...state.logo, + fontFamily, + }, + })); + }, + + updateFontWeight: (fontWeight) => { + set((state) => ({ + logo: { + ...state.logo, + fontWeight, + }, + })); + }, + + updateLetterSpacing: (letterSpacing) => { + set((state) => ({ + logo: { + ...state.logo, + letterSpacing, + }, + })); + }, + + updatePrimaryColor: (primaryColor) => { + set((state) => ({ + logo: { + ...state.logo, + primaryColor, + }, + })); + }, + + updateSecondaryColor: (secondaryColor) => { + set((state) => ({ + logo: { + ...state.logo, + secondaryColor, + }, + })); + }, + + updateBackgroundColor: (backgroundColor) => { + set((state) => ({ + logo: { + ...state.logo, + backgroundColor, + }, + })); + }, + + updateGradientDirection: (gradientDirection) => { + set((state) => ({ + logo: { + ...state.logo, + gradientDirection, + }, + })); + }, + + updateRadius: (radius) => { + set((state) => ({ + logo: { + ...state.logo, + radius, + }, + })); + }, + + resetLogo: () => { + set({ + logo: defaultLogoConfig, + }); + }, +})); \ No newline at end of file diff --git a/apps/web/src/features/logo-builder/tests/export-logo-svg.test.ts b/apps/web/src/features/logo-builder/tests/export-logo-svg.test.ts new file mode 100644 index 0000000..130b493 --- /dev/null +++ b/apps/web/src/features/logo-builder/tests/export-logo-svg.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { exportLogoSvg } from "@/features/logo-builder/exporters/export-logo-svg"; +import { defaultLogoConfig } from "@/features/logo-builder/store/logo-store"; + +describe("exportLogoSvg", () => { + it("exports a valid svg string", () => { + const svg = exportLogoSvg(defaultLogoConfig); + + expect(svg).toContain(""); + expect(svg).toContain("RainbowCode"); + expect(svg).toContain("linearGradient"); + }); + + it("escapes unsafe text", () => { + const svg = exportLogoSvg({ + ...defaultLogoConfig, + text: "