mirror of
https://github.com/nihonbuzz/nihonbuzz-academy.git
synced 2026-01-25 21:18:45 +07:00
feat: Implement a new course learning system with dedicated layouts, lesson playback, and Spaced Repetition System (SRS) functionality.
This commit is contained in:
@@ -62,4 +62,7 @@ AWS_DEFAULT_REGION=us-east-1
|
||||
AWS_BUCKET=
|
||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||
|
||||
AWS_VIDEO_BUCKET=
|
||||
AWS_VIDEO_URL=
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -6,6 +6,7 @@
|
||||
/storage/*.key
|
||||
/storage/pail
|
||||
/vendor
|
||||
/x-dev-environment
|
||||
.env
|
||||
.env.backup
|
||||
.env.production
|
||||
|
||||
@@ -126,7 +126,10 @@ erDiagram
|
||||
### A. Japanese Support System
|
||||
- **Furigana Engine**: Implementasi tag HTML `<ruby>` di seluruh komponen React untuk manajemen cara baca Kanji otomatis.
|
||||
- **Multi-lingual Context**: Setiap data kosa kata wajib memiliki konteks makna dalam Bahasa Indonesia dan Inggris.
|
||||
- **Media Delivery**: Seluruh aset audio (pronunciation) dan video materi dialirkan (streaming) melalui Cloudflare R2 untuk latensi rendah.
|
||||
- **Media Delivery (Supercharged R2)**:
|
||||
- **Asset Proxy**: Gambar static (`/storage`) dilayani via Cloudflare Worker dengan cache agresif 7 hari (`cdn.academy.nihonbuzz.org`).
|
||||
- **Video Proxy**: Video materi dialirkan via Worker khusus (`video.cdn.academy.nihonbuzz.org`) yang menangani *Cross-Origin Resource Sharing* (CORS), *Range Requests*, dan *Dark Theme Error Pages*.
|
||||
- **Storage Separation**: Menggunakan dua bucket terpisah: `academy` (aset umum) dan `academy-video` (materi kursus) untuk isolasi performa.
|
||||
|
||||
### B. SRS (Spaced Repetition System)
|
||||
- Menggunakan algoritma pengulangan berjarak (SM-2 based) untuk menghitung `next_review_at` secara individual bagi setiap siswa pada setiap unit kosa kata.
|
||||
|
||||
@@ -41,7 +41,7 @@ return [
|
||||
'public' => [
|
||||
'driver' => 'local',
|
||||
'root' => storage_path('app/public'),
|
||||
'url' => env('APP_URL').'/storage',
|
||||
'url' => env('APP_URL') . '/storage',
|
||||
'visibility' => 'public',
|
||||
'throw' => false,
|
||||
'report' => false,
|
||||
@@ -73,6 +73,19 @@ return [
|
||||
'throw' => false,
|
||||
],
|
||||
|
||||
'r2_video' => [
|
||||
'driver' => 's3',
|
||||
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||
'region' => 'auto',
|
||||
'bucket' => env('AWS_VIDEO_BUCKET'),
|
||||
'url' => env('AWS_VIDEO_URL'),
|
||||
'endpoint' => env('AWS_ENDPOINT'),
|
||||
'use_path_style_endpoint' => false,
|
||||
'visibility' => 'public',
|
||||
'throw' => false,
|
||||
],
|
||||
|
||||
'r2_private' => [
|
||||
'driver' => 's3',
|
||||
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||
|
||||
@@ -4,14 +4,13 @@ use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('media', function (Blueprint $table) {
|
||||
$table->id();
|
||||
|
||||
$table->morphs('model');
|
||||
$table->uuidMorphs('model');
|
||||
$table->uuid()->nullable()->unique();
|
||||
$table->string('collection_name');
|
||||
$table->string('name');
|
||||
|
||||
108
deploy.sh
Normal file
108
deploy.sh
Normal file
@@ -0,0 +1,108 @@
|
||||
#!/bin/bash
|
||||
|
||||
# =========================================================================
|
||||
# CONFIGURATION & ENVIRONMENT
|
||||
# =========================================================================
|
||||
export HOME=/root
|
||||
export COMPOSER_HOME=/root/.composer
|
||||
export PATH=$PATH:/usr/local/bin:/usr/bin:/bin
|
||||
PROJECT_PATH="/www/wwwroot/academy.nihonbuzz.org"
|
||||
PHP_BIN="/www/server/php/83/bin/php"
|
||||
|
||||
# --- CONFIG TELEGRAM ---
|
||||
# Load from .env if available
|
||||
if [ -f .env ]; then
|
||||
export $(grep -v '^#' .env | xargs)
|
||||
fi
|
||||
|
||||
BOT_TOKEN="${TELEGRAM_BOT_TOKEN}"
|
||||
CHAT_ID="${TELEGRAM_CHAT_ID}"
|
||||
|
||||
send_telegram() {
|
||||
local message="$1"
|
||||
curl -s -X POST "https://api.telegram.org/bot$BOT_TOKEN/sendMessage" \
|
||||
-d chat_id="$CHAT_ID" \
|
||||
-d text="$message" \
|
||||
-d parse_mode="HTML" > /dev/null
|
||||
}
|
||||
|
||||
# =========================================================================
|
||||
# START DEPLOYMENT
|
||||
# =========================================================================
|
||||
echo "🚀 Starting Deployment..."
|
||||
send_telegram "⏳ <b>Deployment Started</b>%0A%0A🚀 <b>Project:</b> Nihonbuzz Academy%0A📅 <b>Date:</b> $(date)"
|
||||
|
||||
set -e
|
||||
|
||||
git config --global --add safe.directory "$PROJECT_PATH"
|
||||
cd "$PROJECT_PATH"
|
||||
|
||||
trap 'send_telegram "❌ <b>Deployment FAILED!</b>%0A%0A⚠️ Check server logs untuk detail.%0A📅 <b>Date:</b> $(date)"; exit 1' ERR
|
||||
|
||||
# 3. Pull & Clean
|
||||
echo "📥 Pulling latest code..."
|
||||
git pull origin main
|
||||
|
||||
echo "🧹 Cleaning untracked files..."
|
||||
# CATATAN: Karena .well-known terhapus, pastikan Anda tidak sedang proses renewal SSL
|
||||
git clean -fd
|
||||
|
||||
# 4. PHP Dependencies
|
||||
echo "📦 Updating Composer dependencies..."
|
||||
$PHP_BIN /usr/bin/composer install --no-dev --optimize-autoloader --no-interaction
|
||||
|
||||
# 5. Frontend Assets (URUTAN DIPERBAIKI)
|
||||
echo "📦 Building frontend assets..."
|
||||
npm install
|
||||
|
||||
echo "🔧 Fixing permissions..."
|
||||
find node_modules -type f \( -path "*/bin/*" -o -path "*/.bin/*" \) -exec chmod +x {} \;
|
||||
if [ -d "node_modules/@esbuild/linux-x64/bin" ]; then
|
||||
chmod +x node_modules/@esbuild/linux-x64/bin/esbuild
|
||||
fi
|
||||
|
||||
rm -rf public/build
|
||||
echo "🏗 Running Vite build..."
|
||||
npx vite build
|
||||
|
||||
# Prune SEKARANG setelah build sukses
|
||||
echo "🧹 Pruning dev dependencies..."
|
||||
npm prune --omit=dev
|
||||
|
||||
# 6. Environment Setup
|
||||
# NOTE: Adjusted to use the new standardized naming if available, otherwise fallback
|
||||
if [ -f .env.production.editable ]; then
|
||||
echo "📄 Updating .env from .env.production.editable..."
|
||||
cp .env.production.editable .env
|
||||
elif [ ! -f .env ]; then
|
||||
# Fallback legacy
|
||||
cp .env.production.example .env
|
||||
fi
|
||||
|
||||
# 6.5 Ensure SQLite Database Exists
|
||||
DB_FILE="$PROJECT_PATH/database/database.sqlite"
|
||||
if [ ! -f "$DB_FILE" ]; then
|
||||
echo "🗄️ Creating SQLite database file..."
|
||||
touch "$DB_FILE"
|
||||
fi
|
||||
|
||||
# 7. Laravel Optimizations
|
||||
echo "⚡ Optimizing Laravel..."
|
||||
# Clear config first to ensure migration uses latest .env
|
||||
$PHP_BIN artisan config:clear
|
||||
|
||||
# Migrate database to ensure tables (like cache) exist
|
||||
$PHP_BIN artisan migrate --force
|
||||
|
||||
# Now clear all caches (including DB-based cache)
|
||||
$PHP_BIN artisan optimize:clear
|
||||
|
||||
# NEW: Conditional CA Data Migration (Optional, uncomment if needed routinely)
|
||||
# $PHP_BIN artisan ca:migrate-data
|
||||
|
||||
$PHP_BIN artisan config:cache
|
||||
$PHP_BIN artisan route:cache
|
||||
$PHP_BIN artisan view:cache
|
||||
|
||||
echo "✅ Deployment SUCCESS!"
|
||||
send_telegram "✅ <b>Deployment Success!</b>%0A%0A📦 <b>Project:</b> Nihonbuzz Academy%0A📅 <b>Date:</b> $(date)"
|
||||
26
public/debug_pgsql.php
Normal file
26
public/debug_pgsql.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
echo "<h3>PHP Debug Info</h3>";
|
||||
echo "<strong>PHP Version:</strong> " . phpversion() . "<br>";
|
||||
echo "<strong>Loaded INI File:</strong> " . php_ini_loaded_file() . "<br>";
|
||||
echo "<strong>Active PDO Drivers:</strong> " . implode(', ', pdo_drivers()) . "<br>";
|
||||
|
||||
echo "<hr>";
|
||||
echo "<h3>Connection Test</h3>";
|
||||
|
||||
try {
|
||||
// Attempt standard connection
|
||||
$dsn = "pgsql:host=10.10.30.3;port=5432;dbname=nihonbuzz_academy";
|
||||
$user = "nihonbuzz_user";
|
||||
$pass = "password_to_be_changed"; // Using the default we set earlier
|
||||
|
||||
$pdo = new PDO($dsn, $user, $pass);
|
||||
echo "<span style='color:green; font-weight:bold;'>✅ Connection SUCCESS!</span><br>";
|
||||
echo "Connected to PostgreSQL database successfully.";
|
||||
} catch (PDOException $e) {
|
||||
echo "<span style='color:red; font-weight:bold;'>❌ Connection FAILED</span><br>";
|
||||
echo "Error Message: " . $e->getMessage() . "<br>";
|
||||
|
||||
if (strpos($e->getMessage(), 'could not find driver') !== false) {
|
||||
echo "<br><strong>Troubleshooting:</strong> The PostgreSQL driver is missing. Verify php.ini settings and restart the web server.";
|
||||
}
|
||||
}
|
||||
@@ -1,286 +0,0 @@
|
||||
import { Link, usePage } from '@inertiajs/react';
|
||||
import { PropsWithChildren, ReactNode } from 'react';
|
||||
import {
|
||||
Menu,
|
||||
LogOut,
|
||||
User as UserIcon,
|
||||
LayoutDashboard,
|
||||
BookOpen,
|
||||
Calendar,
|
||||
Settings,
|
||||
Users,
|
||||
Search,
|
||||
Brain
|
||||
} from 'lucide-react';
|
||||
import { Sheet, SheetContent, SheetTrigger } from '@/Components/ui/sheet';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSeparator } from '@/Components/ui/dropdown-menu';
|
||||
import { Input } from '@/Components/ui/input';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/Components/ui/avatar';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ScrollArea } from '@/Components/ui/scroll-area';
|
||||
import { ModeToggle } from '@/Components/ModeToggle';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { ChevronLeft, ChevronRight, Trophy as TrophyIcon } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
export default function Authenticated({
|
||||
header,
|
||||
children,
|
||||
}: PropsWithChildren<{ header?: ReactNode }>) {
|
||||
const user = usePage().props.auth.user;
|
||||
const currentRoute = route().current() as string;
|
||||
|
||||
const [isCollapsed, setIsCollapsed] = useState(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
return localStorage.getItem('sidebar-collapsed') === 'true';
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('sidebar-collapsed', String(isCollapsed));
|
||||
}, [isCollapsed]);
|
||||
|
||||
const navItems = [
|
||||
{ label: 'Dashboard', icon: LayoutDashboard, href: route('dashboard'), active: route().current('dashboard') },
|
||||
{ label: 'Galeri Kursus', icon: BookOpen, href: route('courses.index'), active: route().current('courses.index') },
|
||||
{ label: 'SRS Practice', icon: Brain, href: route('srs.index'), active: route().current('srs.*') },
|
||||
{ label: 'Jadwal Belajar', icon: Calendar, href: '#', active: false },
|
||||
{ label: 'Komunitas', icon: Users, href: '#', active: false },
|
||||
{ label: 'Pengaturan', icon: Settings, href: route('profile.edit'), active: route().current('profile.edit') },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background font-sans selection:bg-primary selection:text-primary-foreground">
|
||||
|
||||
{/* Desktop Sidebar Island */}
|
||||
<aside className={cn(
|
||||
"hidden lg:flex flex-col fixed left-4 top-4 bottom-4 z-50 bg-card/40 backdrop-blur-2xl border border-border/40 transition-all duration-500 ease-in-out shadow-2xl overflow-hidden",
|
||||
isCollapsed ? "w-20 rounded-[2rem]" : "w-64 rounded-[2.5rem]"
|
||||
)}>
|
||||
{/* Collapse Toggle */}
|
||||
<button
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
className="absolute -right-0 top-20 bg-primary text-white p-1 rounded-l-md shadow-lg opacity-0 group-hover:opacity-100 transition-opacity z-50 hover:bg-primary/90 hidden"
|
||||
>
|
||||
{isCollapsed ? <ChevronRight size={14} /> : <ChevronLeft size={14} />}
|
||||
</button>
|
||||
|
||||
<div className={cn("h-16 flex items-center transition-all duration-500", isCollapsed ? "justify-center px-0" : "px-6")}>
|
||||
<Link href="/" className="flex items-center gap-2 group">
|
||||
{isCollapsed ? (
|
||||
<>
|
||||
<img src="/brand/Nihonbuzz-Academy-Light.png" alt="NB" className="h-10 w-10 dark:hidden transition-transform group-hover:rotate-12" />
|
||||
<img src="/brand/Nihonbuzz-Academy-Dark.png" alt="NB" className="h-10 w-10 hidden dark:block transition-transform group-hover:rotate-12" />
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center gap-3">
|
||||
<img src="/brand/Nihonbuzz-Academy-Light.png" alt="NB" className="h-10 w-10 dark:hidden" />
|
||||
<img src="/brand/Nihonbuzz-Academy-Dark.png" alt="NB" className="h-10 w-10 hidden dark:block" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-black tracking-tighter leading-none dark:text-white text-gray-900">NIHONBUZZ</span>
|
||||
<span className="text-[10px] font-bold tracking-[0.2em] text-primary leading-none mt-1">ACADEMY</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1 py-6 px-3">
|
||||
<nav className="space-y-2">
|
||||
{navItems.map((item, index) => (
|
||||
<Link
|
||||
key={index}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-3 py-3 rounded-2xl text-sm font-medium transition-all duration-300 group relative overflow-hidden",
|
||||
item.active
|
||||
? "bg-primary/10 text-primary shadow-sm"
|
||||
: "text-muted-foreground hover:bg-muted/50 hover:text-foreground",
|
||||
isCollapsed && "justify-center px-0"
|
||||
)}
|
||||
>
|
||||
<item.icon size={20} className={cn(
|
||||
"transition-all duration-300",
|
||||
item.active ? "text-primary scale-110" : "text-muted-foreground group-hover:text-foreground group-hover:scale-110"
|
||||
)} />
|
||||
{!isCollapsed && <span className="truncate">{item.label}</span>}
|
||||
{item.active && !isCollapsed && (
|
||||
<motion.div layoutId="activeNav" className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-6 bg-primary rounded-r-full" />
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{!isCollapsed && (
|
||||
<div className="mt-8 px-3 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||
<h4 className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground/50 mb-3 px-2">Shortcut</h4>
|
||||
<div className="space-y-2">
|
||||
<div className="p-4 rounded-[1.5rem] bg-gradient-to-br from-nihonbuzz-red to-orange-500 text-white shadow-xl shadow-nihonbuzz-red/20 relative overflow-hidden group hover:shadow-nihonbuzz-red/30 transition-all hover:-translate-y-0.5">
|
||||
<div className="absolute -top-2 -right-2 p-2 opacity-10 group-hover:opacity-20 transition-opacity rotate-12">
|
||||
<TrophyIcon size={64} />
|
||||
</div>
|
||||
<p className="text-[10px] font-bold opacity-80 mb-0.5">Current Level</p>
|
||||
<p className="text-xl font-black">N5 Basic</p>
|
||||
<Button size="sm" variant="secondary" className="w-full mt-3 bg-white/20 hover:bg-white/30 text-white border-none h-7 text-[10px] font-bold uppercase tracking-wider backdrop-blur-md">
|
||||
Sertifikat
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
<div className="p-3 border-t border-border/20">
|
||||
<button
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
className={cn(
|
||||
"flex items-center gap-3 w-full px-3 py-2.5 text-xs font-bold text-muted-foreground hover:text-foreground hover:bg-muted/50 rounded-xl transition-all",
|
||||
isCollapsed && "justify-center"
|
||||
)}
|
||||
>
|
||||
{isCollapsed ? <ChevronRight size={18} /> : <><ChevronLeft size={18} /> <span>Sembunyikan</span></>}
|
||||
</button>
|
||||
<Link href={route('logout')} method="post" as="button" className={cn("flex items-center gap-3 w-full px-3 py-2.5 text-sm font-medium text-muted-foreground hover:text-destructive hover:bg-destructive/10 rounded-xl transition-colors mt-1", isCollapsed && "justify-center")}>
|
||||
<LogOut size={18} />
|
||||
{!isCollapsed && "Keluar"}
|
||||
</Link>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main Content Wrapper */}
|
||||
<div className={cn(
|
||||
"min-h-screen flex flex-col transition-all duration-500 ease-in-out",
|
||||
isCollapsed ? "lg:pl-24" : "lg:pl-72"
|
||||
)}>
|
||||
|
||||
{/* Island Topbar Pill */}
|
||||
<header className="sticky top-4 z-40 h-16 flex items-center justify-between px-4 sm:px-6 mx-4 rounded-[1.25rem] bg-background/40 backdrop-blur-2xl border border-border/40 shadow-xl shadow-black/5 transition-all duration-500">
|
||||
<div className="flex items-center gap-4 lg:hidden">
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="rounded-lg -ml-2">
|
||||
<Menu size={20} className="text-muted-foreground" />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left" className="w-[280px] p-0 border-r-0 bg-card">
|
||||
<div className="h-16 flex items-center px-6 border-b border-border">
|
||||
<img
|
||||
src="/brand/Nihonbuzz-Academy-Light-LS-Regular.png"
|
||||
alt="Nihonbuzz Academy"
|
||||
className="h-6 w-auto dark:hidden"
|
||||
/>
|
||||
<img
|
||||
src="/brand/Nihonbuzz-Academy-Dark-LS-Regular.png"
|
||||
alt="Nihonbuzz Academy"
|
||||
className="h-6 w-auto hidden dark:block"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-4 space-y-1">
|
||||
{navItems.map((item, index) => (
|
||||
<Link
|
||||
key={index}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-colors",
|
||||
item.active
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-muted-foreground hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
<item.icon size={18} />
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
<Link href="/" className="lg:hidden">
|
||||
<img src="/brand/logo-symbol.svg" alt="Logo" className="h-8 w-8" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Search Bar (Desktop) */}
|
||||
<div className="hidden md:flex items-center max-w-sm w-full relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground w-4 h-4" />
|
||||
<Input
|
||||
placeholder="Cari materi..."
|
||||
className="pl-10 h-9 rounded-full border-border/50 bg-muted/30 focus:bg-background transition-all w-full text-sm placeholder:text-muted-foreground/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right Profile Menu */}
|
||||
<div className="flex items-center gap-3">
|
||||
<ModeToggle />
|
||||
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="relative h-9 w-9 rounded-full ring-2 ring-transparent hover:ring-primary/20 transition-all p-0 overflow-hidden">
|
||||
<Avatar className="h-9 w-9">
|
||||
<AvatarImage src={user.avatar ?? ''} alt={user.name} />
|
||||
<AvatarFallback className="bg-gradient-to-br from-nihonbuzz-red to-orange-500 text-white font-bold text-xs">
|
||||
{user.name.charAt(0)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56 rounded-xl p-1" align="end" forceMount>
|
||||
<div className="px-2 py-1.5 text-sm font-normal">
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="text-sm font-bold leading-none">{user.name}</p>
|
||||
<p className="text-xs leading-none text-muted-foreground">{user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenuSeparator className="my-1 bg-border/50" />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={route('profile.edit')} className="cursor-pointer rounded-lg font-medium text-foreground focus:text-primary focus:bg-primary/5">
|
||||
<UserIcon className="mr-2 h-4 w-4" />
|
||||
<span>Profile</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={route('logout')} method="post" as="button" className="cursor-pointer w-full rounded-lg font-medium text-destructive focus:text-destructive focus:bg-destructive/10">
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
<span>Keluar</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Page Content */}
|
||||
<main className="flex-1 p-4 sm:p-6 lg:p-8">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper component for Trophy icon which was missing in imports
|
||||
function Trophy({ size = 24, ...props }: { size?: number, [key: string]: any }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
{...props}
|
||||
>
|
||||
<path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6" />
|
||||
<path d="M18 9h1.5a2.5 2.5 0 0 0 0-5H18" />
|
||||
<path d="M4 22h16" />
|
||||
<path d="M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20.24 7 22" />
|
||||
<path d="M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22" />
|
||||
<path d="M18 2H6v7a6 6 0 0 0 12 0V2Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,26 +1,29 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link, Head, usePage } from '@inertiajs/react';
|
||||
import {
|
||||
ChevronLeft,
|
||||
Menu,
|
||||
Search,
|
||||
CheckCircle,
|
||||
Circle,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
import React, { useState } from 'react';
|
||||
import { Link, usePage } from '@inertiajs/react';
|
||||
import {
|
||||
ChevronLeft,
|
||||
Menu,
|
||||
CheckCircle,
|
||||
Circle,
|
||||
PlayCircle,
|
||||
FileText,
|
||||
HelpCircle
|
||||
HelpCircle,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Download,
|
||||
Lock,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
X,
|
||||
LayoutDashboard
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import { ScrollArea } from '@/Components/ui/scroll-area';
|
||||
import { Sheet, SheetContent, SheetTrigger } from '@/Components/ui/sheet';
|
||||
import { Progress } from '@/Components/ui/progress';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/Components/ui/avatar';
|
||||
import { ModeToggle } from '@/Components/ModeToggle';
|
||||
|
||||
// Interfaces for Props
|
||||
interface Lesson {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -48,240 +51,95 @@ interface CourseLayoutProps {
|
||||
course: Course;
|
||||
modules: Module[];
|
||||
currentLesson?: Lesson;
|
||||
nextLesson?: Lesson | null;
|
||||
previousLesson?: Lesson | null;
|
||||
}
|
||||
|
||||
export default function CourseLayout({
|
||||
children,
|
||||
course,
|
||||
modules = [],
|
||||
currentLesson
|
||||
currentLesson,
|
||||
nextLesson,
|
||||
previousLesson
|
||||
}: CourseLayoutProps) {
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
|
||||
const [openModules, setOpenModules] = useState<string[]>([]);
|
||||
const { url } = usePage();
|
||||
|
||||
// Default open all modules or just the active one
|
||||
useEffect(() => {
|
||||
if (modules.length > 0) {
|
||||
// Find module containing current lesson
|
||||
const activeModule = modules.find(m => m.lessons.some(l => l.slug === currentLesson?.slug));
|
||||
if (activeModule) {
|
||||
setOpenModules(prev => [...new Set([...prev, activeModule.id])]);
|
||||
} else {
|
||||
// If no active lesson (e.g. course home), open first module
|
||||
setOpenModules([modules[0].id]);
|
||||
}
|
||||
}
|
||||
}, [modules, currentLesson]);
|
||||
const user = usePage().props.auth.user;
|
||||
const [openModules, setOpenModules] = useState<string[]>(modules.map(m => m.id)); // Default open all
|
||||
|
||||
const toggleModule = (moduleId: string) => {
|
||||
setOpenModules(prev =>
|
||||
prev.includes(moduleId)
|
||||
? prev.filter(id => id !== moduleId)
|
||||
setOpenModules(prev =>
|
||||
prev.includes(moduleId)
|
||||
? prev.filter(id => id !== moduleId)
|
||||
: [...prev, moduleId]
|
||||
);
|
||||
};
|
||||
|
||||
const getLessonIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'video': return <PlayCircle size={14} />;
|
||||
case 'quiz': return <HelpCircle size={14} />;
|
||||
case 'pdf': return <FileText size={14} />;
|
||||
default: return <FileText size={14} />;
|
||||
case 'video': return <PlayCircle size={18} />;
|
||||
case 'quiz': return <HelpCircle size={18} />;
|
||||
case 'pdf': return <FileText size={18} />;
|
||||
default: return <FileText size={18} />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex font-sans text-foreground">
|
||||
{/* Mobile Sheet Sidebar */}
|
||||
<Sheet>
|
||||
<div className="lg:hidden fixed top-0 left-0 right-0 h-14 border-b bg-background/95 backdrop-blur z-40 flex items-center px-4 justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="-ml-2">
|
||||
<Menu size={20} />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<span className="font-semibold text-sm truncate max-w-[200px]">{course.title}</span>
|
||||
</div>
|
||||
<Link href={route('dashboard')}>
|
||||
<Button variant="ghost" size="sm" className="text-xs">Keluar</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<SheetContent side="left" className="p-0 w-[85vw] max-w-[320px]">
|
||||
<SidebarContent
|
||||
course={course}
|
||||
modules={modules}
|
||||
currentLesson={currentLesson}
|
||||
openModules={openModules}
|
||||
toggleModule={toggleModule}
|
||||
getLessonIcon={getLessonIcon}
|
||||
/>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
{/* Desktop Sidebar */}
|
||||
<aside
|
||||
className={cn(
|
||||
"hidden lg:flex flex-col w-80 border-r bg-muted/30 fixed inset-y-0 left-0 z-30 transition-all duration-300",
|
||||
!isSidebarOpen && "-ml-80"
|
||||
)}
|
||||
>
|
||||
<SidebarContent
|
||||
course={course}
|
||||
modules={modules}
|
||||
currentLesson={currentLesson}
|
||||
openModules={openModules}
|
||||
toggleModule={toggleModule}
|
||||
getLessonIcon={getLessonIcon}
|
||||
/>
|
||||
</aside>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<main
|
||||
className={cn(
|
||||
"flex-1 flex flex-col min-h-screen transition-all duration-300 bg-background",
|
||||
isSidebarOpen ? "lg:ml-80" : "lg:ml-0",
|
||||
"pt-14 lg:pt-0" // Mobile padding
|
||||
)}
|
||||
>
|
||||
{/* Desktop Topbar */}
|
||||
<header className="hidden lg:flex h-14 items-center justify-between px-6 border-b bg-background sticky top-0 z-20">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
title={isSidebarOpen ? "Close Sidebar" : "Open Sidebar"}
|
||||
>
|
||||
<Menu size={18} />
|
||||
</Button>
|
||||
<nav className="flex items-center text-sm text-muted-foreground">
|
||||
<Link href={route('dashboard')} className="hover:text-foreground transition-colors">
|
||||
Dashboard
|
||||
</Link>
|
||||
<ChevronRight size={14} className="mx-2" />
|
||||
<Link href={route('courses.index')} className="hover:text-foreground transition-colors">
|
||||
Courses
|
||||
</Link>
|
||||
<ChevronRight size={14} className="mx-2" />
|
||||
<span className="font-medium text-foreground truncate max-w-[300px]">
|
||||
{currentLesson?.title || course.title}
|
||||
</span>
|
||||
</nav>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex flex-col items-end mr-2">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">Progress</span>
|
||||
<span className="text-xs font-bold">{course.progress_percentage}%</span>
|
||||
</div>
|
||||
<Progress value={course.progress_percentage} className="w-32 h-1.5" />
|
||||
</div>
|
||||
<ModeToggle />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Content Slot */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className="max-w-4xl mx-auto p-6 lg:p-10 animate-in fade-in slide-in-from-bottom-4 duration-700">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarContent({ course, modules, currentLesson, openModules, toggleModule, getLessonIcon }: any) {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b bg-background/50 backdrop-blur-sm">
|
||||
<Link
|
||||
href={route('dashboard')}
|
||||
className="flex items-center text-xs font-medium text-muted-foreground hover:text-primary mb-4 transition-colors group"
|
||||
>
|
||||
<ChevronLeft size={14} className="mr-1 group-hover:-translate-x-1 transition-transform" />
|
||||
Kembali ke Dashboard
|
||||
</Link>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="h-10 w-10 rounded-lg bg-gradient-to-br from-nihonbuzz-red to-orange-500 flex items-center justify-center text-white font-bold text-sm shrink-0 shadow-lg shadow-orange-500/20">
|
||||
{course.title.charAt(0)}
|
||||
</div>
|
||||
<h2 className="font-bold leading-tight text-sm line-clamp-2">{course.title}</h2>
|
||||
</div>
|
||||
|
||||
<div className="relative mt-3">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<input
|
||||
className="w-full h-8 pl-8 pr-3 rounded-md border border-input bg-background/50 text-xs focus:outline-none focus:ring-1 focus:ring-primary transition-all placeholder:text-muted-foreground"
|
||||
placeholder="Cari materi..."
|
||||
/>
|
||||
</div>
|
||||
const SidebarContent = () => (
|
||||
<div className="flex flex-col h-full bg-white dark:bg-[#0a0a0b] border-l border-gray-200 dark:border-white/10 transition-colors duration-300">
|
||||
<div className="p-4 border-b border-gray-200 dark:border-white/10 flex items-center justify-between">
|
||||
<h3 className="font-bold text-sm text-gray-900 dark:text-white uppercase tracking-widest">Course Content</h3>
|
||||
<span className="text-xs text-gray-500 dark:text-white/50">{course.progress_percentage}% Complete</span>
|
||||
</div>
|
||||
|
||||
{/* Curriculum List */}
|
||||
<ScrollArea className="flex-1 px-3 py-4">
|
||||
<div className="space-y-4">
|
||||
{modules.map((module: any, index: number) => (
|
||||
<div key={module.id} className="space-y-1">
|
||||
<button
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="flex flex-col">
|
||||
{modules.map((module) => (
|
||||
<div key={module.id} className="flex flex-col">
|
||||
<button
|
||||
onClick={() => toggleModule(module.id)}
|
||||
className="flex items-center w-full text-left gap-2 px-2 py-1.5 text-xs font-bold uppercase tracking-wider text-muted-foreground hover:text-foreground transition-colors group"
|
||||
className="px-4 py-3 bg-gray-50 dark:bg-[#161618]/50 flex items-center justify-between cursor-pointer border-b border-gray-200 dark:border-white/5 hover:bg-gray-100 dark:hover:bg-white/5 transition-colors group"
|
||||
>
|
||||
<ChevronRight
|
||||
size={12}
|
||||
className={cn(
|
||||
"transition-transform bg-muted rounded-sm",
|
||||
openModules.includes(module.id) && "rotate-90"
|
||||
)}
|
||||
<span className="text-xs font-bold text-gray-500 dark:text-white/60 uppercase tracking-tighter group-hover:text-gray-900 dark:group-hover:text-white transition-colors">{module.title}</span>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={cn("text-gray-400 dark:text-white/40 transition-transform", !openModules.includes(module.id) && "-rotate-90")}
|
||||
/>
|
||||
<span className="line-clamp-1 group-hover:underline decoration-border underline-offset-4">{module.title}</span>
|
||||
</button>
|
||||
|
||||
{openModules.includes(module.id) && (
|
||||
<div className="space-y-0.5 ml-1 pl-2 border-l border-border/40">
|
||||
{module.lessons.map((lesson: any) => {
|
||||
<div className="flex flex-col">
|
||||
{module.lessons.map((lesson) => {
|
||||
const isActive = currentLesson?.slug === lesson.slug;
|
||||
return (
|
||||
<Link
|
||||
key={lesson.id}
|
||||
href={route('courses.learn', { course: course.slug, lesson: lesson.slug })}
|
||||
className={cn(
|
||||
"flex items-start gap-2.5 px-3 py-2 rounded-md text-sm transition-all duration-200 group relative",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-muted-foreground hover:bg-muted/50 hover:text-foreground"
|
||||
"group flex items-center gap-3 px-4 py-3 cursor-pointer transition-all border-l-2",
|
||||
isActive
|
||||
? "bg-[#FF4500]/10 border-[#FF4500] relative overflow-hidden"
|
||||
: "hover:bg-gray-100 dark:hover:bg-white/5 border-transparent"
|
||||
)}
|
||||
>
|
||||
<div className="mt-0.5 shrink-0 transition-colors group-hover:text-foreground">
|
||||
{isActive && <div className="absolute inset-0 bg-[#FF4500]/5 blur-xl pointer-events-none" />}
|
||||
|
||||
<div className={cn("text-[20px]", isActive ? "text-[#FF4500]" : "text-gray-300 dark:text-white/40")}>
|
||||
{lesson.is_completed ? (
|
||||
<CheckCircle size={14} className="text-green-500" />
|
||||
<CheckCircle size={18} className="text-green-500" />
|
||||
) : isActive ? (
|
||||
<PlayCircle size={18} />
|
||||
) : (
|
||||
<Circle size={14} className={cn("text-muted-foreground/30", isActive && "text-primary/30")} />
|
||||
<Circle size={18} />
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<span className="line-clamp-2 leading-snug">{lesson.title}</span>
|
||||
<div className="flex items-center gap-2 text-[10px] text-muted-foreground/50 font-medium">
|
||||
<span className="flex items-center gap-1">
|
||||
{getLessonIcon(lesson.type)}
|
||||
<span className="capitalize">{lesson.type}</span>
|
||||
</span>
|
||||
{lesson.duration_seconds > 0 && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>{Math.ceil(lesson.duration_seconds / 60)} min</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0 relative z-10">
|
||||
<p className={cn("text-sm font-semibold truncate", isActive ? "text-gray-900 dark:text-white" : "text-gray-500 dark:text-white/60 group-hover:text-gray-900 dark:group-hover:text-white/80")}>
|
||||
{lesson.title}
|
||||
</p>
|
||||
<p className={cn("text-[10px]", isActive ? "text-[#FF4500]/80" : "text-gray-400 dark:text-white/40")}>
|
||||
{isActive ? "Current Lesson" : `${Math.ceil(lesson.duration_seconds / 60)} min`}
|
||||
</p>
|
||||
</div>
|
||||
{isActive && (
|
||||
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-0.5 h-full bg-primary rounded-r-full" />
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
@@ -291,11 +149,113 @@ function SidebarContent({ course, modules, currentLesson, openModules, toggleMod
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Footer User Info */}
|
||||
<div className="p-4 border-t bg-background/50 text-[10px] text-center text-muted-foreground">
|
||||
Built for <span className="font-bold text-foreground">Future Japan Enthusisasts</span>
|
||||
|
||||
<div className="p-4 bg-gray-50 dark:bg-[#161618] border-t border-gray-200 dark:border-white/10">
|
||||
<Button variant="outline" className="w-full border-gray-200 dark:border-white/10 text-gray-500 dark:text-white/60 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-white/5 text-xs h-9">
|
||||
<Download size={14} className="mr-2" />
|
||||
Lesson Materials (PDF)
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen overflow-hidden bg-white dark:bg-[#0a0a0b] text-gray-900 dark:text-white font-sans selection:bg-[#FF4500]/30 transition-colors duration-300">
|
||||
{/* Top Navigation */}
|
||||
<header className="flex h-16 items-center justify-between border-b border-gray-200 dark:border-white/10 px-6 bg-white dark:bg-[#0a0a0b] z-20 shrink-0">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="size-8 bg-[#FF4500] rounded flex items-center justify-center">
|
||||
<span className="font-bold text-white text-lg">N</span>
|
||||
</div>
|
||||
<h2 className="text-gray-900 dark:text-white text-lg font-extrabold tracking-tight hidden sm:block">
|
||||
Nihonbuzz <span className="font-light text-gray-400 dark:text-white/40">Academy</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div className="h-6 w-px bg-gray-200 dark:bg-white/10 mx-2 hidden sm:block"></div>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500 dark:text-white/40 hidden md:flex">
|
||||
<span className="truncate max-w-[150px]">{course.title}</span>
|
||||
<ChevronRight size={14} />
|
||||
<span className="text-gray-900 dark:text-white font-medium truncate max-w-[200px]">{currentLesson?.title}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href={route('dashboard')}>
|
||||
<Button variant="ghost" size="sm" className="text-gray-500 dark:text-white/60 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-white/5 hidden sm:flex">
|
||||
<LayoutDashboard size={16} className="mr-2" />
|
||||
Dashboard
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<ModeToggle />
|
||||
|
||||
<div className="flex items-center gap-2 p-1 bg-gray-50 dark:bg-[#161618] border border-gray-200 dark:border-white/10 rounded-full">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage src={user.avatar} />
|
||||
<AvatarFallback>{user.name.charAt(0)}</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
|
||||
{/* Mobile Sidebar Trigger */}
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="lg:hidden text-gray-500 dark:text-white/60">
|
||||
<Menu size={20} />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="right" className="p-0 w-80 bg-white dark:bg-[#0a0a0b] border-l border-gray-200 dark:border-white/10">
|
||||
<SidebarContent />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Layout Area */}
|
||||
<div className="flex flex-1 overflow-hidden relative">
|
||||
|
||||
{/* Center Content (Video/Text) */}
|
||||
<main className="flex-1 overflow-y-auto bg-[#f8f6f5] dark:bg-[#0a0a0b] relative scroll-smooth pb-20">
|
||||
{children}
|
||||
</main>
|
||||
|
||||
{/* Right Sidebar (Desktop) */}
|
||||
<aside className="hidden lg:flex w-80 flex-col border-l border-gray-200 dark:border-white/10 bg-white dark:bg-[#0a0a0b]">
|
||||
<SidebarContent />
|
||||
</aside>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Bottom Navigation Bar */}
|
||||
<footer className="fixed bottom-0 left-0 right-0 h-16 bg-white/80 dark:bg-[#0a0a0b]/80 backdrop-blur-md border-t border-gray-200 dark:border-white/10 flex items-center justify-between px-6 z-30">
|
||||
<div className="flex items-center gap-4">
|
||||
{previousLesson && (
|
||||
<Link href={route('courses.learn', { course: course.slug, lesson: previousLesson.slug })}>
|
||||
<Button variant="ghost" className="text-gray-500 dark:text-white/60 hover:text-gray-900 dark:hover:text-white gap-2 pl-0 hover:bg-transparent group">
|
||||
<ArrowLeft size={18} className="group-hover:-translate-x-1 transition-transform" />
|
||||
<span className="hidden sm:inline">Previous Lesson</span>
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{nextLesson && (
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex flex-col items-end hidden md:flex">
|
||||
<span className="text-[10px] text-gray-400 dark:text-white/40 uppercase font-bold tracking-widest">Next Up</span>
|
||||
<span className="text-xs text-gray-900 dark:text-white font-medium truncate max-w-[200px]">{nextLesson.title}</span>
|
||||
</div>
|
||||
<Link href={route('courses.learn', { course: course.slug, lesson: nextLesson.slug })}>
|
||||
<Button className="bg-[#FF4500] hover:bg-[#FF4500]/90 text-white font-bold shadow-[0_0_15px_rgba(255,68,0,0.3)] transition-all active:scale-95 gap-2">
|
||||
Next Lesson
|
||||
<ArrowRight size={16} />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
169
resources/js/Layouts/DashboardLayout.tsx
Normal file
169
resources/js/Layouts/DashboardLayout.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import { Link, usePage } from '@inertiajs/react';
|
||||
import { PropsWithChildren, ReactNode, useState } from 'react';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
BookOpen,
|
||||
Layers,
|
||||
Trophy,
|
||||
User,
|
||||
Settings,
|
||||
LogOut,
|
||||
Menu,
|
||||
Search,
|
||||
Bell,
|
||||
Plus,
|
||||
Play
|
||||
} from 'lucide-react';
|
||||
import { Sheet, SheetContent, SheetTrigger } from '@/Components/ui/sheet';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/Components/ui/avatar';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ScrollArea } from '@/Components/ui/scroll-area';
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/Components/ui/dropdown-menu';
|
||||
import { ModeToggle } from '@/Components/ModeToggle';
|
||||
|
||||
export default function DashboardLayout({
|
||||
header,
|
||||
children,
|
||||
}: PropsWithChildren<{ header?: ReactNode }>) {
|
||||
const user = usePage().props.auth.user;
|
||||
const { url } = usePage();
|
||||
|
||||
const navItems = [
|
||||
{ label: 'Dashboard', icon: LayoutDashboard, href: route('dashboard'), active: route().current('dashboard') },
|
||||
{ label: 'Study (SRS)', icon: BookOpen, href: route('srs.index'), active: route().current('srs.*') },
|
||||
{ label: 'Decks', icon: Layers, href: route('courses.index'), active: route().current('courses.index') },
|
||||
{ label: 'Leaderboard', icon: Trophy, href: '#', active: false },
|
||||
{ label: 'Profile', icon: User, href: route('profile.edit'), active: route().current('profile.edit') },
|
||||
];
|
||||
|
||||
const SidebarContent = () => (
|
||||
<div className="flex flex-col h-full bg-white dark:bg-[#0a0a0b] border-r border-gray-200 dark:border-white/5 p-6 transition-colors duration-300">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center gap-3 mb-8">
|
||||
<div className="size-10 bg-[#FF4500] rounded flex items-center justify-center text-white font-bold text-xl shadow-[0_0_15px_rgba(255,68,0,0.3)]">
|
||||
N
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<h1 className="text-gray-900 dark:text-white text-base font-bold leading-none tracking-tight">NIHONBUZZ</h1>
|
||||
<p className="text-[#FF4500] text-[10px] font-bold tracking-[0.2em] uppercase mt-0.5">Academy</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Nav Links */}
|
||||
<nav className="space-y-2 flex-1">
|
||||
{navItems.map((item, index) => (
|
||||
<Link
|
||||
key={index}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200",
|
||||
item.active
|
||||
? "bg-[#FF4500]/10 text-[#FF4500] border border-[#FF4500]/20"
|
||||
: "text-gray-500 dark:text-white/60 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-white/5"
|
||||
)}
|
||||
>
|
||||
<item.icon size={20} className={cn(item.active ? "text-[#FF4500]" : "text-gray-400 dark:text-white/60")} />
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Profile Section (Bottom) */}
|
||||
<div className="space-y-4 pt-6 border-t border-gray-200 dark:border-white/5">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-10 w-10 border border-gray-200 dark:border-white/10 rounded">
|
||||
<AvatarImage src={user.avatar} />
|
||||
<AvatarFallback className="bg-[#FF4500] text-white">
|
||||
{user.name.charAt(0)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-bold text-gray-900 dark:text-white truncate">{user.name}</p>
|
||||
<p className="text-xs text-gray-500 dark:text-white/40 truncate">Level 12 Scholar</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mini Progress Bar */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex justify-between text-[10px] font-bold uppercase tracking-wider text-gray-500 dark:text-white/40">
|
||||
<span>XP Progress</span>
|
||||
<span>85%</span>
|
||||
</div>
|
||||
<div className="h-1 w-full bg-gray-100 dark:bg-white/5 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-[#FF4500]" style={{ width: '85%' }}></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href={route('profile.edit')}
|
||||
className="flex items-center gap-2 text-gray-500 dark:text-white/40 hover:text-gray-900 dark:hover:text-white text-xs font-medium transition-colors cursor-pointer w-full"
|
||||
>
|
||||
<Settings size={14} />
|
||||
Settings
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#f8f6f5] dark:bg-[#0a0a0b] font-sans selection:bg-[#FF4500]/30 flex transition-colors duration-300">
|
||||
|
||||
{/* Desktop Sidebar */}
|
||||
<aside className="hidden lg:block w-64 flex-shrink-0 fixed inset-y-0 left-0 z-50">
|
||||
<SidebarContent />
|
||||
</aside>
|
||||
|
||||
{/* Mobile Header / Sheet */}
|
||||
<header className="lg:hidden fixed top-0 left-0 right-0 h-16 bg-white/80 dark:bg-[#0a0a0b]/80 backdrop-blur-md border-b border-gray-200 dark:border-white/5 flex items-center justify-between px-4 z-50">
|
||||
<div className="flex items-center gap-3">
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="text-gray-900 dark:text-white">
|
||||
<Menu size={20} />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left" className="p-0 border-r-0 bg-white dark:bg-[#0a0a0b] w-64">
|
||||
<SidebarContent />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
<div className="font-bold text-gray-900 dark:text-white tracking-tight">NIHONBUZZ</div>
|
||||
</div>
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage src={user.avatar} />
|
||||
<AvatarFallback>{user.name.charAt(0)}</AvatarFallback>
|
||||
</Avatar>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 lg:pl-64 flex flex-col min-h-screen pt-16 lg:pt-0 transition-all">
|
||||
|
||||
{/* Desktop Topbar */}
|
||||
<header className="hidden lg:flex h-16 border-b border-gray-200 dark:border-white/5 items-center justify-between px-8 bg-white/50 dark:bg-[#0a0a0b]/50 backdrop-blur-md sticky top-0 z-40">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500 dark:text-white/40">
|
||||
<span>Dashboard</span>
|
||||
<span className="text-gray-300 dark:text-white/20">/</span>
|
||||
<span className="text-gray-900 dark:text-white font-medium">Home</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<Button className="bg-[#FF4500] hover:bg-[#FF4500]/90 text-white font-bold h-9 rounded shadow-[0_0_15px_rgba(255,68,0,0.3)] transition-all">
|
||||
<Plus size={16} className="mr-2" />
|
||||
Quick Add
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" size="icon" className="h-9 w-9 border-gray-200 dark:border-white/10 bg-transparent text-gray-500 dark:text-white/60 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-white/5">
|
||||
<Bell size={20} />
|
||||
</Button>
|
||||
|
||||
<ModeToggle />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="p-4 sm:p-6 lg:p-8 max-w-7xl mx-auto w-full">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,16 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Head, Link } from '@inertiajs/react';
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
CheckCircle,
|
||||
FileText,
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
CheckCircle,
|
||||
FileText,
|
||||
Download,
|
||||
Maximize2
|
||||
} from 'lucide-react';
|
||||
import { Plyr } from 'plyr-react';
|
||||
import 'plyr-react/plyr.css';
|
||||
import CourseLayout from '@/Layouts/CourseLayout';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import { Separator } from '@/Components/ui/separator';
|
||||
@@ -36,6 +38,23 @@ interface PageProps {
|
||||
lesson: Lesson;
|
||||
}
|
||||
|
||||
// Helper to get YouTube video ID (Robust)
|
||||
function getYouTubeId(url: string | null): string | null {
|
||||
if (!url) return null;
|
||||
try {
|
||||
const urlObj = new URL(url.trim());
|
||||
let id = null;
|
||||
if (urlObj.hostname.includes('youtube.com')) {
|
||||
id = urlObj.searchParams.get('v');
|
||||
} else if (urlObj.hostname.includes('youtu.be')) {
|
||||
id = urlObj.pathname.slice(1);
|
||||
}
|
||||
return id;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default function Learn({ course, modules, lesson }: PageProps) {
|
||||
const [isCompleted, setIsCompleted] = useState(lesson.is_completed);
|
||||
|
||||
@@ -48,7 +67,7 @@ export default function Learn({ course, modules, lesson }: PageProps) {
|
||||
return (
|
||||
<CourseLayout course={course} modules={modules} currentLesson={lesson}>
|
||||
<Head title={`${lesson.title} - ${course.title}`} />
|
||||
|
||||
|
||||
{/* Header Content */}
|
||||
<div className="mb-8 space-y-4">
|
||||
<div className="flex items-center gap-2 text-muted-foreground text-sm uppercase tracking-wider font-bold">
|
||||
@@ -63,21 +82,32 @@ export default function Learn({ course, modules, lesson }: PageProps) {
|
||||
|
||||
{/* Main Content Render */}
|
||||
<div className="space-y-8">
|
||||
|
||||
|
||||
{lesson.type === 'video' && lesson.video_url && (
|
||||
<div className="relative aspect-video rounded-2xl overflow-hidden shadow-2xl bg-black border border-border/50 group">
|
||||
<iframe
|
||||
src={lesson.video_url}
|
||||
className="w-full h-full"
|
||||
allowFullScreen
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
title={lesson.title}
|
||||
<Plyr
|
||||
source={{
|
||||
type: 'video',
|
||||
sources: [{
|
||||
src: getYouTubeId(lesson.video_url) || lesson.video_url || '',
|
||||
provider: getYouTubeId(lesson.video_url) ? 'youtube' : 'html5',
|
||||
}]
|
||||
}}
|
||||
options={{
|
||||
youtube: {
|
||||
noCookie: true,
|
||||
rel: 0,
|
||||
showinfo: 0,
|
||||
iv_load_policy: 3,
|
||||
modestbranding: 1
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lesson.type === 'pdf' && lesson.content_pdf && (
|
||||
<div className="rounded-2xl border border-border/50 overflow-hidden bg-card h-[80vh] flex flex-col shadow-sm">
|
||||
<div className="rounded-2xl border border-border/50 overflow-hidden bg-card h-[80vh] flex flex-col shadow-sm">
|
||||
<div className="bg-muted/30 border-b p-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<FileText size={16} />
|
||||
@@ -90,8 +120,8 @@ export default function Learn({ course, modules, lesson }: PageProps) {
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
<iframe
|
||||
src={`${lesson.content_pdf}#toolbar=0`}
|
||||
<iframe
|
||||
src={`${lesson.content_pdf}#toolbar=0`}
|
||||
className="w-full h-full bg-white"
|
||||
title="PDF Viewer"
|
||||
/>
|
||||
@@ -114,10 +144,10 @@ export default function Learn({ course, modules, lesson }: PageProps) {
|
||||
</h3>
|
||||
<div className="grid sm:grid-cols-2 gap-4">
|
||||
{lesson.attachments.map((file) => (
|
||||
<a
|
||||
key={file.id}
|
||||
href={file.url}
|
||||
target="_blank"
|
||||
<a
|
||||
key={file.id}
|
||||
href={file.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex items-center gap-4 p-4 rounded-xl border bg-card hover:bg-muted/50 transition-all group"
|
||||
>
|
||||
@@ -154,8 +184,8 @@ export default function Learn({ course, modules, lesson }: PageProps) {
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-center gap-4 w-full sm:w-auto">
|
||||
{!isCompleted ? (
|
||||
<Button
|
||||
size="lg"
|
||||
<Button
|
||||
size="lg"
|
||||
className="w-full sm:w-auto rounded-full font-bold shadow-lg shadow-primary/20 hover:shadow-primary/40 transition-all"
|
||||
onClick={handleComplete}
|
||||
>
|
||||
@@ -163,9 +193,9 @@ export default function Learn({ course, modules, lesson }: PageProps) {
|
||||
Tandai Selesai
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
size="lg"
|
||||
className="w-full sm:w-auto rounded-full font-bold bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400 hover:bg-green-200 dark:hover:bg-green-900/50 cursor-default"
|
||||
>
|
||||
<CheckCircle className="mr-2 h-5 w-5" />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
|
||||
import DashboardLayout from '@/Layouts/DashboardLayout';
|
||||
import { Head, Link, useForm } from '@inertiajs/react';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from "@/Components/ui/card";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
@@ -28,7 +28,7 @@ interface Props {
|
||||
|
||||
export default function Library({ levels }: Props) {
|
||||
return (
|
||||
<AuthenticatedLayout
|
||||
<DashboardLayout
|
||||
header={
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 className="text-2xl font-bold tracking-tight">Galeri Kursus</h2>
|
||||
@@ -58,7 +58,7 @@ export default function Library({ levels }: Props) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -72,13 +72,13 @@ function CourseCard({ course }: { course: Course }) {
|
||||
return (
|
||||
<Card className="group border-border/40 bg-card/40 backdrop-blur-xl overflow-hidden hover:shadow-2xl hover:shadow-primary/5 transition-all duration-500 hover:-translate-y-1 flex flex-col h-full">
|
||||
<div className="relative aspect-video overflow-hidden">
|
||||
<img
|
||||
src={course.thumbnail}
|
||||
<img
|
||||
src={course.thumbnail}
|
||||
alt={course.title}
|
||||
className="object-cover w-full h-full transition-transform duration-700 group-hover:scale-110"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
|
||||
|
||||
{course.isEnrolled && (
|
||||
<Badge className="absolute top-3 right-3 bg-green-500 hover:bg-green-600 text-white border-none gap-1">
|
||||
<CheckCircle2 size={12} />
|
||||
@@ -107,8 +107,8 @@ function CourseCard({ course }: { course: Course }) {
|
||||
</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleEnroll}
|
||||
<Button
|
||||
onClick={handleEnroll}
|
||||
disabled={processing}
|
||||
className="w-full rounded-xl font-bold bg-primary hover:bg-primary/90 shadow-lg shadow-primary/20"
|
||||
>
|
||||
|
||||
@@ -1,31 +1,17 @@
|
||||
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
|
||||
import { Head, Link, router } from '@inertiajs/react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import CourseLayout from '@/Layouts/CourseLayout';
|
||||
import { Head, router } from '@inertiajs/react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Plyr } from 'plyr-react';
|
||||
import 'plyr-react/plyr.css';
|
||||
import {
|
||||
CheckCircle2,
|
||||
ChevronLeft,
|
||||
Circle,
|
||||
FileText,
|
||||
Play,
|
||||
Video,
|
||||
Menu,
|
||||
Volume2,
|
||||
import {
|
||||
BookOpen,
|
||||
ArrowRight,
|
||||
Trophy
|
||||
Volume2,
|
||||
Play
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import { Progress } from '@/Components/ui/progress';
|
||||
import { FuriganaText } from '@/lib/furigana';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger
|
||||
} from "@/Components/ui/sheet";
|
||||
|
||||
interface VocabularyData {
|
||||
id: string;
|
||||
@@ -56,6 +42,7 @@ interface CourseData {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
progress_percentage: number;
|
||||
modules: ModuleData[];
|
||||
}
|
||||
|
||||
@@ -69,85 +56,54 @@ interface CurrentLessonData {
|
||||
content_pdf: string | null;
|
||||
vocabularies: VocabularyData[];
|
||||
is_completed?: boolean;
|
||||
}
|
||||
|
||||
interface ProgressData {
|
||||
completed_count: number;
|
||||
total_count: number;
|
||||
percentage: number;
|
||||
duration_seconds?: number;
|
||||
}
|
||||
|
||||
interface PlayerProps {
|
||||
course: CourseData;
|
||||
currentLesson: CurrentLessonData;
|
||||
progress: ProgressData;
|
||||
nextLesson?: LessonData | null;
|
||||
previousLesson?: LessonData | null;
|
||||
auth: {
|
||||
user: any;
|
||||
};
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
// Helper to get YouTube video ID
|
||||
// Helper to get YouTube video ID (Original Regex Logic)
|
||||
// Helper to get YouTube video ID (Robust)
|
||||
function getYouTubeId(url: string | null): string | null {
|
||||
if (!url) return null;
|
||||
const regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*/;
|
||||
const match = url.match(regExp);
|
||||
return (match && match[2].length === 11) ? match[2] : null;
|
||||
try {
|
||||
const urlObj = new URL(url.trim());
|
||||
let id = null;
|
||||
if (urlObj.hostname.includes('youtube.com')) {
|
||||
id = urlObj.searchParams.get('v');
|
||||
} else if (urlObj.hostname.includes('youtu.be')) {
|
||||
id = urlObj.pathname.slice(1);
|
||||
}
|
||||
return id;
|
||||
} catch (e) {
|
||||
const regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*/;
|
||||
const match = url.match(regExp);
|
||||
return (match && match[2]) ? match[2] : null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sub-component for Navigation Content
|
||||
*/
|
||||
function NavigationContent({ course, currentLesson }: { course: CourseData, currentLesson: CurrentLessonData }) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-xs font-black text-gray-400 uppercase tracking-widest pl-1">Daftar Materi</h3>
|
||||
<div className="space-y-4">
|
||||
{course.modules.map((module) => (
|
||||
<div key={module.id} className="space-y-2">
|
||||
<div className="flex items-center gap-2 group cursor-default">
|
||||
<div className="w-1 h-4 bg-nihonbuzz-red/30 rounded-full group-hover:bg-nihonbuzz-red transition-colors" />
|
||||
<h4 className="font-black text-gray-800 text-xs uppercase tracking-tight">{module.title}</h4>
|
||||
</div>
|
||||
<ul className="space-y-1 ml-1">
|
||||
{module.lessons.map((lesson) => (
|
||||
<li key={lesson.id}>
|
||||
<Link
|
||||
// @ts-ignore
|
||||
href={route('courses.learn', { course: course.slug, lesson: lesson.slug })}
|
||||
className={`flex items-center gap-3 p-3 rounded-xl transition-all text-sm group ${
|
||||
lesson.id === currentLesson.id
|
||||
? 'bg-nihonbuzz-red text-white shadow-lg shadow-nihonbuzz-red/20 font-bold'
|
||||
: 'hover:bg-gray-100 text-gray-600'
|
||||
}`}
|
||||
>
|
||||
<div className="relative flex items-center justify-center shrink-0">
|
||||
{lesson.is_completed ? (
|
||||
<div className="bg-green-100 p-0.5 rounded-full">
|
||||
<CheckCircle2 className="w-4 h-4 text-green-600" />
|
||||
</div>
|
||||
) : lesson.id === currentLesson.id ? (
|
||||
<Play className="w-4 h-4 text-white fill-current" />
|
||||
) : (
|
||||
<Circle className="w-4 h-4 text-gray-300 group-hover:text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
<span className="line-clamp-1">{lesson.title}</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Player({ course, currentLesson, progress, auth }: PlayerProps) {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
export default function Player({ course, currentLesson, nextLesson, previousLesson, auth }: PlayerProps) {
|
||||
const [isPlaying, setIsPlaying] = useState<string | null>(null);
|
||||
|
||||
// ROBUST LOGIC:
|
||||
// Extract ID first. If valid ID, force provider 'youtube'.
|
||||
const youtubeId = getYouTubeId(currentLesson.video_url);
|
||||
const videoSource = currentLesson.type === 'video' && currentLesson.video_url ? {
|
||||
type: 'video' as const,
|
||||
sources: [{
|
||||
src: youtubeId || currentLesson.video_url,
|
||||
provider: youtubeId ? 'youtube' as const : 'html5' as const,
|
||||
}],
|
||||
} : null;
|
||||
|
||||
// Learning Tracer - Heartbeat every 60 seconds
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
@@ -155,9 +111,9 @@ export default function Player({ course, currentLesson, progress, auth }: Player
|
||||
router.post(route('lessons.heartbeat', { lesson: currentLesson.id }), {}, {
|
||||
preserveScroll: true,
|
||||
preserveState: true,
|
||||
only: [], // Prevent any data reload to keep it "silent"
|
||||
only: [],
|
||||
// @ts-ignore
|
||||
onFinish: () => {}
|
||||
onFinish: () => { }
|
||||
});
|
||||
}, 60000);
|
||||
|
||||
@@ -176,204 +132,129 @@ export default function Player({ course, currentLesson, progress, auth }: Player
|
||||
audio.onended = () => setIsPlaying(null);
|
||||
};
|
||||
|
||||
const videoSource = currentLesson.video_url ? {
|
||||
type: 'video' as const,
|
||||
sources: [{
|
||||
src: getYouTubeId(currentLesson.video_url) || currentLesson.video_url,
|
||||
provider: currentLesson.video_url.includes('youtube') ? 'youtube' as const : 'html5' as const,
|
||||
}],
|
||||
} : null;
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout
|
||||
header={
|
||||
<div className="flex items-center justify-between gap-4 px-2 lg:px-0">
|
||||
<div className="flex items-center gap-2 lg:gap-4 overflow-hidden">
|
||||
<Link
|
||||
// @ts-ignore
|
||||
href={route('courses.index')}
|
||||
className="p-2.5 rounded-xl bg-gray-50 hover:bg-white hover:shadow-md border border-gray-100 transition-all text-gray-500 hover:text-nihonbuzz-red shrink-0"
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
</Link>
|
||||
<div className="overflow-hidden">
|
||||
<h2 className="text-sm lg:text-base font-black text-gray-900 line-clamp-1 flex items-center gap-2">
|
||||
<span className="text-nihonbuzz-red truncate">{course.title}</span>
|
||||
</h2>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<Progress value={progress.percentage} className="w-20 lg:w-32 h-1.5" />
|
||||
<span className="text-[10px] font-bold text-gray-400 whitespace-nowrap">{progress.percentage}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
{/* Mobile Navigation Trigger */}
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="outline" size="icon" className="lg:hidden rounded-xl border-gray-200">
|
||||
<Menu className="w-5 h-5 text-gray-600" />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="right" className="p-0 border-l border-gray-100">
|
||||
<SheetHeader className="p-6 border-b border-gray-50 text-left">
|
||||
<SheetTitle className="text-xl font-black">{course.title}</SheetTitle>
|
||||
<p className="text-xs text-nihonbuzz-red font-bold uppercase tracking-widest">Kurikulum Kursus</p>
|
||||
</SheetHeader>
|
||||
<div className="p-6 overflow-y-auto h-[calc(100vh-120px)]">
|
||||
<NavigationContent course={course} currentLesson={currentLesson} />
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<div className="hidden lg:flex items-center gap-2 px-4 py-2 bg-yellow-50 rounded-full border border-yellow-100">
|
||||
<Trophy className="w-4 h-4 text-yellow-600" />
|
||||
<span className="text-xs font-black text-yellow-700">{auth.user.xp_points} XP</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<CourseLayout
|
||||
course={course}
|
||||
modules={course.modules as any}
|
||||
currentLesson={currentLesson as any}
|
||||
nextLesson={nextLesson as any}
|
||||
previousLesson={previousLesson as any}
|
||||
>
|
||||
<Head title={`${currentLesson.title} - ${course.title}`} />
|
||||
|
||||
<div className="flex min-h-[calc(100vh-80px)] bg-gray-50/30">
|
||||
{/* Main Content Area */}
|
||||
<div className={`flex-1 overflow-y-auto p-4 lg:p-10 transition-all ${sidebarOpen ? 'lg:mr-80' : ''}`}>
|
||||
<div className="max-w-4xl mx-auto space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||
|
||||
{/* Video Player */}
|
||||
{currentLesson.type === 'video' && videoSource && (
|
||||
<div className="relative group shadow-2xl rounded-3xl overflow-hidden ring-1 ring-black/5 bg-black aspect-video transition-transform hover:scale-[1.01] duration-500">
|
||||
<Plyr source={videoSource} />
|
||||
</div>
|
||||
)}
|
||||
<div className="max-w-4xl mx-auto space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500 py-6 px-4 lg:py-10">
|
||||
|
||||
{/* Title & Stats */}
|
||||
<div className="bg-white p-6 lg:p-8 rounded-[32px] shadow-sm border border-gray-100 relative overflow-hidden group">
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-gray-50 -mr-16 -mt-16 rounded-full opacity-50 group-hover:scale-110 transition-transform duration-700" />
|
||||
<div className="relative space-y-3">
|
||||
<div className="flex items-center gap-2 text-[10px] font-black tracking-widest text-nihonbuzz-red uppercase">
|
||||
<span className="px-2 py-0.5 bg-nihonbuzz-red/10 rounded-full">{currentLesson.type}</span>
|
||||
{currentLesson.is_completed && <span className="px-2 py-0.5 bg-green-100 text-green-600 rounded-full">Completed</span>}
|
||||
</div>
|
||||
<h1 className="text-2xl lg:text-3xl font-black text-gray-900 leading-tight">
|
||||
<FuriganaText text={currentLesson.title || ''} />
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Text Content */}
|
||||
{currentLesson.type === 'text' && currentLesson.content && (
|
||||
<div className="bg-white p-8 lg:p-12 rounded-[40px] shadow-sm border border-gray-100">
|
||||
<article className="prose prose-nihonbuzz prose-lg lg:prose-xl max-w-none font-medium text-gray-700 leading-relaxed">
|
||||
<FuriganaText text={currentLesson.content || ''} />
|
||||
</article>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* PDF Content */}
|
||||
{currentLesson.type === 'pdf' && currentLesson.content_pdf && (
|
||||
<div className="aspect-[3/4] w-full rounded-[40px] overflow-hidden shadow-2xl ring-1 ring-black/5">
|
||||
<iframe
|
||||
src={currentLesson.content_pdf}
|
||||
className="w-full h-full border-none"
|
||||
title={currentLesson.title}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Vocabulary Section */}
|
||||
{currentLesson.vocabularies && currentLesson.vocabularies.length > 0 && (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-nihonbuzz-red/10 p-2 rounded-2xl">
|
||||
<BookOpen className="w-5 h-5 text-nihonbuzz-red" />
|
||||
</div>
|
||||
<h2 className="text-xl font-black text-gray-900 tracking-tight">Kosa Kata Fokus</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{currentLesson.vocabularies.map((vocab) => (
|
||||
<div key={vocab.id} className="bg-white p-5 rounded-3xl border border-gray-100 shadow-sm flex items-center justify-between group hover:border-nihonbuzz-red/30 transition-all hover:shadow-md">
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
onClick={() => vocab.audio_url && playAudio(vocab.audio_url, vocab.id)}
|
||||
className={`w-12 h-12 rounded-2xl flex items-center justify-center transition-all cursor-pointer ${
|
||||
isPlaying === vocab.id ? 'bg-nihonbuzz-red text-white scale-95 shadow-inner' : 'bg-gray-50 text-gray-400 group-hover:bg-nihonbuzz-red/5 group-hover:text-nihonbuzz-red'
|
||||
}`}
|
||||
>
|
||||
{isPlaying === vocab.id ? <Volume2 className="w-5 h-5 animate-pulse" /> : <Volume2 className="w-5 h-5" />}
|
||||
</div>
|
||||
<div>
|
||||
<FuriganaText text={vocab.word || ''} className="text-lg font-black text-gray-900" />
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="text-xs font-bold text-nihonbuzz-red">{vocab.reading || ''}</span>
|
||||
<span className="text-[10px] text-gray-400 uppercase font-black">{vocab.type || ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right pr-2">
|
||||
<p className="font-bold text-gray-700">{vocab.meaning_id || ''}</p>
|
||||
<p className="text-[11px] text-gray-400">{vocab.romaji || ''}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Navigation Footer */}
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-6 pt-10 border-t border-gray-100">
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex -space-x-3 overflow-hidden">
|
||||
<div className="w-10 h-10 rounded-full border-4 border-white bg-gray-100 flex items-center justify-center font-bold text-nihonbuzz-red text-xs">NB</div>
|
||||
<div className="w-10 h-10 rounded-full border-4 border-white bg-red-50 flex items-center justify-center font-bold text-nihonbuzz-red text-xs">🇯🇵</div>
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="text-sm font-black text-gray-900 tracking-tight">NihonBuzz Academy</p>
|
||||
<p className="text-xs text-nihonbuzz-red font-bold uppercase tracking-widest">Master Japanese Effortlessly</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleComplete}
|
||||
size="lg"
|
||||
className={`rounded-full px-12 py-7 text-base font-black shadow-2xl transition-all hover:scale-105 active:scale-95 group ${
|
||||
currentLesson.is_completed
|
||||
? 'bg-green-500 hover:bg-green-600 shadow-green-200 text-white'
|
||||
: 'bg-nihonbuzz-red hover:bg-nihonbuzz-red/90 shadow-nihonbuzz-red/20 text-white'
|
||||
}`}
|
||||
>
|
||||
{currentLesson.is_completed ? (
|
||||
<>Selesai! Tonton Lagi</>
|
||||
) : (
|
||||
<>Tandai Selesai <ArrowRight className="w-5 h-5 ml-2 group-hover:translate-x-1 transition-transform" /></>
|
||||
)}
|
||||
</Button>
|
||||
{/* Video Player */}
|
||||
{currentLesson.type === 'video' && videoSource && (
|
||||
<div className="relative group shadow-2xl rounded-3xl overflow-hidden ring-1 ring-black/5 bg-black aspect-video transition-transform hover:scale-[1.01] duration-500">
|
||||
<Plyr source={videoSource} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title & Stats */}
|
||||
<div className="bg-white dark:bg-[#161618] p-6 lg:p-8 rounded-[32px] shadow-sm border border-black/5 dark:border-white/5 relative overflow-hidden group">
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-[#FF4500]/5 -mr-16 -mt-16 rounded-full opacity-50 group-hover:scale-110 transition-transform duration-700" />
|
||||
<div className="relative space-y-3">
|
||||
<div className="flex items-center gap-2 text-[10px] font-black tracking-widest text-[#FF4500] uppercase">
|
||||
<span className="px-2 py-0.5 bg-[#FF4500]/10 rounded-full">{currentLesson.type}</span>
|
||||
{currentLesson.is_completed && <span className="px-2 py-0.5 bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400 rounded-full">Completed</span>}
|
||||
</div>
|
||||
<h1 className="text-2xl lg:text-3xl font-black text-gray-900 dark:text-white leading-tight">
|
||||
<FuriganaText text={currentLesson.title || ''} />
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar - Desktop Navigation */}
|
||||
<aside className={`fixed right-0 top-16 bottom-0 w-80 bg-white border-l border-gray-50 overflow-y-auto transition-all shadow-[-10px_0_30px_rgba(0,0,0,0.02)] ${sidebarOpen ? 'translate-x-0' : 'translate-x-full'} hidden lg:block z-10`}>
|
||||
<div className="p-8 h-full flex flex-col">
|
||||
<NavigationContent course={course} currentLesson={currentLesson} />
|
||||
|
||||
<div className="mt-auto pt-10">
|
||||
<div className="bg-nihonbuzz-red/5 p-6 rounded-[32px] border border-nihonbuzz-red/10 text-center space-y-3 relative overflow-hidden group">
|
||||
<div className="absolute inset-0 bg-nihonbuzz-red/5 translate-y-full group-hover:translate-y-0 transition-transform duration-500" />
|
||||
<div className="relative">
|
||||
<div className="inline-flex items-center justify-center w-12 h-12 bg-nihonbuzz-red text-white rounded-2xl shadow-lg shadow-nihonbuzz-red/20 mb-2">
|
||||
<Trophy className="w-6 h-6" />
|
||||
{/* Text Content */}
|
||||
{currentLesson.type === 'text' && currentLesson.content && (
|
||||
<div className="bg-white dark:bg-[#161618] p-8 lg:p-12 rounded-[40px] shadow-sm border border-black/5 dark:border-white/5">
|
||||
<article className="prose prose-nihonbuzz prose-lg lg:prose-xl max-w-none font-medium text-gray-700 dark:text-gray-300 leading-relaxed">
|
||||
<FuriganaText text={currentLesson.content || ''} />
|
||||
</article>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* PDF Content */}
|
||||
{currentLesson.type === 'pdf' && currentLesson.content_pdf && (
|
||||
<div className="aspect-[3/4] w-full rounded-[40px] overflow-hidden shadow-2xl ring-1 ring-black/5">
|
||||
<iframe
|
||||
src={currentLesson.content_pdf}
|
||||
className="w-full h-full border-none"
|
||||
title={currentLesson.title}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Vocabulary Section */}
|
||||
{currentLesson.vocabularies && currentLesson.vocabularies.length > 0 && (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-[#FF4500]/10 p-2 rounded-2xl">
|
||||
<BookOpen className="w-5 h-5 text-[#FF4500]" />
|
||||
</div>
|
||||
<h2 className="text-xl font-black text-gray-900 dark:text-white tracking-tight">Kosa Kata Fokus</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{currentLesson.vocabularies.map((vocab) => (
|
||||
<div key={vocab.id} className="bg-white dark:bg-[#161618] p-5 rounded-3xl border border-black/5 dark:border-white/5 shadow-sm flex items-center justify-between group hover:border-[#FF4500]/30 transition-all hover:shadow-md">
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
onClick={() => vocab.audio_url && playAudio(vocab.audio_url, vocab.id)}
|
||||
className={`w-12 h-12 rounded-2xl flex items-center justify-center transition-all cursor-pointer ${isPlaying === vocab.id ? 'bg-[#FF4500] text-white scale-95 shadow-inner' : 'bg-gray-50 dark:bg-white/5 text-gray-400 dark:text-white/40 group-hover:bg-[#FF4500]/5 group-hover:text-[#FF4500]'
|
||||
}`}
|
||||
>
|
||||
{isPlaying === vocab.id ? <Volume2 className="w-5 h-5 animate-pulse" /> : <Volume2 className="w-5 h-5" />}
|
||||
</div>
|
||||
<div>
|
||||
<FuriganaText text={vocab.word || ''} className="text-lg font-black text-gray-900 dark:text-white" />
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="text-xs font-bold text-[#FF4500]">{vocab.reading || ''}</span>
|
||||
<span className="text-[10px] text-gray-400 dark:text-gray-500 uppercase font-black">{vocab.type || ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right pr-2">
|
||||
<p className="font-bold text-gray-700 dark:text-gray-300">{vocab.meaning_id || ''}</p>
|
||||
<p className="text-[11px] text-gray-400 dark:text-gray-600">{vocab.romaji || ''}</p>
|
||||
</div>
|
||||
<p className="text-sm font-black text-gray-900">Selesaikan untuk XP!</p>
|
||||
<p className="text-[10px] font-bold text-nihonbuzz-red uppercase tracking-widest">+50 XP Per Materi</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
|
||||
{/* Navigation Footer */}
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-6 pt-10 border-t border-black/5 dark:border-white/5">
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex -space-x-3 overflow-hidden">
|
||||
<div className="w-10 h-10 rounded-full border-4 border-white dark:border-[#161618] bg-gray-100 flex items-center justify-center font-bold text-[#FF4500] text-xs">NB</div>
|
||||
<div className="w-10 h-10 rounded-full border-4 border-white dark:border-[#161618] bg-[#FF4500]/10 flex items-center justify-center font-bold text-[#FF4500] text-xs">🇯🇵</div>
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="text-sm font-black text-gray-900 dark:text-white tracking-tight">NihonBuzz Academy</p>
|
||||
<p className="text-xs text-[#FF4500] font-bold uppercase tracking-widest">Master Japanese Effortlessly</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleComplete}
|
||||
size="lg"
|
||||
className={cn(
|
||||
"rounded-full px-12 py-7 text-base font-black shadow-2xl transition-all hover:scale-105 active:scale-95 group",
|
||||
currentLesson.is_completed
|
||||
? "bg-green-500 hover:bg-green-600 shadow-green-500/20 text-white"
|
||||
: "bg-[#FF4500] hover:bg-[#FF4500]/90 shadow-[#FF4500]/20 text-white"
|
||||
)}
|
||||
>
|
||||
{currentLesson.is_completed ? (
|
||||
<>Selesai! Tonton Lagi</>
|
||||
) : (
|
||||
<>Tandai Selesai <ArrowRight className="w-5 h-5 ml-2 group-hover:translate-x-1 transition-transform" /></>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
</CourseLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
|
||||
import DashboardLayout from '@/Layouts/DashboardLayout';
|
||||
import { Head, Link } from '@inertiajs/react';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/Components/ui/card";
|
||||
import { Card } from "@/Components/ui/card";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/Components/ui/avatar";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { BookOpen, Flame, GraduationCap, Trophy, Play } from "lucide-react";
|
||||
import CourseCard from '@/Components/CourseCard';
|
||||
import { BookOpen, Flame, Trophy, Play, Plus, Brain } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface DashboardProps {
|
||||
stats: {
|
||||
@@ -15,172 +15,156 @@ interface DashboardProps {
|
||||
srs_due: number;
|
||||
srs_new: number;
|
||||
};
|
||||
activeCourses: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
thumbnail: string;
|
||||
level: string;
|
||||
progress: number;
|
||||
lessonsCount: number;
|
||||
completedLessons: number;
|
||||
slug: string;
|
||||
}>;
|
||||
user: {
|
||||
name: string;
|
||||
avatar: string;
|
||||
rank: string;
|
||||
xp_points?: number;
|
||||
};
|
||||
activeCourses: Array<any>;
|
||||
user: any;
|
||||
}
|
||||
|
||||
export default function Dashboard({
|
||||
stats: propStats = { xp_points: 0, current_streak: 0, active_courses: 0, certificates: 0, srs_due: 0, srs_new: 0 },
|
||||
activeCourses: propCourses = [],
|
||||
user: propUser = { name: 'Student', avatar: '', rank: 'Genin' }
|
||||
export default function Dashboard({
|
||||
stats = { xp_points: 0, current_streak: 0, active_courses: 0, certificates: 0, srs_due: 0, srs_new: 0 },
|
||||
activeCourses = [],
|
||||
user
|
||||
}: Partial<DashboardProps>) {
|
||||
|
||||
const activeCourses = propCourses ?? [];
|
||||
const userData = propUser ?? { name: 'Student', avatar: '', rank: 'Genin' };
|
||||
const stats = propStats ?? { xp_points: 0, current_streak: 0, active_courses: 0, certificates: 0, srs_due: 0, srs_new: 0 };
|
||||
|
||||
// Stitch Heatmap Simulation
|
||||
const heatmapIntensity = ['bg-gray-200 dark:bg-white/5', 'bg-[#FF4500]/20', 'bg-[#FF4500]/40', 'bg-[#FF4500]/60', 'bg-[#FF4500]'];
|
||||
const heatmapCells = Array.from({ length: 84 }, () => Math.floor(Math.random() * 5));
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout
|
||||
header={
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 className="text-2xl font-bold tracking-tight">Dashboard Siswa</h2>
|
||||
<p className="text-muted-foreground text-sm">Selamat datang kembali! Yuk, lanjutkan progres belajarmu hari ini.</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<DashboardLayout>
|
||||
<Head title="Dashboard" />
|
||||
|
||||
<div className="space-y-8 animate-in fade-in duration-700">
|
||||
{/* Stats Grid */}
|
||||
<div className="grid gap-4 grid-cols-2 lg:grid-cols-4">
|
||||
<Card className="shadow-none">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">Total XP</CardTitle>
|
||||
<Trophy className="h-4 w-4 text-yellow-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-black">{stats.xp_points.toLocaleString()}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="shadow-none">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">Streak</CardTitle>
|
||||
<Flame className="h-4 w-4 text-orange-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-black">{stats.current_streak} Hari</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="shadow-none">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">Kursus</CardTitle>
|
||||
<BookOpen className="h-4 w-4 text-blue-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-black">{stats.active_courses} Aktif</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="shadow-none">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">Sertifikat</CardTitle>
|
||||
<GraduationCap className="h-4 w-4 text-green-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-black">{stats.certificates} Diraih</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="grid grid-cols-12 gap-6 animate-in fade-in duration-500">
|
||||
|
||||
<div className="grid gap-8 lg:grid-cols-12">
|
||||
{/* Main Content: Course List */}
|
||||
<div className="lg:col-span-8 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-bold tracking-tight">Lanjutkan Belajar</h3>
|
||||
<Link href="#" className="text-xs font-semibold text-primary hover:underline">Lihat Semua</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid sm:grid-cols-2 gap-6">
|
||||
{activeCourses.length > 0 ? activeCourses.map((course, i) => (
|
||||
<CourseCard key={i} {...course} />
|
||||
)) : (
|
||||
<div className="col-span-full border-2 border-dashed border-border/50 rounded-3xl py-12 flex flex-col items-center justify-center text-center px-6">
|
||||
<div className="w-16 h-16 bg-muted rounded-full flex items-center justify-center mb-4">
|
||||
<BookOpen className="text-muted-foreground" size={32} />
|
||||
</div>
|
||||
<h4 className="font-bold text-foreground">Belum ada kursus aktif</h4>
|
||||
<p className="text-sm text-muted-foreground max-w-xs mt-1 mb-6">Mulai perjalanan belajarmu sekarang dengan memilih paket kursus yang tersedia.</p>
|
||||
<Button className="rounded-full px-8">Explorasi Kursus</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* SRS Review Card (Large) */}
|
||||
<div className="col-span-12 lg:col-span-8 bg-white/60 dark:bg-[#161618]/60 backdrop-blur-md border border-gray-200 dark:border-white/5 rounded-xl p-8 relative overflow-hidden group shadow-sm transition-colors duration-300">
|
||||
<div className="absolute top-0 right-0 p-8 opacity-10 group-hover:opacity-20 transition-opacity">
|
||||
<Brain size={120} className="text-[#FF4500]" />
|
||||
</div>
|
||||
|
||||
{/* Sidebar: Profile & SRS */}
|
||||
<div className="lg:col-span-4 space-y-6">
|
||||
<Card className="overflow-hidden border-border/50 bg-card/50 backdrop-blur-xl">
|
||||
<div className="h-24 bg-gradient-to-br from-primary/20 via-primary/10 to-transparent dark:from-primary/30 dark:via-primary/5" />
|
||||
<CardContent className="relative pt-0 px-6 pb-6">
|
||||
<Avatar className="h-20 w-20 absolute -top-10 border-4 border-background ring-1 ring-border/20">
|
||||
<AvatarImage src={userData.avatar} />
|
||||
<AvatarFallback className="bg-primary text-primary-foreground font-bold">{userData.name.charAt(0)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="mt-12">
|
||||
<h3 className="font-bold text-xl">{userData.name}</h3>
|
||||
<p className="text-sm text-muted-foreground font-medium">Rank: <span className="text-primary italic font-bold">{userData.rank}</span></p>
|
||||
</div>
|
||||
<div className="mt-6 pt-6 border-t border-border/50 grid grid-cols-2 gap-4 text-center">
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-xl font-black">{stats.xp_points.toLocaleString()}</p>
|
||||
<p className="text-[10px] text-muted-foreground uppercase font-bold tracking-wider">Total XP</p>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-xl font-black">{stats.current_streak}</p>
|
||||
<p className="text-[10px] text-muted-foreground uppercase font-bold tracking-wider">Streak</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" className="w-full mt-6 rounded-xl text-xs font-bold border-border/50">Edit Profil</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* SRS Reminder */}
|
||||
<Card className="bg-primary text-primary-foreground border-none shadow-xl shadow-primary/20 relative overflow-hidden group">
|
||||
<div className="absolute top-0 right-0 p-8 opacity-10 transform translate-x-4 -translate-y-4 group-hover:scale-110 transition-transform duration-700">
|
||||
<Flame size={120} />
|
||||
</div>
|
||||
<CardHeader className="relative z-10">
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
Hafalan (SRS)
|
||||
</CardTitle>
|
||||
<CardDescription className="text-primary-foreground/80 font-medium">
|
||||
{stats.srs_due > 0 ? (
|
||||
<>Ada <span className="font-black text-white">{stats.srs_due} kata</span> yang perlu diulas hari ini.</>
|
||||
) : stats.srs_new > 0 ? (
|
||||
<>Tidak ada ulasan, tapi ada <span className="font-black text-white">{stats.srs_new} kata baru</span> siap dipelajari!</>
|
||||
) : (
|
||||
<>Luar biasa! Semua hafalanmu sudah selesai untuk hari ini.</>
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="relative z-10">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full font-black text-primary rounded-xl h-11"
|
||||
size="lg"
|
||||
onClick={() => window.location.href = route('srs.index')}
|
||||
>
|
||||
{stats.srs_due > 0 ? 'Mulai Sesi Review' : 'Buka Koleksi Kata'}
|
||||
<Play className="ml-2 w-4 h-4 fill-current" />
|
||||
<div className="relative z-10 flex flex-col h-full justify-between gap-8">
|
||||
<div>
|
||||
<h2 className="text-[#FF4500] font-bold uppercase tracking-widest text-xs mb-2">SRS Status</h2>
|
||||
<p className="text-5xl font-bold text-gray-900 dark:text-white tracking-tight">{stats.srs_due} Reviews Due</p>
|
||||
<p className="text-gray-500 dark:text-white/60 mt-2 max-w-md">
|
||||
{stats.srs_due > 0
|
||||
? "You have Kanji and Vocabulary items waiting. Keep your streak alive!"
|
||||
: "All caught up! Why not learn some new words?"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href={route('srs.index')}>
|
||||
<Button className="bg-[#FF4500] hover:bg-[#FF4500]/90 text-white px-8 py-6 rounded-lg font-bold text-base shadow-[0_0_15px_rgba(255,68,0,0.3)] hover:scale-[1.02] active:scale-[0.98] transition-all flex items-center gap-2">
|
||||
Start Session
|
||||
<Play size={20} className="fill-current" />
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
<div className="text-xs font-medium text-gray-500 dark:text-white/40 flex flex-col">
|
||||
<span>Estimated time</span>
|
||||
<span className="text-gray-900 dark:text-white">~{Math.ceil(stats.srs_due * 0.5)} minutes</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Streak Widget */}
|
||||
<div className="col-span-12 md:col-span-6 lg:col-span-4 bg-[#FF4500]/5 dark:bg-[#FF4500]/[0.03] border border-[#FF4500]/20 backdrop-blur-md rounded-xl p-6 flex flex-col justify-between shadow-sm transition-colors duration-300">
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="size-12 rounded bg-[#FF4500]/20 flex items-center justify-center">
|
||||
<Flame size={24} className="text-[#FF4500] fill-current" />
|
||||
</div>
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-[#FF4500] bg-[#FF4500]/10 px-2 py-1 rounded">Daily Goal Met</span>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<p className="text-4xl font-bold text-gray-900 dark:text-white tracking-tighter">{stats.current_streak} Day Streak</p>
|
||||
<p className="text-gray-500 dark:text-white/40 text-sm mt-1">Don't break the chain. Keep going!</p>
|
||||
</div>
|
||||
<div className="mt-6 flex gap-1">
|
||||
{[1, 1, 1, 1, 0, 0, 0].map((active, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
"h-1.5 flex-1 rounded-full",
|
||||
active ? "bg-[#FF4500] shadow-[0_0_8px_rgba(255,68,0,0.4)]" : "bg-gray-200 dark:bg-white/10"
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Study Heatmap */}
|
||||
<div className="col-span-12 lg:col-span-8 bg-white/60 dark:bg-[#161618]/60 backdrop-blur-md border border-gray-200 dark:border-white/5 rounded-xl p-6 shadow-sm transition-colors duration-300">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h3 className="text-gray-900 dark:text-white font-bold">Study Activity</h3>
|
||||
<p className="text-gray-500 dark:text-white/40 text-xs">Consistent effort yields results</p>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center text-[10px] text-gray-400 dark:text-white/40">
|
||||
<span>Less</span>
|
||||
{heatmapIntensity.map((bg, i) => (
|
||||
<div key={i} className={cn("size-3 rounded-sm", bg)} />
|
||||
))}
|
||||
<span>More</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-12 gap-2">
|
||||
<div className="col-span-12 flex gap-1.5 flex-wrap">
|
||||
{heatmapCells.map((level, i) => (
|
||||
<div key={i} className={cn("w-[11px] h-[11px] rounded-sm", heatmapIntensity[level])} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-between text-[11px] font-medium text-gray-400 dark:text-white/40 uppercase tracking-widest">
|
||||
<span>October</span>
|
||||
<span>November</span>
|
||||
<span>December</span>
|
||||
<span>January</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Level Progress (Circular) */}
|
||||
<div className="col-span-12 md:col-span-6 lg:col-span-4 bg-white/60 dark:bg-[#161618]/60 backdrop-blur-md border border-gray-200 dark:border-white/5 rounded-xl p-6 flex flex-col items-center justify-center text-center shadow-sm transition-colors duration-300">
|
||||
<div className="relative size-32 flex items-center justify-center">
|
||||
<svg className="absolute inset-0 size-full -rotate-90">
|
||||
<circle className="text-gray-200 dark:text-white/5" cx="64" cy="64" fill="transparent" r="58" stroke="currentColor" strokeWidth="8"></circle>
|
||||
<circle className="text-[#FF4500]" cx="64" cy="64" fill="transparent" r="58" stroke="currentColor" strokeDasharray="364.4" strokeDashoffset="127.5" strokeLinecap="round" strokeWidth="8"></circle>
|
||||
</svg>
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="text-3xl font-bold text-gray-900 dark:text-white">65%</span>
|
||||
<span className="text-[10px] font-bold text-gray-500 dark:text-white/40 uppercase">N5 Progress</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<h4 className="text-gray-900 dark:text-white font-bold text-lg">JLPT N5 Path</h4>
|
||||
<p className="text-gray-500 dark:text-white/40 text-sm mt-1">242 / 800 Kanji mastered</p>
|
||||
</div>
|
||||
<Link href={route('courses.index')} className="w-full mt-4">
|
||||
<Button variant="outline" className="w-full border-gray-200 dark:border-white/10 hover:bg-gray-100 dark:hover:bg-white/5 text-xs font-bold text-gray-700 dark:text-white/80">
|
||||
VIEW CURRICULUM
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Quick Stats Footer */}
|
||||
<div className="col-span-12 grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white/60 dark:bg-[#161618]/60 border border-gray-200 dark:border-white/5 p-4 rounded-lg shadow-sm">
|
||||
<p className="text-[10px] font-bold text-gray-500 dark:text-white/40 uppercase tracking-widest">Items Learned</p>
|
||||
<p className="text-xl font-bold text-gray-900 dark:text-white">1,402</p>
|
||||
</div>
|
||||
<div className="bg-white/60 dark:bg-[#161618]/60 border border-gray-200 dark:border-white/5 p-4 rounded-lg shadow-sm">
|
||||
<p className="text-[10px] font-bold text-gray-500 dark:text-white/40 uppercase tracking-widest">Retention Rate</p>
|
||||
<p className="text-xl font-bold text-gray-900 dark:text-white">88.4%</p>
|
||||
</div>
|
||||
<div className="bg-white/60 dark:bg-[#161618]/60 border border-gray-200 dark:border-white/5 p-4 rounded-lg shadow-sm">
|
||||
<p className="text-[10px] font-bold text-gray-500 dark:text-white/40 uppercase tracking-widest">Global Rank</p>
|
||||
<p className="text-xl font-bold text-gray-900 dark:text-white">#412</p>
|
||||
</div>
|
||||
<div className="bg-white/60 dark:bg-[#161618]/60 border border-gray-200 dark:border-white/5 p-4 rounded-lg shadow-sm">
|
||||
<p className="text-[10px] font-bold text-gray-500 dark:text-white/40 uppercase tracking-widest">Total XP</p>
|
||||
<p className="text-xl font-bold text-gray-900 dark:text-white">{stats.xp_points.toLocaleString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
|
||||
import DashboardLayout from '@/Layouts/DashboardLayout';
|
||||
import { PageProps } from '@/types';
|
||||
import { Head } from '@inertiajs/react';
|
||||
import DeleteUserForm from './Partials/DeleteUserForm';
|
||||
@@ -10,7 +10,7 @@ export default function Edit({
|
||||
status,
|
||||
}: PageProps<{ mustVerifyEmail: boolean; status?: string }>) {
|
||||
return (
|
||||
<AuthenticatedLayout
|
||||
<DashboardLayout
|
||||
header={
|
||||
<h2 className="text-xl font-semibold leading-tight text-gray-800 dark:text-gray-200">
|
||||
Profile
|
||||
@@ -38,6 +38,6 @@ export default function Edit({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
|
||||
import DashboardLayout from '@/Layouts/DashboardLayout';
|
||||
import { Head, Link } from '@inertiajs/react';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/Components/ui/card';
|
||||
@@ -12,7 +12,7 @@ interface SrsStats {
|
||||
|
||||
export default function SrsIndex({ stats }: { stats: SrsStats }) {
|
||||
return (
|
||||
<AuthenticatedLayout
|
||||
<DashboardLayout
|
||||
header={
|
||||
<h2 className="text-xl font-semibold leading-tight text-gray-800 dark:text-gray-200">
|
||||
Vocabulary SRS
|
||||
@@ -83,6 +83,6 @@ export default function SrsIndex({ stats }: { stats: SrsStats }) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
|
||||
import DashboardLayout from '@/Layouts/DashboardLayout';
|
||||
import { Head, router } from '@inertiajs/react';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import { Card } from '@/Components/ui/card';
|
||||
@@ -32,7 +32,7 @@ export default function SrsPractice({ items }: { items: ReviewItem[] }) {
|
||||
if (!isFlipped && !isFinished) {
|
||||
setIsFlipped(true);
|
||||
if (currentItem?.audio_url) {
|
||||
new Audio(currentItem.audio_url).play().catch(() => {});
|
||||
new Audio(currentItem.audio_url).play().catch(() => { });
|
||||
}
|
||||
}
|
||||
}, [isFlipped, isFinished, currentItem]);
|
||||
@@ -42,7 +42,7 @@ export default function SrsPractice({ items }: { items: ReviewItem[] }) {
|
||||
|
||||
// Optimistic UI update
|
||||
const itemToSubmit = currentItem;
|
||||
|
||||
|
||||
// Submit in background
|
||||
router.post(route('srs.store'), {
|
||||
vocabulary_id: itemToSubmit.id,
|
||||
@@ -68,7 +68,7 @@ export default function SrsPractice({ items }: { items: ReviewItem[] }) {
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (isFinished) return;
|
||||
|
||||
|
||||
if (e.code === 'Space') {
|
||||
e.preventDefault();
|
||||
if (!isFlipped) handleFlip();
|
||||
@@ -85,7 +85,7 @@ export default function SrsPractice({ items }: { items: ReviewItem[] }) {
|
||||
}, [handleFlip, handleGrade, isFinished, isFlipped]);
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout header={
|
||||
<DashboardLayout header={
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="flex flex-col">
|
||||
<h2 className="text-2xl font-black tracking-tight text-foreground">
|
||||
@@ -119,9 +119,9 @@ export default function SrsPractice({ items }: { items: ReviewItem[] }) {
|
||||
</div>
|
||||
<h3 className="text-3xl font-black text-foreground mb-3">Otsukaresama! 🎉</h3>
|
||||
<p className="text-muted-foreground font-medium mb-10">Sesi ulasan Anda telah selesai dengan luar biasa.</p>
|
||||
<Button
|
||||
onClick={() => router.visit(route('srs.index'))}
|
||||
size="lg"
|
||||
<Button
|
||||
onClick={() => router.visit(route('srs.index'))}
|
||||
size="lg"
|
||||
className="w-full rounded-[1.5rem] h-14 text-lg font-bold bg-primary hover:bg-primary/90 shadow-xl shadow-primary/20 hover:scale-105 active:scale-95 transition-all"
|
||||
>
|
||||
Kembali ke Dashboard
|
||||
@@ -131,7 +131,7 @@ export default function SrsPractice({ items }: { items: ReviewItem[] }) {
|
||||
) : (
|
||||
<div className="w-full max-w-2xl px-4 flex flex-col items-center">
|
||||
{/* Flashcard Area */}
|
||||
<div
|
||||
<div
|
||||
className="w-full aspect-[16/10] sm:aspect-[16/9] perspective-2000 cursor-pointer group mb-12"
|
||||
onClick={handleFlip}
|
||||
>
|
||||
@@ -147,7 +147,7 @@ export default function SrsPractice({ items }: { items: ReviewItem[] }) {
|
||||
{currentItem.type === 'new' ? 'New Discovery' : 'Active Review'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<h1 className="text-7xl md:text-9xl font-black text-foreground tracking-tighter drop-shadow-sm">
|
||||
{currentItem.word}
|
||||
@@ -162,14 +162,14 @@ export default function SrsPractice({ items }: { items: ReviewItem[] }) {
|
||||
{/* Back Side */}
|
||||
<Card className="absolute w-full h-full backface-hidden rotate-y-180 flex flex-col items-center justify-center p-12 bg-card/60 backdrop-blur-3xl border border-primary/20 rounded-[3rem] shadow-2xl">
|
||||
<div className="absolute top-8 left-1/2 -translate-x-1/2 border-b-4 border-primary/30 w-12 rounded-full" />
|
||||
|
||||
|
||||
<div className="flex flex-col items-center text-center space-y-6 w-full">
|
||||
<h2 className="text-5xl md:text-6xl font-black text-primary tracking-tight">
|
||||
{currentItem.reading}
|
||||
</h2>
|
||||
|
||||
|
||||
<div className="h-px w-24 bg-border/50" />
|
||||
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-3xl md:text-4xl text-foreground font-black tracking-tight">
|
||||
{currentItem.meaning}
|
||||
@@ -178,13 +178,13 @@ export default function SrsPractice({ items }: { items: ReviewItem[] }) {
|
||||
</div>
|
||||
|
||||
{currentItem.audio_url && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
new Audio(currentItem.audio_url!).play();
|
||||
}}
|
||||
}}
|
||||
className="mt-6 rounded-2xl h-14 w-14 p-0 bg-primary/10 text-primary hover:bg-primary hover:text-white transition-all shadow-lg shadow-primary/5"
|
||||
>
|
||||
<Volume2 size={24} />
|
||||
@@ -206,9 +206,9 @@ export default function SrsPractice({ items }: { items: ReviewItem[] }) {
|
||||
{ val: 3, label: 'Good', desc: 'Bagus', color: 'blue' },
|
||||
{ val: 4, label: 'Easy', desc: 'Mudah', color: 'green' }
|
||||
].map((btn) => (
|
||||
<Button
|
||||
<Button
|
||||
key={btn.val}
|
||||
variant="outline"
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"flex-1 min-w-[120px] h-20 flex flex-col gap-1 rounded-2xl border-2 transition-all hover:-translate-y-1",
|
||||
btn.color === 'red' && "border-red-500/20 bg-red-500/5 hover:bg-red-500/10 text-red-600 hover:border-red-500/40",
|
||||
@@ -223,7 +223,7 @@ export default function SrsPractice({ items }: { items: ReviewItem[] }) {
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
{/* Keyboard Tips */}
|
||||
<div className={cn(
|
||||
"mt-8 text-center transition-opacity duration-1000",
|
||||
@@ -237,6 +237,6 @@ export default function SrsPractice({ items }: { items: ReviewItem[] }) {
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user