style: slicing dashboard UI
This commit is contained in:
parent
d2c7254e6a
commit
591585e5f6
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface Brand {
|
||||||
|
name: string;
|
||||||
|
count: number;
|
||||||
|
logo?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BrandFilterProps {
|
||||||
|
brands: Brand[];
|
||||||
|
selectedBrand: string | null;
|
||||||
|
onSelect: (brand: string | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BrandFilter({ brands, selectedBrand, onSelect }: BrandFilterProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => onSelect(null)}
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg border px-4 py-2 text-sm font-medium transition-all",
|
||||||
|
selectedBrand === null
|
||||||
|
? "border-primary bg-primary text-primary-foreground"
|
||||||
|
: "border-border bg-card text-muted-foreground hover:border-primary/50 hover:text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Semua ({brands.reduce((sum, b) => sum + b.count, 0).toLocaleString()})
|
||||||
|
</button>
|
||||||
|
{brands.map((brand) => (
|
||||||
|
<button
|
||||||
|
key={brand.name}
|
||||||
|
onClick={() => onSelect(brand.name)}
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg border px-4 py-2 text-sm font-medium transition-all",
|
||||||
|
selectedBrand === brand.name
|
||||||
|
? "border-primary bg-primary text-primary-foreground"
|
||||||
|
: "border-border bg-card text-muted-foreground hover:border-primary/50 hover:text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{brand.name} ({brand.count.toLocaleString()})
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,101 @@
|
||||||
|
import {
|
||||||
|
BarChart3,
|
||||||
|
Database,
|
||||||
|
Laptop,
|
||||||
|
LogOut,
|
||||||
|
RefreshCw,
|
||||||
|
User,
|
||||||
|
UserCircle,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "../ui/dropdown-menu";
|
||||||
|
|
||||||
|
export function Header() {
|
||||||
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const handleRefresh = () => {
|
||||||
|
setIsRefreshing(true);
|
||||||
|
setTimeout(() => setIsRefreshing(false), 1500);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="border-b bg-card/50 backdrop-blur-sm sticky top-0 z-50">
|
||||||
|
<div className="container mx-auto px-4 py-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-primary text-primary-foreground">
|
||||||
|
<BarChart3 className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-foreground">
|
||||||
|
SENTILAISES.
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Analisis Sentimen Ulasan Laptop Tokopedia
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<div className="hidden items-center gap-6 text-sm md:flex">
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<Laptop className="h-4 w-4" />
|
||||||
|
<span>5 Brand</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<Database className="h-4 w-4" />
|
||||||
|
<span>12,450 Ulasan</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
onMouseEnter={() => setOpen(true)}
|
||||||
|
onMouseLeave={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
// variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="gap-2 focus-visible:ring-0 border-border hover:bg-secondary hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<User className={cn("h-4 w-4 text-muted-foreground")} />
|
||||||
|
<span className="hidden sm:inline">Profile</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
|
<DropdownMenuContent
|
||||||
|
align="end"
|
||||||
|
className="w-max bg-card border-border shadow-md"
|
||||||
|
>
|
||||||
|
<DropdownMenuItem className="cursor-pointer gap-2 focus:bg-secondary focus:text-primary transition-colors hover:text-primary">
|
||||||
|
<UserCircle className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span>Menu Profil</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuSeparator className="bg-border" />
|
||||||
|
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="cursor-pointer gap-2 text-destructive focus:bg-destructive/10 focus:text-red-500 transition-colors"
|
||||||
|
onClick={() => console.log("Logout clicked")}
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4" />
|
||||||
|
<span>Logout</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,97 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { modelData } from "@/src/app/dashboard/lib/data";
|
||||||
|
|
||||||
|
export function ModelInfo() {
|
||||||
|
const [selectedModel, setSelectedModel] =
|
||||||
|
useState<keyof typeof modelData>("optimized");
|
||||||
|
|
||||||
|
const currentModel = modelData[selectedModel];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border bg-card p-6">
|
||||||
|
<div className="mb-4 flex items-center justify-between gap-4">
|
||||||
|
<Select
|
||||||
|
value={selectedModel}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setSelectedModel(value as keyof typeof modelData)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-fit min-w-[240px] justify-start gap-3 border bg-transparent text-lg font-semibold shadow-none">
|
||||||
|
<SelectValue placeholder="Pilih Model" />
|
||||||
|
</SelectTrigger>
|
||||||
|
|
||||||
|
<SelectContent className="min-w-[260px]">
|
||||||
|
<SelectItem
|
||||||
|
value="baseline"
|
||||||
|
className="cursor-pointer justify-start px-4 py-2"
|
||||||
|
>
|
||||||
|
Model XGBoost (Baseline)
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem
|
||||||
|
value="tuned"
|
||||||
|
className="cursor-pointer justify-start px-4 py-2"
|
||||||
|
>
|
||||||
|
Model XGBoost (Tuned)
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem
|
||||||
|
value="optimized"
|
||||||
|
className="cursor-pointer justify-start px-4 py-2"
|
||||||
|
>
|
||||||
|
Model XGBoost (Optimized)
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Badge variant="secondary" className="bg-accent/10 text-accent">
|
||||||
|
Active
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mb-6 text-sm text-muted-foreground">
|
||||||
|
{currentModel.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{currentModel.metrics.map((metric) => (
|
||||||
|
<div
|
||||||
|
key={metric.label}
|
||||||
|
className="flex items-center gap-3 rounded-lg bg-muted/50 p-3"
|
||||||
|
>
|
||||||
|
<div className="rounded-lg bg-primary/10 p-2">
|
||||||
|
<metric.icon className="h-4 w-4 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">{metric.label}</p>
|
||||||
|
<p className="font-semibold">{metric.value}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 space-y-2 text-sm text-muted-foreground">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>Preprocessing</span>
|
||||||
|
<span className="text-foreground">
|
||||||
|
Case Folding, Stopwords, Stemming
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>Feature Extraction</span>
|
||||||
|
<span className="text-foreground">TF-IDF Vectorization</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>Training Data</span>
|
||||||
|
<span className="text-foreground">3.445 ulasan</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,116 @@
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Star } from "lucide-react";
|
||||||
|
|
||||||
|
interface Review {
|
||||||
|
id: string;
|
||||||
|
product: string;
|
||||||
|
brand: string;
|
||||||
|
review: string;
|
||||||
|
rating: number;
|
||||||
|
sentiment: "positif" | "negatif" | "netral";
|
||||||
|
date: string;
|
||||||
|
confidence: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReviewTableProps {
|
||||||
|
reviews: Review[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReviewTable({ reviews }: ReviewTableProps) {
|
||||||
|
const getSentimentBadge = (sentiment: Review["sentiment"]) => {
|
||||||
|
const styles = {
|
||||||
|
positif: "sentiment-positive",
|
||||||
|
negatif: "sentiment-negative",
|
||||||
|
netral: "sentiment-neutral",
|
||||||
|
};
|
||||||
|
|
||||||
|
const labels = {
|
||||||
|
positif: "Positif",
|
||||||
|
negatif: "Negatif",
|
||||||
|
netral: "Netral",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className={cn("font-medium", styles[sentiment])}
|
||||||
|
>
|
||||||
|
{labels[sentiment]}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderStars = (rating: number) => {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-0.5">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<Star
|
||||||
|
key={i}
|
||||||
|
className={cn(
|
||||||
|
"h-4 w-4",
|
||||||
|
i < rating
|
||||||
|
? "fill-sentiment-neutral text-sentiment-neutral"
|
||||||
|
: "fill-muted text-muted"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border bg-card">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="hover:bg-transparent">
|
||||||
|
<TableHead className="w-[200px]">Produk</TableHead>
|
||||||
|
<TableHead className="min-w-[300px]">Ulasan</TableHead>
|
||||||
|
<TableHead className="w-[100px]">Rating</TableHead>
|
||||||
|
<TableHead className="w-[100px]">Sentimen</TableHead>
|
||||||
|
<TableHead className="w-[100px] text-right">Confidence</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{reviews.map((review, index) => (
|
||||||
|
<TableRow
|
||||||
|
key={review.id}
|
||||||
|
className="animate-fade-in"
|
||||||
|
style={{ animationDelay: `${index * 50}ms` }}
|
||||||
|
>
|
||||||
|
<TableCell>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-foreground">{review.brand}</p>
|
||||||
|
<p className="text-sm text-muted-foreground line-clamp-1">
|
||||||
|
{review.product}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<p className="line-clamp-2 text-sm">{review.review}</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{review.date}
|
||||||
|
</p>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{renderStars(review.rating)}</TableCell>
|
||||||
|
<TableCell>{getSentimentBadge(review.sentiment)}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<span className="font-medium">
|
||||||
|
{(review.confidence * 100).toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,241 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
Loader2,
|
||||||
|
Send,
|
||||||
|
Sparkles,
|
||||||
|
ThumbsUp,
|
||||||
|
ThumbsDown,
|
||||||
|
Minus,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
|
||||||
|
interface AnalysisResult {
|
||||||
|
sentiment: "positif" | "negatif" | "netral";
|
||||||
|
confidence: number;
|
||||||
|
keywords: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SentimentAnalyzer() {
|
||||||
|
const [text, setText] = useState("");
|
||||||
|
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||||
|
const [result, setResult] = useState<AnalysisResult | null>(null);
|
||||||
|
|
||||||
|
// Simulated analysis - in real implementation, this would call an XGBoost model API
|
||||||
|
const analyzeText = async () => {
|
||||||
|
if (!text.trim()) return;
|
||||||
|
|
||||||
|
setIsAnalyzing(true);
|
||||||
|
setResult(null);
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||||
|
|
||||||
|
const positiveWords = [
|
||||||
|
"bagus",
|
||||||
|
"cepat",
|
||||||
|
"aman",
|
||||||
|
"baik",
|
||||||
|
"mulus",
|
||||||
|
"moga",
|
||||||
|
"awet",
|
||||||
|
"mantap",
|
||||||
|
"sangat",
|
||||||
|
"fungsi",
|
||||||
|
];
|
||||||
|
|
||||||
|
const negativeWords = [
|
||||||
|
"lebih",
|
||||||
|
"jual",
|
||||||
|
"baru",
|
||||||
|
"lalu",
|
||||||
|
"tahun",
|
||||||
|
"masalah",
|
||||||
|
"rusak",
|
||||||
|
"garansi",
|
||||||
|
"layar",
|
||||||
|
"kecewa",
|
||||||
|
];
|
||||||
|
|
||||||
|
const lowerText = text.toLowerCase();
|
||||||
|
let positiveScore = 0;
|
||||||
|
let negativeScore = 0;
|
||||||
|
const foundKeywords: string[] = [];
|
||||||
|
|
||||||
|
positiveWords.forEach((word) => {
|
||||||
|
if (lowerText.includes(word)) {
|
||||||
|
positiveScore++;
|
||||||
|
foundKeywords.push(word);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
negativeWords.forEach((word) => {
|
||||||
|
if (lowerText.includes(word)) {
|
||||||
|
negativeScore++;
|
||||||
|
foundKeywords.push(word);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let sentiment: AnalysisResult["sentiment"];
|
||||||
|
let confidence: number;
|
||||||
|
|
||||||
|
if (positiveScore > negativeScore) {
|
||||||
|
sentiment = "positif";
|
||||||
|
confidence = 0.75 + Math.random() * 0.2;
|
||||||
|
} else if (negativeScore > positiveScore) {
|
||||||
|
sentiment = "negatif";
|
||||||
|
confidence = 0.75 + Math.random() * 0.2;
|
||||||
|
} else {
|
||||||
|
sentiment = "netral";
|
||||||
|
confidence = 0.6 + Math.random() * 0.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
setResult({
|
||||||
|
sentiment,
|
||||||
|
confidence,
|
||||||
|
keywords: foundKeywords,
|
||||||
|
});
|
||||||
|
setIsAnalyzing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSentimentDisplay = (sentiment: AnalysisResult["sentiment"]) => {
|
||||||
|
const config = {
|
||||||
|
positif: {
|
||||||
|
icon: ThumbsUp,
|
||||||
|
label: "Positif",
|
||||||
|
bgClass: "bg-sentiment-positive-light",
|
||||||
|
textClass: "text-sentiment-positive",
|
||||||
|
borderClass: "border-sentiment-positive/30",
|
||||||
|
},
|
||||||
|
negatif: {
|
||||||
|
icon: ThumbsDown,
|
||||||
|
label: "Negatif",
|
||||||
|
bgClass: "bg-sentiment-negative-light",
|
||||||
|
textClass: "text-sentiment-negative",
|
||||||
|
borderClass: "border-sentiment-negative/30",
|
||||||
|
},
|
||||||
|
netral: {
|
||||||
|
icon: Minus,
|
||||||
|
label: "Netral",
|
||||||
|
bgClass: "bg-sentiment-neutral-light",
|
||||||
|
textClass: "text-sentiment-neutral",
|
||||||
|
borderClass: "border-sentiment-neutral/30",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return config[sentiment];
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border bg-card p-6">
|
||||||
|
<div className="mb-4 flex items-center gap-2">
|
||||||
|
<Sparkles className="h-5 w-5 text-primary" />
|
||||||
|
<h3 className="text-lg font-semibold">Analisis Sentimen Real-time</h3>
|
||||||
|
</div>
|
||||||
|
<p className="mb-4 text-sm text-muted-foreground">
|
||||||
|
Masukkan ulasan produk laptop untuk menganalisis sentimennya menggunakan
|
||||||
|
model XGBoost
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Textarea
|
||||||
|
placeholder="Contoh: Laptop ini sangat bagus, performa cepat dan layar jernih. Sangat recommended untuk pekerjaan kantoran."
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
rows={4}
|
||||||
|
className="resize-none"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={analyzeText}
|
||||||
|
disabled={!text.trim() || isAnalyzing}
|
||||||
|
className="w-full gap-2"
|
||||||
|
>
|
||||||
|
{isAnalyzing ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Menganalisis...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Send className="h-4 w-4" />
|
||||||
|
Analisis Sentimen
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
{result && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -10 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg border p-4",
|
||||||
|
getSentimentDisplay(result.sentiment).bgClass,
|
||||||
|
getSentimentDisplay(result.sentiment).borderClass,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{(() => {
|
||||||
|
const Icon = getSentimentDisplay(result.sentiment).icon;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex h-12 w-12 items-center justify-center rounded-full",
|
||||||
|
getSentimentDisplay(result.sentiment).bgClass,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
className={cn(
|
||||||
|
"h-6 w-6",
|
||||||
|
getSentimentDisplay(result.sentiment).textClass,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
<div>
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"text-xl font-bold",
|
||||||
|
getSentimentDisplay(result.sentiment).textClass,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{getSentimentDisplay(result.sentiment).label}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Confidence: {(result.confidence * 100).toFixed(1)}%
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{result.keywords.length > 0 && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<p className="mb-2 text-sm font-medium text-muted-foreground">
|
||||||
|
Kata Kunci Terdeteksi:
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{result.keywords.map((keyword, index) => (
|
||||||
|
<Badge
|
||||||
|
key={index}
|
||||||
|
variant="secondary"
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
{keyword}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,106 @@
|
||||||
|
import {
|
||||||
|
PieChart,
|
||||||
|
Pie,
|
||||||
|
Cell,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Legend,
|
||||||
|
Tooltip,
|
||||||
|
} from "recharts";
|
||||||
|
|
||||||
|
interface SentimentData {
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SentimentChartProps {
|
||||||
|
data: SentimentData[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SentimentChart({ data }: SentimentChartProps) {
|
||||||
|
const total = data.reduce((sum, item) => sum + item.value, 0);
|
||||||
|
|
||||||
|
const CustomTooltip = ({ active, payload }: any) => {
|
||||||
|
if (active && payload && payload.length) {
|
||||||
|
const item = payload[0].payload;
|
||||||
|
const percentage = ((item.value / total) * 100).toFixed(1);
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border bg-card px-4 py-3 shadow-lg">
|
||||||
|
<p className="font-semibold" style={{ color: item.color }}>
|
||||||
|
{item.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{item.value.toLocaleString()} ulasan ({percentage}%)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderCustomLabel = ({
|
||||||
|
cx,
|
||||||
|
cy,
|
||||||
|
midAngle,
|
||||||
|
innerRadius,
|
||||||
|
outerRadius,
|
||||||
|
percent,
|
||||||
|
}: any) => {
|
||||||
|
if (percent < 0.05) return null;
|
||||||
|
const RADIAN = Math.PI / 180;
|
||||||
|
const radius = innerRadius + (outerRadius - innerRadius) * 0.5;
|
||||||
|
const x = cx + radius * Math.cos(-midAngle * RADIAN);
|
||||||
|
const y = cy + radius * Math.sin(-midAngle * RADIAN);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<text
|
||||||
|
x={x}
|
||||||
|
y={y}
|
||||||
|
fill="white"
|
||||||
|
textAnchor="middle"
|
||||||
|
dominantBaseline="central"
|
||||||
|
className="text-sm font-semibold"
|
||||||
|
>
|
||||||
|
{`${(percent * 100).toFixed(0)}%`}
|
||||||
|
</text>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-[300px] w-full">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={data}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
labelLine={false}
|
||||||
|
label={renderCustomLabel}
|
||||||
|
outerRadius={110}
|
||||||
|
innerRadius={60}
|
||||||
|
paddingAngle={3}
|
||||||
|
dataKey="value"
|
||||||
|
strokeWidth={2}
|
||||||
|
stroke="hsl(var(--card))"
|
||||||
|
>
|
||||||
|
{data.map((entry, index) => (
|
||||||
|
<Cell
|
||||||
|
key={`cell-${index}`}
|
||||||
|
fill={entry.color}
|
||||||
|
className="transition-all duration-300 hover:opacity-80"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip content={<CustomTooltip />} />
|
||||||
|
<Legend
|
||||||
|
verticalAlign="bottom"
|
||||||
|
height={36}
|
||||||
|
formatter={(value: string) => (
|
||||||
|
<span className="text-sm text-foreground">{value}</span>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,119 @@
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { LucideIcon } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
interface StatCardProps {
|
||||||
|
title: string;
|
||||||
|
value: number;
|
||||||
|
suffix?: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
trend?: {
|
||||||
|
value: number;
|
||||||
|
isPositive: boolean;
|
||||||
|
};
|
||||||
|
variant?: "default" | "positive" | "negative" | "neutral";
|
||||||
|
delay?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatCard({
|
||||||
|
title,
|
||||||
|
value,
|
||||||
|
suffix = "",
|
||||||
|
icon: Icon,
|
||||||
|
trend,
|
||||||
|
variant = "default",
|
||||||
|
delay = 0,
|
||||||
|
}: StatCardProps) {
|
||||||
|
const [displayValue, setDisplayValue] = useState(0);
|
||||||
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setIsVisible(true);
|
||||||
|
}, delay);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [delay]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isVisible) return;
|
||||||
|
|
||||||
|
const duration = 1200;
|
||||||
|
const steps = 40;
|
||||||
|
const stepValue = value / steps;
|
||||||
|
let current = 0;
|
||||||
|
let step = 0;
|
||||||
|
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
step++;
|
||||||
|
// Easing function for smooth animation
|
||||||
|
const progress = step / steps;
|
||||||
|
const eased = 1 - Math.pow(1 - progress, 3);
|
||||||
|
current = value * eased;
|
||||||
|
|
||||||
|
if (step >= steps) {
|
||||||
|
setDisplayValue(value);
|
||||||
|
clearInterval(timer);
|
||||||
|
} else {
|
||||||
|
setDisplayValue(Math.floor(current));
|
||||||
|
}
|
||||||
|
}, duration / steps);
|
||||||
|
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [value, isVisible]);
|
||||||
|
|
||||||
|
const variantStyles = {
|
||||||
|
default: "bg-card border-border",
|
||||||
|
positive: "bg-sentiment-positive-light border-sentiment-positive/20",
|
||||||
|
negative: "bg-sentiment-negative-light border-sentiment-negative/20",
|
||||||
|
neutral: "bg-sentiment-neutral-light border-sentiment-neutral/20",
|
||||||
|
};
|
||||||
|
|
||||||
|
const iconStyles = {
|
||||||
|
default: "bg-primary/10 text-primary",
|
||||||
|
positive: "bg-sentiment-positive/10 text-sentiment-positive",
|
||||||
|
negative: "bg-sentiment-negative/10 text-sentiment-negative",
|
||||||
|
neutral: "bg-sentiment-neutral/10 text-sentiment-neutral",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative overflow-hidden rounded-xl border p-6 card-elevated transition-all duration-500",
|
||||||
|
variantStyles[variant],
|
||||||
|
isVisible ? "opacity-100 translate-y-0" : "opacity-0 translate-y-4"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">{title}</p>
|
||||||
|
<div className="flex items-baseline gap-1">
|
||||||
|
<span className="text-3xl font-bold tracking-tight">
|
||||||
|
{displayValue.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
{suffix && (
|
||||||
|
<span className="text-lg font-medium text-muted-foreground">
|
||||||
|
{suffix}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{trend && (
|
||||||
|
<div className="flex items-center gap-1 text-sm">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"font-medium",
|
||||||
|
trend.isPositive ? "text-sentiment-positive" : "text-sentiment-negative"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{trend.isPositive ? "+" : "-"}{Math.abs(trend.value)}%
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground">dari periode lalu</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={cn("rounded-xl p-3", iconStyles[variant])}>
|
||||||
|
<Icon className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,147 @@
|
||||||
|
import {
|
||||||
|
AreaChart,
|
||||||
|
Area,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Legend,
|
||||||
|
} from "recharts";
|
||||||
|
|
||||||
|
interface TrendData {
|
||||||
|
date: string;
|
||||||
|
positif: number;
|
||||||
|
negatif: number;
|
||||||
|
netral: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TrendChartProps {
|
||||||
|
data: TrendData[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TrendChart({ data }: TrendChartProps) {
|
||||||
|
const CustomTooltip = ({ active, payload, label }: any) => {
|
||||||
|
if (active && payload && payload.length) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border bg-card px-4 py-3 shadow-lg">
|
||||||
|
<p className="mb-2 font-semibold text-foreground">{label}</p>
|
||||||
|
{payload.map((item: any, index: number) => (
|
||||||
|
<div key={index} className="flex items-center gap-2 text-sm">
|
||||||
|
<div
|
||||||
|
className="h-3 w-3 rounded-full"
|
||||||
|
style={{ backgroundColor: item.color }}
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground">{item.name}:</span>
|
||||||
|
<span className="font-medium">{item.value}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-[350px] w-full">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<AreaChart
|
||||||
|
data={data}
|
||||||
|
margin={{ top: 10, right: 30, left: 0, bottom: 0 }}
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="colorPositif" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop
|
||||||
|
offset="5%"
|
||||||
|
stopColor="hsl(158, 64%, 42%)"
|
||||||
|
stopOpacity={0.3}
|
||||||
|
/>
|
||||||
|
<stop
|
||||||
|
offset="95%"
|
||||||
|
stopColor="hsl(158, 64%, 42%)"
|
||||||
|
stopOpacity={0}
|
||||||
|
/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="colorNegatif" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop
|
||||||
|
offset="5%"
|
||||||
|
stopColor="hsl(0, 72%, 51%)"
|
||||||
|
stopOpacity={0.3}
|
||||||
|
/>
|
||||||
|
<stop
|
||||||
|
offset="95%"
|
||||||
|
stopColor="hsl(0, 72%, 51%)"
|
||||||
|
stopOpacity={0}
|
||||||
|
/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="colorNetral" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop
|
||||||
|
offset="5%"
|
||||||
|
stopColor="hsl(43, 74%, 49%)"
|
||||||
|
stopOpacity={0.3}
|
||||||
|
/>
|
||||||
|
<stop
|
||||||
|
offset="95%"
|
||||||
|
stopColor="hsl(43, 74%, 49%)"
|
||||||
|
stopOpacity={0}
|
||||||
|
/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid
|
||||||
|
strokeDasharray="3 3"
|
||||||
|
stroke="hsl(var(--border))"
|
||||||
|
vertical={false}
|
||||||
|
/>
|
||||||
|
<XAxis
|
||||||
|
dataKey="date"
|
||||||
|
stroke="hsl(var(--muted-foreground))"
|
||||||
|
fontSize={12}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
stroke="hsl(var(--muted-foreground))"
|
||||||
|
fontSize={12}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
/>
|
||||||
|
<Tooltip content={<CustomTooltip />} />
|
||||||
|
<Legend
|
||||||
|
verticalAlign="top"
|
||||||
|
height={36}
|
||||||
|
formatter={(value: string) => (
|
||||||
|
<span className="text-sm capitalize text-foreground">{value}</span>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="positif"
|
||||||
|
name="Positif"
|
||||||
|
stroke="hsl(158, 64%, 42%)"
|
||||||
|
fillOpacity={1}
|
||||||
|
fill="url(#colorPositif)"
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="negatif"
|
||||||
|
name="Negatif"
|
||||||
|
stroke="hsl(0, 72%, 51%)"
|
||||||
|
fillOpacity={1}
|
||||||
|
fill="url(#colorNegatif)"
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="netral"
|
||||||
|
name="Netral"
|
||||||
|
stroke="hsl(43, 74%, 49%)"
|
||||||
|
fillOpacity={1}
|
||||||
|
fill="url(#colorNetral)"
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useState, useEffect, useMemo } from "react";
|
||||||
|
|
||||||
|
interface WordItem {
|
||||||
|
text: string;
|
||||||
|
value: number;
|
||||||
|
sentiment: "positive" | "negative" | "neutral";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WordCloudProps {
|
||||||
|
words: WordItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WordCloud({ words }: WordCloudProps) {
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const maxValue = Math.max(...words.map((w) => w.value), 1);
|
||||||
|
const minValue = Math.min(...words.map((w) => w.value), 0);
|
||||||
|
|
||||||
|
const getSize = (value: number) => {
|
||||||
|
if (maxValue === minValue) return 1.5;
|
||||||
|
const normalized = (value - minValue) / (maxValue - minValue);
|
||||||
|
return 0.75 + normalized * 1.5;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getColor = (sentiment: WordItem["sentiment"]) => {
|
||||||
|
switch (sentiment) {
|
||||||
|
case "positive":
|
||||||
|
return "text-sentiment-positive hover:bg-sentiment-positive-light";
|
||||||
|
case "negative":
|
||||||
|
return "text-sentiment-negative hover:bg-sentiment-negative-light";
|
||||||
|
default:
|
||||||
|
return "text-sentiment-neutral hover:bg-sentiment-neutral-light";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const shuffledWords = useMemo(() => {
|
||||||
|
return [...words].sort(() => Math.random() - 0.5);
|
||||||
|
}, [words]);
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center justify-center gap-2 p-4 min-h-[150px]" />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center justify-center gap-2 p-4">
|
||||||
|
{shuffledWords.map((word, index) => (
|
||||||
|
<span
|
||||||
|
key={`${word.text}-${index}`}
|
||||||
|
className={cn(
|
||||||
|
"cursor-default rounded-lg px-2 py-1 font-medium transition-all duration-200 animate-in fade-in zoom-in",
|
||||||
|
getColor(word.sentiment),
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
fontSize: `${getSize(word.value)}rem`,
|
||||||
|
animationDelay: `${index * 50}ms`,
|
||||||
|
animationFillMode: "both",
|
||||||
|
}}
|
||||||
|
title={`${word.text}: ${word.value} kemunculan`}
|
||||||
|
>
|
||||||
|
{word.text}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { Slot } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"inline-flex items-center justify-center rounded-full border border-transparent 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: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||||
|
destructive:
|
||||||
|
"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:
|
||||||
|
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||||
|
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||||
|
link: "text-primary underline-offset-4 [a&]:hover:underline",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Badge({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span"> &
|
||||||
|
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||||
|
const Comp = asChild ? Slot.Root : "span"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="badge"
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(badgeVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
||||||
|
|
@ -0,0 +1,257 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||||
|
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,190 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||||
|
import { Select as SelectPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Select({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||||
|
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||||
|
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectValue({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||||
|
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectTrigger({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||||
|
size?: "sm" | "default"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
data-slot="select-trigger"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]: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 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDownIcon className="size-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
position = "item-aligned",
|
||||||
|
align = "center",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
data-slot="select-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 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||||
|
position === "popper" &&
|
||||||
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
align={align}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
"p-1",
|
||||||
|
position === "popper" &&
|
||||||
|
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
data-slot="select-label"
|
||||||
|
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
data-slot="select-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 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 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
data-slot="select-item-indicator"
|
||||||
|
className="absolute right-2 flex size-3.5 items-center justify-center"
|
||||||
|
>
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
data-slot="select-separator"
|
||||||
|
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollUpButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
data-slot="select-scroll-up-button"
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUpIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollDownButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
data-slot="select-scroll-down-button"
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDownIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,116 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="table-container"
|
||||||
|
className="relative w-full overflow-x-auto"
|
||||||
|
>
|
||||||
|
<table
|
||||||
|
data-slot="table"
|
||||||
|
className={cn("w-full caption-bottom text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||||
|
return (
|
||||||
|
<thead
|
||||||
|
data-slot="table-header"
|
||||||
|
className={cn("[&_tr]:border-b", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||||
|
return (
|
||||||
|
<tbody
|
||||||
|
data-slot="table-body"
|
||||||
|
className={cn("[&_tr:last-child]:border-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||||
|
return (
|
||||||
|
<tfoot
|
||||||
|
data-slot="table-footer"
|
||||||
|
className={cn(
|
||||||
|
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
data-slot="table-row"
|
||||||
|
className={cn(
|
||||||
|
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
data-slot="table-head"
|
||||||
|
className={cn(
|
||||||
|
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
data-slot="table-cell"
|
||||||
|
className={cn(
|
||||||
|
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCaption({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"caption">) {
|
||||||
|
return (
|
||||||
|
<caption
|
||||||
|
data-slot="table-caption"
|
||||||
|
className={cn("text-muted-foreground mt-4 text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableBody,
|
||||||
|
TableFooter,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
TableCaption,
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
declare module "*.css";
|
||||||
|
declare module "*.scss";
|
||||||
|
declare module "*.png";
|
||||||
|
declare module "*.jpg"
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -17,11 +17,14 @@
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
|
"framer-motion": "^12.31.0",
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
"pg": "^8.18.0",
|
"pg": "^8.18.0",
|
||||||
|
"radix-ui": "^1.4.3",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
|
"recharts": "^3.7.0",
|
||||||
"tailwind-merge": "^3.4.0"
|
"tailwind-merge": "^3.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,193 @@
|
||||||
|
// Sample data for the sentiment analysis dashboard
|
||||||
|
// Based on Tokopedia laptop reviews analysis
|
||||||
|
|
||||||
|
import { CheckCircle2, Cpu, Target, Zap } from "lucide-react";
|
||||||
|
|
||||||
|
export const sentimentDistribution = [
|
||||||
|
{ name: "Positif", value: 2456, color: "hsl(158, 64%, 42%)" },
|
||||||
|
{ name: "Negatif", value: 607, color: "hsl(0, 72%, 51%)" },
|
||||||
|
{ name: "Netral", value: 382, color: "hsl(43, 74%, 49%)" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const trendData = [
|
||||||
|
{ date: "Jan", positif: 580, negatif: 220, netral: 150 },
|
||||||
|
{ date: "Feb", positif: 620, negatif: 245, netral: 175 },
|
||||||
|
{ date: "Mar", positif: 750, negatif: 280, netral: 190 },
|
||||||
|
{ date: "Apr", positif: 690, negatif: 310, netral: 165 },
|
||||||
|
{ date: "Mei", positif: 820, negatif: 265, netral: 210 },
|
||||||
|
{ date: "Jun", positif: 780, negatif: 240, netral: 195 },
|
||||||
|
{ date: "Jul", positif: 850, negatif: 290, netral: 220 },
|
||||||
|
{ date: "Agu", positif: 720, negatif: 255, netral: 180 },
|
||||||
|
{ date: "Sep", positif: 680, negatif: 230, netral: 165 },
|
||||||
|
{ date: "Okt", positif: 540, negatif: 195, netral: 145 },
|
||||||
|
{ date: "Nov", positif: 520, negatif: 175, netral: 150 },
|
||||||
|
{ date: "Des", positif: 474, negatif: 140, netral: 136 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const wordCloudData = [
|
||||||
|
{ text: "barang", value: 1086, sentiment: "positive" as const },
|
||||||
|
{ text: "sesuai", value: 570, sentiment: "positive" as const },
|
||||||
|
{ text: "bagus", value: 523, sentiment: "positive" as const },
|
||||||
|
{ text: "seller", value: 478, sentiment: "positive" as const },
|
||||||
|
{ text: "cepat", value: 471, sentiment: "positive" as const },
|
||||||
|
{ text: "aman", value: 453, sentiment: "positive" as const },
|
||||||
|
{ text: "laptop", value: 451, sentiment: "positive" as const },
|
||||||
|
{ text: "kirim", value: 449, sentiment: "positive" as const },
|
||||||
|
{ text: "baik", value: 396, sentiment: "positive" as const },
|
||||||
|
{ text: "mulus", value: 364, sentiment: "positive" as const },
|
||||||
|
{ text: "barang", value: 181, sentiment: "negative" as const },
|
||||||
|
{ text: "kirim", value: 177, sentiment: "negative" as const },
|
||||||
|
{ text: "laptop", value: 129, sentiment: "negative" as const },
|
||||||
|
{ text: "beli", value: 124, sentiment: "negative" as const },
|
||||||
|
{ text: "lebih", value: 72, sentiment: "negative" as const },
|
||||||
|
{ text: "jual", value: 62, sentiment: "negative" as const },
|
||||||
|
{ text: "baru", value: 60, sentiment: "negative" as const },
|
||||||
|
{ text: "lalu", value: 59, sentiment: "negative" as const },
|
||||||
|
{ text: "sesuai", value: 56, sentiment: "negative" as const },
|
||||||
|
{ text: "tahun", value: 56, sentiment: "negative" as const },
|
||||||
|
{ text: "kirim", value: 106, sentiment: "neutral" as const },
|
||||||
|
{ text: "barang", value: 96, sentiment: "neutral" as const },
|
||||||
|
{ text: "laptop", value: 78, sentiment: "neutral" as const },
|
||||||
|
{ text: "sesuai", value: 48, sentiment: "neutral" as const },
|
||||||
|
{ text: "bagus", value: 47, sentiment: "neutral" as const },
|
||||||
|
{ text: "lebih", value: 45, sentiment: "neutral" as const },
|
||||||
|
{ text: "kurang", value: 44, sentiment: "neutral" as const },
|
||||||
|
{ text: "beli", value: 44, sentiment: "neutral" as const },
|
||||||
|
{ text: "baik", value: 33, sentiment: "neutral" as const },
|
||||||
|
{ text: "baru", value: 32, sentiment: "neutral" as const },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const brandData = [
|
||||||
|
{ name: "ASUS", count: 3245 },
|
||||||
|
{ name: "Lenovo", count: 2890 },
|
||||||
|
{ name: "HP", count: 2456 },
|
||||||
|
{ name: "Acer", count: 2134 },
|
||||||
|
{ name: "Dell", count: 1725 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const reviewData = [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
product: "ASUS VivoBook 15 X515EA Intel Core i5-1135G7",
|
||||||
|
brand: "ASUS",
|
||||||
|
review:
|
||||||
|
"Laptop sangat bagus, performa cepat untuk kerja kantoran. Layar jernih dan keyboard nyaman dipakai mengetik seharian. Pengiriman juga cepat dan aman.",
|
||||||
|
rating: 5,
|
||||||
|
sentiment: "positif" as const,
|
||||||
|
date: "2 hari yang lalu",
|
||||||
|
confidence: 0.945,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2",
|
||||||
|
product: "Lenovo IdeaPad Slim 3 AMD Ryzen 5 5500U",
|
||||||
|
brand: "Lenovo",
|
||||||
|
review:
|
||||||
|
"Produk sesuai deskripsi. Build quality oke, performa lancar untuk multitasking ringan. Baterai awet bisa 6-7 jam pemakaian normal.",
|
||||||
|
rating: 4,
|
||||||
|
sentiment: "positif" as const,
|
||||||
|
date: "3 hari yang lalu",
|
||||||
|
confidence: 0.887,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "3",
|
||||||
|
product: "HP 14s-dq5001TU Intel Core i5-1235U",
|
||||||
|
brand: "HP",
|
||||||
|
review:
|
||||||
|
"Kecewa dengan produk ini. Baru dipakai 2 minggu sudah sering hang dan restart sendiri. Kipas juga berisik sekali padahal hanya buka browser.",
|
||||||
|
rating: 2,
|
||||||
|
sentiment: "negatif" as const,
|
||||||
|
date: "4 hari yang lalu",
|
||||||
|
confidence: 0.923,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "4",
|
||||||
|
product: "Acer Aspire 5 A515-57 Intel Core i5-1235U",
|
||||||
|
brand: "Acer",
|
||||||
|
review:
|
||||||
|
"Laptop oke lah untuk harga segini. Tidak terlalu cepat tapi juga tidak lemot. Cocok untuk mahasiswa dengan budget terbatas.",
|
||||||
|
rating: 3,
|
||||||
|
sentiment: "netral" as const,
|
||||||
|
date: "5 hari yang lalu",
|
||||||
|
confidence: 0.812,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "5",
|
||||||
|
product: "Dell Inspiron 15 3520 Intel Core i3-1215U",
|
||||||
|
brand: "Dell",
|
||||||
|
review:
|
||||||
|
"Sangat puas dengan pembelian ini! Laptop premium dengan harga terjangkau. Build quality solid, keyboard backlit, dan layar anti-glare sangat membantu.",
|
||||||
|
rating: 5,
|
||||||
|
sentiment: "positif" as const,
|
||||||
|
date: "1 minggu yang lalu",
|
||||||
|
confidence: 0.956,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "6",
|
||||||
|
product: "ASUS TUF Gaming F15 FX506HF RTX 2050",
|
||||||
|
brand: "ASUS",
|
||||||
|
review:
|
||||||
|
"Gaming laptop yang worth it! Main game AAA lancar di medium-high setting. Thermal management bagus, tidak terlalu panas saat gaming marathon.",
|
||||||
|
rating: 5,
|
||||||
|
sentiment: "positif" as const,
|
||||||
|
date: "1 minggu yang lalu",
|
||||||
|
confidence: 0.934,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "7",
|
||||||
|
product: "Lenovo V14 G3 AMD Ryzen 3 5300U",
|
||||||
|
brand: "Lenovo",
|
||||||
|
review:
|
||||||
|
"Laptop datang dalam kondisi rusak, layar ada garis horizontal. Sudah komplain ke seller tapi respon lambat. Sangat mengecewakan.",
|
||||||
|
rating: 1,
|
||||||
|
sentiment: "negatif" as const,
|
||||||
|
date: "1 minggu yang lalu",
|
||||||
|
confidence: 0.967,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "8",
|
||||||
|
product: "HP Pavilion 14-dv2045TX Intel Core i5",
|
||||||
|
brand: "HP",
|
||||||
|
review:
|
||||||
|
"Desain elegan dan performa mumpuni. Cocok untuk pekerja mobile yang butuh laptop stylish. Speaker B&O juga keren suaranya.",
|
||||||
|
rating: 4,
|
||||||
|
sentiment: "positif" as const,
|
||||||
|
date: "2 minggu yang lalu",
|
||||||
|
confidence: 0.891,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const modelData = {
|
||||||
|
baseline: {
|
||||||
|
name: "Model XGBoost (Baseline)",
|
||||||
|
metrics: [
|
||||||
|
{ label: "Accuracy", value: "80.0%", icon: Target },
|
||||||
|
{ label: "Macro F1-Score", value: "56.0%", icon: Cpu },
|
||||||
|
{ label: "F1-Negatif", value: "61.0%", icon: CheckCircle2 },
|
||||||
|
{ label: "F1-Netral", value: "16.0%", icon: Zap },
|
||||||
|
],
|
||||||
|
description:
|
||||||
|
"Model awal menggunakan parameter default XGBoost (learning_rate=0.3, max_depth=6) pada dataset yang tidak seimbang.",
|
||||||
|
},
|
||||||
|
tuned: {
|
||||||
|
name: "Model XGBoost (Tuned)",
|
||||||
|
metrics: [
|
||||||
|
{ label: "Accuracy", value: "81.0%", icon: Target },
|
||||||
|
{ label: "Macro F1-Score", value: "58.0%", icon: Cpu },
|
||||||
|
{ label: "F1-Negatif", value: "65.0%", icon: CheckCircle2 },
|
||||||
|
{ label: "F1-Netral", value: "17.0%", icon: Zap },
|
||||||
|
],
|
||||||
|
description:
|
||||||
|
"Model dengan optimasi Hyperparameter menggunakan Grid Search untuk mencari kombinasi learning_rate dan max_depth terbaik.",
|
||||||
|
},
|
||||||
|
optimized: {
|
||||||
|
name: "Model XGBoost (Optimized)",
|
||||||
|
metrics: [
|
||||||
|
{ label: "Accuracy", value: "82.0%", icon: Target },
|
||||||
|
{ label: "Macro F1-Score", value: "61.0%", icon: Cpu },
|
||||||
|
{ label: "F1-Negatif", value: "65.0%", icon: CheckCircle2 },
|
||||||
|
{ label: "F1-Netral", value: "27.0%", icon: Zap },
|
||||||
|
],
|
||||||
|
description:
|
||||||
|
"Model final menggunakan teknik SMOTE untuk menyeimbangkan kelas, seleksi fitur Chi-Square, dan optimasi Grid Search.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,177 @@
|
||||||
|
"use client";
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import {
|
||||||
|
brandData,
|
||||||
|
reviewData,
|
||||||
|
sentimentDistribution,
|
||||||
|
trendData,
|
||||||
|
wordCloudData,
|
||||||
|
} from "./lib/data";
|
||||||
|
import { Header } from "@/components/dashboard/Header";
|
||||||
|
import {
|
||||||
|
MessageSquareText,
|
||||||
|
Minus,
|
||||||
|
ThumbsDown,
|
||||||
|
ThumbsUp,
|
||||||
|
TrendingUp,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { StatCard } from "@/components/dashboard/StatCard";
|
||||||
|
import { TrendChart } from "@/components/dashboard/TrendChart";
|
||||||
|
import { SentimentChart } from "@/components/dashboard/SentimentChart";
|
||||||
|
import { WordCloud } from "@/components/dashboard/WordCloud";
|
||||||
|
import { ModelInfo } from "@/components/dashboard/ModelInfo";
|
||||||
|
import { SentimentAnalyzer } from "@/components/dashboard/SentimentAnalyzer";
|
||||||
|
import { BrandFilter } from "@/components/dashboard/BrandFilter";
|
||||||
|
import { ReviewTable } from "@/components/dashboard/ReviewTable";
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const [selectedBrand, setSelectedBrand] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const totalReviews = sentimentDistribution.reduce(
|
||||||
|
(sum, s) => sum + s.value,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const positiveCount =
|
||||||
|
sentimentDistribution.find((s) => s.name === "Positif")?.value || 0;
|
||||||
|
const negativeCount =
|
||||||
|
sentimentDistribution.find((s) => s.name === "Negatif")?.value || 0;
|
||||||
|
const neutralCount =
|
||||||
|
sentimentDistribution.find((s) => s.name === "Netral")?.value || 0;
|
||||||
|
|
||||||
|
const filteredReviews = selectedBrand
|
||||||
|
? reviewData.filter((r) => r.brand === selectedBrand)
|
||||||
|
: reviewData;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
<main className="container mx-auto px-4 py-8">
|
||||||
|
{/* Hero Section */}
|
||||||
|
<div
|
||||||
|
className="mb-8 rounded-2xl p-8 text-center"
|
||||||
|
style={{ background: "hsl(var(--primary))" }}
|
||||||
|
>
|
||||||
|
<h2 className="mb-2 text-3xl font-bold text-white md:text-4xl">
|
||||||
|
Analisis Sentimen Ulasan Laptop
|
||||||
|
</h2>
|
||||||
|
<p className="mx-auto max-w-2xl text-lg text-white/80">
|
||||||
|
Sistem klasifikasi sentimen menggunakan algoritma XGBoost untuk
|
||||||
|
menganalisis ulasan produk laptop pada platform Tokopedia
|
||||||
|
</p>
|
||||||
|
<div className="mt-6 flex items-center justify-center gap-4 text-sm text-white/70">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<TrendingUp className="h-4 w-4" />
|
||||||
|
Akurasi 92.4%
|
||||||
|
</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>XGBoost + TF-IDF</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>Real-time Analysis</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Grid */}
|
||||||
|
<div className="mb-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<StatCard
|
||||||
|
title="Total Ulasan"
|
||||||
|
value={totalReviews}
|
||||||
|
icon={MessageSquareText}
|
||||||
|
trend={{ value: 12.5, isPositive: true }}
|
||||||
|
delay={0}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Sentimen Positif"
|
||||||
|
value={positiveCount}
|
||||||
|
suffix={`(${((positiveCount / totalReviews) * 100).toFixed(1)}%)`}
|
||||||
|
icon={ThumbsUp}
|
||||||
|
variant="positive"
|
||||||
|
delay={100}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Sentimen Negatif"
|
||||||
|
value={negativeCount}
|
||||||
|
suffix={`(${((negativeCount / totalReviews) * 100).toFixed(1)}%)`}
|
||||||
|
icon={ThumbsDown}
|
||||||
|
variant="negative"
|
||||||
|
delay={200}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Sentimen Netral"
|
||||||
|
value={neutralCount}
|
||||||
|
suffix={`(${((neutralCount / totalReviews) * 100).toFixed(1)}%)`}
|
||||||
|
icon={Minus}
|
||||||
|
variant="neutral"
|
||||||
|
delay={300}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Charts Section */}
|
||||||
|
<div className="mb-8 grid gap-6 lg:grid-cols-3">
|
||||||
|
<div className="rounded-xl border bg-card p-6 lg:col-span-2">
|
||||||
|
<h3 className="mb-4 text-lg font-semibold">
|
||||||
|
Tren Sentimen Bulanan
|
||||||
|
</h3>
|
||||||
|
<TrendChart data={trendData} />
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border bg-card p-6">
|
||||||
|
<h3 className="mb-4 text-lg font-semibold">Distribusi Sentimen</h3>
|
||||||
|
<SentimentChart data={sentimentDistribution} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Word Cloud & Model Info */}
|
||||||
|
<div className="mb-8 grid gap-6 lg:grid-cols-2">
|
||||||
|
<div className="rounded-xl border bg-card p-6">
|
||||||
|
<h3 className="mb-4 text-lg font-semibold">Kata Kunci Populer</h3>
|
||||||
|
<p className="mb-4 text-sm text-muted-foreground">
|
||||||
|
Kata-kata yang sering muncul dalam ulasan berdasarkan kategori
|
||||||
|
sentimen
|
||||||
|
</p>
|
||||||
|
<WordCloud words={wordCloudData} />
|
||||||
|
</div>
|
||||||
|
<ModelInfo />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sentiment Analyzer */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<SentimentAnalyzer />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reviews Section */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold">Ulasan Terbaru</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Hasil klasifikasi sentimen ulasan produk laptop
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<BrandFilter
|
||||||
|
brands={brandData}
|
||||||
|
selectedBrand={selectedBrand}
|
||||||
|
onSelect={setSelectedBrand}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ReviewTable reviews={filteredReviews} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<footer className="mt-12 border-t pt-8">
|
||||||
|
<div className="flex flex-col items-center justify-between gap-4 text-sm text-muted-foreground sm:flex-row">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-foreground">
|
||||||
|
SentiLaptop - Analisis Sentimen
|
||||||
|
</p>
|
||||||
|
<p>Skripsi oleh Syafrizal Wd Mahendra (E41222719)</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p>Politeknik Negeri Jember</p>
|
||||||
|
<p>PSDKU Teknik Informatika Kampus 3 Nganjuk</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -4,115 +4,85 @@
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
|
/* Mapping Core Colors */
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
--font-sans: var(--font-geist-sans);
|
--font-sans: var(--font-geist-sans);
|
||||||
--font-mono: var(--font-geist-mono);
|
|
||||||
--color-sidebar-ring: var(--sidebar-ring);
|
/* Mapping Custom Sentiment Colors */
|
||||||
--color-sidebar-border: var(--sidebar-border);
|
--color-sentiment-positive: hsl(var(--sentiment-positive));
|
||||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
--color-sentiment-positive-light: hsl(var(--sentiment-positive-light));
|
||||||
--color-sidebar-accent: var(--sidebar-accent);
|
--color-sentiment-negative: hsl(var(--sentiment-negative));
|
||||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
--color-sentiment-negative-light: hsl(var(--sentiment-negative-light));
|
||||||
--color-sidebar-primary: var(--sidebar-primary);
|
--color-sentiment-neutral: hsl(var(--sentiment-neutral));
|
||||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
--color-sentiment-neutral-light: hsl(var(--sentiment-neutral-light));
|
||||||
--color-sidebar: var(--sidebar);
|
|
||||||
--color-chart-5: var(--chart-5);
|
/* Chart & UI Colors */
|
||||||
--color-chart-4: var(--chart-4);
|
--color-primary: hsl(var(--primary));
|
||||||
--color-chart-3: var(--chart-3);
|
--color-primary-foreground: hsl(var(--primary-foreground));
|
||||||
--color-chart-2: var(--chart-2);
|
--color-card: hsl(var(--card));
|
||||||
--color-chart-1: var(--chart-1);
|
--color-card-foreground: hsl(var(--card-foreground));
|
||||||
--color-ring: var(--ring);
|
--color-border: hsl(var(--border));
|
||||||
--color-input: var(--input);
|
--color-ring: hsl(var(--ring));
|
||||||
--color-border: var(--border);
|
|
||||||
--color-destructive: var(--destructive);
|
/* Radius */
|
||||||
--color-accent-foreground: var(--accent-foreground);
|
|
||||||
--color-accent: var(--accent);
|
|
||||||
--color-muted-foreground: var(--muted-foreground);
|
|
||||||
--color-muted: var(--muted);
|
|
||||||
--color-secondary-foreground: var(--secondary-foreground);
|
|
||||||
--color-secondary: var(--secondary);
|
|
||||||
--color-primary-foreground: var(--primary-foreground);
|
|
||||||
--color-primary: var(--primary);
|
|
||||||
--color-popover-foreground: var(--popover-foreground);
|
|
||||||
--color-popover: var(--popover);
|
|
||||||
--color-card-foreground: var(--card-foreground);
|
|
||||||
--color-card: var(--card);
|
|
||||||
--radius-sm: calc(var(--radius) - 4px);
|
|
||||||
--radius-md: calc(var(--radius) - 2px);
|
|
||||||
--radius-lg: var(--radius);
|
--radius-lg: var(--radius);
|
||||||
--radius-xl: calc(var(--radius) + 4px);
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
--radius-2xl: calc(var(--radius) + 8px);
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
--radius-3xl: calc(var(--radius) + 12px);
|
|
||||||
--radius-4xl: calc(var(--radius) + 16px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--radius: 0.625rem;
|
/* Deep Blue Analytics Theme - Light Mode */
|
||||||
--background: oklch(1 0 0);
|
--background: 220 20% 97%;
|
||||||
--foreground: oklch(0.145 0 0);
|
--foreground: 220 30% 15%;
|
||||||
--card: oklch(1 0 0);
|
|
||||||
--card-foreground: oklch(0.145 0 0);
|
--card: 0 0% 100%;
|
||||||
--popover: oklch(1 0 0);
|
--card-foreground: 220 30% 15%;
|
||||||
--popover-foreground: oklch(0.145 0 0);
|
|
||||||
--primary: oklch(0.205 0 0);
|
--primary: 213 50% 23%;
|
||||||
--primary-foreground: oklch(0.985 0 0);
|
--primary-foreground: 0 0% 100%;
|
||||||
--secondary: oklch(0.97 0 0);
|
|
||||||
--secondary-foreground: oklch(0.205 0 0);
|
--muted: 220 15% 94%;
|
||||||
--muted: oklch(0.97 0 0);
|
--muted-foreground: 220 10% 45%;
|
||||||
--muted-foreground: oklch(0.556 0 0);
|
|
||||||
--accent: oklch(0.97 0 0);
|
--border: 220 15% 88%;
|
||||||
--accent-foreground: oklch(0.205 0 0);
|
--ring: 213 50% 23%;
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
|
||||||
--border: oklch(0.922 0 0);
|
/* Sentiment Colors (HSL Format) */
|
||||||
--input: oklch(0.922 0 0);
|
--sentiment-positive: 158 64% 42%;
|
||||||
--ring: oklch(0.708 0 0);
|
--sentiment-positive-light: 158 64% 95%;
|
||||||
--chart-1: oklch(0.646 0.222 41.116);
|
--sentiment-negative: 0 72% 51%;
|
||||||
--chart-2: oklch(0.6 0.118 184.704);
|
--sentiment-negative-light: 0 72% 95%;
|
||||||
--chart-3: oklch(0.398 0.07 227.392);
|
--sentiment-neutral: 43 74% 49%;
|
||||||
--chart-4: oklch(0.828 0.189 84.429);
|
--sentiment-neutral-light: 43 74% 94%;
|
||||||
--chart-5: oklch(0.769 0.188 70.08);
|
|
||||||
--sidebar: oklch(0.985 0 0);
|
--radius: 0.75rem;
|
||||||
--sidebar-foreground: oklch(0.145 0 0);
|
|
||||||
--sidebar-primary: oklch(0.205 0 0);
|
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
|
||||||
--sidebar-accent: oklch(0.97 0 0);
|
|
||||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
|
||||||
--sidebar-border: oklch(0.922 0 0);
|
|
||||||
--sidebar-ring: oklch(0.708 0 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--background: oklch(0.145 0 0);
|
/* Deep Blue Analytics Theme - Dark Mode */
|
||||||
--foreground: oklch(0.985 0 0);
|
--background: 220 25% 8%;
|
||||||
--card: oklch(0.205 0 0);
|
--foreground: 220 15% 95%;
|
||||||
--card-foreground: oklch(0.985 0 0);
|
|
||||||
--popover: oklch(0.205 0 0);
|
--card: 220 25% 12%;
|
||||||
--popover-foreground: oklch(0.985 0 0);
|
--card-foreground: 220 15% 95%;
|
||||||
--primary: oklch(0.922 0 0);
|
|
||||||
--primary-foreground: oklch(0.205 0 0);
|
--primary: 213 60% 55%;
|
||||||
--secondary: oklch(0.269 0 0);
|
--primary-foreground: 220 25% 8%;
|
||||||
--secondary-foreground: oklch(0.985 0 0);
|
|
||||||
--muted: oklch(0.269 0 0);
|
--muted: 220 20% 18%;
|
||||||
--muted-foreground: oklch(0.708 0 0);
|
--muted-foreground: 220 10% 55%;
|
||||||
--accent: oklch(0.269 0 0);
|
|
||||||
--accent-foreground: oklch(0.985 0 0);
|
--border: 220 20% 20%;
|
||||||
--destructive: oklch(0.704 0.191 22.216);
|
--ring: 213 60% 55%;
|
||||||
--border: oklch(1 0 0 / 10%);
|
|
||||||
--input: oklch(1 0 0 / 15%);
|
/* Adjusted Sentiments for Dark Mode */
|
||||||
--ring: oklch(0.556 0 0);
|
--sentiment-positive: 158 64% 48%;
|
||||||
--chart-1: oklch(0.488 0.243 264.376);
|
--sentiment-positive-light: 158 40% 18%;
|
||||||
--chart-2: oklch(0.696 0.17 162.48);
|
--sentiment-negative: 0 72% 58%;
|
||||||
--chart-3: oklch(0.769 0.188 70.08);
|
--sentiment-negative-light: 0 50% 18%;
|
||||||
--chart-4: oklch(0.627 0.265 303.9);
|
--sentiment-neutral: 43 74% 55%;
|
||||||
--chart-5: oklch(0.645 0.246 16.439);
|
--sentiment-neutral-light: 43 50% 18%;
|
||||||
--sidebar: oklch(0.205 0 0);
|
|
||||||
--sidebar-foreground: oklch(0.985 0 0);
|
|
||||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
|
||||||
--sidebar-accent: oklch(0.269 0 0);
|
|
||||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
|
||||||
--sidebar-border: oklch(1 0 0 / 10%);
|
|
||||||
--sidebar-ring: oklch(0.556 0 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
|
|
@ -120,6 +90,29 @@
|
||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground antialiased;
|
||||||
|
font-feature-settings: "cv02", "cv03", "cv04", "cv11";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
/* Custom Sentiment Classes */
|
||||||
|
.sentiment-positive {
|
||||||
|
background-color: hsl(var(--sentiment-positive-light));
|
||||||
|
color: hsl(var(--sentiment-positive));
|
||||||
|
}
|
||||||
|
.sentiment-negative {
|
||||||
|
background-color: hsl(var(--sentiment-negative-light));
|
||||||
|
color: hsl(var(--sentiment-negative));
|
||||||
|
}
|
||||||
|
.sentiment-neutral {
|
||||||
|
background-color: hsl(var(--sentiment-neutral-light));
|
||||||
|
color: hsl(var(--sentiment-neutral));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Glassmorphism */
|
||||||
|
.glass {
|
||||||
|
@apply backdrop-blur-lg;
|
||||||
|
background: hsl(var(--card) / 0.8);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono, Inter } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
|
|
@ -17,6 +17,9 @@ export const metadata: Metadata = {
|
||||||
description: "Generated by create next app",
|
description: "Generated by create next app",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
|
||||||
|
// lalu di body: <body className={`${inter.variable} font-sans`}>
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
|
|
@ -24,11 +27,7 @@ export default function RootLayout({
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body
|
<body className={`${inter.variable} font-sans`}>{children}</body>
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</body>
|
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,113 @@
|
||||||
|
import type { Config } from "tailwindcss";
|
||||||
|
|
||||||
|
const config: Config = {
|
||||||
|
content: [
|
||||||
|
"./pages/**/*.{ts,tsx}",
|
||||||
|
"./components/**/*.{ts,tsx}",
|
||||||
|
"./app/**/*.{ts,tsx}",
|
||||||
|
"./src/**/*.{ts,tsx}",
|
||||||
|
],
|
||||||
|
|
||||||
|
theme: {
|
||||||
|
container: {
|
||||||
|
center: true,
|
||||||
|
padding: "2rem",
|
||||||
|
screens: {
|
||||||
|
"2xl": "1400px",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
// Shadcn UI Variables (Menggunakan HSL)
|
||||||
|
border: "hsl(var(--border))",
|
||||||
|
input: "hsl(var(--input))",
|
||||||
|
ring: "hsl(var(--ring))",
|
||||||
|
background: "hsl(var(--background))",
|
||||||
|
foreground: "hsl(var(--foreground))",
|
||||||
|
primary: {
|
||||||
|
DEFAULT: "hsl(var(--primary))",
|
||||||
|
foreground: "hsl(var(--primary-foreground))",
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
DEFAULT: "hsl(var(--secondary))",
|
||||||
|
foreground: "hsl(var(--secondary-foreground))",
|
||||||
|
},
|
||||||
|
destructive: {
|
||||||
|
DEFAULT: "hsl(var(--destructive))",
|
||||||
|
foreground: "hsl(var(--destructive-foreground))",
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
DEFAULT: "hsl(var(--muted))",
|
||||||
|
foreground: "hsl(var(--muted-foreground))",
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: "hsl(var(--accent))",
|
||||||
|
foreground: "hsl(var(--accent-foreground))",
|
||||||
|
},
|
||||||
|
popover: {
|
||||||
|
DEFAULT: "hsl(var(--popover))",
|
||||||
|
foreground: "hsl(var(--popover-foreground))",
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
DEFAULT: "hsl(var(--card))",
|
||||||
|
foreground: "hsl(var(--card-foreground))",
|
||||||
|
},
|
||||||
|
// Custom Sentiment Colors untuk WordCloud/Dashboard
|
||||||
|
sentiment: {
|
||||||
|
positive: "hsl(var(--sentiment-positive))",
|
||||||
|
"positive-light": "hsl(var(--sentiment-positive-light))",
|
||||||
|
negative: "hsl(var(--sentiment-negative))",
|
||||||
|
"negative-light": "hsl(var(--sentiment-negative-light))",
|
||||||
|
neutral: "hsl(var(--sentiment-neutral))",
|
||||||
|
"neutral-light": "hsl(var(--sentiment-neutral-light))",
|
||||||
|
},
|
||||||
|
sidebar: {
|
||||||
|
DEFAULT: "hsl(var(--sidebar-background))",
|
||||||
|
foreground: "hsl(var(--sidebar-foreground))",
|
||||||
|
primary: "hsl(var(--sidebar-primary))",
|
||||||
|
"primary-foreground": "hsl(var(--sidebar-primary-foreground))",
|
||||||
|
accent: "hsl(var(--sidebar-accent))",
|
||||||
|
"accent-foreground": "hsl(var(--sidebar-accent-foreground))",
|
||||||
|
border: "hsl(var(--sidebar-border))",
|
||||||
|
ring: "hsl(var(--sidebar-ring))",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
lg: "var(--radius)",
|
||||||
|
md: "calc(var(--radius) - 2px)",
|
||||||
|
sm: "calc(var(--radius) - 4px)",
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
// 3. Gunakan CSS Variable untuk Font agar sinkron dengan next/font di layout.tsx
|
||||||
|
sans: ["var(--font-inter)", "Inter", "system-ui", "sans-serif"],
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
"accordion-down": {
|
||||||
|
from: { height: "0" },
|
||||||
|
to: { height: "var(--radix-accordion-content-height)" },
|
||||||
|
},
|
||||||
|
"accordion-up": {
|
||||||
|
from: { height: "var(--radix-accordion-content-height)" },
|
||||||
|
to: { height: "0" },
|
||||||
|
},
|
||||||
|
"fade-in": {
|
||||||
|
"0%": { opacity: "0" },
|
||||||
|
"100%": { opacity: "1" },
|
||||||
|
},
|
||||||
|
"slide-up": {
|
||||||
|
"0%": { opacity: "0", transform: "translateY(10px)" },
|
||||||
|
"100%": { opacity: "1", transform: "translateY(0)" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
"accordion-down": "accordion-down 0.2s ease-out",
|
||||||
|
"accordion-up": "accordion-up 0.2s ease-out",
|
||||||
|
"fade-in": "fade-in 0.4s ease-out forwards",
|
||||||
|
"slide-up": "slide-up 0.4s ease-out forwards",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [require("tailwindcss-animate")],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
Loading…
Reference in New Issue