source: frontend/src/App.jsx@ 09e02d7

Last change on this file since 09e02d7 was 09e02d7, checked in by Nikola Sarafimov <sarafimov.nikola12345@…>, 4 days ago

Final room reservation system implementation

  • Property mode set to 100644
File size: 27.5 KB
Line 
1import { useEffect, useMemo, useState } from "react";
2import { AnimatePresence, motion } from "framer-motion";
3import toast, { Toaster } from "react-hot-toast";
4import SearchRoomsPage from "./pages/SearchRoomsPage.jsx";
5import CreateReservationPage from "./pages/CreateReservationPage.jsx";
6import ApprovalsPage from "./pages/ApprovalsPage.jsx";
7import ReportsPage from "./pages/ReportsPage.jsx";
8import AdminPanelPage from "./pages/AdminPanelPage.jsx";
9import {
10 Activity,
11 ArrowRight,
12 BarChart3,
13 Boxes,
14 CalendarCheck,
15 CheckCircle2,
16 ClipboardCheck,
17 Database,
18 DoorOpen,
19 LogOut,
20 PlusCircle,
21 Search,
22 ShieldCheck,
23 Sparkles,
24 UserRound,
25 Users,
26} from "lucide-react";
27
28import {
29 getDashboardStats,
30 getHealth,
31 loginUser,
32 registerUser,
33} from "./api.js";
34
35function App() {
36 const [activeUser, setActiveUser] = useState(() => {
37 try {
38 const savedUser = localStorage.getItem("activeUser");
39 return savedUser ? JSON.parse(savedUser) : null;
40 } catch {
41 localStorage.removeItem("activeUser");
42 return null;
43 }
44 });
45
46 const [authView, setAuthView] = useState(() => {
47 return localStorage.getItem("authView") || "landing";
48 });
49
50 const [activePage, setActivePage] = useState(() => {
51 return localStorage.getItem("activePage") || "dashboard";
52 });
53
54 function changeAuthView(view) {
55 localStorage.setItem("authView", view);
56 setAuthView(view);
57 }
58
59 function handleLogin(user) {
60 localStorage.setItem("activeUser", JSON.stringify(user));
61 localStorage.setItem("activePage", "dashboard");
62 localStorage.removeItem("authView");
63
64 setActiveUser(user);
65 setActivePage("dashboard");
66
67 toast.success(`Logged in as ${user.fullName}`);
68 }
69
70 function handleLogout() {
71 localStorage.removeItem("activeUser");
72 localStorage.removeItem("activePage");
73 localStorage.setItem("authView", "landing");
74
75 setActiveUser(null);
76 setAuthView("landing");
77 setActivePage("dashboard");
78 }
79
80 useEffect(() => {
81 if (activeUser) {
82 localStorage.setItem("activePage", activePage);
83 }
84 }, [activePage, activeUser]);
85
86 if (!activeUser) {
87 return (
88 <>
89 <Toaster position="top-right" />
90 <AnimatePresence mode="wait">
91 {authView === "landing" && (
92 <PageMotion key="landing">
93 <LandingScreen
94 onLoginClick={() => changeAuthView("login")}
95 onRegisterClick={() => changeAuthView("register")}
96 />
97 </PageMotion>
98 )}
99
100 {authView === "login" && (
101 <PageMotion key="login">
102 <LoginScreen
103 onLogin={handleLogin}
104 onBack={() => changeAuthView("landing")}
105 onRegisterClick={() => changeAuthView("register")}
106 />
107 </PageMotion>
108 )}
109
110 {authView === "register" && (
111 <PageMotion key="register">
112 <RegisterScreen
113 onBack={() => changeAuthView("landing")}
114 onLoginClick={() => changeAuthView("login")}
115 onRegistered={handleLogin}
116 />
117 </PageMotion>
118 )}
119 </AnimatePresence>
120 </>
121 );
122 }
123
124 return (
125 <>
126 <Toaster position="top-right" />
127 <AppShell
128 activeUser={activeUser}
129 activePage={activePage}
130 setActivePage={setActivePage}
131 onLogout={handleLogout}
132 >
133 <AnimatePresence mode="wait">
134 {activePage === "dashboard" && (
135 <PageMotion key="dashboard">
136 <Dashboard activeUser={activeUser} setActivePage={setActivePage} />
137 </PageMotion>
138 )}
139
140 {activePage === "search" && (
141 <PageMotion key="search">
142 <SearchRoomsPage />
143 </PageMotion>
144 )}
145
146 {activePage === "create" && (
147 <PageMotion key="create">
148 <CreateReservationPage activeUser={activeUser} />
149 </PageMotion>
150 )}
151
152 {activePage === "approvals" && (
153 <PageMotion key="approvals">
154 <ApprovalsPage activeUser={activeUser} />
155 </PageMotion>
156 )}
157
158 {activePage === "reports" && (
159 <PageMotion key="reports">
160 <ReportsPage />
161 </PageMotion>
162 )}
163
164 {activePage === "admin" && (
165 <PageMotion key="admin">
166 <AdminPanelPage activeUser={activeUser} />
167 </PageMotion>
168 )}
169 </AnimatePresence>
170 </AppShell>
171 </>
172 );
173}
174
175function LandingScreen({ onLoginClick, onRegisterClick }) {
176 return (
177 <div className="landing-page">
178 <div className="landing-orb orb-one" />
179 <div className="landing-orb orb-two" />
180 <div className="landing-orb orb-three" />
181
182 <header className="landing-header">
183 <button
184 className="landing-brand logo-home-button"
185 type="button"
186 onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
187 title="Go to home"
188 >
189 <img src="/logo.png" alt="Room Reservation logo" />
190 </button>
191
192 <div className="landing-header-actions">
193 <button className="ghost-button" onClick={onLoginClick}>
194 Sign in
195 </button>
196 <button className="landing-small-primary" onClick={onRegisterClick}>
197 Create account
198 </button>
199 </div>
200 </header>
201
202 <main className="landing-main">
203 <motion.div
204 className="landing-hero-content"
205 initial={{ opacity: 0, y: 26 }}
206 animate={{ opacity: 1, y: 0 }}
207 transition={{ duration: 0.55 }}
208 >
209 <div className="landing-eyebrow">
210 <Sparkles size={16} />
211 Room Management Platform
212 </div>
213
214 <h1>Smart Room Reservation System</h1>
215
216 <p>
217 Search available rooms, create reservation requests, manage approvals,
218 and monitor resource usage through live PostgreSQL reports.
219 </p>
220
221 <div className="landing-actions">
222 <button className="landing-primary" onClick={onLoginClick}>
223 Sign in
224 <ArrowRight size={20} />
225 </button>
226
227 <button className="landing-secondary" onClick={onRegisterClick}>
228 Create account
229 <UserRound size={19} />
230 </button>
231 </div>
232 </motion.div>
233
234 <motion.div
235 className="landing-preview-card"
236 initial={{ opacity: 0, x: 36, scale: 0.96 }}
237 animate={{ opacity: 1, x: 0, scale: 1 }}
238 transition={{ duration: 0.65, delay: 0.15 }}
239 >
240 <div className="preview-topbar">
241 <span />
242 <span />
243 <span />
244 </div>
245
246 <div className="preview-header">
247 <div>
248 <span>Live dashboard</span>
249 <h3>Reservation Control Center</h3>
250 </div>
251
252 <div className="preview-live">
253 <span className="status-dot" />
254 Online
255 </div>
256 </div>
257
258 <div className="preview-stats">
259 <div>
260 <DoorOpen size={22} />
261 <strong>Rooms</strong>
262 <span>Availability search</span>
263 </div>
264 <div>
265 <ClipboardCheck size={22} />
266 <strong>Approvals</strong>
267 <span>Role-based decisions</span>
268 </div>
269 <div>
270 <BarChart3 size={22} />
271 <strong>Reports</strong>
272 <span>Advanced analytics</span>
273 </div>
274 </div>
275
276 <div className="preview-flow">
277 <div className="flow-item">
278 <CheckCircle2 size={18} />
279 Search available rooms
280 </div>
281 <div className="flow-item">
282 <CheckCircle2 size={18} />
283 Create pending reservation
284 </div>
285 <div className="flow-item">
286 <CheckCircle2 size={18} />
287 Approve through stored function
288 </div>
289 </div>
290 </motion.div>
291 </main>
292 </div>
293 );
294}
295
296function LoginScreen({ onLogin, onBack, onRegisterClick }) {
297 const [form, setForm] = useState({
298 identifier: "",
299 password: "",
300 });
301
302 const [loading, setLoading] = useState(false);
303
304 function updateField(field, value) {
305 setForm((prev) => ({ ...prev, [field]: value }));
306 }
307
308 async function submit(e) {
309 e.preventDefault();
310
311 if (!form.identifier.trim()) {
312 toast.error("Username or email is required.");
313 return;
314 }
315
316 if (!form.password.trim()) {
317 toast.error("Password is required.");
318 return;
319 }
320
321 setLoading(true);
322
323 try {
324 const user = await loginUser({
325 identifier: form.identifier,
326 password: form.password,
327 });
328
329 onLogin(user);
330 } catch (err) {
331 toast.error(err.message);
332 } finally {
333 setLoading(false);
334 }
335 }
336
337 return (
338 <div className="auth-page">
339 <div className="landing-orb orb-one" />
340 <div className="landing-orb orb-two" />
341 <div className="landing-orb orb-three" />
342
343 <motion.div
344 className="auth-card"
345 initial={{ opacity: 0, y: 28, scale: 0.96 }}
346 animate={{ opacity: 1, y: 0, scale: 1 }}
347 transition={{ duration: 0.55, ease: "easeOut" }}
348 >
349 <button className="auth-back-button" onClick={onBack}>
350 ← Back to home
351 </button>
352
353 <div className="auth-left-panel">
354 <button
355 className="auth-logo logo-home-button"
356 type="button"
357 onClick={onBack}
358 title="Back to home"
359 >
360 <img src="/logo.png" alt="Room Reservation logo" />
361 </button>
362
363 <div className="auth-left-copy">
364 <div className="landing-eyebrow">
365 <ShieldCheck size={16} />
366 Role-based access
367 </div>
368
369 <h2>Access the reservation workspace.</h2>
370 <p>
371 Continue to the reservation system with the permissions assigned
372 to your user role. Manage reservations, approvals, reports and
373 administrative modules.
374 </p>
375 </div>
376 </div>
377
378 <div className="auth-right-panel">
379 <form className="login-form polished" onSubmit={submit}>
380 <div className="auth-form-header">
381 <div className="auth-icon-badge">
382 <UserRound size={24} />
383 </div>
384
385 <div>
386 <h1>Sign In</h1>
387 <p>Enter your account details and continue with role-based access.</p>
388 </div>
389 </div>
390
391 <label>
392 Username or email
393 <input
394 value={form.identifier}
395 onChange={(e) => updateField("identifier", e.target.value)}
396 placeholder="Enter username or email"
397 autoComplete="username"
398 />
399 </label>
400
401 <label>
402 Password
403 <input
404 type="password"
405 value={form.password}
406 onChange={(e) => updateField("password", e.target.value)}
407 placeholder="Enter your password"
408 autoComplete="current-password"
409 />
410 </label>
411
412 <button className="primary full auth-submit" disabled={loading}>
413 {loading ? "Signing in..." : "Sign in"}
414 <ArrowRight size={18} />
415 </button>
416
417 <button className="auth-link-button" type="button" onClick={onRegisterClick}>
418 Create account
419 </button>
420 </form>
421 </div>
422 </motion.div>
423 </div>
424 );
425}
426
427function RegisterScreen({ onBack, onLoginClick, onRegistered }) {
428 const [form, setForm] = useState({
429 fullName: "",
430 email: "",
431 username: "",
432 password: "",
433 confirmPassword: "",
434 });
435
436 const [loading, setLoading] = useState(false);
437
438 function updateField(field, value) {
439 setForm((prev) => ({
440 ...prev,
441 [field]: value,
442 }));
443 }
444
445 async function submit(e) {
446 e.preventDefault();
447
448 if (!form.fullName.trim()) {
449 toast.error("Full name is required.");
450 return;
451 }
452
453 if (!form.email.trim()) {
454 toast.error("Email is required.");
455 return;
456 }
457
458 if (!form.username.trim()) {
459 toast.error("Username is required.");
460 return;
461 }
462
463 if (!form.password.trim()) {
464 toast.error("Password is required.");
465 return;
466 }
467
468 if (form.password.length < 8) {
469 toast.error("Password must contain at least 8 characters.");
470 return;
471 }
472
473 if (form.password !== form.confirmPassword) {
474 toast.error("Passwords do not match.");
475 return;
476 }
477
478 setLoading(true);
479
480 try {
481 const user = await registerUser({
482 fullName: form.fullName.trim(),
483 email: form.email.trim(),
484 username: form.username.trim(),
485 password: form.password,
486 });
487
488 toast.success("Account created successfully.");
489 onRegistered(user);
490 } catch (err) {
491 toast.error(err.message);
492 } finally {
493 setLoading(false);
494 }
495 }
496
497 return (
498 <div className="register-page">
499 <div className="landing-orb orb-one" />
500 <div className="landing-orb orb-two" />
501 <div className="landing-orb orb-three" />
502
503 <motion.div
504 className="register-card"
505 initial={{ opacity: 0, y: 28, scale: 0.96 }}
506 animate={{ opacity: 1, y: 0, scale: 1 }}
507 transition={{ duration: 0.5, ease: "easeOut" }}
508 >
509 <button className="auth-back-button" type="button" onClick={onBack}>
510 ← Back to home
511 </button>
512
513 <div className="register-left">
514 <button
515 className="register-logo-button logo-home-button"
516 type="button"
517 onClick={onBack}
518 title="Back to home"
519 >
520 <img src="/logo.png" alt="Room Reservation logo" />
521 </button>
522
523 <div>
524 <div className="landing-eyebrow">
525 <UserRound size={16} />
526 Account creation
527 </div>
528
529 <h1>Create your account</h1>
530
531 <p>
532 Create a standard user account to submit room and equipment reservation requests.
533 Administrative permissions are assigned separately by the system administrator.
534 </p>
535 </div>
536 </div>
537
538 <form className="register-form" onSubmit={submit}>
539 <label>
540 Full name
541 <input
542 value={form.fullName}
543 onChange={(e) => updateField("fullName", e.target.value)}
544 placeholder="Enter your full name"
545 autoComplete="name"
546 />
547 </label>
548
549 <label>
550 Email
551 <input
552 type="email"
553 value={form.email}
554 onChange={(e) => updateField("email", e.target.value)}
555 placeholder="example@finki.ukim.mk"
556 autoComplete="email"
557 />
558 </label>
559
560 <label>
561 Username
562 <input
563 value={form.username}
564 onChange={(e) => updateField("username", e.target.value)}
565 placeholder="example_user"
566 autoComplete="username"
567 />
568 </label>
569
570 <label>
571 Password
572 <input
573 type="password"
574 value={form.password}
575 onChange={(e) => updateField("password", e.target.value)}
576 placeholder="At least 8 characters"
577 autoComplete="new-password"
578 />
579 </label>
580
581 <label>
582 Confirm password
583 <input
584 type="password"
585 value={form.confirmPassword}
586 onChange={(e) => updateField("confirmPassword", e.target.value)}
587 placeholder="Repeat password"
588 autoComplete="new-password"
589 />
590 </label>
591
592 <button className="landing-primary full" type="submit" disabled={loading}>
593 {loading ? "Creating account..." : "Create account"}
594 <ArrowRight size={19} />
595 </button>
596
597 <button className="auth-link-button" type="button" onClick={onLoginClick}>
598 Already have an account? Sign in
599 </button>
600 </form>
601 </motion.div>
602 </div>
603 );
604}
605
606function AppShell({ activeUser, activePage, setActivePage, onLogout, children }) {
607 const items = useMemo(() => {
608 const base = [
609 { key: "dashboard", label: "Dashboard", icon: Activity, roles: ["regular", "approver", "admin"] },
610 { key: "search", label: "Search Rooms", icon: Search, roles: ["regular", "approver", "admin"] },
611 { key: "create", label: "Create Reservation", icon: PlusCircle, roles: ["regular", "approver", "admin"] },
612 { key: "approvals", label: "Approvals", icon: ClipboardCheck, roles: ["approver", "admin"] },
613 { key: "reports", label: "Reports", icon: BarChart3, roles: ["regular", "approver", "admin"] },
614 { key: "admin", label: "Admin Panel", icon: ShieldCheck, roles: ["admin"] },
615 ];
616
617 return base.filter((item) => item.roles.includes(activeUser.role));
618 }, [activeUser.role]);
619
620 const activeItem = items.find((item) => item.key === activePage);
621
622 useEffect(() => {
623 const pageIsAllowed = items.some((item) => item.key === activePage);
624
625 if (!pageIsAllowed) {
626 localStorage.setItem("activePage", "dashboard");
627 setActivePage("dashboard");
628 }
629 }, [items, activePage, setActivePage]);
630
631 return (
632 <div className="app-shell">
633 <aside className="sidebar">
634 <button
635 className="brand brand-only-logo logo-home-button"
636 type="button"
637 onClick={() => setActivePage("dashboard")}
638 title="Go to dashboard"
639 >
640 <div className="brand-logo">
641 <img src="/logo.png" alt="Room Reservation logo" />
642 </div>
643 </button>
644
645 <nav className="nav-menu">
646 {items.map((item) => {
647 const Icon = item.icon;
648
649 return (
650 <button
651 key={item.key}
652 className={activePage === item.key ? "active" : ""}
653 onClick={() => setActivePage(item.key)}
654 >
655 <Icon size={19} />
656 <span>{item.label}</span>
657 </button>
658 );
659 })}
660 </nav>
661
662 <div className="sidebar-footer">
663 <div className="user-card">
664 <div className="avatar small">{getInitials(activeUser.fullName)}</div>
665
666 <div>
667 <strong>{activeUser.fullName}</strong>
668 <span>{activeUser.role}</span>
669 </div>
670
671 <button className="icon-button" onClick={onLogout} title="Logout">
672 <LogOut size={17} />
673 </button>
674 </div>
675 </div>
676 </aside>
677
678 <main className="main-area">
679 <header className="topbar">
680 <div>
681 <h2>{activeItem?.label || "Room Reservation"}</h2>
682 </div>
683
684 <div className="topbar-actions">
685 <div className="status-pill">
686 <span className="status-dot" />
687 Live database
688 </div>
689
690 <div className="top-user">
691 <UserRound size={18} />
692 <div>
693 <strong>{activeUser.fullName}</strong>
694 <span>{activeUser.role}</span>
695 </div>
696 </div>
697 </div>
698 </header>
699
700 {children}
701 </main>
702 </div>
703 );
704}
705
706function Dashboard({ activeUser, setActivePage }) {
707 const [stats, setStats] = useState(null);
708 const [databaseOnline, setDatabaseOnline] = useState(false);
709 const [loading, setLoading] = useState(true);
710
711 useEffect(() => {
712 let mounted = true;
713
714 async function loadDashboard() {
715 setLoading(true);
716
717 try {
718 const [statsResponse, healthResponse] = await Promise.all([
719 getDashboardStats(),
720 getHealth().catch(() => null),
721 ]);
722
723 if (!mounted) {
724 return;
725 }
726
727 setStats(statsResponse);
728 setDatabaseOnline(Boolean(healthResponse));
729 } catch (err) {
730 if (!mounted) {
731 return;
732 }
733
734 setDatabaseOnline(false);
735 toast.error(err.message || "Dashboard data could not be loaded.");
736 } finally {
737 if (mounted) {
738 setLoading(false);
739 }
740 }
741 }
742
743 loadDashboard();
744
745 return () => {
746 mounted = false;
747 };
748 }, []);
749
750 const safeStats = stats || {
751 totalRooms: 0,
752 totalEquipmentTypes: 0,
753 totalUsers: 0,
754 pendingReservations: 0,
755 approvedReservations: 0,
756 };
757
758 const firstName = activeUser?.fullName?.split(" ")?.[0] || "User";
759 const role = activeUser?.role || "regular";
760
761 const statCards = [
762 {
763 label: "Rooms",
764 value: safeStats.totalRooms,
765 meta: "Available spaces",
766 icon: DoorOpen,
767 tone: "blue",
768 },
769 {
770 label: "Equipment",
771 value: safeStats.totalEquipmentTypes,
772 meta: "Resource types",
773 icon: Boxes,
774 tone: "purple",
775 },
776 {
777 label: "Users",
778 value: safeStats.totalUsers,
779 meta: "System accounts",
780 icon: Users,
781 tone: "cyan",
782 },
783 {
784 label: "Pending",
785 value: safeStats.pendingReservations,
786 meta: "Awaiting decision",
787 icon: CalendarCheck,
788 tone: "orange",
789 },
790 {
791 label: "Approved",
792 value: safeStats.approvedReservations,
793 meta: "Confirmed bookings",
794 icon: CheckCircle2,
795 tone: "green",
796 },
797 ];
798
799 const quickActions = [
800 {
801 page: "search",
802 title: "Search available rooms",
803 description: "Check capacity, type, equipment and time overlap.",
804 icon: Search,
805 },
806 {
807 page: "create",
808 title: "Create reservation",
809 description: "Submit a room or equipment request for approval.",
810 icon: PlusCircle,
811 },
812 ...(role === "approver" || role === "admin"
813 ? [
814 {
815 page: "approvals",
816 title: "Review approvals",
817 description: "Approve or reject pending reservation requests.",
818 icon: ClipboardCheck,
819 },
820 ]
821 : []),
822 {
823 page: "reports",
824 title: "Open reports",
825 description: "View advanced SQL analytical reports.",
826 icon: BarChart3,
827 },
828 ...(role === "admin"
829 ? [
830 {
831 page: "admin",
832 title: "Admin panel",
833 description: "Manage administrative project modules.",
834 icon: ShieldCheck,
835 },
836 ]
837 : []),
838 ];
839
840 return (
841 <motion.div
842 className="dashboard-page premium-dashboard"
843 initial={{ opacity: 0, y: 18 }}
844 animate={{ opacity: 1, y: 0 }}
845 transition={{ duration: 0.45, ease: "easeOut" }}
846 >
847 <section className="dashboard-hero-compact">
848 <div>
849 <div className="dashboard-eyebrow">
850 <Sparkles size={16} />
851 System overview
852 </div>
853
854 <h1>Welcome back, {firstName}</h1>
855
856 <p>
857 Manage reservations, approvals, room availability and analytical
858 PostgreSQL reports from one control center.
859 </p>
860 </div>
861 </section>
862
863 <section className="dashboard-stat-grid">
864 {statCards.map((card) => {
865 const Icon = card.icon;
866
867 return (
868 <motion.article
869 className={`dashboard-stat-card ${card.tone}`}
870 key={card.label}
871 whileHover={{ y: -6, scale: 1.01 }}
872 transition={{ duration: 0.18 }}
873 >
874 <div className="stat-icon-wrap">
875 <Icon size={24} />
876 </div>
877
878 <div>
879 <span>{card.label}</span>
880 <strong>{loading ? "—" : card.value}</strong>
881 <p>{card.meta}</p>
882 </div>
883 </motion.article>
884 );
885 })}
886 </section>
887
888 <section className="dashboard-main-grid">
889 <article className="dashboard-panel quick-panel">
890 <div className="panel-heading">
891 <div>
892 <h2>Quick actions</h2>
893 <p>Frequently used reservation workflows.</p>
894 </div>
895 </div>
896
897 <div className="quick-action-grid">
898 {quickActions.map((action) => {
899 const Icon = action.icon;
900
901 return (
902 <button
903 key={action.page}
904 type="button"
905 onClick={() => setActivePage(action.page)}
906 >
907 <Icon size={24} />
908 <strong>{action.title}</strong>
909 <span>{action.description}</span>
910 </button>
911 );
912 })}
913 </div>
914 </article>
915
916 <aside className="dashboard-panel status-panel">
917 <div className="panel-heading">
918 <div>
919 <h2>System status</h2>
920 <p>Current connection and access information.</p>
921 </div>
922 </div>
923
924 <div className="status-stack">
925 <div className="status-card database">
926 <Database size={28} />
927 <div>
928 <span>Status</span>
929 <strong>{databaseOnline ? "CONNECTED" : "CHECK CONNECTION"}</strong>
930 <p>Live project database · {loading ? "—" : safeStats.totalRooms} rooms loaded</p>
931 </div>
932 </div>
933
934 <div className="status-card role">
935 <ShieldCheck size={28} />
936 <div>
937 <span>Active role</span>
938 <strong>{role.toUpperCase()}</strong>
939 <p>Your available modules depend on this role.</p>
940 </div>
941 </div>
942 </div>
943 </aside>
944 </section>
945 </motion.div>
946 );
947}
948
949function PageMotion({ children }) {
950 return (
951 <motion.div
952 initial={{ opacity: 0, y: 18, filter: "blur(6px)" }}
953 animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
954 exit={{ opacity: 0, y: -12, filter: "blur(4px)" }}
955 transition={{ duration: 0.28, ease: "easeOut" }}
956 >
957 {children}
958 </motion.div>
959 );
960}
961
962function getInitials(name) {
963 if (!name) {
964 return "U";
965 }
966
967 return name
968 .split(" ")
969 .map((part) => part[0])
970 .slice(0, 2)
971 .join("")
972 .toUpperCase();
973}
974
975export default App;
Note: See TracBrowser for help on using the repository browser.