refactor: implement custom hooks on all relevant components that require them.

This commit is contained in:
Mahen 2026-02-07 10:50:43 +07:00
parent d2bcba7eb1
commit 0c03517965
27 changed files with 891 additions and 570 deletions

View File

@ -0,0 +1,145 @@
-- CreateEnum
CREATE TYPE "UserGender" AS ENUM ('male', 'female', 'other');
-- CreateEnum
CREATE TYPE "Sentiment" AS ENUM ('positive', 'negative', 'neutral');
-- CreateTable
CREATE TABLE "Account" (
"id" SERIAL NOT NULL,
"userId" INTEGER NOT NULL,
"type" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"providerAccountId" TEXT NOT NULL,
"refresh_token" TEXT,
"access_token" TEXT,
"expires_at" INTEGER,
"token_type" TEXT,
"scope" TEXT,
"id_token" TEXT,
"session_state" TEXT,
CONSTRAINT "Account_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Session" (
"id" SERIAL NOT NULL,
"sessionToken" TEXT NOT NULL,
"userId" INTEGER NOT NULL,
"expires" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "VerificationToken" (
"identifier" TEXT NOT NULL,
"token" TEXT NOT NULL,
"expires" TIMESTAMP(3) NOT NULL
);
-- CreateTable
CREATE TABLE "User" (
"id" SERIAL NOT NULL,
"name" TEXT,
"email" TEXT,
"emailVerified" TIMESTAMP(3),
"image" TEXT,
"gender" "UserGender",
"productReference" TEXT,
"password" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Analysis" (
"id" SERIAL NOT NULL,
"userId" INTEGER NOT NULL,
"reviewId" INTEGER NOT NULL,
"productId" INTEGER NOT NULL,
"modelId" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Analysis_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Product" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"brand" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Product_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Review" (
"id" SERIAL NOT NULL,
"productId" INTEGER NOT NULL,
"content" TEXT NOT NULL,
"keywords" TEXT[],
"sentiment" "Sentiment" NOT NULL,
"confidenceScore" DOUBLE PRECISION NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Review_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Model" (
"id" SERIAL NOT NULL,
"modelName" TEXT NOT NULL,
"description" TEXT NOT NULL,
"accuracy" DOUBLE PRECISION NOT NULL,
"macroF1" DOUBLE PRECISION NOT NULL,
"f1Negative" DOUBLE PRECISION NOT NULL,
"f1Neutral" DOUBLE PRECISION NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Model_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId");
-- CreateIndex
CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken");
-- CreateIndex
CREATE UNIQUE INDEX "VerificationToken_token_key" ON "VerificationToken"("token");
-- CreateIndex
CREATE UNIQUE INDEX "VerificationToken_identifier_token_key" ON "VerificationToken"("identifier", "token");
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- AddForeignKey
ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Analysis" ADD CONSTRAINT "Analysis_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Analysis" ADD CONSTRAINT "Analysis_reviewId_fkey" FOREIGN KEY ("reviewId") REFERENCES "Review"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Analysis" ADD CONSTRAINT "Analysis_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Analysis" ADD CONSTRAINT "Analysis_modelId_fkey" FOREIGN KEY ("modelId") REFERENCES "Model"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Review" ADD CONSTRAINT "Review_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

View File

@ -1,18 +1,11 @@
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { BrandFilterProps } from "@/src/types";
interface Brand { export function BrandFilter({
name: string; brands,
count: number; selectedBrand,
logo?: string; onSelect,
} }: BrandFilterProps) {
interface BrandFilterProps {
brands: Brand[];
selectedBrand: string | null;
onSelect: (brand: string | null) => void;
}
export function BrandFilter({ brands, selectedBrand, onSelect }: BrandFilterProps) {
return ( return (
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<button <button
@ -21,7 +14,7 @@ export function BrandFilter({ brands, selectedBrand, onSelect }: BrandFilterProp
"rounded-lg border px-4 py-2 text-sm font-medium transition-all", "rounded-lg border px-4 py-2 text-sm font-medium transition-all",
selectedBrand === null selectedBrand === null
? "border-primary bg-primary text-primary-foreground" ? "border-primary bg-primary text-primary-foreground"
: "border-border bg-card text-muted-foreground hover:border-primary/50 hover:text-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()}) Semua ({brands.reduce((sum, b) => sum + b.count, 0).toLocaleString()})
@ -34,7 +27,7 @@ export function BrandFilter({ brands, selectedBrand, onSelect }: BrandFilterProp
"rounded-lg border px-4 py-2 text-sm font-medium transition-all", "rounded-lg border px-4 py-2 text-sm font-medium transition-all",
selectedBrand === brand.name selectedBrand === brand.name
? "border-primary bg-primary text-primary-foreground" ? "border-primary bg-primary text-primary-foreground"
: "border-border bg-card text-muted-foreground hover:border-primary/50 hover:text-foreground" : "border-border bg-card text-muted-foreground hover:border-primary/50 hover:text-foreground",
)} )}
> >
{brand.name} ({brand.count.toLocaleString()}) {brand.name} ({brand.count.toLocaleString()})

View File

@ -0,0 +1,29 @@
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>
);
};
export default renderCustomLabel;

View File

@ -0,0 +1,28 @@
import { CustomTooltipProps } from "@/src/types";
import React from "react";
const CustomTooltip: React.FC<CustomTooltipProps> = ({
active,
payload,
total,
}) => {
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;
};
export default CustomTooltip;

View File

@ -1,5 +1,4 @@
"use client"; "use client";
import { useEffect, useState } from "react";
import { Header } from "./Header"; import { Header } from "./Header";
import { import {
MessageSquareText, MessageSquareText,
@ -9,70 +8,32 @@ import {
TrendingUp, TrendingUp,
} from "lucide-react"; } from "lucide-react";
import { StatCard } from "./StatCard"; import { StatCard } from "./StatCard";
import { ModelDB } from "@/src/types";
import { import {
brandData, brandData,
reviewData,
sentimentDistribution, sentimentDistribution,
trendData, trendData,
wordCloudData, wordCloudData,
} from "@/src/app/dashboard/lib/data"; } from "@/src/app/dashboard/lib/data";
import { getClassificationReport } from "@/src/app/dashboard/lib/actions";
import { ModelInfoSkeleton } from "../skeletons/ModelInfoSkeleton"; import { ModelInfoSkeleton } from "../skeletons/ModelInfoSkeleton";
import { ModelInfo } from "./ModelInfo"; import { ModelInfo } from "./ModelInfo";
import { SentimentAnalyzer } from "./SentimentAnalyzer"; import { SentimentAnalyzer } from "./SentimentAnalyzer";
import { BrandFilter } from "./BrandFilter"; import { BrandFilter } from "./BrandFilter";
import { ReviewTable } from "./ReviewTable"; import { ReviewTable } from "./ReviewTable";
import dynamic from "next/dynamic"; import { SentimentChart, TrendChart, WordCloud } from "@/src/utils/dImports";
import { useDashboard } from "@/src/hooks/useDashboard";
const TrendChart = dynamic(
() => import("./TrendChart").then((mod) => ({ default: mod.TrendChart })),
{ ssr: false },
);
const WordCloud = dynamic(
() => import("./WordCloud").then((mod) => ({ default: mod.WordCloud })),
{ ssr: false },
);
const SentimentChart = dynamic(
() =>
import("./SentimentChart").then((mod) => ({ default: mod.SentimentChart })),
{ ssr: false },
);
export default function DashboardClient() { export default function DashboardClient() {
const [selectedBrand, setSelectedBrand] = useState<string | null>(null); const {
const [loading, setLoading] = useState(true); totalReviews,
const [modelData, setModelData] = useState<ModelDB[]>([]); positiveCount,
negativeCount,
const totalReviews = sentimentDistribution.reduce( neutralCount,
(sum, s) => sum + s.value, filteredReviews,
0, selectedBrand,
); setSelectedBrand,
loading,
const positiveCount = modelData,
sentimentDistribution.find((s) => s.name === "Positif")?.value || 0; } = useDashboard();
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;
useEffect(() => {
async function fetchData() {
try {
const data = await getClassificationReport();
setModelData(data);
} catch (error) {
console.error("Failed to fetch model data", error);
} finally {
setLoading(false);
}
}
fetchData();
}, []);
return ( return (
<div className="min-h-screen bg-background"> <div className="min-h-screen bg-background">
@ -102,7 +63,6 @@ export default function DashboardClient() {
</div> </div>
</div> </div>
{/* Stats Grid */}
<div className="mb-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-4"> <div className="mb-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard <StatCard
title="Total Ulasan" title="Total Ulasan"
@ -137,7 +97,6 @@ export default function DashboardClient() {
/> />
</div> </div>
{/* Charts Section */}
<div className="mb-8 grid gap-6 lg:grid-cols-3"> <div className="mb-8 grid gap-6 lg:grid-cols-3">
<div className="rounded-xl border bg-card p-6 lg:col-span-2"> <div className="rounded-xl border bg-card p-6 lg:col-span-2">
<h3 className="mb-4 text-lg font-semibold"> <h3 className="mb-4 text-lg font-semibold">
@ -151,9 +110,7 @@ export default function DashboardClient() {
</div> </div>
</div> </div>
{/* Word Cloud & Model Info */}
<div className="mb-8 grid gap-6 lg:grid-cols-2"> <div className="mb-8 grid gap-6 lg:grid-cols-2">
{/* Slot Kata Kunci */}
<div className="rounded-xl border bg-card p-6"> <div className="rounded-xl border bg-card p-6">
<h3 className="mb-4 text-lg font-semibold">Kata Kunci Populer</h3> <h3 className="mb-4 text-lg font-semibold">Kata Kunci Populer</h3>
<p className="mb-4 text-sm text-muted-foreground"> <p className="mb-4 text-sm text-muted-foreground">
@ -174,12 +131,10 @@ export default function DashboardClient() {
)} )}
</div> </div>
{/* Sentiment Analyzer */}
<div className="mb-8"> <div className="mb-8">
<SentimentAnalyzer /> <SentimentAnalyzer />
</div> </div>
{/* Reviews Section */}
<div className="space-y-6"> <div className="space-y-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div> <div>
@ -197,7 +152,6 @@ export default function DashboardClient() {
<ReviewTable reviews={filteredReviews} /> <ReviewTable reviews={filteredReviews} />
</div> </div>
{/* Footer */}
<footer className="mt-12 border-t pt-8"> <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 className="flex flex-col items-center justify-between gap-4 text-sm text-muted-foreground sm:flex-row">
<div> <div>

View File

@ -4,12 +4,9 @@ import {
Database, Database,
Laptop, Laptop,
LogOut, LogOut,
Smile,
User, User,
UserCircle, UserCircle,
} from "lucide-react"; } from "lucide-react";
import { Button } from "../../components/ui/button";
import { useState } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
DropdownMenu, DropdownMenu,
@ -18,19 +15,12 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "../ui/dropdown-menu"; } from "../ui/dropdown-menu";
import { signOut, useSession } from "next-auth/react"; import { signOut } from "next-auth/react";
import Link from "next/link"; import Link from "next/link";
import { useHeader } from "@/src/hooks/useHeader";
export function Header() { export function Header() {
const [isRefreshing, setIsRefreshing] = useState(false); const { open, setOpen, session } = useHeader();
const [open, setOpen] = useState(false);
const session = useSession();
const handleRefresh = () => {
setIsRefreshing(true);
setTimeout(() => setIsRefreshing(false), 1500);
};
return ( return (
<header className="border-b bg-card/50 backdrop-blur-sm sticky top-0 z-50"> <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="container mx-auto px-4 py-4">
@ -59,21 +49,14 @@ export function Header() {
<Database className="h-4 w-4" /> <Database className="h-4 w-4" />
<span>12,450 Ulasan</span> <span>12,450 Ulasan</span>
</div> </div>
<div className="flex items-center gap-2 text-muted-foreground">
<Smile className="h-4 w-4" />
<span>{`Hi, ${session.data?.user?.name || "Guest"}`}</span>
</div> </div>
</div> <div onClick={() => setOpen(true)}>
<div onMouseEnter={() => setOpen(true)}>
<DropdownMenu open={open} onOpenChange={setOpen}> <DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <div className="flex items-center gap-2 text-muted-foreground cursor-pointer">
size="sm" <span>{`Hi, ${session.data?.user?.name || "Guest"}`}</span>
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")} /> <User className={cn("h-4 w-4 text-muted-foreground")} />
<span className="hidden sm:inline">Profile</span> </div>
</Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent

View File

@ -4,14 +4,9 @@ import { Button } from "../ui/button";
import { ArrowLeft, Pencil } from "lucide-react"; import { ArrowLeft, Pencil } from "lucide-react";
import { Separator } from "../ui/separator"; import { Separator } from "../ui/separator";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import { UserGender } from "@prisma/client";
import Link from "next/link"; import Link from "next/link";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { ProfileClientProps } from "@/src/types";
interface ProfileClientProps {
gender?: UserGender;
productReference?: string;
}
export default function ProfileClient({ export default function ProfileClient({
gender, gender,
@ -27,13 +22,15 @@ export default function ProfileClient({
transition={{ duration: 0.4, ease: "easeInOut" }} transition={{ duration: 0.4, ease: "easeInOut" }}
className="container mx-auto px-4 py-8" className="container mx-auto px-4 py-8"
> >
<div className="flex max-w-xl mx-auto">
<Link <Link
href="/" href="/"
className="flex items-center gap-2 text-md text-primary max-w-xl mx-auto" className="flex items-center gap-2 text-md text-primary max-w-xl w-max mr-auto hover:text-primary/80 transition-colors"
> >
<ArrowLeft className="w-4 h-4" /> <ArrowLeft className="w-4 h-4" />
<span>Back to Dashboard</span> <span>Back to Dashboard</span>
</Link> </Link>
</div>
<motion.div <motion.div
initial={{ opacity: 0, scale: 0.95 }} initial={{ opacity: 0, scale: 0.95 }}

View File

@ -0,0 +1,22 @@
import { cn } from "@/lib/utils";
import { Star } from "lucide-react";
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>
);
};
export default renderStars;

View File

@ -6,77 +6,21 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "../../components/ui/table"; } from "../../components/ui/table";
import { Badge } from "../../components/ui/badge"; import { ReviewTableProps } from "@/src/types";
import { cn } from "@/lib/utils"; import getSentimentBadge from "./SentimentBadge";
import { Star } from "lucide-react"; import renderStars from "./RenderStars";
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) { 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 ( return (
<div className="rounded-xl border bg-card"> <div className="rounded-xl border bg-card">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow className="hover:bg-transparent"> <TableRow className="hover:bg-transparent">
<TableHead className="w-[200px]">Produk</TableHead> <TableHead className="w-50">Produk</TableHead>
<TableHead className="min-w-[300px]">Ulasan</TableHead> <TableHead className="min-w-75">Ulasan</TableHead>
<TableHead className="w-[100px]">Rating</TableHead> <TableHead className="w-25">Rating</TableHead>
<TableHead className="w-[100px]">Sentimen</TableHead> <TableHead className="w-25">Sentimen</TableHead>
<TableHead className="w-[100px] text-right">Confidence</TableHead> <TableHead className="w-25 text-right">Confidence</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@ -88,9 +32,7 @@ export function ReviewTable({ reviews }: ReviewTableProps) {
> >
<TableCell className="max-w-40 overflow-hidden"> <TableCell className="max-w-40 overflow-hidden">
<div className="max-w-40"> <div className="max-w-40">
<p className="font-medium text-foreground"> <p className="font-medium text-foreground">{review.brand}</p>
{review.brand}
</p>
<p className="text-sm text-muted-foreground truncate"> <p className="text-sm text-muted-foreground truncate">
{review.product} {review.product}
</p> </p>

View File

@ -1,26 +1,10 @@
import { useMemo, useState } from "react";
import { Button } from "../../components/ui/button"; import { Button } from "../../components/ui/button";
import { Textarea } from "../../components/ui/textarea"; import { Textarea } from "../../components/ui/textarea";
import { Badge } from "../../components/ui/badge"; import { Badge } from "../../components/ui/badge";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import { Loader2, Send, Sparkles } from "lucide-react";
Loader2,
Send,
Sparkles,
ThumbsUp,
ThumbsDown,
Minus,
} from "lucide-react";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
import { Input } from "../ui/input"; import { Input } from "../ui/input";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "../ui/select";
import { import {
Combobox, Combobox,
ComboboxContent, ComboboxContent,
@ -30,157 +14,25 @@ import {
ComboboxList, ComboboxList,
} from "../ui/combobox"; } from "../ui/combobox";
import { Item, ItemContent, ItemDescription, ItemTitle } from "../ui/item"; import { Item, ItemContent, ItemDescription, ItemTitle } from "../ui/item";
import { useSentiment } from "@/src/hooks/useSentiment";
interface AnalysisResult {
sentiment: "positif" | "negatif" | "netral";
confidence: number;
keywords: string[];
}
export function SentimentAnalyzer() { export function SentimentAnalyzer() {
const [text, setText] = useState(""); const {
const [laptopName, setLaptopName] = useState(""); selectedModel,
const [isAnalyzing, setIsAnalyzing] = useState(false); setSelectedModel,
const [result, setResult] = useState<AnalysisResult | null>(null); text,
const [selectedModel, setSelectedModel] = useState< setText,
(typeof models)[number] | null laptopName,
>(null); setLaptopName,
const [searchQuery, setSearchQuery] = useState(""); isAnalyzing,
analyzeText,
const isFormValid = result,
text.trim() !== "" && laptopName.trim() !== "" && selectedModel !== null; getSentimentDisplay,
searchQuery,
const analyzeText = async () => { setSearchQuery,
if (!text.trim()) return; filteredItems,
isFormValid,
setIsAnalyzing(true); } = useSentiment();
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];
};
const models = [
{
code: "none",
value: "xgboost",
label: "XGBoost (Baseline)",
desc: "Model 1",
},
{
code: "Grid Search",
value: "xgboost",
label: "XGBoost (Tuned)",
desc: "Model 2",
},
{
code: "recommended",
value: "xgboost",
label: "XGBoost (Fully Optimized)",
desc: "Model 3",
},
];
const filteredItems = useMemo(() => {
if (!searchQuery) return models;
return models.filter(
(model) =>
model.label.toLowerCase().includes(searchQuery.toLowerCase()) ||
model.code.toLowerCase().includes(searchQuery.toLowerCase()),
);
}, [searchQuery]);
return ( return (
<div className="rounded-xl border bg-card p-6"> <div className="rounded-xl border bg-card p-6">

View File

@ -0,0 +1,25 @@
import { Review } from "@/src/types";
import { Badge } from "../ui/badge";
import { cn } from "@/lib/utils";
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>
);
};
export default getSentimentBadge;

View File

@ -1,3 +1,4 @@
import { SentimentChartProps } from "@/src/types";
import { import {
PieChart, PieChart,
Pie, Pie,
@ -6,68 +7,14 @@ import {
Legend, Legend,
Tooltip, Tooltip,
} from "recharts"; } from "recharts";
import renderCustomLabel from "./CustomLabel";
interface SentimentData { import CustomTooltip from "./CustomToolTip";
name: string;
value: number;
color: string;
}
interface SentimentChartProps {
data: SentimentData[];
}
export function SentimentChart({ data }: SentimentChartProps) { export function SentimentChart({ data }: SentimentChartProps) {
const total = data.reduce((sum, item) => sum + item.value, 0); 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 ( return (
<div className="rounded-lg border bg-card px-4 py-3 shadow-lg"> <div className="h-75 w-full">
<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%"> <ResponsiveContainer width="100%" height="100%">
<PieChart> <PieChart>
<Pie <Pie
@ -91,7 +38,7 @@ export function SentimentChart({ data }: SentimentChartProps) {
/> />
))} ))}
</Pie> </Pie>
<Tooltip content={<CustomTooltip />} /> <Tooltip content={<CustomTooltip total={total} />} />
<Legend <Legend
verticalAlign="bottom" verticalAlign="bottom"
height={36} height={36}

View File

@ -1,19 +1,7 @@
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { LucideIcon } from "lucide-react"; import { useStatCard } from "@/src/hooks/useStatCard";
import { useEffect, useState } from "react"; import { StatCardProps } from "@/src/types";
import { iconStyles, variantStyles } from "@/src/utils/styleType";
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({ export function StatCard({
title, title,
@ -24,63 +12,14 @@ export function StatCard({
variant = "default", variant = "default",
delay = 0, delay = 0,
}: StatCardProps) { }: StatCardProps) {
const [displayValue, setDisplayValue] = useState(0); const { isVisible, displayValue } = useStatCard({ title, value, icon: Icon });
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 ( return (
<div <div
className={cn( className={cn(
"relative overflow-hidden rounded-xl border p-6 card-elevated transition-all duration-500", "relative overflow-hidden rounded-xl border p-6 card-elevated transition-all duration-500",
variantStyles[variant], variantStyles[variant],
isVisible ? "opacity-100 translate-y-0" : "opacity-0 translate-y-4" isVisible ? "opacity-100 translate-y-0" : "opacity-0 translate-y-4",
)} )}
> >
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
@ -101,10 +40,13 @@ export function StatCard({
<span <span
className={cn( className={cn(
"font-medium", "font-medium",
trend.isPositive ? "text-sentiment-positive" : "text-sentiment-negative" trend.isPositive
? "text-sentiment-positive"
: "text-sentiment-negative",
)} )}
> >
{trend.isPositive ? "+" : "-"}{Math.abs(trend.value)}% {trend.isPositive ? "+" : "-"}
{Math.abs(trend.value)}%
</span> </span>
<span className="text-muted-foreground">dari periode lalu</span> <span className="text-muted-foreground">dari periode lalu</span>
</div> </div>

View File

@ -1,56 +1,24 @@
"use client"; "use client";
import { useEffect, useState } from "react";
import { useTrendChart } from "@/src/hooks/useTrendChart";
import { TrendChartProps } from "@/src/types";
import { import {
AreaChart,
Area, Area,
AreaChart,
CartesianGrid,
Legend,
ResponsiveContainer,
Tooltip,
XAxis, XAxis,
YAxis, YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Legend,
} from "recharts"; } from "recharts";
import TrendChartTooltip from "./TrendChartToolTip";
interface TrendData {
date: string;
positif: number;
negatif: number;
netral: number;
}
interface TrendChartProps {
data: TrendData[];
}
export function TrendChart({ data }: TrendChartProps) { export function TrendChart({ data }: TrendChartProps) {
const [isMounted, setIsMounted] = useState(false); const { isMounted } = useTrendChart();
useEffect(() => {
setIsMounted(true);
}, []);
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;
};
if (!isMounted) { if (!isMounted) {
return <div className="h-[350px] w-full bg-transparent" />; return <div className="h-87.5 w-full bg-transparent" />;
} }
return ( return (
@ -112,7 +80,7 @@ export function TrendChart({ data }: TrendChartProps) {
tickLine={false} tickLine={false}
axisLine={false} axisLine={false}
/> />
<Tooltip content={<CustomTooltip />} /> <Tooltip content={<TrendChartTooltip />} />
<Legend <Legend
verticalAlign="top" verticalAlign="top"
height={36} height={36}

View File

@ -0,0 +1,30 @@
import { TrendChartTooltipProps } from "@/src/types";
import React from "react";
const TrendChartTooltip: React.FC<TrendChartTooltipProps> = ({
active,
payload,
label,
}) => {
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;
};
export default TrendChartTooltip;

View File

@ -1,73 +1,30 @@
"use client"; "use client";
import { cn } from "@/lib/utils"; import { WordCloudProps } from "@/src/types";
import { useState, useEffect, useMemo } from "react"; import WordCloudItem from "./WordCloudItem";
import { useWordCloud } from "@/src/hooks/useWordCloud";
interface WordItem {
text: string;
value: number;
sentiment: "positive" | "negative" | "neutral";
}
interface WordCloudProps {
words: WordItem[];
}
export function WordCloud({ words }: WordCloudProps) { export function WordCloud({ words }: WordCloudProps) {
const [mounted, setMounted] = useState(false); const { mounted, maxValue, minValue, shuffledWords } = useWordCloud({
words,
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) { if (!mounted) {
return ( return (
<div className="flex flex-wrap items-center justify-center gap-2 p-4 min-h-[150px]" /> <div className="flex flex-wrap items-center justify-center gap-2 p-4 min-h-37.5" />
); );
} }
return ( return (
<div className="flex flex-wrap items-center justify-center gap-2 p-4"> <div className="flex flex-wrap items-center justify-center gap-2 p-4">
{shuffledWords.map((word, index) => ( {shuffledWords.map((word, index) => (
<span <WordCloudItem
key={`${word.text}-${index}`} key={`${word.text}-${index}`}
className={cn( word={word}
"cursor-default rounded-lg px-2 py-1 font-medium transition-all duration-200 animate-in fade-in zoom-in", index={index}
getColor(word.sentiment), maxValue={maxValue}
)} minValue={minValue}
style={{ />
fontSize: `${getSize(word.value)}rem`,
animationDelay: `${index * 50}ms`,
animationFillMode: "both",
}}
title={`${word.text}: ${word.value} kemunculan`}
>
{word.text}
</span>
))} ))}
</div> </div>
); );

View File

@ -0,0 +1,47 @@
"use client";
import { cn } from "@/lib/utils";
import { WordCloudItemProps, WordItem } from "@/src/types";
const WordCloudItem: React.FC<WordCloudItemProps> = ({
word,
index,
maxValue,
minValue,
}) => {
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";
}
};
return (
<span
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>
);
};
export default WordCloudItem;

53
src/hooks/useDashboard.ts Normal file
View File

@ -0,0 +1,53 @@
"use client";
import { useEffect, useState } from "react";
import { ModelDB } from "../types";
import { reviewData, sentimentDistribution } from "../app/dashboard/lib/data";
import { getClassificationReport } from "../app/dashboard/lib/actions";
export const useDashboard = () => {
const [selectedBrand, setSelectedBrand] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [modelData, setModelData] = useState<ModelDB[]>([]);
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;
useEffect(() => {
async function fetchData() {
try {
const data = await getClassificationReport();
setModelData(data);
} catch (error) {
console.error("Failed to fetch model data", error);
} finally {
setLoading(false);
}
}
fetchData();
}, []);
return {
totalReviews,
positiveCount,
negativeCount,
neutralCount,
filteredReviews,
selectedBrand,
loading,
modelData,
setSelectedBrand,
};
};

15
src/hooks/useHeader.ts Normal file
View File

@ -0,0 +1,15 @@
import { useSession } from "next-auth/react";
import { useState } from "react";
export const useHeader = () => {
const [isRefreshing, setIsRefreshing] = useState(false);
const [open, setOpen] = useState(false);
const session = useSession();
const handleRefresh = () => {
setIsRefreshing(true);
setTimeout(() => setIsRefreshing(false), 1500);
};
return { open, setOpen, session };
};

166
src/hooks/useSentiment.ts Normal file
View File

@ -0,0 +1,166 @@
import { useMemo, useState } from "react";
import { AnalysisResult } from "../types";
import { Minus, ThumbsDown, ThumbsUp } from "lucide-react";
export const useSentiment = () => {
const [text, setText] = useState("");
const [laptopName, setLaptopName] = useState("");
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [result, setResult] = useState<AnalysisResult | null>(null);
const [selectedModel, setSelectedModel] = useState<
(typeof models)[number] | null
>(null);
const [searchQuery, setSearchQuery] = useState("");
const isFormValid =
text.trim() !== "" && laptopName.trim() !== "" && selectedModel !== null;
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];
};
const models = [
{
code: "none",
value: "xgboost",
label: "XGBoost (Baseline)",
desc: "Model 1",
},
{
code: "Grid Search",
value: "xgboost",
label: "XGBoost (Tuned)",
desc: "Model 2",
},
{
code: "recommended",
value: "xgboost",
label: "XGBoost (Fully Optimized)",
desc: "Model 3",
},
];
const filteredItems = useMemo(() => {
if (!searchQuery) return models;
return models.filter(
(model) =>
model.label.toLowerCase().includes(searchQuery.toLowerCase()) ||
model.code.toLowerCase().includes(searchQuery.toLowerCase()),
);
}, [searchQuery]);
return {
selectedModel,
setSelectedModel,
text,
setText,
laptopName,
setLaptopName,
isAnalyzing,
analyzeText,
result,
getSentimentDisplay,
searchQuery,
setSearchQuery,
filteredItems,
isFormValid,
};
};

50
src/hooks/useStatCard.ts Normal file
View File

@ -0,0 +1,50 @@
import { useEffect, useState } from "react";
import { StatCardProps } from "../types";
export const useStatCard = ({
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++;
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]);
return { isVisible, displayValue };
};

View File

@ -0,0 +1,9 @@
import { useEffect, useState } from "react";
export const useTrendChart = () => {
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
}, []);
return { isMounted, setIsMounted };
};

19
src/hooks/useWordCloud.ts Normal file
View File

@ -0,0 +1,19 @@
import { useEffect, useMemo, useState } from "react";
import { WordCloudProps } from "../types";
export const useWordCloud = ({ 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 shuffledWords = useMemo(() => {
return [...words].sort(() => Math.random() - 0.5);
}, [words]);
return { mounted, maxValue, minValue, shuffledWords };
};

View File

@ -1,3 +1,6 @@
import { UserGender } from "@prisma/client";
import { LucideIcon } from "lucide-react";
export interface ModelDB { export interface ModelDB {
modelName: string; modelName: string;
description: string; description: string;
@ -6,3 +9,104 @@ export interface ModelDB {
f1Negative: number; f1Negative: number;
f1Neutral: number; f1Neutral: number;
} }
export interface ProfileClientProps {
gender?: UserGender;
productReference?: string;
}
interface Brand {
name: string;
count: number;
logo?: string;
}
export interface BrandFilterProps {
brands: Brand[];
selectedBrand: string | null;
onSelect: (brand: string | null) => void;
}
export interface Review {
id: string;
product: string;
brand: string;
review: string;
rating: number;
sentiment: "positif" | "negatif" | "netral";
date: string;
confidence: number;
}
export interface ReviewTableProps {
reviews: Review[];
}
export interface AnalysisResult {
sentiment: "positif" | "negatif" | "netral";
confidence: number;
keywords: string[];
}
interface SentimentData {
name: string;
value: number;
color: string;
}
export interface SentimentChartProps {
data: SentimentData[];
}
export interface CustomTooltipProps {
active?: boolean;
payload?: any[];
total: number;
}
export interface StatCardProps {
title: string;
value: number;
suffix?: string;
icon: LucideIcon;
trend?: {
value: number;
isPositive: boolean;
};
variant?: "default" | "positive" | "negative" | "neutral";
delay?: number;
}
interface TrendData {
date: string;
positif: number;
negatif: number;
netral: number;
}
export interface TrendChartProps {
data: TrendData[];
}
export interface TrendChartTooltipProps {
active?: boolean;
payload?: any[];
label?: string;
}
export interface WordItem {
text: string;
value: number;
sentiment: "positive" | "negative" | "neutral";
}
export interface WordCloudProps {
words: WordItem[];
}
export interface WordCloudItemProps {
word: WordItem;
index: number;
maxValue: number;
minValue: number;
}

25
src/utils/dImports.ts Normal file
View File

@ -0,0 +1,25 @@
import dynamic from "next/dynamic";
const TrendChart = dynamic(
() =>
import("../components/dashboards/TrendChart").then((mod) => ({
default: mod.TrendChart,
})),
{ ssr: false },
);
const WordCloud = dynamic(
() =>
import("../components/dashboards/WordCloud").then((mod) => ({
default: mod.WordCloud,
})),
{ ssr: false },
);
const SentimentChart = dynamic(
() =>
import("../components/dashboards/SentimentChart").then((mod) => ({
default: mod.SentimentChart,
})),
{ ssr: false },
);
export { TrendChart, WordCloud, SentimentChart };

16
src/utils/styleType.ts Normal file
View File

@ -0,0 +1,16 @@
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",
};
export { variantStyles, iconStyles };