289 lines
11 KiB
TypeScript
289 lines
11 KiB
TypeScript
'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<any[]>([]);
|
|
|
|
// Mapping Sentimen dinamis dari backend
|
|
const [sentimentMap, setSentimentMap] = useState<Record<string, string>>({});
|
|
|
|
const fileInputRef = useRef<HTMLInputElement>(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<string, string> = {};
|
|
|
|
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<string, string>) => {
|
|
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<HTMLInputElement>) => {
|
|
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 (
|
|
<section className="w-full py-12 md:py-24 flex flex-col items-center">
|
|
<div className="container px-4 md:px-6 max-w-4xl space-y-8">
|
|
|
|
{/* Header Section */}
|
|
<div className="text-center space-y-4">
|
|
<h1 className="text-3xl font-bold tracking-tighter sm:text-4xl md:text-5xl">Uji Kalimatmu di Sini</h1>
|
|
<p className="mx-auto max-w-[700px] text-muted-foreground md:text-lg">
|
|
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.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Input Area */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Coba Prediksi Sentimen</CardTitle>
|
|
<CardDescription>
|
|
Masukkan teks secara manual atau import dari file Excel/TXT.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
|
|
<div className="flex flex-col gap-2">
|
|
<Textarea
|
|
placeholder="Ketikkan ulasan Anda di sini..."
|
|
className="min-h-[120px] resize-y"
|
|
value={textInput}
|
|
onChange={(e) => setTextInput(e.target.value)}
|
|
disabled={isLoading}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap gap-2 pt-2">
|
|
<span className="text-sm font-medium text-muted-foreground self-center mr-2">Rekomendasi ulasan:</span>
|
|
<Button variant="outline" size="sm" onClick={() => applyRecommendation("Aplikasi skolla sangat membantu belajar UTBK!")} disabled={isLoading}>
|
|
Pujian
|
|
</Button>
|
|
<Button variant="outline" size="sm" onClick={() => applyRecommendation("Sering lag kalau buka video pembelajaran, tolong diperbaiki.")} disabled={isLoading}>
|
|
Keluhan
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
|
|
<CardFooter className="flex flex-col sm:flex-row justify-between gap-4 border-t bg-muted/20 p-6">
|
|
<div className="flex flex-col sm:flex-row gap-2 w-full sm:w-auto">
|
|
<input
|
|
type="file"
|
|
accept=".xlsx, .xls, .txt"
|
|
className="hidden"
|
|
ref={fileInputRef}
|
|
onChange={handleFileUpload}
|
|
/>
|
|
<Button variant="secondary" onClick={() => fileInputRef.current?.click()} disabled={isLoading} className="w-full sm:w-auto">
|
|
{isLoading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Upload className="mr-2 h-4 w-4" />}
|
|
Import Excel / TXT
|
|
</Button>
|
|
<Button variant="ghost" size="sm" onClick={downloadTemplateExcel} disabled={isLoading}>
|
|
<FileDown className="mr-2 h-4 w-4" />
|
|
Template
|
|
</Button>
|
|
</div>
|
|
|
|
<Button onClick={handlePredictSingle} disabled={isLoading || (!textInput.trim() && results.length === 0)} className="w-full sm:w-auto">
|
|
{isLoading ? (
|
|
<>
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
Memproses Teks...
|
|
</>
|
|
) : (
|
|
'Mulai Analisis'
|
|
)}
|
|
</Button>
|
|
</CardFooter>
|
|
</Card>
|
|
|
|
{/* Result Area */}
|
|
{results.length > 0 && !isLoading && (
|
|
<Card className="border-primary/20 bg-primary/5">
|
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
|
<div className="space-y-1">
|
|
<CardTitle>Hasil Prediksi</CardTitle>
|
|
<CardDescription>
|
|
Ditemukan {results.length} hasil analisis sentimen.
|
|
</CardDescription>
|
|
</div>
|
|
<Button variant="outline" size="sm" onClick={() => downloadResultsAsExcel(results)}>
|
|
<Download className="mr-2 h-4 w-4" />
|
|
Unduh Hasil
|
|
</Button>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{results.length === 1 ? (
|
|
<div className="flex flex-col items-center justify-center p-6 bg-background rounded-lg border shadow-sm">
|
|
<span className="text-muted-foreground mb-2">Sentimen:</span>
|
|
<span className={`text-3xl font-bold ${
|
|
results[0].Sentimen === 'Positif' ? 'text-green-600' :
|
|
results[0].Sentimen === 'Negatif' ? 'text-red-600' : 'text-yellow-600'
|
|
}`}>
|
|
{results[0].Sentimen}
|
|
</span>
|
|
</div>
|
|
) : (
|
|
<div className="rounded-md border bg-background max-h-[400px] overflow-auto">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead className="w-2/3">Teks Ulasan</TableHead>
|
|
<TableHead>Sentimen</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{results.map((row, idx) => (
|
|
<TableRow key={idx}>
|
|
<TableCell className="font-medium">{row.Teks}</TableCell>
|
|
<TableCell>
|
|
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold ${
|
|
row.Sentimen === 'Positif' ? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400' :
|
|
row.Sentimen === 'Negatif' ? 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400' : 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400'
|
|
}`}>
|
|
{row.Sentimen}
|
|
</span>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|