Update README.md to reflect project name change and provide detailed features, tech stack, installation instructions, usage guidelines, and development roadmap for the Badge Creation Platform PRO.

This commit is contained in:
defiQUG
2025-11-28 14:14:12 -08:00
parent 36c73dc48d
commit 275dbacd3a
38 changed files with 6853 additions and 1 deletions

19
.eslintrc.cjs Normal file
View File

@@ -0,0 +1,19 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}

25
.gitignore vendored Normal file
View File

@@ -0,0 +1,25 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

104
README.md
View File

@@ -1 +1,103 @@
# stinkin_badges
# Badge Creation Platform PRO
An enterprise-grade badge creation and SVG rendering platform designed for law enforcement, diplomatic, security, commercial, and government agencies.
## Features
### Phase 1 - Core Builder (Current)
- ✅ Dynamic resizable badge layout (2.0" - 4.0" diameter)
- ✅ Star engine (5-10 points with customizable inner/outer radius)
- ✅ Shield builder (1-4 quadrants with image upload)
- ✅ Arc text engine (top/bottom positioning)
- ✅ Ribbon & title bar system
- ✅ Real-time SVG preview with zoom/pan
- ✅ Basic export (SVG/PNG)
- ✅ Undo/Redo history system
### Coming Soon
- Layer management system
- Manufacturing export formats (AI/EPS/DXF/PDF)
- Icon library integration
- Cloud storage and versioning
- Multi-user collaboration
- AI-assisted design
- And more...
## Tech Stack
- **Frontend**: React 18+ with TypeScript
- **Build Tool**: Vite
- **Styling**: Tailwind CSS
- **UI Components**: shadcn/ui (Radix UI primitives)
- **State Management**: Zustand
- **SVG Rendering**: Native SVG DOM + custom generators
- **Drag & Drop**: React DnD
## Getting Started
### Prerequisites
- Node.js 18+ and pnpm
### Installation
1. Install dependencies:
```bash
pnpm install
```
2. Start the development server:
```bash
pnpm dev
```
3. Open your browser to `http://localhost:5173`
### Build for Production
```bash
pnpm build
```
The production build will be in the `dist` directory.
## Project Structure
```
src/
├── components/
│ ├── ui/ # shadcn/ui components
│ ├── controls/ # Badge control panels
│ ├── BadgeCanvas.tsx # Main SVG canvas
│ └── ExportModal.tsx # Export dialog
├── stores/
│ └── badgeStore.ts # Zustand state management
├── types/
│ └── badge.ts # TypeScript type definitions
├── utils/
│ └── svgGenerators.ts # SVG generation utilities
└── lib/
└── utils.ts # Utility functions
```
## Usage
1. **Adjust Badge Geometry**: Use the left panel to set diameter, stroke width, and dual-ring options
2. **Add Star**: Enable and configure a star with customizable points, radii, and metallic finish
3. **Configure Shield**: Set up 1-4 quadrants with background colors and images
4. **Add Text**: Create arc text (top/bottom) or ribbon text with full styling options
5. **Export**: Click the Export button to download as SVG or PNG
## Development Roadmap
See the implementation plan for detailed phases:
- Phase 1: Core Builder ✅
- Phase 2: Pro Tools (Layer Management, Advanced Exports)
- Phase 3: Cloud & Accounts
- Phase 4: Agency/Enterprise Features
- Phase 5: AI & Plugins
- Phase 6: Testing & Deployment
## License
Proprietary - All rights reserved

14
index.html Normal file
View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Badge Creation Platform PRO</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

51
package.json Normal file
View File

@@ -0,0 +1,51 @@
{
"name": "stinkin-badges",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"zustand": "^4.5.2",
"@svgdotjs/svg.js": "^3.2.2",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"@radix-ui/react-accordion": "^1.2.0",
"@radix-ui/react-dialog": "^1.1.1",
"@radix-ui/react-dropdown-menu": "^2.1.1",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-popover": "^1.1.1",
"@radix-ui/react-radio-group": "^1.2.0",
"@radix-ui/react-select": "^2.1.1",
"@radix-ui/react-slider": "^1.2.0",
"@radix-ui/react-switch": "^1.1.0",
"@radix-ui/react-tabs": "^1.1.0",
"@radix-ui/react-scroll-area": "^1.1.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"tailwind-merge": "^2.4.0",
"tailwindcss-animate": "^1.0.7",
"lucide-react": "^0.400.0"
},
"devDependencies": {
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^7.13.1",
"@typescript-eslint/parser": "^7.13.1",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.19",
"eslint": "^8.57.0",
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-react-refresh": "^0.4.7",
"postcss": "^8.4.38",
"tailwindcss": "^3.4.3",
"typescript": "^5.5.3",
"vite": "^5.3.1"
}
}

3793
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

7
postcss.config.js Normal file
View File

@@ -0,0 +1,7 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

115
src/App.tsx Normal file
View File

@@ -0,0 +1,115 @@
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { BadgeCanvas } from './components/BadgeCanvas';
import { GeometryControls } from './components/controls/GeometryControls';
import { StarControls } from './components/controls/StarControls';
import { ShieldControls } from './components/controls/ShieldControls';
import { TextControls } from './components/controls/TextControls';
import { ExportModal } from './components/ExportModal';
import { LayerManager } from './components/LayerManager';
import { HistoryTimeline } from './components/HistoryTimeline';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from './components/ui/accordion';
import { Button } from './components/ui/button';
import { useBadgeStore } from './stores/badgeStore';
import { Undo2, Redo2 } from 'lucide-react';
function App() {
const undo = useBadgeStore((state) => state.undo);
const redo = useBadgeStore((state) => state.redo);
const canUndo = useBadgeStore((state) => state.historyIndex > 0);
const canRedo = useBadgeStore(
(state) => state.historyIndex < state.history.length - 1
);
return (
<DndProvider backend={HTML5Backend}>
<div className="h-screen w-screen flex flex-col">
{/* Top Bar */}
<header className="border-b bg-white px-4 py-2 flex items-center justify-between">
<h1 className="text-xl font-bold">Badge Creation Platform PRO</h1>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
onClick={undo}
disabled={!canUndo}
>
<Undo2 className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
onClick={redo}
disabled={!canRedo}
>
<Redo2 className="h-4 w-4" />
</Button>
<ExportModal />
</div>
</header>
{/* Main Content */}
<div className="flex-1 flex overflow-hidden">
{/* Left Panel - Inspector */}
<aside className="w-80 border-r bg-white overflow-y-auto">
<Accordion type="multiple" className="w-full" defaultValue={['geometry', 'star', 'shield', 'text', 'layers']}>
<AccordionItem value="geometry">
<AccordionTrigger>Badge Geometry</AccordionTrigger>
<AccordionContent>
<GeometryControls />
</AccordionContent>
</AccordionItem>
<AccordionItem value="star">
<AccordionTrigger>Star</AccordionTrigger>
<AccordionContent>
<StarControls />
</AccordionContent>
</AccordionItem>
<AccordionItem value="shield">
<AccordionTrigger>Shield</AccordionTrigger>
<AccordionContent>
<ShieldControls />
</AccordionContent>
</AccordionItem>
<AccordionItem value="text">
<AccordionTrigger>Text</AccordionTrigger>
<AccordionContent>
<TextControls />
</AccordionContent>
</AccordionItem>
<AccordionItem value="layers">
<AccordionTrigger>Layers</AccordionTrigger>
<AccordionContent>
<LayerManager />
</AccordionContent>
</AccordionItem>
</Accordion>
</aside>
{/* Center Panel - Canvas */}
<main className="flex-1 p-4 bg-gray-50">
<BadgeCanvas />
</main>
{/* Right Panel - Properties */}
<aside className="w-80 border-l bg-white p-4 overflow-y-auto">
<h2 className="text-lg font-semibold mb-4">History</h2>
<HistoryTimeline />
</aside>
</div>
</div>
</DndProvider>
);
}
export default App;

View File

@@ -0,0 +1,122 @@
import { useMemo, useRef, useState } from 'react';
import { useBadgeStore } from '@/stores/badgeStore';
import { generateBadgeSVG } from '@/utils/svgGenerators';
export function BadgeCanvas() {
const containerRef = useRef<HTMLDivElement>(null);
const [zoom, setZoom] = useState(1);
const [pan, setPan] = useState({ x: 0, y: 0 });
const [isPanning, setIsPanning] = useState(false);
const [panStart, setPanStart] = useState({ x: 0, y: 0 });
const geometry = useBadgeStore((state) => state.geometry);
const star = useBadgeStore((state) => state.star);
const shield = useBadgeStore((state) => state.shield);
const arcTexts = useBadgeStore((state) => state.text.arcTexts);
const ribbonTexts = useBadgeStore((state) => state.text.ribbonTexts);
const svgString = useMemo(() => {
return generateBadgeSVG(geometry, star, shield, arcTexts, ribbonTexts);
}, [geometry, star, shield, arcTexts, ribbonTexts]);
const diameterPx = geometry.diameter * 96;
const viewBox = useMemo(() => {
const baseWidth = diameterPx;
const baseHeight = diameterPx;
return `${pan.x} ${pan.y} ${baseWidth / zoom} ${baseHeight / zoom}`;
}, [diameterPx, zoom, pan]);
const handleWheel = (e: React.WheelEvent) => {
e.preventDefault();
const delta = e.deltaY > 0 ? 0.9 : 1.1;
setZoom((prev) => Math.max(0.1, Math.min(5, prev * delta)));
};
const handleMouseDown = (e: React.MouseEvent) => {
if (e.button === 1 || (e.button === 0 && e.ctrlKey)) {
setIsPanning(true);
setPanStart({ x: e.clientX - pan.x, y: e.clientY - pan.y });
e.preventDefault();
}
};
const handleMouseMove = (e: React.MouseEvent) => {
if (isPanning) {
setPan({
x: e.clientX - panStart.x,
y: e.clientY - panStart.y,
});
}
};
const handleMouseUp = () => {
setIsPanning(false);
};
const handleZoomIn = () => {
setZoom((prev) => Math.min(5, prev * 1.2));
};
const handleZoomOut = () => {
setZoom((prev) => Math.max(0.1, prev / 1.2));
};
const handleReset = () => {
setZoom(1);
setPan({ x: 0, y: 0 });
};
// Parse and update SVG with viewBox
const svgWithViewBox = useMemo(() => {
const parser = new DOMParser();
const doc = parser.parseFromString(svgString, 'image/svg+xml');
const svgElement = doc.documentElement;
svgElement.setAttribute('viewBox', viewBox);
svgElement.setAttribute('preserveAspectRatio', 'xMidYMid meet');
svgElement.setAttribute('width', '100%');
svgElement.setAttribute('height', '100%');
return svgElement.outerHTML;
}, [svgString, viewBox]);
return (
<div className="relative w-full h-full bg-gray-100 border border-gray-300 rounded-lg overflow-hidden">
<div
ref={containerRef}
className="w-full h-full flex items-center justify-center"
onWheel={handleWheel}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
style={{ cursor: isPanning ? 'grabbing' : 'grab' }}
dangerouslySetInnerHTML={{ __html: svgWithViewBox }}
/>
{/* Zoom controls */}
<div className="absolute top-4 right-4 flex flex-col gap-2">
<button
onClick={handleZoomIn}
className="px-3 py-1 bg-white border border-gray-300 rounded hover:bg-gray-50 text-sm"
>
+
</button>
<button
onClick={handleZoomOut}
className="px-3 py-1 bg-white border border-gray-300 rounded hover:bg-gray-50 text-sm"
>
</button>
<button
onClick={handleReset}
className="px-3 py-1 bg-white border border-gray-300 rounded hover:bg-gray-50 text-sm"
>
Reset
</button>
<div className="px-3 py-1 bg-white border border-gray-300 rounded text-sm text-center">
{Math.round(zoom * 100)}%
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,160 @@
import { useState } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { useBadgeStore } from '@/stores/badgeStore';
import { generateBadgeSVG } from '@/utils/svgGenerators';
export function ExportModal() {
const [open, setOpen] = useState(false);
const [format, setFormat] = useState<'svg' | 'png' | 'pdf' | 'dxf' | 'ai' | 'eps'>('svg');
const [includeDepthMap, setIncludeDepthMap] = useState(false);
const [includeWatermark, setIncludeWatermark] = useState(false);
const geometry = useBadgeStore((state) => state.geometry);
const star = useBadgeStore((state) => state.star);
const shield = useBadgeStore((state) => state.shield);
const arcTexts = useBadgeStore((state) => state.text.arcTexts);
const ribbonTexts = useBadgeStore((state) => state.text.ribbonTexts);
const handleExport = () => {
const svgString = generateBadgeSVG(geometry, star, shield, arcTexts, ribbonTexts);
if (format === 'svg') {
// Export as SVG
const blob = new Blob([svgString], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `badge-${Date.now()}.svg`;
link.click();
URL.revokeObjectURL(url);
} else if (format === 'png') {
// Export as PNG
const parser = new DOMParser();
const doc = parser.parseFromString(svgString, 'image/svg+xml');
const svgElement = doc.documentElement;
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) return;
const diameterPx = geometry.diameter * 96;
canvas.width = diameterPx;
canvas.height = diameterPx;
const img = new Image();
const svgBlob = new Blob([svgString], { type: 'image/svg+xml' });
const url = URL.createObjectURL(svgBlob);
img.onload = () => {
ctx.drawImage(img, 0, 0);
canvas.toBlob((blob) => {
if (blob) {
const downloadUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = downloadUrl;
link.download = `badge-${Date.now()}.png`;
link.click();
URL.revokeObjectURL(downloadUrl);
}
});
URL.revokeObjectURL(url);
};
img.src = url;
} else if (format === 'pdf' || format === 'dxf' || format === 'ai' || format === 'eps') {
// Placeholder for advanced export formats
// In a full implementation, these would use PDFKit.js, DXF exporters, etc.
alert(`${format.toUpperCase()} export is coming soon! This requires additional libraries and backend support.`);
}
setOpen(false);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>Export</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Export Badge</DialogTitle>
<DialogDescription>
Choose the format you want to export your badge in.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label className="mb-2 block">Export Format</Label>
<RadioGroup value={format} onValueChange={(value) => setFormat(value as typeof format)}>
<div className="flex items-center space-x-2">
<RadioGroupItem value="svg" id="svg" />
<Label htmlFor="svg">SVG (Vector)</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="png" id="png" />
<Label htmlFor="png">PNG (Raster)</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="pdf" id="pdf" />
<Label htmlFor="pdf">PDF (Vector)</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="dxf" id="dxf" />
<Label htmlFor="dxf">DXF (Manufacturing)</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="ai" id="ai" />
<Label htmlFor="ai">AI (Adobe Illustrator)</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="eps" id="eps" />
<Label htmlFor="eps">EPS (Encapsulated PostScript)</Label>
</div>
</RadioGroup>
</div>
{(format === 'dxf' || format === 'ai' || format === 'eps') && (
<div className="flex items-center space-x-2">
<Switch
id="depthMap"
checked={includeDepthMap}
onCheckedChange={setIncludeDepthMap}
/>
<Label htmlFor="depthMap">Include Manufacturing Depth Map</Label>
</div>
)}
<div className="flex items-center space-x-2">
<Switch
id="watermark"
checked={includeWatermark}
onCheckedChange={setIncludeWatermark}
/>
<Label htmlFor="watermark">Include Watermark</Label>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button onClick={handleExport}>Export</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,63 @@
import { useBadgeStore } from '@/stores/badgeStore';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
export function HistoryTimeline() {
const history = useBadgeStore((state) => state.history);
const historyIndex = useBadgeStore((state) => state.historyIndex);
const undo = useBadgeStore((state) => state.undo);
const redo = useBadgeStore((state) => state.redo);
const goToHistoryIndex = (index: number) => {
const currentIndex = historyIndex;
if (index < currentIndex) {
// Go back
for (let i = currentIndex; i > index; i--) {
undo();
}
} else if (index > currentIndex) {
// Go forward
for (let i = currentIndex; i < index; i++) {
redo();
}
}
};
return (
<div className="space-y-2">
<h3 className="text-sm font-semibold">History</h3>
<ScrollArea className="h-[200px]">
<div className="space-y-1">
{history.map((_, index) => {
const isActive = index === historyIndex;
return (
<button
key={index}
onClick={() => goToHistoryIndex(index)}
className={cn(
"w-full text-left px-2 py-1 text-xs rounded hover:bg-accent transition-colors",
isActive && "bg-accent font-medium"
)}
>
<div className="flex items-center gap-2">
<div
className={cn(
"w-2 h-2 rounded-full",
isActive ? "bg-primary" : "bg-muted-foreground"
)}
/>
<span>
{index === 0 ? "Initial" : `State ${index}`}
{isActive && " (Current)"}
</span>
</div>
</button>
);
})}
</div>
</ScrollArea>
</div>
);
}

View File

@@ -0,0 +1,125 @@
import { useBadgeStore } from '@/stores/badgeStore';
import { BadgeLayer } from '@/types/badge';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { ScrollArea } from '@/components/ui/scroll-area';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { GripVertical, Eye, EyeOff, Lock, Unlock, Copy, Trash2, MoreVertical } from 'lucide-react';
import { useDrag, useDrop } from 'react-dnd';
interface LayerItemProps {
layer: BadgeLayer;
index: number;
moveLayer: (dragIndex: number, hoverIndex: number) => void;
}
function LayerItem({ layer, index, moveLayer }: LayerItemProps) {
const updateLayer = useBadgeStore((state) => {
// This would be implemented in the store
return () => {};
});
const deleteLayer = useBadgeStore((state) => {
// This would be implemented in the store
return () => {};
});
const [{ isDragging }, drag] = useDrag({
type: 'layer',
item: { index },
collect: (monitor) => ({
isDragging: monitor.isDragging(),
}),
});
const [, drop] = useDrop({
accept: 'layer',
hover: (draggedItem: { index: number }) => {
if (draggedItem.index !== index) {
moveLayer(draggedItem.index, index);
draggedItem.index = index;
}
},
});
return (
<div
ref={(node) => drag(drop(node))}
className={`flex items-center gap-2 p-2 rounded border ${
isDragging ? 'opacity-50' : ''
}`}
>
<GripVertical className="h-4 w-4 text-gray-400 cursor-move" />
<div className="flex-1">
<Label className="font-normal">{layer.name}</Label>
</div>
<Switch
checked={layer.visible}
onCheckedChange={(checked) => {
// Update visibility
}}
/>
<Button variant="ghost" size="icon">
{layer.locked ? <Lock className="h-4 w-4" /> : <Unlock className="h-4 w-4" />}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>
<Copy className="h-4 w-4 mr-2" />
Duplicate
</DropdownMenuItem>
<DropdownMenuItem className="text-destructive">
<Trash2 className="h-4 w-4 mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
export function LayerManager() {
const layers = useBadgeStore((state) => state.layers);
const moveLayer = (dragIndex: number, hoverIndex: number) => {
// This would be implemented in the store
console.log('Move layer', dragIndex, hoverIndex);
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">Layers</h3>
</div>
<ScrollArea className="h-[400px]">
<div className="space-y-2">
{layers.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">
No layers yet. Layers will appear here as you add elements.
</p>
) : (
layers.map((layer, index) => (
<LayerItem
key={layer.id}
layer={layer}
index={index}
moveLayer={moveLayer}
/>
))
)}
</div>
</ScrollArea>
</div>
);
}

View File

@@ -0,0 +1,101 @@
import { useBadgeStore } from '@/stores/badgeStore';
import { Label } from '@/components/ui/label';
import { Slider } from '@/components/ui/slider';
import { Switch } from '@/components/ui/switch';
import { Input } from '@/components/ui/input';
export function GeometryControls() {
const geometry = useBadgeStore((state) => state.geometry);
const setDiameter = useBadgeStore((state) => state.setDiameter);
const setStrokeWidth = useBadgeStore((state) => state.setStrokeWidth);
const setDualRing = useBadgeStore((state) => state.setDualRing);
const setDualRingOffset = useBadgeStore((state) => state.setDualRingOffset);
return (
<div className="space-y-4">
<div>
<Label htmlFor="diameter">
Diameter: {geometry.diameter.toFixed(2)}"
</Label>
<Slider
id="diameter"
min={2.0}
max={4.0}
step={0.1}
value={[geometry.diameter]}
onValueChange={(value) => setDiameter(value[0])}
className="mt-2"
/>
<Input
type="number"
min={2.0}
max={4.0}
step={0.1}
value={geometry.diameter}
onChange={(e) => setDiameter(parseFloat(e.target.value) || 2.0)}
className="mt-2"
/>
</div>
<div>
<Label htmlFor="strokeWidth">
Stroke Width: {geometry.strokeWidth.toFixed(3)}"
</Label>
<Slider
id="strokeWidth"
min={0.01}
max={0.2}
step={0.01}
value={[geometry.strokeWidth]}
onValueChange={(value) => setStrokeWidth(value[0])}
className="mt-2"
/>
<Input
type="number"
min={0.01}
max={0.2}
step={0.01}
value={geometry.strokeWidth}
onChange={(e) => setStrokeWidth(parseFloat(e.target.value) || 0.05)}
className="mt-2"
/>
</div>
<div className="flex items-center space-x-2">
<Switch
id="dualRing"
checked={geometry.hasDualRing}
onCheckedChange={setDualRing}
/>
<Label htmlFor="dualRing">Dual Ring</Label>
</div>
{geometry.hasDualRing && (
<div>
<Label htmlFor="dualRingOffset">
Dual Ring Offset: {geometry.dualRingOffset.toFixed(2)}"
</Label>
<Slider
id="dualRingOffset"
min={0.05}
max={0.5}
step={0.05}
value={[geometry.dualRingOffset]}
onValueChange={(value) => setDualRingOffset(value[0])}
className="mt-2"
/>
<Input
type="number"
min={0.05}
max={0.5}
step={0.05}
value={geometry.dualRingOffset}
onChange={(e) => setDualRingOffset(parseFloat(e.target.value) || 0.1)}
className="mt-2"
/>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,116 @@
import { useBadgeStore } from '@/stores/badgeStore';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { useState } from 'react';
export function ShieldControls() {
const shield = useBadgeStore((state) => state.shield);
const setShieldEnabled = useBadgeStore((state) => state.setShieldEnabled);
const setShieldQuadrants = useBadgeStore((state) => state.setShieldQuadrants);
const setShieldQuadrantImage = useBadgeStore((state) => state.setShieldQuadrantImage);
const setShieldQuadrantColor = useBadgeStore((state) => state.setShieldQuadrantColor);
const handleImageUpload = (index: number, file: File) => {
const reader = new FileReader();
reader.onload = (e) => {
const result = e.target?.result as string;
setShieldQuadrantImage(index, result);
};
reader.readAsDataURL(file);
};
return (
<div className="space-y-4">
<div className="flex items-center space-x-2">
<Switch
id="shieldEnabled"
checked={shield.enabled}
onCheckedChange={setShieldEnabled}
/>
<Label htmlFor="shieldEnabled">Enable Shield</Label>
</div>
{shield.enabled && (
<>
<div>
<Label>Quadrants</Label>
<RadioGroup
value={shield.quadrants.toString()}
onValueChange={(value) => setShieldQuadrants(parseInt(value) as 1 | 2 | 3 | 4)}
className="mt-2"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="1" id="q1" />
<Label htmlFor="q1">1</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="2" id="q2" />
<Label htmlFor="q2">2</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="3" id="q3" />
<Label htmlFor="q3">3</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="4" id="q4" />
<Label htmlFor="q4">4</Label>
</div>
</RadioGroup>
</div>
{Array.from({ length: shield.quadrants }).map((_, index) => (
<div key={index} className="border rounded p-3 space-y-2">
<Label>Quadrant {index + 1}</Label>
<div>
<Label htmlFor={`quadrant-${index}-color`}>Background Color</Label>
<Input
id={`quadrant-${index}-color`}
type="color"
value={shield.quadrantsData[index]?.backgroundColor || '#ffffff'}
onChange={(e) => setShieldQuadrantColor(index, e.target.value)}
className="mt-2 h-10"
/>
</div>
<div>
<Label htmlFor={`quadrant-${index}-image`}>Upload Image</Label>
<Input
id={`quadrant-${index}-image`}
type="file"
accept="image/*"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleImageUpload(index, file);
}}
className="mt-2"
/>
{shield.quadrantsData[index]?.imageUrl && (
<div className="mt-2">
<img
src={shield.quadrantsData[index].imageUrl}
alt={`Quadrant ${index + 1}`}
className="w-full h-24 object-contain border rounded"
/>
<Button
variant="destructive"
size="sm"
onClick={() => setShieldQuadrantImage(index, '')}
className="mt-2"
>
Remove Image
</Button>
</div>
)}
</div>
</div>
))}
</>
)}
</div>
);
}

View File

@@ -0,0 +1,123 @@
import { useBadgeStore } from '@/stores/badgeStore';
import { Label } from '@/components/ui/label';
import { Slider } from '@/components/ui/slider';
import { Switch } from '@/components/ui/switch';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Input } from '@/components/ui/input';
export function StarControls() {
const star = useBadgeStore((state) => state.star);
const setStarEnabled = useBadgeStore((state) => state.setStarEnabled);
const setStarPoints = useBadgeStore((state) => state.setStarPoints);
const setStarInnerRadius = useBadgeStore((state) => state.setStarInnerRadius);
const setStarOuterRadius = useBadgeStore((state) => state.setStarOuterRadius);
const setStarBevelDepth = useBadgeStore((state) => state.setStarBevelDepth);
const setStarMetallic = useBadgeStore((state) => state.setStarMetallic);
const setStarScale = useBadgeStore((state) => state.setStarScale);
return (
<div className="space-y-4">
<div className="flex items-center space-x-2">
<Switch
id="starEnabled"
checked={star.enabled}
onCheckedChange={setStarEnabled}
/>
<Label htmlFor="starEnabled">Enable Star</Label>
</div>
{star.enabled && (
<>
<div>
<Label htmlFor="starPoints">Number of Points</Label>
<Select
value={star.points.toString()}
onValueChange={(value) => setStarPoints(parseInt(value))}
>
<SelectTrigger id="starPoints" className="mt-2">
<SelectValue />
</SelectTrigger>
<SelectContent>
{[5, 6, 7, 8, 9, 10].map((points) => (
<SelectItem key={points} value={points.toString()}>
{points} Points
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="innerRadius">
Inner Radius: {(star.innerRadius * 100).toFixed(0)}%
</Label>
<Slider
id="innerRadius"
min={0.1}
max={0.9}
step={0.05}
value={[star.innerRadius]}
onValueChange={(value) => setStarInnerRadius(value[0])}
className="mt-2"
/>
</div>
<div>
<Label htmlFor="outerRadius">
Outer Radius: {(star.outerRadius * 100).toFixed(0)}%
</Label>
<Slider
id="outerRadius"
min={0.3}
max={1.0}
step={0.05}
value={[star.outerRadius]}
onValueChange={(value) => setStarOuterRadius(value[0])}
className="mt-2"
/>
</div>
<div>
<Label htmlFor="bevelDepth">
Bevel Depth: {star.bevelDepth.toFixed(2)}
</Label>
<Slider
id="bevelDepth"
min={0}
max={0.5}
step={0.05}
value={[star.bevelDepth]}
onValueChange={(value) => setStarBevelDepth(value[0])}
className="mt-2"
/>
</div>
<div>
<Label htmlFor="scale">
Scale: {(star.scale * 100).toFixed(0)}%
</Label>
<Slider
id="scale"
min={0.1}
max={2.0}
step={0.1}
value={[star.scale]}
onValueChange={(value) => setStarScale(value[0])}
className="mt-2"
/>
</div>
<div className="flex items-center space-x-2">
<Switch
id="starMetallic"
checked={star.metallic}
onCheckedChange={setStarMetallic}
/>
<Label htmlFor="starMetallic">Metallic Finish</Label>
</div>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,240 @@
import { useBadgeStore } from '@/stores/badgeStore';
import { Label } from '@/components/ui/label';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Slider } from '@/components/ui/slider';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { useState } from 'react';
import { ArcText, RibbonText } from '@/types/badge';
export function TextControls() {
const arcTexts = useBadgeStore((state) => state.text.arcTexts);
const ribbonTexts = useBadgeStore((state) => state.text.ribbonTexts);
const addArcText = useBadgeStore((state) => state.addArcText);
const updateArcText = useBadgeStore((state) => state.updateArcText);
const removeArcText = useBadgeStore((state) => state.removeArcText);
const addRibbonText = useBadgeStore((state) => state.addRibbonText);
const updateRibbonText = useBadgeStore((state) => state.updateRibbonText);
const removeRibbonText = useBadgeStore((state) => state.removeRibbonText);
const [newArcText, setNewArcText] = useState({
text: '',
position: 'top' as 'top' | 'bottom',
fontSize: 12,
fontFamily: 'Arial',
color: '#000000',
curvature: 50,
spacing: 1,
alignment: 'center' as 'left' | 'center' | 'right',
});
const [newRibbonText, setNewRibbonText] = useState({
text: '',
fontSize: 12,
fontFamily: 'Arial',
color: '#000000',
x: 0,
y: 0,
});
const handleAddArcText = () => {
if (newArcText.text) {
const arcText: ArcText = {
id: Date.now().toString(),
...newArcText,
};
addArcText(arcText);
setNewArcText({
text: '',
position: 'top',
fontSize: 12,
fontFamily: 'Arial',
color: '#000000',
curvature: 50,
spacing: 1,
alignment: 'center',
});
}
};
const handleAddRibbonText = () => {
if (newRibbonText.text) {
const ribbonText: RibbonText = {
id: Date.now().toString(),
...newRibbonText,
};
addRibbonText(ribbonText);
setNewRibbonText({
text: '',
fontSize: 12,
fontFamily: 'Arial',
color: '#000000',
x: 0,
y: 0,
});
}
};
return (
<Tabs defaultValue="arc" className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="arc">Arc Text</TabsTrigger>
<TabsTrigger value="ribbon">Ribbon Text</TabsTrigger>
</TabsList>
<TabsContent value="arc" className="space-y-4 mt-4">
<div className="space-y-4">
{arcTexts.map((arcText) => (
<div key={arcText.id} className="border rounded p-3 space-y-2">
<div className="flex justify-between items-center">
<Label>{arcText.text || 'Untitled'}</Label>
<Button
variant="destructive"
size="sm"
onClick={() => removeArcText(arcText.id)}
>
Remove
</Button>
</div>
<div>
<Label>Text</Label>
<Input
value={arcText.text}
onChange={(e) => updateArcText(arcText.id, { text: e.target.value })}
className="mt-1"
/>
</div>
<div>
<Label>Position</Label>
<Select
value={arcText.position}
onValueChange={(value) => updateArcText(arcText.id, { position: value as 'top' | 'bottom' })}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="top">Top</SelectItem>
<SelectItem value="bottom">Bottom</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>Font Size: {arcText.fontSize}px</Label>
<Slider
min={8}
max={48}
step={1}
value={[arcText.fontSize]}
onValueChange={(value) => updateArcText(arcText.id, { fontSize: value[0] })}
className="mt-2"
/>
</div>
<div>
<Label>Color</Label>
<Input
type="color"
value={arcText.color}
onChange={(e) => updateArcText(arcText.id, { color: e.target.value })}
className="mt-1 h-10"
/>
</div>
</div>
))}
<div className="border rounded p-3 space-y-2">
<Label>Add New Arc Text</Label>
<Input
placeholder="Enter text"
value={newArcText.text}
onChange={(e) => setNewArcText({ ...newArcText, text: e.target.value })}
/>
<Select
value={newArcText.position}
onValueChange={(value) => setNewArcText({ ...newArcText, position: value as 'top' | 'bottom' })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="top">Top</SelectItem>
<SelectItem value="bottom">Bottom</SelectItem>
</SelectContent>
</Select>
<Button onClick={handleAddArcText} className="w-full">
Add Arc Text
</Button>
</div>
</div>
</TabsContent>
<TabsContent value="ribbon" className="space-y-4 mt-4">
<div className="space-y-4">
{ribbonTexts.map((ribbonText) => (
<div key={ribbonText.id} className="border rounded p-3 space-y-2">
<div className="flex justify-between items-center">
<Label>{ribbonText.text || 'Untitled'}</Label>
<Button
variant="destructive"
size="sm"
onClick={() => removeRibbonText(ribbonText.id)}
>
Remove
</Button>
</div>
<div>
<Label>Text</Label>
<Input
value={ribbonText.text}
onChange={(e) => updateRibbonText(ribbonText.id, { text: e.target.value })}
className="mt-1"
/>
</div>
<div>
<Label>Font Size: {ribbonText.fontSize}px</Label>
<Slider
min={8}
max={48}
step={1}
value={[ribbonText.fontSize]}
onValueChange={(value) => updateRibbonText(ribbonText.id, { fontSize: value[0] })}
className="mt-2"
/>
</div>
<div>
<Label>Color</Label>
<Input
type="color"
value={ribbonText.color}
onChange={(e) => updateRibbonText(ribbonText.id, { color: e.target.value })}
className="mt-1 h-10"
/>
</div>
</div>
))}
<div className="border rounded p-3 space-y-2">
<Label>Add New Ribbon Text</Label>
<Input
placeholder="Enter text"
value={newRibbonText.text}
onChange={(e) => setNewRibbonText({ ...newRibbonText, text: e.target.value })}
/>
<Button onClick={handleAddRibbonText} className="w-full">
Add Ribbon Text
</Button>
</div>
</div>
</TabsContent>
</Tabs>
);
}

View File

@@ -0,0 +1,56 @@
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
const Accordion = AccordionPrimitive.Root
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn("border-b", className)}
{...props}
/>
))
AccordionItem.displayName = "AccordionItem"
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
))
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
))
AccordionContent.displayName = AccordionPrimitive.Content.displayName
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View File

@@ -0,0 +1,54 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, ...props }, ref) => {
return (
<button
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@@ -0,0 +1,117 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@@ -0,0 +1,193 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

View File

@@ -0,0 +1,25 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

View File

@@ -0,0 +1,24 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@@ -0,0 +1,42 @@
import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Root
className={cn("grid gap-2", className)}
{...props}
ref={ref}
/>
)
})
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<Circle className="h-2.5 w-2.5 fill-current text-current" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
})
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
export { RadioGroup, RadioGroupItem }

View File

@@ -0,0 +1,46 @@
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }

View File

@@ -0,0 +1,156 @@
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}

View File

@@ -0,0 +1,26 @@
import * as React from "react"
import * as SliderPrimitive from "@radix-ui/react-slider"
import { cn } from "@/lib/utils"
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn(
"relative flex w-full touch-none select-none items-center",
className
)}
{...props}
>
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root>
))
Slider.displayName = SliderPrimitive.Root.displayName
export { Slider }

View File

@@ -0,0 +1,27 @@
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }

View File

@@ -0,0 +1,53 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

60
src/index.css Normal file
View File

@@ -0,0 +1,60 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

7
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,7 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

11
src/main.tsx Normal file
View File

@@ -0,0 +1,11 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

332
src/stores/badgeStore.ts Normal file
View File

@@ -0,0 +1,332 @@
import { create } from 'zustand';
import { BadgeState, BadgeGeometry, StarConfig, ShieldConfig, TextConfig } from '@/types/badge';
const defaultGeometry: BadgeGeometry = {
diameter: 3.0,
strokeWidth: 0.05,
hasDualRing: false,
dualRingOffset: 0.1,
};
const defaultStar: StarConfig = {
enabled: false,
points: 5,
innerRadius: 0.4,
outerRadius: 0.8,
bevelDepth: 0.1,
metallic: false,
x: 0,
y: 0,
scale: 1,
};
const defaultShield: ShieldConfig = {
enabled: false,
quadrants: 1,
quadrantsData: [
{ backgroundColor: '#ffffff' },
{ backgroundColor: '#ffffff' },
{ backgroundColor: '#ffffff' },
{ backgroundColor: '#ffffff' },
],
strokeWidth: 0.05,
strokeColor: '#000000',
borderWidth: 0.1,
borderColor: '#000000',
};
const defaultText: TextConfig = {
arcTexts: [],
ribbonTexts: [],
};
interface BadgeStore extends BadgeState {
// Geometry actions
setDiameter: (diameter: number) => void;
setStrokeWidth: (width: number) => void;
setDualRing: (enabled: boolean) => void;
setDualRingOffset: (offset: number) => void;
// Star actions
setStarEnabled: (enabled: boolean) => void;
setStarPoints: (points: number) => void;
setStarInnerRadius: (radius: number) => void;
setStarOuterRadius: (radius: number) => void;
setStarBevelDepth: (depth: number) => void;
setStarMetallic: (metallic: boolean) => void;
setStarPosition: (x: number, y: number) => void;
setStarScale: (scale: number) => void;
// Shield actions
setShieldEnabled: (enabled: boolean) => void;
setShieldQuadrants: (quadrants: 1 | 2 | 3 | 4) => void;
setShieldQuadrantImage: (index: number, imageUrl: string) => void;
setShieldQuadrantColor: (index: number, color: string) => void;
setShieldStroke: (width: number, color: string) => void;
setShieldBorder: (width: number, color: string) => void;
// Text actions
addArcText: (text: ArcText) => void;
updateArcText: (id: string, updates: Partial<ArcText>) => void;
removeArcText: (id: string) => void;
addRibbonText: (text: RibbonText) => void;
updateRibbonText: (id: string, updates: Partial<RibbonText>) => void;
removeRibbonText: (id: string) => void;
// History actions
undo: () => void;
redo: () => void;
saveHistory: () => void;
}
type ArcText = import('@/types/badge').ArcText;
type RibbonText = import('@/types/badge').RibbonText;
const initialState = {
geometry: defaultGeometry,
star: defaultStar,
shield: defaultShield,
text: defaultText,
};
export const useBadgeStore = create<BadgeStore>((set, get) => ({
...initialState,
layers: [],
history: [initialState],
historyIndex: 0,
setDiameter: (diameter) => {
set((state) => ({
geometry: { ...state.geometry, diameter: Math.max(2.0, Math.min(4.0, diameter)) },
}));
get().saveHistory();
},
setStrokeWidth: (width) => {
set((state) => ({
geometry: { ...state.geometry, strokeWidth: Math.max(0, width) },
}));
get().saveHistory();
},
setDualRing: (enabled) => {
set((state) => ({
geometry: { ...state.geometry, hasDualRing: enabled },
}));
get().saveHistory();
},
setDualRingOffset: (offset) => {
set((state) => ({
geometry: { ...state.geometry, dualRingOffset: Math.max(0, offset) },
}));
get().saveHistory();
},
setStarEnabled: (enabled) => {
set((state) => ({ star: { ...state.star, enabled } }));
get().saveHistory();
},
setStarPoints: (points) => {
set((state) => ({
star: { ...state.star, points: Math.max(5, Math.min(10, points)) },
}));
get().saveHistory();
},
setStarInnerRadius: (radius) => {
set((state) => ({
star: { ...state.star, innerRadius: Math.max(0, Math.min(1, radius)) },
}));
get().saveHistory();
},
setStarOuterRadius: (radius) => {
set((state) => ({
star: { ...state.star, outerRadius: Math.max(0, Math.min(1, radius)) },
}));
get().saveHistory();
},
setStarBevelDepth: (depth) => {
set((state) => ({
star: { ...state.star, bevelDepth: Math.max(0, depth) },
}));
get().saveHistory();
},
setStarMetallic: (metallic) => {
set((state) => ({ star: { ...state.star, metallic } }));
get().saveHistory();
},
setStarPosition: (x, y) => {
set((state) => ({ star: { ...state.star, x, y } }));
get().saveHistory();
},
setStarScale: (scale) => {
set((state) => ({
star: { ...state.star, scale: Math.max(0.1, Math.min(2, scale)) },
}));
get().saveHistory();
},
setShieldEnabled: (enabled) => {
set((state) => ({ shield: { ...state.shield, enabled } }));
get().saveHistory();
},
setShieldQuadrants: (quadrants) => {
set((state) => ({ shield: { ...state.shield, quadrants } }));
get().saveHistory();
},
setShieldQuadrantImage: (index, imageUrl) => {
set((state) => {
const newQuadrantsData = [...state.shield.quadrantsData];
newQuadrantsData[index] = { ...newQuadrantsData[index], imageUrl };
return {
shield: { ...state.shield, quadrantsData: newQuadrantsData },
};
});
get().saveHistory();
},
setShieldQuadrantColor: (index, color) => {
set((state) => {
const newQuadrantsData = [...state.shield.quadrantsData];
newQuadrantsData[index] = { ...newQuadrantsData[index], backgroundColor: color };
return {
shield: { ...state.shield, quadrantsData: newQuadrantsData },
};
});
get().saveHistory();
},
setShieldStroke: (width, color) => {
set((state) => ({
shield: { ...state.shield, strokeWidth: width, strokeColor: color },
}));
get().saveHistory();
},
setShieldBorder: (width, color) => {
set((state) => ({
shield: { ...state.shield, borderWidth: width, borderColor: color },
}));
get().saveHistory();
},
addArcText: (text) => {
set((state) => ({
text: {
...state.text,
arcTexts: [...state.text.arcTexts, text],
},
}));
get().saveHistory();
},
updateArcText: (id, updates) => {
set((state) => ({
text: {
...state.text,
arcTexts: state.text.arcTexts.map((t) =>
t.id === id ? { ...t, ...updates } : t
),
},
}));
get().saveHistory();
},
removeArcText: (id) => {
set((state) => ({
text: {
...state.text,
arcTexts: state.text.arcTexts.filter((t) => t.id !== id),
},
}));
get().saveHistory();
},
addRibbonText: (text) => {
set((state) => ({
text: {
...state.text,
ribbonTexts: [...state.text.ribbonTexts, text],
},
}));
get().saveHistory();
},
updateRibbonText: (id, updates) => {
set((state) => ({
text: {
...state.text,
ribbonTexts: state.text.ribbonTexts.map((t) =>
t.id === id ? { ...t, ...updates } : t
),
},
}));
get().saveHistory();
},
removeRibbonText: (id) => {
set((state) => ({
text: {
...state.text,
ribbonTexts: state.text.ribbonTexts.filter((t) => t.id !== id),
},
}));
get().saveHistory();
},
undo: () => {
const state = get();
if (state.historyIndex > 0) {
const newIndex = state.historyIndex - 1;
const previousState = state.history[newIndex];
set({
geometry: previousState.geometry,
star: previousState.star,
shield: previousState.shield,
text: previousState.text,
historyIndex: newIndex,
});
}
},
redo: () => {
const state = get();
if (state.historyIndex < state.history.length - 1) {
const newIndex = state.historyIndex + 1;
const nextState = state.history[newIndex];
set({
geometry: nextState.geometry,
star: nextState.star,
shield: nextState.shield,
text: nextState.text,
historyIndex: newIndex,
});
}
},
saveHistory: () => {
const state = get();
const currentState = {
geometry: state.geometry,
star: state.star,
shield: state.shield,
text: state.text,
};
const newHistory = state.history.slice(0, state.historyIndex + 1);
newHistory.push(currentState);
set({
history: newHistory.slice(-50), // Keep last 50 states
historyIndex: newHistory.length - 1,
});
},
}));

81
src/types/badge.ts Normal file
View File

@@ -0,0 +1,81 @@
export interface BadgeGeometry {
diameter: number; // in inches (2.0 - 4.0)
strokeWidth: number;
hasDualRing: boolean;
dualRingOffset: number;
}
export interface StarConfig {
enabled: boolean;
points: number; // 5-10
innerRadius: number; // percentage of badge radius
outerRadius: number; // percentage of badge radius
bevelDepth: number;
metallic: boolean;
x: number;
y: number;
scale: number;
}
export interface ShieldQuadrant {
imageUrl?: string;
backgroundColor: string;
pattern?: string;
}
export interface ShieldConfig {
enabled: boolean;
quadrants: 1 | 2 | 3 | 4;
quadrantsData: ShieldQuadrant[];
strokeWidth: number;
strokeColor: string;
borderWidth: number;
borderColor: string;
}
export interface ArcText {
id: string;
text: string;
position: 'top' | 'bottom';
fontSize: number;
fontFamily: string;
color: string;
curvature: number; // 0-100
spacing: number;
alignment: 'left' | 'center' | 'right';
}
export interface RibbonText {
id: string;
text: string;
fontSize: number;
fontFamily: string;
color: string;
x: number;
y: number;
}
export interface TextConfig {
arcTexts: ArcText[];
ribbonTexts: RibbonText[];
}
export interface BadgeLayer {
id: string;
type: 'geometry' | 'star' | 'shield' | 'arcText' | 'ribbonText' | 'image';
visible: boolean;
locked: boolean;
zIndex: number;
name: string;
}
export interface BadgeState {
geometry: BadgeGeometry;
star: StarConfig;
shield: ShieldConfig;
text: TextConfig;
layers: BadgeLayer[];
history: BadgeState[];
historyIndex: number;
}

230
src/utils/svgGenerators.ts Normal file
View File

@@ -0,0 +1,230 @@
import { BadgeGeometry, StarConfig, ShieldConfig, ArcText, RibbonText } from '@/types/badge';
// Convert inches to pixels (assuming 96 DPI)
const inchesToPixels = (inches: number) => inches * 96;
// Generate star path
export function generateStarPath(
points: number,
innerRadius: number,
outerRadius: number,
centerX: number,
centerY: number
): string {
const path: string[] = [];
const angleStep = (2 * Math.PI) / points;
for (let i = 0; i < points * 2; i++) {
const angle = (i * angleStep) / 2 - Math.PI / 2;
const radius = i % 2 === 0 ? outerRadius : innerRadius;
const x = centerX + radius * Math.cos(angle);
const y = centerY + radius * Math.sin(angle);
if (i === 0) {
path.push(`M ${x} ${y}`);
} else {
path.push(`L ${x} ${y}`);
}
}
path.push('Z');
return path.join(' ');
}
// Generate arc path for text
export function generateArcPath(
radius: number,
startAngle: number,
endAngle: number,
centerX: number,
centerY: number
): string {
const startX = centerX + radius * Math.cos(startAngle);
const startY = centerY + radius * Math.sin(startAngle);
const endX = centerX + radius * Math.cos(endAngle);
const endY = centerY + radius * Math.sin(endAngle);
const largeArcFlag = endAngle - startAngle > Math.PI ? 1 : 0;
return `M ${startX} ${startY} A ${radius} ${radius} 0 ${largeArcFlag} 1 ${endX} ${endY}`;
}
// Generate badge circle SVG
export function generateBadgeCircle(geometry: BadgeGeometry): string {
const diameterPx = inchesToPixels(geometry.diameter);
const radius = diameterPx / 2;
const centerX = diameterPx / 2;
const centerY = diameterPx / 2;
let svg = `<circle cx="${centerX}" cy="${centerY}" r="${radius}"
fill="none" stroke="#000000" stroke-width="${inchesToPixels(geometry.strokeWidth)}" />`;
if (geometry.hasDualRing) {
const innerRadius = radius - inchesToPixels(geometry.dualRingOffset);
svg += `<circle cx="${centerX}" cy="${centerY}" r="${innerRadius}"
fill="none" stroke="#000000" stroke-width="${inchesToPixels(geometry.strokeWidth)}" />`;
}
return svg;
}
// Generate star SVG
export function generateStar(star: StarConfig, badgeRadius: number): string {
if (!star.enabled) return '';
const centerX = inchesToPixels(star.x) + badgeRadius;
const centerY = inchesToPixels(star.y) + badgeRadius;
const outerRadius = badgeRadius * star.outerRadius * star.scale;
const innerRadius = badgeRadius * star.innerRadius * star.scale;
const path = generateStarPath(star.points, innerRadius, outerRadius, centerX, centerY);
let svg = `<path d="${path}" fill="${star.metallic ? '#FFD700' : '#000000'}"
stroke="#000000" stroke-width="1" />`;
if (star.metallic) {
// Add gradient for metallic effect
svg = `<defs>
<linearGradient id="metallicGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#FFD700;stop-opacity:1" />
<stop offset="50%" style="stop-color:#FFA500;stop-opacity:1" />
<stop offset="100%" style="stop-color:#FFD700;stop-opacity:1" />
</linearGradient>
</defs>${svg.replace('fill="#FFD700"', 'fill="url(#metallicGradient)"')}`;
}
return svg;
}
// Generate shield SVG
export function generateShield(
shield: ShieldConfig,
badgeRadius: number
): string {
if (!shield.enabled) return '';
const centerX = badgeRadius;
const centerY = badgeRadius;
const width = badgeRadius * 0.8;
const height = badgeRadius * 0.8;
let svg = '';
// Generate quadrant paths
if (shield.quadrants === 1) {
// Simple shield shape
const path = `M ${centerX} ${centerY - height}
L ${centerX + width * 0.7} ${centerY}
L ${centerX} ${centerY + height}
L ${centerX - width * 0.7} ${centerY} Z`;
const quadrant = shield.quadrantsData[0];
svg += `<path d="${path}" fill="${quadrant.backgroundColor}"
stroke="${shield.strokeColor}" stroke-width="${inchesToPixels(shield.strokeWidth)}" />`;
if (quadrant.imageUrl) {
svg += `<image href="${quadrant.imageUrl}" x="${centerX - width * 0.5}"
y="${centerY - height * 0.5}" width="${width}" height="${height}"
preserveAspectRatio="xMidYMid meet" />`;
}
} else {
// Multi-quadrant shield
const quadWidth = width / 2;
const quadHeight = height / 2;
for (let q = 0; q < shield.quadrants; q++) {
const row = Math.floor(q / 2);
const col = q % 2;
const x = centerX - width / 2 + col * quadWidth;
const y = centerY - height / 2 + row * quadHeight;
const quadrant = shield.quadrantsData[q];
svg += `<rect x="${x}" y="${y}" width="${quadWidth}" height="${quadHeight}"
fill="${quadrant.backgroundColor}"
stroke="${shield.strokeColor}" stroke-width="${inchesToPixels(shield.strokeWidth)}" />`;
if (quadrant.imageUrl) {
svg += `<image href="${quadrant.imageUrl}" x="${x}" y="${y}"
width="${quadWidth}" height="${quadHeight}"
preserveAspectRatio="xMidYMid meet" />`;
}
}
}
return svg;
}
// Generate arc text SVG
export function generateArcText(
arcText: ArcText,
badgeRadius: number
): string {
const centerX = badgeRadius;
const centerY = badgeRadius;
const textRadius = badgeRadius * (arcText.position === 'top' ? 0.7 : 0.7);
const startAngle = arcText.position === 'top'
? Math.PI + (arcText.curvature / 100) * Math.PI / 2
: 0 - (arcText.curvature / 100) * Math.PI / 2;
const endAngle = arcText.position === 'top'
? 0 - (arcText.curvature / 100) * Math.PI / 2
: Math.PI + (arcText.curvature / 100) * Math.PI / 2;
const path = generateArcPath(textRadius, startAngle, endAngle, centerX, centerY);
return `<defs>
<path id="arc-text-${arcText.id}" d="${path}" />
</defs>
<text font-family="${arcText.fontFamily}" font-size="${arcText.fontSize}"
fill="${arcText.color}">
<textPath href="#arc-text-${arcText.id}" startOffset="${arcText.alignment === 'center' ? '50%' : arcText.alignment === 'right' ? '100%' : '0%'}"
text-anchor="${arcText.alignment === 'center' ? 'middle' : arcText.alignment === 'right' ? 'end' : 'start'}">
${arcText.text}
</textPath>
</text>`;
}
// Generate ribbon text SVG
export function generateRibbonText(
ribbonText: RibbonText,
badgeRadius: number
): string {
const x = inchesToPixels(ribbonText.x) + badgeRadius;
const y = inchesToPixels(ribbonText.y) + badgeRadius;
return `<text x="${x}" y="${y}" font-family="${ribbonText.fontFamily}"
font-size="${ribbonText.fontSize}" fill="${ribbonText.color}"
text-anchor="middle">${ribbonText.text}</text>`;
}
// Generate complete badge SVG
export function generateBadgeSVG(
geometry: BadgeGeometry,
star: StarConfig,
shield: ShieldConfig,
arcTexts: ArcText[],
ribbonTexts: RibbonText[]
): string {
const diameterPx = inchesToPixels(geometry.diameter);
const radius = diameterPx / 2;
let svg = `<svg width="${diameterPx}" height="${diameterPx}"
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${diameterPx} ${diameterPx}">`;
// Generate all components
const circle = generateBadgeCircle(geometry);
const starSvg = generateStar(star, radius);
const shieldSvg = generateShield(shield, radius);
const arcTextsSvg = arcTexts.map(at => generateArcText(at, radius)).join('\n');
const ribbonTextsSvg = ribbonTexts.map(rt => generateRibbonText(rt, radius)).join('\n');
// Combine in order (background to foreground)
svg += circle;
svg += shieldSvg;
svg += starSvg;
svg += arcTextsSvg;
svg += ribbonTextsSvg;
svg += '</svg>';
return svg;
}

78
tailwind.config.js Normal file
View File

@@ -0,0 +1,78 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: ["class"],
content: [
'./pages/**/*.{ts,tsx}',
'./components/**/*.{ts,tsx}',
'./app/**/*.{ts,tsx}',
'./src/**/*.{ts,tsx}',
],
prefix: "",
theme: {
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
keyframes: {
"accordion-down": {
from: { height: "0" },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
},
},
plugins: [require("tailwindcss-animate")],
}

32
tsconfig.json Normal file
View File

@@ -0,0 +1,32 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
/* Path aliases */
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

12
tsconfig.node.json Normal file
View File

@@ -0,0 +1,12 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

14
vite.config.ts Normal file
View File

@@ -0,0 +1,14 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
})