diff --git a/.env.example b/.env.example index 35db1dd..284aaed 100644 --- a/.env.example +++ b/.env.example @@ -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}" diff --git a/.gitignore b/.gitignore index c7cf1fa..206ba44 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ /storage/*.key /storage/pail /vendor +/x-dev-environment .env .env.backup .env.production diff --git a/README.md b/README.md index 57918bf..82f7560 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,10 @@ erDiagram ### A. Japanese Support System - **Furigana Engine**: Implementasi tag HTML `` 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. diff --git a/config/filesystems.php b/config/filesystems.php index edce36d..830b0a1 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -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'), diff --git a/database/migrations/2026_01_22_225043_create_media_table.php b/database/migrations/2026_01_22_225043_create_media_table.php index 47a4be9..8803cab 100644 --- a/database/migrations/2026_01_22_225043_create_media_table.php +++ b/database/migrations/2026_01_22_225043_create_media_table.php @@ -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'); diff --git a/deploy.sh b/deploy.sh new file mode 100644 index 0000000..af3396a --- /dev/null +++ b/deploy.sh @@ -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 "โณ Deployment Started%0A%0A๐Ÿš€ Project: Nihonbuzz Academy%0A๐Ÿ“… Date: $(date)" + +set -e + +git config --global --add safe.directory "$PROJECT_PATH" +cd "$PROJECT_PATH" + +trap 'send_telegram "โŒ Deployment FAILED!%0A%0Aโš ๏ธ Check server logs untuk detail.%0A๐Ÿ“… Date: $(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 "โœ… Deployment Success!%0A%0A๐Ÿ“ฆ Project: Nihonbuzz Academy%0A๐Ÿ“… Date: $(date)" diff --git a/public/debug_pgsql.php b/public/debug_pgsql.php new file mode 100644 index 0000000..21d4007 --- /dev/null +++ b/public/debug_pgsql.php @@ -0,0 +1,26 @@ +PHP Debug Info"; +echo "PHP Version: " . phpversion() . "
"; +echo "Loaded INI File: " . php_ini_loaded_file() . "
"; +echo "Active PDO Drivers: " . implode(', ', pdo_drivers()) . "
"; + +echo "
"; +echo "

Connection Test

"; + +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 "โœ… Connection SUCCESS!
"; + echo "Connected to PostgreSQL database successfully."; +} catch (PDOException $e) { + echo "โŒ Connection FAILED
"; + echo "Error Message: " . $e->getMessage() . "
"; + + if (strpos($e->getMessage(), 'could not find driver') !== false) { + echo "
Troubleshooting: The PostgreSQL driver is missing. Verify php.ini settings and restart the web server."; + } +} diff --git a/resources/js/Layouts/AuthenticatedLayout.tsx b/resources/js/Layouts/AuthenticatedLayout.tsx deleted file mode 100644 index 4b2479e..0000000 --- a/resources/js/Layouts/AuthenticatedLayout.tsx +++ /dev/null @@ -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 ( -
- - {/* Desktop Sidebar Island */} - - - {/* Main Content Wrapper */} -
- - {/* Island Topbar Pill */} -
-
- - - - - -
- Nihonbuzz Academy - Nihonbuzz Academy -
-
- {navItems.map((item, index) => ( - - - {item.label} - - ))} -
-
-
- - Logo - -
- - {/* Search Bar (Desktop) */} -
- - -
- - {/* Right Profile Menu */} -
- - - - - - - -
-
-

{user.name}

-

{user.email}

-
-
- - - - - Profile - - - - - - Keluar - - -
-
-
-
- - {/* Page Content */} -
- {children} -
-
-
- ); -} - -// Helper component for Trophy icon which was missing in imports -function Trophy({ size = 24, ...props }: { size?: number, [key: string]: any }) { - return ( - - - - - - - - - ) -} - diff --git a/resources/js/Layouts/CourseLayout.tsx b/resources/js/Layouts/CourseLayout.tsx index 0a88c9a..0a34e04 100644 --- a/resources/js/Layouts/CourseLayout.tsx +++ b/resources/js/Layouts/CourseLayout.tsx @@ -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([]); - 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(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 ; - case 'quiz': return ; - case 'pdf': return ; - default: return ; + case 'video': return ; + case 'quiz': return ; + case 'pdf': return ; + default: return ; } }; - return ( -
- {/* Mobile Sheet Sidebar */} - -
-
- - - - {course.title} -
- - - -
- - - - -
- - {/* Desktop Sidebar */} - - - {/* Main Content Area */} -
- {/* Desktop Topbar */} -
-
- - -
-
-
-
- Progress - {course.progress_percentage}% -
- -
- -
-
- - {/* Content Slot */} -
-
- {children} -
-
-
-
- ); -} - -function SidebarContent({ course, modules, currentLesson, openModules, toggleModule, getLessonIcon }: any) { - return ( -
- {/* Header */} -
- - - Kembali ke Dashboard - -
-
- {course.title.charAt(0)} -
-

{course.title}

-
- -
- - -
+ const SidebarContent = () => ( +
+
+

Course Content

+ {course.progress_percentage}% Complete
- {/* Curriculum List */} - -
- {modules.map((module: any, index: number) => ( -
- {openModules.includes(module.id) && ( -
- {module.lessons.map((lesson: any) => { +
+ {module.lessons.map((lesson) => { const isActive = currentLesson?.slug === lesson.slug; return ( -
+ {isActive &&
} + +
{lesson.is_completed ? ( - + + ) : isActive ? ( + ) : ( - + )}
-
- {lesson.title} -
- - {getLessonIcon(lesson.type)} - {lesson.type} - - {lesson.duration_seconds > 0 && ( - <> - โ€ข - {Math.ceil(lesson.duration_seconds / 60)} min - - )} -
+ +
+

+ {lesson.title} +

+

+ {isActive ? "Current Lesson" : `${Math.ceil(lesson.duration_seconds / 60)} min`} +

- {isActive && ( -
- )} ); })} @@ -291,11 +149,113 @@ function SidebarContent({ course, modules, currentLesson, openModules, toggleMod ))}
- - {/* Footer User Info */} -
- Built for Future Japan Enthusisasts + +
+
); + + return ( +
+ {/* Top Navigation */} +
+
+
+
+ N +
+

+ Nihonbuzz Academy +

+
+
+
+ {course.title} + + {currentLesson?.title} +
+
+ +
+ + + + + + +
+ + + {user.name.charAt(0)} + +
+ + {/* Mobile Sidebar Trigger */} + + + + + + + + +
+
+ + {/* Main Layout Area */} +
+ + {/* Center Content (Video/Text) */} +
+ {children} +
+ + {/* Right Sidebar (Desktop) */} + + +
+ + {/* Bottom Navigation Bar */} +
+
+ {previousLesson && ( + + + + )} +
+ +
+ {nextLesson && ( +
+
+ Next Up + {nextLesson.title} +
+ + + +
+ )} +
+
+
+ ); } diff --git a/resources/js/Layouts/DashboardLayout.tsx b/resources/js/Layouts/DashboardLayout.tsx new file mode 100644 index 0000000..004fdcb --- /dev/null +++ b/resources/js/Layouts/DashboardLayout.tsx @@ -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 = () => ( +
+ {/* Logo */} +
+
+ N +
+
+

NIHONBUZZ

+

Academy

+
+
+ + {/* Nav Links */} + + + {/* Profile Section (Bottom) */} +
+
+ + + + {user.name.charAt(0)} + + +
+

{user.name}

+

Level 12 Scholar

+
+
+ + {/* Mini Progress Bar */} +
+
+ XP Progress + 85% +
+
+
+
+
+ + + + Settings + +
+
+ ); + + return ( +
+ + {/* Desktop Sidebar */} + + + {/* Mobile Header / Sheet */} +
+
+ + + + + + + + +
NIHONBUZZ
+
+ + + {user.name.charAt(0)} + +
+ + {/* Main Content */} +
+ + {/* Desktop Topbar */} +
+
+ Dashboard + / + Home +
+ +
+ + + + + +
+
+ +
+ {children} +
+
+
+ ); +} diff --git a/resources/js/Pages/Courses/Learn.tsx b/resources/js/Pages/Courses/Learn.tsx index 2ad31ee..ddc9677 100644 --- a/resources/js/Pages/Courses/Learn.tsx +++ b/resources/js/Pages/Courses/Learn.tsx @@ -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 ( - + {/* Header Content */}
@@ -63,21 +82,32 @@ export default function Learn({ course, modules, lesson }: PageProps) { {/* Main Content Render */}
- + {lesson.type === 'video' && lesson.video_url && (
-