mirror of
https://github.com/freedbygrace/ActiveDirectoryManager.git
synced 2026-08-21 07:37:32 +00:00
Enhance chart customization options by adding color selection, grid display, tooltip display, animation, and value formatting.
Replit-Commit-Author: Agent Replit-Commit-Session-Id: 705f2157-ef97-4fbd-89e4-8c7f2ecaea90 Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/7ed01c5f-a82d-405a-b728-b2e3d127c60c/52091063-0956-4712-acd3-7bb273cc6f8d.jpg
This commit is contained in:
@@ -1,24 +1,23 @@
|
|||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import {
|
import { useToast } from "@/hooks/use-toast";
|
||||||
Dialog,
|
import {
|
||||||
DialogContent,
|
Dialog,
|
||||||
DialogHeader,
|
DialogContent,
|
||||||
DialogTitle,
|
DialogDescription,
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Slider } from "@/components/ui/slider";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { ColorPicker } from "@/components/ui/color-picker";
|
import { ColorPicker } from "@/components/ui/color-picker";
|
||||||
import { CopyIcon, Share2, CheckIcon, Download } from "lucide-react";
|
|
||||||
import { useToast } from "@/hooks/use-toast";
|
|
||||||
import { DashboardConfig } from "./dashboard-layout";
|
import { DashboardConfig } from "./dashboard-layout";
|
||||||
import { generateSnapshot } from "@/lib/snapshot-utils";
|
import { generateSnapshot, SnapshotBranding, SnapshotData } from "@/lib/snapshot-utils";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
|
||||||
interface ShareDashboardDialogProps {
|
interface ShareDashboardDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -32,149 +31,84 @@ interface ShareDashboardDialogProps {
|
|||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ShareDashboardDialog({
|
export function ShareDashboardDialog({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
dashboard,
|
dashboard,
|
||||||
dataSources
|
dataSources,
|
||||||
}: ShareDashboardDialogProps) {
|
}: ShareDashboardDialogProps) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [shareUrl, setShareUrl] = useState("");
|
const [activeTab, setActiveTab] = useState<string>("branding");
|
||||||
const [copied, setCopied] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [logoUrl, setLogoUrl] = useState("");
|
|
||||||
const [companyName, setCompanyName] = useState("");
|
|
||||||
const [customTitle, setCustomTitle] = useState(dashboard.name);
|
|
||||||
const [description, setDescription] = useState("");
|
|
||||||
const [primaryColor, setPrimaryColor] = useState("#0f172a");
|
|
||||||
const [secondaryColor, setSecondaryColor] = useState("#6366f1");
|
|
||||||
const [showDate, setShowDate] = useState(true);
|
|
||||||
const [showFooter, setShowFooter] = useState(true);
|
|
||||||
const [footerText, setFooterText] = useState("Generated with Active Directory Management Platform");
|
|
||||||
const [expiryDays, setExpiryDays] = useState(7);
|
|
||||||
const [includeData, setIncludeData] = useState(true);
|
|
||||||
const [isSharing, setIsSharing] = useState(false);
|
|
||||||
const [isGenerating, setIsGenerating] = useState(false);
|
const [isGenerating, setIsGenerating] = useState(false);
|
||||||
|
const [snapshotUrl, setSnapshotUrl] = useState<string | null>(null);
|
||||||
|
const [branding, setBranding] = useState<SnapshotBranding>({
|
||||||
|
logoUrl: "",
|
||||||
|
companyName: "",
|
||||||
|
title: dashboard.name || "Dashboard Snapshot",
|
||||||
|
description: "Dashboard snapshot generated from Active Directory Management",
|
||||||
|
primaryColor: "#3f51b5",
|
||||||
|
secondaryColor: "#f5f5f5",
|
||||||
|
showDate: true,
|
||||||
|
showFooter: true,
|
||||||
|
footerText: "© " + new Date().getFullYear() + " - Generated by Active Directory Management",
|
||||||
|
expiresInDays: 30,
|
||||||
|
});
|
||||||
|
|
||||||
// When user uploads a logo
|
// Reset the snapshot URL when the dialog is opened
|
||||||
const handleLogoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
useEffect(() => {
|
||||||
const file = e.target.files?.[0];
|
if (open) {
|
||||||
if (file) {
|
setSnapshotUrl(null);
|
||||||
const reader = new FileReader();
|
setActiveTab("branding");
|
||||||
reader.onloadend = () => {
|
|
||||||
setLogoUrl(reader.result as string);
|
|
||||||
};
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
}
|
}
|
||||||
};
|
}, [open]);
|
||||||
|
|
||||||
// Generate a sharable URL with snapshot data
|
// Update title when dashboard name changes
|
||||||
const handleGenerateShareLink = async () => {
|
useEffect(() => {
|
||||||
setIsSharing(true);
|
if (dashboard.name) {
|
||||||
try {
|
setBranding(prev => ({ ...prev, title: dashboard.name }));
|
||||||
const snapshotData = generateSnapshot({
|
|
||||||
dashboard,
|
|
||||||
dataSources: includeData ? dataSources : [],
|
|
||||||
branding: {
|
|
||||||
logoUrl,
|
|
||||||
companyName,
|
|
||||||
title: customTitle,
|
|
||||||
description,
|
|
||||||
primaryColor,
|
|
||||||
secondaryColor,
|
|
||||||
showDate,
|
|
||||||
showFooter,
|
|
||||||
footerText,
|
|
||||||
expiresInDays: expiryDays
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// In a real app, this would make an API call to store the snapshot
|
|
||||||
// For now, we're simulating it with localStorage
|
|
||||||
const snapshotId = `dash_${Date.now().toString(36)}`;
|
|
||||||
localStorage.setItem(`snapshot_${snapshotId}`, JSON.stringify(snapshotData));
|
|
||||||
|
|
||||||
// Generate share URL
|
|
||||||
const shareUrl = `${window.location.origin}/dashboards/shared/${snapshotId}`;
|
|
||||||
setShareUrl(shareUrl);
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: "Share link generated",
|
|
||||||
description: "The share link for this dashboard has been created.",
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error generating share link:", error);
|
|
||||||
toast({
|
|
||||||
title: "Error",
|
|
||||||
description: "Failed to generate share link. Please try again.",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsSharing(false);
|
|
||||||
}
|
}
|
||||||
|
}, [dashboard.name]);
|
||||||
|
|
||||||
|
const handleBrandingChange = (key: keyof SnapshotBranding, value: any) => {
|
||||||
|
setBranding((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[key]: value,
|
||||||
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
// Copy the URL to clipboard
|
const generateDashboardSnapshot = async () => {
|
||||||
const handleCopyLink = () => {
|
|
||||||
navigator.clipboard.writeText(shareUrl);
|
|
||||||
setCopied(true);
|
|
||||||
toast({
|
|
||||||
title: "Copied!",
|
|
||||||
description: "Share link copied to clipboard.",
|
|
||||||
});
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
setCopied(false);
|
|
||||||
}, 2000);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Generate a PDF or image snapshot for download
|
|
||||||
const handleDownloadSnapshot = async () => {
|
|
||||||
setIsGenerating(true);
|
|
||||||
try {
|
try {
|
||||||
const snapshotData = generateSnapshot({
|
setIsGenerating(true);
|
||||||
|
|
||||||
|
// Here you would normally make an API call to store the snapshot
|
||||||
|
// For now, we'll just simulate it
|
||||||
|
|
||||||
|
const snapshotData: SnapshotData = generateSnapshot({
|
||||||
dashboard,
|
dashboard,
|
||||||
dataSources: includeData ? dataSources : [],
|
dataSources,
|
||||||
branding: {
|
branding,
|
||||||
logoUrl,
|
|
||||||
companyName,
|
|
||||||
title: customTitle,
|
|
||||||
description,
|
|
||||||
primaryColor,
|
|
||||||
secondaryColor,
|
|
||||||
showDate,
|
|
||||||
showFooter,
|
|
||||||
footerText,
|
|
||||||
expiresInDays: 0 // Download doesn't expire
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// In a real implementation, we would generate a PDF or image here
|
// Mock API call - in production this would save to server and return a URL
|
||||||
// For now, we'll just download the JSON data
|
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||||
const dataStr = JSON.stringify(snapshotData, null, 2);
|
|
||||||
const dataBlob = new Blob([dataStr], { type: 'application/json' });
|
|
||||||
const url = URL.createObjectURL(dataBlob);
|
|
||||||
|
|
||||||
const a = document.createElement('a');
|
// Simulate a returned URL
|
||||||
a.href = url;
|
const mockSnapshotId = `snapshot-${Date.now().toString(36)}`;
|
||||||
a.download = `${dashboard.name.replace(/\s+/g, '_')}_snapshot.json`;
|
const url = `${window.location.origin}/snapshot/${mockSnapshotId}`;
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
|
|
||||||
// Clean up
|
setSnapshotUrl(url);
|
||||||
setTimeout(() => {
|
setActiveTab("share");
|
||||||
document.body.removeChild(a);
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}, 100);
|
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: "Snapshot downloaded",
|
title: "Snapshot Generated",
|
||||||
description: "Your dashboard snapshot has been downloaded.",
|
description: "Your dashboard snapshot has been created successfully.",
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error generating snapshot:", error);
|
console.error("Error generating snapshot:", error);
|
||||||
toast({
|
toast({
|
||||||
title: "Error",
|
title: "Error",
|
||||||
description: "Failed to generate snapshot. Please try again.",
|
description: "Failed to generate dashboard snapshot. Please try again.",
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
@@ -182,238 +116,218 @@ export function ShareDashboardDialog({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const copyToClipboard = async () => {
|
||||||
|
if (!snapshotUrl) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(snapshotUrl);
|
||||||
|
toast({
|
||||||
|
title: "Copied to Clipboard",
|
||||||
|
description: "Snapshot URL has been copied to your clipboard.",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error copying to clipboard:", error);
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "Failed to copy URL to clipboard. Please try manually.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-y-auto">
|
<DialogContent className="sm:max-w-[700px] max-h-[80vh] overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Share Dashboard: {dashboard.name}</DialogTitle>
|
<DialogTitle>Share Dashboard: {dashboard.name}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Create a shareable snapshot of this dashboard with custom branding.
|
Create a shareable snapshot of your dashboard with custom branding.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<Tabs defaultValue="link" className="w-full">
|
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||||
<TabsList className="grid w-full grid-cols-2">
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
<TabsTrigger value="link">Share Link</TabsTrigger>
|
<TabsTrigger value="branding">Branding</TabsTrigger>
|
||||||
<TabsTrigger value="branding">Branding Options</TabsTrigger>
|
<TabsTrigger value="share" disabled={!snapshotUrl}>Share</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="link" className="space-y-4 mt-4">
|
{/* Branding Tab */}
|
||||||
<div className="space-y-4">
|
<TabsContent value="branding" className="space-y-4 py-4">
|
||||||
<div className="space-y-2">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<Label>Include data in snapshot</Label>
|
<div className="space-y-4">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="space-y-2">
|
||||||
<Switch
|
<Label htmlFor="companyName">Company Name</Label>
|
||||||
checked={includeData}
|
<Input
|
||||||
onCheckedChange={setIncludeData}
|
id="companyName"
|
||||||
id="include-data"
|
value={branding.companyName}
|
||||||
|
onChange={(e) => handleBrandingChange("companyName", e.target.value)}
|
||||||
|
placeholder="Enter your company name"
|
||||||
/>
|
/>
|
||||||
<Label htmlFor="include-data">
|
|
||||||
{includeData
|
|
||||||
? "Data included (snapshot works offline)"
|
|
||||||
: "Live data only (requires access to API)"}
|
|
||||||
</Label>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
<div className="space-y-2">
|
||||||
<div className="space-y-2">
|
<Label htmlFor="logoUrl">Logo URL</Label>
|
||||||
<Label>Snapshot expiry</Label>
|
<Input
|
||||||
<div className="flex flex-col space-y-2">
|
id="logoUrl"
|
||||||
<div className="flex items-center justify-between">
|
value={branding.logoUrl}
|
||||||
<span>Expires after</span>
|
onChange={(e) => handleBrandingChange("logoUrl", e.target.value)}
|
||||||
<span className="font-medium">
|
placeholder="https://example.com/logo.png"
|
||||||
{expiryDays === 0 ? "Never expires" : `${expiryDays} days`}
|
/>
|
||||||
</span>
|
</div>
|
||||||
</div>
|
|
||||||
<Slider
|
<div className="space-y-2">
|
||||||
value={[expiryDays]}
|
<Label htmlFor="title">Dashboard Title</Label>
|
||||||
min={0}
|
<Input
|
||||||
max={30}
|
id="title"
|
||||||
step={1}
|
value={branding.title}
|
||||||
onValueChange={(value) => setExpiryDays(value[0])}
|
onChange={(e) => handleBrandingChange("title", e.target.value)}
|
||||||
|
placeholder="Dashboard Title"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="description">Description</Label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
value={branding.description}
|
||||||
|
onChange={(e) => handleBrandingChange("description", e.target.value)}
|
||||||
|
placeholder="Brief description of this dashboard"
|
||||||
|
rows={3}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!shareUrl ? (
|
<div className="space-y-4">
|
||||||
<Button
|
<div className="space-y-2">
|
||||||
className="w-full"
|
<Label>Primary Color</Label>
|
||||||
onClick={handleGenerateShareLink}
|
<div className="flex items-center gap-2">
|
||||||
disabled={isSharing}
|
<ColorPicker
|
||||||
>
|
value={branding.primaryColor}
|
||||||
{isSharing ? "Generating..." : "Generate Share Link"}
|
onChange={(color) => handleBrandingChange("primaryColor", color)}
|
||||||
</Button>
|
/>
|
||||||
) : (
|
<Input
|
||||||
<>
|
value={branding.primaryColor}
|
||||||
<div className="flex mt-4 space-x-2">
|
onChange={(e) => handleBrandingChange("primaryColor", e.target.value)}
|
||||||
<Input
|
|
||||||
value={shareUrl}
|
|
||||||
readOnly
|
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
/>
|
/>
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
onClick={handleCopyLink}
|
|
||||||
className="flex-shrink-0"
|
|
||||||
>
|
|
||||||
{copied ? <CheckIcon className="h-4 w-4" /> : <CopyIcon className="h-4 w-4" />}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row gap-2 mt-4">
|
<div className="space-y-2">
|
||||||
<Button
|
<Label>Secondary Color</Label>
|
||||||
variant="outline"
|
<div className="flex items-center gap-2">
|
||||||
|
<ColorPicker
|
||||||
|
value={branding.secondaryColor}
|
||||||
|
onChange={(color) => handleBrandingChange("secondaryColor", color)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={branding.secondaryColor}
|
||||||
|
onChange={(e) => handleBrandingChange("secondaryColor", e.target.value)}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
onClick={handleDownloadSnapshot}
|
/>
|
||||||
disabled={isGenerating}
|
|
||||||
>
|
|
||||||
<Download className="h-4 w-4 mr-2" />
|
|
||||||
{isGenerating ? "Generating..." : "Download Snapshot"}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
className="flex-1"
|
|
||||||
onClick={handleGenerateShareLink}
|
|
||||||
disabled={isSharing}
|
|
||||||
>
|
|
||||||
<Share2 className="h-4 w-4 mr-2" />
|
|
||||||
{isSharing ? "Regenerating..." : "Regenerate Link"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
<div className="space-y-2">
|
||||||
</TabsContent>
|
<Label htmlFor="expiresInDays">Expires In</Label>
|
||||||
|
<Select
|
||||||
<TabsContent value="branding" className="space-y-4 mt-4">
|
value={branding.expiresInDays.toString()}
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
onValueChange={(value) => handleBrandingChange("expiresInDays", parseInt(value))}
|
||||||
<div className="space-y-2">
|
>
|
||||||
<Label htmlFor="company-logo">Company Logo</Label>
|
<SelectTrigger>
|
||||||
<Input
|
<SelectValue placeholder="Select expiration period" />
|
||||||
id="company-logo"
|
</SelectTrigger>
|
||||||
type="file"
|
<SelectContent>
|
||||||
accept="image/*"
|
<SelectItem value="0">Never Expires</SelectItem>
|
||||||
onChange={handleLogoUpload}
|
<SelectItem value="1">1 Day</SelectItem>
|
||||||
/>
|
<SelectItem value="7">7 Days</SelectItem>
|
||||||
{logoUrl && (
|
<SelectItem value="30">30 Days</SelectItem>
|
||||||
<div className="mt-2 border rounded-md p-2 flex justify-center">
|
<SelectItem value="90">90 Days</SelectItem>
|
||||||
<img
|
</SelectContent>
|
||||||
src={logoUrl}
|
</Select>
|
||||||
alt="Company Logo"
|
</div>
|
||||||
className="max-h-20 object-contain"
|
|
||||||
|
<div className="flex items-center space-x-2 pt-4">
|
||||||
|
<Switch
|
||||||
|
id="showDate"
|
||||||
|
checked={branding.showDate}
|
||||||
|
onCheckedChange={(checked) => handleBrandingChange("showDate", checked)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="showDate">Show Date</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Switch
|
||||||
|
id="showFooter"
|
||||||
|
checked={branding.showFooter}
|
||||||
|
onCheckedChange={(checked) => handleBrandingChange("showFooter", checked)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="showFooter">Show Footer</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{branding.showFooter && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="footerText">Footer Text</Label>
|
||||||
|
<Input
|
||||||
|
id="footerText"
|
||||||
|
value={branding.footerText}
|
||||||
|
onChange={(e) => handleBrandingChange("footerText", e.target.value)}
|
||||||
|
placeholder="Footer Text"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="company-name">Company Name</Label>
|
|
||||||
<Input
|
|
||||||
id="company-name"
|
|
||||||
value={companyName}
|
|
||||||
onChange={(e) => setCompanyName(e.target.value)}
|
|
||||||
placeholder="Your Company Name"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="custom-title">Dashboard Title</Label>
|
|
||||||
<Input
|
|
||||||
id="custom-title"
|
|
||||||
value={customTitle}
|
|
||||||
onChange={(e) => setCustomTitle(e.target.value)}
|
|
||||||
placeholder="Dashboard Title"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="description">Dashboard Description</Label>
|
|
||||||
<Textarea
|
|
||||||
id="description"
|
|
||||||
value={description}
|
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
|
||||||
placeholder="Add a description for this dashboard"
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2 mt-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Primary Color</Label>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<div
|
|
||||||
className="w-8 h-8 rounded-md border"
|
|
||||||
style={{ backgroundColor: primaryColor }}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
value={primaryColor}
|
|
||||||
onChange={(e) => setPrimaryColor(e.target.value)}
|
|
||||||
placeholder="#000000"
|
|
||||||
className="flex-1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Secondary Color</Label>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<div
|
|
||||||
className="w-8 h-8 rounded-md border"
|
|
||||||
style={{ backgroundColor: secondaryColor }}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
value={secondaryColor}
|
|
||||||
onChange={(e) => setSecondaryColor(e.target.value)}
|
|
||||||
placeholder="#000000"
|
|
||||||
className="flex-1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4 mt-4">
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Switch
|
|
||||||
checked={showDate}
|
|
||||||
onCheckedChange={setShowDate}
|
|
||||||
id="show-date"
|
|
||||||
/>
|
|
||||||
<Label htmlFor="show-date">Show date in snapshot</Label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Switch
|
|
||||||
checked={showFooter}
|
|
||||||
onCheckedChange={setShowFooter}
|
|
||||||
id="show-footer"
|
|
||||||
/>
|
|
||||||
<Label htmlFor="show-footer">Show footer in snapshot</Label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showFooter && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="footer-text">Footer Text</Label>
|
|
||||||
<Input
|
|
||||||
id="footer-text"
|
|
||||||
value={footerText}
|
|
||||||
onChange={(e) => setFooterText(e.target.value)}
|
|
||||||
placeholder="Footer text"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Share Tab */}
|
||||||
|
<TabsContent value="share" className="space-y-4 py-4">
|
||||||
|
{snapshotUrl && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="p-4 border rounded-md bg-secondary">
|
||||||
|
<p className="font-medium mb-2">Snapshot URL:</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input value={snapshotUrl} readOnly />
|
||||||
|
<Button onClick={copyToClipboard} className="shrink-0">
|
||||||
|
Copy
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Share this URL to give access to this dashboard snapshot.
|
||||||
|
{branding.expiresInDays > 0
|
||||||
|
? ` This link will expire in ${branding.expiresInDays} days.`
|
||||||
|
: " This link will never expire."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
<DialogFooter className="flex flex-col-reverse sm:flex-row sm:justify-between sm:space-x-2">
|
<DialogFooter>
|
||||||
<Button
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
variant="outline"
|
Cancel
|
||||||
onClick={() => onOpenChange(false)}
|
|
||||||
>
|
|
||||||
Close
|
|
||||||
</Button>
|
</Button>
|
||||||
|
{activeTab === "branding" ? (
|
||||||
|
<Button
|
||||||
|
onClick={generateDashboardSnapshot}
|
||||||
|
disabled={isGenerating}
|
||||||
|
>
|
||||||
|
{isGenerating ? "Generating..." : "Generate Snapshot"}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setActiveTab("branding");
|
||||||
|
setSnapshotUrl(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Create New Snapshot
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -61,6 +61,15 @@ const widgetSchema = z.object({
|
|||||||
showLegend: z.boolean().default(true),
|
showLegend: z.boolean().default(true),
|
||||||
stacked: z.boolean().default(false),
|
stacked: z.boolean().default(false),
|
||||||
precision: z.number().min(0).max(10).default(2),
|
precision: z.number().min(0).max(10).default(2),
|
||||||
|
// New chart options
|
||||||
|
colors: z.array(z.string()).optional(),
|
||||||
|
showGrid: z.boolean().default(true),
|
||||||
|
showTooltip: z.boolean().default(true),
|
||||||
|
enableAnimation: z.boolean().default(true),
|
||||||
|
valueFormatter: z.enum(["none", "number", "percent", "currency"]).default("none"),
|
||||||
|
currencySymbol: z.string().default("$"),
|
||||||
|
minValue: z.number().nullable().default(null),
|
||||||
|
maxValue: z.number().nullable().default(null),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -100,6 +109,15 @@ export function WidgetEditor({
|
|||||||
showLegend: editWidget?.config?.showLegend ?? true,
|
showLegend: editWidget?.config?.showLegend ?? true,
|
||||||
stacked: editWidget?.config?.stacked ?? false,
|
stacked: editWidget?.config?.stacked ?? false,
|
||||||
precision: editWidget?.config?.precision ?? 2,
|
precision: editWidget?.config?.precision ?? 2,
|
||||||
|
// New chart options with defaults
|
||||||
|
colors: editWidget?.config?.colors || [],
|
||||||
|
showGrid: editWidget?.config?.showGrid ?? true,
|
||||||
|
showTooltip: editWidget?.config?.showTooltip ?? true,
|
||||||
|
enableAnimation: editWidget?.config?.enableAnimation ?? true,
|
||||||
|
valueFormatter: editWidget?.config?.valueFormatter || "none",
|
||||||
|
currencySymbol: editWidget?.config?.currencySymbol || "$",
|
||||||
|
minValue: editWidget?.config?.minValue ?? null,
|
||||||
|
maxValue: editWidget?.config?.maxValue ?? null,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -406,89 +424,261 @@ export function WidgetEditor({
|
|||||||
<AccordionTrigger>Chart Options</AccordionTrigger>
|
<AccordionTrigger>Chart Options</AccordionTrigger>
|
||||||
<AccordionContent>
|
<AccordionContent>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
{/* Data Configuration section */}
|
||||||
{["bar", "line", "area"].includes(watchType) && (
|
{["bar", "line", "area"].includes(watchType) && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-4">
|
||||||
<FormField
|
<h3 className="text-sm font-medium">Data Configuration</h3>
|
||||||
control={form.control}
|
<div className="space-y-2 border-l-2 pl-3">
|
||||||
name="config.xAxis"
|
<FormField
|
||||||
render={({ field }) => (
|
control={form.control}
|
||||||
<FormItem>
|
name="config.xAxis"
|
||||||
<FormLabel>X-Axis Field</FormLabel>
|
render={({ field }) => (
|
||||||
<Select
|
<FormItem>
|
||||||
value={field.value}
|
<FormLabel>X-Axis Field</FormLabel>
|
||||||
onValueChange={field.onChange}
|
<Select
|
||||||
>
|
value={field.value}
|
||||||
<FormControl>
|
onValueChange={field.onChange}
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select X-Axis field" />
|
|
||||||
</SelectTrigger>
|
|
||||||
</FormControl>
|
|
||||||
<SelectContent>
|
|
||||||
{(form.getValues('config.dimensions') || []).map((dimension) => (
|
|
||||||
<SelectItem key={dimension} value={dimension}>
|
|
||||||
{dimension}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<FormDescription>
|
|
||||||
Choose a dimension field for the X-Axis
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<FormLabel>Y-Axis Fields</FormLabel>
|
|
||||||
<div className="space-y-2 mt-2">
|
|
||||||
{(form.getValues('config.metrics') || []).map((metric) => (
|
|
||||||
<div key={metric} className="flex items-center space-x-2">
|
|
||||||
<Checkbox
|
|
||||||
checked={(form.getValues('config.yAxis') || []).includes(metric)}
|
|
||||||
onCheckedChange={(checked) => {
|
|
||||||
if (checked) {
|
|
||||||
toggleAsYAxis(metric);
|
|
||||||
} else {
|
|
||||||
toggleAsYAxis(metric);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
id={`y-axis-${metric}`}
|
|
||||||
/>
|
|
||||||
<label
|
|
||||||
htmlFor={`y-axis-${metric}`}
|
|
||||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
|
||||||
>
|
>
|
||||||
{metric}
|
<FormControl>
|
||||||
</label>
|
<SelectTrigger>
|
||||||
</div>
|
<SelectValue placeholder="Select X-Axis field" />
|
||||||
))}
|
</SelectTrigger>
|
||||||
</div>
|
</FormControl>
|
||||||
<FormDescription>
|
<SelectContent>
|
||||||
Select metric fields to display on the Y-Axis
|
{(form.getValues('config.dimensions') || []).map((dimension) => (
|
||||||
</FormDescription>
|
<SelectItem key={dimension} value={dimension}>
|
||||||
</div>
|
{dimension}
|
||||||
|
</SelectItem>
|
||||||
<FormField
|
))}
|
||||||
control={form.control}
|
</SelectContent>
|
||||||
name="config.stacked"
|
</Select>
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex flex-row items-center justify-between">
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<FormLabel className="text-sm">Stacked Chart</FormLabel>
|
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
Stack the values on top of each other
|
Choose a dimension field for the X-Axis
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
</div>
|
<FormMessage />
|
||||||
<FormControl>
|
</FormItem>
|
||||||
<Switch
|
)}
|
||||||
checked={field.value}
|
/>
|
||||||
onCheckedChange={field.onChange}
|
|
||||||
/>
|
<div>
|
||||||
</FormControl>
|
<FormLabel>Y-Axis Fields</FormLabel>
|
||||||
</FormItem>
|
<div className="space-y-2 mt-2">
|
||||||
|
{(form.getValues('config.metrics') || []).map((metric) => (
|
||||||
|
<div key={metric} className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
checked={(form.getValues('config.yAxis') || []).includes(metric)}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
if (checked) {
|
||||||
|
toggleAsYAxis(metric);
|
||||||
|
} else {
|
||||||
|
toggleAsYAxis(metric);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
id={`y-axis-${metric}`}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor={`y-axis-${metric}`}
|
||||||
|
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
|
>
|
||||||
|
{metric}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<FormDescription>
|
||||||
|
Select metric fields to display on the Y-Axis
|
||||||
|
</FormDescription>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="config.stacked"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-row items-center justify-between">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<FormLabel className="text-sm">Stacked Chart</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Stack the values on top of each other
|
||||||
|
</FormDescription>
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Display Options section */}
|
||||||
|
<h3 className="text-sm font-medium">Display Options</h3>
|
||||||
|
<div className="space-y-4 border-l-2 pl-3">
|
||||||
|
{/* Show/Hide Grid Lines */}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="config.showGrid"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-row items-center justify-between">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<FormLabel className="text-sm">Show Grid Lines</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Display grid lines in the chart background
|
||||||
|
</FormDescription>
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Show/Hide Tooltip */}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="config.showTooltip"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-row items-center justify-between">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<FormLabel className="text-sm">Show Tooltip</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Display tooltip when hovering over data points
|
||||||
|
</FormDescription>
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Enable Animation */}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="config.enableAnimation"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-row items-center justify-between">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<FormLabel className="text-sm">Enable Animation</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Animate chart when loading data
|
||||||
|
</FormDescription>
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Value Formatter */}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="config.valueFormatter"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Value Format</FormLabel>
|
||||||
|
<Select
|
||||||
|
value={field.value}
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select value format" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">No formatting</SelectItem>
|
||||||
|
<SelectItem value="number">Number with commas</SelectItem>
|
||||||
|
<SelectItem value="percent">Percentage</SelectItem>
|
||||||
|
<SelectItem value="currency">Currency</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormDescription>
|
||||||
|
Format for displaying values in the chart
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Currency Symbol (only if valueFormatter is 'currency') */}
|
||||||
|
{form.watch('config.valueFormatter') === 'currency' && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="config.currencySymbol"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Currency Symbol</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="$" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Symbol to display before values (e.g., $, €, £)
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
</div>
|
||||||
|
|
||||||
|
{/* Axis Limits */}
|
||||||
|
<h3 className="text-sm font-medium">Axis Limits</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-3 border-l-2 pl-3">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="config.minValue"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Min Value</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="Auto"
|
||||||
|
value={field.value !== null ? field.value : ''}
|
||||||
|
onChange={(e) => field.onChange(e.target.value === '' ? null : Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Minimum value for Y-axis (optional)
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="config.maxValue"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Max Value</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="Auto"
|
||||||
|
value={field.value !== null ? field.value : ''}
|
||||||
|
onChange={(e) => field.onChange(e.target.value === '' ? null : Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Maximum value for Y-axis (optional)
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import {
|
||||||
import { Button } from "@/components/ui/button";
|
Popover,
|
||||||
import { Input } from "@/components/ui/input";
|
PopoverContent,
|
||||||
import { Check, Droplet } from "lucide-react";
|
PopoverTrigger
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
|
||||||
interface ColorPickerProps {
|
interface ColorPickerProps {
|
||||||
value: string;
|
value: string;
|
||||||
@@ -10,107 +11,69 @@ interface ColorPickerProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PRESET_COLORS = [
|
|
||||||
"#000000", "#ffffff", "#f44336", "#e91e63", "#9c27b0", "#673ab7",
|
|
||||||
"#3f51b5", "#2196f3", "#03a9f4", "#00bcd4", "#009688", "#4caf50",
|
|
||||||
"#8bc34a", "#cddc39", "#ffeb3b", "#ffc107", "#ff9800", "#ff5722",
|
|
||||||
"#795548", "#607d8b", "#9e9e9e", "#f5f5f5", "#1a237e", "#004d40",
|
|
||||||
];
|
|
||||||
|
|
||||||
export function ColorPicker({ value, onChange, className = "" }: ColorPickerProps) {
|
export function ColorPicker({ value, onChange, className = "" }: ColorPickerProps) {
|
||||||
const [color, setColor] = useState(value || "#000000");
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [currentColor, setCurrentColor] = useState(value || "#000000");
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setColor(value);
|
setCurrentColor(value);
|
||||||
}, [value]);
|
}, [value]);
|
||||||
|
|
||||||
const handleChangeComplete = (newColor: string) => {
|
const handleColorChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setColor(newColor);
|
const newColor = e.target.value;
|
||||||
|
setCurrentColor(newColor);
|
||||||
onChange(newColor);
|
onChange(newColor);
|
||||||
};
|
};
|
||||||
|
|
||||||
const isValidColor = (color: string) => {
|
const presetColors = [
|
||||||
const s = new Option().style;
|
"#1E40AF", // Blue
|
||||||
s.color = color;
|
"#047857", // Green
|
||||||
return s.color !== '';
|
"#B91C1C", // Red
|
||||||
};
|
"#C2410C", // Orange
|
||||||
|
"#7E22CE", // Purple
|
||||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
"#0369A1", // Sky Blue
|
||||||
const newColor = e.target.value;
|
"#4D7C0F", // Lime
|
||||||
setColor(newColor);
|
"#A16207", // Amber
|
||||||
|
"#0F172A", // Slate
|
||||||
// Validate color format before updating
|
"#64748B", // Gray
|
||||||
if (isValidColor(newColor)) {
|
];
|
||||||
onChange(newColor);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<button
|
||||||
variant="outline"
|
type="button"
|
||||||
className={`w-10 h-10 p-0 border-2 ${className}`}
|
className={`w-9 h-9 rounded-md border border-input focus:outline-none focus:ring-2 focus:ring-ring ${className}`}
|
||||||
style={{ backgroundColor: color }}
|
style={{ backgroundColor: currentColor }}
|
||||||
|
onClick={() => setIsOpen(true)}
|
||||||
aria-label="Pick a color"
|
aria-label="Pick a color"
|
||||||
>
|
/>
|
||||||
<span className="sr-only">Pick a color</span>
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-64">
|
<PopoverContent className="w-64 p-3">
|
||||||
<div className="space-y-2">
|
<div className="space-y-3">
|
||||||
<div
|
<div>
|
||||||
className="w-full h-8 rounded cursor-pointer border"
|
<input
|
||||||
style={{ backgroundColor: color }}
|
|
||||||
onClick={() => inputRef.current?.click()}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Input
|
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
value={color}
|
type="color"
|
||||||
onChange={handleInputChange}
|
value={currentColor}
|
||||||
className="flex-1"
|
onChange={handleColorChange}
|
||||||
|
className="w-full h-8 cursor-pointer"
|
||||||
/>
|
/>
|
||||||
<Button
|
|
||||||
size="icon"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
if (typeof window !== 'undefined' && 'EyeDropper' in window) {
|
|
||||||
// @ts-ignore
|
|
||||||
const eyeDropper = new window.EyeDropper();
|
|
||||||
eyeDropper.open()
|
|
||||||
.then((result: { sRGBHex: string }) => {
|
|
||||||
handleChangeComplete(result.sRGBHex);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
// User canceled the eyedropper
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Droplet className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="grid grid-cols-5 gap-2">
|
||||||
<div className="grid grid-cols-8 gap-1">
|
{presetColors.map((color) => (
|
||||||
{PRESET_COLORS.map((presetColor) => (
|
<button
|
||||||
<Button
|
key={color}
|
||||||
key={presetColor}
|
type="button"
|
||||||
style={{ backgroundColor: presetColor }}
|
className="w-8 h-8 rounded-md border border-input focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
className="w-6 h-6 p-0 rounded-sm"
|
style={{ backgroundColor: color }}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
handleChangeComplete(presetColor);
|
setCurrentColor(color);
|
||||||
setIsOpen(false);
|
onChange(color);
|
||||||
}}
|
}}
|
||||||
>
|
aria-label={`Select color ${color}`}
|
||||||
{color.toLowerCase() === presetColor.toLowerCase() && (
|
/>
|
||||||
<Check className="h-3 w-3 text-white" />
|
|
||||||
)}
|
|
||||||
<span className="sr-only">Select color {presetColor}</span>
|
|
||||||
</Button>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user