From 2a1115b66f11151dfa42db0254e37271d183d7e2 Mon Sep 17 00:00:00 2001 From: Hewston Fox Date: Sat, 21 Mar 2026 19:50:29 +0200 Subject: [PATCH] feat: add header --- .../modals/ActionModal/ActionModal.module.css | 30 ++---- .../modals/ActionModal/ActionModal.tsx | 49 +++------ src/components/modals/Modal/Modal.module.css | 19 ++++ src/components/modals/Modal/Modal.tsx | 37 +++++++ src/components/modals/Modal/index.ts | 1 + src/routes/-/RootLayout/RootLayout.module.css | 10 ++ src/routes/-/RootLayout/RootLayout.tsx | 4 +- .../components/Header/Header.module.css | 30 ++++-- .../-/RootLayout/components/Header/Header.tsx | 11 +- .../components/Profile/Profile.module.css | 27 +++++ .../Header/components/Profile/Profile.tsx | 19 ++++ .../Profile/assets/profile-bg.svg} | 0 .../Header/components/Profile/index.ts | 1 + .../components/Settings/Settings.module.css | 35 ++++++ .../Header/components/Settings/Settings.tsx | 49 +++++++++ .../Settings/assets/settings-bg.svg} | 0 .../Header/components/Settings/index.ts | 1 + .../Navigation/Navigation.module.css | 7 +- .../components/Navigation/Navigation.tsx | 40 +++---- src/styles/index.css | 31 +++++- src/tg/index.ts | 100 ++++++++++++++++-- 21 files changed, 401 insertions(+), 100 deletions(-) create mode 100644 src/components/modals/Modal/Modal.module.css create mode 100644 src/components/modals/Modal/Modal.tsx create mode 100644 src/components/modals/Modal/index.ts create mode 100644 src/routes/-/RootLayout/components/Header/components/Profile/Profile.module.css create mode 100644 src/routes/-/RootLayout/components/Header/components/Profile/Profile.tsx rename src/routes/-/RootLayout/components/Header/{assets/user-bar.svg => components/Profile/assets/profile-bg.svg} (100%) create mode 100644 src/routes/-/RootLayout/components/Header/components/Profile/index.ts create mode 100644 src/routes/-/RootLayout/components/Header/components/Settings/Settings.module.css create mode 100644 src/routes/-/RootLayout/components/Header/components/Settings/Settings.tsx rename src/routes/-/RootLayout/components/Header/{assets/menu.svg => components/Settings/assets/settings-bg.svg} (100%) create mode 100644 src/routes/-/RootLayout/components/Header/components/Settings/index.ts diff --git a/src/components/modals/ActionModal/ActionModal.module.css b/src/components/modals/ActionModal/ActionModal.module.css index 3326de5..cba9f97 100644 --- a/src/components/modals/ActionModal/ActionModal.module.css +++ b/src/components/modals/ActionModal/ActionModal.module.css @@ -1,29 +1,11 @@ @layer base { - .overlay { - position: fixed; - inset: 0; - display: flex; - align-items: center; - justify-content: center; - z-index: 100; - } + .content { + border-radius: 22px; - .modal { - display: flex; - flex-direction: column; - gap: 6px; - width: 100%; - max-width: 320px; - padding: 13px; - - .content { - border-radius: 22px; - - .description { - padding: 12px; - border-radius: 18px; - text-align: center; - } + .description { + padding: 12px; + border-radius: 18px; + text-align: center; } } diff --git a/src/components/modals/ActionModal/ActionModal.tsx b/src/components/modals/ActionModal/ActionModal.tsx index 0db9ae1..25fc55e 100644 --- a/src/components/modals/ActionModal/ActionModal.tsx +++ b/src/components/modals/ActionModal/ActionModal.tsx @@ -1,7 +1,6 @@ -import { AnimatePresence, motion } from "motion/react"; import { useTranslation } from "react-i18next"; -import SectionSurface from "../../surface/SectionSurface/SectionSurface"; +import Modal from "../Modal/Modal"; import ContentSurface from "../../surface/ContentSurface/ContentSurface"; import LightSurface from "../../surface/LightSurface/LightSurface"; import Button from "../../atoms/Button/Button"; @@ -27,36 +26,20 @@ export default function ActionModal({ open, description, onClose, onConfirm, con const { t } = useTranslation(); return ( - - {open && ( - - - - {description} - -
- - {onConfirm != null && ( - - )} -
-
-
- )} -
+ + + {description} + +
+ + {onConfirm != null && ( + + )} +
+
); } diff --git a/src/components/modals/Modal/Modal.module.css b/src/components/modals/Modal/Modal.module.css new file mode 100644 index 0000000..b79986c --- /dev/null +++ b/src/components/modals/Modal/Modal.module.css @@ -0,0 +1,19 @@ +@layer base { + .overlay { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + z-index: 100; + } + + .modal { + display: flex; + flex-direction: column; + gap: 6px; + width: 100%; + max-width: 320px; + padding: 13px; + } +} diff --git a/src/components/modals/Modal/Modal.tsx b/src/components/modals/Modal/Modal.tsx new file mode 100644 index 0000000..b93c62a --- /dev/null +++ b/src/components/modals/Modal/Modal.tsx @@ -0,0 +1,37 @@ +import type { ReactNode } from "react"; +import { AnimatePresence, motion } from "motion/react"; + +import SectionSurface from "../../surface/SectionSurface/SectionSurface"; +import classes from "./Modal.module.css"; + +type Props = { + open: boolean; + children: ReactNode; + onClose: () => void; +}; + +export default function Modal({ open, children, onClose }: Props) { + return ( + + {open && ( + + e.stopPropagation()} + > + {children} + + + )} + + ); +} diff --git a/src/components/modals/Modal/index.ts b/src/components/modals/Modal/index.ts new file mode 100644 index 0000000..09b91f7 --- /dev/null +++ b/src/components/modals/Modal/index.ts @@ -0,0 +1 @@ +export { default } from "./Modal"; diff --git a/src/routes/-/RootLayout/RootLayout.module.css b/src/routes/-/RootLayout/RootLayout.module.css index 10f5d94..1ca0173 100644 --- a/src/routes/-/RootLayout/RootLayout.module.css +++ b/src/routes/-/RootLayout/RootLayout.module.css @@ -7,5 +7,15 @@ background-image: url("./assets/main-bg.svg"); background-size: auto 101%; background-position: center; + + .main { + height: 100%; + overflow-y: auto; + overflow-x: hidden; + + --padding-x: 16px; + padding: calc(var(--header-total) + var(--padding-x)) var(--safe-right) + calc(var(--navigation-total) + var(--padding-x)) var(--safe-left); + } } } diff --git a/src/routes/-/RootLayout/RootLayout.tsx b/src/routes/-/RootLayout/RootLayout.tsx index 0a4a303..32c6c7e 100644 --- a/src/routes/-/RootLayout/RootLayout.tsx +++ b/src/routes/-/RootLayout/RootLayout.tsx @@ -13,9 +13,7 @@ export default function RootLayout({ children, hideControls }: Props) {
{!hideControls &&
} -
- {children} -
+
{children}
{!hideControls && }
diff --git a/src/routes/-/RootLayout/components/Header/Header.module.css b/src/routes/-/RootLayout/components/Header/Header.module.css index ee2a687..d661c2e 100644 --- a/src/routes/-/RootLayout/components/Header/Header.module.css +++ b/src/routes/-/RootLayout/components/Header/Header.module.css @@ -1,9 +1,6 @@ :root { --header-height: 90px; - --header-padding: calc( - var(--tg-viewport-safe-area-inset-top, 0px) + - var(--tg-viewport-content-safe-area-inset-top, 0px) - ); + --header-padding: var(--safe-top); --header-total: calc(var(--header-height) + var(--header-padding)); } @@ -14,8 +11,29 @@ right: 0; left: 0; z-index: 10; - height: var(--header-total); + padding-top: var(--header-padding); - background: rgb(59 130 246 / 0.3); + padding-left: var(--safe-left); + padding-right: var(--safe-right); + + &::before { + content: ""; + position: absolute; + inset: 0; + bottom: -20px; + backdrop-filter: blur(10px); + mask-image: linear-gradient(to bottom, black 0%, black calc(100% - 50px), transparent 100%); + pointer-events: none; + } + + .content { + position: relative; + height: var(--header-height); + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + gap: 15px; + } } } diff --git a/src/routes/-/RootLayout/components/Header/Header.tsx b/src/routes/-/RootLayout/components/Header/Header.tsx index 36e8a19..32b6e24 100644 --- a/src/routes/-/RootLayout/components/Header/Header.tsx +++ b/src/routes/-/RootLayout/components/Header/Header.tsx @@ -1,5 +1,14 @@ import classes from "./Header.module.css"; +import Profile from "./components/Profile"; +import Settings from "./components/Settings"; export default function Header() { - return
; + return ( +
+
+ + +
+
+ ); } diff --git a/src/routes/-/RootLayout/components/Header/components/Profile/Profile.module.css b/src/routes/-/RootLayout/components/Header/components/Profile/Profile.module.css new file mode 100644 index 0000000..43664d5 --- /dev/null +++ b/src/routes/-/RootLayout/components/Header/components/Profile/Profile.module.css @@ -0,0 +1,27 @@ +@layer base { + .profile { + pointer-events: auto; + width: 200px; + aspect-ratio: 2.18; + background-image: url("./assets/profile-bg.svg"); + background-position: center; + background-repeat: no-repeat; + background-size: cover; + display: flex; + gap: 2px; + } + + .left { + } + + .right { + display: flex; + flex-direction: column; + } + + .rightTop { + } + + .rightBottom { + } +} diff --git a/src/routes/-/RootLayout/components/Header/components/Profile/Profile.tsx b/src/routes/-/RootLayout/components/Header/components/Profile/Profile.tsx new file mode 100644 index 0000000..1d27c91 --- /dev/null +++ b/src/routes/-/RootLayout/components/Header/components/Profile/Profile.tsx @@ -0,0 +1,19 @@ +import classes from "./Profile.module.css"; +import { motion } from "motion/react"; + +export default function Profile() { + return ( + +
+
+
+
+
+ + ); +} diff --git a/src/routes/-/RootLayout/components/Header/assets/user-bar.svg b/src/routes/-/RootLayout/components/Header/components/Profile/assets/profile-bg.svg similarity index 100% rename from src/routes/-/RootLayout/components/Header/assets/user-bar.svg rename to src/routes/-/RootLayout/components/Header/components/Profile/assets/profile-bg.svg diff --git a/src/routes/-/RootLayout/components/Header/components/Profile/index.ts b/src/routes/-/RootLayout/components/Header/components/Profile/index.ts new file mode 100644 index 0000000..2623c86 --- /dev/null +++ b/src/routes/-/RootLayout/components/Header/components/Profile/index.ts @@ -0,0 +1 @@ +export { default } from "./Profile"; diff --git a/src/routes/-/RootLayout/components/Header/components/Settings/Settings.module.css b/src/routes/-/RootLayout/components/Header/components/Settings/Settings.module.css new file mode 100644 index 0000000..805ba39 --- /dev/null +++ b/src/routes/-/RootLayout/components/Header/components/Settings/Settings.module.css @@ -0,0 +1,35 @@ +@layer base { + .settings { + margin-left: auto; + width: 80px; + height: 50px; + display: flex; + align-items: center; + justify-content: center; + background: url("./assets/settings-bg.svg") center / contain no-repeat; + border: none; + cursor: pointer; + padding: 0; + } + + .bars { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 5px; + } + + .bar { + display: block; + width: 32px; + height: 7px; + border-radius: 5px; + background-color: #563417; + background-image: linear-gradient(180deg, #ebcb71 0%, #aa6831 100%); + box-shadow: + 0px 1px 1px 0px #00000073, + 0px 1px 0px 0px #ffefd1 inset, + 0px -1px 0px 0px #0000008c inset; + } +} diff --git a/src/routes/-/RootLayout/components/Header/components/Settings/Settings.tsx b/src/routes/-/RootLayout/components/Header/components/Settings/Settings.tsx new file mode 100644 index 0000000..56c8c64 --- /dev/null +++ b/src/routes/-/RootLayout/components/Header/components/Settings/Settings.tsx @@ -0,0 +1,49 @@ +import { useState } from "react"; +import { motion } from "motion/react"; + +import tg from "@/tg"; + +import classes from "./Settings.module.css"; + +export default function Settings() { + const [isOpen, setIsOpen] = useState(false); + + const toggle = () => { + tg.hapticFeedback.click(); + setIsOpen((prev) => !prev); + }; + + return ( + +
+ {[0, 1, 2].map((i) => ( + + ))} +
+
+ ); +} diff --git a/src/routes/-/RootLayout/components/Header/assets/menu.svg b/src/routes/-/RootLayout/components/Header/components/Settings/assets/settings-bg.svg similarity index 100% rename from src/routes/-/RootLayout/components/Header/assets/menu.svg rename to src/routes/-/RootLayout/components/Header/components/Settings/assets/settings-bg.svg diff --git a/src/routes/-/RootLayout/components/Header/components/Settings/index.ts b/src/routes/-/RootLayout/components/Header/components/Settings/index.ts new file mode 100644 index 0000000..41d6622 --- /dev/null +++ b/src/routes/-/RootLayout/components/Header/components/Settings/index.ts @@ -0,0 +1 @@ +export { default } from "./Settings"; diff --git a/src/routes/-/RootLayout/components/Navigation/Navigation.module.css b/src/routes/-/RootLayout/components/Navigation/Navigation.module.css index a2ccb18..f47a38c 100644 --- a/src/routes/-/RootLayout/components/Navigation/Navigation.module.css +++ b/src/routes/-/RootLayout/components/Navigation/Navigation.module.css @@ -1,6 +1,6 @@ :root { --navigation-height: 74px; - --navigation-padding: var(--tg-viewport-safe-area-inset-bottom, 0px); + --navigation-padding: var(--safe-bottom); --navigation-total: calc(var(--navigation-height) + var(--navigation-padding)); } @@ -12,6 +12,8 @@ left: 0; z-index: 10; max-width: 500px; + padding-left: var(--safe-left); + padding-right: var(--safe-right); margin: auto; display: flex; align-items: flex-end; @@ -84,9 +86,10 @@ border-radius: 27px 0 0 27px; display: flex; flex-direction: column; - align-items: center; + align-items: flex-start; justify-content: flex-start; padding-right: 20px; + padding-left: 20px; cursor: pointer; } diff --git a/src/routes/-/RootLayout/components/Navigation/Navigation.tsx b/src/routes/-/RootLayout/components/Navigation/Navigation.tsx index f9fb12e..2aac27d 100644 --- a/src/routes/-/RootLayout/components/Navigation/Navigation.tsx +++ b/src/routes/-/RootLayout/components/Navigation/Navigation.tsx @@ -22,7 +22,7 @@ import menuIcon from "./assets/menu.svg"; import rouletteIcon from "./assets/roulette.svg"; import tasksIcon from "./assets/tasks.svg"; import earningsIcon from "./assets/earnings.svg"; -import tg from "@/tg"; +import tg, { useTelegramViewportValue } from "@/tg"; const ANIMATION_DURATION = 0.2; const SPRING_ANIMATION = { @@ -62,33 +62,34 @@ type BarProps = { function NavBar({ labelKey, icon, active, entranceDelay, onClick }: BarProps) { const { t } = useTranslation(); - const isInitial = useRef(true); - useEffect(() => { - isInitial.current = false; - }, []); + const [isReady, setIsReady] = useState(false); - const height = active ? ACTIVE_BAR_HEIGHT : BAR_HEIGHT; + const safeAreaBottom = useTelegramViewportValue("safeAreaInsetBottom"); + const safeContentBottom = useTelegramViewportValue("contentSafeAreaInsetBottom"); + const height = (active ? ACTIVE_BAR_HEIGHT : BAR_HEIGHT) + safeAreaBottom + safeContentBottom; + const offscreenOffset = height * 0.3; return ( variant === "visible" && setIsReady(true)} initial="hidden" - animate={isInitial.current ? "visible" : "ready"} + animate={isReady ? "ready" : "visible"} whileTap={{ scale: 0.95 }} onClick={onClick} > @@ -110,34 +111,37 @@ type MenuBarProps = { const MENU_BAR_WIDTH = 94; const ACTIVE_MENU_BAR_WIDTH = 104; -const OFFSCREEN_MENU_BAR_OFFSET = 20; function MenuBar({ labelKey, icon, delay, active, onClick }: MenuBarProps) { const { t } = useTranslation(); const [isReady, setIsReady] = useState(false); - const width = active ? ACTIVE_MENU_BAR_WIDTH : MENU_BAR_WIDTH; + const safeAreaRight = useTelegramViewportValue("safeAreaInsetRight"); + const safeContentRight = useTelegramViewportValue("contentSafeAreaInsetRight"); + const width = + (active ? ACTIVE_MENU_BAR_WIDTH : MENU_BAR_WIDTH) + safeAreaRight + safeContentRight; + const offscreenOffset = width * 0.3; return ( ; }; +const mockCssVar = (key: string, px: number) => + document.documentElement.style.setProperty(key, `${px}px`); + const externalTgApi = { init: () => { tg.setDebug(import.meta.env.DEV); @@ -124,6 +128,20 @@ const externalTgApi = { console.log("TMA closing behavior initialized"); } + if (import.meta.env.DEV) { + mockCssVar("--tg-viewport-content-safe-area-inset-top", 10); + mockCssVar("--tg-viewport-safe-area-inset-top", 10); + + mockCssVar("--tg-viewport-content-safe-area-inset-bottom", 10); + mockCssVar("--tg-viewport-safe-area-inset-bottom", 20); + + mockCssVar("--tg-viewport-content-safe-area-inset-left", 10); + mockCssVar("--tg-viewport-safe-area-inset-left", 10); + + mockCssVar("--tg-viewport-content-safe-area-inset-right", 10); + mockCssVar("--tg-viewport-safe-area-inset-right", 10); + } + console.log( import.meta.env.DEV ? "TMA Debug mode in Web initialized" : "Telegram Mini App initialized", ); @@ -140,6 +158,59 @@ const externalTgApi = { }, }, initData: tg.initData, + viewport: tg.viewport, + viewportCss: { + stableHeight: () => + parseFloat(document.documentElement.style.getPropertyValue("--tg-viewport-stable-height")), + + height: () => + parseFloat(document.documentElement.style.getPropertyValue("--tg-viewport-height")), + width: () => parseFloat(document.documentElement.style.getPropertyValue("--tg-viewport-width")), + + safeAreaInsetTop: () => + parseFloat( + document.documentElement.style.getPropertyValue("--tg-viewport-safe-area-inset-top"), + ), + contentSafeAreaInsetTop: () => + parseFloat( + document.documentElement.style.getPropertyValue( + "--tg-viewport-content-safe-area-inset-top", + ), + ), + + safeAreaInsetBottom: () => + parseFloat( + document.documentElement.style.getPropertyValue("--tg-viewport-safe-area-inset-bottom"), + ), + contentSafeAreaInsetBottom: () => + parseFloat( + document.documentElement.style.getPropertyValue( + "--tg-viewport-content-safe-area-inset-bottom", + ), + ), + + safeAreaInsetLeft: () => + parseFloat( + document.documentElement.style.getPropertyValue("--tg-viewport-safe-area-inset-left"), + ), + contentSafeAreaInsetLeft: () => + parseFloat( + document.documentElement.style.getPropertyValue( + "--tg-viewport-content-safe-area-inset-left", + ), + ), + + safeAreaInsetRight: () => + parseFloat( + document.documentElement.style.getPropertyValue("--tg-viewport-safe-area-inset-right"), + ), + contentSafeAreaInsetRight: () => + parseFloat( + document.documentElement.style.getPropertyValue( + "--tg-viewport-content-safe-area-inset-right", + ), + ), + }, storage: { async clear() { return fallbackImplementation( @@ -149,27 +220,27 @@ const externalTgApi = { () => Object.values(STORAGE_KEYS).forEach((key) => localStorage.removeItem(key)), ); }, - async getItem(key: StorageKey, options?: tg.InvokeCustomMethodFpOptions) { + async getItem(key: StorageKey) { return fallbackImplementation( true, - [key, options], - (key, options) => tg.cloudStorage.getItem.ifAvailable(key, options), + [key], + (key) => tg.cloudStorage.getItem.ifAvailable(key), (key) => localStorage.getItem(key) ?? "", ); }, - async setItem(key: StorageKey, value: string, options?: tg.InvokeCustomMethodFpOptions) { + async setItem(key: StorageKey, value: string) { return fallbackImplementation( true, - [key, value, options], - (key, value, options) => tg.cloudStorage.setItem.ifAvailable(key, value, options), + [key, value], + (key, value) => tg.cloudStorage.setItem.ifAvailable(key, value), (key, value) => localStorage.setItem(key, value), ); }, - async deleteItem(key: StorageKey, options?: tg.InvokeCustomMethodFpOptions) { + async deleteItem(key: StorageKey) { return fallbackImplementation( true, - [key, options], - (key, options) => tg.cloudStorage.deleteItem.ifAvailable(key, options), + [key], + (key) => tg.cloudStorage.deleteItem.ifAvailable(key), (key) => localStorage.removeItem(key as string), ); }, @@ -178,5 +249,12 @@ const externalTgApi = { export default externalTgApi; -// @ts-expect-error -window.TG = externalTgApi; +if (import.meta.env.DEV) { + // @ts-expect-error + window.TG = externalTgApi; +} + +export const useTelegramViewportValue = (key: keyof typeof externalTgApi.viewportCss) => { + useSignal(externalTgApi.viewport[key]); + return externalTgApi.viewportCss[key](); +};