diff --git a/apps/www/public/portal/play_button_disabled_bg.png b/apps/www/public/portal/play_button_disabled_bg.png new file mode 100644 index 00000000..12c9c223 Binary files /dev/null and b/apps/www/public/portal/play_button_disabled_bg.png differ diff --git a/apps/www/public/portal/play_button_focused_bg.png b/apps/www/public/portal/play_button_focused_bg.png new file mode 100644 index 00000000..a7ab98cd Binary files /dev/null and b/apps/www/public/portal/play_button_focused_bg.png differ diff --git a/apps/www/public/portal/play_button_idle.png b/apps/www/public/portal/play_button_idle.png new file mode 100644 index 00000000..3b95b0eb Binary files /dev/null and b/apps/www/public/portal/play_button_idle.png differ diff --git a/apps/www/public/portal/play_button_intro.png b/apps/www/public/portal/play_button_intro.png new file mode 100644 index 00000000..a4d57c84 Binary files /dev/null and b/apps/www/public/portal/play_button_intro.png differ diff --git a/apps/www/public/portal/play_icon_exit.png b/apps/www/public/portal/play_icon_exit.png new file mode 100644 index 00000000..610ce6b4 Binary files /dev/null and b/apps/www/public/portal/play_icon_exit.png differ diff --git a/apps/www/public/portal/play_icon_intro.png b/apps/www/public/portal/play_icon_intro.png new file mode 100644 index 00000000..528503d9 Binary files /dev/null and b/apps/www/public/portal/play_icon_intro.png differ diff --git a/apps/www/public/portal/play_icon_loop.png b/apps/www/public/portal/play_icon_loop.png new file mode 100644 index 00000000..27e55e94 Binary files /dev/null and b/apps/www/public/portal/play_icon_loop.png differ diff --git a/apps/www/public/portal/portal_background_placeholder.png b/apps/www/public/portal/portal_background_placeholder.png new file mode 100644 index 00000000..31490490 Binary files /dev/null and b/apps/www/public/portal/portal_background_placeholder.png differ diff --git a/apps/www/src/root.tsx b/apps/www/src/root.tsx index 2ad5bbed..b9f71f3a 100644 --- a/apps/www/src/root.tsx +++ b/apps/www/src/root.tsx @@ -38,7 +38,7 @@ export default component$(() => { lang="en"> {/* {!isDev && } */} - + diff --git a/apps/www/src/routes/home/index.tsx b/apps/www/src/routes/home/index.tsx index 02d4478a..4e772a01 100644 --- a/apps/www/src/routes/home/index.tsx +++ b/apps/www/src/routes/home/index.tsx @@ -3,8 +3,8 @@ import { HomeNavBar, Card } from "@nestri/ui"; function getGreeting(): string { const hour = new Date().getHours(); - if (hour < 12) return "Good Morning"; - if (hour < 18) return "Good Afternoon"; + if (hour >= 5 && hour < 12) return "Good Morning"; + if (hour >= 12 && hour < 18) return "Good Afternoon"; return "Good Evening"; } @@ -13,78 +13,95 @@ export default component$(() => { <>
-
-

{getGreeting()}, Wanjohi

+
+

{getGreeting()}, Wanjohi

What will you play today?

-
- {/* */} -
-
- - - - -
+
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+ ) }) \ No newline at end of file diff --git a/apps/www/src/routes/index.tsx b/apps/www/src/routes/index.tsx index 08af6c70..0c4d4ce8 100644 --- a/apps/www/src/routes/index.tsx +++ b/apps/www/src/routes/index.tsx @@ -1,7 +1,7 @@ import { component$ } from "@builder.io/qwik"; import { Link, type DocumentHead } from "@builder.io/qwik-city"; -import { HeroSection, Cursor, MotionComponent, transition } from "@nestri/ui/react" -import { NavBar, Footer } from "@nestri/ui" +import { HeroSection, Cursor, MotionComponent, transition } from "@nestri/ui/react" +import { NavBar, Footer, Modal } from "@nestri/ui" import { BasicImageLoader } from "@nestri/ui/image"; const features = [ @@ -90,22 +90,70 @@ export default component$(() => { <> - +
+
+ + + + + + + This is not implemented yet + + + Try logging in to Steam to see if we can find your game + + +
+
+
+
+ 0 games indexed +
+
+ ALPHA V1 +
+
+ +
{ - const imageUrl = `http://localhost:8787/image/cover/${game.id}.avif` +export const Card = component$(({ titleWidth, titleHeight, game, size }: Props) => { + + const modalUrl = `https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/${game.id}/library_hero.jpg`; + const imageUrl = size == "large" ? `https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/${game.id}/header.jpg` : `https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/${game.id}/library_600x900_2x.jpg`; + const modalTitleUrl = `https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/${game.id}/logo.png` + const loadModalImages = $(() => { + return Promise.all([modalUrl, modalTitleUrl].map(url => { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = reject; + img.src = url; + }); + })); + }); + + // eslint-disable-next-line qwik/no-use-visible-task + useVisibleTask$(async () => { + await loadModalImages(); + // imagesLoaded.value = true; + }); return ( - + + ) }); \ No newline at end of file diff --git a/packages/ui/src/github-banner.tsx b/packages/ui/src/github-banner.tsx index e80f52e8..3f2a60a7 100644 --- a/packages/ui/src/github-banner.tsx +++ b/packages/ui/src/github-banner.tsx @@ -50,7 +50,7 @@ export const GithubBanner = component$(() => {
- +
Join Waitlist diff --git a/packages/ui/src/home-nav-bar.tsx b/packages/ui/src/home-nav-bar.tsx index 3d9151e4..6195650a 100644 --- a/packages/ui/src/home-nav-bar.tsx +++ b/packages/ui/src/home-nav-bar.tsx @@ -30,13 +30,13 @@ export const HomeNavBar = component$(() => {

diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 677b97a4..b22365c5 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -8,4 +8,6 @@ export * from "./team-counter" export * from "./tooltip" export * from "./footer" export * from "./router-head" -export * from "./card" \ No newline at end of file +export * from "./card" +export * as Modal from "./modal" +export { default as Portal } from "./portal" \ No newline at end of file diff --git a/packages/ui/src/large-card.tsx b/packages/ui/src/large-card.tsx deleted file mode 100644 index e69de29b..00000000 diff --git a/packages/ui/src/modal/index.ts b/packages/ui/src/modal/index.ts new file mode 100644 index 00000000..bdf8893b --- /dev/null +++ b/packages/ui/src/modal/index.ts @@ -0,0 +1,13 @@ +//Copied from https://github.com/qwikifiers/qwik-ui/blob/main/packages/kit-headless/src/components/modal/index.ts +//Why? because qwik-ui/headless requires qwik v1.7.2 as a peer dependency, which is causing build errors in the monorepo +// Reference: https://github.com/qwikifiers/qwik-ui/blob/26c17886e9a84de9d0da09f1180ede5fdceb70f3/packages/kit-headless/package.json#L33 + +export { HModalRoot as Root } from './modal-root'; +export { HModalPanel as Panel } from './modal-panel'; +export { HModalContent as Content } from './modal-content'; +export { HModalFooter as Footer } from './modal-footer'; +export { HModalHeader as Header } from './modal-header'; +export { HModalTitle as Title } from './modal-title'; +export { HModalDescription as Description } from './modal-description'; +export { HModalTrigger as Trigger } from './modal-trigger'; +export { HModalClose as Close } from './modal-close'; diff --git a/packages/ui/src/modal/modal-close.tsx b/packages/ui/src/modal/modal-close.tsx new file mode 100644 index 00000000..d9e16260 --- /dev/null +++ b/packages/ui/src/modal/modal-close.tsx @@ -0,0 +1,16 @@ +import { type PropsOf, Slot, component$, useContext, $ } from '@builder.io/qwik'; +import { modalContextId } from './modal-context'; + +export const HModalClose = component$((props: PropsOf<'button'>) => { + const context = useContext(modalContextId); + + const handleClick$ = $(() => { + context.showSig.value = false; + }); + + return ( + + ); +}); diff --git a/packages/ui/src/modal/modal-content.tsx b/packages/ui/src/modal/modal-content.tsx new file mode 100644 index 00000000..dae18aec --- /dev/null +++ b/packages/ui/src/modal/modal-content.tsx @@ -0,0 +1,12 @@ +import { type PropsOf, Slot, component$ } from '@builder.io/qwik'; + +/** + * @deprecated This component is deprecated and will be removed in future releases. + */ +export const HModalContent = component$((props: PropsOf<'div'>) => { + return ( +
+ +
+ ); +}); diff --git a/packages/ui/src/modal/modal-context.tsx b/packages/ui/src/modal/modal-context.tsx new file mode 100644 index 00000000..c13e2233 --- /dev/null +++ b/packages/ui/src/modal/modal-context.tsx @@ -0,0 +1,13 @@ +import { type QRL, type Signal, createContextId } from '@builder.io/qwik'; + +export const modalContextId = createContextId('qui-modal'); + +export type ModalContext = { + // core state + localId: string; + showSig: Signal; + onShow$?: QRL<() => void>; + onClose$?: QRL<() => void>; + closeOnBackdropClick?: boolean; + alert?: boolean; +}; diff --git a/packages/ui/src/modal/modal-description.tsx b/packages/ui/src/modal/modal-description.tsx new file mode 100644 index 00000000..51b7b201 --- /dev/null +++ b/packages/ui/src/modal/modal-description.tsx @@ -0,0 +1,16 @@ +import { type PropsOf, Slot, component$, useContext } from '@builder.io/qwik'; +import { modalContextId } from './modal-context'; + +export type ModalDescriptionProps = PropsOf<'p'>; + +export const HModalDescription = component$((props: ModalDescriptionProps) => { + const context = useContext(modalContextId); + + const descriptionId = `${context.localId}-description`; + + return ( +

+ +

+ ); +}); diff --git a/packages/ui/src/modal/modal-footer.tsx b/packages/ui/src/modal/modal-footer.tsx new file mode 100644 index 00000000..326aea39 --- /dev/null +++ b/packages/ui/src/modal/modal-footer.tsx @@ -0,0 +1,12 @@ +import { type PropsOf, Slot, component$ } from '@builder.io/qwik'; + +/** + * @deprecated This component is deprecated and will be removed in future releases. + */ +export const HModalFooter = component$((props: PropsOf<'footer'>) => { + return ( +
+ +
+ ); +}); diff --git a/packages/ui/src/modal/modal-header.tsx b/packages/ui/src/modal/modal-header.tsx new file mode 100644 index 00000000..0333cf63 --- /dev/null +++ b/packages/ui/src/modal/modal-header.tsx @@ -0,0 +1,12 @@ +import { type PropsOf, Slot, component$ } from '@builder.io/qwik'; + +/** + * @deprecated This component is deprecated and will be removed in future releases. + */ +export const HModalHeader = component$((props: PropsOf<'header'>) => { + return ( +
+ +
+ ); +}); diff --git a/packages/ui/src/modal/modal-panel.tsx b/packages/ui/src/modal/modal-panel.tsx new file mode 100644 index 00000000..5c9e82d6 --- /dev/null +++ b/packages/ui/src/modal/modal-panel.tsx @@ -0,0 +1,124 @@ +import { + $, + type PropsOf, + type QRL, + type Signal, + Slot, + component$, + useSignal, + useStyles$, + useTask$, + sync$, + useContext, +} from '@builder.io/qwik'; + +import { modalContextId } from './modal-context'; + +import styles from './modal.css?inline'; +import { useModal } from './use-modal'; + +export type ModalProps = Omit, 'open'> & { + onShow$?: QRL<() => void>; + onClose$?: QRL<() => void>; + 'bind:show': Signal; + closeOnBackdropClick?: boolean; + alert?: boolean; +}; + +export const HModalPanel = component$((props: PropsOf<'dialog'>) => { + useStyles$(styles); + const { + activateFocusTrap, + closeModal, + deactivateFocusTrap, + showModal, + trapFocus, + wasModalBackdropClicked, + } = useModal(); + const context = useContext(modalContextId); + + const panelRef = useSignal(); + + useTask$(async function toggleModal({ track, cleanup }) { + const isOpen = track(() => context.showSig.value); + + if (!panelRef.value) return; + + const focusTrap = await trapFocus(panelRef.value); + + if (isOpen) { + // HACK: keep modal scroll position in place with iOS + const storedRequestAnimationFrame = window.requestAnimationFrame; + window.requestAnimationFrame = () => 42; + + await showModal(panelRef.value); + window.requestAnimationFrame = storedRequestAnimationFrame; + await context.onShow$?.(); + activateFocusTrap(focusTrap); + } else { + await closeModal(panelRef.value); + await context.onClose$?.(); + } + + cleanup(async () => { + await deactivateFocusTrap(focusTrap); + }); + }); + + const closeOnBackdropClick$ = $(async (e: MouseEvent) => { + if (context.alert === true || context.closeOnBackdropClick === false) { + return; + } + + // We do not want to close elements that dangle outside of the modal + if (!(e.target instanceof HTMLDialogElement)) { + return; + } + + if (await wasModalBackdropClicked(panelRef.value, e)) { + context.showSig.value = false; + } + }); + + const handleKeyDownSync$ = sync$((e: KeyboardEvent) => { + const keys = [' ', 'Enter']; + + if (e.target instanceof HTMLDialogElement && keys.includes(e.key)) { + e.preventDefault(); + } + + if (e.key === 'Escape') { + e.preventDefault(); + } + }); + + const handleKeyDown$ = $((e: KeyboardEvent) => { + if (e.key === 'Escape') { + context.showSig.value = false; + e.stopPropagation(); + } + }); + + return ( + { + e.stopPropagation(); + await closeOnBackdropClick$(e); + }} + > + + + ); +}); diff --git a/packages/ui/src/modal/modal-root.tsx b/packages/ui/src/modal/modal-root.tsx new file mode 100644 index 00000000..5c33e0f0 --- /dev/null +++ b/packages/ui/src/modal/modal-root.tsx @@ -0,0 +1,51 @@ +import { + type PropsOf, + type QRL, + type Signal, + Slot, + component$, + useContextProvider, + useId, + useSignal, +} from '@builder.io/qwik'; +import { type ModalContext, modalContextId } from './modal-context'; + +type ModalRootProps = { + onShow$?: QRL<() => void>; + onClose$?: QRL<() => void>; + 'bind:show'?: Signal; + closeOnBackdropClick?: boolean; + alert?: boolean; +} & PropsOf<'div'>; + +export const HModalRoot = component$((props: ModalRootProps) => { + const localId = useId(); + + const { + 'bind:show': givenShowSig, + closeOnBackdropClick, + onShow$, + onClose$, + alert, + } = props; + + const defaultShowSig = useSignal(false); + const showSig = givenShowSig ?? defaultShowSig; + + const context: ModalContext = { + localId, + showSig, + closeOnBackdropClick, + onShow$, + onClose$, + alert, + }; + + useContextProvider(modalContextId, context); + + return ( +
+ +
+ ); +}); diff --git a/packages/ui/src/modal/modal-title.tsx b/packages/ui/src/modal/modal-title.tsx new file mode 100644 index 00000000..9d7b3a63 --- /dev/null +++ b/packages/ui/src/modal/modal-title.tsx @@ -0,0 +1,16 @@ +import { type PropsOf, Slot, component$, useContext } from '@builder.io/qwik'; +import { modalContextId } from './modal-context'; + +export type ModalTitleProps = PropsOf<'h2'>; + +export const HModalTitle = component$((props: ModalTitleProps) => { + const context = useContext(modalContextId); + + const titleId = `${context.localId}-title`; + + return ( +

+ +

+ ); +}); diff --git a/packages/ui/src/modal/modal-trigger.tsx b/packages/ui/src/modal/modal-trigger.tsx new file mode 100644 index 00000000..06379678 --- /dev/null +++ b/packages/ui/src/modal/modal-trigger.tsx @@ -0,0 +1,23 @@ +import { type PropsOf, Slot, component$, useContext, $ } from '@builder.io/qwik'; +import { modalContextId } from './modal-context'; + +export const HModalTrigger = component$((props: PropsOf<'button'>) => { + const context = useContext(modalContextId); + + const handleClick$ = $(() => { + context.showSig.value = !context.showSig.value; + }); + + return ( + + ); +}); diff --git a/packages/ui/src/modal/modal.css b/packages/ui/src/modal/modal.css new file mode 100644 index 00000000..795fe73d --- /dev/null +++ b/packages/ui/src/modal/modal.css @@ -0,0 +1,9 @@ +@layer qwik-ui { + /* browsers automatically set an interesting max-width and max-height for dialogs + https://twitter.com/t3dotgg/status/1774350919133691936 + */ + dialog:modal { + max-width: unset; + max-height: unset; + } +} diff --git a/packages/ui/src/modal/use-modal.tsx b/packages/ui/src/modal/use-modal.tsx new file mode 100644 index 00000000..ab374a2d --- /dev/null +++ b/packages/ui/src/modal/use-modal.tsx @@ -0,0 +1,134 @@ +import { $ } from '@builder.io/qwik'; +import { type FocusTrap, createFocusTrap } from 'focus-trap'; +import { enableBodyScroll, disableBodyScroll } from 'body-scroll-lock-upgrade'; + +export type WidthState = { + width: number | null; +}; + + +export function useModal() { + /** + * Listens for animation/transition events in order to + * remove Animation-CSS-Classes after animation/transition ended. + */ + const supportClosingAnimation = $((modal: HTMLDialogElement) => { + modal.dataset.closing = ''; + modal.classList.add('modal-closing'); + + const { animationDuration, transitionDuration } = getComputedStyle(modal); + + if (animationDuration !== '0s') { + modal.addEventListener( + 'animationend', + (e) => { + if (e.target === modal) { + delete modal.dataset.closing; + modal.classList.remove('modal-closing'); + enableBodyScroll(modal); + modal.close(); + } + }, + { once: true }, + ); + } else if (transitionDuration !== '0s') { + modal.addEventListener( + 'transitionend', + (e) => { + if (e.target === modal) { + delete modal.dataset.closing; + modal.classList.remove('modal-closing'); + enableBodyScroll(modal); + modal.close(); + } + }, + { once: true }, + ); + } else if (animationDuration === '0s' && transitionDuration === '0s') { + delete modal.dataset.closing; + modal.classList.remove('modal-closing'); + enableBodyScroll(modal); + modal.close(); + } + }); + + /** + * Traps the focus of the given Modal + * @returns FocusTrap + */ + const trapFocus = $((modal: HTMLDialogElement): FocusTrap => { + return createFocusTrap(modal, { escapeDeactivates: false }); + }); + + const activateFocusTrap = $((focusTrap: FocusTrap | null) => { + try { + focusTrap?.activate(); + } catch { + // Activating the focus trap throws if no tabbable elements are inside the container. + // If this is the case we are fine with not activating the focus trap. + // That's why we ignore the thrown error. + } + }); + + const deactivateFocusTrap = $((focusTrap: FocusTrap | null) => { + focusTrap?.deactivate(); + focusTrap = null; + }); + + /** + * Shows the given Modal. + * Applies a CSS-Class to animate the modal-showing. + * Calls the given callback that is executed after the Modal has been opened. + */ + const showModal = $(async (modal: HTMLDialogElement) => { + disableBodyScroll(modal, { reserveScrollBarGap: true }); + modal.showModal(); + }); + + /** + * Closes the given Modal. + * Applies a CSS-Class to animate the Modal-closing. + * Calls the given callback that is executed after the Modal has been closed. + */ + const closeModal = $(async (modal: HTMLDialogElement) => { + await supportClosingAnimation(modal); + }); + + /** + * Determines if the backdrop of the Modal has been clicked. + */ + const wasModalBackdropClicked = $( + (modal: HTMLDialogElement | undefined, clickEvent: MouseEvent): boolean => { + if (!modal) { + return false; + } + + const rect = modal.getBoundingClientRect(); + + const wasBackdropClicked = + rect.left > clickEvent.clientX || + rect.right < clickEvent.clientX || + rect.top > clickEvent.clientY || + rect.bottom < clickEvent.clientY; + + /** + * If the inside focusable elements are not prevented, such as a button it will also fire a click event. + * + * Hitting the enter or space keys on a button inside of the dialog for example, will fire a "pointer" event. In reality, it fires our onClick$ handler because we have not prevented the default behavior. + * + * This is why we check if the pointerId is -1. + **/ + return (clickEvent as PointerEvent).pointerId === -1 ? false : wasBackdropClicked; + }, + ); + + return { + trapFocus, + activateFocusTrap, + deactivateFocusTrap, + showModal, + closeModal, + wasModalBackdropClicked, + supportClosingAnimation, + }; +} diff --git a/packages/ui/src/nav-bar.tsx b/packages/ui/src/nav-bar.tsx index 0273780c..52d94806 100644 --- a/packages/ui/src/nav-bar.tsx +++ b/packages/ui/src/nav-bar.tsx @@ -31,7 +31,7 @@ export const NavBar = component$(() => { return (