Gagal memuat data dari server backend Vercel. Pastikan server sedang berjalan atau coba reload model.
++ Visualisasi hasil evaluasi performa model dan distribusi dataset sentimen. +
+Gagal mengambil informasi model dari Vercel Blob.
++ Kelola file model Naive Bayes yang digunakan oleh sistem untuk menganalisis sentimen. +
+dAMQaiH zm^r3v#$Q#2T=X>bsY#D%s!bhs^M9PMAcHbCc0FMHV{u-dwlL;a1eJ63v5U*?Q_8JO zT#50!RD619#j_Uf))0ooADz~*9&lN!bBDRUgE>Vud-i5ck%vT=r^yD*^?Mp@Q^v+V zG#-?gKlr}Eeqifb{|So?HM&g91P8|av8hQoCmQXkd?7wIJw b z_^v8bbg` SAn{I*4bH$u(RZ6*x UhuA~hc=8czK8SHEKTzSxgbwi~9(OqJB&gwb^l4+m`k*Q;_?>Y-APi1{k zAHQ)P)G)f|AyjSgcCFps)Fh6Bca*Xznq36!pV6Az&m{O8$wGFD? zY&O*3*J0;_EqM#jh6^gMQKpXV?#1?>$ml1xvh8nSN>-?H=V;nJIwB07YX$e6vLxH( zqYwQ>qxwR(i4f)DLd)-$P>T-no_c!LsN@)8`e;W@)-Hj0>nJ-}Kla4-ZdPJzI&Mce zv)V_j;(3ERN3_@I$N<^|4Lf`B;8n+bX@bHbcZTopEmDI*Jfl)-pFDvo6svPRoo@(x z);_{lY<;);XzT`dBFpRmGrr}z5u1=p C^ S-{ce6iXQlLGcItwJ^mZx{m$&DA_oEZ)B{_bYPq-HA zcH8WGoBG(aBU_j)vEy+_71T34@4dmSg!|M8Vf92Zj6WH7Q7t#OHQqWgFE3ARt+%!T z?oLovLVlnf?2c7pTc)~cc^($_8nyKwsN`RA-23ed3sdj(ys%pjjM+9JrctL;dy8a( z@en&CQmnV(()bu|Y%G1-4a(6x{aLytn$T-;(&{QIJB9vMox11U-1HpD@d(QkaJdEb zG{)+6Dos_L+O3NpWo^=gR?evp|CqEG?L&Ut#D*KLaRFOgOEK(Kq1@!EGcTfo+%A&I z=dLbB+d$u{sh?u)xP{PF8L%;YPPW53+@{>5W=Jt#wQpN;0_HYdw1{ksf_XhO4#2F= zyPx6Lx2<92L-;L5PD`zn6zwIH`Jk( $?Qw({erA$^bC;q33hv!d!>%wRhj# zal^hk+WGNg;rJtb-EB(?czvOM=H7dl=vblBwAv>}%1@{}mnpUznfq1cE^sgsL0*4I zJ##!*B?=vI_OEVis5o+_IwMIRrpQyT_Sq~ZU%oY7c5JMIADzpD!Upz9h@iWg_>>~j zOLS;wp^i$-E?4<_cp?RiS%Rd?i;f*mOz=~(&3lo<=@(nR!_Rqiprh@weZlL!t#NCc zO!QTcInq|%#>OVgobj{~ixEUec`E25zJ~*DofsQdzIa@5^nOXj2T;8O`l--(QyU ^$t?TGY^7#&FQ+2SS3B#qK*k3`ye?8jUYSajE5iBbJls75CCc(m3dk{t?- zopcER9{Z?TC)mk~gpi^kbbu>b-+a{m#8-y2^p$ka4n60w;Sc2}HMf<8JUvh CL0B&Btk)T`ctE$*qNW8L$`7!r^9T+>=<=2qaq-;ll2{`{Rg zc5a0ZUI$oG&j-qVOuKa=*v4aY#IsoM+1|c4Z)<}lEDvy;5huB@1RJPquU2U*U-;gu z=En2m+qjBzR#DEJDO`WU)hdd{Vj%^0V*KoyZ|5lzV87&g_j~NCjwv0uQVqXOb*QrQ zy|Qn`hxx(58c 70$E;L(X0uZZ72M1!6oeg)(cdKO ze0gDaTz+ohR-#d)NbAH4x{I(21yjwvBQfmpLu$)|m{XolbgF!pmsqJ#D}(ylp6uC> z{bqtcI#hT#HW=wl7>p!38sKsJ`r8}lt-q%Keqy%u(xk=yiIJiUw6|5IvkS+#?JTBl z8H5(Q?l#wzazujH!8o>1xtn8#_w+397* _cy8!pQGP%K(Ga3pAjsaTbbXJlQF_+m+-UpUUent@xM zg%jqLUExj~o^vQ3Gl*>wh=_gOr2*|U64_iXb+-111a H}$TjeajM+I20xw(((>fej-@CIz4S1pi$(#}P7`4({6QS2CaQS4NPENDp>sAqD z$bH4KGzXGffkJ7R>V>)>tC)uax{UsN*dbeNC*v}#8Y#OWYwL4t$ePR?VTyIs!wea+ z5Urmc)X|^`MG~*dS6pGSbU+gPJoq*^a=_>$n4|P^w$sMBBy@f*Z^Jg6?n5?oId6f{ z$LW4M|4m502z0t7g<#Bx%X;9<=)smFolV&(V^(7Cv2-sxbxopQ!)*#ZRhTBpx1)Fc zNm1T%bONzv6@#|dz(w02AH8OXe>kQ#1FMCzO}2J_mST)+ExmBr9cva-@?;wnmWMOk z{3_~EX_xadgJGv&H@zK_8{(x84`}+c?oSBX*Ge3VdfTt&F}yCpFP?CpW+BE^cWY0^ zb&uBN!Ja3UzYHK-CTyA5=L zEMW{l3Usky#ly=7px648W31UNV@K)&Ub&zP1c7%)`{);I4b0Q<)B}3;NMG2JH=X$U zfIW4)4n9ZM`-yRj67I)YSLDK)qfUJ_ij}a#aZN~9EXrh8eZY2&=uY%2N0UFF7<~%M zsB8=erOWZ>Ct_#^tHZ|*q`H;A)5;ycw*I cmVxi8_0Xk}aJA^ath+E;xg!x+As(M#0=)3!NJR6H&9+zd#iP(m0PIW8$ z1Y^VX`>jm`W!=WpF*{ioM?C9`yOR>@0q=u7o>BP-eSHqCgMDj!2anwH?s%i2p+Q7D zzszIf5XJpE)IG4;d_(La-xenmF(tgAxK`Y4sQ}BSJEPs6N_U2vI{8=0C_F?@7<(G; zo$~G=8p+076G;`}>{MQ>t>7cm=zGtfbdDXm6||jUU|?X?CaE?(<6bKDYKeHlz}DA8 zXT={X=yp_R;HfJ9h%?eWvQ!dRgz&Su*JfNt!Wu>|XfU &68iRikRrHRW|ZxzRR^`eIGt zIeiDgVS>IeExKVRWW8-= A= yA`}`)ZkWBrZD`hpWIxBGkh&f#ijr449~m`j6{4jiJ*C!oVA8ZC?$1RM#K(_b zL9TW)kN*Y4%^-qPpMP7d4)o?Nk#>aoYHT(*g)qmRUb?**F@pnNiy6Fv9rEiUqD(^O zzyS?nBrX63BTRYduaG(0VVG2yJRe%o&rVrLjbxTaAFTd8s;<<@Qs>u(<193R8>}2_ zuwp{7;H2a*X7_jryzriZXMg?bTuegABb^87@SsKkr2)0Gyiax8KQWstw^v #ix45EVrcEhr>!NMhprl $InQMzjSFH54x5k9qHc`@9uKQzvL4ihcq{^B zPrVR=o_ic%Y>6&rMN)hTZsI7I<3&`#(nl+3y3ys9A~ &^=4?PL&nd8)`OfG#n zwAMN$1&>K++c{^|7< 4P=2y(B{jJsQ0a#U;HTo4ZmWZYvI{+s;Td{Yzem%0*k#)vjpB zia;J&>}ICate44SFYY3vEelqStQWFihx%^vQ@Do(sOy7yR2@WNv7Y9I^yL=nZr3mb zXKV5t@=?-Sk|b{XMhA7ZGB@2hqsx}4xwCW!in#C zI@}sc Zlr3-NFJ@NFaJlhyfcw{k^vvtGl`N9xSo**rDW4S}i zM9{fMPWo%4wYDG~BZ18BD+}h|GQKc-g^{++3MY>}W_uq7jGHx{mwE9fZiPCoxN$+7 zrODGGJrOkcPQUB(FD5aoS4g~7#6NR^ma7-!>mHuJfY5kTe6PpNNKC9GGRiu^L31uG z$7v`*JknQHsYB!Tm_W{a32TM099djW%5e+j0Ve_ct}IM>XLF1Ap+YvcrLV=|CKo6S zb+ 9Nl3_YdKP6%Cxy@6TxZ>;4&nTneadr z_ES90ydCev)LV!dN=#(*f}|ZORFdvkYBni^aLbUk>BajeWIOcmHP#8S)*2U~QKI%S zyrLmtPqb&TphJ;>yAxri#;{uyk`JJqODDw%(Z=2 `1uc}br^V%>j!gS)D*q*f_-qf8&D;W1dJgQMlaH5er zN2U<%Smb7==vE}dDI8K7cKz!vs^73o9f>2sgiTzWcwY|BMYHH5%Vn7#kiw&eItCqa zIkR2~Q}>X=Ar8W|^Ms41Fm8o6IB2_j60eOeBB1Br!boW7JnoeX6Gs)?7rW0^5psc- zjS16yb>dFn>KPOF;imD}e!enuIniFzv}n$m2#gCCv4jM#ArwlzZ$7@9&XkFxZ4n!V zj3dyiwW4Ki2QG{@i>yuZXQizw_OkZI^-3otXC{!(lUpJF33gI60ak;Uqitp74|B6I zgg{b=Iz}WkhCGj1M =hu4#Aw173YxIVbISaoc z-nLZC*6Tgivd5V`K%GxhBsp@SUU60-rfc$=wb>zdJzXS&-5(NRRodFk;Kxk!S( O(a0e7oY=E( zAyS;Ow?6Q&XA+cnkCb{28_1N8H#?J!*$MmIwLq^*T_9-z^&UE@A(z9oGYtFy6EZef LrJugUA?W`A8`#=m literal 0 HcmV?d00001 diff --git a/fe-nlp/src/app/globals.css b/fe-nlp/src/app/globals.css new file mode 100644 index 0000000..c56032b --- /dev/null +++ b/fe-nlp/src/app/globals.css @@ -0,0 +1,130 @@ +@import "tailwindcss"; +@import "tw-animate-css"; +@import "shadcn/tailwind.css"; + +@custom-variant dark (&:is(.dark *)); + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --font-sans: var(--font-sans); + --font-mono: var(--font-geist-mono); + --font-heading: var(--font-sans); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --radius-sm: calc(var(--radius) * 0.6); + --radius-md: calc(var(--radius) * 0.8); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) * 1.4); + --radius-2xl: calc(var(--radius) * 1.8); + --radius-3xl: calc(var(--radius) * 2.2); + --radius-4xl: calc(var(--radius) * 2.6); +} + +:root { + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.87 0 0); + --chart-2: oklch(0.556 0 0); + --chart-3: oklch(0.439 0 0); + --chart-4: oklch(0.371 0 0); + --chart-5: oklch(0.269 0 0); + --radius: 0.625rem; + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.87 0 0); + --chart-2: oklch(0.556 0 0); + --chart-3: oklch(0.439 0 0); + --chart-4: oklch(0.371 0 0); + --chart-5: oklch(0.269 0 0); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } + html { + @apply font-sans; + } +} \ No newline at end of file diff --git a/fe-nlp/src/app/layout.tsx b/fe-nlp/src/app/layout.tsx new file mode 100644 index 0000000..79bf39e --- /dev/null +++ b/fe-nlp/src/app/layout.tsx @@ -0,0 +1,32 @@ +import type { Metadata } from "next"; +import { Geist, Geist_Mono, Inter } from "next/font/google"; +import "./globals.css"; +import { cn } from "@/lib/utils"; + +const inter = Inter({subsets:['latin'],variable:'--font-sans'}); + +const geistSans = Geist({ + variable: "--font-geist-sans", + subsets: ["latin"], +}); + +const geistMono = Geist_Mono({ + variable: "--font-geist-mono", + subsets: ["latin"], +}); + +export const metadata: Metadata = { + title: "Create Next App", + description: "Generated by create next app", +}; + +export default function RootLayout({ children }: LayoutProps<"/">) { + return ( + + {children} + + ); +} diff --git a/fe-nlp/src/app/page.tsx b/fe-nlp/src/app/page.tsx new file mode 100644 index 0000000..445d019 --- /dev/null +++ b/fe-nlp/src/app/page.tsx @@ -0,0 +1,31 @@ +import Link from 'next/link'; +import { Button } from '@/components/ui/button'; +import { HeroSection } from '@/components/landing-page/hero-section'; +import { ContextSection } from '@/components/landing-page/context-section'; +import { EducationSection } from '@/components/landing-page/education-section'; +import { Footer } from '@/components/landing-page/footer'; +import { PublicNavbar } from '@/components/layout/public-navbar'; +import { cookies } from 'next/headers'; + +export default async function Home() { + const cookieStore = await cookies(); + const token = cookieStore.get('auth-token'); + const isLoggedIn = !!token; + + return ( + + {/* Header / Navbar */} ++ ); +} diff --git a/fe-nlp/src/app/playground/page.tsx b/fe-nlp/src/app/playground/page.tsx new file mode 100644 index 0000000..36150d2 --- /dev/null +++ b/fe-nlp/src/app/playground/page.tsx @@ -0,0 +1,25 @@ +import { cookies } from 'next/headers'; +import { PublicNavbar } from '@/components/layout/public-navbar'; +import { Footer } from '@/components/landing-page/footer'; +import { PlaygroundSection } from '@/components/landing-page/playground/playground-section'; + +export default async function PlaygroundPage() { + const cookieStore = await cookies(); + const token = cookieStore.get('auth-token'); + const isLoggedIn = !!token; + + return ( ++ + {/* Main Content */} + + + + {/* Footer */} + ++ + + + {/* Header / Navbar */} ++ ); +} diff --git a/fe-nlp/src/components/dashboard/dataset-chart.tsx b/fe-nlp/src/components/dashboard/dataset-chart.tsx new file mode 100644 index 0000000..8714880 --- /dev/null +++ b/fe-nlp/src/components/dashboard/dataset-chart.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card" +import { PieChart, Pie, Cell, Tooltip as RechartsTooltip, Legend, ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid } from 'recharts'; +import { DatasetInfo } from "../../types/model" + +interface DatasetChartProps { + info: DatasetInfo; +} + +const COLORS = ['#ef4444', '#22c55e', '#3b82f6', '#f59e0b', '#8b5cf6']; + +export function DatasetChart({ info }: DatasetChartProps) { + // Prepare data for Pie Chart (Label Distribution) + const labelData = Object.keys(info.label_size).map((key, index) => ({ + name: `Label ${key}`, + value: info.label_size[key], + color: COLORS[index % COLORS.length] + })); + + // Prepare data for Bar Chart (Train vs Test) + const splitData = [ + { name: 'Data Latih (Train)', value: info.train_size, fill: '#3b82f6' }, + { name: 'Data Uji (Test)', value: info.test_size, fill: '#f59e0b' } + ]; + + return ( ++ + {/* Main Content */} + + + + {/* Footer */} + ++ ++ ) +} diff --git a/fe-nlp/src/components/dashboard/insights-panel.tsx b/fe-nlp/src/components/dashboard/insights-panel.tsx new file mode 100644 index 0000000..7bf473d --- /dev/null +++ b/fe-nlp/src/components/dashboard/insights-panel.tsx @@ -0,0 +1,52 @@ +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" +import { SummaryInsight } from "../../types/model" +import { Info } from "lucide-react" + +interface InsightsPanelProps { + insight: SummaryInsight; +} + +export function InsightsPanel({ insight }: InsightsPanelProps) { + const getBadgeColor = (sentiment: string) => { + if (sentiment === 'Positif') return 'bg-green-500 hover:bg-green-600'; + if (sentiment === 'Negatif') return 'bg-red-500 hover:bg-red-600'; + return 'bg-blue-500 hover:bg-blue-600'; + }; + + return ( ++ + ++ +Distribusi Label Sentimen +Porsi jumlah sampel untuk setiap label pada dataset ++ ++ ++ ++ {labelData.map((entry, index) => ( + ++ ))} + | [`${value} sampel`, 'Jumlah']} /> + + + ++ +Pembagian Dataset (Train vs Test) +Rasio data yang digunakan untuk melatih dan menguji model ++ ++ ++ ++ + + [`${value} sampel`, 'Jumlah']} /> + + {splitData.map((entry, index) => ( + ++ ))} + | + + ) +} diff --git a/fe-nlp/src/components/dashboard/metrics-cards.tsx b/fe-nlp/src/components/dashboard/metrics-cards.tsx new file mode 100644 index 0000000..70cebae --- /dev/null +++ b/fe-nlp/src/components/dashboard/metrics-cards.tsx @@ -0,0 +1,51 @@ +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Progress } from "@/components/ui/progress" +import { ModelMetrics } from "../../types/model" +import { calculateMetricsDerivatives } from "../../utils/metrics-calculator" + +interface MetricsCardsProps { + metrics: ModelMetrics; +} + +export function MetricsCards({ metrics }: MetricsCardsProps) { + const derived = calculateMetricsDerivatives(metrics); + + const cards = [ + { title: "Accuracy", value: derived.accuracyPercentage, description: "Ketepatan prediksi keseluruhan" }, + { title: "Precision", value: derived.precisionPercentage, description: "Ketepatan prediksi sentimen" }, + { title: "Recall", value: derived.recallPercentage, description: "Sensitivitas model" }, + { title: "F1-Score", value: derived.f1ScorePercentage, description: "Keseimbangan Precision & Recall" }, + ]; + + return ( ++ ++++ Kesimpulan Analisis Dataset +Rangkuman dari distribusi data dan karakteristik dataset sentimen. ++ +++ ++ {insight.conclusionText} +
++++ Dominan {insight.dominantSentiment} + +++• Total sampel data yang dianalisis: {insight.totalData} sampel.
+• Komposisi Data Latih (Train) sebesar {insight.trainRatioPercentage.toFixed(1)}% dan Data Uji (Test) sebesar {insight.testRatioPercentage.toFixed(1)}%.
+ +++Rincian Per Label:
+ {insight.insightsList.map((text, idx) => ( +- {text}
+ ))} ++ {cards.map((card, i) => ( ++ ) +} diff --git a/fe-nlp/src/components/dashboard/model-reload-card.tsx b/fe-nlp/src/components/dashboard/model-reload-card.tsx new file mode 100644 index 0000000..2d1aeef --- /dev/null +++ b/fe-nlp/src/components/dashboard/model-reload-card.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { useReloadModel } from "../../hooks/use-reload-model" +import { useDashboardStore } from "../../store/use-dashboard-store" +import { RefreshCw, Server } from "lucide-react" + +export function ModelReloadCard() { + const { mutate: reloadModel, isPending } = useReloadModel(); + const lastReloadedAt = useDashboardStore((state) => state.lastReloadedAt); + + const handleReload = () => { + reloadModel(undefined, { + onSuccess: (data) => { + alert(data.message || "Model berhasil diunduh ulang dan dimuat ke RAM Vercel."); + window.location.reload(); + }, + onError: () => { + alert("Terjadi kesalahan saat memuat ulang model."); + } + }); + }; + + return ( ++ + ))} ++ ++ {card.title} + ++ +{card.value.toFixed(2)}%++ {card.description} +
+ ++ ++ +Error Rate ++ +{derived.errorRatePercentage.toFixed(2)}%++ Tingkat kesalahan prediksi dari model Naive Bayes. +
++ + ) +} diff --git a/fe-nlp/src/components/input-model/active-model-info.tsx b/fe-nlp/src/components/input-model/active-model-info.tsx new file mode 100644 index 0000000..1388276 --- /dev/null +++ b/fe-nlp/src/components/input-model/active-model-info.tsx @@ -0,0 +1,57 @@ +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; + +import { Download, FileIcon } from "lucide-react"; +import { buttonVariants } from "@/components/ui/button"; +import { BlobModelData } from "../../services/blob-service"; + +interface ActiveModelInfoProps { + model: BlobModelData; +} + +export function ActiveModelInfo({ model }: ActiveModelInfoProps) { + const sizeInMB = (model.size / (1024 * 1024)).toFixed(2); + const uploadDate = new Date(model.uploadedAt).toLocaleString('id-ID', { + day: 'numeric', + month: 'long', + year: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + + return ( ++ ++ ++ Status Model di Vercel RAM + + Model Naive Bayes saat ini dimuat dan di-cache di dalam memori server untuk mempercepat waktu prediksi. + ++ +++ +Waktu Terakhir Reload:
++ {lastReloadedAt ? lastReloadedAt.toLocaleString('id-ID') : 'Belum direload pada sesi ini'} +
++ + ); +} diff --git a/fe-nlp/src/components/input-model/model-upload-form.tsx b/fe-nlp/src/components/input-model/model-upload-form.tsx new file mode 100644 index 0000000..9c2824c --- /dev/null +++ b/fe-nlp/src/components/input-model/model-upload-form.tsx @@ -0,0 +1,89 @@ +import { useState } from "react"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { UploadCloud, Loader2 } from "lucide-react"; +import { useUploadModel } from "../../hooks/use-blob-model"; + +interface ModelUploadFormProps { + hasExistingModel: boolean; +} + +export function ModelUploadForm({ hasExistingModel }: ModelUploadFormProps) { + const [file, setFile] = useState+ ++ ++ Model Aktif Saat Ini + + Berikut adalah detail file model sentimen analisis yang sedang digunakan. + ++ +++ + +++Nama File
+{model.pathname}
+++Ukuran
+{sizeInMB} MB
+++Waktu Diunggah
+{uploadDate}
+(null); + const { mutate: uploadModel, isPending } = useUploadModel(); + + const handleFileChange = (e: React.ChangeEvent ) => { + if (e.target.files && e.target.files.length > 0) { + setFile(e.target.files[0]); + } + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!file) return; + + uploadModel(file, { + onSuccess: () => { + alert("Model berhasil diunggah dan direload!"); + setFile(null); // Reset form + // Reset file input element + const fileInput = document.getElementById("model-file") as HTMLInputElement; + if (fileInput) fileInput.value = ""; + }, + onError: () => { + alert("Terjadi kesalahan saat mengunggah model. Silakan coba lagi."); + } + }); + }; + + return ( + + + ); +} diff --git a/fe-nlp/src/components/landing-page/context-section.tsx b/fe-nlp/src/components/landing-page/context-section.tsx new file mode 100644 index 0000000..9c38c77 --- /dev/null +++ b/fe-nlp/src/components/landing-page/context-section.tsx @@ -0,0 +1,23 @@ +export function ContextSection() { + return ( ++ +{hasExistingModel ? "Ganti Model" : "Unggah Model Baru"} ++ {hasExistingModel + ? "Pilih file .pkl baru untuk menggantikan model yang saat ini aktif. Model lama akan dihapus." + : "Silakan unggah file model .pkl Anda untuk digunakan oleh sistem."} + ++ + ++ + ); +} diff --git a/fe-nlp/src/components/landing-page/education-section.tsx b/fe-nlp/src/components/landing-page/education-section.tsx new file mode 100644 index 0000000..98d0dd5 --- /dev/null +++ b/fe-nlp/src/components/landing-page/education-section.tsx @@ -0,0 +1,65 @@ +import { Brain, Filter, PieChart } from 'lucide-react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; + +export function EducationSection() { + return ( ++++++++ Mengenal Skolla: Ruang Belajar Digital Masa Kini. +
++++ Skolla adalah platform EdTech (Edukasi Teknologi) atau aplikasi bimbingan belajar online yang dirancang untuk membantu siswa SD hingga SMA, dan juga para alumni. Lewat Skolla, ribuan siswa belajar secara interaktif untuk persiapan ujian sekolah hingga tes masuk Perguruan Tinggi Negeri (UTBK). +
++ Dengan fitur yang begitu beragam, pengguna tentu memiliki berbagai pengalaman—ada yang sangat puas, namun ada juga yang mungkin menemukan kendala. Di sinilah sistem Machine Learning (ML) yang ada di web ini bekerja: Membaca dan menyimpulkan jutaan pendapat tersebut secara otomatis dalam hitungan detik. +
++ + ); +} diff --git a/fe-nlp/src/components/landing-page/footer.tsx b/fe-nlp/src/components/landing-page/footer.tsx new file mode 100644 index 0000000..ba3f732 --- /dev/null +++ b/fe-nlp/src/components/landing-page/footer.tsx @@ -0,0 +1,41 @@ +import { AtSign, Mail } from 'lucide-react'; + +export function Footer() { + const currentYear = new Date().getFullYear(); + + return ( + + ); +} diff --git a/fe-nlp/src/components/landing-page/hero-section.tsx b/fe-nlp/src/components/landing-page/hero-section.tsx new file mode 100644 index 0000000..b82add6 --- /dev/null +++ b/fe-nlp/src/components/landing-page/hero-section.tsx @@ -0,0 +1,31 @@ +import { Button } from '@/components/ui/button'; +import Link from 'next/link'; + +export function HeroSection() { + return ( +++++++ ++ Di Balik Layar: Bagaimana ML 'Membaca' Perasaan? +
++ Mesin tidak mengerti emosi seperti manusia. Bagi ML, kalimatmu hanyalah sekumpulan kata. Namun, dengan metode yang disebut Naive Bayes, sistem ini dilatih untuk mengenali 'pola kata' dari ribuan ulasan sebelumnya. Mari kita lihat prosesnya secara sederhana: +
++++ + ++ ++++ 1. Text Preprocessing ++ ++ Kalimat akan dibersihkan dari simbol, tanda baca, dan kata-kata yang tidak memiliki makna (stop words), kemudian dikembalikan ke kata dasarnya (stemming). +
++ + ++ ++++ 2. Menghitung Probabilitas ++ ++ Sistem mengevaluasi setiap kata berdasarkan data latih (dataset) yang sudah ada, menghitung seberapa sering kata tersebut muncul dalam sentimen positif atau negatif. +
++ ++ ++++ 3. Klasifikasi Akhir ++ ++ Berdasarkan kalkulasi Theorema Bayes, ML akan menentukan apakah kalimat tersebut secara keseluruhan lebih condong ke arah sentimen positif atau negatif. +
++ + ); +} diff --git a/fe-nlp/src/components/landing-page/playground/playground-section.tsx b/fe-nlp/src/components/landing-page/playground/playground-section.tsx new file mode 100644 index 0000000..e6c6fbe --- /dev/null +++ b/fe-nlp/src/components/landing-page/playground/playground-section.tsx @@ -0,0 +1,288 @@ +'use client'; + +import { useState, useEffect, useRef } from 'react'; +import { Button } from '@/components/ui/button'; +import { Textarea } from '@/components/ui/textarea'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from '@/components/ui/card'; +import { Loader2, Upload, FileDown, Download } from 'lucide-react'; +import { sentimentApi } from '@/services/api'; +import { readTextsFromFile, downloadResultsAsExcel, downloadTemplateExcel } from '@/utils/excelUtils'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; + +export function PlaygroundSection() { + const [textInput, setTextInput] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [results, setResults] = useState+++++++ Baca Perasaan Pengguna Skolla dalam Hitungan Detik. +
++ Ribuan orang telah menggunakan Skolla. Melalui simulasi ML sederhana ini, kamu bisa melihat bagaimana komputer dilatih untuk memahami dan mengelompokkan ribuan feedback pengguna secara otomatis. +
++ + + +++ Tanpa perlu login. Coba gratis sekarang. +
+([]); + + // Mapping Sentimen dinamis dari backend + const [sentimentMap, setSentimentMap] = useState >({}); + + const fileInputRef = useRef (null); + + const fetchInfo = async () => { + try { + const info = await sentimentApi.getInfo(); + const labelSize = info.data?.label_size || {}; + + // Ekstrak keys dan konversi ke integer + const keys = Object.keys(labelSize).map(Number).sort((a, b) => a - b); + + if (keys.length > 0) { + const minKey = keys[0]; + const maxKey = keys[keys.length - 1]; + + const newMap: Record = {}; + + keys.forEach(k => { + if (k === minKey) newMap[k] = 'Negatif'; + else if (k === maxKey) newMap[k] = 'Positif'; + else newMap[k] = 'Netral'; + }); + + setSentimentMap(newMap); + return newMap; + } + } catch (error) { + console.error('Gagal mengambil info model:', error); + } + return null; + }; + + useEffect(() => { + fetchInfo(); + }, []); + + const getSentimentLabel = (value: number, currentMap: Record ) => { + if (value === undefined || value === null) return 'Tidak Diketahui'; + + // Gunakan mapping dari backend jika ada + if (currentMap && currentMap[String(value)]) { + return currentMap[String(value)]; + } + + // Fallback default jika mapping backend belum/gagal dimuat + if (value === 0) return 'Negatif'; + if (value === 1 || value === 2) return 'Positif'; + + return 'Tidak Diketahui'; + }; + + const handlePredictSingle = async () => { + if (!textInput.trim()) return; + + setIsLoading(true); + try { + let activeMap = sentimentMap; + if (Object.keys(activeMap).length === 0) { + const loadedMap = await fetchInfo(); + if (loadedMap) activeMap = loadedMap; + } + + const response = await sentimentApi.predict([textInput]); + if (response && response.length > 0) { + setResults([ + { + Teks: textInput, + Sentimen: getSentimentLabel(response[0].sentiment, activeMap) + } + ]); + } + } catch (error: any) { + console.error('Gagal memprediksi:', error); + if (error.response?.status === 500 || error.response?.status === 503) { + alert('Server Machine Learning sedang bermasalah (Error 500/503). Silakan coba beberapa saat lagi.'); + } else { + alert('Terjadi kesalahan saat memproses permintaan.'); + } + } finally { + setIsLoading(false); + } + }; + + const handleFileUpload = async (e: React.ChangeEvent ) => { + const file = e.target.files?.[0]; + if (!file) return; + + setIsLoading(true); + setResults([]); + setTextInput(''); + + try { + const texts = await readTextsFromFile(file); + if (texts.length === 0) { + alert('File kosong atau tidak valid.'); + setIsLoading(false); + return; + } + + let activeMap = sentimentMap; + if (Object.keys(activeMap).length === 0) { + const loadedMap = await fetchInfo(); + if (loadedMap) activeMap = loadedMap; + } + + const response = await sentimentApi.predict(texts); + + const combinedResults = texts.map((text, idx) => ({ + Teks: text, + Sentimen: getSentimentLabel(response[idx]?.sentiment, activeMap) + })); + + setResults(combinedResults); + } catch (error: any) { + console.error('Gagal memproses file:', error); + if (error.response?.status === 500 || error.response?.status === 503) { + alert('Server Machine Learning sedang bermasalah (Error 500/503). Silakan coba beberapa saat lagi.'); + } else { + alert('Terjadi kesalahan saat memproses file.'); + } + } finally { + setIsLoading(false); + // Reset input file + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + } + }; + + const applyRecommendation = (text: string) => { + setTextInput(text); + }; + + return ( + + + ); +} diff --git a/fe-nlp/src/components/layout/app-header.tsx b/fe-nlp/src/components/layout/app-header.tsx new file mode 100644 index 0000000..5eea143 --- /dev/null +++ b/fe-nlp/src/components/layout/app-header.tsx @@ -0,0 +1,12 @@ +import { SidebarTrigger } from "@/components/ui/sidebar" + +export function AppHeader() { + return ( ++ + {/* Header Section */} ++++ + {/* Input Area */} +Uji Kalimatmu di Sini
++ Ketik ulasan, keluhan, atau pujian tentang aplikasi belajar Skolla di dalam kotak ini. + Model Machine Learning (Naive Bayes) yang kami latih akan langsung menebak apakah kalimatmu bersentimen Positif atau Negatif. +
++ + + {/* Result Area */} + {results.length > 0 && !isLoading && ( ++ +Coba Prediksi Sentimen ++ Masukkan teks secara manual atau import dari file Excel/TXT. + ++ + + +++ ++ Rekomendasi ulasan: + + +++ ++ + + ++ + ++ + )} + ++ +++ +Hasil Prediksi ++ Ditemukan {results.length} hasil analisis sentimen. + ++ {results.length === 1 ? ( + ++ Sentimen: + + {results[0].Sentimen} + ++ ) : ( +++ )} ++
++ ++ +Teks Ulasan +Sentimen ++ {results.map((row, idx) => ( + ++ + ))} +{row.Teks} ++ + {row.Sentimen} + + ++ + ) +} diff --git a/fe-nlp/src/components/layout/app-sidebar.tsx b/fe-nlp/src/components/layout/app-sidebar.tsx new file mode 100644 index 0000000..13d496d --- /dev/null +++ b/fe-nlp/src/components/layout/app-sidebar.tsx @@ -0,0 +1,78 @@ +"use client" + +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from "@/components/ui/sidebar" +import { LayoutDashboard, BrainCircuit, LogOut } from "lucide-react" +import axios from "axios" +import { useRouter } from "next/navigation" + +// Menu items. +const items = [ + { + title: "Dashboard", + url: "/dashboard", + icon: LayoutDashboard, + }, + { + title: "Input Model", + url: "/input-model", + icon: BrainCircuit, + }, +] + +export function AppSidebar() { + const router = useRouter(); + + const handleLogout = async () => { + try { + await axios.post('/api/auth/logout'); + router.push('/login'); + } catch (error) { + console.error('Logout error:', error); + router.push('/login'); + } + }; + + return ( ++ ++Dashboard Sentimen
++ + ) +} diff --git a/fe-nlp/src/components/layout/public-navbar.tsx b/fe-nlp/src/components/layout/public-navbar.tsx new file mode 100644 index 0000000..a6b5856 --- /dev/null +++ b/fe-nlp/src/components/layout/public-navbar.tsx @@ -0,0 +1,47 @@ +'use client'; + +import Link from 'next/link'; +import { Button } from '@/components/ui/button'; +import { usePathname } from 'next/navigation'; + +interface PublicNavbarProps { + isLoggedIn?: boolean; +} + +export function PublicNavbar({ isLoggedIn = false }: PublicNavbarProps) { + const pathname = usePathname(); + const isPlayground = pathname === '/playground'; + + return ( ++ ++ +Analisis Sentimen ++ ++ {items.map((item) => ( + ++ + ))} ++ + ++ {item.title} + + + ++ ++ ++ ++ Logout + + + ); +} diff --git a/fe-nlp/src/components/ui/badge.tsx b/fe-nlp/src/components/ui/badge.tsx new file mode 100644 index 0000000..b20959d --- /dev/null +++ b/fe-nlp/src/components/ui/badge.tsx @@ -0,0 +1,52 @@ +import { mergeProps } from "@base-ui/react/merge-props" +import { useRender } from "@base-ui/react/use-render" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", + secondary: + "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80", + destructive: + "bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20", + outline: + "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground", + ghost: + "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50", + link: "text-primary underline-offset-4 hover:underline", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Badge({ + className, + variant = "default", + render, + ...props +}: useRender.ComponentProps<"span"> & VariantProps+ + SkollaSentiment + + ++) { + return useRender({ + defaultTagName: "span", + props: mergeProps<"span">( + { + className: cn(badgeVariants({ variant }), className), + }, + props + ), + render, + state: { + slot: "badge", + variant, + }, + }) +} + +export { Badge, badgeVariants } diff --git a/fe-nlp/src/components/ui/button.tsx b/fe-nlp/src/components/ui/button.tsx new file mode 100644 index 0000000..76a87dd --- /dev/null +++ b/fe-nlp/src/components/ui/button.tsx @@ -0,0 +1,58 @@ +import { Button as ButtonPrimitive } from "@base-ui/react/button" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "group/button inline-flex shrink-0 items-center justify-center rounded-md border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/80", + outline: + "border-border bg-background shadow-xs hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", + secondary: + "bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", + ghost: + "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50", + destructive: + "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: + "h-9 gap-1.5 px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", + xs: "h-6 gap-1 rounded-[min(var(--radius-md),8px)] px-2 text-xs in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", + sm: "h-8 gap-1 rounded-[min(var(--radius-md),10px)] px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5", + lg: "h-10 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", + icon: "size-9", + "icon-xs": + "size-6 rounded-[min(var(--radius-md),8px)] in-data-[slot=button-group]:rounded-md [&_svg:not([class*='size-'])]:size-3", + "icon-sm": + "size-8 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-md", + "icon-lg": "size-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +function Button({ + className, + variant = "default", + size = "default", + ...props +}: ButtonPrimitive.Props & VariantProps ) { + return ( + + ) +} + +export { Button, buttonVariants } diff --git a/fe-nlp/src/components/ui/card.tsx b/fe-nlp/src/components/ui/card.tsx new file mode 100644 index 0000000..fc9a740 --- /dev/null +++ b/fe-nlp/src/components/ui/card.tsx @@ -0,0 +1,103 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Card({ + className, + size = "default", + ...props +}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) { + return ( + img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(4)] *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl", + className + )} + {...props} + /> + ) +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( + + ) +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( + + ) +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( + + ) +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( + + ) +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( + + ) +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( + + ) +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +} diff --git a/fe-nlp/src/components/ui/input.tsx b/fe-nlp/src/components/ui/input.tsx new file mode 100644 index 0000000..82ccce4 --- /dev/null +++ b/fe-nlp/src/components/ui/input.tsx @@ -0,0 +1,20 @@ +import * as React from "react" +import { Input as InputPrimitive } from "@base-ui/react/input" + +import { cn } from "@/lib/utils" + +function Input({ className, type, ...props }: React.ComponentProps<"input">) { + return ( ++ ) +} + +export { Input } diff --git a/fe-nlp/src/components/ui/label.tsx b/fe-nlp/src/components/ui/label.tsx new file mode 100644 index 0000000..74da65c --- /dev/null +++ b/fe-nlp/src/components/ui/label.tsx @@ -0,0 +1,20 @@ +"use client" + +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Label({ className, ...props }: React.ComponentProps<"label">) { + return ( + + ) +} + +export { Label } diff --git a/fe-nlp/src/components/ui/progress.tsx b/fe-nlp/src/components/ui/progress.tsx new file mode 100644 index 0000000..ef0b612 --- /dev/null +++ b/fe-nlp/src/components/ui/progress.tsx @@ -0,0 +1,83 @@ +"use client" + +import { Progress as ProgressPrimitive } from "@base-ui/react/progress" + +import { cn } from "@/lib/utils" + +function Progress({ + className, + children, + value, + ...props +}: ProgressPrimitive.Root.Props) { + return ( + + {children} + + ) +} + +function ProgressTrack({ className, ...props }: ProgressPrimitive.Track.Props) { + return ( ++ ++ + ) +} + +function ProgressIndicator({ + className, + ...props +}: ProgressPrimitive.Indicator.Props) { + return ( + + ) +} + +function ProgressLabel({ className, ...props }: ProgressPrimitive.Label.Props) { + return ( + + ) +} + +function ProgressValue({ className, ...props }: ProgressPrimitive.Value.Props) { + return ( + + ) +} + +export { + Progress, + ProgressTrack, + ProgressIndicator, + ProgressLabel, + ProgressValue, +} diff --git a/fe-nlp/src/components/ui/separator.tsx b/fe-nlp/src/components/ui/separator.tsx new file mode 100644 index 0000000..6e1369e --- /dev/null +++ b/fe-nlp/src/components/ui/separator.tsx @@ -0,0 +1,25 @@ +"use client" + +import { Separator as SeparatorPrimitive } from "@base-ui/react/separator" + +import { cn } from "@/lib/utils" + +function Separator({ + className, + orientation = "horizontal", + ...props +}: SeparatorPrimitive.Props) { + return ( + + ) +} + +export { Separator } diff --git a/fe-nlp/src/components/ui/sheet.tsx b/fe-nlp/src/components/ui/sheet.tsx new file mode 100644 index 0000000..917d337 --- /dev/null +++ b/fe-nlp/src/components/ui/sheet.tsx @@ -0,0 +1,135 @@ +"use client" + +import * as React from "react" +import { Dialog as SheetPrimitive } from "@base-ui/react/dialog" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { XIcon } from "lucide-react" + +function Sheet({ ...props }: SheetPrimitive.Root.Props) { + return +} + +function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) { + return +} + +function SheetClose({ ...props }: SheetPrimitive.Close.Props) { + return +} + +function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) { + return +} + +function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) { + return ( + + ) +} + +function SheetContent({ + className, + children, + side = "right", + showCloseButton = true, + ...props +}: SheetPrimitive.Popup.Props & { + side?: "top" | "right" | "bottom" | "left" + showCloseButton?: boolean +}) { + return ( + + + ) +} + +function SheetHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( + + ) +} + +function SheetFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( + + ) +} + +function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) { + return ( ++ + {children} + {showCloseButton && ( + ++ } + > + + Close + + )} + + ) +} + +function SheetDescription({ + className, + ...props +}: SheetPrimitive.Description.Props) { + return ( + + ) +} + +export { + Sheet, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, +} diff --git a/fe-nlp/src/components/ui/sidebar.tsx b/fe-nlp/src/components/ui/sidebar.tsx new file mode 100644 index 0000000..42375b9 --- /dev/null +++ b/fe-nlp/src/components/ui/sidebar.tsx @@ -0,0 +1,723 @@ +"use client" + +import * as React from "react" +import { mergeProps } from "@base-ui/react/merge-props" +import { useRender } from "@base-ui/react/use-render" +import { cva, type VariantProps } from "class-variance-authority" + +import { useIsMobile } from "@/hooks/use-mobile" +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Separator } from "@/components/ui/separator" +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { Skeleton } from "@/components/ui/skeleton" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" +import { PanelLeftIcon } from "lucide-react" + +const SIDEBAR_COOKIE_NAME = "sidebar_state" +const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 +const SIDEBAR_WIDTH = "16rem" +const SIDEBAR_WIDTH_MOBILE = "18rem" +const SIDEBAR_WIDTH_ICON = "3rem" +const SIDEBAR_KEYBOARD_SHORTCUT = "b" + +type SidebarContextProps = { + state: "expanded" | "collapsed" + open: boolean + setOpen: (open: boolean) => void + openMobile: boolean + setOpenMobile: (open: boolean) => void + isMobile: boolean + toggleSidebar: () => void +} + +const SidebarContext = React.createContext (null) + +function useSidebar() { + const context = React.useContext(SidebarContext) + if (!context) { + throw new Error("useSidebar must be used within a SidebarProvider.") + } + + return context +} + +function SidebarProvider({ + defaultOpen = true, + open: openProp, + onOpenChange: setOpenProp, + className, + style, + children, + ...props +}: React.ComponentProps<"div"> & { + defaultOpen?: boolean + open?: boolean + onOpenChange?: (open: boolean) => void +}) { + const isMobile = useIsMobile() + const [openMobile, setOpenMobile] = React.useState(false) + + // This is the internal state of the sidebar. + // We use openProp and setOpenProp for control from outside the component. + const [_open, _setOpen] = React.useState(defaultOpen) + const open = openProp ?? _open + const setOpen = React.useCallback( + (value: boolean | ((value: boolean) => boolean)) => { + const openState = typeof value === "function" ? value(open) : value + if (setOpenProp) { + setOpenProp(openState) + } else { + _setOpen(openState) + } + + // This sets the cookie to keep the sidebar state. + document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}` + }, + [setOpenProp, open] + ) + + // Helper to toggle the sidebar. + const toggleSidebar = React.useCallback(() => { + return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open) + }, [isMobile, setOpen, setOpenMobile]) + + // Adds a keyboard shortcut to toggle the sidebar. + React.useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if ( + event.key === SIDEBAR_KEYBOARD_SHORTCUT && + (event.metaKey || event.ctrlKey) + ) { + event.preventDefault() + toggleSidebar() + } + } + + window.addEventListener("keydown", handleKeyDown) + return () => window.removeEventListener("keydown", handleKeyDown) + }, [toggleSidebar]) + + // We add a state so that we can do data-state="expanded" or "collapsed". + // This makes it easier to style the sidebar with Tailwind classes. + const state = open ? "expanded" : "collapsed" + + const contextValue = React.useMemo ( + () => ({ + state, + open, + setOpen, + isMobile, + openMobile, + setOpenMobile, + toggleSidebar, + }), + [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar] + ) + + return ( + + + ) +} + +function Sidebar({ + side = "left", + variant = "sidebar", + collapsible = "offcanvas", + className, + children, + dir, + ...props +}: React.ComponentProps<"div"> & { + side?: "left" | "right" + variant?: "sidebar" | "floating" | "inset" + collapsible?: "offcanvas" | "icon" | "none" +}) { + const { isMobile, state, openMobile, setOpenMobile } = useSidebar() + + if (collapsible === "none") { + return ( ++ {children} +++ {children} ++ ) + } + + if (isMobile) { + return ( ++ + ) + } + + return ( ++ ++ +Sidebar +Displays the mobile sidebar. +{children}++ {/* This is what handles the sidebar gap on desktop */} + ++ ) +} + +function SidebarTrigger({ + className, + onClick, + ...props +}: React.ComponentProps+++ {children} ++) { + const { toggleSidebar } = useSidebar() + + return ( + + ) +} + +function SidebarRail({ className, ...props }: React.ComponentProps<"button">) { + const { toggleSidebar } = useSidebar() + + return ( + + ) +} + +function SidebarInset({ className, ...props }: React.ComponentProps<"main">) { + return ( + + ) +} + +function SidebarInput({ + className, + ...props +}: React.ComponentProps ) { + return ( + + ) +} + +function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( + + ) +} + +function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( + + ) +} + +function SidebarSeparator({ + className, + ...props +}: React.ComponentProps ) { + return ( + + ) +} + +function SidebarContent({ className, ...props }: React.ComponentProps<"div">) { + return ( + + ) +} + +function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( + + ) +} + +function SidebarGroupLabel({ + className, + render, + ...props +}: useRender.ComponentProps<"div"> & React.ComponentProps<"div">) { + return useRender({ + defaultTagName: "div", + props: mergeProps<"div">( + { + className: cn( + "flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", + className + ), + }, + props + ), + render, + state: { + slot: "sidebar-group-label", + sidebar: "group-label", + }, + }) +} + +function SidebarGroupAction({ + className, + render, + ...props +}: useRender.ComponentProps<"button"> & React.ComponentProps<"button">) { + return useRender({ + defaultTagName: "button", + props: mergeProps<"button">( + { + className: cn( + "absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0", + className + ), + }, + props + ), + render, + state: { + slot: "sidebar-group-action", + sidebar: "group-action", + }, + }) +} + +function SidebarGroupContent({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( + + ) +} + +function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) { + return ( + + ) +} + +function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) { + return ( + + ) +} + +const sidebarMenuButtonVariants = cva( + "peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate", + { + variants: { + variant: { + default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground", + outline: + "bg-background shadow-[0_0_0_1px_var(--sidebar-border)] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_var(--sidebar-accent)]", + }, + size: { + default: "h-8 text-sm", + sm: "h-7 text-xs", + lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +function SidebarMenuButton({ + render, + isActive = false, + variant = "default", + size = "default", + tooltip, + className, + ...props +}: useRender.ComponentProps<"button"> & + React.ComponentProps<"button"> & { + isActive?: boolean + tooltip?: string | React.ComponentProps
+ } & VariantProps ) { + const { isMobile, state } = useSidebar() + const comp = useRender({ + defaultTagName: "button", + props: mergeProps<"button">( + { + className: cn(sidebarMenuButtonVariants({ variant, size }), className), + }, + props + ), + render: !tooltip ? render : , + state: { + slot: "sidebar-menu-button", + sidebar: "menu-button", + size, + active: isActive, + }, + }) + + if (!tooltip) { + return comp + } + + if (typeof tooltip === "string") { + tooltip = { + children: tooltip, + } + } + + return ( + + {comp} + + ) +} + +function SidebarMenuAction({ + className, + render, + showOnHover = false, + ...props +}: useRender.ComponentProps<"button"> & + React.ComponentProps<"button"> & { + showOnHover?: boolean + }) { + return useRender({ + defaultTagName: "button", + props: mergeProps<"button">( + { + className: cn( + "absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0", + showOnHover && + "group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0", + className + ), + }, + props + ), + render, + state: { + slot: "sidebar-menu-action", + sidebar: "menu-action", + }, + }) +} + +function SidebarMenuBadge({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( + + ) +} + +function SidebarMenuSkeleton({ + className, + showIcon = false, + ...props +}: React.ComponentProps<"div"> & { + showIcon?: boolean +}) { + // Random width between 50 to 90%. + const [width] = React.useState(() => { + return `${Math.floor(Math.random() * 40) + 50}%` + }) + + return ( ++ + {showIcon && ( ++ ) +} + +function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) { + return ( ++ )} + + + ) +} + +function SidebarMenuSubItem({ + className, + ...props +}: React.ComponentProps<"li">) { + return ( + + ) +} + +function SidebarMenuSubButton({ + render, + size = "md", + isActive = false, + className, + ...props +}: useRender.ComponentProps<"a"> & + React.ComponentProps<"a"> & { + size?: "sm" | "md" + isActive?: boolean + }) { + return useRender({ + defaultTagName: "a", + props: mergeProps<"a">( + { + className: cn( + "flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden group-data-[collapsible=icon]:hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-sm data-[size=sm]:text-xs data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground", + className + ), + }, + props + ), + render, + state: { + slot: "sidebar-menu-sub-button", + sidebar: "menu-sub-button", + size, + active: isActive, + }, + }) +} + +export { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarGroup, + SidebarGroupAction, + SidebarGroupContent, + SidebarGroupLabel, + SidebarHeader, + SidebarInput, + SidebarInset, + SidebarMenu, + SidebarMenuAction, + SidebarMenuBadge, + SidebarMenuButton, + SidebarMenuItem, + SidebarMenuSkeleton, + SidebarMenuSub, + SidebarMenuSubButton, + SidebarMenuSubItem, + SidebarProvider, + SidebarRail, + SidebarSeparator, + SidebarTrigger, + useSidebar, +} diff --git a/fe-nlp/src/components/ui/skeleton.tsx b/fe-nlp/src/components/ui/skeleton.tsx new file mode 100644 index 0000000..0118624 --- /dev/null +++ b/fe-nlp/src/components/ui/skeleton.tsx @@ -0,0 +1,13 @@ +import { cn } from "@/lib/utils" + +function Skeleton({ className, ...props }: React.ComponentProps<"div">) { + return ( + + ) +} + +export { Skeleton } diff --git a/fe-nlp/src/components/ui/table.tsx b/fe-nlp/src/components/ui/table.tsx new file mode 100644 index 0000000..abeaced --- /dev/null +++ b/fe-nlp/src/components/ui/table.tsx @@ -0,0 +1,116 @@ +"use client" + +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Table({ className, ...props }: React.ComponentProps<"table">) { + return ( +
++ ) +} + +function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { + return ( + + ) +} + +function TableBody({ className, ...props }: React.ComponentProps<"tbody">) { + return ( + + ) +} + +function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) { + return ( + tr]:last:border-b-0", + className + )} + {...props} + /> + ) +} + +function TableRow({ className, ...props }: React.ComponentProps<"tr">) { + return ( ++
+ ) +} + +function TableHead({ className, ...props }: React.ComponentProps<"th">) { + return ( + + ) +} + +function TableCell({ className, ...props }: React.ComponentProps<"td">) { + return ( + + ) +} + +function TableCaption({ + className, + ...props +}: React.ComponentProps<"caption">) { + return ( + + ) +} + +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +} diff --git a/fe-nlp/src/components/ui/textarea.tsx b/fe-nlp/src/components/ui/textarea.tsx new file mode 100644 index 0000000..95ed1c4 --- /dev/null +++ b/fe-nlp/src/components/ui/textarea.tsx @@ -0,0 +1,18 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Textarea({ className, ...props }: React.ComponentProps<"textarea">) { + return ( + + ) +} + +export { Textarea } diff --git a/fe-nlp/src/components/ui/toast.tsx b/fe-nlp/src/components/ui/toast.tsx new file mode 100644 index 0000000..cb19f02 --- /dev/null +++ b/fe-nlp/src/components/ui/toast.tsx @@ -0,0 +1,234 @@ +"use client" + +import * as React from "react" +import { Toast as ToastPrimitive } from "@base-ui/react/toast" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { XIcon, CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react" + +const toast = ToastPrimitive.createToastManager() + +function ToastProvider({ ...props }: ToastPrimitive.Provider.Props) { + return +} + +function ToastPortal({ ...props }: ToastPrimitive.Portal.Props) { + return +} + +function ToastViewport({ className, ...props }: ToastPrimitive.Viewport.Props) { + return ( + + ) +} + +function Toast({ className, ...props }: ToastPrimitive.Root.Props) { + return ( + + ) +} + +function ToastContent({ className, ...props }: ToastPrimitive.Content.Props) { + return ( + + ) +} + +function ToastTitle({ className, ...props }: ToastPrimitive.Title.Props) { + return ( + + ) +} + +function ToastDescription({ + className, + ...props +}: ToastPrimitive.Description.Props) { + return ( + + ) +} + +function ToastAction({ + className, + render = , + ...props +}: ToastPrimitive.Action.Props) { + return ( + + ) +} + +function ToastClose({ + className, + children, + render = , + ...props +}: ToastPrimitive.Close.Props) { + return ( + + {children ?? ( + + )} + + ) +} + +function ToastIcon({ type }: { type: string | undefined }) { + let icon: React.ReactNode = null + + if (type === "success") { + icon = ( + + ) + } + + if (type === "info") { + icon = ( + + ) + } + + if (type === "warning") { + icon = ( + + ) + } + + if (type === "error") { + icon = ( + + ) + } + + if (type === "loading") { + icon = ( + + ) + } + + if (!icon) { + return null + } + + return ( + + {icon} + + ) +} + +function ToastList() { + const { toasts } = ToastPrimitive.useToastManager() + + return toasts.map((toastItem) => ( ++ + )) +} + +function Toaster({ + children, + toastManager = toast, + ...props +}: ToastPrimitive.Provider.Props) { + return ( ++ ++ +++ + + + + {children} + + ) +} + +const createToastManager = ToastPrimitive.createToastManager +const useToastManager = ToastPrimitive.useToastManager + +export { + Toaster, + Toast, + ToastAction, + ToastClose, + ToastContent, + ToastDescription, + ToastPortal, + ToastProvider, + ToastTitle, + ToastViewport, + createToastManager, + toast, + useToastManager, +} diff --git a/fe-nlp/src/components/ui/tooltip.tsx b/fe-nlp/src/components/ui/tooltip.tsx new file mode 100644 index 0000000..69e8a82 --- /dev/null +++ b/fe-nlp/src/components/ui/tooltip.tsx @@ -0,0 +1,66 @@ +"use client" + +import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip" + +import { cn } from "@/lib/utils" + +function TooltipProvider({ + delay = 0, + ...props +}: TooltipPrimitive.Provider.Props) { + return ( ++ ++ ++ + ) +} + +function Tooltip({ ...props }: TooltipPrimitive.Root.Props) { + return +} + +function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) { + return +} + +function TooltipContent({ + className, + side = "top", + sideOffset = 4, + align = "center", + alignOffset = 0, + children, + ...props +}: TooltipPrimitive.Popup.Props & + Pick< + TooltipPrimitive.Positioner.Props, + "align" | "alignOffset" | "side" | "sideOffset" + >) { + return ( + + + ) +} + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } diff --git a/fe-nlp/src/hooks/use-blob-model.ts b/fe-nlp/src/hooks/use-blob-model.ts new file mode 100644 index 0000000..2d670fa --- /dev/null +++ b/fe-nlp/src/hooks/use-blob-model.ts @@ -0,0 +1,34 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { getActiveModel, uploadModel } from '../services/blob-service'; +import { reloadModel } from '../services/model-service'; + +export const useActiveModel = () => { + return useQuery({ + queryKey: ['active-blob-model'], + queryFn: getActiveModel, + staleTime: 5 * 60 * 1000, // 5 minutes + refetchOnWindowFocus: true, + }); +}; + +export const useUploadModel = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (file: File) => uploadModel(file), + onSuccess: async () => { + // Refresh model info + queryClient.invalidateQueries({ queryKey: ['active-blob-model'] }); + + // Auto-reload backend model + try { + await reloadModel(); + // optionally invalidate metrics/info if they are loaded + queryClient.invalidateQueries({ queryKey: ['metrics'] }); + queryClient.invalidateQueries({ queryKey: ['info'] }); + } catch (error) { + console.error("Failed to automatically reload the model on the backend.", error); + } + }, + }); +}; diff --git a/fe-nlp/src/hooks/use-mobile.ts b/fe-nlp/src/hooks/use-mobile.ts new file mode 100644 index 0000000..a56aebc --- /dev/null +++ b/fe-nlp/src/hooks/use-mobile.ts @@ -0,0 +1,20 @@ +import * as React from "react" + +const MOBILE_BREAKPOINT = 768 + +export function useIsMobile() { + const [isMobile, setIsMobile] = React.useState+ ++ {children} + ++ (undefined) + + React.useEffect(() => { + const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) + const onChange = () => { + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) + } + mql.addEventListener("change", onChange) + // eslint-disable-next-line react-hooks/set-state-in-effect + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) + return () => mql.removeEventListener("change", onChange) + }, []) + + return !!isMobile +} diff --git a/fe-nlp/src/hooks/use-model-info.ts b/fe-nlp/src/hooks/use-model-info.ts new file mode 100644 index 0000000..eb7dded --- /dev/null +++ b/fe-nlp/src/hooks/use-model-info.ts @@ -0,0 +1,11 @@ +import { useQuery } from '@tanstack/react-query'; +import { fetchInfo } from '../services/model-service'; + +export const useModelInfo = () => { + return useQuery({ + queryKey: ['info'], + queryFn: fetchInfo, + staleTime: 10 * 60 * 1000, // 10 minutes + refetchOnWindowFocus: false, + }); +}; diff --git a/fe-nlp/src/hooks/use-model-metrics.ts b/fe-nlp/src/hooks/use-model-metrics.ts new file mode 100644 index 0000000..bb5f787 --- /dev/null +++ b/fe-nlp/src/hooks/use-model-metrics.ts @@ -0,0 +1,11 @@ +import { useQuery } from '@tanstack/react-query'; +import { fetchMetrics } from '../services/model-service'; + +export const useModelMetrics = () => { + return useQuery({ + queryKey: ['metrics'], + queryFn: fetchMetrics, + staleTime: 10 * 60 * 1000, // 10 minutes + refetchOnWindowFocus: false, + }); +}; diff --git a/fe-nlp/src/hooks/use-reload-model.ts b/fe-nlp/src/hooks/use-reload-model.ts new file mode 100644 index 0000000..2f00eb0 --- /dev/null +++ b/fe-nlp/src/hooks/use-reload-model.ts @@ -0,0 +1,19 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { reloadModel } from '../services/model-service'; +import { useDashboardStore } from '../store/use-dashboard-store'; + +export const useReloadModel = () => { + const queryClient = useQueryClient(); + const setLastReloadedAt = useDashboardStore((state) => state.setLastReloadedAt); + + return useMutation({ + mutationFn: reloadModel, + onSuccess: () => { + // Invalidate queries to refresh data on screen if needed + queryClient.invalidateQueries({ queryKey: ['metrics'] }); + queryClient.invalidateQueries({ queryKey: ['info'] }); + // Update global state + setLastReloadedAt(new Date()); + }, + }); +}; diff --git a/fe-nlp/src/lib/jwt.ts b/fe-nlp/src/lib/jwt.ts new file mode 100644 index 0000000..6aa1494 --- /dev/null +++ b/fe-nlp/src/lib/jwt.ts @@ -0,0 +1,26 @@ +import { SignJWT, jwtVerify, type JWTPayload } from 'jose'; + +export const getJwtSecretKey = () => { + const secret = process.env.JWT_SECRET; + if (!secret || secret.length === 0) { + throw new Error('Variabel environment JWT_SECRET tidak diatur.'); + } + return new TextEncoder().encode(secret); +}; + +export async function signJwt(payload: JWTPayload, expiresIn: string | number = '24h') { + return new SignJWT(payload) + .setProtectedHeader({ alg: 'HS256' }) + .setIssuedAt() + .setExpirationTime(expiresIn) + .sign(getJwtSecretKey()); +} + +export async function verifyJwt(token: string) { + try { + const { payload } = await jwtVerify(token, getJwtSecretKey()); + return payload; + } catch { + return null; + } +} diff --git a/fe-nlp/src/lib/prisma.ts b/fe-nlp/src/lib/prisma.ts new file mode 100644 index 0000000..fb2a0d6 --- /dev/null +++ b/fe-nlp/src/lib/prisma.ts @@ -0,0 +1,18 @@ +import { PrismaClient } from '../generated/prisma/client'; +import { PrismaPg } from '@prisma/adapter-pg'; +import { Pool } from 'pg'; + +const connectionString = `${process.env.DATABASE_URL}`; + +const pool = new Pool({ connectionString }); +const adapter = new PrismaPg(pool); + +const globalForPrisma = globalThis as unknown as { + prisma: PrismaClient | undefined; +}; + +export const prisma = + globalForPrisma.prisma ?? + new PrismaClient({ adapter }); + +if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma; diff --git a/fe-nlp/src/lib/utils.ts b/fe-nlp/src/lib/utils.ts new file mode 100644 index 0000000..bd0c391 --- /dev/null +++ b/fe-nlp/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from "clsx" +import { twMerge } from "tailwind-merge" + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} diff --git a/fe-nlp/src/proxy.ts b/fe-nlp/src/proxy.ts new file mode 100644 index 0000000..7eeff60 --- /dev/null +++ b/fe-nlp/src/proxy.ts @@ -0,0 +1,51 @@ +import { NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; +import { verifyJwt } from './lib/jwt'; + +// Rute yang perlu diproteksi +const protectedRoutes = ['/dashboard', '/input-model']; +// Rute yang tidak boleh diakses kalau sudah login +const publicOnlyRoutes = ['/login']; + +export async function proxy(request: NextRequest) { + const { pathname } = request.nextUrl; + + const isProtectedRoute = protectedRoutes.some( + (route) => pathname.startsWith(route) + ); + const isPublicOnlyRoute = publicOnlyRoutes.some((route) => pathname.startsWith(route)); + + // Jika halaman butuh proteksi + if (isProtectedRoute) { + const token = request.cookies.get('auth-token')?.value; + + if (!token) { + return NextResponse.redirect(new URL('/login', request.url)); + } + + const payload = await verifyJwt(token); + if (!payload) { + const response = NextResponse.redirect(new URL('/login', request.url)); + response.cookies.delete('auth-token'); // clear invalid token + return response; + } + } + + // Jika user sudah login tapi coba buka /login + if (isPublicOnlyRoute) { + const token = request.cookies.get('auth-token')?.value; + + if (token) { + const payload = await verifyJwt(token); + if (payload) { + return NextResponse.redirect(new URL('/dashboard', request.url)); + } + } + } + + return NextResponse.next(); +} + +export const config = { + matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'], +}; diff --git a/fe-nlp/src/services/api-client.ts b/fe-nlp/src/services/api-client.ts new file mode 100644 index 0000000..65c4f27 --- /dev/null +++ b/fe-nlp/src/services/api-client.ts @@ -0,0 +1,53 @@ +import axios from 'axios'; + +export const apiClient = axios.create({ + baseURL: '/api/backend', + timeout: 60000, // 60 seconds because reload might take a while +}); + +let isReloading = false; +let reloadPromise: Promise | null = null; + +apiClient.interceptors.response.use( + (response) => response, + async (error) => { + const originalRequest = error.config; + + // Redirect to login if 401 Unauthorized + if (error.response && error.response.status === 401) { + if (typeof window !== 'undefined') { + window.location.href = '/login'; + } + return Promise.reject(error); + } + + // Check if error is 500 or 503, hasn't been retried, and is not a reload-model request itself + if ( + error.response && + (error.response.status === 500 || error.response.status === 503) && + !originalRequest._retry && + originalRequest.url !== '/reload-model' + ) { + originalRequest._retry = true; + + if (!isReloading) { + isReloading = true; + // Trigger reload model + reloadPromise = apiClient.post('/reload-model').finally(() => { + isReloading = false; + reloadPromise = null; + }); + } + + try { + await reloadPromise; + // Retry the original request after reload is complete + return apiClient(originalRequest); + } catch (e) { + return Promise.reject(e); + } + } + + return Promise.reject(error); + } +); diff --git a/fe-nlp/src/services/api.ts b/fe-nlp/src/services/api.ts new file mode 100644 index 0000000..77f54af --- /dev/null +++ b/fe-nlp/src/services/api.ts @@ -0,0 +1,66 @@ +import axios from 'axios'; + +const API_BASE_URL = '/api/backend'; + +export interface PredictResponse { + sentiment: number; +} + +export interface InfoResponse { + status: string; + data: { + label_size: Record ; + train_size: number; + test_size: number; + }; +} + +export const sentimentApi = { + /** + * Mengambil informasi model termasuk label_size untuk pemetaan sentimen dinamis + */ + /** + * Mengambil informasi model termasuk label_size untuk pemetaan sentimen dinamis + */ + async getInfo(): Promise { + try { + const response = await axios.get (`${API_BASE_URL}/info`); + return response.data; + } catch (error: any) { + if (error.response && (error.response.status === 500 || error.response.status === 503)) { + console.warn('Get info merespons 500/503. Mencoba reload model...'); + await this.reloadModel(); + const retryResponse = await axios.get (`${API_BASE_URL}/info`); + return retryResponse.data; + } + throw error; + } + }, + + /** + * Memanggil ulang model jika terjadi kesalahan 500/503 pada predict + */ + async reloadModel(): Promise { + await axios.post(`${API_BASE_URL}/reload-model`); + }, + + /** + * Melakukan prediksi batch. Jika mendapat 500/503, otomatis reload model dan coba lagi. + */ + async predict(texts: string[]): Promise { + try { + const response = await axios.post (`${API_BASE_URL}/predict`, { texts }); + return response.data; + } catch (error: any) { + if (error.response && (error.response.status === 500 || error.response.status === 503)) { + console.warn('Backend merespons dengan 500/503. Mencoba reload model...'); + // Coba reload model + await this.reloadModel(); + // Coba kembali predict + const retryResponse = await axios.post (`${API_BASE_URL}/predict`, { texts }); + return retryResponse.data; + } + throw error; + } + } +}; diff --git a/fe-nlp/src/services/blob-service.ts b/fe-nlp/src/services/blob-service.ts new file mode 100644 index 0000000..6264c7b --- /dev/null +++ b/fe-nlp/src/services/blob-service.ts @@ -0,0 +1,46 @@ +import axios from 'axios'; + +// We don't use the apiClient with the backend proxy for vercel blob, +// because this goes to our own Next.js API route. +export const blobApiClient = axios.create({ + baseURL: '/api', + timeout: 60000, // 60 seconds because file upload might take a while +}); + +export interface BlobModelData { + url: string; + downloadUrl: string; + pathname: string; + size: number; + uploadedAt: string; +} + +export interface GetBlobModelResponse { + status: string; + data: BlobModelData | null; + message?: string; +} + +export interface UploadBlobModelResponse { + status: string; + data: BlobModelData; + message?: string; +} + +export const getActiveModel = async (): Promise => { + const response = await blobApiClient.get ('/blob-model'); + return response.data.data; +}; + +export const uploadModel = async (file: File): Promise => { + const formData = new FormData(); + formData.append('file', file); + + const response = await blobApiClient.post ('/blob-model', formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + + return response.data.data; +}; diff --git a/fe-nlp/src/services/model-service.ts b/fe-nlp/src/services/model-service.ts new file mode 100644 index 0000000..2078c9d --- /dev/null +++ b/fe-nlp/src/services/model-service.ts @@ -0,0 +1,17 @@ +import { apiClient } from './api-client'; +import { MetricsResponse, InfoResponse, ReloadResponse } from '../types/model'; + +export const fetchMetrics = async (): Promise => { + const response = await apiClient.get ('/metrics'); + return response.data; +}; + +export const fetchInfo = async (): Promise => { + const response = await apiClient.get ('/info'); + return response.data; +}; + +export const reloadModel = async (): Promise => { + const response = await apiClient.post ('/reload-model'); + return response.data; +}; diff --git a/fe-nlp/src/store/use-dashboard-store.ts b/fe-nlp/src/store/use-dashboard-store.ts new file mode 100644 index 0000000..e991608 --- /dev/null +++ b/fe-nlp/src/store/use-dashboard-store.ts @@ -0,0 +1,15 @@ +import { create } from 'zustand'; + +interface DashboardState { + lastReloadedAt: Date | null; + isReloading: boolean; + setLastReloadedAt: (date: Date) => void; + setReloading: (status: boolean) => void; +} + +export const useDashboardStore = create ((set) => ({ + lastReloadedAt: null, + isReloading: false, + setLastReloadedAt: (date) => set({ lastReloadedAt: date }), + setReloading: (status) => set({ isReloading: status }), +})); diff --git a/fe-nlp/src/types/model.ts b/fe-nlp/src/types/model.ts new file mode 100644 index 0000000..d6e452a --- /dev/null +++ b/fe-nlp/src/types/model.ts @@ -0,0 +1,46 @@ +export interface ModelMetrics { + accuracy: number; + precision: number; + recall: number; + f1_score: number; +} + +export interface MetricsResponse { + status: string; + metrics: ModelMetrics; +} + +export interface DatasetInfo { + label_size: Record ; + train_size: number; + test_size: number; +} + +export interface InfoResponse { + status: string; + data: DatasetInfo; +} + +export interface ReloadResponse { + status: string; + message: string; +} + +export interface DynamicLabelInsight { + label: string; + count: number; + percentage: number; + sentimentCategory: 'Positif' | 'Negatif' | 'Netral'; +} + +export interface SummaryInsight { + dominantSentiment: 'Positif' | 'Negatif' | 'Seimbang'; + dominantLabel: string; + dominantPercentage: number; + conclusionText: string; + totalData: number; + trainRatioPercentage: number; + testRatioPercentage: number; + errorRatePercentage: number; + insightsList: string[]; +} diff --git a/fe-nlp/src/utils/excelUtils.ts b/fe-nlp/src/utils/excelUtils.ts new file mode 100644 index 0000000..0fa0aa7 --- /dev/null +++ b/fe-nlp/src/utils/excelUtils.ts @@ -0,0 +1,90 @@ +import * as XLSX from 'xlsx'; + +/** + * Membaca file Excel atau TXT dan mengembalikan array of string dari baris/kolom pertama + */ +export async function readTextsFromFile(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + + reader.onload = (e) => { + try { + const data = e.target?.result; + + // Cek jika ini adalah file txt + if (file.name.endsWith('.txt')) { + const text = data as string; + // Pisahkan berdasarkan baris baru dan bersihkan baris kosong + const lines = text.split(/\r?\n/).map(line => line.trim()).filter(line => line.length > 0); + resolve(lines); + return; + } + + // Proses sebagai file excel + const workbook = XLSX.read(data, { type: 'binary' }); + const firstSheetName = workbook.SheetNames[0]; + const worksheet = workbook.Sheets[firstSheetName]; + + // Mengubah sheet menjadi array 2D + const json: any[][] = XLSX.utils.sheet_to_json(worksheet, { header: 1 }); + + // Ambil data dari kolom pertama (index 0) dan bersihkan nilai kosong/undefined + const texts: string[] = []; + for (let i = 0; i < json.length; i++) { + const row = json[i]; + if (row && row[0] !== undefined && row[0] !== null && String(row[0]).trim() !== '') { + const textValue = String(row[0]).trim(); + // Abaikan jika baris pertama adalah header (misal: "Ulasan") + if (i === 0 && (textValue.toLowerCase() === 'ulasan' || textValue.toLowerCase() === 'teks' || textValue.toLowerCase() === 'text')) { + continue; + } + texts.push(textValue); + } + } + + resolve(texts); + } catch (err) { + reject(new Error('Gagal membaca file')); + } + }; + + reader.onerror = () => { + reject(new Error('Terjadi kesalahan saat membaca file')); + }; + + if (file.name.endsWith('.txt')) { + reader.readAsText(file); + } else { + reader.readAsBinaryString(file); + } + }); +} + +/** + * Mengubah array of object menjadi file Excel dan mengunduhnya + */ +export function downloadResultsAsExcel(results: any[], filename: string = 'Hasil_Analisis_Sentimen.xlsx') { + const worksheet = XLSX.utils.json_to_sheet(results); + const workbook = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(workbook, worksheet, 'Hasil Prediksi'); + + // Memicu unduhan + XLSX.writeFile(workbook, filename); +} + +/** + * Membuat file contoh (template) dan mengunduhnya + */ +export function downloadTemplateExcel() { + const templateData = [ + { Ulasan: "Aplikasi skolla ini sangat membantu saya belajar UTBK!" }, + { Ulasan: "Terkadang loadingnya lama saat buka video pembelajaran." }, + { Ulasan: "Aplikasi nya sering eror tiba tiba." } + ]; + + const worksheet = XLSX.utils.json_to_sheet(templateData); + const workbook = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(workbook, worksheet, 'Template Ulasan'); + + XLSX.writeFile(workbook, 'Template_Input_Skolla.xlsx'); +} diff --git a/fe-nlp/src/utils/label-parser.ts b/fe-nlp/src/utils/label-parser.ts new file mode 100644 index 0000000..401d739 --- /dev/null +++ b/fe-nlp/src/utils/label-parser.ts @@ -0,0 +1,62 @@ +import { DatasetInfo, DynamicLabelInsight, SummaryInsight } from '../types/model'; + +export function parseLabelInsights(data: DatasetInfo): SummaryInsight { + const { label_size, train_size, test_size } = data; + + // Sort keys to determine which is lower (Negative) and higher (Positive) + const labels = Object.keys(label_size).sort((a, b) => Number(a) - Number(b)); + + const totalData = train_size + test_size; + + // Create an insight for each label + const insightsList: string[] = []; + const parsedLabels: DynamicLabelInsight[] = labels.map((label, index) => { + const count = label_size[label]; + const percentage = (count / totalData) * 100; + + // Determine category based on sorting (lowest = Negatif, highest = Positif, middle = Netral) + let category: 'Positif' | 'Negatif' | 'Netral' = 'Netral'; + if (index === 0) { + category = 'Negatif'; + } else if (index === labels.length - 1) { + category = 'Positif'; + } + + insightsList.push(`Label ${label} memiliki ${count} sampel (${percentage.toFixed(2)}%) yang merepresentasikan sentimen ${category}.`); + + return { + label, + count, + percentage, + sentimentCategory: category + }; + }); + + // Find dominant label + let dominantInsight = parsedLabels[0]; + for (const item of parsedLabels) { + if (item.count > dominantInsight.count) { + dominantInsight = item; + } + } + + const dominantSentiment = dominantInsight.sentimentCategory === 'Netral' ? 'Seimbang' : dominantInsight.sentimentCategory; + + const conclusionText = `Berdasarkan analisis distribusi data, dataset ini memiliki kecenderungan condong ke arah sentimen ${dominantSentiment.toUpperCase()} dengan porsi ${dominantInsight.percentage.toFixed(1)}%.`; + + // Provide other summary metrics + const trainRatioPercentage = (train_size / totalData) * 100; + const testRatioPercentage = (test_size / totalData) * 100; + + return { + dominantSentiment, + dominantLabel: dominantInsight.label, + dominantPercentage: dominantInsight.percentage, + conclusionText, + totalData, + trainRatioPercentage, + testRatioPercentage, + errorRatePercentage: 0, // This will be calculated in metrics-calculator + insightsList + }; +} diff --git a/fe-nlp/src/utils/metrics-calculator.ts b/fe-nlp/src/utils/metrics-calculator.ts new file mode 100644 index 0000000..c501055 --- /dev/null +++ b/fe-nlp/src/utils/metrics-calculator.ts @@ -0,0 +1,13 @@ +import { ModelMetrics } from '../types/model'; + +export function calculateMetricsDerivatives(metrics: ModelMetrics) { + const errorRatePercentage = (1 - metrics.accuracy) * 100; + + return { + accuracyPercentage: metrics.accuracy * 100, + precisionPercentage: metrics.precision * 100, + recallPercentage: metrics.recall * 100, + f1ScorePercentage: metrics.f1_score * 100, + errorRatePercentage, + }; +} diff --git a/fe-nlp/tsconfig.json b/fe-nlp/tsconfig.json new file mode 100644 index 0000000..cf9c65d --- /dev/null +++ b/fe-nlp/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts", + "**/*.mts" + ], + "exclude": ["node_modules"] +}