refactor: implement custom hooks on all relevant components that require them.
This commit is contained in:
parent
d2bcba7eb1
commit
0c03517965
|
|
@ -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;
|
||||
|
|
@ -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"
|
||||
|
|
@ -1,18 +1,11 @@
|
|||
import { cn } from "@/lib/utils";
|
||||
import { BrandFilterProps } from "@/src/types";
|
||||
|
||||
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) {
|
||||
export function BrandFilter({
|
||||
brands,
|
||||
selectedBrand,
|
||||
onSelect,
|
||||
}: BrandFilterProps) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<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",
|
||||
selectedBrand === null
|
||||
? "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()})
|
||||
|
|
@ -34,7 +27,7 @@ export function BrandFilter({ brands, selectedBrand, onSelect }: BrandFilterProp
|
|||
"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"
|
||||
: "border-border bg-card text-muted-foreground hover:border-primary/50 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{brand.name} ({brand.count.toLocaleString()})
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Header } from "./Header";
|
||||
import {
|
||||
MessageSquareText,
|
||||
|
|
@ -9,70 +8,32 @@ import {
|
|||
TrendingUp,
|
||||
} from "lucide-react";
|
||||
import { StatCard } from "./StatCard";
|
||||
import { ModelDB } from "@/src/types";
|
||||
import {
|
||||
brandData,
|
||||
reviewData,
|
||||
sentimentDistribution,
|
||||
trendData,
|
||||
wordCloudData,
|
||||
} from "@/src/app/dashboard/lib/data";
|
||||
import { getClassificationReport } from "@/src/app/dashboard/lib/actions";
|
||||
import { ModelInfoSkeleton } from "../skeletons/ModelInfoSkeleton";
|
||||
import { ModelInfo } from "./ModelInfo";
|
||||
import { SentimentAnalyzer } from "./SentimentAnalyzer";
|
||||
import { BrandFilter } from "./BrandFilter";
|
||||
import { ReviewTable } from "./ReviewTable";
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
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 },
|
||||
);
|
||||
import { SentimentChart, TrendChart, WordCloud } from "@/src/utils/dImports";
|
||||
import { useDashboard } from "@/src/hooks/useDashboard";
|
||||
|
||||
export default function DashboardClient() {
|
||||
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();
|
||||
}, []);
|
||||
const {
|
||||
totalReviews,
|
||||
positiveCount,
|
||||
negativeCount,
|
||||
neutralCount,
|
||||
filteredReviews,
|
||||
selectedBrand,
|
||||
setSelectedBrand,
|
||||
loading,
|
||||
modelData,
|
||||
} = useDashboard();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
|
|
@ -102,7 +63,6 @@ export default function DashboardClient() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="mb-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard
|
||||
title="Total Ulasan"
|
||||
|
|
@ -137,7 +97,6 @@ export default function DashboardClient() {
|
|||
/>
|
||||
</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">
|
||||
|
|
@ -151,9 +110,7 @@ export default function DashboardClient() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Word Cloud & Model Info */}
|
||||
<div className="mb-8 grid gap-6 lg:grid-cols-2">
|
||||
{/* Slot Kata Kunci */}
|
||||
<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">
|
||||
|
|
@ -174,12 +131,10 @@ export default function DashboardClient() {
|
|||
)}
|
||||
</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>
|
||||
|
|
@ -197,7 +152,6 @@ export default function DashboardClient() {
|
|||
<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>
|
||||
|
|
|
|||
|
|
@ -4,12 +4,9 @@ import {
|
|||
Database,
|
||||
Laptop,
|
||||
LogOut,
|
||||
Smile,
|
||||
User,
|
||||
UserCircle,
|
||||
} from "lucide-react";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
DropdownMenu,
|
||||
|
|
@ -18,19 +15,12 @@ import {
|
|||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "../ui/dropdown-menu";
|
||||
import { signOut, useSession } from "next-auth/react";
|
||||
import { signOut } from "next-auth/react";
|
||||
import Link from "next/link";
|
||||
import { useHeader } from "@/src/hooks/useHeader";
|
||||
|
||||
export function Header() {
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const session = useSession();
|
||||
|
||||
const handleRefresh = () => {
|
||||
setIsRefreshing(true);
|
||||
setTimeout(() => setIsRefreshing(false), 1500);
|
||||
};
|
||||
|
||||
const { open, setOpen, session } = useHeader();
|
||||
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">
|
||||
|
|
@ -59,21 +49,14 @@ export function Header() {
|
|||
<Database className="h-4 w-4" />
|
||||
<span>12,450 Ulasan</span>
|
||||
</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 onMouseEnter={() => setOpen(true)}>
|
||||
<div onClick={() => setOpen(true)}>
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
className="gap-2 focus-visible:ring-0 border-border hover:bg-secondary hover:text-white transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-muted-foreground cursor-pointer">
|
||||
<span>{`Hi, ${session.data?.user?.name || "Guest"}`}</span>
|
||||
<User className={cn("h-4 w-4 text-muted-foreground")} />
|
||||
<span className="hidden sm:inline">Profile</span>
|
||||
</Button>
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent
|
||||
|
|
|
|||
|
|
@ -4,14 +4,9 @@ import { Button } from "../ui/button";
|
|||
import { ArrowLeft, Pencil } from "lucide-react";
|
||||
import { Separator } from "../ui/separator";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { UserGender } from "@prisma/client";
|
||||
import Link from "next/link";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
interface ProfileClientProps {
|
||||
gender?: UserGender;
|
||||
productReference?: string;
|
||||
}
|
||||
import { ProfileClientProps } from "@/src/types";
|
||||
|
||||
export default function ProfileClient({
|
||||
gender,
|
||||
|
|
@ -27,13 +22,15 @@ export default function ProfileClient({
|
|||
transition={{ duration: 0.4, ease: "easeInOut" }}
|
||||
className="container mx-auto px-4 py-8"
|
||||
>
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-2 text-md text-primary max-w-xl mx-auto"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
<span>Back to Dashboard</span>
|
||||
</Link>
|
||||
<div className="flex max-w-xl mx-auto">
|
||||
<Link
|
||||
href="/"
|
||||
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" />
|
||||
<span>Back to Dashboard</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -6,77 +6,21 @@ import {
|
|||
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[];
|
||||
}
|
||||
import { ReviewTableProps } from "@/src/types";
|
||||
import getSentimentBadge from "./SentimentBadge";
|
||||
import renderStars from "./RenderStars";
|
||||
|
||||
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>
|
||||
<TableHead className="w-50">Produk</TableHead>
|
||||
<TableHead className="min-w-75">Ulasan</TableHead>
|
||||
<TableHead className="w-25">Rating</TableHead>
|
||||
<TableHead className="w-25">Sentimen</TableHead>
|
||||
<TableHead className="w-25 text-right">Confidence</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
|
|
@ -88,9 +32,7 @@ export function ReviewTable({ reviews }: ReviewTableProps) {
|
|||
>
|
||||
<TableCell className="max-w-40 overflow-hidden">
|
||||
<div className="max-w-40">
|
||||
<p className="font-medium text-foreground">
|
||||
{review.brand}
|
||||
</p>
|
||||
<p className="font-medium text-foreground">{review.brand}</p>
|
||||
<p className="text-sm text-muted-foreground truncate">
|
||||
{review.product}
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -1,26 +1,10 @@
|
|||
import { useMemo, 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 { Loader2, Send, Sparkles } from "lucide-react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Input } from "../ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../ui/select";
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxContent,
|
||||
|
|
@ -30,157 +14,25 @@ import {
|
|||
ComboboxList,
|
||||
} from "../ui/combobox";
|
||||
import { Item, ItemContent, ItemDescription, ItemTitle } from "../ui/item";
|
||||
|
||||
interface AnalysisResult {
|
||||
sentiment: "positif" | "negatif" | "netral";
|
||||
confidence: number;
|
||||
keywords: string[];
|
||||
}
|
||||
import { useSentiment } from "@/src/hooks/useSentiment";
|
||||
|
||||
export function SentimentAnalyzer() {
|
||||
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]);
|
||||
const {
|
||||
selectedModel,
|
||||
setSelectedModel,
|
||||
text,
|
||||
setText,
|
||||
laptopName,
|
||||
setLaptopName,
|
||||
isAnalyzing,
|
||||
analyzeText,
|
||||
result,
|
||||
getSentimentDisplay,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
filteredItems,
|
||||
isFormValid,
|
||||
} = useSentiment();
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border bg-card p-6">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { SentimentChartProps } from "@/src/types";
|
||||
import {
|
||||
PieChart,
|
||||
Pie,
|
||||
|
|
@ -6,68 +7,14 @@ import {
|
|||
Legend,
|
||||
Tooltip,
|
||||
} from "recharts";
|
||||
|
||||
interface SentimentData {
|
||||
name: string;
|
||||
value: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface SentimentChartProps {
|
||||
data: SentimentData[];
|
||||
}
|
||||
import renderCustomLabel from "./CustomLabel";
|
||||
import CustomTooltip from "./CustomToolTip";
|
||||
|
||||
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">
|
||||
<div className="h-75 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
|
|
@ -91,7 +38,7 @@ export function SentimentChart({ data }: SentimentChartProps) {
|
|||
/>
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Tooltip content={<CustomTooltip total={total} />} />
|
||||
<Legend
|
||||
verticalAlign="bottom"
|
||||
height={36}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,7 @@
|
|||
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;
|
||||
}
|
||||
import { useStatCard } from "@/src/hooks/useStatCard";
|
||||
import { StatCardProps } from "@/src/types";
|
||||
import { iconStyles, variantStyles } from "@/src/utils/styleType";
|
||||
|
||||
export function StatCard({
|
||||
title,
|
||||
|
|
@ -24,63 +12,14 @@ export function StatCard({
|
|||
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",
|
||||
};
|
||||
const { isVisible, displayValue } = useStatCard({ title, value, icon: Icon });
|
||||
|
||||
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"
|
||||
isVisible ? "opacity-100 translate-y-0" : "opacity-0 translate-y-4",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
|
|
@ -101,10 +40,13 @@ export function StatCard({
|
|||
<span
|
||||
className={cn(
|
||||
"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 className="text-muted-foreground">dari periode lalu</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,56 +1,24 @@
|
|||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { useTrendChart } from "@/src/hooks/useTrendChart";
|
||||
import { TrendChartProps } from "@/src/types";
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
AreaChart,
|
||||
CartesianGrid,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Legend,
|
||||
} from "recharts";
|
||||
|
||||
interface TrendData {
|
||||
date: string;
|
||||
positif: number;
|
||||
negatif: number;
|
||||
netral: number;
|
||||
}
|
||||
|
||||
interface TrendChartProps {
|
||||
data: TrendData[];
|
||||
}
|
||||
import TrendChartTooltip from "./TrendChartToolTip";
|
||||
|
||||
export function TrendChart({ data }: TrendChartProps) {
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
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;
|
||||
};
|
||||
const { isMounted } = useTrendChart();
|
||||
|
||||
if (!isMounted) {
|
||||
return <div className="h-[350px] w-full bg-transparent" />;
|
||||
return <div className="h-87.5 w-full bg-transparent" />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -112,7 +80,7 @@ export function TrendChart({ data }: TrendChartProps) {
|
|||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Tooltip content={<TrendChartTooltip />} />
|
||||
<Legend
|
||||
verticalAlign="top"
|
||||
height={36}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -1,73 +1,30 @@
|
|||
"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[];
|
||||
}
|
||||
import { WordCloudProps } from "@/src/types";
|
||||
import WordCloudItem from "./WordCloudItem";
|
||||
import { useWordCloud } from "@/src/hooks/useWordCloud";
|
||||
|
||||
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]);
|
||||
const { mounted, maxValue, minValue, shuffledWords } = useWordCloud({
|
||||
words,
|
||||
});
|
||||
|
||||
if (!mounted) {
|
||||
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 (
|
||||
<div className="flex flex-wrap items-center justify-center gap-2 p-4">
|
||||
{shuffledWords.map((word, index) => (
|
||||
<span
|
||||
<WordCloudItem
|
||||
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>
|
||||
word={word}
|
||||
index={index}
|
||||
maxValue={maxValue}
|
||||
minValue={minValue}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
@ -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 };
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
@ -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 };
|
||||
};
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import { useEffect, useState } from "react";
|
||||
|
||||
export const useTrendChart = () => {
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
return { isMounted, setIsMounted };
|
||||
};
|
||||
|
|
@ -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 };
|
||||
};
|
||||
|
|
@ -1,3 +1,6 @@
|
|||
import { UserGender } from "@prisma/client";
|
||||
import { LucideIcon } from "lucide-react";
|
||||
|
||||
export interface ModelDB {
|
||||
modelName: string;
|
||||
description: string;
|
||||
|
|
@ -6,3 +9,104 @@ export interface ModelDB {
|
|||
f1Negative: 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
Loading…
Reference in New Issue