first commit

This commit is contained in:
2026-01-23 17:28:21 +07:00
commit 29ff8992b9
331 changed files with 30545 additions and 0 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

99
resources/css/app.css Normal file
View File

@@ -0,0 +1,99 @@
/* Plyr CSS - Must be at top before @tailwind */
@import "plyr/dist/plyr.css";
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html {
scroll-behavior: smooth;
scrollbar-gutter: stable;
}
:root {
--brand-red: #FF4500;
--brand-black: #000000;
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary: 16 100% 50%;
--primary-foreground: 0 0% 100%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 16 100% 50%;
--radius: 1rem;
/* Plyr Custom Theme */
--plyr-color-main: #FF4500;
}
.dark {
--background: 240 10% 3.9%; /* Matching nihonbuzz.org #0a0a0b approx */
--foreground: 0 0% 98%;
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 240 10% 4.8%;
--popover-foreground: 0 0% 98%;
--primary: 16 100% 50%;
--primary-foreground: 0 0% 100%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 12%;
--muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 16 100% 50%;
}
}
@layer utilities {
.bg-grid {
background-size: 40px 40px;
background-image:
linear-gradient(to right, hsl(var(--foreground) / var(--grid-opacity, 0.05)) 1px, transparent 1px),
linear-gradient(to bottom, hsl(var(--foreground) / var(--grid-opacity, 0.05)) 1px, transparent 1px);
mask-image: linear-gradient(180deg, white, rgba(255, 255, 255, 0));
-webkit-mask-image: linear-gradient(180deg, white, rgba(255, 255, 255, 0));
}
.bg-seigaiha {
background-color: transparent;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='80' height='40' viewBox='0 0 80 40'%3E%3Cpath d='M0 40a40 40 0 0 1 40-40 40 40 0 0 1 40 40H70a30 30 0 0 0-30-30 30 30 0 0 0-30 30H0zm40 0a10 10 0 0 1 10-10 10 10 0 0 1 10 10H50a5 5 0 0 0-5-5 5 5 0 0 0-5 5h-10zm0-30a10 10 0 0 1 10-10 10 10 0 0 1 10 10h-5a5 5 0 0 0-5-5 5 5 0 0 0-5 5h-5zM0 10a10 10 0 0 1 10-10 10 10 0 0 1 10 10h-5a5 5 0 0 0-5-5 5 5 0 0 0-5 5H0zm70 0a10 10 0 0 1 10-10 10 10 0 0 1 10 10h-5a5 5 0 0 0-5-5 5 5 0 0 0-5 5h-5z' fill='none' stroke='%23FF4500' stroke-width='1.5' stroke-opacity='0.15'/%3E%3C/svg%3E");
mask-image: linear-gradient(180deg, white, rgba(255, 255, 255, 0));
-webkit-mask-image: linear-gradient(180deg, white, rgba(255, 255, 255, 0));
}
}
@layer base {
* {
@apply border-border;
}
body {
--grid-opacity: 0.05;
--hero-glow-opacity: 0.8;
@apply bg-background text-foreground transition-colors duration-300;
}
.dark body {
--grid-opacity: 0.02;
--hero-glow-opacity: 0.5;
}
}
.plyr--video {
@apply rounded-2xl overflow-hidden shadow-2xl;
}

View File

@@ -0,0 +1,12 @@
import { HTMLAttributes } from 'react';
export default function ApplicationLogo({ className = '', ...props }: HTMLAttributes<HTMLImageElement>) {
return (
<img
{...props}
src="/brand/Nihonbuzz-Academy-Light-LS-Regular.png"
alt="Nihonbuzz Academy"
className={className}
/>
);
}

View File

@@ -0,0 +1,34 @@
import { Checkbox as ShadcnCheckbox } from '@/Components/ui/checkbox';
export default function Checkbox({
className = '',
checked,
onChange,
onCheckedChange,
...props
}: any) {
const handleChange = (val: boolean) => {
if (onCheckedChange) onCheckedChange(val);
if (onChange) {
onChange({
target: {
name: props.name,
checked: val,
type: 'checkbox'
}
});
}
};
return (
<ShadcnCheckbox
{...props}
checked={checked}
onCheckedChange={handleChange}
className={
'rounded border-gray-300 ' +
className
}
/>
);
}

View File

@@ -0,0 +1,76 @@
import { Card, CardContent, CardHeader } from "@/Components/ui/card";
import { Progress } from "@/Components/ui/progress";
import { Badge } from "@/Components/ui/badge";
import { Link } from "@inertiajs/react";
import { Play } from "lucide-react";
interface CourseCardProps {
title: string;
thumbnail: string;
level: string;
progress: number;
lessonsCount: number;
completedLessons: number;
slug: string;
}
export default function CourseCard({
title,
thumbnail,
level,
progress,
lessonsCount,
completedLessons,
slug
}: CourseCardProps) {
return (
<div className="relative group">
{/* Hover Glow Effect */}
<div className="absolute inset-0 bg-gradient-to-br from-primary/10 via-orange-500/5 to-transparent blur-2xl opacity-0 group-hover:opacity-100 transition-opacity duration-500 rounded-3xl" />
<Card className="relative overflow-hidden bg-card/50 backdrop-blur-sm transition-all duration-500 border-border/50 group-hover:border-primary/30 shadow-sm group-hover:shadow-2xl group-hover:shadow-primary/5 group-hover:-translate-y-1 rounded-2xl">
<div className="relative aspect-[16/9] overflow-hidden">
<img
src={thumbnail || "/brand/Nihonbuzz-Academy-Logo-Branding-Pattern-Landscape.png"}
alt={title}
className="object-cover w-full h-full transition-transform duration-700 group-hover:scale-105"
/>
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex items-center justify-center backdrop-blur-[2px]">
<div className="w-12 h-12 rounded-full bg-primary text-primary-foreground flex items-center justify-center shadow-lg transform translate-y-4 group-hover:translate-y-0 transition-all duration-500">
<Play className="w-5 h-5 fill-current ml-1" />
</div>
</div>
<Badge className="absolute top-3 left-3 bg-background/80 text-primary hover:bg-background border-none backdrop-blur-md shadow-sm font-bold text-[10px] px-3">
{level}
</Badge>
</div>
<CardHeader className="p-5 space-y-1.5">
<h3 className="font-bold text-foreground line-clamp-1 group-hover:text-primary transition-colors text-base duration-300">
{title}
</h3>
<div className="flex items-center gap-2 text-[11px] text-muted-foreground font-medium">
<Badge variant="secondary" className="bg-muted text-muted-foreground border-none font-semibold px-2 h-5">
{completedLessons}/{lessonsCount} Lessons
</Badge>
<span className="w-1 h-1 rounded-full bg-muted-foreground/30" />
<span>{progress}% Complete</span>
</div>
</CardHeader>
<CardContent className="px-5 pb-5">
<div className="space-y-4">
<Progress value={progress} className="h-1.5 bg-muted" />
<Link
href={route('courses.learn', { course: slug })}
className="flex items-center justify-center gap-2 w-full py-2.5 text-xs font-bold rounded-xl bg-muted/50 text-muted-foreground hover:bg-primary hover:text-primary-foreground transition-all duration-300"
>
Lanjutkan Belajar
<Play className="w-3 h-3 fill-current" />
</Link>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,16 @@
import { HTMLAttributes } from 'react';
export default function InputError({
message,
className = '',
...props
}: HTMLAttributes<HTMLParagraphElement> & { message?: string }) {
return message ? (
<p
{...props}
className={'text-sm text-red-600 dark:text-red-400 ' + className}
>
{message}
</p>
) : null;
}

View File

@@ -0,0 +1,66 @@
import { Link } from "@inertiajs/react";
import { MapPin, Mail, Phone } from "lucide-react";
export function Footer() {
return (
<footer className="border-t border-white/5 bg-black py-16 relative z-10 w-full">
<div className="container mx-auto px-6">
<div className="grid md:grid-cols-4 gap-12 mb-16">
<div className="col-span-1 md:col-span-2">
<Link href="/" className="mb-6 block">
<img
src="/brand/Nihonbuzz-Academy-Dark-LS-Regular.png"
alt="NihonBuzz"
className="h-8 w-auto transition-transform hover:scale-105 duration-300"
/>
</Link>
<p className="text-muted-foreground mb-8 max-w-sm leading-relaxed">
Ekosistem belajar Bahasa Jepang modern untuk penguasaan JLPT, Karir, dan Budaya. Platform LMS terintegrasi untuk siswa Indonesia.
</p>
<div className="flex gap-6">
{['Instagram', 'Youtube', 'TikTok'].map((social) => (
<Link key={social} href="#" className="text-muted-foreground hover:text-primary transition-colors text-sm font-bold">
{social}
</Link>
))}
</div>
</div>
<div>
<h5 className="font-bold text-white mb-6 text-sm">Program & Kursus</h5>
<ul className="space-y-4 text-sm text-muted-foreground font-medium">
<li><Link href="#" className="hover:text-primary transition-colors">Persiapan JLPT N5 - N2</Link></li>
<li><Link href="#" className="hover:text-primary transition-colors">Mastery Kanji & Vocab</Link></li>
<li><Link href="#" className="hover:text-primary transition-colors">Kaiwa Professional</Link></li>
<li><Link href="#" className="hover:text-primary transition-colors">Dashboard Siswa</Link></li>
</ul>
</div>
<div>
<h5 className="font-bold text-white mb-6 text-sm">Hubungi Kami</h5>
<ul className="space-y-4 text-sm text-muted-foreground font-medium">
<li className="flex items-start gap-3">
<MapPin className="w-4 h-4 text-primary shrink-0 mt-0.5" />
<span className="leading-relaxed text-xs">Jl. Palapa VII No.1, Kedoya Sel., Kec. Kb. Jeruk, Jakarta Barat 11520</span>
</li>
<li className="flex items-center gap-3">
<Mail className="w-4 h-4 text-primary shrink-0" />
<a href="mailto:hello@nihonbuzz.org" className="hover:text-primary transition-colors">hello@nihonbuzz.org</a>
</li>
<li className="flex items-center gap-3">
<Phone className="w-4 h-4 text-primary shrink-0" />
<span>+62 851-2988-0919</span>
</li>
</ul>
</div>
</div>
<div className="border-t border-white/5 pt-8 text-center">
<p className="text-muted-foreground text-[10px] font-bold uppercase tracking-[0.2em] opacity-50">
&copy; {new Date().getFullYear()} NihonBuzz Academy. All rights reserved.
</p>
</div>
</div>
</footer>
);
}

View File

@@ -0,0 +1,91 @@
"use client";
import { motion, Variants } from "framer-motion";
import { ArrowRight, Sparkles } from "lucide-react";
import { Button } from "@/Components/ui/button";
import { Link } from "@inertiajs/react";
const containerVariants: Variants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.2,
delayChildren: 0.3,
},
},
};
const itemVariants: Variants = {
hidden: { y: 20, opacity: 0 },
visible: {
y: 0,
opacity: 1,
transition: {
duration: 0.8,
ease: [0.215, 0.610, 0.355, 1.000],
},
},
};
export function Hero() {
return (
<section className="relative min-h-screen flex flex-col items-center justify-center overflow-hidden w-full bg-background selection:bg-primary/30 selection:text-primary pt-20">
{/* Background Gradients - Boosted for Light Mode using CSS variables */}
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-[1000px] h-[600px] bg-primary/20 dark:bg-primary/10 blur-[120px] rounded-full opacity-[var(--hero-glow-opacity)] pointer-events-none" />
<div className="absolute bottom-0 right-0 w-[800px] h-[800px] bg-blue-500/10 dark:bg-blue-500/5 blur-[150px] rounded-full opacity-[var(--hero-glow-opacity)] pointer-events-none" />
{/* Grid Pattern Overlay */}
<div className="absolute inset-0 bg-grid z-0" />
{/* Japanese Wave Pattern - Adjusted Opacity */}
<div className="absolute inset-0 bg-seigaiha z-0 opacity-80 dark:opacity-40" />
<motion.div
variants={containerVariants}
initial="hidden"
animate="visible"
className="relative z-10 container px-4 mx-auto flex flex-col items-center text-center gap-8"
>
<motion.div variants={itemVariants}>
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full border border-primary/10 bg-primary/5 backdrop-blur-sm shadow-inner text-sm text-primary font-bold mb-6">
<Sparkles className="w-4 h-4" />
<span>Pendaftaran Batch 2026 Dibuka!</span>
</div>
</motion.div>
<motion.h1
variants={itemVariants}
className="text-5xl md:text-7xl lg:text-8xl font-black tracking-tight text-foreground leading-[1.1]"
>
Hubungkan Impianmu <br />
<span className="text-transparent bg-clip-text bg-gradient-to-r from-primary via-primary/80 to-primary/40 italic">
Ke Negeri Sakura
</span>
</motion.h1>
<motion.p
variants={itemVariants}
className="max-w-2xl text-lg md:text-xl text-muted-foreground leading-relaxed font-medium"
>
Platform edukasi terdepan untuk penguasaan Bahasa Jepang & karir profesional.
Belajar JLPT N5 hingga N2 dengan kurikulum modern dan Spaced Repetition System.
</motion.p>
<motion.div variants={itemVariants} className="flex flex-col sm:flex-row gap-5 mt-4">
<Button asChild size="lg" className="rounded-full bg-primary hover:bg-primary/90 text-white min-w-[180px] h-14 shadow-xl shadow-primary/20 font-bold text-lg">
<Link href={route('register')}>
Mulai Belajar Gratis
<ArrowRight className="w-5 h-5 ml-2" />
</Link>
</Button>
<Button asChild size="lg" variant="outline" className="rounded-full border-border hover:bg-muted min-w-[180px] h-14 font-bold text-lg text-foreground">
<Link href="#programs">
Lihat Program
</Link>
</Button>
</motion.div>
</motion.div>
</section>
);
}

View File

@@ -0,0 +1,137 @@
"use client";
import { Link } from "@inertiajs/react";
import { useState, useEffect } from "react";
import { motion } from "framer-motion";
import { Menu, X } from "lucide-react";
import { Button } from "@/Components/ui/button";
import { Sheet, SheetContent, SheetTrigger } from "@/Components/ui/sheet";
import { cn } from "@/lib/utils";
import { ModeToggle } from "@/Components/ModeToggle";
const navLinks = [
{ name: "Beranda", href: "/" },
{ name: "Kursus", href: "#courses" },
{ name: "Biaya", href: "#pricing" },
{ name: "Kurikulum", href: "#curriculum" },
{ name: "FAQ", href: "#faq" },
];
export function Navbar() {
const [scrolled, setScrolled] = useState(false);
useEffect(() => {
const handleScroll = () => {
setScrolled(window.scrollY > 50);
};
window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll);
}, []);
return (
<motion.header
initial={{ y: -100, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.5 }}
className="fixed top-0 left-0 right-0 z-50 flex justify-center pt-6 px-4 pointer-events-none"
>
<div
className={cn(
"flex items-center justify-between transition-all duration-300 ease-in-out pointer-events-auto",
scrolled
? "bg-background/80 backdrop-blur-xl border border-white/10 shadow-2xl w-full max-w-5xl px-10 py-3 rounded-full gap-8"
: "bg-transparent border-transparent w-full max-w-6xl px-6 py-3 rounded-full"
)}
>
{/* Logo */}
<Link href="/" className="relative flex items-center gap-2 group">
<img
src="/brand/Nihonbuzz-Academy-Light-LS-Regular.png"
alt="NihonBuzz"
className="h-8 md:h-10 w-auto object-contain transition-transform duration-300 group-hover:scale-105 dark:hidden"
/>
<img
src="/brand/Nihonbuzz-Academy-Dark-LS-Regular.png"
alt="NihonBuzz"
className="h-8 md:h-10 w-auto object-contain transition-transform duration-300 group-hover:scale-105 hidden dark:block"
/>
</Link>
{/* Desktop Navigation */}
<nav className="hidden lg:flex items-center gap-6">
{navLinks.map((link) => (
<Link
key={link.name}
href={link.href}
className="text-sm font-bold text-foreground/70 hover:text-primary transition-colors cursor-pointer whitespace-nowrap"
>
{link.name}
</Link>
))}
</nav>
{/* Action Button & Mobile Menu */}
<div className="flex items-center gap-3">
<div className="hidden lg:flex items-center gap-3">
<ModeToggle />
<Link href={route('login')}>
<Button
variant="ghost"
className="text-foreground/70 hover:text-primary hover:bg-primary/5 font-bold whitespace-nowrap"
size="sm"
>
Login Siswa
</Button>
</Link>
<Link href={route('register')}>
<Button
className="rounded-full bg-primary hover:bg-primary/90 text-white shadow-lg shadow-primary/20 font-black px-6 whitespace-nowrap"
size="sm"
>
Daftar Kelas
</Button>
</Link>
</div>
{/* Mobile Toggle */}
<Sheet>
<div className="lg:hidden flex items-center gap-2 pointer-events-auto">
<ModeToggle />
<SheetTrigger asChild>
<Button variant="ghost" size="icon" className="text-foreground rounded-full h-10 w-10 bg-foreground/5">
<Menu className="w-5 h-5" />
</Button>
</SheetTrigger>
</div>
<SheetContent side="top" className="w-full h-auto rounded-b-[2rem] border-border bg-background/95 backdrop-blur-xl pt-24 pb-10">
<nav className="flex flex-col items-center gap-6">
{navLinks.map((link) => (
<Link
key={link.name}
href={link.href}
className="text-lg font-bold text-foreground/80 hover:text-primary transition-colors cursor-pointer"
>
{link.name}
</Link>
))}
<div className="flex flex-col gap-3 w-full max-w-xs mt-4">
<Link href={route('login')} className="w-full">
<Button variant="outline" className="w-full rounded-full border-border text-foreground hover:bg-muted font-bold">
Login Siswa
</Button>
</Link>
<Link href={route('register')} className="w-full">
<Button className="w-full rounded-full bg-primary hover:bg-primary/90 font-bold">
Daftar Kelas
</Button>
</Link>
</div>
</nav>
</SheetContent>
</Sheet>
</div>
</div>
</motion.header>
);
}

View File

@@ -0,0 +1,36 @@
import { Moon, Sun } from "lucide-react"
import { Button } from "@/Components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/Components/ui/dropdown-menu"
import { useTheme } from "@/Components/ThemeProvider"
export function ModeToggle() {
const { setTheme } = useTheme()
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -0,0 +1,23 @@
import { InertiaLinkProps, Link } from '@inertiajs/react';
export default function NavLink({
active = false,
className = '',
children,
...props
}: InertiaLinkProps & { active: boolean }) {
return (
<Link
{...props}
className={
'inline-flex items-center border-b-2 px-1 pt-1 text-sm font-medium leading-5 transition duration-150 ease-in-out focus:outline-none ' +
(active
? 'border-nihonbuzz-red text-gray-900 focus:border-nihonbuzz-red'
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 focus:border-gray-300 focus:text-gray-700') +
className
}
>
{children}
</Link>
);
}

View File

@@ -0,0 +1,21 @@
import { InertiaLinkProps, Link } from '@inertiajs/react';
export default function ResponsiveNavLink({
active = false,
className = '',
children,
...props
}: InertiaLinkProps & { active?: boolean }) {
return (
<Link
{...props}
className={`flex w-full items-start border-l-4 py-2 pe-4 ps-3 ${
active
? 'border-nihonbuzz-red bg-nihonbuzz-red/10 text-nihonbuzz-red focus:border-nihonbuzz-red focus:bg-nihonbuzz-red/20'
: 'border-transparent text-gray-600 hover:border-gray-300 hover:bg-gray-50 hover:text-gray-800'
} text-base font-medium transition duration-150 ease-in-out focus:outline-none ${className}`}
>
{children}
</Link>
);
}

View File

@@ -0,0 +1,72 @@
import { createContext, useContext, useEffect, useState } from "react"
type Theme = "dark" | "light" | "system"
type ThemeProviderProps = {
children: React.ReactNode
defaultTheme?: Theme
storageKey?: string
}
type ThemeProviderState = {
theme: Theme
setTheme: (theme: Theme) => void
}
const initialState: ThemeProviderState = {
theme: "system",
setTheme: () => null,
}
const ThemeProviderContext = createContext<ThemeProviderState>(initialState)
export function ThemeProvider({
children,
defaultTheme = "system",
storageKey = "vite-ui-theme",
}: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>(
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
)
useEffect(() => {
const root = window.document.documentElement
root.classList.remove("light", "dark")
if (theme === "system") {
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
.matches
? "dark"
: "light"
root.classList.add(systemTheme)
return
}
root.classList.add(theme)
}, [theme])
const value = {
theme,
setTheme: (theme: Theme) => {
localStorage.setItem(storageKey, theme)
setTheme(theme)
},
}
return (
<ThemeProviderContext.Provider value={value}>
{children}
</ThemeProviderContext.Provider>
)
}
export const useTheme = () => {
const context = useContext(ThemeProviderContext)
if (context === undefined)
throw new Error("useTheme must be used within a ThemeProvider")
return context
}

View File

@@ -0,0 +1,50 @@
"use client"
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
))
Avatar.displayName = AvatarPrimitive.Root.displayName
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
))
AvatarImage.displayName = AvatarPrimitive.Image.displayName
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className
)}
{...props}
/>
))
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
export { Avatar, AvatarImage, AvatarFallback }

View File

@@ -0,0 +1,36 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,57 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@@ -0,0 +1,76 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-xl border bg-card text-card-foreground shadow",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@@ -0,0 +1,28 @@
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"grid place-content-center peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("grid place-content-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }

View File

@@ -0,0 +1,122 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@@ -0,0 +1,201 @@
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

View File

@@ -0,0 +1,176 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import {
Controller,
FormProvider,
useFormContext,
type ControllerProps,
type FieldPath,
type FieldValues,
} from "react-hook-form"
import { cn } from "@/lib/utils"
import { Label } from "@/Components/ui/label"
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue | null>(null)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState, formState } = useFormContext()
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
if (!itemContext) {
throw new Error("useFormField should be used within <FormItem>")
}
const fieldState = getFieldState(fieldContext.name, formState)
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue | null>(null)
const FormItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} />
</FormItemContext.Provider>
)
})
FormItem.displayName = "FormItem"
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField()
return (
<Label
ref={ref}
className={cn(error && "text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
)
})
FormLabel.displayName = "FormLabel"
const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
})
FormControl.displayName = "FormControl"
const FormDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField()
return (
<p
ref={ref}
id={formDescriptionId}
className={cn("text-[0.8rem] text-muted-foreground", className)}
{...props}
/>
)
})
FormDescription.displayName = "FormDescription"
const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message ?? "") : children
if (!body) {
return null
}
return (
<p
ref={ref}
id={formMessageId}
className={cn("text-[0.8rem] font-medium text-destructive", className)}
{...props}
>
{body}
</p>
)
})
FormMessage.displayName = "FormMessage"
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}

View File

@@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

View File

@@ -0,0 +1,24 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@@ -0,0 +1,26 @@
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/utils"
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-2 w-full overflow-hidden rounded-full bg-primary/20",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }

View File

@@ -0,0 +1,46 @@
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }

View File

@@ -0,0 +1,138 @@
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Sheet = SheetPrimitive.Root
const SheetTrigger = SheetPrimitive.Trigger
const SheetClose = SheetPrimitive.Close
const SheetPortal = SheetPrimitive.Portal
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
}
)
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
{children}
</SheetPrimitive.Content>
</SheetPortal>
))
SheetContent.displayName = SheetPrimitive.Content.displayName
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
SheetHeader.displayName = "SheetHeader"
const SheetFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
SheetFooter.displayName = "SheetFooter"
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold text-foreground", className)}
{...props}
/>
))
SheetTitle.displayName = SheetPrimitive.Title.displayName
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
SheetDescription.displayName = SheetPrimitive.Description.displayName
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@@ -0,0 +1,286 @@
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>
)
}

View File

@@ -0,0 +1,15 @@
import { ThemeProvider } from "@/Components/ThemeProvider";
import { Navbar } from "@/Components/Landing/Navbar";
import { Footer } from "@/Components/Landing/Footer";
export default function GuestLayout({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider defaultTheme="dark" storageKey="nihonbuzz-theme">
<div className="min-h-screen bg-background text-foreground selection:bg-primary/30 selection:text-primary">
<Navbar />
<main>{children}</main>
<Footer />
</div>
</ThemeProvider>
);
}

View File

@@ -0,0 +1,58 @@
import InputError from '@/Components/InputError';
import GuestLayout from '@/Layouts/GuestLayout';
import { Head, useForm } from '@inertiajs/react';
import { FormEventHandler } from 'react';
import { Button } from '@/Components/ui/button';
import { Input } from '@/Components/ui/input';
import { Label } from '@/Components/ui/label';
export default function ConfirmPassword() {
const { data, setData, post, processing, errors, reset } = useForm({
password: '',
});
const submit: FormEventHandler = (e) => {
e.preventDefault();
post(route('password.confirm'), {
onFinish: () => reset('password'),
});
};
return (
<GuestLayout>
<Head title="Confirm Password" />
<div className="flex flex-col items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div className="w-full max-w-md bg-white p-8 rounded-3xl shadow-2xl border border-gray-100">
<div className="mb-6 text-sm text-gray-600 dark:text-gray-400">
This is a secure area of the application. Please confirm your
password before continuing.
</div>
<form onSubmit={submit} className="space-y-6">
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
name="password"
value={data.password}
className="block w-full"
onChange={(e) => setData('password', e.target.value)}
/>
<InputError message={errors.password} />
</div>
<div className="flex items-center justify-end">
<Button className="w-full bg-nihonbuzz-red hover:bg-nihonbuzz-red/90 text-white font-bold py-3 rounded-xl" disabled={processing}>
Confirm
</Button>
</div>
</form>
</div>
</div>
</GuestLayout>
);
}

View File

@@ -0,0 +1,63 @@
import InputError from '@/Components/InputError';
import GuestLayout from '@/Layouts/GuestLayout';
import { Head, useForm } from '@inertiajs/react';
import { FormEventHandler } from 'react';
import { Button } from '@/Components/ui/button';
import { Input } from '@/Components/ui/input';
import { Label } from '@/Components/ui/label';
export default function ForgotPassword({ status }: { status?: string }) {
const { data, setData, post, processing, errors } = useForm({
email: '',
});
const submit: FormEventHandler = (e) => {
e.preventDefault();
post(route('password.email'));
};
return (
<GuestLayout>
<Head title="Forgot Password" />
<div className="flex flex-col items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div className="w-full max-w-md bg-white p-8 rounded-3xl shadow-2xl border border-gray-100">
<div className="mb-6 text-sm text-gray-600 dark:text-gray-400">
Forgot your password? No problem. Just let us know your email
address and we will email you a password reset link that will
allow you to choose a new one.
</div>
{status && (
<div className="mb-4 text-sm font-medium text-green-600 dark:text-green-400">
{status}
</div>
)}
<form onSubmit={submit} className="space-y-6">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
name="email"
value={data.email}
className="block w-full"
onChange={(e) => setData('email', e.target.value)}
/>
<InputError message={errors.email} />
</div>
<div className="flex items-center justify-end">
<Button className="w-full bg-nihonbuzz-red hover:bg-nihonbuzz-red/90 text-white font-bold py-3 rounded-xl" disabled={processing}>
Email Password Reset Link
</Button>
</div>
</form>
</div>
</div>
</GuestLayout>
);
}

View File

@@ -0,0 +1,162 @@
import GuestLayout from '@/Layouts/GuestLayout';
import { Head, Link, useForm } from '@inertiajs/react';
import { FormEventHandler } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/Components/ui/card';
import { Label } from '@/Components/ui/label';
import { Input } from '@/Components/ui/input';
import { Button } from '@/Components/ui/button';
import { Checkbox } from '@/Components/ui/checkbox';
import InputError from '@/Components/InputError';
export default function Login({
status,
canResetPassword,
}: {
status?: string;
canResetPassword: boolean;
}) {
const { data, setData, post, processing, errors, reset } = useForm({
email: '',
password: '',
remember: false,
});
const submit: FormEventHandler = (e) => {
e.preventDefault();
post(route('login'), {
onFinish: () => reset('password'),
});
};
return (
<GuestLayout>
<Head title="Log in" />
<div className="flex flex-col items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<Card className="w-full max-w-md border-gray-100 shadow-2xl rounded-3xl overflow-hidden">
<CardHeader className="space-y-1 text-center pt-8">
<CardTitle className="text-3xl font-extrabold tracking-tight">Selamat Datang Kembali</CardTitle>
<CardDescription className="text-gray-500">
Masuk ke akun NihonBuzz Academy anda
</CardDescription>
</CardHeader>
<CardContent className="pb-8">
{status && (
<div className="mb-4 text-sm font-medium text-green-600">
{status}
</div>
)}
<form onSubmit={submit} className="space-y-6">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
name="email"
value={data.email}
className="block w-full"
autoComplete="username"
placeholder="nama@email.com"
onChange={(e) => setData('email', e.target.value)}
/>
<InputError message={errors.email} />
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="password">Password</Label>
{canResetPassword && (
<Link
href={route('password.request')}
className="text-xs text-nihonbuzz-red hover:underline font-medium"
>
Lupa password?
</Link>
)}
</div>
<Input
id="password"
type="password"
name="password"
value={data.password}
className="block w-full"
autoComplete="current-password"
onChange={(e) => setData('password', e.target.value)}
/>
<InputError message={errors.password} />
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="remember"
checked={data.remember}
onCheckedChange={(checked) => setData('remember', checked as boolean)}
/>
<Label htmlFor="remember" className="text-sm text-gray-600 font-normal cursor-pointer">
Ingat saya
</Label>
</div>
<div className="pt-2">
<Button className="w-full py-6 rounded-2xl text-base font-bold shadow-lg shadow-nihonbuzz-red/20 bg-nihonbuzz-red hover:bg-nihonbuzz-red/90" disabled={processing}>
Masuk Sekarang
</Button>
</div>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t border-gray-100" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-white px-2 text-gray-500 font-medium">Atau masuk dengan</span>
</div>
</div>
<div className="grid grid-cols-1 gap-3">
<Button
type="button"
variant="outline"
className="w-full py-6 rounded-2xl text-sm font-bold border-gray-100 hover:bg-gray-50 flex items-center justify-center gap-3 transition-all"
onClick={() => window.location.href = route('social.redirect', { provider: 'google' })}
>
<svg className="w-5 h-5" viewBox="0 0 24 24">
<path
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
fill="#4285F4"
/>
<path
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
fill="#34A853"
/>
<path
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.84z"
fill="#FBBC05"
/>
<path
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
fill="#EA4335"
/>
</svg>
Google
</Button>
</div>
<div className="text-center text-sm text-gray-500">
Belum punya akun?{' '}
<Link
href={route('register')}
className="text-nihonbuzz-red font-bold hover:underline"
>
Daftar Gratis
</Link>
</div>
</form>
</CardContent>
</Card>
</div>
</GuestLayout>
);
}

View File

@@ -0,0 +1,124 @@
import InputError from '@/Components/InputError';
import GuestLayout from '@/Layouts/GuestLayout';
import { Head, Link, useForm } from '@inertiajs/react';
import { FormEventHandler } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/Components/ui/card';
import { Label } from '@/Components/ui/label';
import { Input } from '@/Components/ui/input';
import { Button } from '@/Components/ui/button';
export default function Register() {
const { data, setData, post, processing, errors, reset } = useForm({
name: '',
email: '',
password: '',
password_confirmation: '',
});
const submit: FormEventHandler = (e) => {
e.preventDefault();
post(route('register'), {
onFinish: () => reset('password', 'password_confirmation'),
});
};
return (
<GuestLayout>
<Head title="Register" />
<div className="flex flex-col items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<Card className="w-full max-w-md border-gray-100 shadow-2xl rounded-3xl overflow-hidden">
<CardHeader className="space-y-1 text-center pt-8">
<CardTitle className="text-3xl font-extrabold tracking-tight">Buat Akun Baru</CardTitle>
<CardDescription className="text-gray-500">
Mulai petualangan belajarmu hari ini
</CardDescription>
</CardHeader>
<CardContent className="pb-8">
<form onSubmit={submit} className="space-y-6">
<div className="space-y-2">
<Label htmlFor="name">Nama Lengkap</Label>
<Input
id="name"
name="name"
value={data.name}
className="block w-full"
autoComplete="name"
placeholder="Nama Lengkap Anda"
onChange={(e) => setData('name', e.target.value)}
required
/>
<InputError message={errors.name} />
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
name="email"
value={data.email}
className="block w-full"
autoComplete="username"
placeholder="nama@email.com"
onChange={(e) => setData('email', e.target.value)}
required
/>
<InputError message={errors.email} />
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
name="password"
value={data.password}
className="block w-full"
autoComplete="new-password"
onChange={(e) => setData('password', e.target.value)}
required
/>
<InputError message={errors.password} />
</div>
<div className="space-y-2">
<Label htmlFor="password_confirmation">Konfirmasi</Label>
<Input
id="password_confirmation"
type="password"
name="password_confirmation"
value={data.password_confirmation}
className="block w-full"
autoComplete="new-password"
onChange={(e) => setData('password_confirmation', e.target.value)}
required
/>
<InputError message={errors.password_confirmation} />
</div>
</div>
<div className="pt-2">
<Button className="w-full py-6 rounded-2xl text-base font-bold shadow-lg shadow-nihonbuzz-red/20 bg-nihonbuzz-red hover:bg-nihonbuzz-red/90" disabled={processing}>
Daftar Sekarang
</Button>
</div>
<div className="text-center text-sm text-gray-500">
Sudah punya akun?{' '}
<Link
href={route('login')}
className="text-nihonbuzz-red font-bold hover:underline"
>
Masuk Saja
</Link>
</div>
</form>
</CardContent>
</Card>
</div>
</GuestLayout>
);
}

View File

@@ -0,0 +1,92 @@
import InputError from '@/Components/InputError';
import GuestLayout from '@/Layouts/GuestLayout';
import { Head, useForm } from '@inertiajs/react';
import { FormEventHandler } from 'react';
import { Button } from '@/Components/ui/button';
import { Input } from '@/Components/ui/input';
import { Label } from '@/Components/ui/label';
export default function ResetPassword({
token,
email,
}: {
token: string;
email: string;
}) {
const { data, setData, post, processing, errors, reset } = useForm({
token: token,
email: email,
password: '',
password_confirmation: '',
});
const submit: FormEventHandler = (e) => {
e.preventDefault();
post(route('password.store'), {
onFinish: () => reset('password', 'password_confirmation'),
});
};
return (
<GuestLayout>
<Head title="Reset Password" />
<div className="flex flex-col items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div className="w-full max-w-md bg-white p-8 rounded-3xl shadow-2xl border border-gray-100">
<h2 className="text-2xl font-bold mb-6 text-center">Reset Password</h2>
<form onSubmit={submit} className="space-y-6">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
name="email"
value={data.email}
className="block w-full"
autoComplete="username"
onChange={(e) => setData('email', e.target.value)}
/>
<InputError message={errors.email} />
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
name="password"
value={data.password}
className="block w-full"
autoComplete="new-password"
onChange={(e) => setData('password', e.target.value)}
/>
<InputError message={errors.password} />
</div>
<div className="space-y-2">
<Label htmlFor="password_confirmation">Confirm Password</Label>
<Input
type="password"
name="password_confirmation"
value={data.password_confirmation}
className="block w-full"
autoComplete="new-password"
onChange={(e) => setData('password_confirmation', e.target.value)}
/>
<InputError message={errors.password_confirmation} />
</div>
<div className="flex items-center justify-end">
<Button className="w-full bg-nihonbuzz-red hover:bg-nihonbuzz-red/90 text-white font-bold py-3 rounded-xl" disabled={processing}>
Reset Password
</Button>
</div>
</form>
</div>
</div>
</GuestLayout>
);
}

View File

@@ -0,0 +1,53 @@
import GuestLayout from '@/Layouts/GuestLayout';
import { Head, Link, useForm } from '@inertiajs/react';
import { FormEventHandler } from 'react';
import { Button } from '@/Components/ui/button';
export default function VerifyEmail({ status }: { status?: string }) {
const { post, processing } = useForm({});
const submit: FormEventHandler = (e) => {
e.preventDefault();
post(route('verification.send'));
};
return (
<GuestLayout>
<Head title="Email Verification" />
<div className="flex flex-col items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div className="w-full max-w-md bg-white p-8 rounded-3xl shadow-2xl border border-gray-100 text-center">
<div className="mb-6 text-sm text-gray-600 dark:text-gray-400">
Thanks for signing up! Before getting started, could you verify
your email address by clicking on the link we just emailed to
you? If you didn't receive the email, we will gladly send you
another.
</div>
{status === 'verification-link-sent' && (
<div className="mb-6 text-sm font-medium text-green-600 dark:text-green-400">
A new verification link has been sent to the email address
you provided during registration.
</div>
)}
<form onSubmit={submit} className="space-y-4">
<Button className="w-full bg-nihonbuzz-red hover:bg-nihonbuzz-red/90 text-white font-bold py-3 rounded-xl" disabled={processing}>
Resend Verification Email
</Button>
<Link
href={route('logout')}
method="post"
as="button"
className="text-sm text-gray-600 underline hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:text-gray-400 dark:hover:text-gray-100"
>
Log Out
</Link>
</form>
</div>
</div>
</GuestLayout>
);
}

View File

@@ -0,0 +1,121 @@
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head, Link, useForm } from '@inertiajs/react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from "@/Components/ui/card";
import { Button } from "@/Components/ui/button";
import { Badge } from "@/Components/ui/badge";
import { BookOpen, CheckCircle2, Play } from "lucide-react";
interface Course {
id: string;
title: string;
description: string;
thumbnail: string;
slug: string;
modulesCount: number;
isEnrolled: boolean;
}
interface Level {
id: string;
name: string;
code: string;
courses: Course[];
}
interface Props {
levels: Level[];
}
export default function Library({ levels }: Props) {
return (
<AuthenticatedLayout
header={
<div className="flex flex-col gap-1">
<h2 className="text-2xl font-bold tracking-tight">Galeri Kursus</h2>
<p className="text-muted-foreground text-sm">Pilih materi yang ingin kamu pelajari hari ini.</p>
</div>
}
>
<Head title="Galeri Kursus" />
<div className="space-y-12 animate-in fade-in duration-700">
{levels.length > 0 ? levels.map((level) => (
<div key={level.id} className="space-y-6">
<div className="flex items-center gap-3">
<div className="h-8 w-1 bg-primary rounded-full" />
<h3 className="text-xl font-black tracking-tight">{level.name} ({level.code})</h3>
</div>
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-6">
{level.courses.map((course) => (
<CourseCard key={course.id} course={course} />
))}
</div>
</div>
)) : (
<div className="text-center py-20">
<p className="text-muted-foreground">Belum ada materi tersedia saat ini.</p>
</div>
)}
</div>
</AuthenticatedLayout>
);
}
function CourseCard({ course }: { course: Course }) {
const { post, processing } = useForm();
const handleEnroll = () => {
post(route('courses.enroll', course.slug));
};
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}
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} />
Terdaftar
</Badge>
)}
</div>
<CardHeader className="p-5 flex-1">
<div className="flex items-center gap-2 mb-2">
<Badge variant="outline" className="text-[10px] font-bold uppercase tracking-widest border-primary/20 text-primary">
{course.modulesCount} Materi
</Badge>
</div>
<CardTitle className="text-lg font-bold group-hover:text-primary transition-colors">{course.title}</CardTitle>
<CardDescription className="line-clamp-2 text-xs mt-2 font-medium leading-relaxed">
{course.description}
</CardDescription>
</CardHeader>
<CardFooter className="p-5 pt-0 mt-auto">
{course.isEnrolled ? (
<Button asChild className="w-full rounded-xl font-bold bg-primary/10 hover:bg-primary/20 text-primary shadow-none border-none">
<Link href={route('courses.learn', course.slug)}>
Lanjutkan Belajar <Play className="ml-2 w-3 h-3 fill-current" />
</Link>
</Button>
) : (
<Button
onClick={handleEnroll}
disabled={processing}
className="w-full rounded-xl font-bold bg-primary hover:bg-primary/90 shadow-lg shadow-primary/20"
>
{processing ? 'Memproses...' : 'Daftar Sekarang'}
</Button>
)}
</CardFooter>
</Card>
);
}

View File

@@ -0,0 +1,183 @@
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head, Link, router } from '@inertiajs/react';
import { useState } from 'react';
import { Plyr } from 'plyr-react';
import { CheckCircle2, ChevronLeft, Circle, FileText, Play, Video } from 'lucide-react';
import { Button } from '@/Components/ui/button';
import { Progress } from '@/Components/ui/progress';
interface LessonData {
id: string;
title: string;
slug: string;
type: 'video' | 'text' | 'pdf';
is_completed: boolean;
}
interface ModuleData {
id: string;
title: string;
lessons: LessonData[];
}
interface CourseData {
id: string;
title: string;
slug: string;
modules: ModuleData[];
}
interface CurrentLessonData {
id: string;
title: string;
slug: string;
type: 'video' | 'text' | 'pdf';
content: string | null;
video_url: string | null;
content_pdf: string | null;
}
interface ProgressData {
completed_count: number;
total_count: number;
percentage: number;
}
interface PlayerProps {
course: CourseData;
currentLesson: CurrentLessonData;
progress: ProgressData;
}
// Helper to get YouTube video ID
function getYouTubeId(url: string): string | 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;
}
export default function Player({ course, currentLesson, progress }: PlayerProps) {
const [sidebarOpen, setSidebarOpen] = useState(true);
const handleComplete = () => {
router.post(route('lessons.complete', { lesson: currentLesson.id }));
};
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">
<div className="flex items-center gap-4">
<Link href={route('dashboard')} className="p-2 rounded-xl bg-gray-100 hover:bg-gray-200 transition-colors">
<ChevronLeft className="w-5 h-5 text-gray-600" />
</Link>
<div>
<h2 className="text-lg font-bold text-gray-900 line-clamp-1">{course.title}</h2>
<p className="text-xs text-gray-500">{progress.completed_count}/{progress.total_count} Materi Selesai</p>
</div>
</div>
<Progress value={progress.percentage} className="w-32 h-2" />
</div>
}
>
<Head title={`${currentLesson.title} - ${course.title}`} />
<div className="flex h-[calc(100vh-80px)]">
{/* Main Content Area */}
<div className={`flex-1 overflow-y-auto p-6 lg:p-10 transition-all ${sidebarOpen ? 'lg:mr-80' : ''}`}>
<div className="max-w-4xl mx-auto space-y-8">
{/* Lesson Title */}
<div className="flex items-center gap-3">
{currentLesson.type === 'video' && <Video className="w-6 h-6 text-nihonbuzz-red" />}
{currentLesson.type === 'text' && <FileText className="w-6 h-6 text-blue-500" />}
{currentLesson.type === 'pdf' && <FileText className="w-6 h-6 text-orange-500" />}
<h1 className="text-2xl font-black text-gray-900">{currentLesson.title}</h1>
</div>
{/* Video Player */}
{currentLesson.type === 'video' && videoSource && (
<div className="aspect-video rounded-2xl overflow-hidden shadow-2xl bg-black">
<Plyr source={videoSource} />
</div>
)}
{/* Text Content */}
{currentLesson.type === 'text' && currentLesson.content && (
<div
className="prose prose-lg max-w-none bg-white p-8 rounded-2xl shadow-sm border border-gray-100"
dangerouslySetInnerHTML={{ __html: currentLesson.content }}
/>
)}
{/* PDF Content */}
{currentLesson.type === 'pdf' && currentLesson.content_pdf && (
<div className="aspect-[3/4] w-full">
<iframe
src={currentLesson.content_pdf}
className="w-full h-full rounded-2xl shadow-lg border border-gray-200"
title={currentLesson.title}
/>
</div>
)}
{/* Mark as Complete Button */}
<div className="flex justify-center pt-6">
<Button
onClick={handleComplete}
size="lg"
className="bg-nihonbuzz-red hover:bg-nihonbuzz-red/90 text-white font-bold rounded-full px-10 py-6 text-base shadow-xl shadow-nihonbuzz-red/30"
>
<CheckCircle2 className="w-5 h-5 mr-2" />
Tandai Selesai & Lanjutkan
</Button>
</div>
</div>
</div>
{/* Sidebar - Module & Lesson Navigation */}
<aside className={`fixed right-0 top-16 bottom-0 w-80 bg-white border-l border-gray-100 overflow-y-auto transition-transform ${sidebarOpen ? 'translate-x-0' : 'translate-x-full'} hidden lg:block`}>
<div className="p-6 space-y-6">
<h3 className="text-sm font-black text-gray-400 uppercase tracking-widest">Daftar Materi</h3>
{course.modules.map((module) => (
<div key={module.id} className="space-y-2">
<h4 className="font-bold text-gray-700 text-sm">{module.title}</h4>
<ul className="space-y-1">
{module.lessons.map((lesson) => (
<li key={lesson.id}>
<Link
href={route('courses.learn', { course: course.slug, lesson: lesson.slug })}
className={`flex items-center gap-3 p-3 rounded-xl transition-all text-sm ${
lesson.id === currentLesson.id
? 'bg-nihonbuzz-red/10 text-nihonbuzz-red font-bold'
: 'hover:bg-gray-50 text-gray-600'
}`}
>
{lesson.is_completed ? (
<CheckCircle2 className="w-5 h-5 text-green-500 shrink-0" />
) : lesson.id === currentLesson.id ? (
<Play className="w-5 h-5 text-nihonbuzz-red shrink-0 fill-current" />
) : (
<Circle className="w-5 h-5 text-gray-300 shrink-0" />
)}
<span className="line-clamp-1">{lesson.title}</span>
</Link>
</li>
))}
</ul>
</div>
))}
</div>
</aside>
</div>
</AuthenticatedLayout>
);
}

View File

@@ -0,0 +1,186 @@
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head, Link } from '@inertiajs/react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } 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';
interface DashboardProps {
stats: {
xp_points: number;
current_streak: number;
active_courses: number;
certificates: number;
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;
};
}
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' }
}: 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 };
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>
}
>
<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="border-border/50 bg-card/50 backdrop-blur-sm">
<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="border-border/50 bg-card/50 backdrop-blur-sm">
<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="border-border/50 bg-card/50 backdrop-blur-sm">
<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="border-border/50 bg-card/50 backdrop-blur-sm">
<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 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>
</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" />
</Button>
</CardContent>
</Card>
</div>
</div>
</div>
</AuthenticatedLayout>
);
}

View File

@@ -0,0 +1,43 @@
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { PageProps } from '@/types';
import { Head } from '@inertiajs/react';
import DeleteUserForm from './Partials/DeleteUserForm';
import UpdatePasswordForm from './Partials/UpdatePasswordForm';
import UpdateProfileInformationForm from './Partials/UpdateProfileInformationForm';
export default function Edit({
mustVerifyEmail,
status,
}: PageProps<{ mustVerifyEmail: boolean; status?: string }>) {
return (
<AuthenticatedLayout
header={
<h2 className="text-xl font-semibold leading-tight text-gray-800 dark:text-gray-200">
Profile
</h2>
}
>
<Head title="Profile" />
<div className="py-12">
<div className="mx-auto max-w-7xl space-y-6 sm:px-6 lg:px-8">
<div className="bg-white p-4 shadow sm:rounded-lg sm:p-8 dark:bg-gray-800">
<UpdateProfileInformationForm
mustVerifyEmail={mustVerifyEmail}
status={status}
className="max-w-xl"
/>
</div>
<div className="bg-white p-4 shadow sm:rounded-lg sm:p-8 dark:bg-gray-800">
<UpdatePasswordForm className="max-w-xl" />
</div>
<div className="bg-white p-4 shadow sm:rounded-lg sm:p-8 dark:bg-gray-800">
<DeleteUserForm className="max-w-xl" />
</div>
</div>
</div>
</AuthenticatedLayout>
);
}

View File

@@ -0,0 +1,117 @@
import InputError from '@/Components/InputError';
import { useForm } from '@inertiajs/react';
import { FormEventHandler, useRef, useState } from 'react';
import { Button } from '@/Components/ui/button';
import { Input } from '@/Components/ui/input';
import { Label } from '@/Components/ui/label';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/Components/ui/dialog"
export default function DeleteUserForm({
className = '',
}: {
className?: string;
}) {
const [confirmingUserDeletion, setConfirmingUserDeletion] = useState(false);
const passwordInput = useRef<HTMLInputElement>(null);
const {
data,
setData,
delete: destroy,
processing,
reset,
errors,
clearErrors,
} = useForm({
password: '',
});
const confirmUserDeletion = () => {
setConfirmingUserDeletion(true);
};
const deleteUser: FormEventHandler = (e) => {
e.preventDefault();
destroy(route('profile.destroy'), {
preserveScroll: true,
onSuccess: () => closeModal(),
onError: () => passwordInput.current?.focus(),
onFinish: () => reset(),
});
};
const closeModal = () => {
setConfirmingUserDeletion(false);
clearErrors();
reset();
};
return (
<section className={`space-y-6 ${className}`}>
<header>
<h2 className="text-lg font-medium text-gray-900 dark:text-gray-100">
Delete Account
</h2>
<p className="mt-1 text-sm text-gray-600 dark:text-gray-400">
Once your account is deleted, all of its resources and data
will be permanently deleted. Before deleting your account,
please download any data or information that you wish to
retain.
</p>
</header>
<Button variant="destructive" onClick={confirmUserDeletion}>
Delete Account
</Button>
<Dialog open={confirmingUserDeletion} onOpenChange={setConfirmingUserDeletion}>
<DialogContent>
<DialogHeader>
<DialogTitle>Are you sure you want to delete your account?</DialogTitle>
<DialogDescription>
Once your account is deleted, all of its resources and
data will be permanently deleted. Please enter your
password to confirm you would like to permanently delete
your account.
</DialogDescription>
</DialogHeader>
<form onSubmit={deleteUser} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="password" className="sr-only">Password</Label>
<Input
id="password"
type="password"
name="password"
ref={passwordInput}
value={data.password}
onChange={(e) => setData('password', e.target.value)}
className="block w-full"
placeholder="Password"
/>
<InputError message={errors.password} />
</div>
<DialogFooter>
<Button type="button" variant="secondary" onClick={closeModal}>
Cancel
</Button>
<Button variant="destructive" disabled={processing}>
Delete Account
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</section>
);
}

View File

@@ -0,0 +1,124 @@
import InputError from '@/Components/InputError';
import { Transition } from '@headlessui/react';
import { useForm } from '@inertiajs/react';
import { FormEventHandler, useRef } from 'react';
import { Button } from '@/Components/ui/button';
import { Input } from '@/Components/ui/input';
import { Label } from '@/Components/ui/label';
export default function UpdatePasswordForm({
className = '',
}: {
className?: string;
}) {
const passwordInput = useRef<HTMLInputElement>(null);
const currentPasswordInput = useRef<HTMLInputElement>(null);
const {
data,
setData,
errors,
put,
reset,
processing,
recentlySuccessful,
} = useForm({
current_password: '',
password: '',
password_confirmation: '',
});
const updatePassword: FormEventHandler = (e) => {
e.preventDefault();
put(route('password.update'), {
preserveScroll: true,
onSuccess: () => reset(),
onError: (errors) => {
if (errors.password) {
reset('password', 'password_confirmation');
passwordInput.current?.focus();
}
if (errors.current_password) {
reset('current_password');
currentPasswordInput.current?.focus();
}
},
});
};
return (
<section className={className}>
<header>
<h2 className="text-lg font-medium text-gray-900 dark:text-gray-100">
Update Password
</h2>
<p className="mt-1 text-sm text-gray-600 dark:text-gray-400">
Ensure your account is using a long, random password to stay
secure.
</p>
</header>
<form onSubmit={updatePassword} className="mt-6 space-y-6 max-w-xl">
<div className="space-y-2">
<Label htmlFor="current_password">Current Password</Label>
<Input
id="current_password"
ref={currentPasswordInput}
value={data.current_password}
onChange={(e) => setData('current_password', e.target.value)}
type="password"
className="block w-full"
autoComplete="current-password"
/>
<InputError message={errors.current_password} />
</div>
<div className="space-y-2">
<Label htmlFor="password">New Password</Label>
<Input
id="password"
ref={passwordInput}
value={data.password}
onChange={(e) => setData('password', e.target.value)}
type="password"
className="block w-full"
autoComplete="new-password"
/>
<InputError message={errors.password} />
</div>
<div className="space-y-2">
<Label htmlFor="password_confirmation">Confirm Password</Label>
<Input
id="password_confirmation"
value={data.password_confirmation}
onChange={(e) => setData('password_confirmation', e.target.value)}
type="password"
className="block w-full"
autoComplete="new-password"
/>
<InputError message={errors.password_confirmation} />
</div>
<div className="flex items-center gap-4">
<Button disabled={processing} className="bg-nihonbuzz-red hover:bg-nihonbuzz-red/90 text-white font-bold">Save</Button>
<Transition
show={recentlySuccessful}
enter="transition ease-in-out"
enterFrom="opacity-0"
leave="transition ease-in-out"
leaveTo="opacity-0"
>
<p className="text-sm text-gray-600 dark:text-gray-400">
Saved.
</p>
</Transition>
</div>
</form>
</section>
);
}

View File

@@ -0,0 +1,114 @@
import InputError from '@/Components/InputError';
import { Transition } from '@headlessui/react';
import { Link, useForm, usePage } from '@inertiajs/react';
import { FormEventHandler } from 'react';
import { Button } from '@/Components/ui/button';
import { Input } from '@/Components/ui/input';
import { Label } from '@/Components/ui/label';
export default function UpdateProfileInformation({
mustVerifyEmail,
status,
className = '',
}: {
mustVerifyEmail: boolean;
status?: string;
className?: string;
}) {
const user = usePage().props.auth.user;
const { data, setData, patch, errors, processing, recentlySuccessful } =
useForm({
name: user.name,
email: user.email,
});
const submit: FormEventHandler = (e) => {
e.preventDefault();
patch(route('profile.update'));
};
return (
<section className={className}>
<header>
<h2 className="text-lg font-medium text-gray-900 dark:text-gray-100">
Profile Information
</h2>
<p className="mt-1 text-sm text-gray-600 dark:text-gray-400">
Update your account's profile information and email address.
</p>
</header>
<form onSubmit={submit} className="mt-6 space-y-6 max-w-xl">
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
type="text"
value={data.name}
onChange={(e) => setData('name', e.target.value)}
required
autoComplete="name"
className="block w-full"
/>
<InputError message={errors.name} />
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
value={data.email}
onChange={(e) => setData('email', e.target.value)}
required
autoComplete="username"
className="block w-full"
/>
<InputError message={errors.email} />
</div>
{mustVerifyEmail && user.email_verified_at === null && (
<div>
<p className="mt-2 text-sm text-gray-800 dark:text-gray-200">
Your email address is unverified.
<Link
href={route('verification.send')}
method="post"
as="button"
className="rounded-md text-sm text-gray-600 underline hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:text-gray-400 dark:hover:text-gray-100 dark:focus:ring-offset-gray-800"
>
Click here to re-send the verification email.
</Link>
</p>
{status === 'verification-link-sent' && (
<div className="mt-2 text-sm font-medium text-green-600 dark:text-green-400">
A new verification link has been sent to your
email address.
</div>
)}
</div>
)}
<div className="flex items-center gap-4">
<Button disabled={processing} className="bg-nihonbuzz-red hover:bg-nihonbuzz-red/90 text-white font-bold">Save</Button>
<Transition
show={recentlySuccessful}
enter="transition ease-in-out"
enterFrom="opacity-0"
leave="transition ease-in-out"
leaveTo="opacity-0"
>
<p className="text-sm text-gray-600 dark:text-gray-400">
Saved.
</p>
</Transition>
</div>
</form>
</section>
);
}

View File

@@ -0,0 +1,88 @@
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head, Link } from '@inertiajs/react';
import { Button } from '@/Components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/Components/ui/card';
import { BookOpen, Brain, TrendingUp } from 'lucide-react';
interface SrsStats {
due: number;
new: number;
total_learned: number;
}
export default function SrsIndex({ stats }: { stats: SrsStats }) {
return (
<AuthenticatedLayout
header={
<h2 className="text-xl font-semibold leading-tight text-gray-800 dark:text-gray-200">
Vocabulary SRS
</h2>
}
>
<Head title="Vocabulary Flashcards" />
<div className="py-12">
<div className="mx-auto max-w-7xl sm:px-6 lg:px-8">
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3 mb-8">
{/* Due Reviews Card */}
<Card className="bg-orange-50 border-orange-200">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-orange-900">
Review Due
</CardTitle>
<Brain className="h-4 w-4 text-orange-600" />
</CardHeader>
<CardContent>
<div className="text-4xl font-bold text-orange-700">{stats.due}</div>
<p className="text-xs text-orange-600 mt-1">Cards ready to review</p>
</CardContent>
</Card>
{/* New Cards Card */}
<Card className="bg-blue-50 border-blue-200">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-blue-900">
New Cards
</CardTitle>
<BookOpen className="h-4 w-4 text-blue-600" />
</CardHeader>
<CardContent>
<div className="text-4xl font-bold text-blue-700">{stats.new}</div>
<p className="text-xs text-blue-600 mt-1">Ready to learn</p>
</CardContent>
</Card>
{/* Total Learned Card */}
<Card className="bg-green-50 border-green-200">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-green-900">
Total Learned
</CardTitle>
<TrendingUp className="h-4 w-4 text-green-600" />
</CardHeader>
<CardContent>
<div className="text-4xl font-bold text-green-700">{stats.total_learned}</div>
<p className="text-xs text-green-600 mt-1">Vocabulary mastered</p>
</CardContent>
</Card>
</div>
<div className="flex justify-center">
{stats.due + stats.new > 0 ? (
<Link href={route('srs.practice')}>
<Button size="lg" className="w-full md:w-auto px-12 py-6 text-lg font-bold bg-nihonbuzz-red hover:bg-nihonbuzz-red/90 shadow-xl shadow-nihonbuzz-red/20 rounded-2xl transition-all hover:scale-105 active:scale-95">
Start Session ({stats.due + stats.new})
</Button>
</Link>
) : (
<div className="text-center p-12 bg-gray-50 rounded-3xl border border-gray-100">
<h3 className="text-xl font-bold text-gray-800 mb-2">All Caught Up! 🎉</h3>
<p className="text-gray-500">You have no cards due for review. Come back later!</p>
</div>
)}
</div>
</div>
</div>
</AuthenticatedLayout>
);
}

View File

@@ -0,0 +1,242 @@
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head, router } from '@inertiajs/react';
import { Button } from '@/Components/ui/button';
import { Card } from '@/Components/ui/card';
import { useState, useEffect, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Check, X, Volume2, RotateCw } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Progress } from '@/Components/ui/progress';
interface ReviewItem {
type: 'review' | 'new';
id: string; // Vocabulary ID
srs_id?: string;
word: string;
reading: string;
meaning: string;
audio_url?: string;
}
export default function SrsPractice({ items }: { items: ReviewItem[] }) {
const [currentIndex, setCurrentIndex] = useState(0);
const [isFlipped, setIsFlipped] = useState(false);
const [sessionQueue, setSessionQueue] = useState(items);
const [completedCount, setCompletedCount] = useState(0);
const currentItem = sessionQueue[currentIndex];
const isFinished = !currentItem;
const progress = items.length > 0 ? (completedCount / items.length) * 100 : 0;
const handleFlip = useCallback(() => {
if (!isFlipped && !isFinished) {
setIsFlipped(true);
if (currentItem?.audio_url) {
new Audio(currentItem.audio_url).play().catch(() => {});
}
}
}, [isFlipped, isFinished, currentItem]);
const handleGrade = useCallback((grade: number) => {
if (!isFlipped || isFinished) return;
// Optimistic UI update
const itemToSubmit = currentItem;
// Submit in background
router.post(route('srs.store'), {
vocabulary_id: itemToSubmit.id,
grade: grade
}, {
preserveScroll: true,
preserveState: true, // Keep our local state intact
onSuccess: () => {
// Determine if we should requeue (Again/1) or remove
// For simplicity in this version, we move to next regardless,
// but real apps might requeue failed cards.
}
});
// Move to next
setIsFlipped(false);
setCompletedCount(prev => prev + 1);
setCurrentIndex(prev => prev + 1);
}, [isFlipped, isFinished, currentItem]);
// Keyboard Shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (isFinished) return;
if (e.code === 'Space') {
e.preventDefault();
if (!isFlipped) handleFlip();
} else if (isFlipped) {
if (e.key === '1') handleGrade(1);
if (e.key === '2') handleGrade(2);
if (e.key === '3') handleGrade(3);
if (e.key === '4') handleGrade(4);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [handleFlip, handleGrade, isFinished, isFlipped]);
return (
<AuthenticatedLayout 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">
SRS Practice
</h2>
<p className="text-xs text-muted-foreground font-medium">Power up your Japanese memory</p>
</div>
<div className="w-1/3 lg:w-1/4">
<div className="flex justify-between items-end mb-1.5">
<span className="text-[10px] font-bold uppercase tracking-widest text-primary">Progress</span>
<span className="text-[10px] font-bold text-muted-foreground">{completedCount} / {items.length}</span>
</div>
<Progress value={progress} className="h-1.5 bg-primary/10" />
</div>
</div>
}>
<Head title="SRS Practice" />
<div className="max-w-4xl mx-auto w-full py-8 lg:py-12 flex flex-col items-center min-h-[70vh]">
<AnimatePresence mode="wait">
{isFinished ? (
<motion.div
initial={{ opacity: 0, scale: 0.9, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
className="text-center w-full max-w-md"
>
<Card className="bg-card/40 backdrop-blur-2xl border-border/40 p-12 rounded-[3rem] shadow-2xl flex flex-col items-center relative overflow-hidden">
<div className="absolute top-0 inset-x-0 h-2 bg-gradient-to-r from-transparent via-green-500 to-transparent opacity-50" />
<div className="h-24 w-24 bg-green-500/10 text-green-500 rounded-full flex items-center justify-center mb-8 ring-8 ring-green-500/5">
<Check size={48} strokeWidth={3} />
</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"
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
</Button>
</Card>
</motion.div>
) : (
<div className="w-full max-w-2xl px-4 flex flex-col items-center">
{/* Flashcard Area */}
<div
className="w-full aspect-[16/10] sm:aspect-[16/9] perspective-2000 cursor-pointer group mb-12"
onClick={handleFlip}
>
<motion.div
className="relative w-full h-full transform-style-3d shadow-2xl rounded-[3rem]"
animate={{ rotateY: isFlipped ? 180 : 0 }}
transition={{ type: "spring", stiffness: 150, damping: 25 }}
>
{/* Front Side */}
<Card className="absolute w-full h-full backface-hidden flex flex-col items-center justify-center p-12 bg-card/40 backdrop-blur-3xl border border-white/20 dark:border-white/5 rounded-[3rem] shadow-[0_32px_64px_-16px_rgba(0,0,0,0.1)]">
<div className="absolute top-8 left-1/2 -translate-x-1/2 px-4 py-1 rounded-full bg-primary/10 border border-primary/20">
<span className="text-[10px] font-black text-primary uppercase tracking-[0.2em]">
{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}
</h1>
<div className="mt-8 flex items-center gap-2 text-muted-foreground/40 font-bold text-xs uppercase tracking-widest animate-pulse">
<span>Klik untuk Membalik</span>
<RotateCw className="w-3 h-3" />
</div>
</div>
</Card>
{/* 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}
</p>
<p className="text-sm font-medium text-muted-foreground/60 italic">Kosa Kata N5</p>
</div>
{currentItem.audio_url && (
<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} />
</Button>
)}
</div>
</Card>
</motion.div>
</div>
{/* Controls */}
<div className={cn(
"flex flex-wrap justify-center gap-4 w-full transition-all duration-500",
isFlipped ? "opacity-100 translate-y-0 pointer-events-auto" : "opacity-0 translate-y-8 pointer-events-none"
)}>
{[
{ val: 1, label: 'Again', desc: 'Lupakan', color: 'red' },
{ val: 2, label: 'Hard', desc: 'Sulit', color: 'orange' },
{ val: 3, label: 'Good', desc: 'Bagus', color: 'blue' },
{ val: 4, label: 'Easy', desc: 'Mudah', color: 'green' }
].map((btn) => (
<Button
key={btn.val}
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",
btn.color === 'orange' && "border-orange-500/20 bg-orange-500/5 hover:bg-orange-500/10 text-orange-600 hover:border-orange-500/40",
btn.color === 'blue' && "border-blue-500/20 bg-blue-500/5 hover:bg-blue-500/10 text-blue-600 hover:border-blue-500/40",
btn.color === 'green' && "border-green-500/20 bg-green-500/5 hover:bg-green-500/10 text-green-600 hover:border-green-500/40"
)}
onClick={() => handleGrade(btn.val)}
>
<span className="text-base font-black uppercase tracking-wider">{btn.label}</span>
<span className="text-[10px] font-bold opacity-60 uppercase tracking-tighter">{btn.desc}</span>
</Button>
))}
</div>
{/* Keyboard Tips */}
<div className={cn(
"mt-8 text-center transition-opacity duration-1000",
isFlipped ? "opacity-40" : "opacity-0"
)}>
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
Tips: Gunakan angka <span className="text-foreground">1 - 4</span> di keyboard Anda
</p>
</div>
</div>
)}
</AnimatePresence>
</div>
</AuthenticatedLayout>
);
}

View File

@@ -0,0 +1,13 @@
import { PageProps } from '@/types';
import { Head } from '@inertiajs/react';
import GuestLayout from '@/Layouts/GuestLayout';
import { Hero } from '@/Components/Landing/Hero';
export default function Welcome({ auth }: PageProps) {
return (
<GuestLayout>
<Head title="Ecosystem Belajar Bahasa Jepang Modern" />
<Hero />
</GuestLayout>
);
}

30
resources/js/app.tsx Normal file
View File

@@ -0,0 +1,30 @@
import '../css/app.css';
import './bootstrap';
import { ThemeProvider } from "@/Components/ThemeProvider";
import { createInertiaApp } from '@inertiajs/react';
import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
import { createRoot } from 'react-dom/client';
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
createInertiaApp({
title: (title) => `${title} - ${appName}`,
resolve: (name) =>
resolvePageComponent(
`./Pages/${name}.tsx`,
import.meta.glob('./Pages/**/*.tsx'),
),
setup({ el, App, props }) {
const root = createRoot(el);
root.render(
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
<App {...props} />
</ThemeProvider>
);
},
progress: {
color: '#4B5563',
},
});

View File

@@ -0,0 +1,4 @@
import axios from 'axios';
window.axios = axios;
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';

View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

17
resources/js/types/global.d.ts vendored Normal file
View File

@@ -0,0 +1,17 @@
import { PageProps as InertiaPageProps } from '@inertiajs/core';
import { AxiosInstance } from 'axios';
import { route as ziggyRoute } from 'ziggy-js';
import { PageProps as AppPageProps } from './';
declare global {
interface Window {
axios: AxiosInstance;
}
/* eslint-disable no-var */
var route: typeof ziggyRoute;
}
declare module '@inertiajs/core' {
interface PageProps extends InertiaPageProps, AppPageProps {}
}

15
resources/js/types/index.d.ts vendored Normal file
View File

@@ -0,0 +1,15 @@
export interface User {
id: number;
name: string;
email: string;
email_verified_at?: string;
avatar?: string;
}
export type PageProps<
T extends Record<string, unknown> = Record<string, unknown>,
> = T & {
auth: {
user: User;
};
};

1
resources/js/types/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,42 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title inertia>{{ config('app.name', 'Laravel') }}</title>
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,200..800;1,200..800&display=swap" rel="stylesheet">
<!-- Favicons -->
<link rel="apple-touch-icon" sizes="57x57" href="/apple-icon-57x57.png">
<link rel="apple-touch-icon" sizes="60x60" href="/apple-icon-60x60.png">
<link rel="apple-touch-icon" sizes="72x72" href="/apple-icon-72x72.png">
<link rel="apple-touch-icon" sizes="76x76" href="/apple-icon-76x76.png">
<link rel="apple-touch-icon" sizes="114x114" href="/apple-icon-114x114.png">
<link rel="apple-touch-icon" sizes="120x120" href="/apple-icon-120x120.png">
<link rel="apple-touch-icon" sizes="144x144" href="/apple-icon-144x144.png">
<link rel="apple-touch-icon" sizes="152x152" href="/apple-icon-152x152.png">
<link rel="apple-touch-icon" sizes="180x180" href="/apple-icon-180x180.png">
<link rel="icon" type="image/png" sizes="192x192" href="/android-icon-192x192.png">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="96x96" href="/favicon-96x96.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<link rel="manifest" href="/manifest.json">
<meta name="msapplication-TileColor" content="#ffffff">
<meta name="msapplication-TileImage" content="/ms-icon-144x144.png">
<meta name="theme-color" content="#ffffff">
<!-- Scripts -->
@routes
@viteReactRefresh
@vite(['resources/js/app.tsx', "resources/js/Pages/{$page['component']}.tsx"])
@inertiaHead
</head>
<body class="font-sans antialiased">
@inertia
</body>
</html>