Initial commit - Sitemap Builder app
This commit is contained in:
134
src/components/editor/RichTextEditor.tsx
Normal file
134
src/components/editor/RichTextEditor.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
"use client";
|
||||
|
||||
import { useEditor, EditorContent } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import { useEffect } from "react";
|
||||
|
||||
interface RichTextEditorProps {
|
||||
content: string;
|
||||
onChange: (content: string) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export default function RichTextEditor({
|
||||
content,
|
||||
onChange,
|
||||
placeholder,
|
||||
}: RichTextEditorProps) {
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit,
|
||||
Placeholder.configure({
|
||||
placeholder: placeholder || "Zacznij pisać...",
|
||||
emptyEditorClass: "is-editor-empty",
|
||||
}),
|
||||
],
|
||||
content,
|
||||
immediatelyRender: false,
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class:
|
||||
"prose prose-invert prose-sm max-w-none min-h-[150px] p-4 focus:outline-none",
|
||||
},
|
||||
},
|
||||
onUpdate: ({ editor }) => {
|
||||
onChange(editor.getHTML());
|
||||
},
|
||||
});
|
||||
|
||||
// Sync content from props
|
||||
useEffect(() => {
|
||||
if (editor && content !== editor.getHTML()) {
|
||||
editor.commands.setContent(content);
|
||||
}
|
||||
}, [content, editor]);
|
||||
|
||||
if (!editor) {
|
||||
return (
|
||||
<div className="min-h-[150px] bg-slate-700/50 border border-slate-600 rounded-lg animate-pulse" />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-slate-700/50 border border-slate-600 rounded-lg overflow-hidden">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-1 p-2 border-b border-slate-600 bg-slate-700/30">
|
||||
<button
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
className={`p-1.5 rounded hover:bg-slate-600 transition-colors ${editor.isActive("bold") ? "bg-slate-600 text-white" : "text-slate-400"
|
||||
}`}
|
||||
title="Pogrubienie"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 12h8a4 4 0 100-8H6v8zm0 0h9a4 4 0 110 8H6v-8z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||
className={`p-1.5 rounded hover:bg-slate-600 transition-colors ${editor.isActive("italic") ? "bg-slate-600 text-white" : "text-slate-400"
|
||||
}`}
|
||||
title="Kursywa"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 20l4-16m4 4l-4 4 4 4M6 16l4-4-4-4" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => editor.chain().focus().toggleStrike().run()}
|
||||
className={`p-1.5 rounded hover:bg-slate-600 transition-colors ${editor.isActive("strike") ? "bg-slate-600 text-white" : "text-slate-400"
|
||||
}`}
|
||||
title="Przekreślenie"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 18c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className="w-px h-5 bg-slate-600 mx-1" />
|
||||
<button
|
||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||
className={`p-1.5 rounded hover:bg-slate-600 transition-colors ${editor.isActive("bulletList") ? "bg-slate-600 text-white" : "text-slate-400"
|
||||
}`}
|
||||
title="Lista punktowana"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||
className={`p-1.5 rounded hover:bg-slate-600 transition-colors ${editor.isActive("orderedList") ? "bg-slate-600 text-white" : "text-slate-400"
|
||||
}`}
|
||||
title="Lista numerowana"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className="w-px h-5 bg-slate-600 mx-1" />
|
||||
<button
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||
className={`p-1.5 rounded hover:bg-slate-600 transition-colors ${editor.isActive("heading", { level: 2 }) ? "bg-slate-600 text-white" : "text-slate-400"
|
||||
}`}
|
||||
title="Nagłówek"
|
||||
>
|
||||
<span className="text-sm font-bold">H2</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
|
||||
className={`p-1.5 rounded hover:bg-slate-600 transition-colors ${editor.isActive("heading", { level: 3 }) ? "bg-slate-600 text-white" : "text-slate-400"
|
||||
}`}
|
||||
title="Podtytuł"
|
||||
>
|
||||
<span className="text-sm font-bold">H3</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Editor */}
|
||||
<EditorContent
|
||||
editor={editor}
|
||||
className="text-white [&_.is-editor-empty:first-child::before]:text-slate-500 [&_.is-editor-empty:first-child::before]:content-[attr(data-placeholder)] [&_.is-editor-empty:first-child::before]:float-left [&_.is-editor-empty:first-child::before]:h-0 [&_.is-editor-empty:first-child::before]:pointer-events-none"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
149
src/components/flow/CustomNode.tsx
Normal file
149
src/components/flow/CustomNode.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useMemo } from "react";
|
||||
import { Handle, Position, NodeProps } from "@xyflow/react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
export interface PageNodeData {
|
||||
title: string;
|
||||
status: "draft" | "ready" | "review";
|
||||
content?: string;
|
||||
notes?: string;
|
||||
hasAttachments?: boolean;
|
||||
hasLinks?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const statusConfig = {
|
||||
draft: {
|
||||
label: "Szkic",
|
||||
className: "bg-yellow-500/20 text-yellow-400 border-yellow-500/30",
|
||||
},
|
||||
ready: {
|
||||
label: "Gotowe",
|
||||
className: "bg-green-500/20 text-green-400 border-green-500/30",
|
||||
},
|
||||
review: {
|
||||
label: "Do poprawki",
|
||||
className: "bg-red-500/20 text-red-400 border-red-500/30",
|
||||
},
|
||||
};
|
||||
|
||||
// Different styles for node types
|
||||
const nodeTypeConfig = {
|
||||
page: {
|
||||
bgClass: "bg-slate-800/90",
|
||||
borderClass: "border-slate-600",
|
||||
selectedBorderClass: "border-blue-500",
|
||||
accentColor: "blue",
|
||||
icon: (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
),
|
||||
label: "Strona",
|
||||
},
|
||||
note: {
|
||||
bgClass: "bg-amber-900/40",
|
||||
borderClass: "border-amber-600/50",
|
||||
selectedBorderClass: "border-amber-400",
|
||||
accentColor: "amber",
|
||||
icon: (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
),
|
||||
label: "Notatka",
|
||||
},
|
||||
section: {
|
||||
bgClass: "bg-purple-900/40",
|
||||
borderClass: "border-purple-600/50",
|
||||
selectedBorderClass: "border-purple-400",
|
||||
accentColor: "purple",
|
||||
icon: (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
),
|
||||
label: "Sekcja",
|
||||
},
|
||||
};
|
||||
|
||||
function CustomNode({ data, selected, type }: NodeProps) {
|
||||
const nodeData = data as PageNodeData;
|
||||
const status = statusConfig[nodeData.status] || statusConfig.draft;
|
||||
const nodeType = nodeTypeConfig[type as keyof typeof nodeTypeConfig] || nodeTypeConfig.page;
|
||||
|
||||
const hasContent = useMemo(() => {
|
||||
return nodeData.hasAttachments || nodeData.hasLinks;
|
||||
}, [nodeData.hasAttachments, nodeData.hasLinks]);
|
||||
|
||||
const handleColorClass = nodeType.accentColor === "amber"
|
||||
? "!bg-amber-500"
|
||||
: nodeType.accentColor === "purple"
|
||||
? "!bg-purple-500"
|
||||
: "!bg-blue-500";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
${nodeType.bgClass} backdrop-blur-sm border-2 rounded-xl shadow-xl
|
||||
min-w-[180px] max-w-[240px] transition-all duration-200
|
||||
${selected ? `${nodeType.selectedBorderClass} shadow-${nodeType.accentColor}-500/25` : `${nodeType.borderClass} hover:border-opacity-80`}
|
||||
`}
|
||||
>
|
||||
{/* Top Handle */}
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
className={`!w-3 !h-3 ${handleColorClass} !border-2 !border-slate-800`}
|
||||
/>
|
||||
|
||||
{/* Type indicator */}
|
||||
<div className={`px-3 py-1.5 border-b border-white/10 flex items-center gap-2 text-${nodeType.accentColor}-400`}>
|
||||
{nodeType.icon}
|
||||
<span className="text-xs font-medium opacity-70">{nodeType.label}</span>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4">
|
||||
<div className="flex items-start justify-between gap-2 mb-2">
|
||||
<h3 className="font-semibold text-white text-sm leading-tight line-clamp-2">
|
||||
{nodeData.title || "Nowa strona"}
|
||||
</h3>
|
||||
{hasContent && (
|
||||
<div className="flex-shrink-0">
|
||||
<svg
|
||||
className={`w-4 h-4 text-${nodeType.accentColor}-400`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Badge variant="outline" className={`text-xs ${status.className}`}>
|
||||
{status.label}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Bottom Handle */}
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
className={`!w-3 !h-3 ${handleColorClass} !border-2 !border-slate-800`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(CustomNode);
|
||||
|
||||
261
src/components/flow/FlowCanvas.tsx
Normal file
261
src/components/flow/FlowCanvas.tsx
Normal file
@@ -0,0 +1,261 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
useCallback,
|
||||
useRef,
|
||||
useState,
|
||||
DragEvent,
|
||||
} from "react";
|
||||
import {
|
||||
ReactFlow,
|
||||
Background,
|
||||
Controls,
|
||||
MiniMap,
|
||||
addEdge,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
Connection,
|
||||
Edge,
|
||||
Node,
|
||||
ReactFlowProvider,
|
||||
useReactFlow,
|
||||
} from "@xyflow/react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
import CustomNode, { PageNodeData } from "./CustomNode";
|
||||
import NodeSidebar from "./NodeSidebar";
|
||||
import NodeDrawer from "./NodeDrawer";
|
||||
|
||||
const nodeTypes = {
|
||||
page: CustomNode,
|
||||
note: CustomNode,
|
||||
section: CustomNode,
|
||||
};
|
||||
|
||||
interface FlowCanvasProps {
|
||||
projectId: string;
|
||||
initialNodes: Node[];
|
||||
initialEdges: Edge[];
|
||||
onSave: (nodes: Node[], edges: Edge[]) => Promise<void>;
|
||||
}
|
||||
|
||||
function FlowCanvasInner({
|
||||
projectId,
|
||||
initialNodes,
|
||||
initialEdges,
|
||||
onSave,
|
||||
}: FlowCanvasProps) {
|
||||
const reactFlowWrapper = useRef<HTMLDivElement>(null);
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
|
||||
const [selectedNode, setSelectedNode] = useState<Node | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const { screenToFlowPosition } = useReactFlow();
|
||||
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Auto-save with debounce
|
||||
const triggerAutoSave = useCallback(() => {
|
||||
if (saveTimeoutRef.current) {
|
||||
clearTimeout(saveTimeoutRef.current);
|
||||
}
|
||||
saveTimeoutRef.current = setTimeout(async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await onSave(nodes, edges);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, 1500);
|
||||
}, [nodes, edges, onSave]);
|
||||
|
||||
// Trigger auto-save when nodes or edges change
|
||||
const handleNodesChange = useCallback(
|
||||
(changes: Parameters<typeof onNodesChange>[0]) => {
|
||||
onNodesChange(changes);
|
||||
triggerAutoSave();
|
||||
},
|
||||
[onNodesChange, triggerAutoSave]
|
||||
);
|
||||
|
||||
const handleEdgesChange = useCallback(
|
||||
(changes: Parameters<typeof onEdgesChange>[0]) => {
|
||||
onEdgesChange(changes);
|
||||
triggerAutoSave();
|
||||
},
|
||||
[onEdgesChange, triggerAutoSave]
|
||||
);
|
||||
|
||||
const onConnect = useCallback(
|
||||
(params: Connection) => {
|
||||
setEdges((eds) => addEdge({ ...params, type: "default" }, eds));
|
||||
triggerAutoSave();
|
||||
},
|
||||
[setEdges, triggerAutoSave]
|
||||
);
|
||||
|
||||
const onDragOver = useCallback((event: DragEvent) => {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = "move";
|
||||
}, []);
|
||||
|
||||
const onDrop = useCallback(
|
||||
(event: DragEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
const type = event.dataTransfer.getData("application/reactflow");
|
||||
if (!type) return;
|
||||
|
||||
const position = screenToFlowPosition({
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
});
|
||||
|
||||
const newNode: Node<PageNodeData> = {
|
||||
id: `node-${Date.now()}`,
|
||||
type,
|
||||
position,
|
||||
data: {
|
||||
title: type === "page" ? "Nowa strona" : type === "note" ? "Notatka" : "Sekcja",
|
||||
status: "draft",
|
||||
hasAttachments: false,
|
||||
hasLinks: false,
|
||||
},
|
||||
};
|
||||
|
||||
setNodes((nds) => nds.concat(newNode));
|
||||
triggerAutoSave();
|
||||
},
|
||||
[screenToFlowPosition, setNodes, triggerAutoSave]
|
||||
);
|
||||
|
||||
const onNodeClick = useCallback((event: React.MouseEvent, node: Node) => {
|
||||
event.stopPropagation();
|
||||
setSelectedNode(node);
|
||||
setDrawerOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleNodeUpdate = useCallback(
|
||||
(nodeId: string, data: Partial<PageNodeData>) => {
|
||||
setNodes((nds) =>
|
||||
nds.map((node) =>
|
||||
node.id === nodeId
|
||||
? { ...node, data: { ...node.data, ...data } }
|
||||
: node
|
||||
)
|
||||
);
|
||||
triggerAutoSave();
|
||||
},
|
||||
[setNodes, triggerAutoSave]
|
||||
);
|
||||
|
||||
const handleNodeDelete = useCallback(
|
||||
(nodeId: string) => {
|
||||
setNodes((nds) => nds.filter((node) => node.id !== nodeId));
|
||||
setEdges((eds) =>
|
||||
eds.filter((edge) => edge.source !== nodeId && edge.target !== nodeId)
|
||||
);
|
||||
setDrawerOpen(false);
|
||||
setSelectedNode(null);
|
||||
triggerAutoSave();
|
||||
},
|
||||
[setNodes, setEdges, triggerAutoSave]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-full h-full relative" ref={reactFlowWrapper}>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={handleNodesChange}
|
||||
onEdgesChange={handleEdgesChange}
|
||||
onConnect={onConnect}
|
||||
onDrop={onDrop}
|
||||
onDragOver={onDragOver}
|
||||
onNodeClick={onNodeClick}
|
||||
nodeTypes={nodeTypes}
|
||||
fitView
|
||||
className="bg-slate-900"
|
||||
defaultEdgeOptions={{
|
||||
style: { stroke: "#64748b", strokeWidth: 2 },
|
||||
type: "smoothstep",
|
||||
}}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
>
|
||||
<Background color="#334155" gap={20} size={1} />
|
||||
<Controls className="!bg-slate-800 !border-slate-700 !shadow-xl [&>button]:!bg-slate-700 [&>button]:!border-slate-600 [&>button]:!text-slate-300 [&>button:hover]:!bg-slate-600" />
|
||||
<MiniMap
|
||||
className="!bg-slate-800 !border-slate-700"
|
||||
nodeColor="#3b82f6"
|
||||
maskColor="rgba(15, 23, 42, 0.8)"
|
||||
/>
|
||||
</ReactFlow>
|
||||
|
||||
{/* Node Sidebar */}
|
||||
<NodeSidebar
|
||||
isCollapsed={sidebarCollapsed}
|
||||
onToggle={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||
/>
|
||||
|
||||
{/* Save Indicator */}
|
||||
<div className="absolute bottom-4 left-4 z-10">
|
||||
<div
|
||||
className={`
|
||||
px-3 py-1.5 rounded-lg text-xs font-medium transition-all duration-300
|
||||
${isSaving
|
||||
? "bg-blue-500/20 text-blue-400 border border-blue-500/30"
|
||||
: "bg-green-500/20 text-green-400 border border-green-500/30"
|
||||
}
|
||||
`}
|
||||
>
|
||||
{isSaving ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<svg className="animate-spin h-3 w-3" viewBox="0 0 24 24">
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
Zapisywanie...
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-2">
|
||||
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Zapisano
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Node Detail Drawer */}
|
||||
<NodeDrawer
|
||||
open={drawerOpen}
|
||||
onOpenChange={setDrawerOpen}
|
||||
node={selectedNode}
|
||||
projectId={projectId}
|
||||
onUpdate={handleNodeUpdate}
|
||||
onDelete={handleNodeDelete}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function FlowCanvas(props: FlowCanvasProps) {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<FlowCanvasInner {...props} />
|
||||
</ReactFlowProvider>
|
||||
);
|
||||
}
|
||||
436
src/components/flow/NodeDrawer.tsx
Normal file
436
src/components/flow/NodeDrawer.tsx
Normal file
@@ -0,0 +1,436 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Node } from "@xyflow/react";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import RichTextEditor from "@/components/editor/RichTextEditor";
|
||||
import FileDropzone from "@/components/ui/FileDropzone";
|
||||
import { PageNodeData } from "./CustomNode";
|
||||
|
||||
interface Attachment {
|
||||
id: string;
|
||||
filename: string;
|
||||
originalName: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
interface Link {
|
||||
id: string;
|
||||
url: string;
|
||||
label: string | null;
|
||||
}
|
||||
|
||||
interface NodeDrawerProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
node: Node | null;
|
||||
projectId: string;
|
||||
onUpdate: (nodeId: string, data: Partial<PageNodeData>) => void;
|
||||
onDelete: (nodeId: string) => void;
|
||||
}
|
||||
|
||||
const statusOptions = [
|
||||
{ value: "draft", label: "Szkic", color: "bg-yellow-500" },
|
||||
{ value: "ready", label: "Gotowe", color: "bg-green-500" },
|
||||
{ value: "review", label: "Do poprawki", color: "bg-red-500" },
|
||||
] as const;
|
||||
|
||||
export default function NodeDrawer({
|
||||
open,
|
||||
onOpenChange,
|
||||
node,
|
||||
projectId,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
}: NodeDrawerProps) {
|
||||
const [title, setTitle] = useState("");
|
||||
const [content, setContent] = useState("");
|
||||
const [notes, setNotes] = useState("");
|
||||
const [status, setStatus] = useState<"draft" | "ready" | "review">("draft");
|
||||
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||||
const [links, setLinks] = useState<Link[]>([]);
|
||||
const [newLinkUrl, setNewLinkUrl] = useState("");
|
||||
const [newLinkLabel, setNewLinkLabel] = useState("");
|
||||
const [dbNodeId, setDbNodeId] = useState<string | null>(null);
|
||||
|
||||
// Load node data
|
||||
useEffect(() => {
|
||||
if (node && open) {
|
||||
const data = node.data as PageNodeData;
|
||||
setTitle(data.title || "");
|
||||
setContent(data.content || "");
|
||||
setNotes(data.notes || "");
|
||||
setStatus(data.status || "draft");
|
||||
|
||||
// Fetch attachments and links from API
|
||||
fetchNodeDetails(node.id);
|
||||
}
|
||||
}, [node, open]);
|
||||
|
||||
const fetchNodeDetails = async (nodeId: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/nodes/${nodeId}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setDbNodeId(data.id);
|
||||
setAttachments(data.attachments || []);
|
||||
setLinks(data.links || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch node details:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// Save changes with debounce
|
||||
const handleTitleChange = useCallback(
|
||||
(value: string) => {
|
||||
setTitle(value);
|
||||
if (node) {
|
||||
onUpdate(node.id, { title: value });
|
||||
}
|
||||
},
|
||||
[node, onUpdate]
|
||||
);
|
||||
|
||||
const handleContentChange = useCallback(
|
||||
(value: string) => {
|
||||
setContent(value);
|
||||
if (node) {
|
||||
onUpdate(node.id, { content: value });
|
||||
}
|
||||
},
|
||||
[node, onUpdate]
|
||||
);
|
||||
|
||||
const handleNotesChange = useCallback(
|
||||
(value: string) => {
|
||||
setNotes(value);
|
||||
if (node) {
|
||||
onUpdate(node.id, { notes: value });
|
||||
}
|
||||
},
|
||||
[node, onUpdate]
|
||||
);
|
||||
|
||||
const handleStatusChange = useCallback(
|
||||
(value: "draft" | "ready" | "review") => {
|
||||
setStatus(value);
|
||||
if (node) {
|
||||
onUpdate(node.id, { status: value });
|
||||
}
|
||||
},
|
||||
[node, onUpdate]
|
||||
);
|
||||
|
||||
const handleFileUpload = async (files: File[]) => {
|
||||
if (!dbNodeId) return;
|
||||
|
||||
for (const file of files) {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("nodeId", dbNodeId);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/upload", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const newAttachment = await res.json();
|
||||
setAttachments((prev) => [...prev, newAttachment]);
|
||||
if (node) {
|
||||
onUpdate(node.id, { hasAttachments: true });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to upload file:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteAttachment = async (attachmentId: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/attachments/${attachmentId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setAttachments((prev) => prev.filter((a) => a.id !== attachmentId));
|
||||
if (node && attachments.length <= 1) {
|
||||
onUpdate(node.id, { hasAttachments: false });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to delete attachment:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddLink = async () => {
|
||||
if (!dbNodeId || !newLinkUrl.trim()) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/nodes/${dbNodeId}/links`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
url: newLinkUrl,
|
||||
label: newLinkLabel || null,
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const newLink = await res.json();
|
||||
setLinks((prev) => [...prev, newLink]);
|
||||
setNewLinkUrl("");
|
||||
setNewLinkLabel("");
|
||||
if (node) {
|
||||
onUpdate(node.id, { hasLinks: true });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to add link:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteLink = async (linkId: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/links/${linkId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setLinks((prev) => prev.filter((l) => l.id !== linkId));
|
||||
if (node && links.length <= 1) {
|
||||
onUpdate(node.id, { hasLinks: false });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to delete link:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteNode = () => {
|
||||
if (node) {
|
||||
onDelete(node.id);
|
||||
}
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes < 1024) return bytes + " B";
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + " MB";
|
||||
};
|
||||
|
||||
if (!node) return null;
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="w-full sm:max-w-xl bg-slate-800 border-slate-700 p-0 overflow-hidden">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-6">
|
||||
<SheetHeader className="mb-6">
|
||||
<SheetTitle className="text-white flex items-center justify-between">
|
||||
<span>Edycja węzła</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleDeleteNode}
|
||||
className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</Button>
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
{/* Title */}
|
||||
<div className="space-y-2 mb-6">
|
||||
<Label className="text-slate-300">Tytuł</Label>
|
||||
<Input
|
||||
value={title}
|
||||
onChange={(e) => handleTitleChange(e.target.value)}
|
||||
placeholder="Nazwa strony"
|
||||
className="bg-slate-700/50 border-slate-600 text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div className="space-y-2 mb-6">
|
||||
<Label className="text-slate-300">Status</Label>
|
||||
<div className="flex gap-2">
|
||||
{statusOptions.map((option) => (
|
||||
<Badge
|
||||
key={option.value}
|
||||
variant="outline"
|
||||
onClick={() => handleStatusChange(option.value)}
|
||||
className={`
|
||||
cursor-pointer transition-all
|
||||
${status === option.value
|
||||
? `${option.color} text-white border-transparent`
|
||||
: "bg-slate-700/50 text-slate-400 border-slate-600 hover:border-slate-500"
|
||||
}
|
||||
`}
|
||||
>
|
||||
{option.label}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className="bg-slate-700 my-6" />
|
||||
|
||||
{/* Content */}
|
||||
<div className="space-y-2 mb-6">
|
||||
<Label className="text-slate-300">Treść (od klienta)</Label>
|
||||
<RichTextEditor
|
||||
content={content}
|
||||
onChange={handleContentChange}
|
||||
placeholder="Wklej treści od klienta..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator className="bg-slate-700 my-6" />
|
||||
|
||||
{/* Notes */}
|
||||
<div className="space-y-2 mb-6">
|
||||
<Label className="text-slate-300">Uwagi deweloperskie</Label>
|
||||
<Textarea
|
||||
value={notes}
|
||||
onChange={(e) => handleNotesChange(e.target.value)}
|
||||
placeholder="Notatki techniczne, TODO, etc..."
|
||||
className="bg-slate-700/50 border-slate-600 text-white min-h-[100px] resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator className="bg-slate-700 my-6" />
|
||||
|
||||
{/* Attachments */}
|
||||
<div className="space-y-3 mb-6">
|
||||
<Label className="text-slate-300">Pliki</Label>
|
||||
<FileDropzone onUpload={handleFileUpload} />
|
||||
|
||||
{attachments.length > 0 && (
|
||||
<div className="space-y-2 mt-3">
|
||||
{attachments.map((attachment) => (
|
||||
<div
|
||||
key={attachment.id}
|
||||
className="flex items-center justify-between p-3 bg-slate-700/30 rounded-lg border border-slate-700"
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="w-8 h-8 bg-slate-600 rounded flex items-center justify-center flex-shrink-0">
|
||||
{attachment.mimeType.startsWith("image/") ? (
|
||||
<svg className="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm text-white truncate">{attachment.originalName}</p>
|
||||
<p className="text-xs text-slate-500">{formatFileSize(attachment.size)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteAttachment(attachment.id)}
|
||||
className="text-slate-400 hover:text-red-400 flex-shrink-0"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator className="bg-slate-700 my-6" />
|
||||
|
||||
{/* Links */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-slate-300">Linki do inspiracji</Label>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={newLinkUrl}
|
||||
onChange={(e) => setNewLinkUrl(e.target.value)}
|
||||
placeholder="https://example.com"
|
||||
className="bg-slate-700/50 border-slate-600 text-white flex-1"
|
||||
/>
|
||||
<Input
|
||||
value={newLinkLabel}
|
||||
onChange={(e) => setNewLinkLabel(e.target.value)}
|
||||
placeholder="Etykieta (opcjonalnie)"
|
||||
className="bg-slate-700/50 border-slate-600 text-white w-40"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleAddLink}
|
||||
disabled={!newLinkUrl.trim()}
|
||||
size="sm"
|
||||
className="bg-blue-600 hover:bg-blue-500"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{links.length > 0 && (
|
||||
<div className="space-y-2 mt-3">
|
||||
{links.map((link) => (
|
||||
<div
|
||||
key={link.id}
|
||||
className="flex items-center justify-between p-3 bg-slate-700/30 rounded-lg border border-slate-700"
|
||||
>
|
||||
<a
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 text-blue-400 hover:text-blue-300 truncate"
|
||||
>
|
||||
<svg className="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
<span className="truncate">{link.label || link.url}</span>
|
||||
</a>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteLink(link.id)}
|
||||
className="text-slate-400 hover:text-red-400 flex-shrink-0"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
108
src/components/flow/NodeSidebar.tsx
Normal file
108
src/components/flow/NodeSidebar.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
"use client";
|
||||
|
||||
import { DragEvent } from "react";
|
||||
|
||||
const nodeTypes = [
|
||||
{
|
||||
type: "page",
|
||||
label: "Strona",
|
||||
description: "Podstrona w sitemapie",
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
type: "note",
|
||||
label: "Notatka",
|
||||
description: "Luźna notatka/pomysł",
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
type: "section",
|
||||
label: "Sekcja",
|
||||
description: "Grupowanie stron",
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
interface NodeSidebarProps {
|
||||
isCollapsed?: boolean;
|
||||
onToggle?: () => void;
|
||||
}
|
||||
|
||||
export default function NodeSidebar({ isCollapsed, onToggle }: NodeSidebarProps) {
|
||||
const onDragStart = (event: DragEvent, nodeType: string) => {
|
||||
event.dataTransfer.setData("application/reactflow", nodeType);
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
};
|
||||
|
||||
if (isCollapsed) {
|
||||
return (
|
||||
<div className="absolute left-4 top-4 z-10">
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="p-3 bg-slate-800/90 backdrop-blur-sm border border-slate-700 rounded-xl shadow-xl hover:bg-slate-700/90 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5 text-slate-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="absolute left-4 top-4 z-10 w-56">
|
||||
<div className="bg-slate-800/90 backdrop-blur-sm border border-slate-700 rounded-xl shadow-xl overflow-hidden">
|
||||
<div className="p-3 border-b border-slate-700 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-white">Elementy</h3>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="p-1 hover:bg-slate-700 rounded-lg transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-2 space-y-1">
|
||||
{nodeTypes.map((node) => (
|
||||
<div
|
||||
key={node.type}
|
||||
draggable
|
||||
onDragStart={(e) => onDragStart(e, node.type)}
|
||||
className="p-3 bg-slate-700/50 hover:bg-slate-700 rounded-lg cursor-grab active:cursor-grabbing transition-colors group"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-slate-400 group-hover:text-blue-400 transition-colors">
|
||||
{node.icon}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white">{node.label}</p>
|
||||
<p className="text-xs text-slate-500">{node.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="p-3 border-t border-slate-700">
|
||||
<p className="text-xs text-slate-500 text-center">
|
||||
Przeciągnij element na tablicę
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
346
src/components/flow/ProjectSettingsDrawer.tsx
Normal file
346
src/components/flow/ProjectSettingsDrawer.tsx
Normal file
@@ -0,0 +1,346 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
|
||||
interface ProjectSettings {
|
||||
globalNotes: string | null;
|
||||
fontPrimary: string | null;
|
||||
fontSecondary: string | null;
|
||||
colorPalette: string | null;
|
||||
designNotes: string | null;
|
||||
}
|
||||
|
||||
interface ColorItem {
|
||||
color: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface ProjectSettingsDrawerProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export default function ProjectSettingsDrawer({
|
||||
open,
|
||||
onOpenChange,
|
||||
projectId,
|
||||
}: ProjectSettingsDrawerProps) {
|
||||
const [settings, setSettings] = useState<ProjectSettings>({
|
||||
globalNotes: "",
|
||||
fontPrimary: "",
|
||||
fontSecondary: "",
|
||||
colorPalette: "",
|
||||
designNotes: "",
|
||||
});
|
||||
const [colors, setColors] = useState<ColorItem[]>([]);
|
||||
const [newColor, setNewColor] = useState("#3b82f6");
|
||||
const [newColorLabel, setNewColorLabel] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [copiedColor, setCopiedColor] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && projectId) {
|
||||
fetchSettings();
|
||||
}
|
||||
}, [open, projectId]);
|
||||
|
||||
const fetchSettings = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/projects/${projectId}/settings`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setSettings(data);
|
||||
if (data.colorPalette) {
|
||||
try {
|
||||
const parsed = JSON.parse(data.colorPalette);
|
||||
// Support both old format (string[]) and new format (ColorItem[])
|
||||
if (Array.isArray(parsed)) {
|
||||
if (typeof parsed[0] === "string") {
|
||||
// Old format - convert to new
|
||||
setColors(parsed.map((c: string) => ({ color: c, label: "" })));
|
||||
} else {
|
||||
setColors(parsed);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
setColors([]);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch settings:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const saveSettings = useCallback(
|
||||
async (updates: Partial<ProjectSettings>) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await fetch(`/api/projects/${projectId}/settings`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to save settings:", error);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
},
|
||||
[projectId]
|
||||
);
|
||||
|
||||
const handleAddColor = () => {
|
||||
if (newColor && !colors.find((c) => c.color === newColor)) {
|
||||
const updatedColors = [...colors, { color: newColor, label: newColorLabel }];
|
||||
setColors(updatedColors);
|
||||
saveSettings({ colorPalette: JSON.stringify(updatedColors) });
|
||||
setNewColorLabel("");
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveColor = (colorValue: string) => {
|
||||
const updatedColors = colors.filter((c) => c.color !== colorValue);
|
||||
setColors(updatedColors);
|
||||
saveSettings({ colorPalette: JSON.stringify(updatedColors) });
|
||||
};
|
||||
|
||||
const handleUpdateLabel = (colorValue: string, newLabel: string) => {
|
||||
const updatedColors = colors.map((c) =>
|
||||
c.color === colorValue ? { ...c, label: newLabel } : c
|
||||
);
|
||||
setColors(updatedColors);
|
||||
saveSettings({ colorPalette: JSON.stringify(updatedColors) });
|
||||
};
|
||||
|
||||
const handleCopyColor = async (colorValue: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(colorValue);
|
||||
setCopiedColor(colorValue);
|
||||
setTimeout(() => setCopiedColor(null), 2000);
|
||||
} catch (error) {
|
||||
console.error("Failed to copy:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFieldChange = (field: keyof ProjectSettings, value: string) => {
|
||||
setSettings((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handleFieldBlur = (field: keyof ProjectSettings) => {
|
||||
saveSettings({ [field]: settings[field] });
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="w-full sm:max-w-xl bg-slate-800 border-slate-700 p-0 overflow-hidden">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-6">
|
||||
<SheetHeader className="mb-6">
|
||||
<SheetTitle className="text-white flex items-center gap-2">
|
||||
<svg className="w-5 h-5 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
Ustawienia projektu
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
{/* Save indicator */}
|
||||
{saving && (
|
||||
<div className="mb-4 px-3 py-1.5 bg-blue-500/20 text-blue-400 border border-blue-500/30 rounded-lg text-xs inline-flex items-center gap-2">
|
||||
<svg className="animate-spin h-3 w-3" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
Zapisywanie...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Typography */}
|
||||
<div className="space-y-4 mb-6">
|
||||
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
|
||||
<svg className="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h8m-8 6h16" />
|
||||
</svg>
|
||||
Typografia
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-slate-300">Font główny</Label>
|
||||
<Input
|
||||
value={settings.fontPrimary || ""}
|
||||
onChange={(e) => handleFieldChange("fontPrimary", e.target.value)}
|
||||
onBlur={() => handleFieldBlur("fontPrimary")}
|
||||
placeholder="np. Poppins"
|
||||
className="bg-slate-700/50 border-slate-600 text-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-slate-300">Font pomocniczy</Label>
|
||||
<Input
|
||||
value={settings.fontSecondary || ""}
|
||||
onChange={(e) => handleFieldChange("fontSecondary", e.target.value)}
|
||||
onBlur={() => handleFieldBlur("fontSecondary")}
|
||||
placeholder="np. Open Sans"
|
||||
className="bg-slate-700/50 border-slate-600 text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className="bg-slate-700 my-6" />
|
||||
|
||||
{/* Color Palette */}
|
||||
<div className="space-y-4 mb-6">
|
||||
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
|
||||
<svg className="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
|
||||
</svg>
|
||||
Paleta kolorów
|
||||
<span className="text-xs text-slate-500 font-normal ml-2">
|
||||
(kliknij kolor aby skopiować)
|
||||
</span>
|
||||
</h3>
|
||||
|
||||
{/* Color List */}
|
||||
<div className="space-y-3">
|
||||
{colors.map((item) => (
|
||||
<div
|
||||
key={item.color}
|
||||
className="flex items-center gap-3 p-2 bg-slate-700/30 rounded-lg group"
|
||||
>
|
||||
{/* Color swatch - clickable to copy */}
|
||||
<button
|
||||
onClick={() => handleCopyColor(item.color)}
|
||||
className="relative w-12 h-12 rounded-lg shadow-lg border-2 border-slate-600 hover:border-slate-400 transition-all hover:scale-105 flex-shrink-0"
|
||||
style={{ backgroundColor: item.color }}
|
||||
title="Kliknij aby skopiować"
|
||||
>
|
||||
{copiedColor === item.color && (
|
||||
<div className="absolute inset-0 bg-black/70 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Color info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<Input
|
||||
value={item.label}
|
||||
onChange={(e) => handleUpdateLabel(item.color, e.target.value)}
|
||||
placeholder="np. Kolor główny, Akcent, Tło..."
|
||||
className="bg-slate-700/50 border-slate-600 text-white text-sm h-8 mb-1"
|
||||
/>
|
||||
<code className="text-xs text-slate-400 font-mono">{item.color}</code>
|
||||
</div>
|
||||
|
||||
{/* Remove button */}
|
||||
<button
|
||||
onClick={() => handleRemoveColor(item.color)}
|
||||
className="p-1.5 text-slate-500 hover:text-red-400 hover:bg-red-500/10 rounded transition-colors opacity-0 group-hover:opacity-100"
|
||||
title="Usuń kolor"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Add new color */}
|
||||
<div className="space-y-2 p-3 bg-slate-700/20 rounded-lg border border-dashed border-slate-600">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={newColor}
|
||||
onChange={(e) => setNewColor(e.target.value)}
|
||||
className="w-12 h-10 rounded-lg border border-slate-600 cursor-pointer"
|
||||
/>
|
||||
<Input
|
||||
value={newColor}
|
||||
onChange={(e) => setNewColor(e.target.value)}
|
||||
placeholder="#000000"
|
||||
className="bg-slate-700/50 border-slate-600 text-white w-28"
|
||||
/>
|
||||
<Input
|
||||
value={newColorLabel}
|
||||
onChange={(e) => setNewColorLabel(e.target.value)}
|
||||
placeholder="Opis koloru (opcjonalnie)"
|
||||
className="bg-slate-700/50 border-slate-600 text-white flex-1"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleAddColor}
|
||||
size="sm"
|
||||
className="w-full bg-blue-600 hover:bg-blue-500"
|
||||
>
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Dodaj kolor
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className="bg-slate-700 my-6" />
|
||||
|
||||
{/* Design Notes */}
|
||||
<div className="space-y-4 mb-6">
|
||||
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
|
||||
<svg className="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
Uwagi techniczne (WordPress)
|
||||
</h3>
|
||||
<Textarea
|
||||
value={settings.designNotes || ""}
|
||||
onChange={(e) => handleFieldChange("designNotes", e.target.value)}
|
||||
onBlur={() => handleFieldBlur("designNotes")}
|
||||
placeholder="Wymogi techniczne, pluginy, motyw WP, szczególne funkcjonalności..."
|
||||
className="bg-slate-700/50 border-slate-600 text-white min-h-[120px] resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator className="bg-slate-700 my-6" />
|
||||
|
||||
{/* Global Notes */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
|
||||
<svg className="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
Globalne notatki projektu
|
||||
</h3>
|
||||
<Textarea
|
||||
value={settings.globalNotes || ""}
|
||||
onChange={(e) => handleFieldChange("globalNotes", e.target.value)}
|
||||
onBlur={() => handleFieldBlur("globalNotes")}
|
||||
placeholder="Ogólne informacje o projekcie, terminy, ważne ustalenia..."
|
||||
className="bg-slate-700/50 border-slate-600 text-white min-h-[150px] resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
85
src/components/ui/FileDropzone.tsx
Normal file
85
src/components/ui/FileDropzone.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
import { useDropzone } from "react-dropzone";
|
||||
import { useCallback } from "react";
|
||||
|
||||
interface FileDropzoneProps {
|
||||
onUpload: (files: File[]) => void;
|
||||
accept?: Record<string, string[]>;
|
||||
maxSize?: number;
|
||||
}
|
||||
|
||||
export default function FileDropzone({
|
||||
onUpload,
|
||||
accept,
|
||||
maxSize = 10 * 1024 * 1024, // 10MB default
|
||||
}: FileDropzoneProps) {
|
||||
const onDrop = useCallback(
|
||||
(acceptedFiles: File[]) => {
|
||||
onUpload(acceptedFiles);
|
||||
},
|
||||
[onUpload]
|
||||
);
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive, isDragReject } =
|
||||
useDropzone({
|
||||
onDrop,
|
||||
accept: accept || {
|
||||
"image/*": [".png", ".jpg", ".jpeg", ".gif", ".webp"],
|
||||
"application/pdf": [".pdf"],
|
||||
"application/msword": [".doc"],
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document":
|
||||
[".docx"],
|
||||
"text/plain": [".txt"],
|
||||
},
|
||||
maxSize,
|
||||
multiple: true,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={`
|
||||
border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors
|
||||
${isDragActive && !isDragReject
|
||||
? "border-blue-500 bg-blue-500/10"
|
||||
: isDragReject
|
||||
? "border-red-500 bg-red-500/10"
|
||||
: "border-slate-600 hover:border-slate-500 bg-slate-700/30"
|
||||
}
|
||||
`}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div
|
||||
className={`w-10 h-10 rounded-lg flex items-center justify-center ${isDragActive ? "bg-blue-500/20" : "bg-slate-700"
|
||||
}`}
|
||||
>
|
||||
<svg
|
||||
className={`w-5 h-5 ${isDragActive ? "text-blue-400" : "text-slate-400"}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-slate-300">
|
||||
{isDragActive
|
||||
? "Upuść pliki tutaj..."
|
||||
: "Przeciągnij pliki lub kliknij"}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
PNG, JPG, PDF, DOC do {maxSize / (1024 * 1024)}MB
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
46
src/components/ui/badge.tsx
Normal file
46
src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "span"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
62
src/components/ui/button.tsx
Normal file
62
src/components/ui/button.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
92
src/components/ui/card.tsx
Normal file
92
src/components/ui/card.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
143
src/components/ui/dialog.tsx
Normal file
143
src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background 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 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
257
src/components/ui/dropdown-menu.tsx
Normal file
257
src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground 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 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground 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 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
21
src/components/ui/input.tsx
Normal file
21
src/components/ui/input.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
24
src/components/ui/label.tsx
Normal file
24
src/components/ui/label.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
58
src/components/ui/scroll-area.tsx
Normal file
58
src/components/ui/scroll-area.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none p-px transition-colors select-none",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="bg-border relative flex-1 rounded-full"
|
||||
/>
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
)
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
28
src/components/ui/separator.tsx
Normal file
28
src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
139
src/components/ui/sheet.tsx
Normal file
139
src/components/ui/sheet.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||
}
|
||||
|
||||
function SheetTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||
}
|
||||
|
||||
function SheetClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||
}
|
||||
|
||||
function SheetPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||
}
|
||||
|
||||
function SheetOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||
return (
|
||||
<SheetPrimitive.Overlay
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||
side === "right" &&
|
||||
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
|
||||
side === "left" &&
|
||||
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
|
||||
side === "top" &&
|
||||
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
||||
side === "bottom" &&
|
||||
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn("flex flex-col gap-1.5 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn("text-foreground font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
18
src/components/ui/textarea.tsx
Normal file
18
src/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
Reference in New Issue
Block a user