style: improve profile card UI
This commit is contained in:
parent
641bc5bfcf
commit
da42cd48ac
|
|
@ -0,0 +1,8 @@
|
|||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `version` on the `Model` table. All the data in the column will be lost.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "Model" DROP COLUMN "version";
|
||||
|
|
@ -166,7 +166,6 @@ model Model {
|
|||
id Int @id @default(autoincrement())
|
||||
modelName String
|
||||
description String?
|
||||
version String?
|
||||
|
||||
accuracy Float
|
||||
macroF1 Float
|
||||
|
|
|
|||
|
|
@ -8,14 +8,26 @@ export const getAnotherUserData = async () => {
|
|||
|
||||
if (!session?.user?.email) return null;
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: session.user.email },
|
||||
const userData = await prisma.user.findUnique({
|
||||
where: {
|
||||
email: session.user.email,
|
||||
},
|
||||
select: {
|
||||
gender: true,
|
||||
bio: true,
|
||||
preference: {
|
||||
select: {
|
||||
id: true,
|
||||
profession: true,
|
||||
preferedBrand: true,
|
||||
preferredOS: true,
|
||||
budgetMin: true,
|
||||
budgetMax: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return user;
|
||||
return userData;
|
||||
} catch (error) {
|
||||
console.error("Error fetching user data:", error);
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -1,18 +1,11 @@
|
|||
import { Header } from "@/src/components/dashboards/Header";
|
||||
import { getAnotherUserData } from "./lib/action";
|
||||
import ProfileClient from "@/src/components/dashboards/ProfileClient";
|
||||
import { UserGender } from "@prisma/client";
|
||||
|
||||
export default async function ProfilePage() {
|
||||
const user = await getAnotherUserData();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="min-h-screen bg-[#F8FBFF]">
|
||||
<Header />
|
||||
<ProfileClient
|
||||
gender={user?.gender as UserGender}
|
||||
productReference={user?.productReference || "None"}
|
||||
/>
|
||||
</>
|
||||
<ProfileClient />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import {
|
|||
import { Input } from "../ui/input";
|
||||
import { Button } from "../ui/button";
|
||||
import ResultSection from "./ResultSection";
|
||||
import { professions } from "@/src/utils/datas";
|
||||
import { professions } from "@/src/utils/const";
|
||||
|
||||
export default function AnalysisClient() {
|
||||
const {
|
||||
|
|
@ -49,18 +49,30 @@ export default function AnalysisClient() {
|
|||
<SelectTrigger
|
||||
className={`w-full mb-6 ${!profession ? "text-gray-500" : "text-black"}`}
|
||||
>
|
||||
<SelectValue placeholder="-- Pilih Profesi/Kebutuhan --" />
|
||||
<SelectValue placeholder="Pilih Profesi/Kebutuhan" />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent
|
||||
className="bg-card border-border shadow-lg"
|
||||
position="popper"
|
||||
>
|
||||
{professions.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
{professions.map((item) => {
|
||||
const PIcon = item.icon;
|
||||
return (
|
||||
<SelectItem
|
||||
key={item.value}
|
||||
value={item.value}
|
||||
className="cursor-pointer hover:bg-primary hover:text-card focus:bg-primary focus:text-card"
|
||||
>
|
||||
<div className="flex gap-2 items-center">
|
||||
<span>
|
||||
<PIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</span>
|
||||
<span>{item.label}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,6 @@
|
|||
import { CustomTooltipProps } from "@/src/types";
|
||||
import React from "react";
|
||||
|
||||
const CustomTooltip: React.FC<CustomTooltipProps> = ({
|
||||
active,
|
||||
payload,
|
||||
total,
|
||||
}) => {
|
||||
const CustomTooltip = ({ active, payload, total }: CustomTooltipProps) => {
|
||||
if (active && payload && payload.length) {
|
||||
const item = payload[0].payload;
|
||||
const percentage = ((item.value / total) * 100).toFixed(1);
|
||||
|
|
|
|||
|
|
@ -14,11 +14,9 @@ import { BrandFilter } from "./BrandFilter";
|
|||
import { ReviewTable } from "./ReviewTable";
|
||||
import { SentimentChart, TrendChart } from "@/src/utils/dImports";
|
||||
import { useDashboards } from "@/src/hooks/useDashboard";
|
||||
import SentimentForm from "./SentimentAnalyzer";
|
||||
import { WordCloud } from "./WordCloud";
|
||||
import AnalysisPage from "@/src/app/analyze/page";
|
||||
import SentimentAnalyzer from "./SentimentAnalyzer";
|
||||
import AnalysisClient from "./AnalysisClient";
|
||||
import Footer from "./Footer";
|
||||
|
||||
export default function DashboardClient() {
|
||||
const {
|
||||
|
|
@ -133,7 +131,6 @@ export default function DashboardClient() {
|
|||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
{/* <SentimentAnalyzer /> */}
|
||||
<AnalysisClient />
|
||||
</div>
|
||||
|
||||
|
|
@ -154,20 +151,7 @@ export default function DashboardClient() {
|
|||
<ReviewTable />
|
||||
</div>
|
||||
|
||||
<footer className="mt-12 border-t pt-8">
|
||||
<div className="flex flex-col items-center justify-between gap-4 text-sm text-muted-foreground sm:flex-row">
|
||||
<div>
|
||||
<p className="font-medium text-foreground text-center lg:text-start md:text-start">
|
||||
SentiLaptop - Analisis Sentimen
|
||||
</p>
|
||||
<p>Skripsi oleh Syafrizal Wd Mahendra (E41222719)</p>
|
||||
</div>
|
||||
<div className="text-center lg:text-end md:text-end">
|
||||
<p>Politeknik Negeri Jember</p>
|
||||
<p>PSDKU Teknik Informatika Kampus 3 Nganjuk</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<Footer />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
export default function Footer() {
|
||||
return (
|
||||
<footer className="mt-12 border-t pt-8">
|
||||
<div className="flex flex-col items-center justify-between gap-4 text-sm text-muted-foreground sm:flex-row">
|
||||
<div>
|
||||
<p className="font-medium text-foreground text-center lg:text-start md:text-start">
|
||||
SentiLaptop - Analisis Sentimen
|
||||
</p>
|
||||
<p>Skripsi oleh Syafrizal Wd Mahendra (E41222719)</p>
|
||||
</div>
|
||||
<div className="text-center lg:text-end md:text-end">
|
||||
<p>Politeknik Negeri Jember</p>
|
||||
<p>PSDKU Teknik Informatika Kampus 3 Nganjuk</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
|
@ -17,7 +17,7 @@ export function ModelInfo({ data }: { data: ModelDB[] }) {
|
|||
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<div className="rounded-xl border bg-card p-6 flex items-center justify-center h-[350px]">
|
||||
<div className="rounded-xl border bg-card p-6 flex items-center justify-center h-87.5">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Data model tidak tersedia.
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,166 @@
|
|||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import Image from "next/image";
|
||||
import { Pencil, Briefcase, Wallet, Laptop, User, Monitor, Fan } from "lucide-react";
|
||||
import { ProfileClientProps } from "@/src/types";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { Button } from "../ui/button";
|
||||
import { Separator } from "../ui/separator";
|
||||
import { brandFormat, formatRupiah } from "@/src/utils/datas";
|
||||
|
||||
export default function ProfileCard({
|
||||
bio,
|
||||
preferenceBrand,
|
||||
preferenceOS,
|
||||
budgetMax,
|
||||
budgetMin,
|
||||
profession,
|
||||
}: ProfileClientProps) {
|
||||
const session = useSession();
|
||||
const { brands } = brandFormat({ preferenceBrand });
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, ease: "circOut" }}
|
||||
className="mx-auto w-full max-w-xl overflow-hidden rounded-2xl border bg-card mt-4"
|
||||
>
|
||||
<div className="relative bg-linier-to-r from-primary/5 via-primary/10 to-transparent p-6 sm:p-8">
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-5">
|
||||
<div className="relative">
|
||||
<Image
|
||||
src={session?.data?.user?.image ?? "/default-avatar.svg"}
|
||||
alt="User Avatar"
|
||||
width={88}
|
||||
height={88}
|
||||
className="h-20 w-20 rounded-full border-4 border-background object-cover shadow-sm"
|
||||
/>
|
||||
<div className="absolute bottom-1 right-1 h-4 w-4 rounded-full border-2 border-background bg-sentiment-positive"></div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-card-foreground tracking-tight">
|
||||
{session?.data?.user?.name || "Guest User"}
|
||||
</h1>
|
||||
<p className="text-sm font-medium text-muted-foreground mb-2">
|
||||
{session?.data?.user?.email || "Belum ada email"}
|
||||
</p>
|
||||
|
||||
{profession && (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-primary/10 px-2.5 py-0.5 text-xs font-semibold text-primary">
|
||||
<Fan className="w-3.5 h-3.5" />
|
||||
<span className="capitalize">{profession}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-full sm:w-auto gap-2 rounded-full shadow-sm"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
Edit Profile
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="p-6 sm:p-8 space-y-8">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
<User className="w-4 h-4" />
|
||||
Tentang Saya
|
||||
</div>
|
||||
<div className="rounded-xl bg-muted/50 p-4 border border-muted">
|
||||
<p className="text-sm leading-relaxed text-foreground/90 italic">
|
||||
{bio
|
||||
? `"${bio}"`
|
||||
: "Belum ada deskripsi profil. Ceritakan sedikit tentang aktivitas dan kebutuhan laptop Anda."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-5 rounded-xl border p-5">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-muted-foreground mb-3">
|
||||
<Laptop className="w-4 h-4 text-primary" />
|
||||
Preferensi Merek
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{brands.length > 0 ? (
|
||||
brands.map((brand, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="rounded-md bg-secondary px-2.5 py-1 text-xs font-semibold text-secondary-foreground border"
|
||||
>
|
||||
{brand}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Tidak ada preferensi
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-muted-foreground mb-3">
|
||||
<Monitor className="w-4 h-4 text-primary" />
|
||||
Sistem Operasi
|
||||
</div>
|
||||
<div>
|
||||
{preferenceOS ? (
|
||||
<span className="inline-flex items-center rounded-md bg-[#F8FBFF] px-2.5 py-1 text-xs font-bold text-primary border border-primary">
|
||||
{preferenceOS}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Bebas / Semua OS
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 rounded-xl border bg-linier-to-br from-green-50 to-emerald-50/30 p-5">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<Wallet className="w-4 h-4" />
|
||||
Rentang Anggaran
|
||||
</div>
|
||||
|
||||
{budgetMin || budgetMax ? (
|
||||
<div className="flex flex-col justify-center h-[calc(100%-2rem)]">
|
||||
<p className="text-sm text-muted-foreground mb-1">Dari</p>
|
||||
<p className="text-xl font-bold">
|
||||
{formatRupiah(budgetMin)}
|
||||
</p>
|
||||
|
||||
<div className="my-2 h-px w-full bg-border"></div>
|
||||
|
||||
<p className="text-sm text-muted-foreground mb-1">Hingga</p>
|
||||
<p className="text-xl font-bold text-sentiment-positive">
|
||||
{formatRupiah(budgetMax)}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-[calc(100%-2rem)] items-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Budget belum diatur.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,27 +1,13 @@
|
|||
"use client";
|
||||
import Image from "next/image";
|
||||
import { Button } from "../ui/button";
|
||||
import { ArrowLeft, Pencil } from "lucide-react";
|
||||
import { Separator } from "../ui/separator";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { motion } from "framer-motion";
|
||||
import { ProfileClientProps } from "@/src/types";
|
||||
import ProfileCard from "./ProfileCard";
|
||||
import { getAnotherUserData } from "@/src/app/profile/lib/action";
|
||||
|
||||
export default function ProfileClient({
|
||||
gender,
|
||||
productReference,
|
||||
}: ProfileClientProps) {
|
||||
const session = useSession();
|
||||
export default async function ProfileClient() {
|
||||
const user = await getAnotherUserData();
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.4, ease: "easeInOut" }}
|
||||
className="container mx-auto px-4 py-8"
|
||||
>
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="flex max-w-xl mx-auto">
|
||||
<Link
|
||||
href="/"
|
||||
|
|
@ -32,55 +18,14 @@ export default function ProfileClient({
|
|||
</Link>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.4, ease: "easeOut" }}
|
||||
className="mx-auto w-full max-w-xl rounded-xl border bg-background shadow-sm mt-4"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4 p-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Image
|
||||
src={session?.data?.user?.image ?? "file.svg"}
|
||||
alt="User Avatar"
|
||||
width={80}
|
||||
height={80}
|
||||
className="h-14 w-14 rounded-full border object-cover"
|
||||
/>
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold leading-tight">
|
||||
{session?.data?.user?.name || "Guest"}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{session?.data?.user?.email || "Not logged in"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2 bg-primary text-card border-none"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
Edit Profile
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 p-6 sm:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-muted-foreground">Gender</p>
|
||||
<p className="font-medium">{gender || "Not specified"}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-muted-foreground">Product Preference</p>
|
||||
<p className="font-medium">{productReference || "None"}</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
<ProfileCard
|
||||
bio={user?.bio || "None"}
|
||||
preferenceBrand={user?.preference?.preferedBrand || "None"}
|
||||
preferenceOS={user?.preference?.preferredOS || "None"}
|
||||
budgetMax={user?.preference?.budgetMax || 0}
|
||||
budgetMin={user?.preference?.budgetMin || 0}
|
||||
profession={user?.preference?.profession || "None"}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { AnalysisResults, ResultProps } from "@/src/types";
|
||||
import { ResultProps } from "@/src/types";
|
||||
import { motion } from "framer-motion";
|
||||
import { Trophy, ExternalLink, CheckCircle2, TrendingUp } from "lucide-react";
|
||||
|
||||
|
|
@ -24,7 +24,6 @@ export default function ResultSection({ result }: ResultProps) {
|
|||
},
|
||||
}}
|
||||
>
|
||||
{/* ================= HEADER WINNER ================= */}
|
||||
<motion.div
|
||||
variants={{
|
||||
hidden: { opacity: 0, y: -20, height: 0 },
|
||||
|
|
@ -37,7 +36,6 @@ export default function ResultSection({ result }: ResultProps) {
|
|||
}}
|
||||
className="bg-sentiment-positive text-card p-8 rounded-2xl text-center mb-12 relative overflow-hidden"
|
||||
>
|
||||
{/* Dekorasi Background Abstrak */}
|
||||
<div className="absolute top-0 right-0 -mt-10 -mr-10 w-64 h-64 bg-white/10 rounded-full blur-3xl"></div>
|
||||
<div className="absolute bottom-0 left-0 -mb-10 -ml-10 w-40 h-40 bg-primary/20 rounded-full blur-3xl"></div>
|
||||
|
||||
|
|
@ -62,7 +60,6 @@ export default function ResultSection({ result }: ResultProps) {
|
|||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* ================= CARDS GRID ================= */}
|
||||
<div className={`grid gap-8 ${getGridClass(result.details.length)}`}>
|
||||
{result.details.map((item, index) => {
|
||||
const isWinner = item.name === result.winning_product;
|
||||
|
|
@ -84,7 +81,6 @@ export default function ResultSection({ result }: ResultProps) {
|
|||
: "bg-white border-gray-200 hover:border-primary/50 hover:shadow-lg"
|
||||
}`}
|
||||
>
|
||||
{/* WINNER BADGE (Floating) */}
|
||||
{isWinner && (
|
||||
<div className="absolute -top-4 left-1/2 transform -translate-x-1/2 bg-sentiment-positive text-white px-6 py-1.5 rounded-full shadow-lg flex items-center gap-2 z-20">
|
||||
<Trophy className="w-4 h-4 fill-yellow-400 text-yellow-400" />
|
||||
|
|
@ -95,7 +91,6 @@ export default function ResultSection({ result }: ResultProps) {
|
|||
)}
|
||||
|
||||
<div className="p-6 md:p-8 flex flex-col h-full">
|
||||
{/* 1. HEADER KARTU */}
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between items-start gap-4">
|
||||
<h3
|
||||
|
|
@ -127,9 +122,7 @@ export default function ResultSection({ result }: ResultProps) {
|
|||
</a>
|
||||
</div>
|
||||
|
||||
{/* 2. STATISTIK UTAMA (Progress Bars) */}
|
||||
<div className="space-y-5 mb-8">
|
||||
{/* Kecocokan Profesi */}
|
||||
<div>
|
||||
<div className="flex justify-between items-end mb-2">
|
||||
<span className="text-sm font-medium text-gray-600 flex items-center gap-1.5">
|
||||
|
|
@ -158,7 +151,6 @@ export default function ResultSection({ result }: ResultProps) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sentimen Publik */}
|
||||
<div>
|
||||
<div className="flex justify-between items-end mb-2">
|
||||
<span className="text-sm font-medium text-gray-600 flex items-center gap-1.5">
|
||||
|
|
@ -182,7 +174,6 @@ export default function ResultSection({ result }: ResultProps) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3. FOOTER (Keywords) */}
|
||||
<div className="mt-auto pt-5 border-t border-gray-100">
|
||||
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">
|
||||
Kata Kunci
|
||||
|
|
|
|||
|
|
@ -1,212 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { Send, Loader2, AlertCircle, Sparkles } from "lucide-react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
} from "../ui/combobox";
|
||||
import { Item, ItemContent, ItemDescription, ItemTitle } from "../ui/item";
|
||||
import { Input } from "../ui/input";
|
||||
import { Textarea } from "../ui/textarea";
|
||||
import { Button } from "../ui/button";
|
||||
import { Badge } from "../ui/badge";
|
||||
import { getSentimentDisplay } from "@/src/utils/datas";
|
||||
import { useSentimentForm } from "@/src/hooks/useSentimentForm";
|
||||
|
||||
export default function SentimentAnalyzer() {
|
||||
const {
|
||||
selectedModel,
|
||||
searchQuery,
|
||||
laptopName,
|
||||
text,
|
||||
isAnalyzing,
|
||||
result,
|
||||
filteredItems,
|
||||
isFormValid,
|
||||
error,
|
||||
analyzeText,
|
||||
setSelectedModel,
|
||||
setSearchQuery,
|
||||
setLaptopName,
|
||||
setText,
|
||||
} = useSentimentForm();
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border bg-card p-6">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<Sparkles className="h-5 w-5 text-primary" />
|
||||
<h3 className="text-lg font-semibold">Analisis Sentimen Real-time</h3>
|
||||
</div>
|
||||
|
||||
<p className="mb-4 text-sm text-muted-foreground">
|
||||
Masukkan ulasan produk laptop untuk menganalisis sentimennya menggunakan
|
||||
model XGBoost
|
||||
</p>
|
||||
|
||||
<form onSubmit={analyzeText}>
|
||||
<div className="space-y-4">
|
||||
{error && (
|
||||
<div className="p-3 text-sm text-red-600 bg-red-50 border border-red-200 rounded-md flex items-center gap-2">
|
||||
<AlertCircle size={16} />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-4">
|
||||
<Combobox
|
||||
value={selectedModel}
|
||||
onValueChange={(value) => {
|
||||
if (value !== null) {
|
||||
setSelectedModel(value);
|
||||
}
|
||||
}}
|
||||
itemToStringValue={(model) => model?.label ?? ""}
|
||||
>
|
||||
<ComboboxInput
|
||||
placeholder="Pilih model analisis..."
|
||||
className="focus:ring-primary/20 border-border w-1/2"
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
<ComboboxContent className="bg-card border-border shadow-lg animate-in fade-in zoom-in-95 duration-200 z-50">
|
||||
{filteredItems.length === 0 && (
|
||||
<ComboboxEmpty className="text-muted-foreground py-3 px-4 text-sm text-center">
|
||||
{` Model "${searchQuery}" tidak ditemukan.`}
|
||||
</ComboboxEmpty>
|
||||
)}
|
||||
<ComboboxList className="p-1">
|
||||
{filteredItems.map((model) => (
|
||||
<ComboboxItem
|
||||
key={model.code}
|
||||
value={model}
|
||||
className="rounded-md cursor-pointer transition-colors gap-2 focus:bg-secondary focus:text-primary data-[selected]:bg-secondary data-[selected]:text-primary"
|
||||
>
|
||||
<Item size="default" className="p-1 bg-transparent">
|
||||
<ItemContent>
|
||||
<ItemTitle className="whitespace-nowrap font-medium text-foreground">
|
||||
{model.label}
|
||||
</ItemTitle>
|
||||
<ItemDescription className="text-muted-foreground/80 text-xs">
|
||||
{model.desc}
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
</ComboboxItem>
|
||||
))}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
|
||||
<Input
|
||||
className="w-1/2"
|
||||
placeholder="Masukkan nama laptop (misal: Asus ROG)"
|
||||
value={laptopName}
|
||||
onChange={(e) => setLaptopName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
placeholder="Tulis ulasan laptop di sini... (Contoh: Baterainya awet tapi kipas berisik)"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
rows={4}
|
||||
className="resize-none"
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!isFormValid || isAnalyzing}
|
||||
className="w-full gap-2"
|
||||
>
|
||||
{isAnalyzing ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Sedang Menganalisis...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="h-4 w-4" />
|
||||
Analisis Sentimen
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{result && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className={cn(
|
||||
"rounded-lg border p-4",
|
||||
getSentimentDisplay(result.sentiment).bgClass,
|
||||
getSentimentDisplay(result.sentiment).borderClass,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{(() => {
|
||||
const { icon: Icon, textClass } = getSentimentDisplay(
|
||||
result.sentiment,
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-12 w-12 items-center justify-center rounded-full bg-white/50",
|
||||
textClass,
|
||||
)}
|
||||
>
|
||||
<Icon className="h-6 w-6" />
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
<div>
|
||||
<p
|
||||
className={cn(
|
||||
"text-xl font-bold",
|
||||
getSentimentDisplay(result.sentiment).textClass,
|
||||
)}
|
||||
>
|
||||
{getSentimentDisplay(result.sentiment).label}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Tingkat Keyakinan (Confidence):{" "}
|
||||
{(result.confidence * 100).toFixed(1)}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{result.keywords && result.keywords.length > 0 && (
|
||||
<div className="mt-4 pt-4 border-t border-black/5 dark:border-white/5">
|
||||
<p className="mb-2 text-sm font-medium text-muted-foreground flex items-center gap-2">
|
||||
Kata Kunci Terdeteksi:
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{result.keywords.map((keyword, index) => (
|
||||
<Badge
|
||||
key={index}
|
||||
variant="secondary"
|
||||
className="text-xs px-2 py-1 bg-white/80 dark:bg-black/20 hover:bg-white border-black/10"
|
||||
>
|
||||
{keyword}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { cn } from "@/lib/utils";
|
||||
import { WordCloudItemProps, WordItem } from "@/src/types";
|
||||
import { WordCloudItemProps } from "@/src/types";
|
||||
import { setWordCloud } from "@/src/utils/datas";
|
||||
|
||||
const WordCloudItem: React.FC<WordCloudItemProps> = ({
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ export function ModelInfoSkeleton() {
|
|||
<div className="h-4.5 w-13.5 rounded-full bg-gray-200" />
|
||||
</div>
|
||||
|
||||
<div className="mb-6 min-h-[40px] space-y-2">
|
||||
<div className="mb-6 min-h-10 space-y-2">
|
||||
<div className="h-4 w-3/4 rounded bg-gray-100" />
|
||||
<div className="h-4 w-1/2 rounded bg-gray-100" />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { MODEL_OPTIONS } from "../utils/datas";
|
||||
import { MODEL_OPTIONS } from "../utils/const";
|
||||
|
||||
export const useSentimentForm = () => {
|
||||
const [selectedModel, setSelectedModel] = useState(MODEL_OPTIONS[2]);
|
||||
|
|
|
|||
|
|
@ -1,9 +0,0 @@
|
|||
// import { useEffect, useState } from "react";
|
||||
|
||||
// export const useTrendChart = () => {
|
||||
// const isMounted = true;
|
||||
// useEffect(() => {
|
||||
// setIsMounted(true);
|
||||
// }, []);
|
||||
// return { isMounted, setIsMounted };
|
||||
// };
|
||||
|
|
@ -6,7 +6,7 @@ import {
|
|||
Sentiment,
|
||||
WordItem,
|
||||
} from "@/src/types";
|
||||
import { WORD_LIMIT } from "../utils/datas";
|
||||
import { WORD_LIMIT } from "../utils/const";
|
||||
|
||||
export const useWordCloud = () => {
|
||||
const [words, setWords] = useState<WordItem[]>([]);
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { UserGender } from "@prisma/client";
|
||||
import { LucideIcon } from "lucide-react";
|
||||
import Brand from "@prisma/client";
|
||||
|
||||
export interface ModelDB {
|
||||
modelName: string;
|
||||
description: string;
|
||||
description: string | null;
|
||||
accuracy: number;
|
||||
macroF1: number;
|
||||
f1Negative: number;
|
||||
|
|
@ -11,8 +11,13 @@ export interface ModelDB {
|
|||
}
|
||||
|
||||
export interface ProfileClientProps {
|
||||
gender?: UserGender;
|
||||
productReference?: string;
|
||||
bio?: string;
|
||||
preferenceBrand?: string;
|
||||
preferenceOS: string;
|
||||
budgetMin: number;
|
||||
budgetMax: number;
|
||||
profession: string;
|
||||
id?: number;
|
||||
}
|
||||
|
||||
interface Brand {
|
||||
|
|
@ -62,7 +67,7 @@ export interface SentimentChartProps {
|
|||
|
||||
export interface CustomTooltipProps {
|
||||
active?: boolean;
|
||||
payload?: string[];
|
||||
payload?: any[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
import { Book, Briefcase, Code, GamepadDirectional, Laptop, Palette } from "lucide-react";
|
||||
|
||||
export const MODEL_OPTIONS = [
|
||||
{
|
||||
label: "Model XGBoost (Baseline)",
|
||||
code: "baseline",
|
||||
desc: "Raw Data (Imbalanced)",
|
||||
},
|
||||
{
|
||||
label: "Model XGBoost (Tuned)",
|
||||
code: "tuned",
|
||||
desc: "Hyperparameter Tuned",
|
||||
},
|
||||
{
|
||||
label: "Model XGBoost (Optimized)",
|
||||
code: "optimized",
|
||||
desc: "Pipeline (SMOTE + Chi2)",
|
||||
},
|
||||
];
|
||||
|
||||
export const WORD_LIMIT = 15;
|
||||
|
||||
export const professions = [
|
||||
{ value: "programmer", label: "Programmer", icon: Code },
|
||||
{ value: "designer", label: "Designer", icon: Palette },
|
||||
{ value: "student", label: "Student", icon: Book },
|
||||
{ value: "gamer", label: "Gamer", icon: GamepadDirectional },
|
||||
];
|
||||
|
|
@ -7,13 +7,7 @@ const TrendChart = dynamic(
|
|||
})),
|
||||
{ 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) => ({
|
||||
|
|
|
|||
|
|
@ -1,23 +1,10 @@
|
|||
import { Frown, Meh, Smile } from "lucide-react";
|
||||
import { ScrapeResult, WordCloudConfig, WordItem } from "../types";
|
||||
|
||||
export const MODEL_OPTIONS = [
|
||||
{
|
||||
label: "Model XGBoost (Baseline)",
|
||||
code: "baseline",
|
||||
desc: "Raw Data (Imbalanced)",
|
||||
},
|
||||
{
|
||||
label: "Model XGBoost (Tuned)",
|
||||
code: "tuned",
|
||||
desc: "Hyperparameter Tuned",
|
||||
},
|
||||
{
|
||||
label: "Model XGBoost (Optimized)",
|
||||
code: "optimized",
|
||||
desc: "Pipeline (SMOTE + Chi2)",
|
||||
},
|
||||
];
|
||||
import {
|
||||
ProfileClientProps,
|
||||
ScrapeResult,
|
||||
WordCloudConfig,
|
||||
WordItem,
|
||||
} from "../types";
|
||||
|
||||
export const getSentimentDisplay = (sentiment: string) => {
|
||||
switch (sentiment?.toLowerCase()) {
|
||||
|
|
@ -51,8 +38,6 @@ export const getSentimentDisplay = (sentiment: string) => {
|
|||
}
|
||||
};
|
||||
|
||||
export const WORD_LIMIT = 15;
|
||||
|
||||
export const setWordCloud = ({ maxValue, minValue }: WordCloudConfig) => {
|
||||
const getSize = (value: number) => {
|
||||
if (maxValue === minValue) return 1.5;
|
||||
|
|
@ -91,9 +76,22 @@ export function getFallbackData(url: string): ScrapeResult {
|
|||
};
|
||||
}
|
||||
|
||||
export const professions = [
|
||||
{ value: "programmer", label: "Programmer" },
|
||||
{ value: "designer", label: "Designer" },
|
||||
{ value: "student", label: "Student" },
|
||||
{ value: "gamer", label: "Gamer" },
|
||||
];
|
||||
export const formatRupiah = (value: number | string) => {
|
||||
if (!value) return "Rp 0";
|
||||
return new Intl.NumberFormat("id-ID", {
|
||||
style: "currency",
|
||||
currency: "IDR",
|
||||
maximumFractionDigits: 0,
|
||||
}).format(Number(value));
|
||||
};
|
||||
|
||||
export function brandFormat({
|
||||
preferenceBrand,
|
||||
}: Pick<ProfileClientProps, "preferenceBrand">) {
|
||||
const brands = Array.isArray(preferenceBrand)
|
||||
? preferenceBrand
|
||||
: preferenceBrand
|
||||
? [preferenceBrand]
|
||||
: [];
|
||||
return { brands };
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue