Changes
This commit is contained in:
246
src/components/AppLayout.tsx
Normal file
246
src/components/AppLayout.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
import { ReactNode, useState } from 'react';
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useCart } from '@/contexts/CartContext';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Package,
|
||||
BookOpen,
|
||||
ShoppingCart,
|
||||
Receipt,
|
||||
User,
|
||||
Settings,
|
||||
Users,
|
||||
Calendar,
|
||||
LogOut,
|
||||
Menu,
|
||||
Home,
|
||||
MoreHorizontal,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface NavItem {
|
||||
label: string;
|
||||
href: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
}
|
||||
|
||||
const userNavItems: NavItem[] = [
|
||||
{ label: 'Dashboard', href: '/dashboard', icon: LayoutDashboard },
|
||||
{ label: 'Produk', href: '/products', icon: Package },
|
||||
{ label: 'Akses', href: '/access', icon: BookOpen },
|
||||
{ label: 'Order', href: '/orders', icon: Receipt },
|
||||
{ label: 'Profil', href: '/profile', icon: User },
|
||||
];
|
||||
|
||||
const adminNavItems: NavItem[] = [
|
||||
{ label: 'Dashboard', href: '/admin', icon: LayoutDashboard },
|
||||
{ label: 'Produk', href: '/admin/products', icon: Package },
|
||||
{ label: 'Bootcamp', href: '/admin/bootcamp', icon: BookOpen },
|
||||
{ label: 'Order', href: '/admin/orders', icon: Receipt },
|
||||
{ label: 'Member', href: '/admin/members', icon: Users },
|
||||
{ label: 'Kalender', href: '/admin/events', icon: Calendar },
|
||||
{ label: 'Pengaturan', href: '/admin/settings', icon: Settings },
|
||||
];
|
||||
|
||||
const mobileUserNav: NavItem[] = [
|
||||
{ label: 'Home', href: '/dashboard', icon: Home },
|
||||
{ label: 'Kelas', href: '/access', icon: BookOpen },
|
||||
{ label: 'Pesanan', href: '/orders', icon: Receipt },
|
||||
{ label: 'Profil', href: '/profile', icon: User },
|
||||
];
|
||||
|
||||
const mobileAdminNav: NavItem[] = [
|
||||
{ label: 'Dashboard', href: '/admin', icon: LayoutDashboard },
|
||||
{ label: 'Produk', href: '/admin/products', icon: Package },
|
||||
{ label: 'Pesanan', href: '/admin/orders', icon: Receipt },
|
||||
{ label: 'Pengguna', href: '/admin/members', icon: Users },
|
||||
];
|
||||
|
||||
interface AppLayoutProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function AppLayout({ children }: AppLayoutProps) {
|
||||
const { user, isAdmin, signOut } = useAuth();
|
||||
const { items } = useCart();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
|
||||
const navItems = isAdmin ? adminNavItems : userNavItems;
|
||||
const mobileNav = isAdmin ? mobileAdminNav : mobileUserNav;
|
||||
|
||||
const handleSignOut = async () => {
|
||||
await signOut();
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
const isActive = (href: string) => {
|
||||
if (href === '/dashboard' && location.pathname === '/dashboard') return true;
|
||||
if (href === '/admin' && location.pathname === '/admin') return true;
|
||||
if (href !== '/dashboard' && href !== '/admin') {
|
||||
return location.pathname.startsWith(href);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Get additional items for "More" menu
|
||||
const moreItems = navItems.filter(item => !mobileNav.some(m => m.href === item.href));
|
||||
|
||||
if (!user) {
|
||||
// Public layout for non-authenticated pages
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<header className="border-b-2 border-border bg-background sticky top-0 z-50">
|
||||
<div className="container mx-auto px-4 py-4 flex items-center justify-between">
|
||||
<Link to="/" className="text-2xl font-bold">LearnHub</Link>
|
||||
<nav className="flex items-center gap-4">
|
||||
<Link to="/products" className="hover:underline font-medium">Produk</Link>
|
||||
<Link to="/events" className="hover:underline font-medium">Kalender</Link>
|
||||
<Link to="/auth">
|
||||
<Button variant="outline" size="sm" className="border-2">
|
||||
<User className="w-4 h-4 mr-2" />
|
||||
Login
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to="/checkout">
|
||||
<Button variant="outline" size="sm" className="relative border-2">
|
||||
<ShoppingCart className="w-4 h-4" />
|
||||
{items.length > 0 && (
|
||||
<span className="absolute -top-2 -right-2 bg-primary text-primary-foreground text-xs w-5 h-5 flex items-center justify-center">
|
||||
{items.length}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<main>{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex">
|
||||
{/* Desktop Sidebar */}
|
||||
<aside className="hidden md:flex flex-col w-64 border-r-2 border-border bg-sidebar fixed h-screen">
|
||||
<div className="p-4 border-b-2 border-border">
|
||||
<Link to="/" className="text-xl font-bold">LearnHub</Link>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 p-4 space-y-1 overflow-y-auto">
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
to={item.href}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-3 py-2 rounded-none text-sm font-medium transition-colors",
|
||||
isActive(item.href)
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
<item.icon className="w-5 h-5" />
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="p-4 border-t-2 border-border space-y-2">
|
||||
<Link to="/checkout" className="flex items-center gap-3 px-3 py-2 hover:bg-muted text-sm font-medium">
|
||||
<ShoppingCart className="w-5 h-5" />
|
||||
Keranjang
|
||||
{items.length > 0 && (
|
||||
<span className="ml-auto bg-primary text-primary-foreground text-xs px-2 py-0.5">
|
||||
{items.length}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleSignOut}
|
||||
className="flex items-center gap-3 px-3 py-2 hover:bg-muted text-sm font-medium w-full text-left"
|
||||
>
|
||||
<LogOut className="w-5 h-5" />
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex-1 md:ml-64">
|
||||
{/* Mobile Header */}
|
||||
<header className="md:hidden sticky top-0 z-50 border-b-2 border-border bg-background px-4 py-3 flex items-center justify-between">
|
||||
<Link to="/" className="text-xl font-bold">LearnHub</Link>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link to="/checkout" className="relative p-2">
|
||||
<ShoppingCart className="w-5 h-5" />
|
||||
{items.length > 0 && (
|
||||
<span className="absolute top-0 right-0 bg-primary text-primary-foreground text-xs w-4 h-4 flex items-center justify-center">
|
||||
{items.length}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="pb-20 md:pb-0">{children}</main>
|
||||
|
||||
{/* Mobile Bottom Navigation */}
|
||||
<nav className="md:hidden fixed bottom-0 left-0 right-0 border-t-2 border-border bg-background z-50">
|
||||
<div className="flex items-center justify-around py-2">
|
||||
{mobileNav.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
to={item.href}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-1 px-3 py-1 text-xs font-medium",
|
||||
isActive(item.href) ? "text-primary" : "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<item.icon className="w-5 h-5" />
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
{moreItems.length > 0 && (
|
||||
<Sheet open={moreOpen} onOpenChange={setMoreOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<button className="flex flex-col items-center gap-1 px-3 py-1 text-xs font-medium text-muted-foreground">
|
||||
<MoreHorizontal className="w-5 h-5" />
|
||||
Lainnya
|
||||
</button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="bottom" className="border-t-2 border-border">
|
||||
<div className="space-y-2 py-4">
|
||||
{moreItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
to={item.href}
|
||||
onClick={() => setMoreOpen(false)}
|
||||
className="flex items-center gap-3 px-4 py-3 hover:bg-muted text-sm font-medium"
|
||||
>
|
||||
<item.icon className="w-5 h-5" />
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
<button
|
||||
onClick={() => { handleSignOut(); setMoreOpen(false); }}
|
||||
className="flex items-center gap-3 px-4 py-3 hover:bg-muted text-sm font-medium w-full text-left text-destructive"
|
||||
>
|
||||
<LogOut className="w-5 h-5" />
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
209
src/components/RichTextEditor.tsx
Normal file
209
src/components/RichTextEditor.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
import { useEditor, EditorContent } from '@tiptap/react';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import Link from '@tiptap/extension-link';
|
||||
import Image from '@tiptap/extension-image';
|
||||
import Placeholder from '@tiptap/extension-placeholder';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Bold, Italic, List, ListOrdered, Quote, Link as LinkIcon,
|
||||
Image as ImageIcon, Heading1, Heading2, Undo, Redo
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useCallback } from 'react';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { toast } from '@/hooks/use-toast';
|
||||
|
||||
interface RichTextEditorProps {
|
||||
content: string;
|
||||
onChange: (html: string) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function RichTextEditor({ content, onChange, placeholder = 'Tulis konten...', className }: RichTextEditorProps) {
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit,
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
HTMLAttributes: {
|
||||
class: 'text-primary underline',
|
||||
},
|
||||
}),
|
||||
Image.configure({
|
||||
HTMLAttributes: {
|
||||
class: 'max-w-full h-auto rounded-md',
|
||||
},
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder,
|
||||
}),
|
||||
],
|
||||
content,
|
||||
onUpdate: ({ editor }) => {
|
||||
onChange(editor.getHTML());
|
||||
},
|
||||
});
|
||||
|
||||
const addLink = useCallback(() => {
|
||||
if (!editor) return;
|
||||
const url = window.prompt('Masukkan URL:');
|
||||
if (url) {
|
||||
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
|
||||
}
|
||||
}, [editor]);
|
||||
|
||||
const handleImageUpload = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file || !editor) return;
|
||||
|
||||
// For now, convert to base64 data URL since storage bucket may not be configured
|
||||
// In production, you would upload to Supabase Storage
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const dataUrl = reader.result as string;
|
||||
editor.chain().focus().setImage({ src: dataUrl }).run();
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}, [editor]);
|
||||
|
||||
const handlePaste = useCallback(async (e: React.ClipboardEvent) => {
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items || !editor) return;
|
||||
|
||||
for (const item of Array.from(items)) {
|
||||
if (item.type.startsWith('image/')) {
|
||||
e.preventDefault();
|
||||
const file = item.getAsFile();
|
||||
if (!file) continue;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const dataUrl = reader.result as string;
|
||||
editor.chain().focus().setImage({ src: dataUrl }).run();
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, [editor]);
|
||||
|
||||
if (!editor) return null;
|
||||
|
||||
return (
|
||||
<div className={cn("border-2 border-border rounded-md", className)}>
|
||||
<div className="flex flex-wrap gap-1 p-2 border-b-2 border-border bg-muted">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
className={editor.isActive('bold') ? 'bg-accent' : ''}
|
||||
>
|
||||
<Bold className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||
className={editor.isActive('italic') ? 'bg-accent' : ''}
|
||||
>
|
||||
<Italic className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
|
||||
className={editor.isActive('heading', { level: 1 }) ? 'bg-accent' : ''}
|
||||
>
|
||||
<Heading1 className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||
className={editor.isActive('heading', { level: 2 }) ? 'bg-accent' : ''}
|
||||
>
|
||||
<Heading2 className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||
className={editor.isActive('bulletList') ? 'bg-accent' : ''}
|
||||
>
|
||||
<List className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||
className={editor.isActive('orderedList') ? 'bg-accent' : ''}
|
||||
>
|
||||
<ListOrdered className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
||||
className={editor.isActive('blockquote') ? 'bg-accent' : ''}
|
||||
>
|
||||
<Quote className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={addLink}
|
||||
className={editor.isActive('link') ? 'bg-accent' : ''}
|
||||
>
|
||||
<LinkIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
<label>
|
||||
<Button type="button" variant="ghost" size="sm" asChild>
|
||||
<span>
|
||||
<ImageIcon className="w-4 h-4" />
|
||||
</span>
|
||||
</Button>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleImageUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
<div className="flex-1" />
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => editor.chain().focus().undo().run()}
|
||||
disabled={!editor.can().undo()}
|
||||
>
|
||||
<Undo className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => editor.chain().focus().redo().run()}
|
||||
disabled={!editor.can().redo()}
|
||||
>
|
||||
<Redo className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div onPaste={handlePaste}>
|
||||
<EditorContent
|
||||
editor={editor}
|
||||
className="prose prose-sm max-w-none p-4 min-h-[200px] focus:outline-none [&_.ProseMirror]:outline-none [&_.ProseMirror]:min-h-[180px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user