feat(gallery): add Style Detail Modal with phone UI controls preview (#155)

Click any StyleCard to open a full-screen modal showing the style applied
to 9 interactive phone UI controls (buttons, inputs, toggles, checkboxes,
radios, cards, badges, slider, nav bar) inside an iPhone mockup frame.

Co-authored-by: yongtang <jlinux@msn.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Yong Tang 2026-08-01 09:29:30 +08:00 committed by GitHub
parent b0ebb1797e
commit 14ddef5c05
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 3789 additions and 0 deletions

3
gallery/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
node_modules/
.next/
out/

22
gallery/app/globals.css Normal file
View File

@ -0,0 +1,22 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
@apply bg-gray-50 text-gray-900 dark:bg-gray-950 dark:text-gray-100;
}
}
@layer utilities {
.scrollbar-thin {
scrollbar-width: thin;
}
.scrollbar-thin::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.scrollbar-thin::-webkit-scrollbar-thumb {
@apply bg-gray-300 dark:bg-gray-700 rounded-full;
}
}

22
gallery/app/layout.tsx Normal file
View File

@ -0,0 +1,22 @@
import type { Metadata } from "next";
import { Providers } from "./providers";
import "./globals.css";
export const metadata: Metadata = {
title: "Style Gallery — UI/UX Pro Max",
description: "Browse and explore 67 UI styles with live previews, color palettes, and implementation details.",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" suppressHydrationWarning>
<body className="min-h-screen antialiased">
<Providers>{children}</Providers>
</body>
</html>
);
}

16
gallery/app/page.tsx Normal file
View File

@ -0,0 +1,16 @@
import fs from "fs";
import path from "path";
import { parseStylesCSV } from "@/lib/parseStyles";
import { GalleryGrid } from "@/components/GalleryGrid";
export default function Home() {
const csvPath = path.join(process.cwd(), "data", "styles.csv");
const csvContent = fs.readFileSync(csvPath, "utf-8");
const styles = parseStylesCSV(csvContent);
return (
<main>
<GalleryGrid styles={styles} />
</main>
);
}

11
gallery/app/providers.tsx Normal file
View File

@ -0,0 +1,11 @@
"use client";
import { ThemeProvider } from "next-themes";
export function Providers({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
{children}
</ThemeProvider>
);
}

View File

@ -0,0 +1,50 @@
"use client";
import { useState } from "react";
interface CodeSnippetProps {
code: string;
label?: string;
}
export function CodeSnippet({ code, label }: CodeSnippetProps) {
const [copied, setCopied] = useState(false);
async function handleCopy() {
try {
await navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// Fallback: ignore
}
}
return (
<div className="relative group">
{label && (
<span className="text-[10px] font-medium text-gray-400 uppercase tracking-wider">
{label}
</span>
)}
<pre className="mt-1 p-3 text-xs bg-gray-50 dark:bg-gray-900 rounded-lg overflow-x-auto scrollbar-thin font-mono text-gray-700 dark:text-gray-300 leading-relaxed">
{code}
</pre>
<button
onClick={handleCopy}
className="absolute top-2 right-2 p-1.5 rounded-md bg-gray-200/80 dark:bg-gray-700/80 text-gray-500 dark:text-gray-400 opacity-0 group-hover:opacity-100 transition-opacity hover:bg-gray-300 dark:hover:bg-gray-600"
title="Copy to clipboard"
>
{copied ? (
<svg className="w-3.5 h-3.5 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
) : (
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
)}
</button>
</div>
);
}

View File

@ -0,0 +1,47 @@
"use client";
import { useState } from "react";
interface ColorPaletteProps {
colors: string[];
}
export function ColorPalette({ colors }: ColorPaletteProps) {
const [copiedIdx, setCopiedIdx] = useState<number | null>(null);
if (colors.length === 0) return null;
async function copyColor(color: string, idx: number) {
try {
await navigator.clipboard.writeText(color);
setCopiedIdx(idx);
setTimeout(() => setCopiedIdx(null), 2000);
} catch {
// Fallback: ignore
}
}
// Show at most 8 colors
const displayed = colors.slice(0, 8);
return (
<div className="flex items-center gap-1.5">
{displayed.map((color, i) => (
<div key={`${color}-${i}`} className="group relative">
<button
onClick={() => copyColor(color, i)}
className="w-6 h-6 rounded-full border border-gray-200 dark:border-gray-700 transition-transform hover:scale-125 focus:outline-none focus:ring-2 focus:ring-blue-400"
style={{ backgroundColor: color }}
title={color}
/>
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-1 px-1.5 py-0.5 text-[10px] font-mono bg-gray-900 text-white rounded opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity whitespace-nowrap z-10">
{copiedIdx === i ? "Copied!" : color}
</div>
</div>
))}
{colors.length > 8 && (
<span className="text-xs text-gray-400">+{colors.length - 8}</span>
)}
</div>
);
}

View File

@ -0,0 +1,35 @@
"use client";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
export function DarkModeToggle() {
const { theme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) {
return <div className="w-9 h-9" />;
}
const isDark = theme === "dark";
return (
<button
onClick={() => setTheme(isDark ? "light" : "dark")}
className="p-2 rounded-lg bg-gray-200 dark:bg-gray-800 hover:bg-gray-300 dark:hover:bg-gray-700 transition-colors"
aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"}
>
{isDark ? (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
) : (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
)}
</button>
);
}

View File

@ -0,0 +1,37 @@
"use client";
import { useState } from "react";
interface ExpandableSectionProps {
title: string;
children: React.ReactNode;
defaultOpen?: boolean;
}
export function ExpandableSection({
title,
children,
defaultOpen = false,
}: ExpandableSectionProps) {
const [open, setOpen] = useState(defaultOpen);
return (
<div className="border-t border-gray-100 dark:border-gray-800">
<button
onClick={() => setOpen(!open)}
className="w-full flex items-center justify-between py-2 px-1 text-left text-sm font-medium text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 transition-colors"
>
<span>{title}</span>
<svg
className={`w-4 h-4 transition-transform ${open ? "rotate-90" : ""}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
{open && <div className="pb-3 px-1">{children}</div>}
</div>
);
}

View File

@ -0,0 +1,111 @@
"use client";
import { Filters } from "@/lib/filterStyles";
interface FilterBarProps {
filters: Filters;
onChange: (filters: Filters) => void;
types: string[];
complexities: string[];
eras: string[];
resultCount: number;
totalCount: number;
}
function FilterGroup({
label,
options,
value,
onChange,
}: {
label: string;
options: string[];
value: string;
onChange: (v: string) => void;
}) {
return (
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-xs font-medium text-gray-500 dark:text-gray-400 mr-1">
{label}:
</span>
<button
onClick={() => onChange("")}
className={`px-2.5 py-1 text-xs rounded-full transition-colors ${
value === ""
? "bg-blue-500 text-white"
: "bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700"
}`}
>
All
</button>
{options.map((opt) => (
<button
key={opt}
onClick={() => onChange(value === opt ? "" : opt)}
className={`px-2.5 py-1 text-xs rounded-full transition-colors ${
value === opt
? "bg-blue-500 text-white"
: "bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700"
}`}
>
{opt}
</button>
))}
</div>
);
}
export function FilterBar({
filters,
onChange,
types,
complexities,
eras,
resultCount,
totalCount,
}: FilterBarProps) {
const hasFilters =
filters.search || filters.type || filters.complexity || filters.era;
return (
<div className="space-y-3">
<div className="flex flex-wrap gap-4">
<FilterGroup
label="Type"
options={types}
value={filters.type}
onChange={(type) => onChange({ ...filters, type })}
/>
<FilterGroup
label="Complexity"
options={complexities}
value={filters.complexity}
onChange={(complexity) => onChange({ ...filters, complexity })}
/>
</div>
<div className="flex flex-wrap gap-4">
<FilterGroup
label="Era"
options={eras}
value={filters.era}
onChange={(era) => onChange({ ...filters, era })}
/>
</div>
<div className="flex items-center justify-between">
<p className="text-sm text-gray-500 dark:text-gray-400">
Showing <span className="font-semibold text-gray-700 dark:text-gray-200">{resultCount}</span> of {totalCount} styles
</p>
{hasFilters && (
<button
onClick={() =>
onChange({ search: "", type: "", complexity: "", era: "" })
}
className="text-xs text-blue-500 hover:text-blue-600 transition-colors"
>
Clear all filters
</button>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,103 @@
"use client";
import { useMemo, useState } from "react";
import { StyleData } from "@/lib/types";
import {
Filters,
DEFAULT_FILTERS,
filterStyles,
getUniqueValues,
} from "@/lib/filterStyles";
import { StyleCard } from "./StyleCard";
import { StyleDetailModal } from "./StyleDetailModal";
import { SearchInput } from "./SearchInput";
import { FilterBar } from "./FilterBar";
import { DarkModeToggle } from "./DarkModeToggle";
interface GalleryGridProps {
styles: StyleData[];
}
export function GalleryGrid({ styles }: GalleryGridProps) {
const [filters, setFilters] = useState<Filters>(DEFAULT_FILTERS);
const [selectedStyle, setSelectedStyle] = useState<StyleData | null>(null);
const types = useMemo(() => getUniqueValues(styles, "type"), [styles]);
const complexities = useMemo(
() => getUniqueValues(styles, "complexity"),
[styles]
);
const eras = useMemo(() => getUniqueValues(styles, "eraOrigin"), [styles]);
const filtered = useMemo(
() => filterStyles(styles, filters),
[styles, filters]
);
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
Style Gallery
</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Browse {styles.length} UI styles from Antigravity Kit
</p>
</div>
<DarkModeToggle />
</div>
{/* Search */}
<div className="mb-4">
<SearchInput
value={filters.search}
onChange={(search) => setFilters((f) => ({ ...f, search }))}
/>
</div>
{/* Filters */}
<div className="mb-6">
<FilterBar
filters={filters}
onChange={setFilters}
types={types}
complexities={complexities}
eras={eras}
resultCount={filtered.length}
totalCount={styles.length}
/>
</div>
{/* Grid */}
{filtered.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filtered.map((style) => (
<StyleCard key={style.no} style={style} onSelect={setSelectedStyle} />
))}
</div>
) : (
<div className="text-center py-16">
<p className="text-gray-500 dark:text-gray-400">
No styles match your filters.
</p>
<button
onClick={() => setFilters(DEFAULT_FILTERS)}
className="mt-2 text-sm text-blue-500 hover:text-blue-600"
>
Clear all filters
</button>
</div>
)}
{/* Style Detail Modal */}
{selectedStyle && (
<StyleDetailModal
style={selectedStyle}
onClose={() => setSelectedStyle(null)}
/>
)}
</div>
);
}

View File

@ -0,0 +1,62 @@
"use client";
import { useState } from "react";
interface InteractiveChecklistProps {
checklist: string;
}
function parseItems(raw: string): string[] {
return raw
.split(/,\s*(?=☐)/)
.map((s) => s.replace(/^☐\s*/, "").trim())
.filter(Boolean);
}
export function InteractiveChecklist({ checklist }: InteractiveChecklistProps) {
const items = parseItems(checklist);
const [checked, setChecked] = useState<Set<number>>(new Set());
if (items.length === 0) return null;
function toggle(idx: number) {
setChecked((prev) => {
const next = new Set(prev);
if (next.has(idx)) next.delete(idx);
else next.add(idx);
return next;
});
}
return (
<div>
<div className="text-[10px] text-gray-400 mb-2">
{checked.size} of {items.length} completed
</div>
<div className="space-y-1.5">
{items.map((item, i) => (
<label
key={i}
className="flex items-start gap-2 cursor-pointer group"
>
<input
type="checkbox"
checked={checked.has(i)}
onChange={() => toggle(i)}
className="mt-0.5 rounded border-gray-300 dark:border-gray-600 text-blue-500 focus:ring-blue-500"
/>
<span
className={`text-xs leading-relaxed transition-colors ${
checked.has(i)
? "line-through text-gray-400 dark:text-gray-600"
: "text-gray-700 dark:text-gray-300"
}`}
>
{item}
</span>
</label>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,57 @@
import { StyleData } from "@/lib/types";
interface MetadataBadgesProps {
style: StyleData;
}
function Badge({
label,
variant,
}: {
label: string;
variant: "green" | "yellow" | "red" | "blue" | "gray";
}) {
const colors = {
green: "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400",
yellow: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400",
red: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400",
blue: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400",
gray: "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-400",
};
return (
<span className={`inline-flex items-center px-2 py-0.5 text-[10px] font-medium rounded-full ${colors[variant]}`}>
{label}
</span>
);
}
function getComplexityVariant(c: string): "green" | "yellow" | "red" {
if (c === "Low") return "green";
if (c === "Medium") return "yellow";
return "red";
}
function getPerfVariant(p: string): "green" | "yellow" | "red" {
if (p.includes("Excellent")) return "green";
if (p.includes("Good")) return "yellow";
return "red";
}
function getA11yVariant(a: string): "green" | "yellow" | "red" {
if (a.includes("AAA")) return "green";
if (a.includes("AA") || a.includes("Good")) return "yellow";
return "red";
}
export function MetadataBadges({ style }: MetadataBadgesProps) {
return (
<div className="flex flex-wrap gap-1.5">
<Badge label={style.complexity} variant={getComplexityVariant(style.complexity)} />
<Badge label={style.performance.replace(/[⚡❌⚠]/g, "").trim()} variant={getPerfVariant(style.performance)} />
<Badge label={style.accessibility.replace(/[✓⚠✗]/g, "").trim()} variant={getA11yVariant(style.accessibility)} />
{style.lightMode.includes("Full") && <Badge label="Light" variant="blue" />}
{style.darkMode.includes("Full") && <Badge label="Dark" variant="gray" />}
</div>
);
}

View File

@ -0,0 +1,85 @@
"use client";
import React from "react";
interface PhoneMockupProps {
children: React.ReactNode;
}
export function PhoneMockup({ children }: PhoneMockupProps) {
return (
<div className="flex items-center justify-center py-4">
{/* iPhone outer frame */}
<div
className="relative bg-gray-900 rounded-[44px] p-3 shadow-2xl"
style={{ width: "300px" }}
>
{/* Notch / Dynamic Island */}
<div className="absolute top-0 left-1/2 -translate-x-1/2 z-20">
<div className="bg-gray-900 rounded-b-2xl px-6 pt-0 pb-1.5">
<div className="w-20 h-5 bg-black rounded-full" />
</div>
</div>
{/* Screen bezel */}
<div className="rounded-[32px] overflow-hidden bg-white dark:bg-gray-950 relative">
{/* Status bar */}
<div className="flex items-center justify-between px-6 pt-3 pb-1 text-[10px] font-semibold relative z-10">
<span className="text-gray-900 dark:text-gray-100">9:41</span>
<div className="flex items-center gap-1">
{/* Signal */}
<svg
width="14"
height="10"
viewBox="0 0 14 10"
className="text-gray-900 dark:text-gray-100"
>
<rect x="0" y="7" width="2.5" height="3" rx="0.5" fill="currentColor" />
<rect x="3.5" y="5" width="2.5" height="5" rx="0.5" fill="currentColor" />
<rect x="7" y="2.5" width="2.5" height="7.5" rx="0.5" fill="currentColor" />
<rect x="10.5" y="0" width="2.5" height="10" rx="0.5" fill="currentColor" />
</svg>
{/* WiFi */}
<svg
width="12"
height="10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-gray-900 dark:text-gray-100"
>
<path d="M1.42 9a16 16 0 0121.16 0" />
<path d="M5 12.55a11 11 0 0114.08 0" />
<path d="M8.53 16.11a6 6 0 016.95 0" />
<circle cx="12" cy="20" r="1" fill="currentColor" />
</svg>
{/* Battery */}
<div className="flex items-center">
<div className="w-5 h-2.5 border border-current rounded-sm relative">
<div className="absolute inset-0.5 bg-current rounded-[1px]" />
</div>
<div className="w-0.5 h-1 bg-current rounded-r-sm" />
</div>
</div>
</div>
{/* Content area */}
<div
className="overflow-y-auto"
style={{ height: "540px" }}
>
{children}
</div>
{/* Home indicator */}
<div className="flex justify-center pb-2 pt-1">
<div className="w-28 h-1 bg-gray-400 dark:bg-gray-600 rounded-full" />
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,62 @@
"use client";
import { useEffect, useRef, useState } from "react";
interface SearchInputProps {
value: string;
onChange: (value: string) => void;
}
export function SearchInput({ value, onChange }: SearchInputProps) {
const [local, setLocal] = useState(value);
const timerRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
setLocal(value);
}, [value]);
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const v = e.target.value;
setLocal(v);
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => onChange(v), 300);
}
return (
<div className="relative">
<svg
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<input
type="text"
value={local}
onChange={handleChange}
placeholder="Search styles..."
className="w-full pl-10 pr-4 py-2.5 rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent placeholder:text-gray-400"
/>
{local && (
<button
onClick={() => {
setLocal("");
onChange("");
}}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
);
}

View File

@ -0,0 +1,126 @@
"use client";
import { StyleData } from "@/lib/types";
import { StylePreview } from "./StylePreview";
import { ColorPalette } from "./ColorPalette";
import { MetadataBadges } from "./MetadataBadges";
import { ExpandableSection } from "./ExpandableSection";
import { CodeSnippet } from "./CodeSnippet";
import { InteractiveChecklist } from "./InteractiveChecklist";
interface StyleCardProps {
style: StyleData;
onSelect?: (style: StyleData) => void;
}
export function StyleCard({ style, onSelect }: StyleCardProps) {
return (
<div
className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 overflow-hidden hover:shadow-lg dark:hover:shadow-gray-900/50 transition-shadow cursor-pointer"
onClick={() => onSelect?.(style)}
>
{/* Header */}
<div className="px-4 pt-4 pb-2">
<div className="flex items-start justify-between gap-2">
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100 leading-tight">
{style.styleCategory}
</h3>
<div className="flex items-center gap-1.5 shrink-0">
<span className="text-[10px] px-2 py-0.5 rounded-full bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400">
{style.type}
</span>
<span className="text-[10px] text-gray-400">{style.eraOrigin}</span>
</div>
</div>
</div>
{/* Preview */}
<div className="px-4">
<StylePreview style={style} />
</div>
{/* Color Palette */}
<div className="px-4 py-3">
<ColorPalette colors={style.extractedColors} />
</div>
{/* Metadata Badges */}
<div className="px-4 pb-3">
<MetadataBadges style={style} />
</div>
{/* Expandable Sections */}
<div className="px-4 pb-2">
<ExpandableSection title="CSS Code">
<CodeSnippet code={style.cssTechnicalKeywords} label="CSS/Technical" />
{style.designSystemVariables && (
<div className="mt-2">
<CodeSnippet
code={style.designSystemVariables}
label="Design Variables"
/>
</div>
)}
</ExpandableSection>
<ExpandableSection title="AI Prompt">
<CodeSnippet code={style.aiPromptKeywords} />
</ExpandableSection>
<ExpandableSection title="Implementation Checklist">
<InteractiveChecklist checklist={style.implementationChecklist} />
</ExpandableSection>
<ExpandableSection title="Details">
<div className="space-y-2 text-xs">
{style.bestFor && (
<div>
<span className="font-medium text-gray-500 dark:text-gray-400">
Best for:{" "}
</span>
<span className="text-gray-700 dark:text-gray-300">
{style.bestFor}
</span>
</div>
)}
{style.doNotUseFor && (
<div>
<span className="font-medium text-gray-500 dark:text-gray-400">
Avoid for:{" "}
</span>
<span className="text-gray-700 dark:text-gray-300">
{style.doNotUseFor}
</span>
</div>
)}
{style.frameworkCompatibility && (
<div>
<span className="font-medium text-gray-500 dark:text-gray-400">
Frameworks:{" "}
</span>
<span className="text-gray-700 dark:text-gray-300">
{style.frameworkCompatibility}
</span>
</div>
)}
{style.keywords && (
<div className="flex flex-wrap gap-1 mt-1">
{style.keywords
.split(",")
.slice(0, 6)
.map((kw, i) => (
<span
key={i}
className="px-1.5 py-0.5 text-[10px] bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 rounded"
>
{kw.trim()}
</span>
))}
</div>
)}
</div>
</ExpandableSection>
</div>
</div>
);
}

View File

@ -0,0 +1,220 @@
"use client";
import { useEffect, useState } from "react";
import { useTheme } from "next-themes";
import { StyleData } from "@/lib/types";
import {
generatePreviewStyle,
generateBackgroundGradient,
} from "@/lib/cssGenerator";
import { PhoneMockup } from "./PhoneMockup";
import { UIControlsShowcase } from "./UIControlsShowcase";
import { ColorPalette } from "./ColorPalette";
import { MetadataBadges } from "./MetadataBadges";
import { ExpandableSection } from "./ExpandableSection";
import { CodeSnippet } from "./CodeSnippet";
import { InteractiveChecklist } from "./InteractiveChecklist";
interface StyleDetailModalProps {
style: StyleData;
onClose: () => void;
}
export function StyleDetailModal({ style, onClose }: StyleDetailModalProps) {
const { theme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
// ESC to close
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [onClose]);
// Lock body scroll
useEffect(() => {
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = "";
};
}, []);
const isDark = mounted ? theme === "dark" : false;
const cardStyle = generatePreviewStyle(style, isDark);
const bgGradient = generateBackgroundGradient(style.extractedColors, isDark);
const accentColor =
style.extractedColors[0] || (isDark ? "#6366f1" : "#3b82f6");
const secondaryColor =
style.extractedColors[1] || style.extractedColors[0] || (isDark ? "#8b5cf6" : "#6366f1");
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
onClick={onClose}
>
{/* Backdrop */}
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" />
{/* Modal container */}
<div
className="relative z-10 bg-white dark:bg-gray-900 rounded-2xl shadow-2xl w-[95vw] max-w-5xl max-h-[90vh] overflow-hidden flex flex-col"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-800 shrink-0">
<button
onClick={onClose}
className="flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 transition-colors"
>
<svg
className="w-5 h-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
Close
</button>
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{style.styleCategory}
</h2>
<div className="w-16" /> {/* Spacer for centering */}
</div>
{/* Body — two-column on desktop, single-column on mobile */}
<div className="flex-1 overflow-y-auto">
<div className="flex flex-col lg:flex-row">
{/* Left: Phone mockup */}
<div
className="lg:w-1/2 flex items-start justify-center p-6 lg:border-r border-gray-200 dark:border-gray-800"
style={{ background: bgGradient }}
>
<PhoneMockup>
<UIControlsShowcase
accentColor={accentColor}
secondaryColor={secondaryColor}
borderRadius={cardStyle.borderRadius?.toString() || "8px"}
boxShadow={
cardStyle.boxShadow?.toString() ||
"0 4px 12px rgba(0,0,0,0.1)"
}
backdropFilter={cardStyle.backdropFilter?.toString()}
fontFamily={cardStyle.fontFamily?.toString()}
fontWeight={cardStyle.fontWeight?.toString()}
border={cardStyle.border?.toString()}
textShadow={cardStyle.textShadow?.toString()}
letterSpacing={cardStyle.letterSpacing?.toString()}
isDark={isDark}
/>
</PhoneMockup>
</div>
{/* Right: Details */}
<div className="lg:w-1/2 p-6">
{/* Color Palette */}
<div className="mb-4">
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">
Color Palette
</h4>
<ColorPalette colors={style.extractedColors} />
</div>
{/* Metadata */}
<div className="mb-4">
<MetadataBadges style={style} />
</div>
{/* Expandable sections */}
<div>
<ExpandableSection title="CSS Code" defaultOpen>
<CodeSnippet
code={style.cssTechnicalKeywords}
label="CSS/Technical"
/>
{style.designSystemVariables && (
<div className="mt-2">
<CodeSnippet
code={style.designSystemVariables}
label="Design Variables"
/>
</div>
)}
</ExpandableSection>
<ExpandableSection title="AI Prompt">
<CodeSnippet code={style.aiPromptKeywords} />
</ExpandableSection>
<ExpandableSection title="Implementation Checklist">
<InteractiveChecklist
checklist={style.implementationChecklist}
/>
</ExpandableSection>
<ExpandableSection title="Details">
<div className="space-y-2 text-xs">
{style.bestFor && (
<div>
<span className="font-medium text-gray-500 dark:text-gray-400">
Best for:{" "}
</span>
<span className="text-gray-700 dark:text-gray-300">
{style.bestFor}
</span>
</div>
)}
{style.doNotUseFor && (
<div>
<span className="font-medium text-gray-500 dark:text-gray-400">
Avoid for:{" "}
</span>
<span className="text-gray-700 dark:text-gray-300">
{style.doNotUseFor}
</span>
</div>
)}
{style.frameworkCompatibility && (
<div>
<span className="font-medium text-gray-500 dark:text-gray-400">
Frameworks:{" "}
</span>
<span className="text-gray-700 dark:text-gray-300">
{style.frameworkCompatibility}
</span>
</div>
)}
{style.keywords && (
<div className="flex flex-wrap gap-1 mt-1">
{style.keywords
.split(",")
.slice(0, 6)
.map((kw, i) => (
<span
key={i}
className="px-1.5 py-0.5 text-[10px] bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 rounded"
>
{kw.trim()}
</span>
))}
</div>
)}
</div>
</ExpandableSection>
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,80 @@
"use client";
import { StyleData } from "@/lib/types";
import {
generatePreviewStyle,
generateBackgroundGradient,
} from "@/lib/cssGenerator";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
interface StylePreviewProps {
style: StyleData;
}
export function StylePreview({ style }: StylePreviewProps) {
const { theme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const isDark = mounted ? theme === "dark" : false;
const cardStyle = generatePreviewStyle(style, isDark);
const bgGradient = generateBackgroundGradient(style.extractedColors, isDark);
const accentColor = style.extractedColors[0] || (isDark ? "#6366f1" : "#3b82f6");
return (
<div
className="relative h-40 rounded-lg overflow-hidden"
style={{ background: bgGradient }}
>
{/* Demo card */}
<div className="absolute inset-0 flex items-center justify-center p-4">
<div
className="w-full max-w-[200px] p-4 bg-white/80 dark:bg-gray-900/80"
style={{
borderRadius: cardStyle.borderRadius || "8px",
boxShadow:
cardStyle.boxShadow || "0 4px 12px rgba(0,0,0,0.1)",
backdropFilter: cardStyle.backdropFilter,
WebkitBackdropFilter: cardStyle.WebkitBackdropFilter,
border: cardStyle.border || "1px solid rgba(128,128,128,0.1)",
fontFamily: cardStyle.fontFamily,
fontWeight: cardStyle.fontWeight,
filter: cardStyle.filter,
textShadow: cardStyle.textShadow,
letterSpacing: cardStyle.letterSpacing,
transition: cardStyle.transition || "all 0.2s ease",
}}
>
<div
className="h-2 rounded-full mb-2 w-3/4"
style={{ background: accentColor }}
/>
<div className="h-1.5 rounded-full bg-gray-300 dark:bg-gray-600 mb-1.5 w-full" />
<div className="h-1.5 rounded-full bg-gray-300 dark:bg-gray-600 mb-1.5 w-5/6" />
<div className="h-1.5 rounded-full bg-gray-300 dark:bg-gray-600 w-2/3" />
<div
className="mt-3 h-6 rounded flex items-center justify-center"
style={{
background: accentColor,
borderRadius: cardStyle.borderRadius
? `calc(${cardStyle.borderRadius} / 2)`
: "4px",
}}
>
<span className="text-[10px] font-medium text-white">
Button
</span>
</div>
</div>
</div>
{/* Style name overlay */}
<div className="absolute bottom-0 left-0 right-0 px-3 py-1.5 bg-gradient-to-t from-black/40 to-transparent">
<span className="text-[10px] font-medium text-white/80">
Preview
</span>
</div>
</div>
);
}

View File

@ -0,0 +1,610 @@
"use client";
import React, { useState } from "react";
interface UIControlsShowcaseProps {
accentColor: string;
secondaryColor: string;
borderRadius: string;
boxShadow: string;
backdropFilter?: string;
fontFamily?: string;
fontWeight?: string;
border?: string;
textShadow?: string;
letterSpacing?: string;
isDark: boolean;
}
export function UIControlsShowcase({
accentColor,
secondaryColor,
borderRadius,
boxShadow,
backdropFilter,
fontFamily,
fontWeight,
border,
textShadow,
letterSpacing,
isDark,
}: UIControlsShowcaseProps) {
const [toggle1, setToggle1] = useState(true);
const [toggle2, setToggle2] = useState(false);
const [check1, setCheck1] = useState(true);
const [check2, setCheck2] = useState(false);
const [radio, setRadio] = useState<"a" | "b">("a");
const [slider, setSlider] = useState(65);
const bg = isDark ? "#1a1a2e" : "#ffffff";
const textPrimary = isDark ? "#f1f5f9" : "#1e293b";
const textSecondary = isDark ? "#94a3b8" : "#64748b";
const surfaceBg = isDark ? "rgba(255,255,255,0.06)" : "rgba(0,0,0,0.03)";
const borderColor = isDark ? "rgba(255,255,255,0.1)" : "rgba(0,0,0,0.08)";
const halfRadius = `calc(${borderRadius} / 2)`;
const baseFont: React.CSSProperties = {
fontFamily,
fontWeight,
textShadow,
letterSpacing,
};
return (
<div
style={{
background: bg,
color: textPrimary,
padding: "16px",
display: "flex",
flexDirection: "column",
gap: "16px",
fontSize: "13px",
lineHeight: 1.4,
minHeight: "100%",
...baseFont,
}}
>
{/* 1. Buttons */}
<Section label="Buttons" textSecondary={textSecondary}>
<div style={{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
<button
style={{
background: accentColor,
color: "#fff",
border: "none",
borderRadius: halfRadius,
padding: "8px 16px",
fontSize: "13px",
fontWeight: 600,
boxShadow,
cursor: "pointer",
...baseFont,
}}
>
Primary
</button>
<button
style={{
background: secondaryColor,
color: "#fff",
border: "none",
borderRadius: halfRadius,
padding: "8px 16px",
fontSize: "13px",
fontWeight: 600,
cursor: "pointer",
...baseFont,
}}
>
Secondary
</button>
<button
style={{
background: "transparent",
color: accentColor,
border: `1.5px solid ${accentColor}`,
borderRadius: halfRadius,
padding: "8px 16px",
fontSize: "13px",
fontWeight: 600,
cursor: "pointer",
...baseFont,
}}
>
Outline
</button>
</div>
</Section>
{/* 2. Text Input */}
<Section label="Text Input" textSecondary={textSecondary}>
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
<input
type="text"
placeholder="Placeholder text..."
style={{
background: surfaceBg,
color: textPrimary,
border: border || `1px solid ${borderColor}`,
borderRadius: halfRadius,
padding: "8px 12px",
fontSize: "13px",
outline: "none",
width: "100%",
boxSizing: "border-box",
...baseFont,
}}
/>
<input
type="text"
value="Filled input"
readOnly
style={{
background: surfaceBg,
color: textPrimary,
border: border || `1px solid ${accentColor}44`,
borderRadius: halfRadius,
padding: "8px 12px",
fontSize: "13px",
outline: "none",
width: "100%",
boxSizing: "border-box",
...baseFont,
}}
/>
</div>
</Section>
{/* 3. Toggle Switch */}
<Section label="Toggle" textSecondary={textSecondary}>
<div style={{ display: "flex", gap: "16px", alignItems: "center" }}>
<ToggleSwitch
on={toggle1}
onToggle={() => setToggle1(!toggle1)}
accentColor={accentColor}
borderRadius={borderRadius}
/>
<ToggleSwitch
on={toggle2}
onToggle={() => setToggle2(!toggle2)}
accentColor={accentColor}
borderRadius={borderRadius}
/>
</div>
</Section>
{/* 4. Checkbox */}
<Section label="Checkbox" textSecondary={textSecondary}>
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
<CheckboxItem
checked={check1}
onToggle={() => setCheck1(!check1)}
label="Checked option"
accentColor={accentColor}
halfRadius={halfRadius}
textPrimary={textPrimary}
baseFont={baseFont}
/>
<CheckboxItem
checked={check2}
onToggle={() => setCheck2(!check2)}
label="Unchecked option"
accentColor={accentColor}
halfRadius={halfRadius}
textPrimary={textPrimary}
baseFont={baseFont}
/>
</div>
</Section>
{/* 5. Radio Button */}
<Section label="Radio" textSecondary={textSecondary}>
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
<RadioItem
selected={radio === "a"}
onSelect={() => setRadio("a")}
label="Option A"
accentColor={accentColor}
textPrimary={textPrimary}
baseFont={baseFont}
/>
<RadioItem
selected={radio === "b"}
onSelect={() => setRadio("b")}
label="Option B"
accentColor={accentColor}
textPrimary={textPrimary}
baseFont={baseFont}
/>
</div>
</Section>
{/* 6. Card */}
<Section label="Card" textSecondary={textSecondary}>
<div
style={{
background: surfaceBg,
borderRadius,
boxShadow,
border: border || `1px solid ${borderColor}`,
overflow: "hidden",
backdropFilter,
WebkitBackdropFilter: backdropFilter,
}}
>
<div
style={{
height: "64px",
background: `linear-gradient(135deg, ${accentColor}44, ${secondaryColor}44)`,
}}
/>
<div style={{ padding: "10px 12px" }}>
<div
style={{
fontSize: "13px",
fontWeight: 600,
marginBottom: "4px",
...baseFont,
}}
>
Card Title
</div>
<div
style={{
fontSize: "11px",
color: textSecondary,
...baseFont,
}}
>
A short description of this card with some preview text.
</div>
</div>
</div>
</Section>
{/* 7. Badge / Tag */}
<Section label="Badges" textSecondary={textSecondary}>
<div style={{ display: "flex", gap: "6px", flexWrap: "wrap" }}>
{["Design", "UI/UX", "Style", "Modern"].map((tag) => (
<span
key={tag}
style={{
background: `${accentColor}22`,
color: accentColor,
padding: "3px 10px",
borderRadius: "999px",
fontSize: "11px",
fontWeight: 500,
...baseFont,
}}
>
{tag}
</span>
))}
</div>
</Section>
{/* 8. Slider */}
<Section label="Slider" textSecondary={textSecondary}>
<div>
<div
style={{
display: "flex",
justifyContent: "space-between",
marginBottom: "6px",
fontSize: "11px",
color: textSecondary,
}}
>
<span>0</span>
<span style={{ color: accentColor, fontWeight: 600 }}>
{slider}
</span>
<span>100</span>
</div>
<input
type="range"
min={0}
max={100}
value={slider}
onChange={(e) => setSlider(Number(e.target.value))}
style={{
width: "100%",
accentColor,
cursor: "pointer",
}}
/>
</div>
</Section>
{/* 9. Navigation Bar */}
<Section label="Nav Bar" textSecondary={textSecondary}>
<div
style={{
display: "flex",
justifyContent: "space-around",
alignItems: "center",
background: surfaceBg,
borderRadius: halfRadius,
padding: "8px 0",
border: border || `1px solid ${borderColor}`,
}}
>
<NavIcon
label="Home"
active
accentColor={accentColor}
textSecondary={textSecondary}
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0h4"
/>
<NavIcon
label="Search"
accentColor={accentColor}
textSecondary={textSecondary}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
<NavIcon
label="Heart"
accentColor={accentColor}
textSecondary={textSecondary}
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
/>
<NavIcon
label="Profile"
accentColor={accentColor}
textSecondary={textSecondary}
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</div>
</Section>
</div>
);
}
/* --- Sub-components --- */
function Section({
label,
textSecondary,
children,
}: {
label: string;
textSecondary: string;
children: React.ReactNode;
}) {
return (
<div>
<div
style={{
fontSize: "10px",
fontWeight: 600,
textTransform: "uppercase",
letterSpacing: "0.05em",
color: textSecondary,
marginBottom: "6px",
}}
>
{label}
</div>
{children}
</div>
);
}
function ToggleSwitch({
on,
onToggle,
accentColor,
borderRadius,
}: {
on: boolean;
onToggle: () => void;
accentColor: string;
borderRadius: string;
}) {
const trackRadius =
borderRadius && parseInt(borderRadius) > 20 ? "999px" : "12px";
return (
<button
onClick={onToggle}
style={{
width: "44px",
height: "24px",
borderRadius: trackRadius,
background: on ? accentColor : "rgba(128,128,128,0.3)",
border: "none",
position: "relative",
cursor: "pointer",
transition: "background 0.2s",
flexShrink: 0,
}}
>
<div
style={{
width: "18px",
height: "18px",
borderRadius: trackRadius,
background: "#fff",
position: "absolute",
top: "3px",
left: on ? "23px" : "3px",
transition: "left 0.2s",
boxShadow: "0 1px 3px rgba(0,0,0,0.2)",
}}
/>
</button>
);
}
function CheckboxItem({
checked,
onToggle,
label,
accentColor,
halfRadius,
textPrimary,
baseFont,
}: {
checked: boolean;
onToggle: () => void;
label: string;
accentColor: string;
halfRadius: string;
textPrimary: string;
baseFont: React.CSSProperties;
}) {
return (
<label
style={{
display: "flex",
alignItems: "center",
gap: "8px",
cursor: "pointer",
fontSize: "13px",
color: textPrimary,
...baseFont,
}}
>
<div
onClick={(e) => {
e.preventDefault();
onToggle();
}}
style={{
width: "18px",
height: "18px",
borderRadius: `min(${halfRadius}, 4px)`,
border: checked
? `2px solid ${accentColor}`
: "2px solid rgba(128,128,128,0.4)",
background: checked ? accentColor : "transparent",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
transition: "all 0.15s",
cursor: "pointer",
}}
>
{checked && (
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="#fff"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M5 13l4 4L19 7" />
</svg>
)}
</div>
{label}
</label>
);
}
function RadioItem({
selected,
onSelect,
label,
accentColor,
textPrimary,
baseFont,
}: {
selected: boolean;
onSelect: () => void;
label: string;
accentColor: string;
textPrimary: string;
baseFont: React.CSSProperties;
}) {
return (
<label
style={{
display: "flex",
alignItems: "center",
gap: "8px",
cursor: "pointer",
fontSize: "13px",
color: textPrimary,
...baseFont,
}}
onClick={(e) => {
e.preventDefault();
onSelect();
}}
>
<div
style={{
width: "18px",
height: "18px",
borderRadius: "50%",
border: selected
? `2px solid ${accentColor}`
: "2px solid rgba(128,128,128,0.4)",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
transition: "all 0.15s",
}}
>
{selected && (
<div
style={{
width: "10px",
height: "10px",
borderRadius: "50%",
background: accentColor,
}}
/>
)}
</div>
{label}
</label>
);
}
function NavIcon({
label,
active,
accentColor,
textSecondary,
d,
}: {
label: string;
active?: boolean;
accentColor: string;
textSecondary: string;
d: string;
}) {
const color = active ? accentColor : textSecondary;
return (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: "2px",
}}
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke={color}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d={d} />
</svg>
<span style={{ fontSize: "9px", color }}>{label}</span>
</div>
);
}

1
gallery/data/styles.csv Symbolic link
View File

@ -0,0 +1 @@
../../src/ui-ux-pro-max/data/styles.csv
1 ../../src/ui-ux-pro-max/data/styles.csv

View File

@ -0,0 +1,34 @@
const HEX_RE = /#([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})\b/g;
const RGBA_RE = /rgba?\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*(?:,\s*[\d.]+\s*)?\)/g;
function normalizeHex(hex: string): string {
hex = hex.toUpperCase();
if (hex.length === 4) {
return `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}`;
}
return hex;
}
export function extractColors(text: string): string[] {
const colors: string[] = [];
const seen = new Set<string>();
const hexMatches = text.match(HEX_RE) || [];
for (const h of hexMatches) {
const normalized = normalizeHex(h);
if (!seen.has(normalized)) {
seen.add(normalized);
colors.push(normalized);
}
}
const rgbaMatches = text.match(RGBA_RE) || [];
for (const r of rgbaMatches) {
if (!seen.has(r)) {
seen.add(r);
colors.push(r);
}
}
return colors;
}

133
gallery/lib/cssGenerator.ts Normal file
View File

@ -0,0 +1,133 @@
import React from "react";
const CSS_PROPERTY_MAP: Record<string, string> = {
"border-radius": "borderRadius",
"box-shadow": "boxShadow",
"backdrop-filter": "backdropFilter",
"-webkit-backdrop-filter": "WebkitBackdropFilter",
background: "background",
"background-color": "backgroundColor",
color: "color",
"font-family": "fontFamily",
"font-weight": "fontWeight",
"font-size": "fontSize",
border: "border",
transition: "transition",
transform: "transform",
filter: "filter",
opacity: "opacity",
"mix-blend-mode": "mixBlendMode",
"text-shadow": "textShadow",
"letter-spacing": "letterSpacing",
gap: "gap",
padding: "padding",
"max-width": "maxWidth",
display: "display",
"text-transform": "textTransform",
};
export function parseCssKeywords(
cssTechnical: string
): Record<string, string> {
const result: Record<string, string> = {};
if (!cssTechnical) return result;
// Match patterns like "property: value"
const re = /([\w-]+)\s*:\s*([^,;]+?)(?=\s*[,;]|\s+[\w-]+\s*:|$)/g;
let match;
while ((match = re.exec(cssTechnical)) !== null) {
const prop = match[1].trim().toLowerCase();
const val = match[2].trim();
if (CSS_PROPERTY_MAP[prop]) {
result[CSS_PROPERTY_MAP[prop]] = val;
}
}
return result;
}
export function generatePreviewStyle(
style: { extractedColors: string[]; cssProperties: Record<string, string> },
isDark: boolean
): React.CSSProperties {
const colors = style.extractedColors;
const props = style.cssProperties;
const baseStyle: React.CSSProperties = {};
// Apply border-radius
if (props.borderRadius) {
baseStyle.borderRadius = props.borderRadius;
}
// Apply box-shadow
if (props.boxShadow) {
baseStyle.boxShadow = props.boxShadow;
}
// Apply backdrop-filter
if (props.backdropFilter) {
baseStyle.backdropFilter = props.backdropFilter;
baseStyle.WebkitBackdropFilter = props.backdropFilter;
}
// Apply font-family
if (props.fontFamily) {
baseStyle.fontFamily = props.fontFamily;
}
// Apply font-weight
if (props.fontWeight) {
baseStyle.fontWeight = props.fontWeight;
}
// Apply border
if (props.border) {
baseStyle.border = props.border;
}
// Apply transition
if (props.transition) {
baseStyle.transition = props.transition;
}
// Apply filter
if (props.filter) {
baseStyle.filter = props.filter;
}
// Apply text-shadow
if (props.textShadow) {
baseStyle.textShadow = props.textShadow;
}
// Apply letter-spacing
if (props.letterSpacing) {
baseStyle.letterSpacing = props.letterSpacing;
}
return baseStyle;
}
export function generateBackgroundGradient(
colors: string[],
isDark: boolean
): string {
if (colors.length === 0) {
return isDark
? "linear-gradient(135deg, #1a1a2e, #16213e)"
: "linear-gradient(135deg, #f5f7fa, #c3cfe2)";
}
if (colors.length === 1) {
const c = colors[0];
return isDark
? `linear-gradient(135deg, ${c}33, ${c}11)`
: `linear-gradient(135deg, ${c}22, ${c}11)`;
}
const c1 = colors[0];
const c2 = colors[1];
return isDark
? `linear-gradient(135deg, ${c1}44, ${c2}22)`
: `linear-gradient(135deg, ${c1}22, ${c2}11)`;
}

View File

@ -0,0 +1,55 @@
import { StyleData } from "./types";
export interface Filters {
search: string;
type: string;
complexity: string;
era: string;
}
export const DEFAULT_FILTERS: Filters = {
search: "",
type: "",
complexity: "",
era: "",
};
export function getUniqueValues(
styles: StyleData[],
key: keyof StyleData
): string[] {
const set = new Set<string>();
for (const s of styles) {
const val = String(s[key]).trim();
if (val) set.add(val);
}
return Array.from(set).sort();
}
export function filterStyles(
styles: StyleData[],
filters: Filters
): StyleData[] {
return styles.filter((s) => {
if (filters.type && s.type !== filters.type) return false;
if (filters.complexity && s.complexity !== filters.complexity) return false;
if (filters.era && s.eraOrigin !== filters.era) return false;
if (filters.search) {
const q = filters.search.toLowerCase();
const searchable = [
s.styleCategory,
s.keywords,
s.bestFor,
s.eraOrigin,
s.type,
s.complexity,
]
.join(" ")
.toLowerCase();
if (!searchable.includes(q)) return false;
}
return true;
});
}

View File

@ -0,0 +1,77 @@
import { StyleData } from "./types";
import { extractColors } from "./colorExtractor";
import { parseCssKeywords } from "./cssGenerator";
function parseCSVLine(line: string): string[] {
const fields: string[] = [];
let current = "";
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const char = line[i];
if (inQuotes) {
if (char === '"') {
if (i + 1 < line.length && line[i + 1] === '"') {
current += '"';
i++;
} else {
inQuotes = false;
}
} else {
current += char;
}
} else {
if (char === '"') {
inQuotes = true;
} else if (char === ",") {
fields.push(current.trim());
current = "";
} else {
current += char;
}
}
}
fields.push(current.trim());
return fields;
}
export function parseStylesCSV(csvContent: string): StyleData[] {
const lines = csvContent.split("\n").filter((l) => l.trim().length > 0);
if (lines.length < 2) return [];
// Skip header
const dataLines = lines.slice(1);
return dataLines.map((line) => {
const f = parseCSVLine(line);
const primaryColors = f[4] || "";
const secondaryColors = f[5] || "";
const cssTechnicalKeywords = f[19] || "";
return {
no: parseInt(f[0]) || 0,
styleCategory: f[1] || "",
type: f[2] || "",
keywords: f[3] || "",
primaryColors,
secondaryColors,
effectsAnimation: f[6] || "",
bestFor: f[7] || "",
doNotUseFor: f[8] || "",
lightMode: f[9] || "",
darkMode: f[10] || "",
performance: f[11] || "",
accessibility: f[12] || "",
mobileFriendly: f[13] || "",
conversionFocused: f[14] || "",
frameworkCompatibility: f[15] || "",
eraOrigin: f[16] || "",
complexity: f[17] || "",
aiPromptKeywords: f[18] || "",
cssTechnicalKeywords,
implementationChecklist: f[20] || "",
designSystemVariables: f[21] || "",
extractedColors: extractColors(`${primaryColors} ${secondaryColors}`),
cssProperties: parseCssKeywords(cssTechnicalKeywords),
};
});
}

27
gallery/lib/types.ts Normal file
View File

@ -0,0 +1,27 @@
export interface StyleData {
no: number;
styleCategory: string;
type: string;
keywords: string;
primaryColors: string;
secondaryColors: string;
effectsAnimation: string;
bestFor: string;
doNotUseFor: string;
lightMode: string;
darkMode: string;
performance: string;
accessibility: string;
mobileFriendly: string;
conversionFocused: string;
frameworkCompatibility: string;
eraOrigin: string;
complexity: string;
aiPromptKeywords: string;
cssTechnicalKeywords: string;
implementationChecklist: string;
designSystemVariables: string;
// Computed fields
extractedColors: string[];
cssProperties: Record<string, string>;
}

5
gallery/next-env.d.ts vendored Normal file
View File

@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.

3
gallery/next.config.js Normal file
View File

@ -0,0 +1,3 @@
/** @type {import('next').NextConfig} */
const nextConfig = {};
module.exports = nextConfig;

1628
gallery/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

25
gallery/package.json Normal file
View File

@ -0,0 +1,25 @@
{
"name": "style-gallery",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"dependencies": {
"next": "^14.2.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"next-themes": "^0.4.4"
},
"devDependencies": {
"@types/node": "^20.0.0",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"autoprefixer": "^10.4.0",
"postcss": "^8.4.0",
"tailwindcss": "^3.4.0",
"typescript": "^5.5.0"
}
}

View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@ -0,0 +1,15 @@
import type { Config } from "tailwindcss";
const config: Config = {
content: [
"./app/**/*.{ts,tsx}",
"./components/**/*.{ts,tsx}",
],
darkMode: "class",
theme: {
extend: {},
},
plugins: [],
};
export default config;

21
gallery/tsconfig.json Normal file
View File

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}