diff --git a/frontend/src/app/(event)/[event-code]/page-client.tsx b/frontend/src/app/(event)/[event-code]/page-client.tsx index a9bbf1362..03300d347 100644 --- a/frontend/src/app/(event)/[event-code]/page-client.tsx +++ b/frontend/src/app/(event)/[event-code]/page-client.tsx @@ -6,9 +6,11 @@ import { PencilIcon, ShareIcon, SquarePenIcon } from "lucide-react"; import KebabMenu from "@/components/kebab-menu"; import { EventInformation } from "@/core/event/types"; +import ActionButton from "@/features/button/components/action"; import EmptyButton from "@/features/button/components/empty"; import LinkButton from "@/features/button/components/link"; import ScheduleGrid from "@/features/event/grid/grid"; +import { GRID_ID_SELECTOR } from "@/features/event/grid/lib/constants"; import AttendeesPanel from "@/features/event/results/attendees/desktop-panel"; import AttendeesDrawer from "@/features/event/results/attendees/mobile-drawer"; import { getResultBanners } from "@/features/event/results/components/banners"; @@ -20,6 +22,7 @@ import { import { ResultsInformation } from "@/features/event/results/lib/types"; import HeaderSpacer from "@/features/header/components/header-spacer"; import ShareMenu from "@/features/share-menu/menu"; +import { useViewTransition } from "@/lib/hooks/use-view-transition"; import { cn } from "@/lib/utils/classname"; export default function ClientPage({ @@ -86,13 +89,18 @@ function EventResults({ eventData }: { eventData: EventInformation }) { currentUser !== null, ); + const doViewTransition = useViewTransition(); + /* BUTTONS */ const paintingButton = ( - } label={(currentUser ? "Edit" : "Add") + " Availability"} - href={`/${eventCode}/painting`} + onClick={() => { + doViewTransition(`/${eventCode}/painting`, GRID_ID_SELECTOR); + }} + loadOnSuccess /> ); @@ -172,6 +180,13 @@ function EventResults({ eventData }: { eventData: EventInformation }) { eventTitle={eventTitle} eventCode={eventCode} /> + {/* Desktop Sidebar */} diff --git a/frontend/src/app/(event)/[event-code]/painting/page-client.tsx b/frontend/src/app/(event)/[event-code]/painting/page-client.tsx index 8fb4afac0..8d1c15441 100644 --- a/frontend/src/app/(event)/[event-code]/painting/page-client.tsx +++ b/frontend/src/app/(event)/[event-code]/painting/page-client.tsx @@ -3,7 +3,6 @@ import { useEffect, useRef, useState } from "react"; import { parseISO } from "date-fns"; -import { useRouter } from "next/navigation"; import { useDebouncedCallback } from "use-debounce"; import Checkbox from "@/components/checkbox"; @@ -12,17 +11,18 @@ import TextInputField from "@/components/text-input-field"; import { useAvailability } from "@/core/availability/use-availability"; import { EventRange } from "@/core/event/types"; import ActionButton from "@/features/button/components/action"; -import LinkButton from "@/features/button/components/link"; import { MAX_DISPLAY_NAME_LENGTH } from "@/features/event/availability/constants"; import { validateAvailabilityData } from "@/features/event/availability/validate-data"; import TimeZoneSelector from "@/features/event/components/selectors/timezone"; import { ScheduleGrid } from "@/features/event/grid"; +import { GRID_ID_SELECTOR } from "@/features/event/grid/lib/constants"; import HeaderSpacer from "@/features/header/components/header-spacer"; import { ConfirmationDialog, RateLimitBanner, useToast, } from "@/features/system-feedback"; +import { useViewTransition } from "@/lib/hooks/use-view-transition"; import { MESSAGES } from "@/lib/messages"; import { clientPost } from "@/lib/utils/api/client-fetch"; import { ROUTES } from "@/lib/utils/api/endpoints"; @@ -46,7 +46,7 @@ export default function ClientPage({ timeslots: Date[]; initialData: SelfAvailability | null; }) { - const router = useRouter(); + const doViewTransition = useViewTransition(); // AVAILABILITY STATE const { state, setDisplayName, setTimeZone, toggleSlot } = useAvailability( @@ -212,7 +212,7 @@ export default function ClientPage({ try { await clientPost(ROUTES.availability.add, payload); - router.push(`/${eventCode}`); + doViewTransition(`/${eventCode}`, GRID_ID_SELECTOR); return true; } catch (e) { const error = e as ApiErrorResponse; @@ -230,10 +230,11 @@ export default function ClientPage({ // BUTTONS const cancelButton = ( - doViewTransition(`/${eventCode}`, GRID_ID_SELECTOR)} + loadOnSuccess /> ); const submitButton = ( @@ -321,6 +322,7 @@ export default function ClientPage({
+ (null); @@ -40,6 +42,11 @@ export default function MobileFooterIsland({ "border-foreground/10 border", "flex flex-col gap-3", )} + style={{ + viewTransitionName: viewTransitionName + ? viewTransitionName + : undefined, + }} > {children}
diff --git a/frontend/src/features/drawer/components/base.tsx b/frontend/src/features/drawer/components/base.tsx index c4bdeb543..517bbb61b 100644 --- a/frontend/src/features/drawer/components/base.tsx +++ b/frontend/src/features/drawer/components/base.tsx @@ -28,6 +28,7 @@ export default function BaseDrawer({ showOverlay = !frostedGlass && modal, nested = false, hideCloseButton = false, + viewTransitionName, ...rest }: DrawerProps) { useDrawerResize(); @@ -155,7 +156,12 @@ export default function BaseDrawer({ _type !== "floating" && "h-[100dvh]", contentClassName, )} - style={{ zIndex: contentZIndex }} + style={{ + zIndex: contentZIndex, + viewTransitionName: viewTransitionName + ? viewTransitionName + : undefined, + }} >
; return ( -
+
(0.22); + /* SNAP POINTS */ + const [activeSnap, setActiveSnap] = useState(0); + const [currentSnapPoints, setCurrentSnapPoints] = useState([ + 0, 0.22, 0.37, + ]); useEffect(() => { onSnapChange(activeSnap); @@ -51,16 +56,31 @@ export default function AttendeesDrawer({ } }, [isCollapsed, isRemoving, clearSelectedParticipants, setIsRemoving]); + const doViewTransition = useViewTransition(); + /* BUTTONS */ const paintingButton = ( - } label={(currentUser ? "Edit" : "Add") + " Availability"} - href={`/${eventCode}/painting`} + onClick={() => { + doViewTransition(`/${eventCode}/painting`, GRID_ID_SELECTOR); + }} + loadOnSuccess /> ); + useEffect(() => { + // Wait for the view transition to finish, then animate the drawer in + const timer = setTimeout(() => { + setCurrentSnapPoints([0.22, 0.37]); + setActiveSnap(0.22); + }, 350); + + return () => clearTimeout(timer); + }, []); + return ( } + viewTransitionName="results-drawer" > , ariaLabel: "Dark Theme" }, ]} value={theme} - onChange={setTheme} + onChange={toggleTheme} className="frosted-glass-inset" />
diff --git a/frontend/src/lib/hooks/use-theme-toggle.ts b/frontend/src/lib/hooks/use-theme-toggle.ts new file mode 100644 index 000000000..658e8c65d --- /dev/null +++ b/frontend/src/lib/hooks/use-theme-toggle.ts @@ -0,0 +1,30 @@ +import { useTheme } from "next-themes"; + +export function useThemeToggle() { + const { theme, resolvedTheme, setTheme } = useTheme(); + + const toggleTheme = (newTheme: string) => { + let targetTheme = newTheme; + + if (newTheme === "system") { + targetTheme = window.matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light"; + } + + if (targetTheme === resolvedTheme) { + setTheme(newTheme); + return; + } + + if (!document.startViewTransition) { + setTheme(newTheme); + } else { + document.startViewTransition(() => { + setTheme(newTheme); + }); + } + }; + + return { theme, toggleTheme }; +} diff --git a/frontend/src/lib/hooks/use-view-transition.ts b/frontend/src/lib/hooks/use-view-transition.ts new file mode 100644 index 000000000..2d2f06ae1 --- /dev/null +++ b/frontend/src/lib/hooks/use-view-transition.ts @@ -0,0 +1,66 @@ +"use client"; + +import { useRouter } from "next/navigation"; + +export function useViewTransition() { + const router = useRouter(); + + const doViewTransition = ( + href: string, + targetSelector?: string, + timeout: number = 3000, + ) => { + if (!document.startViewTransition) { + router.push(href); + return; + } + + const currentPath = window.location.pathname; + const previousTarget = targetSelector + ? document.querySelector(targetSelector) + : null; + + const transition = document.startViewTransition(() => { + return new Promise((resolve, reject) => { + const observer = new MutationObserver(() => { + // Don't resolve if the page hasn't changed yet + if (window.location.pathname === currentPath) return; + + if (targetSelector) { + // Only resolve if the target element has mounted, bypassing loading.tsx + const target = document.querySelector(targetSelector); + if (target && target !== previousTarget) { + observer.disconnect(); + clearTimeout(timer); + resolve(); + } + } else { + // Resolve on any mutation if no selector is provided + observer.disconnect(); + clearTimeout(timer); + resolve(); + } + }); + + observer.observe(document.body, { + childList: true, + subtree: true, + }); + + router.push(href); + + const timer = setTimeout(() => { + observer.disconnect(); + // This aborts the view transition, showing the loading skeleton instead + reject(); + }, timeout); + }); + }); + + // Catch the rejection promises + transition.ready.catch(() => {}); + transition.finished.catch(() => {}); + }; + + return doViewTransition; +} diff --git a/frontend/src/styles/globals.css b/frontend/src/styles/globals.css index a6754d77c..a2f3d8e4d 100644 --- a/frontend/src/styles/globals.css +++ b/frontend/src/styles/globals.css @@ -9,6 +9,7 @@ @import "./radix.css"; @import "./scrollbar.css"; @import "./header.css"; +@import "./view-transition.css"; /* GLOBAL STYLES */ diff --git a/frontend/src/styles/view-transition.css b/frontend/src/styles/view-transition.css new file mode 100644 index 000000000..97d5ed9f2 --- /dev/null +++ b/frontend/src/styles/view-transition.css @@ -0,0 +1,18 @@ +:root { + --vt-duration: 0.35s; + --vt-timing-function: ease-in-out; +} + +::view-transition-group(*) { + animation-duration: var(--vt-duration); + animation-timing-function: var(--vt-timing-function); +} + +::view-transition-group(grid) { + z-index: 1; +} + +::view-transition-group(results-drawer), +::view-transition-group(painting-island) { + z-index: 100; +}