style: slicing dashboard UI

This commit is contained in:
Mahen 2026-02-05 08:32:44 +07:00
parent d2c7254e6a
commit 591585e5f6
22 changed files with 5992 additions and 108 deletions

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

48
components/ui/badge.tsx Normal file
View File

@ -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 }

View File

@ -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,
}

190
components/ui/select.tsx Normal file
View File

@ -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,
}

116
components/ui/table.tsx Normal file
View File

@ -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,
}

View File

@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
)
}
export { Textarea }

4
declarations.d.ts vendored Normal file
View File

@ -0,0 +1,4 @@
declare module "*.css";
declare module "*.scss";
declare module "*.png";
declare module "*.jpg"

3731
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -17,11 +17,14 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dotenv": "^17.2.3",
"framer-motion": "^12.31.0",
"lucide-react": "^0.563.0",
"next": "16.1.6",
"pg": "^8.18.0",
"radix-ui": "^1.4.3",
"react": "19.2.3",
"react-dom": "19.2.3",
"recharts": "^3.7.0",
"tailwind-merge": "^3.4.0"
},
"devDependencies": {

View File

@ -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.",
},
};

View File

@ -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>
);
}

View File

@ -4,115 +4,85 @@
@custom-variant dark (&:is(.dark *));
@theme inline {
/* Mapping Core Colors */
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--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);
/* Mapping Custom Sentiment Colors */
--color-sentiment-positive: hsl(var(--sentiment-positive));
--color-sentiment-positive-light: hsl(var(--sentiment-positive-light));
--color-sentiment-negative: hsl(var(--sentiment-negative));
--color-sentiment-negative-light: hsl(var(--sentiment-negative-light));
--color-sentiment-neutral: hsl(var(--sentiment-neutral));
--color-sentiment-neutral-light: hsl(var(--sentiment-neutral-light));
/* Chart & UI Colors */
--color-primary: hsl(var(--primary));
--color-primary-foreground: hsl(var(--primary-foreground));
--color-card: hsl(var(--card));
--color-card-foreground: hsl(var(--card-foreground));
--color-border: hsl(var(--border));
--color-ring: hsl(var(--ring));
/* Radius */
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
--radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) - 4px);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--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);
/* Deep Blue Analytics Theme - Light Mode */
--background: 220 20% 97%;
--foreground: 220 30% 15%;
--card: 0 0% 100%;
--card-foreground: 220 30% 15%;
--primary: 213 50% 23%;
--primary-foreground: 0 0% 100%;
--muted: 220 15% 94%;
--muted-foreground: 220 10% 45%;
--border: 220 15% 88%;
--ring: 213 50% 23%;
/* Sentiment Colors (HSL Format) */
--sentiment-positive: 158 64% 42%;
--sentiment-positive-light: 158 64% 95%;
--sentiment-negative: 0 72% 51%;
--sentiment-negative-light: 0 72% 95%;
--sentiment-neutral: 43 74% 49%;
--sentiment-neutral-light: 43 74% 94%;
--radius: 0.75rem;
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--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);
/* Deep Blue Analytics Theme - Dark Mode */
--background: 220 25% 8%;
--foreground: 220 15% 95%;
--card: 220 25% 12%;
--card-foreground: 220 15% 95%;
--primary: 213 60% 55%;
--primary-foreground: 220 25% 8%;
--muted: 220 20% 18%;
--muted-foreground: 220 10% 55%;
--border: 220 20% 20%;
--ring: 213 60% 55%;
/* Adjusted Sentiments for Dark Mode */
--sentiment-positive: 158 64% 48%;
--sentiment-positive-light: 158 40% 18%;
--sentiment-negative: 0 72% 58%;
--sentiment-negative-light: 0 50% 18%;
--sentiment-neutral: 43 74% 55%;
--sentiment-neutral-light: 43 50% 18%;
}
@layer base {
@ -120,6 +90,29 @@
@apply border-border outline-ring/50;
}
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);
}
}

View File

@ -1,5 +1,5 @@
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";
const geistSans = Geist({
@ -17,6 +17,9 @@ export const metadata: Metadata = {
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({
children,
}: Readonly<{
@ -24,11 +27,7 @@ export default function RootLayout({
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
</body>
<body className={`${inter.variable} font-sans`}>{children}</body>
</html>
);
}

113
tailwind.config.ts Normal file
View File

@ -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;