Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 138 additions & 1 deletion src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useRef, useEffect, useState } from 'react'
import { motion } from 'framer-motion'
import { Helmet } from 'react-helmet-async'
import { CreateOrgModal } from '@/app/components/CreateOrgModal'
import { AppSearchModal } from '@/app/components/AppSearchModal'
import { AppTopNav } from '@/app/components/AppTopNav'
import { CreateEventModal } from '@/app/components/CreateEventModal'
import { ExitOverlay } from '@/shared/components/ExitOverlay'
Expand All @@ -14,14 +15,21 @@ import { EventsSection } from './sections/EventsSection'
import { OrgsSection } from './sections/OrgsSection'
import styles from './App.module.css'

const easeInOutQuart = (t: number) =>
t < 0.5 ? 8 * t * t * t * t : 1 - Math.pow(-2 * t + 2, 4) / 2

/**
* The main App component (/app).
* Features a horizontally scrolling scaffold with snapping pages.
*/
export default function AppMain() {
const scrollerRef = useRef<HTMLElement | null>(null)
const scrollRafRef = useRef<number | null>(null)
const releaseSnapTimerRef = useRef<number | null>(null)
const [isCreateEventOpen, setIsCreateEventOpen] = useState(false)
const [isCreateOrgOpen, setIsCreateOrgOpen] = useState(false)
const [isSearchOpen, setIsSearchOpen] = useState(false)
const [searchSessionKey, setSearchSessionKey] = useState(0)
const [eventsRefreshKey, setEventsRefreshKey] = useState(0)
const [organizationsRefreshKey, setOrganizationsRefreshKey] = useState(0)
useHorizontalWheelScroll(scrollerRef, { endCutoffPx: 0 })
Expand All @@ -36,8 +44,126 @@ export default function AppMain() {
scrollerRef.current.scrollLeft = homeSection.offsetLeft
}
}, 10)

return () => {
if (scrollRafRef.current != null) {
window.cancelAnimationFrame(scrollRafRef.current)
}
if (releaseSnapTimerRef.current != null) {
window.clearTimeout(releaseSnapTimerRef.current)
}
}
}, [])

const scrollSectionIntoView = (section: HTMLElement) => {
const scroller = scrollerRef.current
if (!scroller) return

const scrollerRect = scroller.getBoundingClientRect()
const sectionRect = section.getBoundingClientRect()
const sectionCenter =
scroller.scrollLeft + (sectionRect.left - scrollerRect.left) + sectionRect.width / 2
const rawLeft = sectionCenter - scroller.clientWidth / 2
const maxLeft = Math.max(0, scroller.scrollWidth - scroller.clientWidth)
const targetLeft = Math.max(0, Math.min(rawLeft, maxLeft))
const startLeft = scroller.scrollLeft
const distance = targetLeft - startLeft

if (Math.abs(distance) < 1) {
scroller.scrollLeft = targetLeft
return
}

if (scrollRafRef.current != null) {
window.cancelAnimationFrame(scrollRafRef.current)
scrollRafRef.current = null
}

if (releaseSnapTimerRef.current != null) {
window.clearTimeout(releaseSnapTimerRef.current)
}

scroller.style.scrollSnapType = 'none'

const durationMs = Math.min(560, Math.max(220, Math.abs(distance) * 0.4))
let startedAt: number | null = null

const tick = (now: number) => {
if (startedAt == null) {
startedAt = now
}

const elapsed = now - startedAt
const t = Math.min(1, elapsed / durationMs)
const eased = easeInOutQuart(t)

scroller.scrollLeft = startLeft + distance * eased

if (t < 1) {
scrollRafRef.current = window.requestAnimationFrame(tick)
return
}

scroller.scrollLeft = targetLeft
scroller.style.scrollSnapType = ''
scrollRafRef.current = null
}

releaseSnapTimerRef.current = window.setTimeout(() => {
if (scrollRafRef.current != null) {
window.cancelAnimationFrame(scrollRafRef.current)
scrollRafRef.current = null
}
scroller.style.scrollSnapType = ''
}, durationMs + 120)

scrollRafRef.current = window.requestAnimationFrame(tick)
}

const handleSearchSelect = (key: string) => {
setIsSearchOpen(false)

const target = document.querySelector<HTMLElement>(`[data-search-key="${key}"]`)
if (!target) return

const targetSectionId = target.dataset.searchSection
const targetSection = targetSectionId
? document.getElementById(targetSectionId)
: target.closest('.panel')

if (targetSection instanceof HTMLElement) {
scrollSectionIntoView(targetSection)
}

const activateTarget = () => {
if (target.dataset.searchAction === 'focus') {
target.focus({ preventScroll: true })
return
}

if (target.dataset.searchAction === 'click') {
if (target.closest('[data-native-horizontal-scroll]')) {
target.scrollIntoView({
behavior: 'smooth',
inline: 'center',
block: 'nearest',
})
window.setTimeout(() => {
target.click()
}, 220)
return
}

target.click()
return
}

target.focus?.({ preventScroll: true })
}

window.setTimeout(activateTarget, 340)
}

return (
<>
<Helmet>
Expand All @@ -46,7 +172,12 @@ export default function AppMain() {
</Helmet>

<div className={styles.appRoot}>
<AppTopNav />
<AppTopNav
onOpenSearch={() => {
setSearchSessionKey((current) => current + 1)
setIsSearchOpen(true)
}}
/>

<main className={styles.horizontalScroller} ref={scrollerRef} id="scroller">
<motion.div
Expand Down Expand Up @@ -86,6 +217,12 @@ export default function AppMain() {
setIsCreateOrgOpen(false)
}}
/>
<AppSearchModal
key={searchSessionKey}
isOpen={isSearchOpen}
onClose={() => setIsSearchOpen(false)}
onSelect={handleSearchSelect}
/>
<ExitOverlay />
</>
)
Expand Down
168 changes: 168 additions & 0 deletions src/app/components/AppSearchModal.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
.overlay {
position: fixed;
inset: 0;
z-index: 28;
background: linear-gradient(
180deg,
rgba(1, 23, 22, 0.32),
rgba(1, 23, 22, 0.08) 34%,
transparent 64%
);
display: flex;
justify-content: center;
align-items: flex-start;
padding: clamp(80px, 12svh, 120px) 1rem 1rem;
}

.popup {
width: min(100%, 680px);
max-height: min(70svh, 720px);
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
gap: 0.9rem;
padding: clamp(1rem, 2.6vw, 1.25rem);
border-radius: 28px;
border: 1px solid rgba(133, 232, 225, 0.28);
background:
linear-gradient(180deg, rgba(20, 88, 83, 0.92), rgba(12, 61, 57, 0.95)),
radial-gradient(circle at top, rgba(124, 230, 220, 0.16), transparent 42%);
box-shadow: 0 26px 64px rgba(0, 21, 19, 0.42);
backdrop-filter: blur(18px);
}

.header {
display: flex;
align-items: center;
gap: 0.9rem;
}

.searchIcon {
width: 44px;
height: 44px;
border-radius: 999px;
display: grid;
place-items: center;
color: rgba(240, 255, 253, 0.92);
background: linear-gradient(180deg, rgba(123, 226, 217, 0.22), rgba(82, 174, 166, 0.16));
border: 1px solid rgba(168, 243, 237, 0.24);
}

.searchIcon svg {
width: 20px;
height: 20px;
}

.headerText {
min-width: 0;
}

.title {
margin: 0;
color: var(--c-text-light);
font: 600 clamp(1.15rem, 2vw, 1.45rem) / 1 var(--font-display);
text-transform: lowercase;
}

.subtitle {
margin: 0.18rem 0 0;
color: rgba(232, 251, 249, 0.7);
font: 400 0.92rem/1.35 var(--font-body);
}

.input {
width: 100%;
min-height: 56px;
padding: 0 1rem;
border-radius: 20px;
border: 1px solid rgba(157, 238, 232, 0.2);
background: rgba(1, 31, 29, 0.34);
color: var(--c-text-light);
font: 400 1rem/1.2 var(--font-body);
outline: none;
box-sizing: border-box;
}

.input::placeholder {
color: rgba(232, 251, 249, 0.48);
}

.input:focus {
border-color: rgba(170, 245, 239, 0.42);
box-shadow: 0 0 0 3px rgba(140, 235, 227, 0.14);
}

.results {
overflow-y: auto;
display: grid;
gap: 0.6rem;
transition: max-height 220ms var(--ease-premium);
}

.resultsCompact {
max-height: calc((5.7rem * 3) + 1.2rem);
}

.resultsExpanded {
max-height: 100%;
}

.resultButton {
width: 100%;
text-align: left;
display: grid;
gap: 0.2rem;
padding: 0.9rem 1rem;
border: 1px solid rgba(146, 233, 226, 0.1);
border-radius: 20px;
background: rgba(6, 38, 36, 0.44);
color: var(--c-text-light);
cursor: pointer;
transition:
transform 200ms var(--ease-premium),
border-color 200ms var(--ease-premium),
background 200ms var(--ease-premium);
}

.resultButton:hover,
.isSelected {
transform: translateY(-1px);
border-color: rgba(167, 243, 237, 0.3);
background: rgba(19, 71, 67, 0.72);
}

.resultMeta {
color: rgba(171, 240, 235, 0.74);
font: 600 0.72rem/1.1 var(--font-body);
letter-spacing: 0.12em;
text-transform: uppercase;
}

.resultLabel {
font: 500 1rem/1.2 var(--font-display);
text-transform: lowercase;
}

.resultDescription {
color: rgba(235, 252, 250, 0.72);
font: 400 0.9rem/1.4 var(--font-body);
}

.emptyState {
padding: 1rem;
border-radius: 20px;
background: rgba(6, 38, 36, 0.38);
color: rgba(235, 252, 250, 0.7);
font: 400 0.95rem/1.4 var(--font-body);
text-align: center;
}

@media (max-width: 720px) {
.overlay {
padding-top: 76px;
}

.popup {
max-height: min(72svh, 680px);
border-radius: 24px;
}
}
Loading
Loading