mirror of
https://github.com/nihonbuzz/nihonbuzz-academy.git
synced 2026-01-26 05:25:37 +07:00
first commit
This commit is contained in:
BIN
resources/assets/logo-white.png
Normal file
BIN
resources/assets/logo-white.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 118 KiB |
99
resources/css/app.css
Normal file
99
resources/css/app.css
Normal 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;
|
||||
}
|
||||
|
||||
12
resources/js/Components/ApplicationLogo.tsx
Normal file
12
resources/js/Components/ApplicationLogo.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
34
resources/js/Components/Checkbox.tsx
Normal file
34
resources/js/Components/Checkbox.tsx
Normal 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
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
76
resources/js/Components/CourseCard.tsx
Normal file
76
resources/js/Components/CourseCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
resources/js/Components/InputError.tsx
Normal file
16
resources/js/Components/InputError.tsx
Normal 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;
|
||||
}
|
||||
66
resources/js/Components/Landing/Footer.tsx
Normal file
66
resources/js/Components/Landing/Footer.tsx
Normal 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">
|
||||
© {new Date().getFullYear()} NihonBuzz Academy. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
91
resources/js/Components/Landing/Hero.tsx
Normal file
91
resources/js/Components/Landing/Hero.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
137
resources/js/Components/Landing/Navbar.tsx
Normal file
137
resources/js/Components/Landing/Navbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
resources/js/Components/ModeToggle.tsx
Normal file
36
resources/js/Components/ModeToggle.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
23
resources/js/Components/NavLink.tsx
Normal file
23
resources/js/Components/NavLink.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
resources/js/Components/ResponsiveNavLink.tsx
Normal file
21
resources/js/Components/ResponsiveNavLink.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
72
resources/js/Components/ThemeProvider.tsx
Normal file
72
resources/js/Components/ThemeProvider.tsx
Normal 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
|
||||
}
|
||||
50
resources/js/Components/ui/avatar.tsx
Normal file
50
resources/js/Components/ui/avatar.tsx
Normal 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 }
|
||||
36
resources/js/Components/ui/badge.tsx
Normal file
36
resources/js/Components/ui/badge.tsx
Normal 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 }
|
||||
57
resources/js/Components/ui/button.tsx
Normal file
57
resources/js/Components/ui/button.tsx
Normal 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 }
|
||||
76
resources/js/Components/ui/card.tsx
Normal file
76
resources/js/Components/ui/card.tsx
Normal 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 }
|
||||
28
resources/js/Components/ui/checkbox.tsx
Normal file
28
resources/js/Components/ui/checkbox.tsx
Normal 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 }
|
||||
122
resources/js/Components/ui/dialog.tsx
Normal file
122
resources/js/Components/ui/dialog.tsx
Normal 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,
|
||||
}
|
||||
201
resources/js/Components/ui/dropdown-menu.tsx
Normal file
201
resources/js/Components/ui/dropdown-menu.tsx
Normal 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,
|
||||
}
|
||||
176
resources/js/Components/ui/form.tsx
Normal file
176
resources/js/Components/ui/form.tsx
Normal 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,
|
||||
}
|
||||
22
resources/js/Components/ui/input.tsx
Normal file
22
resources/js/Components/ui/input.tsx
Normal 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 }
|
||||
24
resources/js/Components/ui/label.tsx
Normal file
24
resources/js/Components/ui/label.tsx
Normal 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 }
|
||||
26
resources/js/Components/ui/progress.tsx
Normal file
26
resources/js/Components/ui/progress.tsx
Normal 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 }
|
||||
46
resources/js/Components/ui/scroll-area.tsx
Normal file
46
resources/js/Components/ui/scroll-area.tsx
Normal 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 }
|
||||
138
resources/js/Components/ui/sheet.tsx
Normal file
138
resources/js/Components/ui/sheet.tsx
Normal 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,
|
||||
}
|
||||
286
resources/js/Layouts/AuthenticatedLayout.tsx
Normal file
286
resources/js/Layouts/AuthenticatedLayout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
15
resources/js/Layouts/GuestLayout.tsx
Normal file
15
resources/js/Layouts/GuestLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
58
resources/js/Pages/Auth/ConfirmPassword.tsx
Normal file
58
resources/js/Pages/Auth/ConfirmPassword.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
63
resources/js/Pages/Auth/ForgotPassword.tsx
Normal file
63
resources/js/Pages/Auth/ForgotPassword.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
162
resources/js/Pages/Auth/Login.tsx
Normal file
162
resources/js/Pages/Auth/Login.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
124
resources/js/Pages/Auth/Register.tsx
Normal file
124
resources/js/Pages/Auth/Register.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
92
resources/js/Pages/Auth/ResetPassword.tsx
Normal file
92
resources/js/Pages/Auth/ResetPassword.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
resources/js/Pages/Auth/VerifyEmail.tsx
Normal file
53
resources/js/Pages/Auth/VerifyEmail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
121
resources/js/Pages/Courses/Library.tsx
Normal file
121
resources/js/Pages/Courses/Library.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
183
resources/js/Pages/Courses/Player.tsx
Normal file
183
resources/js/Pages/Courses/Player.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
186
resources/js/Pages/Dashboard.tsx
Normal file
186
resources/js/Pages/Dashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
43
resources/js/Pages/Profile/Edit.tsx
Normal file
43
resources/js/Pages/Profile/Edit.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
117
resources/js/Pages/Profile/Partials/DeleteUserForm.tsx
Normal file
117
resources/js/Pages/Profile/Partials/DeleteUserForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
124
resources/js/Pages/Profile/Partials/UpdatePasswordForm.tsx
Normal file
124
resources/js/Pages/Profile/Partials/UpdatePasswordForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
88
resources/js/Pages/Srs/Index.tsx
Normal file
88
resources/js/Pages/Srs/Index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
242
resources/js/Pages/Srs/Practice.tsx
Normal file
242
resources/js/Pages/Srs/Practice.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
13
resources/js/Pages/Welcome.tsx
Normal file
13
resources/js/Pages/Welcome.tsx
Normal 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
30
resources/js/app.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
4
resources/js/bootstrap.ts
Normal file
4
resources/js/bootstrap.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import axios from 'axios';
|
||||
window.axios = axios;
|
||||
|
||||
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
|
||||
6
resources/js/lib/utils.ts
Normal file
6
resources/js/lib/utils.ts
Normal 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
17
resources/js/types/global.d.ts
vendored
Normal 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
15
resources/js/types/index.d.ts
vendored
Normal 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
1
resources/js/types/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
42
resources/views/app.blade.php
Normal file
42
resources/views/app.blade.php
Normal 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>
|
||||
Reference in New Issue
Block a user