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:
19
.eslintrc.cjs
Normal file
19
.eslintrc.cjs
Normal 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
25
.gitignore
vendored
Normal 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
104
README.md
@@ -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
14
index.html
Normal 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
51
package.json
Normal 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
3793
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
7
postcss.config.js
Normal file
7
postcss.config.js
Normal file
@@ -0,0 +1,7 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
||||
115
src/App.tsx
Normal file
115
src/App.tsx
Normal 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;
|
||||
|
||||
122
src/components/BadgeCanvas.tsx
Normal file
122
src/components/BadgeCanvas.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
160
src/components/ExportModal.tsx
Normal file
160
src/components/ExportModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
63
src/components/HistoryTimeline.tsx
Normal file
63
src/components/HistoryTimeline.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
125
src/components/LayerManager.tsx
Normal file
125
src/components/LayerManager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
101
src/components/controls/GeometryControls.tsx
Normal file
101
src/components/controls/GeometryControls.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
116
src/components/controls/ShieldControls.tsx
Normal file
116
src/components/controls/ShieldControls.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
123
src/components/controls/StarControls.tsx
Normal file
123
src/components/controls/StarControls.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
240
src/components/controls/TextControls.tsx
Normal file
240
src/components/controls/TextControls.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
56
src/components/ui/accordion.tsx
Normal file
56
src/components/ui/accordion.tsx
Normal 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 }
|
||||
|
||||
54
src/components/ui/button.tsx
Normal file
54
src/components/ui/button.tsx
Normal 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 }
|
||||
|
||||
117
src/components/ui/dialog.tsx
Normal file
117
src/components/ui/dialog.tsx
Normal 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,
|
||||
}
|
||||
|
||||
193
src/components/ui/dropdown-menu.tsx
Normal file
193
src/components/ui/dropdown-menu.tsx
Normal 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,
|
||||
}
|
||||
|
||||
25
src/components/ui/input.tsx
Normal file
25
src/components/ui/input.tsx
Normal 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 }
|
||||
|
||||
24
src/components/ui/label.tsx
Normal file
24
src/components/ui/label.tsx
Normal 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 }
|
||||
|
||||
42
src/components/ui/radio-group.tsx
Normal file
42
src/components/ui/radio-group.tsx
Normal 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 }
|
||||
|
||||
46
src/components/ui/scroll-area.tsx
Normal file
46
src/components/ui/scroll-area.tsx
Normal 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 }
|
||||
|
||||
156
src/components/ui/select.tsx
Normal file
156
src/components/ui/select.tsx
Normal 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,
|
||||
}
|
||||
|
||||
26
src/components/ui/slider.tsx
Normal file
26
src/components/ui/slider.tsx
Normal 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 }
|
||||
|
||||
27
src/components/ui/switch.tsx
Normal file
27
src/components/ui/switch.tsx
Normal 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 }
|
||||
|
||||
53
src/components/ui/tabs.tsx
Normal file
53
src/components/ui/tabs.tsx
Normal 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
60
src/index.css
Normal 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
7
src/lib/utils.ts
Normal 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
11
src/main.tsx
Normal 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
332
src/stores/badgeStore.ts
Normal 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
81
src/types/badge.ts
Normal 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
230
src/utils/svgGenerators.ts
Normal 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
78
tailwind.config.js
Normal 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
32
tsconfig.json
Normal 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
12
tsconfig.node.json
Normal 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
14
vite.config.ts
Normal 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'),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user