diff --git a/App.tsx b/App.tsx index 7d480d3..74635ab 100644 --- a/App.tsx +++ b/App.tsx @@ -4,9 +4,11 @@ import { AnimatePresence } from 'framer-motion'; import { Navbar } from './components/Navbar'; import { Footer } from './components/Footer'; import { ErrorBoundary } from './components/ErrorBoundary'; +import { AuthProvider } from './components/AuthProvider'; import { Home } from './pages/Home'; // Muat Home secara eager +import { Login } from './pages/Auth/Login'; +import { Register } from './pages/Auth/Register'; -// Lazy loaded pages // Lazy loaded pages const Karya = React.lazy(() => import('./pages/Karya').then(module => ({ default: module.Karya }))); const Tim = React.lazy(() => import('./pages/Tim').then(module => ({ default: module.Tim }))); @@ -18,8 +20,20 @@ const VideoPage = React.lazy(() => import('./pages/divisions/Video').then(module const Writing = React.lazy(() => import('./pages/divisions/Writing').then(module => ({ default: module.Writing }))); const Meme = React.lazy(() => import('./pages/divisions/Meme').then(module => ({ default: module.Meme }))); const Coding = React.lazy(() => import('./pages/divisions/Coding').then(module => ({ default: module.Coding }))); +const Studio = React.lazy(() => import('./pages/Studio').then(module => ({ default: module.Studio }))); +const Settings = React.lazy(() => import('./pages/Settings').then(module => ({ default: module.Settings }))); +const Profile = React.lazy(() => import('./pages/Profile').then(module => ({ default: module.Profile }))); const V5Launch = React.lazy(() => import('./pages/V5Launch').then(module => ({ default: module.V5Launch }))); +// Admin Components +import { AdminGuard } from './components/AdminGuard'; +import { AdminLayout } from './components/AdminLayout'; +const AdminDashboard = React.lazy(() => import('./pages/Admin/Dashboard').then(module => ({ default: module.Dashboard }))); +const AdminUsers = React.lazy(() => import('./pages/Admin/Users').then(module => ({ default: module.Users }))); +const AdminContent = React.lazy(() => import('./pages/Admin/Content').then(module => ({ default: module.Content }))); +const AdminAnnouncements = React.lazy(() => import('./pages/Admin/Announcements').then(module => ({ default: module.Announcements }))); +const AdminSettings = React.lazy(() => import('./pages/Admin/Settings').then(module => ({ default: module.Settings }))); + const Loading = () => (
@@ -44,6 +58,8 @@ const AnimatedRoutes = () => { }> } /> + } /> + } /> } /> } /> } /> @@ -54,7 +70,23 @@ const AnimatedRoutes = () => { } /> } /> } /> + } /> + } /> + } /> } /> + + {/* Admin Routes */} + + + + }> + } /> + } /> + } /> + } /> + } /> + } /> @@ -64,6 +96,32 @@ const AnimatedRoutes = () => { ); }; +const AppContent = () => { + const { pathname } = useLocation(); + const isStudio = pathname.toLowerCase() === '/studio'; + const isAdmin = pathname.toLowerCase().startsWith('/admin'); + const isZenMode = isStudio || isAdmin; + + return ( +
+
+
+
+
+
+
+ +
+ {!isZenMode && } +
+ +
+ {!isZenMode &&
} +
+
+ ); +}; + export default function App() { const [mounted, setMounted] = useState(false); @@ -75,25 +133,12 @@ export default function App() { return ( - - -
-
-
-
-
-
-
- -
- -
- -
-
-
-
- + + + + + + ); } \ No newline at end of file diff --git a/components/AdminGuard.tsx b/components/AdminGuard.tsx new file mode 100644 index 0000000..2a0de49 --- /dev/null +++ b/components/AdminGuard.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { Navigate } from 'react-router-dom'; +import { useAuth } from './AuthProvider'; + +interface AdminGuardProps { + children: React.ReactNode; +} + +export const AdminGuard: React.FC = ({ children }) => { + const { profile, loading } = useAuth(); + + if (loading) { + return ( +
+
+
+ ); + } + + if (!profile || profile.role !== 'admin') { + return ; + } + + return <>{children}; +}; diff --git a/components/AdminLayout.tsx b/components/AdminLayout.tsx new file mode 100644 index 0000000..defbe49 --- /dev/null +++ b/components/AdminLayout.tsx @@ -0,0 +1,92 @@ +import React from 'react'; +import { Link, useLocation, Outlet } from 'react-router-dom'; +import { + LayoutDashboard, Users, FileText, Megaphone, + Settings, LogOut, ArrowLeft +} from 'lucide-react'; +import { useAuth } from './AuthProvider'; +import { motion } from 'framer-motion'; + +export const AdminLayout = () => { + const location = useLocation(); + const { signOut } = useAuth(); + + const menuItems = [ + { icon: LayoutDashboard, label: 'Beranda', path: '/admin' }, + { icon: Users, label: 'Pengguna', path: '/admin/users' }, + { icon: FileText, label: 'Konten', path: '/admin/content' }, + { icon: Megaphone, label: 'Pengumuman', path: '/admin/announcements' }, + { icon: Settings, label: 'Pengaturan', path: '/admin/settings' }, + ]; + + const isActive = (path: string) => { + if (path === '/admin') return location.pathname === '/admin'; + return location.pathname.startsWith(path); + }; + + return ( +
+ {/* Sidebar - Clean & Zen */} + + + {/* Main Content Area - Zen Mode */} +
+ {/* Minimal Background Glow */} +
+ +
+ +
+
+
+ ); +}; diff --git a/components/AuthProvider.tsx b/components/AuthProvider.tsx new file mode 100644 index 0000000..94efdea --- /dev/null +++ b/components/AuthProvider.tsx @@ -0,0 +1,106 @@ +import React, { createContext, useContext, useEffect, useState } from 'react'; +import { User, Session } from '@supabase/supabase-js'; +import { supabase } from '../lib/supabase'; + +interface Profile { + id: string; + username: string; + avatar_url: string; + is_approved: boolean; + role: string; + website?: string; + bio?: string; +} + +interface AuthContextType { + session: Session | null; + user: User | null; + profile: Profile | null; + loading: boolean; + signOut: () => Promise; +} + +const AuthContext = createContext(undefined); + +export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [session, setSession] = useState(null); + const [user, setUser] = useState(null); + const [profile, setProfile] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + // 1. Get initial session + const getInitialSession = async () => { + try { + const { data: { session } } = await supabase.auth.getSession(); + setSession(session); + setUser(session?.user ?? null); + if (session?.user) { + await fetchProfile(session.user.id); + } + } catch (err) { + console.error('Error getting session:', err); + } finally { + setLoading(false); + } + }; + + getInitialSession(); + + // 2. Listen for auth changes + const { data: { subscription } } = supabase.auth.onAuthStateChange(async (_event, session) => { + setSession(session); + setUser(session?.user ?? null); + + if (session?.user) { + await fetchProfile(session.user.id); + } else { + setProfile(null); + } + setLoading(false); + }); + + return () => { + subscription.unsubscribe(); + }; + }, []); + + const fetchProfile = async (userId: string) => { + try { + const { data, error } = await supabase + .from('profiles') + .select('id, username, avatar_url, is_approved, role') + .eq('id', userId) + .single(); + + if (error) { + console.error('Error fetching profile:', error); + } else { + setProfile(data); + } + } catch (err) { + console.error('Error in fetchProfile:', err); + } + }; + + const signOut = async () => { + await supabase.auth.signOut(); + setProfile(null); + setUser(null); + setSession(null); + }; + + return ( + + {children} + + ); +}; + +export const useAuth = () => { + const context = useContext(AuthContext); + if (context === undefined) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +}; diff --git a/components/BottomCTA.tsx b/components/BottomCTA.tsx index 8e276b4..664d1c8 100644 --- a/components/BottomCTA.tsx +++ b/components/BottomCTA.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { motion } from 'framer-motion'; import { ArrowRight } from 'lucide-react'; +import { Link } from 'react-router-dom'; export const BottomCTA = () => { return ( @@ -32,16 +33,18 @@ export const BottomCTA = () => { tanpa birokrasi, hanya kreativitas murni. - - Gabung Sekarang - - + + + Gabung Sekarang + + +
); diff --git a/components/BrutalistCard.tsx b/components/BrutalistCard.tsx index 562c3ff..918967f 100644 --- a/components/BrutalistCard.tsx +++ b/components/BrutalistCard.tsx @@ -35,83 +35,76 @@ export const BrutalistCard: React.FC = ({ initial={{ opacity: 0, y: 20 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true }} - whileHover={{ x: -4, y: -4 }} - className={`relative backdrop-blur-md border-4 p-6 transition-all group ${isOwner - ? 'bg-rose-950/20 border-rose-500 shadow-brutalist-rose hover:shadow-brutalist-rose-lg' + whileHover={{ y: -5 }} + className={`relative group p-6 rounded-3xl transition-all duration-500 overflow-hidden ${isOwner + ? 'bg-gradient-to-br from-rose-500/10 to-transparent border border-rose-500/20 shadow-[0_0_40px_rgba(244,63,94,0.1)]' : isReporter - ? 'bg-purple-950/20 border-purple-500 shadow-brutalist-purple hover:shadow-brutalist-purple-lg' - : 'bg-white/5 border-white/20 shadow-brutalist hover:shadow-brutalist-lg' + ? 'bg-gradient-to-br from-purple-500/10 to-transparent border border-purple-500/20 shadow-[0_0_40px_rgba(168,85,247,0.1)]' + : 'bg-white/5 border border-white/10 backdrop-blur-sm' }`} > -
-
-
+ {/* Soft Ambient Glow */} +
+ +
+
+
{login} {isOwner && ( -
- -
- )} - {isReporter && ( -
- +
+
)}
-
-

- {login} -

+ +

+ {login} +

+ +
{contributions !== undefined && ( - - {contributions} KONTRIBUSI + + {contributions} Komitmen )} {issueCount !== undefined && ( - - {issueCount} LAPORAN + + {issueCount} Laporan )}
-
-
{bio && ( -

+

{bio}

)} - {issueTitle && ( -
- LAPORAN TERAKHIR: - {issueTitle} -
- )} - -
+
- + {socials?.twitter && ( - + )} {socials?.website && ( @@ -119,17 +112,18 @@ export const BrutalistCard: React.FC = ({ href={socials.website} target="_blank" rel="noopener noreferrer" - className="bg-white/5 text-white p-2 hover:bg-purple-500 hover:text-white border-2 border-white/10 transition-all" + className="text-gray-500 hover:text-white transition-colors" title="Situs Web" > - + )}
- {/* Decoration */} -
+ {/* Bottom Decoration */} +
); }; diff --git a/components/ChangelogTimeline.tsx b/components/ChangelogTimeline.tsx index fef671c..1058b54 100644 --- a/components/ChangelogTimeline.tsx +++ b/components/ChangelogTimeline.tsx @@ -28,7 +28,7 @@ export const ChangelogTimeline = () => { try { const { data, error } = await supabase .from('announcements') - .select('*') + .select('id, version, major_version, title, subtitle, date, description, content, changes, highlights, color, category') .eq('type', 'changelog') .order('major_version', { ascending: false }) .order('date', { ascending: false }); diff --git a/components/CreationStudio/carousel/SlideBuilder.tsx b/components/CreationStudio/carousel/SlideBuilder.tsx index a7b9329..4b08f7d 100644 --- a/components/CreationStudio/carousel/SlideBuilder.tsx +++ b/components/CreationStudio/carousel/SlideBuilder.tsx @@ -9,35 +9,31 @@ interface SlideBuilderProps { } export const SlideBuilder: React.FC = ({ slides, onChange }) => { - const onDrop = (acceptedFiles: File[]) => { + const onDrop = async (acceptedFiles: File[]) => { if (slides.length + acceptedFiles.length > 10) { alert("Maximum 10 slides allowed"); return; } - const newSlides = acceptedFiles.map((file, index) => { - const reader = new FileReader(); - const id = Date.now().toString() + Math.random().toString(); - - // We need to handle async reading, for simplicity in this MVP - // we'll push a placeholder and update it when loaded - // Ideally we'd use a more robust logic but this works for basic preview - const slide: SlideContent = { - id, - type: 'image', - content: '', // Will update - order: slides.length + index - }; - - reader.onload = () => { - updateSlideContent(id, reader.result as string); - }; - reader.readAsDataURL(file); - - return slide; + const readFiles = acceptedFiles.map((file, index) => { + return new Promise((resolve) => { + const reader = new FileReader(); + const id = Date.now().toString() + Math.random().toString(); + + reader.onload = () => { + resolve({ + id, + type: 'image', + content: reader.result as string, + order: slides.length + index + }); + }; + reader.readAsDataURL(file); + }); }); - onChange([...slides, ...newSlides]); + const newSlidesFromFiles = await Promise.all(readFiles); + onChange([...slides, ...newSlidesFromFiles]); }; const updateSlideContent = (id: string, content: string) => { @@ -121,8 +117,8 @@ export const SlideBuilder: React.FC = ({ slides, onChange })
diff --git a/components/CreationStudio/index.tsx b/components/CreationStudio/index.tsx index fb06e0d..e584f63 100644 --- a/components/CreationStudio/index.tsx +++ b/components/CreationStudio/index.tsx @@ -11,6 +11,7 @@ import { PyodideSandbox } from './sandbox/PyodideSandbox'; import { WebsiteEmbed } from './embed/WebsiteEmbed'; import { DocumentUploader } from './editors/DocumentUploader'; import { SlideBuilder } from './carousel/SlideBuilder'; +import { supabase } from '../../lib/supabase'; // Re-using the props interface interface Props { @@ -95,15 +96,128 @@ export const CreationStudio: React.FC = ({ isOpen, onClose, onPublish }) }, 500); }; - const handleImageUpload = (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (file) { - const reader = new FileReader(); - reader.onloadend = () => { - const url = reader.result as string; - setFormData(prev => ({ ...prev, image: url, image_url: url })); - }; - reader.readAsDataURL(file); + const [isPublishing, setIsPublishing] = useState(false); + + const handleImageUpload = async (e: React.ChangeEvent) => { + const files = e.target.files; + if (!files || files.length === 0) return; + + const file = files[0]; + + // Preview locally first + const reader = new FileReader(); + reader.onloadend = () => { + const url = reader.result as string; + setFormData(prev => ({ ...prev, image: url })); + }; + reader.readAsDataURL(file); + + // Actual upload will happen on Publish to keep it efficient, + // or we store the file object to upload now. + // Let's store the file for later upload to avoid abandoned files. + setFormData(prev => ({ ...prev, _pendingFile: file })); + }; + + const uploadFile = async (file: File): Promise => { + const fileExt = file.name.split('.').pop(); + const fileName = `${Math.random()}.${fileExt}`; + const filePath = `${fileName}`; + + const { error: uploadError } = await supabase.storage + .from('works') + .upload(filePath, file); + + if (uploadError) throw uploadError; + + const { data } = supabase.storage + .from('works') + .getPublicUrl(filePath); + + return data.publicUrl; + }; + + const handlePublishInternal = async () => { + try { + setIsPublishing(true); + let finalData = { ...formData }; + + // Upload main image/video if exists + if (formData._pendingFile) { + const publicUrl = await uploadFile(formData._pendingFile as File); + finalData.image_url = publicUrl; + delete finalData._pendingFile; + } + + // Upload slides images if any + if (formData.slides && formData.slides.length > 0) { + const updatedSlides = await Promise.all(formData.slides.map(async (slide) => { + if (slide.type === 'image' && slide.content.startsWith('data:')) { + // Extract blob from data URL + const res = await fetch(slide.content); + const blob = await res.blob(); + const file = new File([blob], `slide_${slide.id}.jpg`, { type: 'image/jpeg' }); + const publicUrl = await uploadFile(file); + return { ...slide, content: publicUrl }; + } + return slide; + })); + finalData.slides = updatedSlides; + } + + // Determine type and thumbnail + let finalType = finalData.type || 'image'; + + // Priority: Trust the subMode selection + if (medium === 'visual') { + if (subMode === 'slide') { + finalType = 'slide'; + } else { + finalType = 'image'; + } + } else if (medium === 'kode') { + finalType = 'code'; + } else if (medium === 'narasi') { + finalType = subMode === 'document' ? 'document' : 'text'; + } else if (medium === 'sinema') { + finalType = subMode === 'embed' ? 'embed' : 'video'; + } + + // Fallback for image_url if it's a slide work + // If image_url is missing (because we uploaded slides but not a main image), use the first slide + if (!finalData.image_url && finalData.slides && finalData.slides.length > 0) { + const firstImageSlide = finalData.slides.find(s => s.type === 'image'); + if (firstImageSlide) { + finalData.image_url = firstImageSlide.content; + } + } + + // SET THE FINAL TYPE + finalData.type = finalType; + + // Cleanup payload before sending to Supabase + // We remove 'image' (the local preview base64) to keep the DB entry clean + if ('image' in finalData) delete (finalData as any).image; + if ('_pendingFile' in finalData) delete (finalData as any)._pendingFile; + + // Wait for DB insertion to complete + await onPublish(finalData); + + // Close and Reset + onClose(); + setTimeout(() => { + setStep('selection'); + setMedium(null); + setSubMode('default'); + setFormData({ + title: '', description: '', author: '', division: 'graphics', tags: [], content: '', slides: [] + }); + }, 500); + + } catch (error: any) { + console.error('Error during upload:', error); + alert('Gagal mengunggah media: ' + error.message); + } finally { + setIsPublishing(false); } }; @@ -410,10 +524,15 @@ export const CreationStudio: React.FC = ({ isOpen, onClose, onPublish }) ) : ( )}
diff --git a/components/CreationStudio/types.ts b/components/CreationStudio/types.ts index b731de9..a337846 100644 --- a/components/CreationStudio/types.ts +++ b/components/CreationStudio/types.ts @@ -33,6 +33,7 @@ export interface CreationData { code_language?: string; embed_url?: string; document_source?: string; + _pendingFile?: File; // Internal use for uploads } export interface CodeEditorProps { diff --git a/components/Navbar.tsx b/components/Navbar.tsx index 3c83e3f..e5ed71b 100644 --- a/components/Navbar.tsx +++ b/components/Navbar.tsx @@ -1,12 +1,15 @@ import React, { useState, useEffect } from 'react'; -import { Menu, X, Asterisk, ArrowRight } from 'lucide-react'; +import { Menu, X, Asterisk, ArrowRight, User as UserIcon, LogOut, Settings, ChevronRight, Shield } from 'lucide-react'; import { motion, AnimatePresence } from 'framer-motion'; import { Link, useLocation } from 'react-router-dom'; +import { useAuth } from './AuthProvider'; export const Navbar = () => { + const { user, profile, signOut } = useAuth(); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); const [isScrolled, setIsScrolled] = useState(false); const [isHovered, setIsHovered] = useState(false); + const [isProfileOpen, setIsProfileOpen] = useState(false); const location = useLocation(); const navLinks = [ @@ -38,141 +41,255 @@ export const Navbar = () => { // Konfigurasi transisi animasi - pegas cair "mirip Apple" const springTransition = { type: "spring" as const, - stiffness: 350, + stiffness: 400, damping: 30, - mass: 1 + mass: 0.8 + }; + + const containerVariants = { + collapsed: { + width: "auto", + height: "50px", + borderRadius: "50px", + padding: "8px 16px", + }, + expanded: { + width: "auto", + height: "60px", + borderRadius: "50px", + padding: "12px 24px", + }, + profileOpen: { + width: "auto", + minWidth: "200px", + height: "auto", + borderRadius: "32px", + padding: "12px 20px 20px 20px", + }, + mobileOpen: { + width: "100%", + maxWidth: "400px", + height: "auto", + borderRadius: "32px", + padding: "24px", + } }; return (
setIsHovered(true)} onHoverEnd={() => setIsHovered(false)} - className={`pointer-events-auto bg-[#111]/90 backdrop-blur-lg border border-white/10 shadow-2xl shadow-black/50 overflow-hidden flex flex-col ${isMobileMenuOpen ? 'p-6 gap-6' : (showFullMenu ? 'px-6 py-3' : 'px-3 py-2') - }`} + className="pointer-events-auto bg-[#111]/90 backdrop-blur-lg border border-white/10 shadow-2xl shadow-black/50 flex flex-col overflow-hidden" > - - {/* Logo */} - setIsMobileMenuOpen(false)}> - - - +
+ {/* Logo & Title Wrapper */} +
+ setIsMobileMenuOpen(false)}> + + + + + {(showFullMenu || isMobileMenuOpen) && ( Our Creativity. )} - +
{/* Tautan Desktop */} -
- - {showFullMenu && ( + + {showFullMenu && !isMobileMenuOpen && ( + + {navLinks.map((link) => ( + + {link.name} + {isActive(link.href) && ( + + )} + {!isActive(link.href) && ( +
+ )} + + ))} + + )} + + + {/* CTA & Toggle Area */} +
+ + {showFullMenu && !isMobileMenuOpen && ( - {navLinks.map((link) => ( + {user && profile ? ( +
+ + {profile.username} + {profile.username} + + +
+ ) : ( - {link.name} + Masuk + - ))} + )}
)}
-
- {/* CTA & Toggle Seluler */} -
- - {showFullMenu && !isMobileMenuOpen ? ( - - - Bergabung - - - - ) : ( - !isMobileMenuOpen && ( - - {/* Indikator mini saat diciutkan */} -
- - ) - )} - + {!isMobileMenuOpen && !showFullMenu && ( + + {user && ( +
+ +
+ )} +
+ + )}
-
+
- {/* Konten Menu Seluler - Di dalam Pulau */} + {/* Profile Dropdown Content */} + + {isProfileOpen && !isMobileMenuOpen && ( + +
+
+
+ Status + + {profile?.is_approved ? "ACTIVE MEMBER" : "PENDING APPROVAL"} + +
+
+ setIsProfileOpen(false)} className="flex items-center gap-2 px-3 py-2.5 text-xs font-bold text-gray-300 hover:text-white hover:bg-white/5 rounded-xl transition-colors"> + Profil Saya + + setIsProfileOpen(false)} className="flex items-center gap-2 px-3 py-2.5 text-xs font-bold text-gray-300 hover:text-white hover:bg-white/5 rounded-xl transition-colors"> + Pengaturan + +
+ {profile?.role === 'admin' && ( + setIsProfileOpen(false)} className="flex items-center gap-2 px-3 py-2.5 text-xs font-bold text-rose-400 hover:text-rose-300 hover:bg-rose-500/10 rounded-xl transition-colors"> + Panel Admin + + )} + +
+ + )} + + + {/* Mobile Menu Content */} {isMobileMenuOpen && ( -
+
{navLinks.map((link, i) => ( { initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.3 }} + className="flex flex-col gap-2 mt-2 pt-2 border-t border-white/5" > - setIsMobileMenuOpen(false)} - className="bg-white text-black text-center py-3 rounded-xl font-bold mt-2 flex items-center justify-center gap-2" - > - Bergabung Sekarang - - + {user && profile ? ( +
+
+ {profile.username} +
+ {profile.username} + {profile.is_approved ? "Member" : "Pending"} +
+
+
+ setIsMobileMenuOpen(false)} + className="px-4 py-3 bg-white/5 rounded-xl text-xs font-bold text-gray-300 hover:text-white flex items-center justify-center gap-2" + > + Profil + + setIsMobileMenuOpen(false)} + className="px-4 py-3 bg-white/5 rounded-xl text-xs font-bold text-gray-300 hover:text-white flex items-center justify-center gap-2" + > + Settings + +
+ +
+ ) : ( + setIsMobileMenuOpen(false)} + className="bg-white text-black text-center py-3 rounded-xl font-bold mt-2 flex items-center justify-center gap-2 hover:bg-gray-200 transition-colors" + > + Masuk / Daftar + + + )}
)} diff --git a/components/Pagination.tsx b/components/Pagination.tsx new file mode 100644 index 0000000..f40ac76 --- /dev/null +++ b/components/Pagination.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { ChevronLeft, ChevronRight, Loader2 } from 'lucide-react'; + +interface PaginationProps { + page: number; + totalPages: number; + hasMore: boolean; + onPageChange: (page: number) => void; + loading?: boolean; +} + +export const Pagination: React.FC = ({ + page, + totalPages, + hasMore, + onPageChange, + loading = false +}) => { + return ( +
+
+ Halaman {page} +
+
+ + +
+
+ ); +}; diff --git a/data/changelogData.ts b/data/changelogData.ts index 5e66c53..e468586 100644 --- a/data/changelogData.ts +++ b/data/changelogData.ts @@ -15,7 +15,7 @@ export interface ChangelogEntry { export const changelogData: ChangelogEntry[] = [ { version: "v5.0.0", - date: "November 2025", + date: "Desember 2024", title: "Revolution Edition", description: "Akhirnya rilis juga! Redesign total platform. Gila sih ini, bener-bener beda dari yang lama. Fokus ke estetika modern, interaksi yang 'mahal', dan experience yang premium abis.", type: "major", diff --git a/index.css b/index.css index 4a16389..21f2c0b 100644 --- a/index.css +++ b/index.css @@ -6,13 +6,16 @@ ::-webkit-scrollbar { width: 6px; } + ::-webkit-scrollbar-track { background: #050505; } + ::-webkit-scrollbar-thumb { background: #333; border-radius: 3px; } + ::-webkit-scrollbar-thumb:hover { background: #555; } @@ -26,3 +29,16 @@ body { background-color: #050505; color: #ffffff; } + +/* Hide scrollbar for Chrome, Safari and Opera */ +.no-scrollbar::-webkit-scrollbar { + display: none; +} + +/* Hide scrollbar for IE, Edge and Firefox */ +.no-scrollbar { + -ms-overflow-style: none; + /* IE and Edge */ + scrollbar-width: none; + /* Firefox */ +} \ No newline at end of file diff --git a/index.tsx b/index.tsx index 465bdb4..a2a33f6 100644 --- a/index.tsx +++ b/index.tsx @@ -4,6 +4,8 @@ import './index.css'; import App from './App'; import { SpeedInsights } from '@vercel/speed-insights/react'; import { Analytics } from '@vercel/analytics/react'; +import { QueryClientProvider } from '@tanstack/react-query'; +import { queryClient } from './lib/react-query'; const rootElement = document.getElementById('root'); if (!rootElement) { @@ -13,8 +15,10 @@ if (!rootElement) { const root = ReactDOM.createRoot(rootElement); root.render( - - - + + + + + ); \ No newline at end of file diff --git a/lib/react-query.ts b/lib/react-query.ts new file mode 100644 index 0000000..1cca5de --- /dev/null +++ b/lib/react-query.ts @@ -0,0 +1,12 @@ +import { QueryClient } from '@tanstack/react-query'; + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 1000 * 60 * 5, // Data is fresh for 5 minutes + gcTime: 1000 * 60 * 30, // Unused data is garbage collected after 30 minutes + retry: 1, // Retry failed requests once + refetchOnWindowFocus: false, // Don't refetch when window gets focus (optional, good for admin) + }, + }, +}); diff --git a/package-lock.json b/package-lock.json index 5fea064..ace088e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@gsap/react": "^2.1.2", "@monaco-editor/react": "^4.7.0", "@supabase/supabase-js": "^2.86.0", + "@tanstack/react-query": "^5.90.12", "@tiptap/extension-image": "^3.13.0", "@tiptap/extension-placeholder": "^3.13.0", "@tiptap/react": "^3.13.0", @@ -1337,6 +1338,32 @@ "node": ">=20.0.0" } }, + "node_modules/@tanstack/query-core": { + "version": "5.90.12", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.12.tgz", + "integrity": "sha512-T1/8t5DhV/SisWjDnaiU2drl6ySvsHj1bHBCWNXd+/T+Hh1cf6JodyEYMd5sgwm+b/mETT4EV3H+zCVczCU5hg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.90.12", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.12.tgz", + "integrity": "sha512-graRZspg7EoEaw0a8faiUASCyJrqjKPdqJ9EwuDRUF9mEYJ1YPczI9H+/agJ0mOJkPCJDk0lsz5QTrLZ/jQ2rg==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.90.12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, "node_modules/@tiptap/core": { "version": "3.13.0", "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.13.0.tgz", diff --git a/package.json b/package.json index a561fa4..5776b61 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "@gsap/react": "^2.1.2", "@monaco-editor/react": "^4.7.0", "@supabase/supabase-js": "^2.86.0", + "@tanstack/react-query": "^5.90.12", "@tiptap/extension-image": "^3.13.0", "@tiptap/extension-placeholder": "^3.13.0", "@tiptap/react": "^3.13.0", diff --git a/pages/Admin/Announcements.tsx b/pages/Admin/Announcements.tsx new file mode 100644 index 0000000..0d8feca --- /dev/null +++ b/pages/Admin/Announcements.tsx @@ -0,0 +1,251 @@ +import React, { useState, useEffect } from 'react'; +import { Plus, Edit2, Trash2, X, Save, Loader2, Megaphone } from 'lucide-react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { supabase } from '../../lib/supabase'; + +export const Announcements = () => { + const [announcements, setAnnouncements] = useState([]); + const [loading, setLoading] = useState(true); + const [isEditing, setIsEditing] = useState(false); + const [formData, setFormData] = useState({ + title: '', + subtitle: '', + description: '', + content: '', + type: 'announcement', + category: 'General', + status: 'Baru', + color: 'from-purple-500 to-blue-500', + highlights: '', // comma separated for input + is_active: true + }); + + const fetchAnnouncements = async () => { + setLoading(true); + try { + const { data, error } = await supabase + .from('announcements') + .select('id, title, subtitle, description, date, type, status, color, is_active') + .order('date', { ascending: false }); + + if (error) throw error; + setAnnouncements(data || []); + } catch (error) { + console.error('Error fetching announcements:', error); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchAnnouncements(); + }, []); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + try { + const payload = { + ...formData, + highlights: formData.highlights.split(',').map((h: string) => h.trim()).filter(Boolean), + date: new Date().toISOString() + }; + + if (formData.id) { + const { error } = await supabase + .from('announcements') + .update(payload) + .eq('id', formData.id); + if (error) throw error; + } else { + const { error } = await supabase + .from('announcements') + .insert([payload]); + if (error) throw error; + } + + setIsEditing(false); + fetchAnnouncements(); + resetForm(); + } catch (error) { + console.error('Error saving announcement:', error); + alert('Failed to save announcement.'); + } + }; + + const handleDelete = async (id: string) => { + if (!window.confirm('Delete this announcement?')) return; + try { + const { error } = await supabase.from('announcements').delete().eq('id', id); + if (error) throw error; + setAnnouncements(announcements.filter(a => a.id !== id)); + } catch (error) { + console.error('Error deleting:', error); + } + }; + + const handleEdit = (item: any) => { + setFormData({ + ...item, + highlights: item.highlights ? item.highlights.join(', ') : '' + }); + setIsEditing(true); + }; + + const resetForm = () => { + setFormData({ + title: '', subtitle: '', description: '', content: '', + type: 'announcement', category: 'General', status: 'Baru', + color: 'from-purple-500 to-blue-500', highlights: '', is_active: true + }); + }; + + return ( +
+
+ +

Pusat Pengumuman

+

Kelola berita, pembaruan, dan notifikasi platform.

+
+ +
+ + {/* List - Zen Refinement */} +
+ {loading ? ( +
+ +

Menarik data...

+
+ ) : announcements.length === 0 ? ( +
+

Belum ada pengumuman

+
+ ) : ( +
+ {announcements.map((item, index) => ( + +
+
+
+ +
+
+ + {item.category || 'Umum'} + +
{new Date(item.date).toLocaleDateString('id-ID')}
+
+
+ + {item.status} + +
+

{item.title}

+

{item.description}

+ +
+
+ + +
+
+
+ + ))} +
+ )} +
+ + {/* Edit Modal - Zen Refinement */} + + {isEditing && ( + + +
+
+

{formData.id ? 'Perbarui Rilis' : 'Terbitkan Baru'}

+

Lengkapi detail informasi untuk publik.

+
+ +
+
+
+
+ + setFormData({ ...formData, title: e.target.value })} className="w-full bg-white/[0.03] border border-white/10 rounded-2xl py-4 px-6 text-white focus:border-rose-500/50 outline-none transition-all font-bold placeholder:text-gray-800" placeholder="Ketik judul pengumuman..." /> +
+
+ + setFormData({ ...formData, subtitle: e.target.value })} className="w-full bg-white/[0.03] border border-white/10 rounded-2xl py-4 px-6 text-white focus:border-rose-500/50 outline-none transition-all font-bold placeholder:text-gray-800" placeholder="Opsional subjudul..." /> +
+
+ +
+ +