feat: add settings menu
All checks were successful
Deploy to VPS (dist) / deploy (push) Successful in 1m40s
All checks were successful
Deploy to VPS (dist) / deploy (push) Successful in 1m40s
This commit is contained in:
@@ -24,6 +24,8 @@
|
||||
|
||||
&:disabled {
|
||||
pointer-events: none;
|
||||
opacity: 0.9;
|
||||
filter: grayscale(0.6);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { motion, type HTMLMotionProps } from "motion/react";
|
||||
import clsx, { type ClassValue } from "clsx";
|
||||
|
||||
import { usePlaySound } from "@/audio";
|
||||
import tg from "@/tg";
|
||||
|
||||
import classes from "./Button.module.css";
|
||||
@@ -18,10 +19,13 @@ const VARIANTS_MAP = {
|
||||
} satisfies Record<Exclude<Props["variant"], undefined>, string>;
|
||||
|
||||
export default function Button({ className, variant = "blue", onClick, ...props }: Props) {
|
||||
const play = usePlaySound();
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
{...props}
|
||||
onClick={(e) => {
|
||||
play("click");
|
||||
tg.hapticFeedback.click();
|
||||
onClick?.(e);
|
||||
}}
|
||||
|
||||
74
src/components/atoms/Pagination/Pagination.module.css
Normal file
74
src/components/atoms/Pagination/Pagination.module.css
Normal file
@@ -0,0 +1,74 @@
|
||||
@layer base {
|
||||
.wrapper {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
flex-shrink: 0;
|
||||
gap: 1px;
|
||||
border-radius: 9999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.startItem {
|
||||
border-radius: 9999px 5px 5px 9999px;
|
||||
}
|
||||
|
||||
.endItem {
|
||||
border-radius: 5px 9999px 9999px 5px;
|
||||
}
|
||||
|
||||
.button {
|
||||
cursor: pointer;
|
||||
flex: 1;
|
||||
flex-shrink: 0;
|
||||
user-select: none;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.state {
|
||||
flex: 1;
|
||||
padding: 8px;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.mirrored {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
.blueWrapper {
|
||||
padding: 3px;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.blueItem {
|
||||
background: #bef9fb;
|
||||
box-shadow:
|
||||
0px 1px 0px 0px #ffffffbf inset,
|
||||
-1px 0px 0px 0px #00000059 inset,
|
||||
1px 0px 0px 0px #00000059 inset,
|
||||
0px -1px 0px 0px #00000059 inset;
|
||||
color: #0c2836;
|
||||
--icon-fill: #227873;
|
||||
--icon-stroke: #0c2836;
|
||||
}
|
||||
}
|
||||
107
src/components/atoms/Pagination/Pagination.tsx
Normal file
107
src/components/atoms/Pagination/Pagination.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { motion } from "motion/react";
|
||||
import clsx from "clsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import tg from "@/tg";
|
||||
import ContentSurface from "@components/surface/ContentSurface";
|
||||
import { BlueSurface } from "@components/surface/BlueSectionSurface";
|
||||
import LightSurface from "@components/surface/LightSurface";
|
||||
|
||||
import BackIcon from "./icons/BackIcon";
|
||||
import StartIcon from "./icons/StartIcon";
|
||||
import classes from "./Pagination.module.css";
|
||||
import { usePlaySound } from "@/audio";
|
||||
|
||||
type Props = {
|
||||
value: number;
|
||||
total: number;
|
||||
onChange?: (page: number) => unknown;
|
||||
variant?: "default" | "blue";
|
||||
};
|
||||
|
||||
export default function Pagination({ value, total, onChange, variant = "default" }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const play = usePlaySound();
|
||||
|
||||
const isAtStart = value <= 1 || total <= 1;
|
||||
const isAtEnd = value >= total || total <= 1;
|
||||
const isBlue = variant === "blue";
|
||||
|
||||
const Wrapper = isBlue ? BlueSurface : ContentSurface;
|
||||
const ItemSurface = isBlue ? motion.div : LightSurface;
|
||||
|
||||
const itemClass = (...extra: Parameters<typeof clsx>) =>
|
||||
clsx(classes.item, isBlue ? classes.blueItem : undefined, ...extra);
|
||||
|
||||
return (
|
||||
<Wrapper className={clsx(classes.wrapper, isBlue ? classes.blueWrapper : "rounded-full")}>
|
||||
<ItemSurface className={itemClass(classes.startItem)}>
|
||||
<motion.button
|
||||
type="button"
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className={classes.button}
|
||||
onClick={() => {
|
||||
play("click");
|
||||
tg.hapticFeedback.click();
|
||||
onChange?.(1);
|
||||
}}
|
||||
disabled={isAtStart}
|
||||
>
|
||||
<StartIcon className="w-5 h-4.5" />
|
||||
</motion.button>
|
||||
</ItemSurface>
|
||||
|
||||
<ItemSurface className={itemClass()}>
|
||||
<motion.button
|
||||
type="button"
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className={classes.button}
|
||||
onClick={() => {
|
||||
play("click");
|
||||
tg.hapticFeedback.click();
|
||||
onChange?.(value - 1);
|
||||
}}
|
||||
disabled={isAtStart}
|
||||
>
|
||||
<BackIcon className="w-4 h-5" />
|
||||
</motion.button>
|
||||
</ItemSurface>
|
||||
|
||||
<ItemSurface className={itemClass(classes.state)}>
|
||||
{value} {t("pagination.of")} {total}
|
||||
</ItemSurface>
|
||||
|
||||
<ItemSurface className={itemClass()}>
|
||||
<motion.button
|
||||
type="button"
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className={classes.button}
|
||||
onClick={() => {
|
||||
play("click");
|
||||
tg.hapticFeedback.click();
|
||||
onChange?.(value + 1);
|
||||
}}
|
||||
disabled={isAtEnd}
|
||||
>
|
||||
<BackIcon className={clsx("w-4 h-5", classes.mirrored)} />
|
||||
</motion.button>
|
||||
</ItemSurface>
|
||||
|
||||
<ItemSurface className={itemClass(classes.endItem)}>
|
||||
<motion.button
|
||||
type="button"
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className={classes.button}
|
||||
onClick={() => {
|
||||
play("click");
|
||||
tg.hapticFeedback.click();
|
||||
onChange?.(total);
|
||||
}}
|
||||
disabled={isAtEnd}
|
||||
>
|
||||
<StartIcon className={clsx("w-5 h-4.5", classes.mirrored)} />
|
||||
</motion.button>
|
||||
</ItemSurface>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
3
src/components/atoms/Pagination/assets/back.svg
Normal file
3
src/components/atoms/Pagination/assets/back.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="20" viewBox="0 0 16 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14.4341 0.835938C13.8355 0.426426 13.086 0.362755 12.4019 0.804688V0.805664L1.61182 7.77344C0.889589 8.23986 0.50054 9.09777 0.500488 10C0.500488 10.9023 0.889512 11.7611 1.61182 12.2275L12.4019 19.1953C13.0867 19.6375 13.8357 19.5743 14.4341 19.165C15.0406 18.7501 15.5005 17.9663 15.5005 16.9668V3.0332C15.5005 2.03451 15.0407 1.2511 14.4341 0.835938Z" fill="#774923" stroke="#3F2814"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 502 B |
3
src/components/atoms/Pagination/assets/start.svg
Normal file
3
src/components/atoms/Pagination/assets/start.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="20" height="18" viewBox="0 0 20 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3.36816 0.5H1.89453C1.71624 0.500029 1.53882 0.537515 1.37207 0.611328C1.20516 0.685217 1.05109 0.794386 0.919922 0.93457C0.788697 1.07482 0.683015 1.2432 0.610352 1.43066C0.537677 1.61818 0.5 1.82068 0.5 2.02539V15.9746C0.5 16.1793 0.537677 16.3818 0.610352 16.5693C0.683014 16.7568 0.788695 16.9252 0.919922 17.0654C1.05109 17.2056 1.20517 17.3148 1.37207 17.3887C1.53882 17.4625 1.71624 17.5 1.89453 17.5H3.36816C3.72683 17.5 4.07828 17.348 4.34277 17.0654C4.60835 16.7816 4.7627 16.3894 4.7627 15.9746V2.02539C4.7627 1.6106 4.60835 1.2184 4.34277 0.93457C4.07828 0.65202 3.72683 0.5 3.36816 0.5ZM18.9141 0.500977C18.8201 0.507575 18.7262 0.541698 18.6436 0.603516L8.11719 8.47852C8.04438 8.53306 7.98186 8.6082 7.9375 8.69922C7.89307 8.79047 7.86914 8.89398 7.86914 9C7.86914 9.10602 7.89307 9.20953 7.9375 9.30078C7.98186 9.3918 8.04438 9.46694 8.11719 9.52148L18.6436 17.3955C18.7262 17.4573 18.8201 17.4915 18.9141 17.498C19.0078 17.5045 19.1032 17.4841 19.1904 17.4355C19.2782 17.3867 19.3558 17.31 19.4121 17.2109C19.4684 17.1118 19.4999 16.9956 19.5 16.875V1.125L19.4941 1.03516C19.4827 0.946822 19.4544 0.862538 19.4121 0.788086C19.3559 0.689138 19.278 0.613272 19.1904 0.564453C19.1031 0.515936 19.0079 0.494413 18.9141 0.500977Z" fill="#774923" stroke="#3F2814"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
23
src/components/atoms/Pagination/icons/BackIcon.tsx
Normal file
23
src/components/atoms/Pagination/icons/BackIcon.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { motion, type SVGMotionProps } from "motion/react";
|
||||
import clsx, { type ClassValue } from "clsx";
|
||||
|
||||
type Props = Omit<SVGMotionProps<SVGElement>, "className"> & {
|
||||
className?: ClassValue;
|
||||
};
|
||||
|
||||
export default function BackIcon(props: Props) {
|
||||
return (
|
||||
<motion.svg
|
||||
viewBox="0 0 16 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
className={clsx(props.className)}
|
||||
>
|
||||
<path
|
||||
d="M14.4341 0.835938C13.8355 0.426426 13.086 0.362755 12.4019 0.804688V0.805664L1.61182 7.77344C0.889589 8.23986 0.50054 9.09777 0.500488 10C0.500488 10.9023 0.889512 11.7611 1.61182 12.2275L12.4019 19.1953C13.0867 19.6375 13.8357 19.5743 14.4341 19.165C15.0406 18.7501 15.5005 17.9663 15.5005 16.9668V3.0332C15.5005 2.03451 15.0407 1.2511 14.4341 0.835938Z"
|
||||
style={{ fill: "var(--icon-fill, #774923)", stroke: "var(--icon-stroke, #3F2814)" }}
|
||||
/>
|
||||
</motion.svg>
|
||||
);
|
||||
}
|
||||
23
src/components/atoms/Pagination/icons/StartIcon.tsx
Normal file
23
src/components/atoms/Pagination/icons/StartIcon.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { motion, type SVGMotionProps } from "motion/react";
|
||||
import clsx, { type ClassValue } from "clsx";
|
||||
|
||||
type Props = Omit<SVGMotionProps<SVGElement>, "className"> & {
|
||||
className?: ClassValue;
|
||||
};
|
||||
|
||||
export default function StartIcon(props: Props) {
|
||||
return (
|
||||
<motion.svg
|
||||
viewBox="0 0 20 18"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
className={clsx(props.className)}
|
||||
>
|
||||
<path
|
||||
d="M3.36816 0.5H1.89453C1.71624 0.500029 1.53882 0.537515 1.37207 0.611328C1.20516 0.685217 1.05109 0.794386 0.919922 0.93457C0.788697 1.07482 0.683015 1.2432 0.610352 1.43066C0.537677 1.61818 0.5 1.82068 0.5 2.02539V15.9746C0.5 16.1793 0.537677 16.3818 0.610352 16.5693C0.683014 16.7568 0.788695 16.9252 0.919922 17.0654C1.05109 17.2056 1.20517 17.3148 1.37207 17.3887C1.53882 17.4625 1.71624 17.5 1.89453 17.5H3.36816C3.72683 17.5 4.07828 17.348 4.34277 17.0654C4.60835 16.7816 4.7627 16.3894 4.7627 15.9746V2.02539C4.7627 1.6106 4.60835 1.2184 4.34277 0.93457C4.07828 0.65202 3.72683 0.5 3.36816 0.5ZM18.9141 0.500977C18.8201 0.507575 18.7262 0.541698 18.6436 0.603516L8.11719 8.47852C8.04438 8.53306 7.98186 8.6082 7.9375 8.69922C7.89307 8.79047 7.86914 8.89398 7.86914 9C7.86914 9.10602 7.89307 9.20953 7.9375 9.30078C7.98186 9.3918 8.04438 9.46694 8.11719 9.52148L18.6436 17.3955C18.7262 17.4573 18.8201 17.4915 18.9141 17.498C19.0078 17.5045 19.1032 17.4841 19.1904 17.4355C19.2782 17.3867 19.3558 17.31 19.4121 17.2109C19.4684 17.1118 19.4999 16.9956 19.5 16.875V1.125L19.4941 1.03516C19.4827 0.946822 19.4544 0.862538 19.4121 0.788086C19.3559 0.689138 19.278 0.613272 19.1904 0.564453C19.1031 0.515936 19.0079 0.494413 18.9141 0.500977Z"
|
||||
style={{ fill: "var(--icon-fill, #774923)", stroke: "var(--icon-stroke, #3F2814)" }}
|
||||
/>
|
||||
</motion.svg>
|
||||
);
|
||||
}
|
||||
1
src/components/atoms/Pagination/index.ts
Normal file
1
src/components/atoms/Pagination/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from "./Pagination";
|
||||
@@ -4,6 +4,8 @@ import DarkSurface from "@components/surface/DarkSurface";
|
||||
import { motion, type HTMLMotionProps } from "motion/react";
|
||||
|
||||
import classes from "./TabSelector.module.css";
|
||||
import { usePlaySound } from "@/audio";
|
||||
import tg from "@/tg";
|
||||
|
||||
type Tab = {
|
||||
key: string;
|
||||
@@ -18,6 +20,8 @@ type Props = Omit<HTMLMotionProps<"div">, "className" | "onChange"> & {
|
||||
};
|
||||
|
||||
export default function TabSelector({ tabs, value, onChange, className, ...props }: Props) {
|
||||
const play = usePlaySound();
|
||||
|
||||
const selectedIndex = value != null ? tabs.findIndex((tab) => tab.key === value) : -1;
|
||||
|
||||
return (
|
||||
@@ -29,7 +33,11 @@ export default function TabSelector({ tabs, value, onChange, className, ...props
|
||||
type="button"
|
||||
key={tab.key}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
onClick={() => onChange?.(tab.key)}
|
||||
onClick={() => {
|
||||
play("click");
|
||||
tg.hapticFeedback.click();
|
||||
onChange?.(tab.key);
|
||||
}}
|
||||
className={classes.tab}
|
||||
>
|
||||
{tab.title}
|
||||
|
||||
@@ -4,6 +4,8 @@ import { type ReactNode, useRef, useState, type ChangeEvent, useId } from "react
|
||||
import KeyboardIcon from "@components/icons/KeyboardIcon";
|
||||
|
||||
import classes from "./NumberInput.module.css";
|
||||
import { usePlaySound } from "@/audio";
|
||||
import tg from "@/tg";
|
||||
|
||||
type Props = Omit<HTMLMotionProps<"input">, "className" | "type" | "onChange"> & {
|
||||
className?: ClassValue;
|
||||
@@ -29,6 +31,8 @@ export default function NumberInput({
|
||||
onChange,
|
||||
...props
|
||||
}: Props) {
|
||||
const play = usePlaySound();
|
||||
|
||||
const stableId = useId();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const id = props.id ?? stableId;
|
||||
@@ -66,7 +70,11 @@ export default function NumberInput({
|
||||
<motion.button
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className={classes.iconButton}
|
||||
onClick={() => inputRef.current?.focus()}
|
||||
onClick={() => {
|
||||
play("click");
|
||||
tg.hapticFeedback.click();
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
<KeyboardIcon className={classes.icon} />
|
||||
</motion.button>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import clsx, { type ClassValue } from "clsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ContentSurface from "@components/surface/ContentSurface";
|
||||
import LightSurface from "@components/surface/LightSurface";
|
||||
import { motion, type HTMLMotionProps } from "motion/react";
|
||||
|
||||
import classes from "./SwitchInput.module.css";
|
||||
import tg from "@/tg";
|
||||
|
||||
type Props = Omit<HTMLMotionProps<"div">, "className" | "onChange"> & {
|
||||
value?: boolean | null;
|
||||
@@ -12,6 +14,7 @@ type Props = Omit<HTMLMotionProps<"div">, "className" | "onChange"> & {
|
||||
};
|
||||
|
||||
export default function SwitchInput({ value, onChange, className, ...props }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const selectedIndex = value != null ? (value ? 0 : 1) : -1;
|
||||
|
||||
return (
|
||||
@@ -19,24 +22,24 @@ export default function SwitchInput({ value, onChange, className, ...props }: Pr
|
||||
{...props}
|
||||
className={clsx(classes.container, className)}
|
||||
whileTap={{ scale: 1.1 }}
|
||||
onClick={() => {
|
||||
tg.hapticFeedback.click();
|
||||
onChange?.(!value);
|
||||
}}
|
||||
>
|
||||
<div className={classes.optionsContainer}>
|
||||
<div className={classes.options}>
|
||||
<motion.button
|
||||
type="button"
|
||||
whileTap={{ scale: 0.95 }}
|
||||
onClick={() => onChange?.(true)}
|
||||
className={clsx(classes.option, value === true && classes.selected)}
|
||||
>
|
||||
on
|
||||
{t("common.on")}
|
||||
</motion.button>
|
||||
<motion.button
|
||||
type="button"
|
||||
whileTap={{ scale: 0.95 }}
|
||||
onClick={() => onChange?.(false)}
|
||||
className={clsx(classes.option, value === false && classes.selected)}
|
||||
>
|
||||
off
|
||||
{t("common.off")}
|
||||
</motion.button>
|
||||
</div>
|
||||
{selectedIndex >= 0 && (
|
||||
|
||||
57
src/components/lift/LiftContext.tsx
Normal file
57
src/components/lift/LiftContext.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { createContext, useCallback, useContext, useRef, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
type LiftContextValue = {
|
||||
liftedIds: Set<string>;
|
||||
alwaysLiftedIds: Set<string>;
|
||||
setLiftedIds: (ids: string[]) => void;
|
||||
registerAlways: (id: string) => void;
|
||||
unregisterAlways: (id: string) => void;
|
||||
portalContainer: HTMLElement | null;
|
||||
setPortalContainer: (el: HTMLElement | null) => void;
|
||||
};
|
||||
|
||||
const LiftContext = createContext<LiftContextValue | null>(null);
|
||||
|
||||
export function useLift(): LiftContextValue {
|
||||
const ctx = useContext(LiftContext);
|
||||
if (!ctx) throw new Error("useLift must be used within LiftProvider");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function LiftProvider({ children }: { children: ReactNode }) {
|
||||
const [liftedIds, setLiftedIdsRaw] = useState<Set<string>>(new Set());
|
||||
const [alwaysLiftedIds, setAlwaysLiftedIds] = useState<Set<string>>(new Set());
|
||||
const [portalContainer, setPortalContainer] = useState<HTMLElement | null>(null);
|
||||
const alwaysRef = useRef<Set<string>>(new Set());
|
||||
|
||||
const setLiftedIds = useCallback((ids: string[]) => {
|
||||
setLiftedIdsRaw(new Set(ids));
|
||||
}, []);
|
||||
|
||||
const registerAlways = useCallback((id: string) => {
|
||||
alwaysRef.current.add(id);
|
||||
setAlwaysLiftedIds(new Set(alwaysRef.current));
|
||||
}, []);
|
||||
|
||||
const unregisterAlways = useCallback((id: string) => {
|
||||
alwaysRef.current.delete(id);
|
||||
setAlwaysLiftedIds(new Set(alwaysRef.current));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<LiftContext
|
||||
value={{
|
||||
liftedIds,
|
||||
alwaysLiftedIds,
|
||||
setLiftedIds,
|
||||
registerAlways,
|
||||
unregisterAlways,
|
||||
portalContainer,
|
||||
setPortalContainer,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</LiftContext>
|
||||
);
|
||||
}
|
||||
8
src/components/lift/LiftLayer.module.css
Normal file
8
src/components/lift/LiftLayer.module.css
Normal file
@@ -0,0 +1,8 @@
|
||||
@layer base {
|
||||
.liftLayer {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 103;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
16
src/components/lift/LiftLayer.tsx
Normal file
16
src/components/lift/LiftLayer.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { useCallback } from "react";
|
||||
import { useLift } from "./LiftContext";
|
||||
import classes from "./LiftLayer.module.css";
|
||||
|
||||
export function LiftLayer() {
|
||||
const { setPortalContainer } = useLift();
|
||||
|
||||
const refCallback = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
setPortalContainer(node);
|
||||
},
|
||||
[setPortalContainer],
|
||||
);
|
||||
|
||||
return <div ref={refCallback} className={classes.liftLayer} />;
|
||||
}
|
||||
121
src/components/lift/Liftable.tsx
Normal file
121
src/components/lift/Liftable.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useLift } from "./LiftContext";
|
||||
|
||||
type LiftableProps = {
|
||||
id?: string;
|
||||
always?: boolean;
|
||||
};
|
||||
|
||||
type Props = LiftableProps & {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function useLiftable<T extends Record<string, unknown> = Record<string, never>>(
|
||||
render: (props: { isLifted: boolean } & T) => ReactNode,
|
||||
options?: LiftableProps & T,
|
||||
): { id: string; element: ReactNode } {
|
||||
const autoId = useId();
|
||||
const {
|
||||
id: idProp,
|
||||
always,
|
||||
...extraProps
|
||||
} = (options ?? {}) as LiftableProps & Record<string, unknown>;
|
||||
const id = idProp ?? autoId;
|
||||
const { liftedIds, alwaysLiftedIds, registerAlways, unregisterAlways } = useLift();
|
||||
|
||||
useEffect(() => {
|
||||
if (always) {
|
||||
registerAlways(id);
|
||||
return () => unregisterAlways(id);
|
||||
}
|
||||
}, [always, id, registerAlways, unregisterAlways]);
|
||||
|
||||
const isLifted = liftedIds.has(id) || alwaysLiftedIds.has(id);
|
||||
|
||||
const element = (
|
||||
<Liftable id={id}>{render({ isLifted, ...extraProps } as { isLifted: boolean } & T)}</Liftable>
|
||||
);
|
||||
|
||||
return { id, element };
|
||||
}
|
||||
|
||||
export function Liftable({ id: idProp, always, children }: Props) {
|
||||
const autoId = useId();
|
||||
const id = idProp ?? autoId;
|
||||
const { liftedIds, alwaysLiftedIds, registerAlways, unregisterAlways, portalContainer } =
|
||||
useLift();
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const [rect, setRect] = useState<DOMRect | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (always) {
|
||||
registerAlways(id);
|
||||
return () => unregisterAlways(id);
|
||||
}
|
||||
}, [always, id, registerAlways, unregisterAlways]);
|
||||
|
||||
const isLifted = liftedIds.has(id) || alwaysLiftedIds.has(id);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (isLifted && wrapperRef.current) {
|
||||
setRect(wrapperRef.current.getBoundingClientRect());
|
||||
}
|
||||
if (!isLifted) {
|
||||
setRect(null);
|
||||
}
|
||||
}, [isLifted]);
|
||||
|
||||
// Re-measure on resize while lifted
|
||||
useEffect(() => {
|
||||
if (!isLifted || !wrapperRef.current) return;
|
||||
|
||||
const measure = () => {
|
||||
if (wrapperRef.current) {
|
||||
setRect(wrapperRef.current.getBoundingClientRect());
|
||||
}
|
||||
};
|
||||
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(wrapperRef.current);
|
||||
window.addEventListener("resize", measure);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
window.removeEventListener("resize", measure);
|
||||
};
|
||||
}, [isLifted]);
|
||||
|
||||
// When lifted and we have measurements + portal target, render in portal
|
||||
if (isLifted && rect && portalContainer) {
|
||||
return (
|
||||
<>
|
||||
{/* Placeholder preserves layout space */}
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
style={{ width: rect.width, height: rect.height, visibility: "hidden" }}
|
||||
/>
|
||||
{/* Portal children above blur */}
|
||||
{createPortal(
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: rect.top,
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
pointerEvents: "auto",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>,
|
||||
portalContainer,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Normal inline rendering
|
||||
return <div ref={wrapperRef}>{children}</div>;
|
||||
}
|
||||
3
src/components/lift/index.ts
Normal file
3
src/components/lift/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { LiftProvider, useLift } from "./LiftContext";
|
||||
export { LiftLayer } from "./LiftLayer";
|
||||
export { Liftable, useLiftable } from "./Liftable";
|
||||
@@ -1,11 +1,27 @@
|
||||
@layer base {
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.modalContainer {
|
||||
width: 100%;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
z-index: 102;
|
||||
}
|
||||
|
||||
.modalInner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.modal {
|
||||
@@ -13,7 +29,19 @@
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
max-height: calc(
|
||||
var(--safe-area-height) - var(--header-total) - var(--navigation-total) - 70px
|
||||
);
|
||||
overflow: auto;
|
||||
padding: 13px;
|
||||
}
|
||||
|
||||
.title {
|
||||
color: #ffffff;
|
||||
-webkit-text-stroke: 0.7px #331b01;
|
||||
font-weight: 700;
|
||||
font-size: 24px;
|
||||
margin-bottom: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,36 +1,85 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
|
||||
import SectionSurface from "../../surface/SectionSurface/SectionSurface";
|
||||
import SectionSurface from "@components/surface/SectionSurface";
|
||||
import { useLift } from "@components/lift";
|
||||
import classes from "./Modal.module.css";
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
children: ReactNode;
|
||||
onClose: () => void;
|
||||
liftIds?: string[];
|
||||
title?: ReactNode;
|
||||
className?: ClassValue;
|
||||
};
|
||||
|
||||
export default function Modal({ open, children, onClose }: Props) {
|
||||
export default function Modal({ open, children, onClose, liftIds, title, className }: Props) {
|
||||
const { setLiftedIds } = useLift();
|
||||
const prevLiftIdsRef = useRef<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
const key = liftIds?.join(",") ?? "";
|
||||
|
||||
if (open && key) {
|
||||
if (key !== prevLiftIdsRef.current) {
|
||||
prevLiftIdsRef.current = key;
|
||||
setLiftedIds(liftIds!);
|
||||
}
|
||||
}
|
||||
|
||||
if (!open && prevLiftIdsRef.current) {
|
||||
prevLiftIdsRef.current = "";
|
||||
setLiftedIds([]);
|
||||
}
|
||||
}, [open, liftIds, setLiftedIds]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (prevLiftIdsRef.current) {
|
||||
setLiftedIds([]);
|
||||
}
|
||||
};
|
||||
}, [setLiftedIds]);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
className={classes.overlay}
|
||||
initial={{ backdropFilter: "blur(0px)" }}
|
||||
animate={{ backdropFilter: "blur(8px)" }}
|
||||
exit={{ backdropFilter: "blur(0px)" }}
|
||||
transition={{ duration: 0.2 }}
|
||||
onClick={onClose}
|
||||
>
|
||||
<SectionSurface
|
||||
className={classes.modal}
|
||||
exit={{ scale: 0 }}
|
||||
transition={{ duration: 0.2, type: "spring" }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
<>
|
||||
<motion.div
|
||||
className={classes.overlay}
|
||||
initial={{ backdropFilter: "blur(0px)" }}
|
||||
animate={{ backdropFilter: "blur(8px)" }}
|
||||
exit={{ backdropFilter: "blur(0px)" }}
|
||||
transition={{ duration: 0.2 }}
|
||||
onClick={onClose}
|
||||
/>
|
||||
<motion.div
|
||||
className={classes.modalContainer}
|
||||
initial={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
onClick={onClose}
|
||||
>
|
||||
{children}
|
||||
</SectionSurface>
|
||||
</motion.div>
|
||||
{/* oxlint-disable-next-line jsx_a11y/no-static-element-interactions*/}
|
||||
<div
|
||||
className={classes.modalInner}
|
||||
// oxlint-disable-next-line jsx_a11y/click-events-have-key-events
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{title && <div className={classes.title}>{title}</div>}
|
||||
<SectionSurface
|
||||
className={clsx(classes.modal, className)}
|
||||
exit={{ scale: 0 }}
|
||||
transition={{ duration: 0.2, type: "spring" }}
|
||||
>
|
||||
{children}
|
||||
</SectionSurface>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
@reference "@/index.css";
|
||||
|
||||
@layer base {
|
||||
.blueSectionSurface {
|
||||
background: linear-gradient(180deg, #278789 0%, #206f66 100%);
|
||||
|
||||
Reference in New Issue
Block a user