├── src ├── GlCards │ ├── index.ts │ ├── GlCard.tsx │ ├── GlCards.tsx │ ├── GlLogoCard.tsx │ ├── GlProjectCard.tsx │ └── GlMetricCard.tsx ├── GlFooter │ ├── index.ts │ ├── GlFooterInfo.tsx │ └── GlFooter.tsx ├── GlHeader │ ├── index.ts │ ├── GlHeaderLink.tsx │ ├── GlHeaderLinks.tsx │ └── GlHeader.tsx ├── GlHero │ ├── index.ts │ ├── GlHeroText.tsx │ └── GlHero.tsx ├── index.ts ├── tools │ ├── Source.ts │ ├── disableEmotionWarnings.ts │ ├── IllustrationProps.ts │ ├── useMediaAspectRatio.ts │ ├── useIntersectionObserver.tsx │ └── useNumberCountUpAnimation.ts ├── assets │ └── svg │ │ ├── downArrow.svg │ │ ├── backgroundWaveDark.svg │ │ ├── backgroundWaveLight.svg │ │ ├── youtube.svg │ │ ├── reddit.svg │ │ └── twitter.svg ├── tss.ts ├── global.d.ts ├── GlSectionDivider.tsx ├── theme.tsx ├── shared │ ├── GlGithubStarCount.tsx │ ├── useIllustrationStyles.ts │ ├── GlImage.tsx │ ├── GlArrow.tsx │ ├── GlLinkToTop.tsx │ └── GlVideo.tsx ├── GlReviewSlide.tsx ├── GlYoutubeVideoSection.tsx ├── GlSlider.tsx ├── GlTemplate.tsx ├── GlCheckList.tsx └── GlArticle.tsx ├── .eslintignore ├── .prettierignore ├── .prettierrc.json ├── .eslintrc.js ├── scripts ├── tools │ ├── getThisCodebaseRootDirPath.ts │ ├── crawl.ts │ └── transformCodebase.ts ├── build.ts ├── startRebuildOnSrcChange.ts ├── link-in-demo.ts └── link-in-app.ts ├── tsconfig.json ├── .gitignore ├── LICENSE ├── renovate.json ├── README.md ├── package.json └── .github └── workflows └── ci.yaml /src/GlCards/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./GlCards"; 2 | -------------------------------------------------------------------------------- /src/GlFooter/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./GlFooter"; 2 | -------------------------------------------------------------------------------- /src/GlHeader/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./GlHeader"; 2 | -------------------------------------------------------------------------------- /src/GlHero/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./GlHero"; 2 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { useTheme, breakpointsValues } from "./theme"; 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /dist/ 3 | /.eslintrc.js 4 | /CHANGELOG.md 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /dist/ 3 | /.eslintrc.js 4 | /docs/ 5 | /CHANGELOG.md 6 | /.yarn_home 7 | -------------------------------------------------------------------------------- /src/tools/Source.ts: -------------------------------------------------------------------------------- 1 | import type { DetailedHTMLProps, SourceHTMLAttributes } from "react"; 2 | 3 | export type Source = DetailedHTMLProps< 4 | SourceHTMLAttributes, 5 | HTMLSourceElement 6 | >; 7 | -------------------------------------------------------------------------------- /src/assets/svg/downArrow.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/tss.ts: -------------------------------------------------------------------------------- 1 | import { createTss } from "tss-react"; 2 | import { useTheme } from "./theme"; 3 | 4 | export const { tss } = createTss({ 5 | "useContext": function useContext() { 6 | const theme = useTheme(); 7 | return { theme }; 8 | }, 9 | }); 10 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "tabWidth": 4, 4 | "useTabs": false, 5 | "semi": true, 6 | "singleQuote": false, 7 | "quoteProps": "preserve", 8 | "trailingComma": "all", 9 | "bracketSpacing": true, 10 | "arrowParens": "avoid" 11 | } 12 | -------------------------------------------------------------------------------- /src/assets/svg/backgroundWaveDark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/svg/backgroundWaveLight.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "plugins": [ 5 | "@typescript-eslint", 6 | ], 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:@typescript-eslint/recommended" 10 | ], 11 | "rules": { 12 | "no-extra-boolean-cast": "off", 13 | "@typescript-eslint/explicit-module-boundary-types": "off", 14 | "@typescript-eslint/no-namespace": "off", 15 | "@typescript-eslint/no-explicit-any": "off", 16 | "prefer-const": "off" 17 | }, 18 | }; -------------------------------------------------------------------------------- /scripts/tools/getThisCodebaseRootDirPath.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs"; 2 | import { join as pathJoin } from "path"; 3 | 4 | function getThisCodebaseRootDirPath_rec(dirPath: string): string { 5 | if (fs.existsSync(pathJoin(dirPath, "package.json"))) { 6 | return dirPath; 7 | } 8 | return getThisCodebaseRootDirPath_rec(pathJoin(dirPath, "..")); 9 | } 10 | 11 | let result: string | undefined = undefined; 12 | 13 | export function getThisCodebaseRootDirPath(): string { 14 | if (result !== undefined) { 15 | return result; 16 | } 17 | 18 | return (result = getThisCodebaseRootDirPath_rec(__dirname)); 19 | } 20 | -------------------------------------------------------------------------------- /src/tools/disableEmotionWarnings.ts: -------------------------------------------------------------------------------- 1 | export function disableEmotionWarnings() { 2 | if (process.env.NODE_ENV !== "development") { 3 | return; 4 | } 5 | /* eslint-disable no-console */ 6 | const log = console.error.bind(console); 7 | console.error = (...args) => { 8 | /* eslint-enable no-console */ 9 | if ( 10 | args.indexOf("The pseudo class") && 11 | args.indexOf( 12 | "is potentially unsafe when doing server-side rendering. Try changing it to", 13 | ) 14 | ) { 15 | return; 16 | } 17 | log(...args); 18 | }; 19 | } 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "ES2020", 4 | "moduleResolution": "node", 5 | "target": "es5", 6 | "lib": ["es2015", "DOM"], 7 | "esModuleInterop": true, 8 | "declaration": true, 9 | "outDir": "./dist", 10 | "sourceMap": true, 11 | "newLine": "LF", 12 | "noUnusedLocals": true, 13 | "noUnusedParameters": true, 14 | "incremental": true, 15 | "strict": true, 16 | "downlevelIteration": true, 17 | "jsx": "react-jsx", 18 | "noFallthroughCasesInSwitch": true 19 | }, 20 | "include": ["src"], 21 | "exclude": ["src/test", "src/bin/yarn_link.ts"] 22 | } 23 | -------------------------------------------------------------------------------- /src/global.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.svg" { 2 | import * as React from "react"; 3 | 4 | export const ReactComponent: React.FunctionComponent< 5 | React.SVGProps & { title?: string } 6 | >; 7 | 8 | const src: string; 9 | export default src; 10 | } 11 | 12 | declare module "*.png" { 13 | const _default: string; 14 | export default _default; 15 | } 16 | 17 | declare module "*.css" { 18 | const _default: string; 19 | export default _default; 20 | } 21 | 22 | declare module "*.jpeg" { 23 | const _default: string; 24 | export default _default; 25 | } 26 | 27 | declare module "*.mp4" { 28 | const _default: string; 29 | export default _default; 30 | } 31 | -------------------------------------------------------------------------------- /scripts/build.ts: -------------------------------------------------------------------------------- 1 | import * as child_process from "child_process"; 2 | import { join as pathJoin } from "path"; 3 | import { transformCodebase } from "./tools/transformCodebase"; 4 | import { getThisCodebaseRootDirPath } from "./tools/getThisCodebaseRootDirPath"; 5 | 6 | console.log("Building vite-envs..."); 7 | 8 | const startTime = Date.now(); 9 | 10 | run(`npx tsc`); 11 | 12 | transformCodebase({ 13 | srcDirPath: pathJoin(getThisCodebaseRootDirPath(), "src", "assets"), 14 | destDirPath: pathJoin(getThisCodebaseRootDirPath(), "dist", "assets"), 15 | }); 16 | 17 | console.log(`✓ built in ${((Date.now() - startTime) / 1000).toFixed(2)}s`); 18 | 19 | function run(command: string) { 20 | console.log(`$ ${command}`); 21 | 22 | child_process.execSync(command, { stdio: "inherit" }); 23 | } 24 | -------------------------------------------------------------------------------- /src/assets/svg/youtube.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directories 30 | node_modules 31 | jspm_packages 32 | 33 | # Optional npm cache directory 34 | .npm 35 | 36 | # Optional REPL history 37 | .node_repl_history 38 | 39 | .vscode 40 | 41 | .DS_Store 42 | 43 | /dist 44 | /.yarn_home 45 | -------------------------------------------------------------------------------- /src/GlSectionDivider.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from "react"; 2 | import { tss } from "./tss"; 3 | 4 | export type GlSectionDividerProps = { 5 | className?: string; 6 | }; 7 | 8 | export const GlSectionDivider = memo((props: GlSectionDividerProps) => { 9 | const { className } = props; 10 | 11 | const { classes, cx } = useStyles(); 12 | 13 | return ( 14 |
15 |
16 |
17 | ); 18 | }); 19 | 20 | const useStyles = tss.withName({ GlSectionDivider }).create(({ theme }) => ({ 21 | "root": { 22 | "display": "flex", 23 | "justifyContent": "center", 24 | ...theme.spacing.topBottom("padding", `${theme.spacing(7)}px`), 25 | }, 26 | "inner": { 27 | "backgroundColor": theme.colors.useCases.typography.textTertiary, 28 | "height": 1, 29 | "width": "60%", 30 | }, 31 | })); 32 | -------------------------------------------------------------------------------- /src/theme.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-irregular-whitespace */ 2 | import { breakpointsValues as defaultBreakpointsValues } from "onyxia-ui"; 3 | import { useTheme as useTheme_base } from "onyxia-ui"; 4 | 5 | export function useTheme() { 6 | const theme = useTheme_base(); 7 | 8 | return { 9 | ...theme, 10 | "paddingRightLeft": theme.spacing( 11 | (() => { 12 | if (theme.windowInnerWidth >= breakpointsValues["lg"]) { 13 | return 7; 14 | } 15 | 16 | if (theme.windowInnerWidth >= breakpointsValues["sm"]) { 17 | return 6; 18 | } 19 | 20 | return 4; 21 | })(), 22 | ), 23 | "customShadow": "2px 3px 35px -1px rgba(0,0,0,0.45)", 24 | "borderRadius": theme.spacing(1), 25 | }; 26 | } 27 | 28 | export const breakpointsValues = { 29 | ...defaultBreakpointsValues, 30 | "lg+": 1440 as const, 31 | }; 32 | -------------------------------------------------------------------------------- /src/assets/svg/reddit.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/tools/IllustrationProps.ts: -------------------------------------------------------------------------------- 1 | import type { Source } from "./Source"; 2 | 3 | export type IllustrationProps = 4 | | IllustrationProps.Image 5 | | IllustrationProps.Video 6 | | IllustrationProps.CustomComponent; 7 | 8 | declare namespace IllustrationProps { 9 | export type Image = { 10 | type: "image"; 11 | sources?: Source[]; 12 | src: string; 13 | hasShadow?: boolean; 14 | hasBorderRadius?: boolean; 15 | alt?: string; 16 | }; 17 | 18 | export type Video = { 19 | type: "video"; 20 | sources: Source[]; 21 | hasShadow?: boolean; 22 | autoPlay?: boolean; 23 | delayBeforeAutoPlay?: number; 24 | muted?: boolean; 25 | loop?: boolean; 26 | controls?: boolean; 27 | hasBorderRadius?: boolean; 28 | }; 29 | 30 | export type CustomComponent = { 31 | type: "custom component"; 32 | Component: (props: { 33 | className?: string; 34 | onLoad: () => void; 35 | id: string; 36 | }) => JSX.Element | null; 37 | }; 38 | } 39 | -------------------------------------------------------------------------------- /scripts/tools/crawl.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs"; 2 | import { join as pathJoin, relative as pathRelative } from "path"; 3 | 4 | const crawlRec = (dirPath: string, filePaths: string[]) => { 5 | for (const basename of fs.readdirSync(dirPath)) { 6 | const fileOrDirPath = pathJoin(dirPath, basename); 7 | 8 | if (fs.lstatSync(fileOrDirPath).isDirectory()) { 9 | crawlRec(fileOrDirPath, filePaths); 10 | 11 | continue; 12 | } 13 | 14 | filePaths.push(fileOrDirPath); 15 | } 16 | }; 17 | 18 | /** List all files in a given directory return paths relative to the dir_path */ 19 | export function crawl(params: { 20 | dirPath: string; 21 | returnedPathsType: "absolute" | "relative to dirPath"; 22 | }): string[] { 23 | const { dirPath, returnedPathsType } = params; 24 | 25 | const filePaths: string[] = []; 26 | 27 | crawlRec(dirPath, filePaths); 28 | 29 | switch (returnedPathsType) { 30 | case "absolute": 31 | return filePaths; 32 | case "relative to dirPath": 33 | return filePaths.map(filePath => pathRelative(dirPath, filePath)); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 GitHub user u/thieryw 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "baseBranches": ["main", "landingpage"], 4 | "extends": ["config:base"], 5 | "dependencyDashboard": false, 6 | "bumpVersion": "patch", 7 | "rangeStrategy": "bump", 8 | "ignorePaths": [".github/**", "Dockerfile"], 9 | "branchPrefix": "renovate_", 10 | "vulnerabilityAlerts": { 11 | "enabled": false 12 | }, 13 | "packageRules": [ 14 | { 15 | "packagePatterns": ["*"], 16 | "excludePackagePatterns": [ 17 | "onyxia-ui", 18 | "i18nifty", 19 | "tss-react", 20 | "powerhooks", 21 | "tsafe", 22 | "evt" 23 | ], 24 | "enabled": false 25 | }, 26 | { 27 | "packagePatterns": [ 28 | "onyxia-ui", 29 | "i18nifty", 30 | "tss-react", 31 | "powerhooks", 32 | "tsafe", 33 | "evt" 34 | ], 35 | "matchUpdateTypes": ["minor", "patch"], 36 | "automerge": true, 37 | "automergeType": "pr", 38 | "platformAutomerge": true, 39 | "groupName": "garronej_modules_update" 40 | } 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /scripts/startRebuildOnSrcChange.ts: -------------------------------------------------------------------------------- 1 | import * as child_process from "child_process"; 2 | import { waitForDebounceFactory } from "powerhooks/tools/waitForDebounce"; 3 | import chokidar from "chokidar"; 4 | import * as runExclusive from "run-exclusive"; 5 | import { Deferred } from "evt/tools/Deferred"; 6 | import chalk from "chalk"; 7 | 8 | export function startRebuildOnSrcChange() { 9 | const { waitForDebounce } = waitForDebounceFactory({ delay: 400 }); 10 | 11 | const runYarnBuild = runExclusive.build(async () => { 12 | console.log(chalk.green("Running `yarn build`")); 13 | 14 | const dCompleted = new Deferred(); 15 | 16 | const child = child_process.spawn("yarn", ["build"], { shell: true }); 17 | 18 | child.stdout.on("data", data => process.stdout.write(data)); 19 | 20 | child.stderr.on("data", data => process.stderr.write(data)); 21 | 22 | child.on("exit", () => dCompleted.resolve()); 23 | 24 | await dCompleted.pr; 25 | 26 | console.log("\n\n"); 27 | }); 28 | 29 | console.log(chalk.green("Watching for changes in src/")); 30 | 31 | chokidar 32 | .watch(["src"], { ignoreInitial: true }) 33 | .on("all", async (event, path) => { 34 | console.log(chalk.bold(`${event}: ${path}`)); 35 | 36 | await waitForDebounce(); 37 | 38 | runYarnBuild(); 39 | }); 40 | } 41 | -------------------------------------------------------------------------------- /src/tools/useMediaAspectRatio.ts: -------------------------------------------------------------------------------- 1 | import { useDomRect } from "powerhooks/useDomRect"; 2 | import type { RefObject } from "react"; 3 | import { useEffect, useState } from "react"; 4 | import { useStateRef } from "powerhooks/useStateRef"; 5 | 6 | export function useMediaAspectRatio< 7 | T extends HTMLImageElement | HTMLVideoElement = any, 8 | >(): { ref: RefObject; aspectRatio: number }; 9 | export function useMediaAspectRatio< 10 | T extends HTMLImageElement | HTMLVideoElement = any, 11 | >(params: { ref: RefObject }): { aspectRatio: number }; 12 | export function useMediaAspectRatio< 13 | T extends HTMLImageElement | HTMLVideoElement = any, 14 | >(params?: { ref: RefObject }): { ref: RefObject; aspectRatio: number } { 15 | const internallyCreateRef = useStateRef(null); 16 | 17 | const ref = params?.ref ?? internallyCreateRef; 18 | 19 | const { 20 | domRect: { width, height }, 21 | } = useDomRect({ ref }); 22 | const [aspectRatio, setAspectRatio] = useState( 23 | undefined, 24 | ); 25 | 26 | useEffect(() => { 27 | if (aspectRatio !== undefined && !isNaN(aspectRatio)) { 28 | return; 29 | } 30 | 31 | setAspectRatio(width / height); 32 | }, [width, height]); 33 | 34 | return { 35 | "aspectRatio": aspectRatio === undefined ? NaN : aspectRatio, 36 | ref, 37 | }; 38 | } 39 | -------------------------------------------------------------------------------- /src/tools/useIntersectionObserver.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import type { RefObject } from "react"; 3 | import { useStateRef } from "powerhooks"; 4 | import { useConstCallback } from "powerhooks/useConstCallback"; 5 | 6 | export function useIntersectionObserver(params: { 7 | callback: (params: { 8 | entry: IntersectionObserverEntry; 9 | observer: IntersectionObserver; 10 | }) => void; 11 | rootMargin?: string; 12 | root?: Element | Document; 13 | threshold?: number | number[]; 14 | }): { 15 | ref: RefObject; 16 | } { 17 | const { rootMargin, root, threshold } = params; 18 | const ref = useStateRef(null); 19 | const callback = useConstCallback(params.callback); 20 | 21 | useEffect(() => { 22 | if (ref.current === null) { 23 | return; 24 | } 25 | 26 | const observer = new IntersectionObserver( 27 | entries => { 28 | callback({ 29 | "entry": entries[0], 30 | observer, 31 | }); 32 | }, 33 | { 34 | rootMargin, 35 | root, 36 | threshold, 37 | }, 38 | ); 39 | 40 | observer.observe(ref.current); 41 | 42 | return () => { 43 | observer.disconnect(); 44 | }; 45 | }, [rootMargin, root, threshold, ref.current]); 46 | 47 | return { ref }; 48 | } 49 | -------------------------------------------------------------------------------- /src/GlCards/GlCard.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from "react"; 2 | import { memo } from "react"; 3 | import { tss } from "../tss"; 4 | 5 | export type GlCardProps = { 6 | className?: string; 7 | children?: ReactNode; 8 | link?: { 9 | href: string; 10 | onClick?: () => void; 11 | }; 12 | }; 13 | 14 | export const GlCard = memo((props: GlCardProps) => { 15 | const { children, link, className } = props; 16 | 17 | const { classes, cx } = useStyles({ "isLink": link !== undefined }); 18 | 19 | const onClick = (() => { 20 | if (link === undefined) { 21 | return undefined; 22 | } 23 | 24 | return () => window.open(link.href, "_blank"); 25 | })(); 26 | 27 | return ( 28 |
29 | {children} 30 |
31 | ); 32 | }); 33 | 34 | const useStyles = tss 35 | .withName({ GlCard }) 36 | .withParams<{ isLink: boolean }>() 37 | .create(({ theme, isLink }) => ({ 38 | "root": { 39 | "borderRadius": 16, 40 | "transition": "box-shadow 200ms", 41 | "margin": theme.spacing(2), 42 | "boxShadow": theme.shadows[1], 43 | "backgroundColor": theme.colors.useCases.surfaces.surface1, 44 | "cursor": isLink ? "pointer" : undefined, 45 | ":hover": { 46 | "boxShadow": theme.shadows[2], 47 | }, 48 | }, 49 | })); 50 | -------------------------------------------------------------------------------- /src/GlHero/GlHeroText.tsx: -------------------------------------------------------------------------------- 1 | import { Text } from "onyxia-ui/Text"; 2 | import { memo } from "react"; 3 | import type { ReactNode } from "react"; 4 | import { tss } from "../tss"; 5 | import { breakpointsValues } from "../theme"; 6 | 7 | export type GlHeroTextProps = { 8 | className?: string; 9 | children: NonNullable; 10 | }; 11 | 12 | export const GlHeroText = memo((props: GlHeroTextProps) => { 13 | const { children, className } = props; 14 | 15 | const { classes, cx } = useStyles(); 16 | 17 | return ( 18 | 23 | {children} 24 | 25 | ); 26 | }); 27 | 28 | const useStyles = tss.withName({ GlHeroText }).create(({ theme }) => ({ 29 | "root": { 30 | "fontWeight": 700, 31 | ...(() => { 32 | const value = 33 | (theme.typography.rootFontSizePx / 16) * 34 | (() => { 35 | if (theme.windowInnerWidth >= breakpointsValues.xl) { 36 | return 86; 37 | } 38 | 39 | if (theme.windowInnerWidth >= 600) { 40 | return 52; 41 | } 42 | 43 | return 36; 44 | })(); 45 | 46 | return { 47 | "fontSize": value, 48 | "lineHeight": `${value}px`, 49 | }; 50 | })(), 51 | }, 52 | })); 53 | -------------------------------------------------------------------------------- /src/assets/svg/twitter.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /src/GlFooter/GlFooterInfo.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from "react"; 2 | import Link from "@mui/material/Link"; 3 | import { tss } from "../tss"; 4 | import { Text } from "onyxia-ui/Text"; 5 | 6 | export type GlFooterInfoProps = { 7 | className?: string; 8 | email?: string; 9 | phoneNumber?: string; 10 | }; 11 | 12 | export const GlFooterInfo = memo((props: GlFooterInfoProps) => { 13 | const { email, phoneNumber, className } = props; 14 | 15 | const { classes, cx } = useStyles(); 16 | 17 | return ( 18 |
19 | {email !== undefined && ( 20 | 21 | {email} 22 | 23 | )} 24 | 25 | {phoneNumber !== undefined && ( 26 | {phoneNumber} 27 | )} 28 |
29 | ); 30 | }); 31 | 32 | const useStyles = tss.withName({ GlFooterInfo }).create(({ theme }) => ({ 33 | "root": { 34 | "display": "flex", 35 | "flexDirection": "column", 36 | "alignItems": "center", 37 | "justifyContent": "center", 38 | ...(() => { 39 | const value = theme.spacing(4); 40 | 41 | return { 42 | "paddingTop": value, 43 | "paddingBottom": value, 44 | }; 45 | })(), 46 | }, 47 | "email": { 48 | "color": theme.colors.useCases.typography.textSecondary, 49 | "marginBottom": theme.spacing(1), 50 | "textDecoration": "none", 51 | }, 52 | })); 53 | -------------------------------------------------------------------------------- /scripts/link-in-demo.ts: -------------------------------------------------------------------------------- 1 | import * as child_process from "child_process"; 2 | import * as fs from "fs"; 3 | import { join } from "path"; 4 | import { startRebuildOnSrcChange } from "./startRebuildOnSrcChange"; 5 | import { crawl } from "./tools/crawl"; 6 | 7 | { 8 | const dirPath = "node_modules"; 9 | 10 | try { 11 | fs.rmSync(dirPath, { recursive: true, force: true }); 12 | } catch { 13 | // NOTE: This is a workaround for windows 14 | // we can't remove locked executables. 15 | 16 | crawl({ 17 | dirPath, 18 | returnedPathsType: "absolute", 19 | }).forEach(filePath => { 20 | try { 21 | fs.rmSync(filePath, { force: true }); 22 | } catch (error) { 23 | if (filePath.endsWith(".exe")) { 24 | return; 25 | } 26 | throw error; 27 | } 28 | }); 29 | } 30 | } 31 | 32 | fs.rmSync("dist", { recursive: true, force: true }); 33 | fs.rmSync(".yarn_home", { recursive: true, force: true }); 34 | 35 | run("yarn install"); 36 | run("yarn build"); 37 | 38 | const starterName = "gitlanding-demo"; 39 | 40 | fs.rmSync(join("..", starterName, "node_modules"), { 41 | recursive: true, 42 | force: true, 43 | }); 44 | 45 | run("yarn install", { cwd: join("..", starterName) }); 46 | 47 | run(`npx tsx ${join("scripts", "link-in-app.ts")} ${starterName}`); 48 | 49 | startRebuildOnSrcChange(); 50 | 51 | function run(command: string, options?: { cwd: string }) { 52 | console.log(`$ ${command}`); 53 | 54 | child_process.execSync(command, { stdio: "inherit", ...options }); 55 | } 56 | -------------------------------------------------------------------------------- /src/shared/GlGithubStarCount.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from "react"; 2 | import { useTheme } from "../theme"; 3 | import { tss } from "../tss"; 4 | import GithubBtn from "react-github-btn"; 5 | 6 | export type GlGithubStarCountProps = { 7 | className?: string; 8 | size?: "normal" | "large"; 9 | repoUrl: string; 10 | showCount?: boolean; 11 | }; 12 | 13 | export const GlGithubStarCount = memo((props: GlGithubStarCountProps) => { 14 | const { repoUrl, size, className, showCount } = props; 15 | 16 | const { classes, cx } = useStyles(); 17 | 18 | const { themeVariant } = (function useClosure() { 19 | const { isDarkModeEnabled } = useTheme(); 20 | 21 | const themeVariant = isDarkModeEnabled ? "dark" : ("light" as const); 22 | 23 | return { themeVariant }; 24 | })(); 25 | 26 | return ( 27 |
28 | `${osPref}: ${themeVariant};`, 34 | ), 35 | ].join("\n")} 36 | data-icon="octicon-star" 37 | data-size={size === "large" ? size : ""} 38 | data-show-count={`${showCount ?? false}`} 39 | > 40 |
41 | ); 42 | }); 43 | 44 | const useStyles = tss.withName({ GlGithubStarCount }).create({ 45 | "root": { 46 | "& span": { 47 | "display": "flex", 48 | "alignItems": "center", 49 | }, 50 | }, 51 | }); 52 | -------------------------------------------------------------------------------- /src/shared/useIllustrationStyles.ts: -------------------------------------------------------------------------------- 1 | import { tss } from "../tss"; 2 | import type { IllustrationProps } from "../tools/IllustrationProps"; 3 | import { breakpointsValues } from "../theme"; 4 | 5 | export const useIllustrationStyles = tss 6 | .withParams<{ 7 | aspectRatio: number; 8 | illustrationZoomFactor: number | undefined; 9 | type: IllustrationProps["type"] | undefined; 10 | }>() 11 | .create(({ theme, aspectRatio, illustrationZoomFactor, type }) => ({ 12 | "root": { 13 | ...(() => { 14 | if (type === "custom component" || type === undefined) { 15 | return undefined; 16 | } 17 | 18 | if (isNaN(aspectRatio)) { 19 | return { 20 | "opacity": 0, 21 | }; 22 | } 23 | 24 | const value = 25 | (() => { 26 | if (aspectRatio <= 1) { 27 | return 600 * aspectRatio; 28 | } 29 | 30 | return 600; 31 | })() * (illustrationZoomFactor ?? 1); 32 | return { 33 | "maxWidth": value, 34 | "minWidth": 35 | theme.windowInnerWidth < breakpointsValues.md 36 | ? undefined 37 | : value, 38 | "alignSelf": 39 | theme.windowInnerWidth < breakpointsValues.md 40 | ? "center" 41 | : undefined, 42 | }; 43 | })(), 44 | }, 45 | })); 46 | -------------------------------------------------------------------------------- /src/shared/GlImage.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-namespace */ 2 | import { memo, forwardRef } from "react"; 3 | import type { ForwardedRef } from "react"; 4 | import { tss } from "../tss"; 5 | import { Source } from "../tools/Source"; 6 | 7 | export type GlImageProps = { 8 | id?: string; 9 | className?: string; 10 | src: string; 11 | alt?: string; 12 | width?: number; 13 | height?: number; 14 | hasShadow?: boolean; 15 | hasBorderRadius?: boolean; 16 | sources?: Source[]; 17 | onLoad?: () => void; 18 | }; 19 | 20 | export const GlImage = memo( 21 | forwardRef((props: GlImageProps, ref: ForwardedRef) => { 22 | const { 23 | hasBorderRadius = true, 24 | id, 25 | className, 26 | src, 27 | alt, 28 | height, 29 | width, 30 | hasShadow = true, 31 | sources, 32 | onLoad, 33 | } = props; 34 | 35 | const { classes, cx } = useStyles({ 36 | hasShadow, 37 | hasBorderRadius, 38 | }); 39 | return ( 40 | 41 | {sources !== undefined && 42 | sources.map(source => )} 43 | {alt} 53 | 54 | ); 55 | }), 56 | ); 57 | 58 | const useStyles = tss 59 | .withParams<{ hasShadow: boolean; hasBorderRadius: boolean }>() 60 | .withName({ GlImage }) 61 | .create(({ theme, hasShadow, hasBorderRadius }) => ({ 62 | "root": { 63 | "boxShadow": !hasShadow ? undefined : theme.customShadow, 64 | "borderRadius": !hasBorderRadius ? undefined : theme.borderRadius, 65 | }, 66 | })); 67 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 |

5 | ✒️ A set of components for creating landing pages with onyxia-ui ✒️ 6 |
7 |
8 | 9 | 10 | 11 | 12 | 13 | 14 |

15 | 16 | Gitlanding is an extension for [onyxia-ui](https://github.com/InseeFrLab/onyxia-ui) that features a set of 17 | component for creating landing pages. 18 | 19 | Example of gitlanding landing page: 20 | 21 | - https://www.sspcloud.fr 22 | - https://www.onyxia.sh 23 | - https://www.keycloakify.dev 24 | - https://www.tss-react.dev 25 | - https://www.i18nifty.dev 26 | 27 | https://user-images.githubusercontent.com/6702424/148715912-64485db0-ae26-474f-a6ce-b9a142a419e0.mp4 28 | 29 | https://user-images.githubusercontent.com/6702424/148716227-4a699c07-ba17-4860-b4bb-9feeed8b7662.mp4 30 | 31 | # 🚀 Quick start 32 | 33 | Try the demo project: 34 | 35 | ```bash 36 | git clone https://github.com/garrone/gitlanding-demo 37 | cd gitlanding-demo 38 | yarn 39 | yarn dev 40 | ``` 41 | 42 | > [!WARNING]: There are bugs when you use ``... 43 | 44 | > [!NOTE] 45 | > This project is an extension of [onyxia-ui](https://github.com/InseeFrLab/onyxia-ui) the 46 | > gitlanding components needs be inside the `` provider. 47 | > Besides the required dependencies of onyxia-ui you only need to install `gitlanding`. 48 | 49 | # Contributing 50 | 51 | To link your local copy of gitlanding in the demo project: 52 | 53 | ```bash 54 | cd ~/github 55 | git clone https://github.com/thieryw/gitlanding 56 | git clone https://github.com/garronej/gitlanding-demo 57 | cd gitlanding 58 | yarn link-in-demo 59 | ``` 60 | 61 | In another terminal: 62 | 63 | ```bash 64 | cd ~/github/gitlanding-demo 65 | yarn dev 66 | ``` 67 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gitlanding", 3 | "version": "2.3.16", 4 | "description": "A module that generates a landing page for your projects", 5 | "repository": { 6 | "type": "git", 7 | "url": "git://github.com/thieryw/gitlanding.git" 8 | }, 9 | "main": "dist/index.js", 10 | "types": "dist/index.d.ts", 11 | "scripts": { 12 | "build": "tsx scripts/build.ts", 13 | "link-in-demo": "tsx scripts/link-in-demo.ts", 14 | "_format": "prettier '**/*.{ts,tsx,json,md}'", 15 | "format": "npm run _format -- --write", 16 | "format:check": "npm run _format -- --list-different" 17 | }, 18 | "lint-staged": { 19 | "*.{ts,tsx,json,md}": [ 20 | "prettier --write" 21 | ] 22 | }, 23 | "husky": { 24 | "hooks": { 25 | "pre-commit": "lint-staged -v" 26 | } 27 | }, 28 | "author": "u/thieryw", 29 | "license": "MIT", 30 | "files": [ 31 | "src/", 32 | "!src/test/", 33 | "dist/", 34 | "!dist/test/", 35 | "!dist/tsconfig.tsbuildinfo" 36 | ], 37 | "keywords": [], 38 | "homepage": "https://github.com/thieryw/gitlanding", 39 | "peerDependencies": { 40 | "onyxia-ui": "^6.7.8", 41 | "@mui/material": "^6.1.7", 42 | "@emotion/react": "^11.0.0", 43 | "react": "^17.0.0 || ^18.0.0 || ^19.0.0", 44 | "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" 45 | }, 46 | "dependencies": { 47 | "@mui/icons-material": "^6.1.7", 48 | "embla-carousel-react": "^7.0.3", 49 | "framer-motion": "^4.1.17", 50 | "powerhooks": "^2.0.1", 51 | "react-github-btn": "^1.2.0", 52 | "tsafe": "^1.8.12", 53 | "tss-react": "^4.9.20" 54 | }, 55 | "devDependencies": { 56 | "@emotion/react": "^11.13.3", 57 | "@emotion/styled": "^11.13.0", 58 | "@mui/material": "^6.1.7", 59 | "@types/node": "^22.5.5", 60 | "@types/react": "^18.3.5", 61 | "@types/react-dom": "^18.3.0", 62 | "evt": "^2.5.9", 63 | "husky": "^4.3.0", 64 | "lint-staged": "^10.5.4", 65 | "onyxia-ui": "^6.7.8", 66 | "prettier": "^2.7.1", 67 | "react": "^18.3.1", 68 | "react-dom": "^18.3.1", 69 | "storybook-dark-mode": "^1.1.0", 70 | "typescript": "^5.6.2", 71 | "tsx": "^4.19.1", 72 | "chokidar": "^3.6.0" 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/tools/useNumberCountUpAnimation.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import type { RefObject, Dispatch, SetStateAction } from "react"; 3 | import { useStateRef } from "powerhooks/useStateRef"; 4 | 5 | export type UseNumberCountUpAnimationParams = { 6 | number: number | undefined; 7 | intervalMs: number; 8 | }; 9 | 10 | export function useNumberCountUpAnimation( 11 | params: UseNumberCountUpAnimationParams, 12 | ): { 13 | renderedNumber: number; 14 | ref: RefObject; 15 | } { 16 | const { number, intervalMs } = params; 17 | const [renderedNumber, setRenderedNumber] = useState(0); 18 | const ref = useStateRef(null); 19 | 20 | useEffect(() => { 21 | const element = ref.current; 22 | if (!element) { 23 | animate({ 24 | number, 25 | intervalMs, 26 | setRenderedNumber, 27 | }); 28 | return; 29 | } 30 | 31 | const observer = new IntersectionObserver(entries => { 32 | if (!entries[0].isIntersecting) { 33 | return; 34 | } 35 | 36 | animate({ number, intervalMs, setRenderedNumber }); 37 | observer.unobserve(entries[0].target); 38 | }); 39 | 40 | observer.observe(element); 41 | }, [number]); 42 | 43 | return { renderedNumber, ref }; 44 | } 45 | 46 | const { animate } = (() => { 47 | type Params = UseNumberCountUpAnimationParams & { 48 | setRenderedNumber: Dispatch>; 49 | }; 50 | 51 | const awaitDelay = (params: { delayMs: number }) => 52 | new Promise(resolve => setTimeout(resolve, params.delayMs)); 53 | 54 | async function animate(params: Params) { 55 | const { intervalMs, number, setRenderedNumber } = params; 56 | 57 | if (number === undefined) { 58 | return; 59 | } 60 | 61 | let currentIntervalMs = intervalMs; 62 | 63 | for (let count = 0; count <= number; count++) { 64 | await awaitDelay({ 65 | "delayMs": (() => { 66 | if ( 67 | (number < 40 && count <= number - 7) || 68 | (number >= 40 && count <= number - 14) 69 | ) { 70 | return currentIntervalMs; 71 | } 72 | 73 | return (currentIntervalMs += 10); 74 | })(), 75 | }); 76 | setRenderedNumber(count); 77 | } 78 | } 79 | 80 | return { animate }; 81 | })(); 82 | -------------------------------------------------------------------------------- /scripts/tools/transformCodebase.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs"; 2 | import * as path from "path"; 3 | import { crawl } from "./crawl"; 4 | 5 | type TransformSourceCode = (params: { 6 | sourceCode: Buffer; 7 | filePath: string; 8 | fileRelativePath: string; 9 | }) => 10 | | { 11 | modifiedSourceCode: Buffer; 12 | newFileName?: string; 13 | } 14 | | undefined; 15 | 16 | /** 17 | * Apply a transformation function to every file of directory 18 | * If source and destination are the same this function can be used to apply the transformation in place 19 | * like filtering out some files or modifying them. 20 | * */ 21 | export function transformCodebase(params: { 22 | srcDirPath: string; 23 | destDirPath: string; 24 | transformSourceCode?: TransformSourceCode; 25 | }) { 26 | const { srcDirPath, transformSourceCode } = params; 27 | 28 | const isTargetSameAsSource = 29 | path.relative(srcDirPath, params.destDirPath) === ""; 30 | 31 | const destDirPath = isTargetSameAsSource 32 | ? path.join(srcDirPath, "..", "tmp_xOsPdkPsTdzPs34sOkHs") 33 | : params.destDirPath; 34 | 35 | fs.mkdirSync(destDirPath, { 36 | "recursive": true, 37 | }); 38 | 39 | for (const fileRelativePath of crawl({ 40 | "dirPath": srcDirPath, 41 | "returnedPathsType": "relative to dirPath", 42 | })) { 43 | const filePath = path.join(srcDirPath, fileRelativePath); 44 | const destFilePath = path.join(destDirPath, fileRelativePath); 45 | 46 | // NOTE: Optimization, if we don't need to transform the file, just copy 47 | // it using the lower level implementation. 48 | if (transformSourceCode === undefined) { 49 | fs.mkdirSync(path.dirname(destFilePath), { 50 | "recursive": true, 51 | }); 52 | 53 | fs.copyFileSync(filePath, destFilePath); 54 | 55 | continue; 56 | } 57 | 58 | const transformSourceCodeResult = transformSourceCode({ 59 | "sourceCode": fs.readFileSync(filePath), 60 | filePath, 61 | fileRelativePath, 62 | }); 63 | 64 | if (transformSourceCodeResult === undefined) { 65 | continue; 66 | } 67 | 68 | fs.mkdirSync(path.dirname(destFilePath), { 69 | "recursive": true, 70 | }); 71 | 72 | const { newFileName, modifiedSourceCode } = transformSourceCodeResult; 73 | 74 | fs.writeFileSync( 75 | path.join( 76 | path.dirname(destFilePath), 77 | newFileName ?? path.basename(destFilePath), 78 | ), 79 | modifiedSourceCode, 80 | ); 81 | } 82 | 83 | if (isTargetSameAsSource) { 84 | fs.rmSync(srcDirPath, { "recursive": true }); 85 | 86 | fs.renameSync(destDirPath, srcDirPath); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/GlCards/GlCards.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-namespace */ 2 | import { memo } from "react"; 3 | import type { ReactNode } from "react"; 4 | import { Text } from "onyxia-ui/Text"; 5 | import { tss } from "../tss"; 6 | import { breakpointsValues } from "../theme"; 7 | import { useEffect, useState } from "react"; 8 | import { useStateRef } from "powerhooks/useStateRef"; 9 | 10 | export type GlCardsProps = { 11 | className?: string; 12 | classes?: Partial["classes"]>; 13 | id?: string; 14 | title?: ReactNode; 15 | children?: ReactNode; 16 | }; 17 | 18 | export const GlCards = memo((props: GlCardsProps) => { 19 | const { title, children, className, id } = props; 20 | const ref = useStateRef(null); 21 | 22 | const [numberOfCards, setNumberOfCards] = useState(0); 23 | 24 | useEffect(() => { 25 | if (!ref.current) { 26 | return; 27 | } 28 | 29 | setNumberOfCards(ref.current.childElementCount); 30 | }, []); 31 | 32 | const { classes, cx } = useStyles({ 33 | numberOfCards, 34 | "classesOverrides": props.classes, 35 | }); 36 | 37 | return ( 38 |
39 | {title !== undefined && ( 40 |
41 | {typeof title === "string" ? ( 42 | 43 | {title} 44 | 45 | ) : ( 46 | title 47 | )} 48 |
49 | )} 50 |
51 | {children} 52 |
53 |
54 | ); 55 | }); 56 | 57 | const useStyles = tss 58 | .withName({ GlCards }) 59 | .withParams<{ numberOfCards: number }>() 60 | .create(({ theme, numberOfCards }) => ({ 61 | "root": { 62 | ...theme.spacing.rightLeft( 63 | "padding", 64 | `${theme.paddingRightLeft}px`, 65 | ), 66 | ...theme.spacing.topBottom("margin", `${theme.spacing(7)}px`), 67 | }, 68 | "titleWrapper": { 69 | "marginTop": theme.spacing(5), 70 | "marginBottom": theme.spacing(7), 71 | "display": "flex", 72 | "justifyContent": "center", 73 | }, 74 | "title": {}, 75 | "cardsWrapper": { 76 | "display": "grid", 77 | "gridTemplateColumns": (() => { 78 | if (theme.windowInnerWidth >= breakpointsValues.lg) { 79 | return `repeat(${ 80 | numberOfCards > 4 ? 4 : numberOfCards 81 | }, 1fr)`; 82 | } 83 | 84 | if (theme.windowInnerWidth >= breakpointsValues.md) { 85 | return `repeat(${ 86 | numberOfCards > 3 ? 2 : numberOfCards 87 | }, 1fr)`; 88 | } 89 | 90 | if (theme.windowInnerWidth >= breakpointsValues.sm) { 91 | return `repeat(${numberOfCards > 3 ? 2 : 1}, 1fr)`; 92 | } 93 | 94 | return undefined; 95 | })(), 96 | }, 97 | })); 98 | -------------------------------------------------------------------------------- /src/shared/GlArrow.tsx: -------------------------------------------------------------------------------- 1 | import DownArrow from "@mui/icons-material/KeyboardArrowDown"; 2 | 3 | import { memo } from "react"; 4 | import { tss } from "../tss"; 5 | 6 | export type GlArrowProps = { 7 | className?: string; 8 | classes?: Partial["classes"]>; 9 | direction: "up" | "down" | "left" | "right"; 10 | hasCircularBorder?: boolean; 11 | onClick?: () => void; 12 | colorLight?: string; 13 | colorDark?: string; 14 | }; 15 | 16 | export const GlArrow = memo((props: GlArrowProps) => { 17 | const { 18 | className, 19 | direction, 20 | hasCircularBorder, 21 | onClick, 22 | colorDark, 23 | colorLight, 24 | } = props; 25 | 26 | const { classes, cx } = useStyles({ 27 | direction, 28 | "hasCircularBorder": hasCircularBorder ?? false, 29 | "colorDark": colorDark ?? "", 30 | "colorLight": colorLight ?? "", 31 | "classesOverrides": props.classes, 32 | }); 33 | 34 | return ( 35 |
36 | 37 |
38 | ); 39 | }); 40 | 41 | const useStyles = tss 42 | .withName({ GlArrow }) 43 | .withParams< 44 | Required> 45 | >() 46 | .create( 47 | ({ theme, direction, hasCircularBorder, colorDark, colorLight }) => { 48 | const color = (() => { 49 | if (theme.isDarkModeEnabled) { 50 | return colorDark.length === 0 51 | ? theme.colors.palette.light.main 52 | : colorDark; 53 | } 54 | 55 | return colorLight.length === 0 56 | ? theme.colors.palette.dark.main 57 | : colorLight; 58 | })(); 59 | 60 | return { 61 | "root": { 62 | "border": hasCircularBorder 63 | ? ` 64 | solid ${color} 2px 65 | ` 66 | : undefined, 67 | "padding": 10, 68 | "borderRadius": "50%", 69 | ...(() => { 70 | const value = theme.spacing(5); 71 | 72 | return { 73 | "width": value, 74 | "height": value, 75 | }; 76 | })(), 77 | "transform": (() => { 78 | switch (direction) { 79 | case "down": 80 | return undefined; 81 | case "up": 82 | return "rotate(180deg)"; 83 | case "left": 84 | return "rotate(90deg)"; 85 | case "right": 86 | return "rotate(-90deg)"; 87 | } 88 | })(), 89 | "alignItems": "center", 90 | "justifyContent": "center", 91 | "display": "flex", 92 | }, 93 | "arrow": { 94 | "fill": color, 95 | }, 96 | }; 97 | }, 98 | ); 99 | -------------------------------------------------------------------------------- /src/shared/GlLinkToTop.tsx: -------------------------------------------------------------------------------- 1 | import { memo, useState, useEffect } from "react"; 2 | import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward"; 3 | import { tss } from "../tss"; 4 | import { useConstCallback } from "powerhooks/useConstCallback"; 5 | import { Evt } from "evt"; 6 | import { useEvt } from "evt/hooks"; 7 | import { getScrollableParent } from "powerhooks/getScrollableParent"; 8 | import { useStateRef } from "powerhooks/useStateRef"; 9 | 10 | export type GlLinkToTopProps = { 11 | className?: string; 12 | classes?: Partial["classes"]>; 13 | }; 14 | 15 | export const GlLinkToTop = memo((props: GlLinkToTopProps) => { 16 | const { className } = props; 17 | const [isShown, setIsShown] = useState(false); 18 | const [scrollableParent, setScrollableParent] = useState< 19 | ReturnType | undefined 20 | >(undefined); 21 | 22 | const ref = useStateRef(null); 23 | 24 | useEffect(() => { 25 | const element = ref.current; 26 | if (!element) { 27 | return; 28 | } 29 | 30 | setScrollableParent( 31 | getScrollableParent({ 32 | element, 33 | "doReturnElementIfScrollable": true, 34 | }), 35 | ); 36 | }, []); 37 | 38 | const onClick = useConstCallback(() => { 39 | if (scrollableParent === undefined) { 40 | return; 41 | } 42 | scrollableParent.scrollTo({ 43 | "top": 0, 44 | "behavior": "smooth", 45 | }); 46 | }); 47 | 48 | useEvt( 49 | ctx => { 50 | if (scrollableParent === undefined) { 51 | return; 52 | } 53 | 54 | Evt.from(ctx, scrollableParent, "scroll").attach(() => { 55 | const { scrollTop } = scrollableParent; 56 | if (scrollTop / (window.innerHeight / 100) >= 70) { 57 | setIsShown(true); 58 | return; 59 | } 60 | 61 | setIsShown(false); 62 | }); 63 | }, 64 | [scrollableParent], 65 | ); 66 | 67 | const { classes, cx } = useStyles({ 68 | isShown, 69 | "classesOverrides": props.classes, 70 | }); 71 | 72 | return ( 73 |
78 | 79 |
80 | ); 81 | }); 82 | 83 | const useStyles = tss 84 | .withParams<{ isShown: boolean }>() 85 | .withName({ GlLinkToTop }) 86 | .create(({ theme, isShown }) => ({ 87 | "root": { 88 | "transition": "opacity, 500ms", 89 | "zIndex": 1, 90 | "display": "flex", 91 | "backgroundColor": theme.colors.useCases.surfaces.background, 92 | "alignItems": "center", 93 | "borderRadius": 5, 94 | "justifyContent": "center", 95 | "padding": theme.spacing(1), 96 | "opacity": isShown ? 0.6 : 0, 97 | "pointerEvents": isShown ? undefined : "none", 98 | "position": "fixed", 99 | "border": `solid ${theme.colors.useCases.typography.textPrimary} 3px`, 100 | "top": "90%", 101 | "right": theme.paddingRightLeft, 102 | "cursor": "pointer", 103 | }, 104 | "arrowIcon": {}, 105 | })); 106 | -------------------------------------------------------------------------------- /src/GlHeader/GlHeaderLink.tsx: -------------------------------------------------------------------------------- 1 | import { memo, useState, ReactNode } from "react"; 2 | import { tss } from "../tss"; 3 | import { symToStr } from "tsafe/symToStr"; 4 | import { useCallbackFactory } from "powerhooks/useCallbackFactory"; 5 | import { useDomRect } from "powerhooks/useDomRect"; 6 | 7 | export type GlHeaderLinkProps = { 8 | className?: string; 9 | classes?: Partial["classes"]>; 10 | label: ReactNode; 11 | href: string; 12 | onClick?: () => void; 13 | isActive?: boolean; 14 | }; 15 | 16 | export const GlHeaderLink = memo((props: GlHeaderLinkProps) => { 17 | const { href, label, className, onClick, isActive = false } = props; 18 | const [isHover, setIsHover] = useState(false); 19 | const [isPressed, setIsPressed] = useState(false); 20 | 21 | const handleHoverFactory = useCallbackFactory( 22 | ([state]: ["enter" | "leave"]) => { 23 | switch (state) { 24 | case "enter": 25 | setIsHover(true); 26 | return; 27 | case "leave": 28 | setIsHover(false); 29 | } 30 | }, 31 | ); 32 | 33 | const handleClickFactory = useCallbackFactory( 34 | ([state]: ["down" | "up"]) => { 35 | switch (state) { 36 | case "down": 37 | setIsPressed(true); 38 | return; 39 | case "up": 40 | setIsPressed(false); 41 | } 42 | }, 43 | ); 44 | 45 | const { 46 | ref, 47 | domRect: { width }, 48 | } = useDomRect(); 49 | 50 | const { classes, cx } = useStyles({ 51 | isUnderlined: isHover || isActive, 52 | width, 53 | isAccentColor: isPressed || isActive, 54 | "classesOverrides": props.classes, 55 | }); 56 | 57 | return ( 58 |
59 | 68 | {label} 69 | 70 |
71 |
72 | ); 73 | }); 74 | 75 | const useStyles = tss 76 | .withName(`${symToStr({ GlHeaderLink })}`) 77 | .withParams<{ 78 | isUnderlined: boolean; 79 | isAccentColor: boolean; 80 | width: number; 81 | }>() 82 | .create(({ theme, isUnderlined, isAccentColor, width }) => ({ 83 | "root": { 84 | "display": "flex", 85 | "flexDirection": "column", 86 | "alignItems": "center", 87 | }, 88 | "link": { 89 | "transition": "color 200ms", 90 | "color": isAccentColor 91 | ? theme.colors.useCases.buttons.actionActive 92 | : theme.colors.useCases.typography.textPrimary, 93 | "textDecoration": "none", 94 | ...theme.spacing.rightLeft("padding", `${theme.spacing(3)}px`), 95 | "whiteSpace": "nowrap", 96 | }, 97 | "underline": { 98 | "width": isUnderlined ? width - theme.spacing(3) : 0, 99 | "marginTop": theme.spacing(1), 100 | "height": 1, 101 | "backgroundColor": isAccentColor 102 | ? theme.colors.useCases.buttons.actionActive 103 | : theme.colors.useCases.typography.textPrimary, 104 | "transition": "width 200ms, background-color 200ms", 105 | }, 106 | })); 107 | -------------------------------------------------------------------------------- /src/GlReviewSlide.tsx: -------------------------------------------------------------------------------- 1 | import { tss } from "./tss"; 2 | import { Text } from "onyxia-ui/Text"; 3 | import { memo } from "react"; 4 | import { ThemedImage } from "onyxia-ui/ThemedImage"; 5 | import Paper from "@mui/material/Paper"; 6 | import { breakpointsValues } from "./theme"; 7 | import { Markdown } from "onyxia-ui/Markdown"; 8 | 9 | export type GlReviewSlideProps = { 10 | /** 11 | * you can use markdown between back ticks. 12 | */ 13 | descriptionMd?: string; 14 | signature?: string; 15 | /** 16 | * If you use an svg image that does not have a fill, 17 | * the fill will be set to the current font color, 18 | * depending on the dark mode being active. 19 | */ 20 | logoUrl?: string; 21 | className?: string; 22 | classes?: Partial["classes"]>; 23 | }; 24 | 25 | export const GlReviewSlide = memo((props: GlReviewSlideProps) => { 26 | const { descriptionMd, className, signature, logoUrl } = props; 27 | 28 | const { classes, cx } = useStyles({ 29 | "classesOverrides": props.classes, 30 | }); 31 | 32 | return ( 33 | 34 | {logoUrl !== undefined && ( 35 | 36 | )} 37 |
38 | {descriptionMd !== undefined && ( 39 | 40 | {descriptionMd} 41 | 42 | )} 43 | {signature !== undefined && ( 44 | 45 | {signature} 46 | 47 | )} 48 |
49 |
50 | ); 51 | }); 52 | 53 | const useStyles = tss.withName({ GlReviewSlide }).create(({ theme }) => ({ 54 | "root": { 55 | "display": "flex", 56 | "alignItems": "center", 57 | "justifyContent": "space-between", 58 | "flexDirection": (() => { 59 | if (theme.windowInnerWidth >= breakpointsValues.md) { 60 | return "row"; 61 | } 62 | 63 | return "column"; 64 | })(), 65 | }, 66 | "paragraph": { 67 | ...theme.typography.variants["body 1"].style, 68 | "textAlign": (() => { 69 | if (theme.windowInnerWidth >= breakpointsValues.md) { 70 | return undefined; 71 | } 72 | 73 | return "center"; 74 | })(), 75 | "margin": (() => { 76 | if (theme.windowInnerWidth >= breakpointsValues.sm) { 77 | return theme.spacing(5); 78 | } 79 | 80 | return theme.spacing({ 81 | "topBottom": 1, 82 | "rightLeft": 5, 83 | }); 84 | })(), 85 | }, 86 | "signature": { 87 | "fontStyle": "italic", 88 | ...theme.spacing.rightLeft("margin", `${theme.spacing(5)}px`), 89 | "marginBottom": theme.spacing(5), 90 | "textAlign": (() => { 91 | if (theme.windowInnerWidth >= breakpointsValues.md) { 92 | return "right"; 93 | } 94 | 95 | return "center"; 96 | })(), 97 | }, 98 | "logo": { 99 | "width": 70, 100 | "& svg": { 101 | "width": 70, 102 | "height": 70, 103 | }, 104 | ...(() => { 105 | if (theme.windowInnerWidth >= breakpointsValues.md) { 106 | return { 107 | "marginLeft": theme.spacing(5), 108 | }; 109 | } 110 | 111 | return { 112 | "marginLeft": 0, 113 | "marginTop": theme.spacing(5), 114 | }; 115 | })(), 116 | }, 117 | })); 118 | -------------------------------------------------------------------------------- /src/shared/GlVideo.tsx: -------------------------------------------------------------------------------- 1 | import { memo, forwardRef, useEffect, useState, useId } from "react"; 2 | import type { ForwardedRef } from "react"; 3 | import { Source } from "../tools/Source"; 4 | import { tss } from "../tss"; 5 | import { useIntersectionObserver } from "../tools/useIntersectionObserver"; 6 | import { useMergeRefs } from "powerhooks/useMergeRefs"; 7 | 8 | export type GlVideoProps = { 9 | id?: string; 10 | className?: string; 11 | width?: number; 12 | height?: number; 13 | hasShadow?: boolean; 14 | sources: Source[]; 15 | onLoad?: () => void; 16 | autoPlay?: boolean; 17 | delayBeforeAutoPlay?: number; 18 | muted?: boolean; 19 | loop?: boolean; 20 | controls?: boolean; 21 | hasBorderRadius?: boolean; 22 | }; 23 | 24 | export const GlVideo = memo( 25 | forwardRef( 26 | ( 27 | props: GlVideoProps, 28 | ref_forwarded: ForwardedRef, 29 | ) => { 30 | const { 31 | sources, 32 | className, 33 | hasShadow = true, 34 | hasBorderRadius = true, 35 | height, 36 | id: id_props, 37 | width, 38 | autoPlay = true, 39 | delayBeforeAutoPlay = 0, 40 | loop, 41 | muted, 42 | controls, 43 | onLoad, 44 | } = props; 45 | 46 | const { classes, cx } = useStyles({ 47 | hasShadow, 48 | hasBorderRadius, 49 | }); 50 | 51 | const [isDataLoaded, setIsDataLoaded] = useState(false); 52 | const [isIntersected, setIntersected] = useState(false); 53 | 54 | const id = (function useClosure() { 55 | const id = useId(); 56 | return id_props ?? id; 57 | })(); 58 | 59 | const { ref: internalRef } = useIntersectionObserver({ 60 | "callback": ({ observer, entry }) => { 61 | if (entry.isIntersecting) { 62 | observer.unobserve(entry.target); 63 | setIntersected(true); 64 | } 65 | }, 66 | "threshold": 0.2, 67 | }); 68 | 69 | const ref = useMergeRefs([internalRef, ref_forwarded]); 70 | 71 | useEffect(() => { 72 | if (delayBeforeAutoPlay === 0) { 73 | return; 74 | } 75 | 76 | if (!isDataLoaded || !isIntersected) { 77 | return; 78 | } 79 | 80 | const timer = setTimeout(() => { 81 | //@ts-expect-error: we know is will work 82 | document.getElementById(id).play(); 83 | }, delayBeforeAutoPlay); 84 | 85 | return () => { 86 | clearTimeout(timer); 87 | }; 88 | }, [isDataLoaded]); 89 | 90 | return ( 91 | 111 | ); 112 | }, 113 | ), 114 | ); 115 | 116 | const useStyles = tss 117 | .withParams<{ hasShadow: boolean; hasBorderRadius: boolean }>() 118 | .withName({ GlVideo }) 119 | .create(({ theme, hasShadow, hasBorderRadius }) => ({ 120 | "root": { 121 | "boxShadow": !hasShadow ? undefined : theme.customShadow, 122 | "borderRadius": !hasBorderRadius ? undefined : theme.borderRadius, 123 | }, 124 | })); 125 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: ci 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | branches: 8 | - main 9 | 10 | jobs: 11 | 12 | test_format: 13 | runs-on: ubuntu-latest 14 | if: ${{ !github.event.created && github.repository != 'garronej/ts-ci' }} 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: actions/setup-node@v4 18 | - uses: bahmutov/npm-install@v1 19 | - name: If this step fails run 'npm run format' then commit again. 20 | run: npm run format:check 21 | test: 22 | runs-on: ubuntu-latest 23 | needs: test_format 24 | steps: 25 | - uses: actions/checkout@v4 26 | - uses: actions/setup-node@v4 27 | with: 28 | node-version: ${{ matrix.node }} 29 | - uses: bahmutov/npm-install@v1 30 | - run: npm run build 31 | 32 | check_if_version_upgraded: 33 | name: Check if version upgrade 34 | # When someone forks the repo and opens a PR we want to enables the tests to be run (the previous jobs) 35 | # but obviously only us should be allowed to release. 36 | # In the following check we make sure that we own the branch this CI workflow is running on before continuing. 37 | # Without this check, trying to release would fail anyway because only us have the correct secret.NPM_TOKEN but 38 | # it's cleaner to stop the execution instead of letting the CI crash. 39 | if: | 40 | github.event_name == 'push' || 41 | github.event.pull_request.head.repo.owner.login == github.event.pull_request.base.repo.owner.login 42 | runs-on: ubuntu-latest 43 | needs: test 44 | outputs: 45 | from_version: ${{ steps.step1.outputs.from_version }} 46 | to_version: ${{ steps.step1.outputs.to_version }} 47 | is_upgraded_version: ${{ steps.step1.outputs.is_upgraded_version }} 48 | is_pre_release: ${{steps.step1.outputs.is_pre_release }} 49 | steps: 50 | - uses: garronej/ts-ci@v2.1.5 51 | id: step1 52 | with: 53 | action_name: is_package_json_version_upgraded 54 | branch: ${{ github.head_ref || github.ref }} 55 | 56 | create_github_release: 57 | runs-on: ubuntu-latest 58 | # We create release only if the version in the package.json have been upgraded and this CI is running against the main branch. 59 | # We allow branches with a PR open on main to publish pre-release (x.y.z-rc.u) but not actual releases. 60 | if: | 61 | needs.check_if_version_upgraded.outputs.is_upgraded_version == 'true' && 62 | ( 63 | github.event_name == 'push' || 64 | needs.check_if_version_upgraded.outputs.is_pre_release == 'true' 65 | ) 66 | needs: 67 | - check_if_version_upgraded 68 | steps: 69 | - uses: softprops/action-gh-release@v2 70 | with: 71 | name: Release v${{ needs.check_if_version_upgraded.outputs.to_version }} 72 | tag_name: v${{ needs.check_if_version_upgraded.outputs.to_version }} 73 | target_commitish: ${{ github.head_ref || github.ref }} 74 | generate_release_notes: true 75 | draft: false 76 | prerelease: ${{ needs.check_if_version_upgraded.outputs.is_pre_release == 'true' }} 77 | env: 78 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 79 | 80 | publish_on_npm: 81 | runs-on: ubuntu-latest 82 | needs: 83 | - create_github_release 84 | - check_if_version_upgraded 85 | steps: 86 | - uses: actions/checkout@v4 87 | with: 88 | ref: ${{ github.ref }} 89 | - uses: actions/setup-node@v4 90 | with: 91 | registry-url: https://registry.npmjs.org/ 92 | - uses: bahmutov/npm-install@v1 93 | - run: npm run build 94 | - run: npx -y -p denoify@1.6.13 enable_short_npm_import_path 95 | env: 96 | DRY_RUN: "0" 97 | - uses: garronej/ts-ci@v2.1.5 98 | with: 99 | action_name: remove_dark_mode_specific_images_from_readme 100 | - name: Publishing on NPM 101 | run: | 102 | if [ "$(npm show . version)" = "$VERSION" ]; then 103 | echo "This version is already published" 104 | exit 0 105 | fi 106 | if [ "$NODE_AUTH_TOKEN" = "" ]; then 107 | echo "Can't publish on NPM, You must first create a secret called NPM_TOKEN that contains your NPM auth token. https://help.github.com/en/actions/automating-your-workflow-with-github-actions/creating-and-using-encrypted-secrets" 108 | false 109 | fi 110 | EXTRA_ARGS="" 111 | if [ "$IS_PRE_RELEASE" = "true" ]; then 112 | EXTRA_ARGS="--tag next" 113 | fi 114 | npm publish $EXTRA_ARGS 115 | env: 116 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 117 | VERSION: ${{ needs.check_if_version_upgraded.outputs.to_version }} 118 | IS_PRE_RELEASE: ${{ needs.check_if_version_upgraded.outputs.is_pre_release }} -------------------------------------------------------------------------------- /src/GlCards/GlLogoCard.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from "react"; 2 | import { memo } from "react"; 3 | import { tss } from "../tss"; 4 | import { Button } from "onyxia-ui/Button"; 5 | import { Text } from "onyxia-ui/Text"; 6 | import { ThemedImage } from "onyxia-ui/ThemedImage"; 7 | import { GlCard } from "./GlCard"; 8 | import type { GlCardProps } from "./GlCard"; 9 | import { breakpointsValues } from "../theme"; 10 | 11 | export type GlLogoCardProps = GlCardProps & { 12 | iconUrls?: string[]; 13 | title?: string; 14 | paragraph?: NonNullable; 15 | buttonLabel?: string; 16 | overlapIcons?: boolean; 17 | classes?: Partial["classes"]>; 18 | }; 19 | 20 | export const GlLogoCard = memo((props: GlLogoCardProps) => { 21 | const { 22 | className, 23 | iconUrls, 24 | paragraph, 25 | title, 26 | buttonLabel, 27 | overlapIcons, 28 | link, 29 | } = props; 30 | 31 | const { classes, cx } = useStyles({ 32 | "overlapIcons": overlapIcons ?? false, 33 | "classesOverrides": props.classes, 34 | }); 35 | 36 | return ( 37 | 38 | {iconUrls && ( 39 |
40 | {iconUrls 41 | .map((url, index) => ( 42 | 47 | )) 48 | .reverse()} 49 |
50 | )} 51 | 52 |
53 | {title !== undefined && ( 54 | 55 | {title} 56 | 57 | )} 58 | {paragraph !== undefined && ( 59 | 60 | {paragraph} 61 | 62 | )} 63 |
64 | 65 | {buttonLabel !== undefined && ( 66 | 75 | )} 76 |
77 | ); 78 | }); 79 | 80 | const useStyles = tss 81 | .withName({ GlLogoCard }) 82 | .withParams<{ 83 | overlapIcons: boolean; 84 | }>() 85 | .create(({ theme, overlapIcons }) => ({ 86 | "root": { 87 | "padding": theme.spacing({ 88 | "rightLeft": 3, 89 | "topBottom": 4, 90 | }), 91 | "boxShadow": theme.shadows[1], 92 | "display": "flex", 93 | "flexDirection": "column", 94 | "justifyContent": "space-between", 95 | "alignItems": "center", 96 | "margin": (() => { 97 | if (theme.windowInnerWidth >= breakpointsValues.lg) { 98 | return undefined; 99 | } 100 | 101 | return theme.spacing(1); 102 | })(), 103 | }, 104 | "iconWrapper": { 105 | "display": "flex", 106 | "alignItems": "center", 107 | "transform": "rotate(180deg)", 108 | ...(overlapIcons 109 | ? { 110 | "marginLeft": -theme.spacing(3), 111 | } 112 | : {}), 113 | }, 114 | 115 | "icon": { 116 | "transform": "rotate(180deg)", 117 | ...(!overlapIcons 118 | ? { 119 | ...theme.spacing.rightLeft( 120 | "margin", 121 | `${theme.spacing(1)}px`, 122 | ), 123 | } 124 | : { 125 | "marginLeft": -theme.spacing(3), 126 | }), 127 | ...(() => { 128 | const value = (() => { 129 | if (theme.windowInnerWidth >= breakpointsValues.lg) { 130 | return 50; 131 | } 132 | 133 | return 40; 134 | })(); 135 | 136 | return { 137 | "width": value, 138 | "fill": theme.colors.palette.focus.main, 139 | "& svg": { 140 | "width": value, 141 | "height": value, 142 | }, 143 | }; 144 | })(), 145 | }, 146 | "title": { 147 | "marginTop": theme.spacing(4), 148 | }, 149 | "paragraph": { 150 | "marginTop": theme.spacing(4), 151 | }, 152 | "textWrapper": { 153 | "textAlign": "center", 154 | "marginBottom": theme.spacing(4), 155 | }, 156 | "button": {}, 157 | })); 158 | -------------------------------------------------------------------------------- /scripts/link-in-app.ts: -------------------------------------------------------------------------------- 1 | import { execSync } from "child_process"; 2 | import { join as pathJoin, relative as pathRelative } from "path"; 3 | import * as fs from "fs"; 4 | import * as os from "os"; 5 | 6 | const singletonDependencies: string[] = ["onyxia-ui", "react", "@types/react"]; 7 | 8 | // For example [ "@emotion" ] it's more convenient than 9 | // having to list every sub emotion packages (@emotion/css @emotion/utils ...) 10 | // in singletonDependencies 11 | const namespaceSingletonDependencies: string[] = ["@emotion", "@mui"]; 12 | 13 | const rootDirPath = pathJoin(__dirname, ".."); 14 | 15 | const commonThirdPartyDeps = [ 16 | ...namespaceSingletonDependencies 17 | .map(namespaceModuleName => 18 | fs 19 | .readdirSync( 20 | pathJoin(rootDirPath, "node_modules", namespaceModuleName), 21 | ) 22 | .map( 23 | submoduleName => `${namespaceModuleName}/${submoduleName}`, 24 | ), 25 | ) 26 | .reduce((prev, curr) => [...prev, ...curr], []), 27 | ...singletonDependencies, 28 | ]; 29 | 30 | //NOTE: This is only required because of: https://github.com/garronej/ts-ci/blob/c0e207b9677523d4ec97fe672ddd72ccbb3c1cc4/README.md?plain=1#L54-L58 31 | { 32 | let modifiedPackageJsonContent = fs 33 | .readFileSync(pathJoin(rootDirPath, "package.json")) 34 | .toString("utf8"); 35 | 36 | modifiedPackageJsonContent = (() => { 37 | const o = JSON.parse(modifiedPackageJsonContent); 38 | 39 | delete o.files; 40 | 41 | return JSON.stringify(o, null, 2); 42 | })(); 43 | 44 | modifiedPackageJsonContent = modifiedPackageJsonContent 45 | .replace(/"dist\//g, '"') 46 | .replace(/"\.\/dist\//g, '"./') 47 | .replace(/"!dist\//g, '"!') 48 | .replace(/"!\.\/dist\//g, '"!./'); 49 | 50 | modifiedPackageJsonContent = JSON.stringify( 51 | { ...JSON.parse(modifiedPackageJsonContent), version: "0.0.0" }, 52 | null, 53 | 4, 54 | ); 55 | 56 | fs.writeFileSync( 57 | pathJoin(rootDirPath, "dist", "package.json"), 58 | Buffer.from(modifiedPackageJsonContent, "utf8"), 59 | ); 60 | } 61 | 62 | const yarnGlobalDirPath = pathJoin(rootDirPath, ".yarn_home"); 63 | 64 | fs.rmSync(yarnGlobalDirPath, { recursive: true, force: true }); 65 | fs.mkdirSync(yarnGlobalDirPath); 66 | 67 | const execYarnLink = (params: { targetModuleName?: string; cwd: string }) => { 68 | const { targetModuleName, cwd } = params; 69 | 70 | const cmd = [ 71 | "yarn", 72 | "link", 73 | ...(targetModuleName !== undefined 74 | ? [targetModuleName] 75 | : ["--no-bin-links"]), 76 | ].join(" "); 77 | 78 | console.log(`$ cd ${pathRelative(rootDirPath, cwd) || "."} && ${cmd}`); 79 | 80 | execSync(cmd, { 81 | cwd, 82 | env: { 83 | ...process.env, 84 | ...(os.platform() === "win32" 85 | ? { USERPROFILE: yarnGlobalDirPath } 86 | : { HOME: yarnGlobalDirPath }), 87 | }, 88 | }); 89 | }; 90 | 91 | const testAppPaths = (() => { 92 | const [, , ...testAppNames] = process.argv; 93 | 94 | return testAppNames 95 | .map(testAppName => { 96 | const testAppPath = pathJoin(rootDirPath, "..", testAppName); 97 | 98 | if (fs.existsSync(testAppPath)) { 99 | return testAppPath; 100 | } 101 | 102 | console.warn( 103 | `Skipping ${testAppName} since it cant be found here: ${testAppPath}`, 104 | ); 105 | 106 | return undefined; 107 | }) 108 | .filter((path): path is string => path !== undefined); 109 | })(); 110 | 111 | if (testAppPaths.length === 0) { 112 | console.error("No test app to link into!"); 113 | process.exit(-1); 114 | } 115 | 116 | testAppPaths.forEach(testAppPath => 117 | execSync("yarn install", { cwd: testAppPath }), 118 | ); 119 | 120 | console.log("=== Linking common dependencies ==="); 121 | 122 | const total = commonThirdPartyDeps.length; 123 | let current = 0; 124 | 125 | commonThirdPartyDeps.forEach(commonThirdPartyDep => { 126 | current++; 127 | 128 | console.log(`${current}/${total} ${commonThirdPartyDep}`); 129 | 130 | const localInstallPath = pathJoin( 131 | ...[ 132 | rootDirPath, 133 | "node_modules", 134 | ...(commonThirdPartyDep.startsWith("@") 135 | ? commonThirdPartyDep.split("/") 136 | : [commonThirdPartyDep]), 137 | ], 138 | ); 139 | 140 | execYarnLink({ cwd: localInstallPath }); 141 | }); 142 | 143 | commonThirdPartyDeps.forEach(commonThirdPartyDep => 144 | testAppPaths.forEach(testAppPath => 145 | execYarnLink({ 146 | cwd: testAppPath, 147 | targetModuleName: commonThirdPartyDep, 148 | }), 149 | ), 150 | ); 151 | 152 | console.log("=== Linking in house dependencies ==="); 153 | 154 | execYarnLink({ cwd: pathJoin(rootDirPath, "dist") }); 155 | 156 | testAppPaths.forEach(testAppPath => 157 | execYarnLink({ 158 | cwd: testAppPath, 159 | targetModuleName: JSON.parse( 160 | fs 161 | .readFileSync(pathJoin(rootDirPath, "package.json")) 162 | .toString("utf8"), 163 | )["name"], 164 | }), 165 | ); 166 | 167 | export {}; 168 | -------------------------------------------------------------------------------- /src/GlHeader/GlHeaderLinks.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from "react"; 2 | import type { GlHeaderLinkProps } from "./GlHeaderLink"; 3 | import { GlHeaderLink } from "./GlHeaderLink"; 4 | import { tss } from "../tss"; 5 | import { useDomRect } from "powerhooks/useDomRect"; 6 | 7 | export type GlHeaderLinksProps = 8 | | GlHeaderLinksProps.LargeScreen 9 | | GlHeaderLinksProps.SmallScreen; 10 | 11 | namespace GlHeaderLinksProps { 12 | export type LargeScreen = { 13 | type: "largeScreen"; 14 | className?: string; 15 | classes?: Partial["classes"]>; 16 | links: GlHeaderLinkProps[]; 17 | }; 18 | 19 | export type SmallScreen = { 20 | type: "smallScreen"; 21 | isUnfolded: boolean; 22 | } & Omit; 23 | } 24 | 25 | export const GlHeaderLinks = memo((props: GlHeaderLinksProps) => { 26 | const { links, className, type } = props; 27 | const { 28 | ref: contentWrapperRef, 29 | domRect: { height: contentWrapperHeight }, 30 | } = useDomRect(); 31 | const { classes, cx } = useStyles({ 32 | type, 33 | contentWrapperHeight, 34 | "isUnfolded": type === "largeScreen" ? undefined : props.isUnfolded, 35 | "classesOverrides": props.classes, 36 | }); 37 | 38 | return ( 39 |
40 | {type === "smallScreen" &&
} 41 |
42 | {links.map(link => ( 43 | 51 | ))} 52 |
53 |
54 | ); 55 | }); 56 | 57 | const useStyles = tss 58 | .withName({ GlHeaderLinks }) 59 | .withParams< 60 | Pick & { 61 | contentWrapperHeight: number; 62 | isUnfolded: boolean | undefined; 63 | } 64 | >() 65 | .create(({ theme, type, contentWrapperHeight, isUnfolded }) => ({ 66 | "root": { 67 | ...(() => { 68 | switch (type) { 69 | case "largeScreen": 70 | return {}; 71 | case "smallScreen": 72 | return { 73 | "overflow": "hidden", 74 | "transition": "height 300ms", 75 | "height": isUnfolded ? contentWrapperHeight : 0, 76 | "backgroundColor": 77 | theme.colors.useCases.surfaces.background, 78 | "display": "flex", 79 | "alignItems": "center", 80 | "opacity": "0.94", 81 | }; 82 | } 83 | })(), 84 | }, 85 | "overline": { 86 | "position": "absolute", 87 | "height": 1, 88 | "top": 0, 89 | "width": "100%", 90 | "transition": "background-color 300ms", 91 | "backgroundColor": isUnfolded 92 | ? theme.colors.useCases.typography.textSecondary 93 | : undefined, 94 | }, 95 | "contentWrapper": { 96 | "display": "flex", 97 | "justifyContent": "start", 98 | ...(() => { 99 | switch (type) { 100 | case "largeScreen": 101 | return { 102 | "flexDirection": "row", 103 | }; 104 | case "smallScreen": 105 | return { 106 | "alignItems": "flex-start", 107 | "flexDirection": "column", 108 | "padding": theme.spacing({ 109 | "rightLeft": `${theme.paddingRightLeft}px`, 110 | "topBottom": `${theme.spacing(3)}px`, 111 | }), 112 | }; 113 | } 114 | })(), 115 | }, 116 | "linkRoot": { 117 | ...(() => { 118 | switch (type) { 119 | case "largeScreen": 120 | return {}; 121 | case "smallScreen": 122 | return { 123 | "alignItems": "flex-start", 124 | "paddingRight": theme.spacing(3), 125 | ...theme.spacing.topBottom( 126 | "margin", 127 | `${theme.spacing(2)}px`, 128 | ), 129 | }; 130 | } 131 | })(), 132 | }, 133 | "link": { 134 | ...(() => { 135 | switch (type) { 136 | case "largeScreen": 137 | return {}; 138 | case "smallScreen": 139 | return { 140 | "paddingLeft": 0, 141 | }; 142 | } 143 | })(), 144 | }, 145 | })); 146 | -------------------------------------------------------------------------------- /src/GlFooter/GlFooter.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from "react"; 2 | import { ThemedImage } from "onyxia-ui/ThemedImage"; 3 | import Link from "@mui/material/Link"; 4 | import { tss } from "../tss"; 5 | import { GlFooterInfo } from "./GlFooterInfo"; 6 | import { Markdown } from "onyxia-ui/Markdown"; 7 | 8 | import type { ReactNode } from "react"; 9 | 10 | type Link = { 11 | href: string; 12 | onClick?: () => void; 13 | }; 14 | 15 | export type GlFooterProps = { 16 | className?: string; 17 | classes?: Partial["classes"]>; 18 | iconLinks?: (Link & { iconUrl: string })[]; 19 | links?: (Link & { label: ReactNode })[]; 20 | bottomDivContent?: string; 21 | email?: string; 22 | phoneNumber?: string; 23 | }; 24 | 25 | export const GlFooter = memo((props: GlFooterProps) => { 26 | const { 27 | bottomDivContent, 28 | className, 29 | email, 30 | phoneNumber, 31 | iconLinks, 32 | links, 33 | } = props; 34 | 35 | const { classes, cx } = useStyles({ 36 | "classesOverrides": props.classes, 37 | }); 38 | 39 | return ( 40 |
41 | {iconLinks !== undefined && ( 42 |
43 | {iconLinks.map((iconLink, index) => ( 44 |
49 | (window.location.href = 50 | iconLink.href ?? "#")) 51 | } 52 | className={classes.iconWrapper} 53 | > 54 | 58 |
59 | ))} 60 |
61 | )} 62 | 63 | {links !== undefined && ( 64 |
65 | {links.map((link, index) => ( 66 | 73 | {link.label} 74 | 75 | ))} 76 |
77 | )} 78 | 79 | {(email !== undefined || phoneNumber !== undefined) && ( 80 | 85 | )} 86 | 87 | {bottomDivContent !== undefined && ( 88 |
89 | {bottomDivContent} 90 |
91 | )} 92 |
93 | ); 94 | }); 95 | 96 | const useStyles = tss.withName({ GlFooter }).create(({ theme }) => ({ 97 | "root": { 98 | "display": "flex", 99 | "flexDirection": "column", 100 | "alignItems": "center", 101 | "justifyContent": "center", 102 | "marginTop": theme.spacing(7), 103 | "backgroundColor": theme.colors.useCases.surfaces.surface2, 104 | ...(() => { 105 | const value = theme.spacing(4); 106 | 107 | return { 108 | "paddingTop": value, 109 | }; 110 | })(), 111 | ...theme.spacing.rightLeft("padding", `${theme.paddingRightLeft}px`), 112 | }, 113 | "icon": { 114 | "transition": "transform 200ms", 115 | ":hover": { 116 | "cursor": "pointer", 117 | "transform": "scale(1.2)", 118 | }, 119 | ...(() => { 120 | const value = theme.spacing(5); 121 | 122 | return { 123 | "width": value, 124 | "height": value, 125 | "& svg": { 126 | "width": value, 127 | "height": value, 128 | }, 129 | }; 130 | })(), 131 | }, 132 | "icons": { 133 | "display": "flex", 134 | "flexDirection": "row", 135 | "flexWrap": "wrap", 136 | "justifyContent": "center", 137 | "gap": theme.spacing(4), 138 | "marginBottom": theme.spacing(3), 139 | "marginTop": theme.spacing(2), 140 | }, 141 | "link": { 142 | "color": theme.colors.useCases.typography.textPrimary, 143 | "margin": theme.spacing({ 144 | "rightLeft": 2, 145 | "topBottom": 2, 146 | }), 147 | }, 148 | "links": { 149 | "display": "flex", 150 | "flexDirection": "row", 151 | "flexWrap": "wrap", 152 | "justifyContent": "center", 153 | "marginTop": theme.spacing(3), 154 | "marginBottom": theme.spacing(3), 155 | }, 156 | "bottomDiv": { 157 | "borderTop": `solid ${theme.colors.useCases.typography.textDisabled} 1px`, 158 | "marginTop": theme.spacing(3), 159 | "width": "100%", 160 | "display": "flex", 161 | "justifyContent": "center", 162 | "alignItems": "center", 163 | }, 164 | "iconWrapper": {}, 165 | "info": {}, 166 | })); 167 | -------------------------------------------------------------------------------- /src/GlYoutubeVideoSection.tsx: -------------------------------------------------------------------------------- 1 | import { memo, useMemo, useReducer } from "react"; 2 | import { tss } from "./tss"; 3 | import { Text } from "onyxia-ui/Text"; 4 | import { breakpointsValues } from "./theme"; 5 | import { Button } from "onyxia-ui/Button"; 6 | import { useDomRect } from "powerhooks/useDomRect"; 7 | import { motion } from "framer-motion"; 8 | import { useIntersectionObserver } from "./tools/useIntersectionObserver"; 9 | 10 | export type GlYoutubeVideoSectionProps = { 11 | className?: string; 12 | title?: string; 13 | classes?: Partial["classes"]>; 14 | src?: string; 15 | width?: string | number; 16 | height?: string | number; 17 | link?: { 18 | href: string; 19 | onClick?: () => void; 20 | }; 21 | buttonLabel?: string; 22 | hasAnimation?: boolean; 23 | }; 24 | 25 | export const GlYoutubeVideoSection = memo( 26 | (props: GlYoutubeVideoSectionProps) => { 27 | const { 28 | className, 29 | link, 30 | src, 31 | title, 32 | width, 33 | height, 34 | buttonLabel, 35 | hasAnimation, 36 | } = props; 37 | 38 | const [, forceUpdate] = useReducer(x => x + 1, 0); 39 | 40 | const { 41 | domRect: { width: iframeWidth }, 42 | ref: iframeRef, 43 | } = useDomRect(); 44 | 45 | const animationProps = useMemo(() => { 46 | if ( 47 | hasAnimation === undefined || 48 | !hasAnimation || 49 | src === undefined 50 | ) { 51 | return undefined; 52 | } 53 | 54 | return { 55 | "initial": { 56 | "opacity": 0, 57 | }, 58 | "animate": {}, 59 | "transition": { 60 | "duration": 1, 61 | "ease": "easeOut", 62 | }, 63 | }; 64 | }, []); 65 | 66 | const { ref } = useIntersectionObserver({ 67 | "callback": ({ entry, observer }) => { 68 | if ( 69 | hasAnimation === undefined || 70 | !hasAnimation || 71 | animationProps === undefined 72 | ) { 73 | observer.unobserve(entry.target); 74 | return; 75 | } 76 | 77 | if (entry.isIntersecting) { 78 | animationProps.animate = { 79 | "opacity": 1, 80 | }; 81 | observer.unobserve(entry.target); 82 | forceUpdate(); 83 | } 84 | }, 85 | "threshold": 0.5, 86 | }); 87 | 88 | const { classes, cx } = useStyles({ 89 | width, 90 | height, 91 | "currentWidth": iframeWidth, 92 | "classesOverrides": props.classes, 93 | }); 94 | 95 | return ( 96 |
97 | {title !== undefined && ( 98 | 99 | {title} 100 | 101 | )} 102 | {src !== undefined && ( 103 | 112 | )} 113 | 114 | {buttonLabel !== undefined && ( 115 | 124 | )} 125 |
126 | ); 127 | }, 128 | ); 129 | 130 | const useStyles = tss 131 | .withName({ GlYoutubeVideoSection }) 132 | .withParams<{ 133 | width: string | number | undefined; 134 | height: string | number | undefined; 135 | currentWidth: number; 136 | }>() 137 | .create(({ theme, height, width, currentWidth }) => ({ 138 | "root": { 139 | "display": "grid", 140 | "gridTemplateColumns": "1fr", 141 | "justifyItems": "center", 142 | ...theme.spacing.rightLeft( 143 | "padding", 144 | `${theme.paddingRightLeft}px`, 145 | ), 146 | }, 147 | "title": { 148 | "textAlign": "center", 149 | }, 150 | "iframe": { 151 | "border": "none", 152 | ...theme.spacing.topBottom("margin", `${theme.spacing(7)}px`), 153 | "boxShadow": theme.customShadow, 154 | "width": 155 | width ?? 156 | (() => { 157 | if (theme.windowInnerWidth >= breakpointsValues.md) { 158 | return 700; 159 | } 160 | 161 | if (theme.windowInnerWidth >= breakpointsValues.sm) { 162 | return "70%"; 163 | } 164 | 165 | return "100%"; 166 | })(), 167 | "height": 168 | height ?? 169 | (() => { 170 | if (theme.windowInnerWidth >= breakpointsValues.md) { 171 | return 7 * 60; 172 | } 173 | 174 | return (currentWidth / 100) * 60; 175 | })(), 176 | }, 177 | "button": {}, 178 | })); 179 | -------------------------------------------------------------------------------- /src/GlCards/GlProjectCard.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-namespace */ 2 | import { memo, useEffect, useState } from "react"; 3 | import { Button } from "onyxia-ui/Button"; 4 | import { tss } from "../tss"; 5 | import { Text } from "onyxia-ui/Text"; 6 | import { GlCard } from "./GlCard"; 7 | import type { GlCardProps } from "./GlCard"; 8 | import { breakpointsValues } from "../theme"; 9 | import { useDomRect } from "powerhooks/useDomRect"; 10 | 11 | export type GlProjectCardProps = GlCardProps & { 12 | projectImageUrl: string; 13 | badgeLabel?: string; 14 | badgeColor?: string; 15 | badgeBackgroundColor?: string; 16 | title: string; 17 | subtitle?: string; 18 | text?: string; 19 | classes?: Partial["classes"]>; 20 | }; 21 | 22 | export const GlProjectCard = memo((props: GlProjectCardProps) => { 23 | const { 24 | className, 25 | text, 26 | projectImageUrl, 27 | subtitle, 28 | title, 29 | badgeLabel, 30 | link, 31 | badgeBackgroundColor, 32 | badgeColor, 33 | } = props; 34 | 35 | const [imgAspectRatio, setImgAspectRatio] = useState( 36 | undefined, 37 | ); 38 | 39 | const { 40 | ref: headerRef, 41 | domRect: { width: headerWidth }, 42 | } = useDomRect(); 43 | 44 | const img = new Image(); 45 | 46 | useEffect(() => { 47 | img.src = projectImageUrl; 48 | img.onload = () => { 49 | setImgAspectRatio(img.height / img.width); 50 | }; 51 | }, [projectImageUrl]); 52 | 53 | const { classes, cx } = useStyles({ 54 | badgeColor, 55 | badgeBackgroundColor, 56 | projectImageUrl, 57 | headerWidth, 58 | imgAspectRatio, 59 | "classesOverrides": props.classes, 60 | }); 61 | 62 | return ( 63 | 64 |
65 | {badgeLabel !== undefined && ( 66 | 75 | )} 76 |
77 |
78 | 79 | {title} 80 | 81 | {subtitle !== undefined && ( 82 | 83 | {subtitle} 84 | 85 | )} 86 | {text !== undefined && ( 87 | 88 | {text} 89 | 90 | )} 91 |
92 |
93 | ); 94 | }); 95 | 96 | const useStyles = tss 97 | .withName({ GlProjectCard }) 98 | .withParams< 99 | Pick< 100 | GlProjectCardProps, 101 | "badgeBackgroundColor" | "badgeColor" | "projectImageUrl" 102 | > & { 103 | headerWidth: number; 104 | imgAspectRatio: number | undefined; 105 | } 106 | >() 107 | .create( 108 | ({ 109 | theme, 110 | badgeBackgroundColor, 111 | badgeColor, 112 | projectImageUrl, 113 | headerWidth, 114 | imgAspectRatio, 115 | }) => ({ 116 | "root": { 117 | "transition": "opacity 300ms", 118 | "opacity": 119 | headerWidth === 0 || imgAspectRatio === undefined ? 0 : 1, 120 | "display": "flex", 121 | "flexDirection": "column", 122 | "overflow": "hidden", 123 | "margin": (() => { 124 | if (theme.windowInnerWidth >= breakpointsValues.lg) { 125 | return undefined; 126 | } 127 | 128 | return theme.spacing(1); 129 | })(), 130 | }, 131 | 132 | "footer": { 133 | "flex": 1, 134 | "backgroundColor": theme.isDarkModeEnabled 135 | ? theme.colors.palette.dark.greyVariant1 136 | : theme.colors.palette.light.light, 137 | "padding": [4, 5, 4, 5] 138 | .map(spacing => `${theme.spacing(spacing)}px`) 139 | .join(" "), 140 | }, 141 | 142 | "footerTitle": { 143 | "marginBottom": theme.spacing(1), 144 | }, 145 | 146 | "footerSubtitle": { 147 | "marginBottom": theme.spacing(1), 148 | }, 149 | 150 | "header": { 151 | "width": "100%", 152 | "margin": 0, 153 | "background": `url("${projectImageUrl}")`, 154 | "minHeight": 155 | imgAspectRatio === undefined 156 | ? undefined 157 | : headerWidth * imgAspectRatio, 158 | "backgroundSize": "cover", 159 | "backgroundPosition": "center", 160 | "display": "flex", 161 | "justifyContent": "flex-end", 162 | "alignItems": "flex-start", 163 | "padding": theme.spacing(3), 164 | }, 165 | 166 | "button": { 167 | "marinLeft": theme.spacing(7), 168 | "border": "none", 169 | "backgroundColor": badgeBackgroundColor ?? undefined, 170 | "color": (() => { 171 | if (badgeColor !== undefined) { 172 | return `${badgeColor} !important`; 173 | } 174 | 175 | return undefined; 176 | })(), 177 | }, 178 | "footerText": {}, 179 | }), 180 | ); 181 | -------------------------------------------------------------------------------- /src/GlSlider.tsx: -------------------------------------------------------------------------------- 1 | import { memo, useEffect, useState } from "react"; 2 | import type { ReactNode } from "react"; 3 | import useEmblaCarousel from "embla-carousel-react"; 4 | import { tss } from "./tss"; 5 | import ArrowForwardIos from "@mui/icons-material/ArrowForwardIos"; 6 | import { Text } from "onyxia-ui/Text"; 7 | import { Icon } from "onyxia-ui/Icon"; 8 | import { useCallbackFactory } from "powerhooks/useCallbackFactory"; 9 | import { useConstCallback } from "powerhooks/useConstCallback"; 10 | import { useEvt } from "evt/hooks/useEvt"; 11 | import { Evt } from "evt"; 12 | import { useIntersectionObserver } from "./tools/useIntersectionObserver"; 13 | 14 | export type GlSliderProps = { 15 | className?: string; 16 | id?: string; 17 | classes?: Partial["classes"]>; 18 | title?: string; 19 | slides?: ReactNode[]; 20 | autoPlayTimeInterval?: number; 21 | width?: string | number; 22 | }; 23 | 24 | export const GlSlider = memo((props: GlSliderProps) => { 25 | const { className, id, slides, title, autoPlayTimeInterval, width } = props; 26 | 27 | const [emblaRef, emblaApi] = useEmblaCarousel({ "loop": true }); 28 | const [isPlaying, setIsPlaying] = useState(false); 29 | const [interval, setInt] = useState(); 30 | 31 | useEvt(ctx => { 32 | if (autoPlayTimeInterval === undefined) { 33 | return; 34 | } 35 | new Map([ 36 | ["blur", false], 37 | ["focus", true], 38 | ]).forEach((value, key) => { 39 | Evt.from(ctx, window, key).attach(() => { 40 | setIsPlaying(value); 41 | }); 42 | }); 43 | }, []); 44 | 45 | useEffect(() => { 46 | if ( 47 | autoPlayTimeInterval === undefined || 48 | autoPlayTimeInterval === 0 || 49 | emblaApi === undefined 50 | ) { 51 | return; 52 | } 53 | 54 | if (!isPlaying) { 55 | if (interval !== undefined) { 56 | clearInterval(interval); 57 | } 58 | return; 59 | } 60 | 61 | setInt( 62 | setInterval(async () => { 63 | emblaApi.scrollNext(); 64 | }, autoPlayTimeInterval * 1000), 65 | ); 66 | }, [autoPlayTimeInterval, emblaApi, isPlaying]); 67 | 68 | const { ref } = useIntersectionObserver({ 69 | "callback": useConstCallback(({ entry, observer }) => { 70 | if ( 71 | autoPlayTimeInterval === undefined || 72 | autoPlayTimeInterval === 0 73 | ) { 74 | observer.unobserve(entry.target); 75 | return; 76 | } 77 | setIsPlaying(entry.isIntersecting); 78 | }), 79 | }); 80 | 81 | const onClickFactory = useCallbackFactory( 82 | ([direction]: ["left" | "right"]) => { 83 | if (emblaApi === undefined) { 84 | return; 85 | } 86 | 87 | if (autoPlayTimeInterval !== undefined) { 88 | setIsPlaying(false); 89 | } 90 | 91 | switch (direction) { 92 | case "left": 93 | emblaApi.scrollPrev(); 94 | break; 95 | case "right": 96 | emblaApi.scrollNext(); 97 | } 98 | }, 99 | ); 100 | 101 | const onMouseDown = useConstCallback(() => { 102 | setIsPlaying(false); 103 | }); 104 | 105 | const { classes, cx } = useStyles({ 106 | width, 107 | "classesOverrides": props.classes, 108 | }); 109 | 110 | return ( 111 |
112 | {title !== undefined && ( 113 | 114 | {title} 115 | 116 | )} 117 |
118 | 123 |
124 |
125 | {slides !== undefined && 126 | slides.map((slide, index) => ( 127 |
132 | {slide} 133 |
134 | ))} 135 |
136 |
137 | 142 |
143 |
144 | ); 145 | }); 146 | 147 | const useStyles = tss 148 | .withName({ GlSlider }) 149 | .withParams<{ 150 | width: number | string | undefined; 151 | }>() 152 | .create(({ theme, width }) => ({ 153 | "root": { 154 | ...theme.spacing.rightLeft( 155 | "padding", 156 | `${theme.paddingRightLeft}px`, 157 | ), 158 | ...theme.spacing.topBottom("margin", `${theme.spacing(7)}px`), 159 | }, 160 | "heading": { 161 | "marginBottom": theme.spacing(7), 162 | "textAlign": "center", 163 | }, 164 | "sliderWrapper": { 165 | "display": "flex", 166 | "alignItems": "center", 167 | "justifyContent": "center", 168 | "width": width ?? "100%", 169 | }, 170 | "viewport": { 171 | "overflow": "hidden", 172 | "userSelect": "none", 173 | }, 174 | "container": { 175 | "display": "flex", 176 | "alignItems": "center", 177 | }, 178 | 179 | "arrows": { 180 | "transition": "transform 300ms", 181 | ":hover": { 182 | "transform": "scale(1.2)", 183 | }, 184 | }, 185 | "slide": { 186 | "minWidth": "100%", 187 | "display": "flex", 188 | "justifyContent": "center", 189 | "overflow": "hidden", 190 | "padding": theme.spacing({ 191 | "rightLeft": 4, 192 | "topBottom": 4, 193 | }), 194 | }, 195 | "prev": {}, 196 | "next": {}, 197 | })); 198 | -------------------------------------------------------------------------------- /src/GlCards/GlMetricCard.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-namespace */ 2 | import { type ReactNode, memo } from "react"; 3 | import { Button } from "onyxia-ui/Button"; 4 | import { tss } from "../tss"; 5 | import { Text } from "onyxia-ui/Text"; 6 | import { ThemedImage } from "onyxia-ui/ThemedImage"; 7 | import { GlCard } from "./GlCard"; 8 | import type { GlCardProps } from "./GlCard"; 9 | import { breakpointsValues } from "../theme"; 10 | import { useNumberCountUpAnimation } from "../tools/useNumberCountUpAnimation"; 11 | 12 | export type GlMetricCardProps = GlCardProps & { 13 | number?: number; 14 | iconUrl?: string; 15 | subHeading?: string; 16 | buttonLabel?: string; 17 | /** If provided buttonLabel will be ignored */ 18 | button?: ReactNode; 19 | isNumberAnimated?: boolean; 20 | timeIntervalBetweenNumbersMs?: number; 21 | classes?: Partial["classes"]>; 22 | }; 23 | 24 | export const GlMetricCard = memo((props: GlMetricCardProps) => { 25 | const { 26 | button, 27 | buttonLabel, 28 | iconUrl, 29 | subHeading, 30 | number, 31 | className, 32 | link, 33 | isNumberAnimated, 34 | timeIntervalBetweenNumbersMs, 35 | } = props; 36 | 37 | const { classes, cx } = useStyles({ 38 | "classesOverrides": props.classes, 39 | }); 40 | 41 | return ( 42 | 43 |
44 | {number !== undefined && ( 45 | 54 | )} 55 | 56 | {iconUrl !== undefined && ( 57 | 58 | )} 59 |
60 | 61 | {subHeading && ( 62 | 63 | {subHeading} 64 | 65 | )} 66 | 67 | {(buttonLabel || button !== undefined) && ( 68 |
69 | {button !== undefined ? ( 70 | button 71 | ) : ( 72 | 81 | )} 82 |
83 | )} 84 |
85 | ); 86 | }); 87 | 88 | const useStyles = tss.withName({ GlMetricCard }).create(({ theme }) => ({ 89 | "root": { 90 | "display": "flex", 91 | "justifyContent": "space-between", 92 | "flexDirection": "column", 93 | "padding": theme.spacing({ 94 | "rightLeft": 3, 95 | "topBottom": 6, 96 | }), 97 | "margin": (() => { 98 | if (theme.windowInnerWidth >= breakpointsValues.lg) { 99 | return undefined; 100 | } 101 | 102 | return theme.spacing(1); 103 | })(), 104 | }, 105 | "subHeading": { 106 | "fontWeight": "normal", 107 | "textAlign": "center", 108 | ...(() => { 109 | if (theme.windowInnerWidth >= breakpointsValues.lg) { 110 | return {}; 111 | } 112 | return { 113 | "fontSize": "18px", 114 | "lineHeight": "28px", 115 | }; 116 | })(), 117 | }, 118 | "heading": { 119 | "display": "flex", 120 | "justifyContent": "center", 121 | "alignItems": "center", 122 | "gap": theme.spacing(2), 123 | "marginBottom": theme.spacing(4), 124 | }, 125 | "icon": { 126 | "borderRadius": "50%", 127 | "padding": theme.spacing(2), 128 | "backgroundColor": !theme.isDarkModeEnabled 129 | ? theme.colors.useCases.surfaces.background 130 | : theme.colors.palette.light.greyVariant1, 131 | "fill": theme.colors.useCases.buttons.actionActive, 132 | ...(() => { 133 | const value = theme.spacing(6.5); 134 | return { 135 | "width": value, 136 | "height": value, 137 | }; 138 | })(), 139 | }, 140 | "buttonWrapper": { 141 | "textAlign": "center", 142 | "marginTop": theme.spacing(4), 143 | }, 144 | "number": {}, 145 | "button": {}, 146 | })); 147 | 148 | const { Number } = (() => { 149 | type Props = Required< 150 | Pick< 151 | GlMetricCardProps, 152 | "number" | "isNumberAnimated" | "timeIntervalBetweenNumbersMs" 153 | > 154 | > & { 155 | className?: string; 156 | }; 157 | 158 | const Number = memo((props: Props) => { 159 | const { 160 | isNumberAnimated, 161 | number, 162 | timeIntervalBetweenNumbersMs, 163 | className, 164 | } = props; 165 | 166 | const { ref, renderedNumber } = useNumberCountUpAnimation({ 167 | "intervalMs": timeIntervalBetweenNumbersMs, 168 | number, 169 | }); 170 | 171 | const { classes, cx } = useStyles(); 172 | 173 | return ( 174 | 181 | {isNumberAnimated ? renderedNumber : number} 182 | 183 | ); 184 | }); 185 | 186 | const useStyles = tss.withName({ Number }).create(({ theme }) => ({ 187 | "root": { 188 | "fontSize": "86px", 189 | ...(() => { 190 | if (theme.windowInnerWidth >= breakpointsValues.lg) { 191 | return {}; 192 | } 193 | 194 | return { 195 | "fontSize": "52px", 196 | }; 197 | })(), 198 | }, 199 | })); 200 | 201 | return { Number }; 202 | })(); 203 | -------------------------------------------------------------------------------- /src/GlTemplate.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { tss } from "./tss"; 3 | import { useStateRef } from "powerhooks/useStateRef"; 4 | import type { ReactNode } from "react"; 5 | import { useDomRect } from "powerhooks/useDomRect"; 6 | import { useEvt } from "evt/hooks/useEvt"; 7 | import { Evt } from "evt"; 8 | import { GlLinkToTop } from "./shared/GlLinkToTop"; 9 | import { disableEmotionWarnings } from "./tools/disableEmotionWarnings"; 10 | import { getScrollableParent } from "powerhooks/getScrollableParent"; 11 | 12 | disableEmotionWarnings(); 13 | 14 | export type HeaderOptions = HeaderOptions.TopOfPage | HeaderOptions.Sticky; 15 | 16 | export namespace HeaderOptions { 17 | export type TopOfPage = { 18 | position: "top of page"; 19 | isRetracted?: boolean; 20 | }; 21 | 22 | export type Sticky = { 23 | position: "sticky"; 24 | isRetracted?: boolean | "smart"; 25 | }; 26 | } 27 | 28 | export type GlTemplateProps = { 29 | header?: ReactNode; 30 | body?: ReactNode; 31 | footer?: ReactNode; 32 | headerOptions?: HeaderOptions; 33 | applyHeaderPadding?: boolean; 34 | className?: string; 35 | hasTopOfPageLinkButton?: boolean; 36 | classes?: Partial["classes"]>; 37 | }; 38 | 39 | //NOTE: Here we are sure that we are wrapped into a . 40 | //we can use useTheme, useStyles, ect... 41 | export function GlTemplate(props: GlTemplateProps) { 42 | const { 43 | header, 44 | body, 45 | footer, 46 | className, 47 | hasTopOfPageLinkButton, 48 | applyHeaderPadding, 49 | } = props; 50 | 51 | const rootRef = useStateRef(null); 52 | 53 | const headerOptions: Required = (() => { 54 | const { headerOptions } = props; 55 | 56 | if (headerOptions === undefined) { 57 | return { 58 | "position": "top of page", 59 | "isRetracted": false, 60 | } as const; 61 | } 62 | 63 | switch (headerOptions.position) { 64 | case "top of page": 65 | return { 66 | ...headerOptions, 67 | "isRetracted": headerOptions.isRetracted ?? false, 68 | }; 69 | case "sticky": 70 | return { 71 | ...headerOptions, 72 | "isRetracted": headerOptions.isRetracted ?? false, 73 | }; 74 | } 75 | })(); 76 | 77 | const { 78 | ref: headerWrapperRef, 79 | domRect: { height: headerHeight }, 80 | } = useDomRect(); 81 | const { 82 | ref: bodyAndFooterWrapperRef, 83 | domRect: { width: childrenWrapperWidth }, 84 | } = useDomRect(); 85 | 86 | const [isSmartHeaderVisible, setIsSmartHeaderVisible] = useState(true); 87 | 88 | useEvt( 89 | ctx => { 90 | const element = rootRef.current; 91 | if (!element) { 92 | return; 93 | } 94 | let previousScrollTop = 0; 95 | const scrollableParent = getScrollableParent({ 96 | element, 97 | "doReturnElementIfScrollable": true, 98 | }); 99 | 100 | Evt.from(ctx, scrollableParent, "scroll").attach(() => { 101 | const { scrollTop } = scrollableParent; 102 | 103 | setIsSmartHeaderVisible( 104 | scrollTop < previousScrollTop 105 | ? true 106 | : scrollTop <= headerHeight, 107 | ); 108 | 109 | previousScrollTop = scrollTop; 110 | }); 111 | }, 112 | [rootRef.current, headerHeight, headerOptions.isRetracted], 113 | ); 114 | 115 | const { classes, cx } = useStyles({ 116 | childrenWrapperWidth, 117 | headerHeight, 118 | "isHeaderRetracted": 119 | headerOptions.isRetracted === "smart" 120 | ? !isSmartHeaderVisible 121 | : headerOptions.isRetracted, 122 | "headerPosition": headerOptions.position, 123 | "applyHeaderPadding": applyHeaderPadding ?? false, 124 | "classesOverrides": props.classes, 125 | }); 126 | 127 | return ( 128 |
129 |
130 | {header} 131 |
132 |
136 | {body} 137 | {hasTopOfPageLinkButton && } 138 |
{footer}
139 |
140 |
141 | ); 142 | } 143 | 144 | const useStyles = tss 145 | .withName({ GlTemplate }) 146 | .withParams<{ 147 | headerHeight: number; 148 | childrenWrapperWidth: number; 149 | isHeaderRetracted: boolean; 150 | headerPosition: Required["position"]; 151 | applyHeaderPadding: boolean; 152 | }>() 153 | .create( 154 | ({ 155 | theme, 156 | headerHeight, 157 | childrenWrapperWidth, 158 | isHeaderRetracted, 159 | headerPosition, 160 | applyHeaderPadding, 161 | }) => { 162 | return { 163 | "root": {}, 164 | "headerWrapper": { 165 | "padding": applyHeaderPadding 166 | ? theme.spacing({ 167 | "rightLeft": `${theme.paddingRightLeft}px`, 168 | "topBottom": `${theme.spacing(3)}px`, 169 | }) 170 | : undefined, 171 | "zIndex": 4000, 172 | ...(() => { 173 | switch (headerPosition) { 174 | case "sticky": 175 | return { 176 | "width": childrenWrapperWidth, 177 | "backgroundColor": "transparent", 178 | "top": !isHeaderRetracted 179 | ? 0 180 | : -headerHeight, 181 | "transition": "top 350ms", 182 | "position": "sticky", 183 | "pointerEvents": isHeaderRetracted 184 | ? "none" 185 | : undefined, 186 | }; 187 | case "top of page": 188 | return {}; 189 | } 190 | })(), 191 | }, 192 | "footerWrapper": { 193 | "marginTop": "auto", 194 | }, 195 | "bodyAndFooterWrapper": { 196 | "display": "flex", 197 | "flexDirection": "column", 198 | "minHeight": window.innerHeight - headerHeight, 199 | }, 200 | }; 201 | }, 202 | ); 203 | -------------------------------------------------------------------------------- /src/GlCheckList.tsx: -------------------------------------------------------------------------------- 1 | import { memo, useMemo, useReducer } from "react"; 2 | import CheckIcon from "@mui/icons-material/Check"; 3 | import { Text } from "onyxia-ui/Text"; 4 | import { Markdown } from "onyxia-ui/Markdown"; 5 | import { tss } from "./tss"; 6 | import { breakpointsValues } from "./theme"; 7 | import { useIntersectionObserver } from "./tools/useIntersectionObserver"; 8 | import { motion } from "framer-motion"; 9 | import { Icon, type IconProps } from "onyxia-ui/Icon"; 10 | import { useGuaranteedMemo } from "powerhooks/useGuaranteedMemo"; 11 | import { useTheme } from "./theme"; 12 | 13 | export type GlCheckListProps = { 14 | className?: string; 15 | hasAnimation?: boolean; 16 | classes?: Partial["classes"]>; 17 | heading?: string; 18 | subHeading?: string; 19 | setIconColor?: (colors: ReturnType["colors"]) => { 20 | iconColor: string; 21 | }; 22 | icon?: IconProps.Icon; 23 | elements?: { 24 | title?: string; 25 | description?: string; 26 | iconOverride?: IconProps.Icon; 27 | setIconColorOverride?: GlCheckListProps["setIconColor"]; 28 | isIconHidden?: boolean; 29 | }[]; 30 | }; 31 | 32 | const elementWidth = 300; 33 | 34 | export const GlCheckList = memo((props: GlCheckListProps) => { 35 | const { 36 | className, 37 | elements, 38 | heading, 39 | subHeading, 40 | hasAnimation, 41 | icon, 42 | setIconColor, 43 | } = props; 44 | 45 | const [, forceUpdate] = useReducer(x => x + 1, 0); 46 | 47 | const container = useMemo(() => { 48 | if (hasAnimation === undefined || !hasAnimation) { 49 | return undefined; 50 | } 51 | return { 52 | "show": {}, 53 | "hidden": { "opacity": 0 }, 54 | }; 55 | }, [hasAnimation]); 56 | 57 | const listItem = useMemo(() => { 58 | if (hasAnimation === undefined || !hasAnimation) { 59 | return undefined; 60 | } 61 | 62 | return { 63 | "hidden": { 64 | "opacity": 0, 65 | "y": -40, 66 | }, 67 | "show": {}, 68 | }; 69 | }, [hasAnimation]); 70 | 71 | const { ref } = useIntersectionObserver({ 72 | "callback": ({ observer, entry }) => { 73 | if (hasAnimation === undefined || !hasAnimation) { 74 | observer.unobserve(entry.target); 75 | return; 76 | } 77 | 78 | if (container === undefined || listItem === undefined) { 79 | observer.unobserve(entry.target); 80 | return; 81 | } 82 | 83 | if (entry.isIntersecting) { 84 | container.show = { 85 | "transition": { 86 | "staggerChildren": 0.2, 87 | }, 88 | "opacity": 1, 89 | }; 90 | 91 | listItem.show = { 92 | "opacity": 1, 93 | "y": 0, 94 | "transition": { 95 | "duration": 0.6, 96 | "ease": "easeOut", 97 | }, 98 | }; 99 | 100 | observer.unobserve(entry.target); 101 | forceUpdate(); 102 | } 103 | }, 104 | "threshold": 0.05, 105 | }); 106 | 107 | const { classes, cx, theme } = useStyles({ 108 | "classesOverrides": props.classes, 109 | }); 110 | 111 | return ( 112 |
113 | {(heading !== undefined || subHeading !== undefined) && ( 114 |
115 | {heading !== undefined && ( 116 | 120 | {heading} 121 | 122 | )} 123 | {subHeading !== undefined && ( 124 | 125 | {subHeading} 126 | 127 | )} 128 |
129 | )} 130 | 131 | 138 | {elements !== undefined && 139 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 140 | elements.map(({ setIconColorOverride, ...rest }) => ( 141 | 142 | 161 | 162 | ))} 163 | 164 |
165 | ); 166 | }); 167 | 168 | const useStyles = tss.withName({ GlCheckList }).create(({ theme }) => ({ 169 | "root": { 170 | ...theme.spacing.rightLeft("padding", `${theme.paddingRightLeft}px`), 171 | ...theme.spacing.topBottom("margin", `${theme.spacing(7)}px`), 172 | }, 173 | "heading": { 174 | ...(() => { 175 | const value = "2rem"; 176 | return { 177 | "fontSize": value, 178 | "lineHeight": value, 179 | }; 180 | })(), 181 | }, 182 | "headingWrapper": { 183 | "marginBottom": theme.spacing(6), 184 | "display": "flex", 185 | "flexDirection": "column", 186 | "alignItems": "center", 187 | }, 188 | "elements": { 189 | "justifyItems": 190 | theme.windowInnerWidth <= breakpointsValues.sm 191 | ? undefined 192 | : "center", 193 | "display": "grid", 194 | "columnCount": 3, 195 | "gridTemplateColumns": `repeat(auto-fit, minmax(min(100%, max(${elementWidth}px, 25%)), 1fr))`, 196 | "gap": theme.spacing(6), 197 | }, 198 | "subHeading": { 199 | ...theme.typography.variants["body 1"].style, 200 | "color": theme.colors.useCases.typography.textSecondary, 201 | }, 202 | "element": {}, 203 | "checkIconWrapper": {}, 204 | "checkIcon": {}, 205 | "elementTitleAndDescriptionWrapper": {}, 206 | "elementTitle": {}, 207 | "elementDescription": {}, 208 | })); 209 | 210 | const { CheckListElement } = (() => { 211 | type Props = Required["elements"][number] & { 212 | className?: string; 213 | classes?: Partial["classes"]>; 214 | icon?: IconProps.Icon; 215 | iconColor: string; 216 | }; 217 | 218 | const CheckListElement = memo((props: Props) => { 219 | const { 220 | description, 221 | title, 222 | className, 223 | isIconHidden, 224 | iconOverride, 225 | setIconColorOverride, 226 | } = props; 227 | 228 | const { classes, cx, theme, css } = useStyles({ 229 | "isIconHidden": isIconHidden ?? false, 230 | "classesOverrides": props.classes, 231 | }); 232 | 233 | const { iconColor } = useGuaranteedMemo((): { iconColor: string } => { 234 | return { 235 | "iconColor": (() => { 236 | if (setIconColorOverride === undefined) { 237 | return props.iconColor; 238 | } 239 | return setIconColorOverride(theme.colors).iconColor; 240 | })(), 241 | }; 242 | }, [setIconColorOverride]); 243 | 244 | return ( 245 |
246 |
247 | 256 |
257 | 258 |
259 | {title !== undefined && ( 260 | {title} 261 | )} 262 | {description !== undefined && ( 263 | 264 | {description} 265 | 266 | )} 267 |
268 |
269 | ); 270 | }); 271 | 272 | const useStyles = tss 273 | .withParams<{ 274 | isIconHidden: boolean; 275 | }>() 276 | .create(({ theme, isIconHidden }) => ({ 277 | "root": { 278 | "width": 279 | theme.windowInnerWidth >= breakpointsValues.sm 280 | ? elementWidth 281 | : undefined, 282 | "display": "flex", 283 | }, 284 | "checkIcon": {}, 285 | "checkIconWrapper": { 286 | "paddingTop": theme.spacing(3.5), 287 | "marginRight": theme.spacing(3), 288 | "opacity": isIconHidden ? 0 : 1, 289 | }, 290 | "description": { 291 | "color": theme.colors.useCases.typography.textSecondary, 292 | ...theme.typography.variants["body 1"].style, 293 | }, 294 | "title": { 295 | ...theme.typography.variants["object heading"].style, 296 | }, 297 | "titleAndDescriptionWrapper": {}, 298 | })); 299 | 300 | return { CheckListElement }; 301 | })(); 302 | -------------------------------------------------------------------------------- /src/GlHero/GlHero.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-namespace */ 2 | /* eslint-disable @typescript-eslint/ban-types */ 3 | import { Text } from "onyxia-ui/Text"; 4 | import { GlImage } from "../shared/GlImage"; 5 | import { memo, useEffect, useState } from "react"; 6 | import type { ReactNode } from "react"; 7 | import { tss } from "../tss"; 8 | import { useSplashScreen } from "onyxia-ui"; 9 | import { motion } from "framer-motion"; 10 | import { breakpointsValues } from "../theme"; 11 | import { GlArrow } from "../shared/GlArrow"; 12 | import { useConstCallback } from "powerhooks/useConstCallback"; 13 | import { getScrollableParent } from "powerhooks/getScrollableParent"; 14 | import { GlHeroText } from "./GlHeroText"; 15 | import { GlVideo } from "../shared/GlVideo"; 16 | import type { IllustrationProps } from "../tools/IllustrationProps"; 17 | import { useStateRef } from "powerhooks/useStateRef"; 18 | import { useMediaAspectRatio } from "../tools/useMediaAspectRatio"; 19 | import { useIllustrationStyles } from "../shared/useIllustrationStyles"; 20 | 21 | export type GlHeroProps = { 22 | className?: string; 23 | title?: NonNullable; 24 | subTitle?: NonNullable; 25 | illustration?: IllustrationProps; 26 | illustrationZoomFactor?: number; 27 | hasLinkToSectionBellow?: boolean; 28 | classes?: Partial["classes"]>; 29 | hasAnimation?: boolean; 30 | }; 31 | 32 | const illustrationId = "illustrationId"; 33 | 34 | const textWrapperVariant = { 35 | "show": {}, 36 | "hidden": { "opacity": 0 }, 37 | }; 38 | 39 | const textVariant = { 40 | "show": {}, 41 | "hidden": { 42 | "x": -150, 43 | "opacity": 0, 44 | }, 45 | }; 46 | 47 | const imageAnimProps = { 48 | "transition": { 49 | "delay": 1, 50 | "duration": 0.5, 51 | }, 52 | "initial": { 53 | "opacity": 0, 54 | }, 55 | "animate": {}, 56 | }; 57 | 58 | export const GlHero = memo((props: GlHeroProps) => { 59 | const { 60 | title, 61 | subTitle, 62 | className, 63 | hasLinkToSectionBellow = true, 64 | illustration, 65 | hasAnimation, 66 | illustrationZoomFactor, 67 | } = props; 68 | 69 | const [isAnimationComplete, setIsAnimationComplete] = useState(false); 70 | const [isImageLoaded, setIsImageLoaded] = useState(false); 71 | const ref = useStateRef(null); 72 | 73 | const { ref: mediaRef, aspectRatio } = useMediaAspectRatio(); 74 | 75 | const handleOnIllustrationLoad = useConstCallback(async () => { 76 | await new Promise(resolve => setTimeout(resolve, 50)); 77 | setIsImageLoaded(true); 78 | }); 79 | 80 | const animate = useConstCallback(() => { 81 | textWrapperVariant.show = { 82 | "transition": { 83 | "staggerChildren": 0.5, 84 | }, 85 | "opacity": 1, 86 | }; 87 | 88 | textVariant.show = { 89 | "opacity": 1, 90 | "x": 0, 91 | "transition": { 92 | "duration": 1, 93 | "ease": "easeOut", 94 | }, 95 | }; 96 | 97 | imageAnimProps.animate = { 98 | "opacity": 1, 99 | }; 100 | setIsAnimationComplete(true); 101 | }); 102 | 103 | useSplashScreen({ 104 | "onHidden": () => { 105 | if ( 106 | isImageLoaded && 107 | isAnimationComplete && 108 | (hasAnimation || hasAnimation === undefined) 109 | ) { 110 | animate(); 111 | } 112 | }, 113 | }); 114 | 115 | useEffect(() => { 116 | if ( 117 | !isImageLoaded || 118 | isAnimationComplete || 119 | (!hasAnimation && hasAnimation !== undefined) 120 | ) { 121 | return; 122 | } 123 | animate(); 124 | }, [isImageLoaded]); 125 | 126 | const onClick = useConstCallback(() => { 127 | const element = ref.current; 128 | if (!element) return; 129 | 130 | getScrollableParent({ 131 | element, 132 | "doReturnElementIfScrollable": true, 133 | }).scrollTo({ 134 | "behavior": "smooth", 135 | "top": element.clientHeight, 136 | }); 137 | }); 138 | 139 | const { classes, cx } = useStyles({ 140 | "hasOnlyText": illustration === undefined, 141 | isImageLoaded, 142 | "classesOverrides": props.classes, 143 | }); 144 | 145 | const { classes: illustrationClasses } = useIllustrationStyles({ 146 | illustrationZoomFactor, 147 | aspectRatio, 148 | "type": props.illustration?.type, 149 | }); 150 | 151 | return ( 152 |
153 |
154 | {(title !== undefined || subTitle !== undefined) && ( 155 | { 158 | if (!hasAnimation && hasAnimation !== undefined) { 159 | return; 160 | } 161 | return { 162 | "variants": textWrapperVariant, 163 | "initial": "hidden", 164 | "animate": "show", 165 | }; 166 | })()} 167 | > 168 | {title !== undefined && ( 169 | 176 | {typeof title === "string" ? ( 177 | 178 | {title} 179 | 180 | ) : ( 181 | title 182 | )} 183 | 184 | )} 185 | {subTitle !== undefined && ( 186 | 193 | {typeof subTitle === "string" ? ( 194 | 198 | {subTitle} 199 | 200 | ) : ( 201 | subTitle 202 | )} 203 | 204 | )} 205 | 206 | )} 207 | 208 | {illustration !== undefined && ( 209 | 218 | {(() => { 219 | switch (illustration.type) { 220 | case "image": 221 | return ( 222 | 230 | ); 231 | case "video": 232 | return ( 233 | 240 | ); 241 | case "custom component": 242 | return ( 243 | 248 | ); 249 | } 250 | })()} 251 | 252 | )} 253 |
254 | {hasLinkToSectionBellow && ( 255 |
256 | 262 |
263 | )} 264 |
265 | ); 266 | }); 267 | 268 | const useStyles = tss 269 | .withName({ GlHero }) 270 | .withParams<{ 271 | hasOnlyText: boolean; 272 | isImageLoaded: boolean; 273 | }>() 274 | .create(({ theme, hasOnlyText, isImageLoaded }) => ({ 275 | "root": { 276 | "width": "100%", 277 | "paddingBottom": theme.spacing(7), 278 | ...theme.spacing.rightLeft( 279 | "padding", 280 | `${theme.paddingRightLeft}px`, 281 | ), 282 | }, 283 | "arrow": { 284 | "cursor": "pointer", 285 | }, 286 | "textAndImageWrapper": { 287 | "margin": theme.spacing({ 288 | "topBottom": 5, 289 | "rightLeft": 0, 290 | }), 291 | "minHeight": (window.innerHeight / 100) * 70, 292 | "display": "flex", 293 | "alignItems": "center", 294 | "justifyContent": "center", 295 | ...(theme.windowInnerWidth < breakpointsValues.md 296 | ? { 297 | "flexDirection": "column", 298 | "alignItems": "left", 299 | } 300 | : {}), 301 | }, 302 | 303 | "title": { 304 | "marginBottom": theme.spacing(4), 305 | }, 306 | "subtitle": { 307 | "marginTop": theme.spacing(4), 308 | "maxWidth": 650, 309 | "color": theme.colors.useCases.typography.textSecondary, 310 | ...(() => { 311 | if (theme.windowInnerWidth >= breakpointsValues["lg+"]) { 312 | return undefined; 313 | } 314 | return theme.typography.variants["body 1"].style; 315 | })(), 316 | }, 317 | 318 | "textWrapper": { 319 | "textAlign": 320 | hasOnlyText && theme.windowInnerWidth >= breakpointsValues.sm 321 | ? "center" 322 | : undefined, 323 | "alignItems": hasOnlyText ? "center" : undefined, 324 | "flexDirection": "column", 325 | ...(() => { 326 | if (theme.windowInnerWidth < breakpointsValues.md) { 327 | return undefined; 328 | } 329 | return { 330 | "maxWidth": 800, 331 | }; 332 | })(), 333 | "display": "flex", 334 | ...(() => { 335 | const value = theme.spacing(7); 336 | if (theme.windowInnerWidth >= breakpointsValues.md) { 337 | return { 338 | "marginRight": hasOnlyText ? undefined : value, 339 | }; 340 | } 341 | 342 | return { 343 | "marginBottom": value, 344 | }; 345 | })(), 346 | }, 347 | 348 | "illustrationWrapper": {}, 349 | 350 | "illustration": { 351 | "display": "inline-block", //So that text align center applies 352 | "width": "100%", 353 | }, 354 | 355 | "linkToSectionBelowWrapper": { 356 | "display": "flex", 357 | "justifyContent": "center", 358 | "transition": "opacity 300ms", 359 | "opacity": isImageLoaded ? 1 : 0, 360 | }, 361 | })); 362 | -------------------------------------------------------------------------------- /src/GlArticle.tsx: -------------------------------------------------------------------------------- 1 | import { memo, useReducer, useMemo, useState } from "react"; 2 | import type { ReactNode } from "react"; 3 | import { tss } from "./tss"; 4 | import { breakpointsValues } from "./theme"; 5 | import { Button } from "onyxia-ui/Button"; 6 | import { motion } from "framer-motion"; 7 | import { useIntersectionObserver } from "./tools/useIntersectionObserver"; 8 | import { Markdown } from "onyxia-ui/Markdown"; 9 | import { Text } from "onyxia-ui/Text"; 10 | import { GlImage } from "./shared/GlImage"; 11 | import { GlVideo } from "./shared/GlVideo"; 12 | import { useConstCallback } from "powerhooks/useConstCallback"; 13 | import { IllustrationProps } from "./tools/IllustrationProps"; 14 | import { useMediaAspectRatio } from "./tools/useMediaAspectRatio"; 15 | import { useIllustrationStyles } from "./shared/useIllustrationStyles"; 16 | 17 | export type GlArticleProps = { 18 | className?: string; 19 | classes?: Partial["classes"]>; 20 | id?: string; 21 | title?: ReactNode; 22 | body?: ReactNode; 23 | buttonLabel?: ReactNode; 24 | buttonLink?: { 25 | href: string; 26 | onClick?: () => void; 27 | }; 28 | illustrationPosition?: "left" | "right"; 29 | illustration?: IllustrationProps; 30 | hasAnimation?: boolean; 31 | illustrationZoomFactor?: number; 32 | }; 33 | 34 | const textTransitionParameters = { 35 | "ease": "easeOut", 36 | "duration": 0.5, 37 | }; 38 | 39 | function getIllustrationAnimationProps( 40 | params: Pick, 41 | ) { 42 | const { illustrationPosition } = params; 43 | return { 44 | "initial": (() => { 45 | return { 46 | "opacity": 0, 47 | "x": illustrationPosition === "left" ? -100 : 100, 48 | }; 49 | })(), 50 | 51 | "animate": {}, 52 | "transition": { 53 | "delay": 0.3, 54 | "duration": 0.5, 55 | "ease": "easeOut", 56 | }, 57 | }; 58 | } 59 | 60 | function getTitleAnimationProps( 61 | params: Pick, 62 | ) { 63 | const { illustrationPosition } = params; 64 | return { 65 | "initial": { 66 | "opacity": 0, 67 | "x": (() => { 68 | const value = 100; 69 | switch (illustrationPosition) { 70 | case "left": 71 | return value; 72 | default: 73 | return -value; 74 | } 75 | })(), 76 | }, 77 | "animate": {}, 78 | "transition": textTransitionParameters, 79 | }; 80 | } 81 | 82 | const bodyAnimationProps = { 83 | "initial": { 84 | "opacity": 0, 85 | }, 86 | "animate": {}, 87 | "transition": textTransitionParameters, 88 | }; 89 | 90 | export const GlArticle = memo((props: GlArticleProps) => { 91 | const { 92 | illustration, 93 | body, 94 | buttonLabel, 95 | illustrationPosition, 96 | title, 97 | className, 98 | id, 99 | buttonLink, 100 | hasAnimation, 101 | illustrationZoomFactor, 102 | } = props; 103 | 104 | const [, forceUpdate] = useReducer(x => x + 1, 0); 105 | const [isIllustrationLoaded, setIsIllustrationLoaded] = useState(() => { 106 | if ( 107 | illustration === undefined || 108 | illustration.type === "custom component" 109 | ) { 110 | return true; 111 | } 112 | return false; 113 | }); 114 | 115 | const illustrationAnimationProps = useMemo( 116 | () => getIllustrationAnimationProps({ illustrationPosition }), 117 | [illustrationPosition], 118 | ); 119 | const titleAnimationProps = useMemo( 120 | () => getTitleAnimationProps({ illustrationPosition }), 121 | [illustrationAnimationProps], 122 | ); 123 | 124 | const { ref } = useIntersectionObserver({ 125 | "callback": ({ observer, entry }) => { 126 | if (hasAnimation === undefined || !hasAnimation) { 127 | observer.unobserve(entry.target); 128 | return; 129 | } 130 | 131 | if (entry.isIntersecting && isIllustrationLoaded) { 132 | illustrationAnimationProps.animate = { 133 | "opacity": 1, 134 | "x": 0, 135 | }; 136 | titleAnimationProps.animate = { 137 | "opacity": 1, 138 | "x": 0, 139 | }; 140 | bodyAnimationProps.animate = { 141 | "opacity": 1, 142 | }; 143 | observer.unobserve(entry.target); 144 | forceUpdate(); 145 | } 146 | }, 147 | "threshold": 0.2, 148 | }); 149 | 150 | const { aspectRatio, ref: mediaRef } = useMediaAspectRatio(); 151 | 152 | const onIllustrationLoaded = useConstCallback(() => { 153 | setIsIllustrationLoaded(true); 154 | }); 155 | 156 | const hasArticle = 157 | title !== undefined || body !== undefined || buttonLabel !== undefined; 158 | 159 | const hasIllustration = illustration !== undefined; 160 | 161 | const { classes, cx } = useStyles({ 162 | "illustrationPosition": illustrationPosition ?? "right", 163 | hasIllustration, 164 | hasArticle, 165 | isIllustrationLoaded, 166 | aspectRatio, 167 | "classesOverrides": props.classes, 168 | }); 169 | 170 | const { classes: illustrationClasses } = useIllustrationStyles({ 171 | aspectRatio, 172 | illustrationZoomFactor, 173 | "type": props.illustration?.type, 174 | }); 175 | 176 | return ( 177 |
178 | {hasArticle && ( 179 |
180 | {title && ( 181 | { 183 | if (!hasAnimation) { 184 | return undefined; 185 | } 186 | return titleAnimationProps; 187 | })()} 188 | > 189 | {typeof title === "string" ? ( 190 | {title} 191 | ) : ( 192 | title 193 | )} 194 | 195 | )} 196 | 197 | {body && ( 198 | { 200 | if (!hasAnimation) { 201 | return undefined; 202 | } 203 | return bodyAnimationProps; 204 | })()} 205 | > 206 | {typeof body === "string" ? ( 207 | 208 | {body} 209 | 210 | ) : ( 211 | body 212 | )} 213 | 214 | )} 215 | {buttonLabel && ( 216 | 225 | )} 226 |
227 | )} 228 | {hasIllustration && ( 229 | { 232 | if (!hasAnimation) { 233 | return undefined; 234 | } 235 | 236 | return illustrationAnimationProps; 237 | })()} 238 | > 239 | {(() => { 240 | switch (illustration.type) { 241 | case "custom component": 242 | return ( 243 | 248 | ); 249 | case "image": 250 | return ( 251 | 257 | ); 258 | case "video": 259 | return ( 260 | 266 | ); 267 | } 268 | })()} 269 | 270 | )} 271 |
272 | ); 273 | }); 274 | 275 | const useStyles = tss 276 | .withName({ GlArticle }) 277 | .withParams<{ 278 | illustrationPosition: "left" | "right"; 279 | hasIllustration: boolean; 280 | hasArticle: boolean; 281 | isIllustrationLoaded: boolean; 282 | aspectRatio: number; 283 | }>() 284 | .create( 285 | ({ 286 | theme, 287 | illustrationPosition, 288 | hasIllustration, 289 | hasArticle, 290 | isIllustrationLoaded, 291 | aspectRatio, 292 | }) => ({ 293 | "root": { 294 | ...theme.spacing.rightLeft( 295 | "padding", 296 | `${theme.paddingRightLeft}px`, 297 | ), 298 | "display": "flex", 299 | "alignItems": "center", 300 | "justifyContent": "center", 301 | "flexDirection": (() => { 302 | if (theme.windowInnerWidth < breakpointsValues.md) { 303 | return "column"; 304 | } 305 | switch (illustrationPosition) { 306 | case "left": 307 | return "row-reverse"; 308 | case "right": 309 | return "row"; 310 | } 311 | })(), 312 | ...theme.spacing.topBottom("margin", `${theme.spacing(7)}px`), 313 | "overflowX": "hidden", 314 | }, 315 | "article": { 316 | "display": "flex", 317 | "flexDirection": "column", 318 | "minWidth": (() => { 319 | if ( 320 | isNaN(aspectRatio) || 321 | theme.windowInnerWidth >= breakpointsValues.lg 322 | ) { 323 | return 300; 324 | } 325 | if (theme.windowInnerWidth < breakpointsValues.md) { 326 | return undefined; 327 | } 328 | return 200; 329 | })(), 330 | "flex": hasIllustration ? 0.7 : undefined, 331 | "marginBottom": 332 | theme.windowInnerWidth >= breakpointsValues.md || 333 | !hasIllustration 334 | ? undefined 335 | : theme.spacing(8), 336 | ...(() => { 337 | const value = 338 | theme.windowInnerWidth >= breakpointsValues.lg 339 | ? theme.spacing(9) 340 | : theme.spacing(5); 341 | if ( 342 | theme.windowInnerWidth < breakpointsValues.md || 343 | !hasIllustration 344 | ) { 345 | return undefined; 346 | } 347 | switch (illustrationPosition) { 348 | case "left": 349 | return { "marginRight": value }; 350 | case "right": 351 | return { "marginLeft": value }; 352 | } 353 | })(), 354 | }, 355 | "aside": { 356 | ...(() => { 357 | if ( 358 | !hasArticle || 359 | theme.windowInnerWidth < breakpointsValues.md 360 | ) { 361 | return undefined; 362 | } 363 | 364 | const value = (() => { 365 | if ( 366 | isNaN(aspectRatio) || 367 | theme.windowInnerWidth >= breakpointsValues.lg 368 | ) { 369 | return theme.spacing(10); 370 | } 371 | 372 | return theme.spacing(8); 373 | })(); 374 | switch (illustrationPosition) { 375 | case "left": 376 | return { 377 | "marginRight": value, 378 | }; 379 | case "right": 380 | return { 381 | "marginLeft": value, 382 | }; 383 | } 384 | })(), 385 | }, 386 | "body": { 387 | "color": theme.colors.useCases.typography.textSecondary, 388 | }, 389 | "button": { 390 | "alignSelf": "end", 391 | "opacity": isIllustrationLoaded ? 1 : 0, 392 | }, 393 | "image": { 394 | "width": "100%", 395 | "height": "auto", 396 | "objectFit": "cover", 397 | "verticalAlign": "middle", 398 | }, 399 | "video": { 400 | "width": "100%", 401 | }, 402 | "customComponent": {}, 403 | }), 404 | ); 405 | -------------------------------------------------------------------------------- /src/GlHeader/GlHeader.tsx: -------------------------------------------------------------------------------- 1 | import { memo, useState, useEffect, useCallback, forwardRef } from "react"; 2 | import type { ReactNode } from "react"; 3 | import { tss } from "../tss"; 4 | import { breakpointsValues } from "../theme"; 5 | import { Text } from "onyxia-ui/Text"; 6 | import { GlHeaderLinks } from "./GlHeaderLinks"; 7 | import { DarkModeSwitch } from "onyxia-ui/DarkModeSwitch"; 8 | import { GlGithubStarCount } from "../shared/GlGithubStarCount"; 9 | import UnfoldIcon from "@mui/icons-material/FormatLineSpacing"; 10 | import { useConstCallback } from "powerhooks/useConstCallback"; 11 | import { useClickAway } from "powerhooks/useClickAway"; 12 | import { getScrollableParent } from "powerhooks/getScrollableParent"; 13 | import { useDomRect } from "powerhooks/useDomRect"; 14 | import { useEvt } from "evt/hooks/useEvt"; 15 | import { Evt } from "evt"; 16 | import { useMergeRefs } from "powerhooks/useMergeRefs"; 17 | import { useStateRef } from "powerhooks/useStateRef"; 18 | 19 | type Behavior = "hide" | "wrap" | "normal"; 20 | 21 | type CustomItem = { 22 | item: NonNullable; 23 | behaviorOnSmallDevice?: Behavior; 24 | }; 25 | 26 | export type GlHeaderProps = { 27 | links: { 28 | label: ReactNode; 29 | href: string; 30 | onClick?: () => void; 31 | isActive?: boolean; 32 | }[]; 33 | title?: ReactNode; 34 | titleDark?: ReactNode; 35 | titleSmallScreen?: ReactNode; 36 | titleSmallScreenDark?: ReactNode; 37 | customBreakpoint?: number; 38 | className?: string; 39 | classes?: Partial["classes"]>; 40 | enableDarkModeSwitch?: boolean; 41 | githubRepoUrl?: string; 42 | githubButtonSize?: "normal" | "large"; 43 | showGithubStarCount?: boolean; 44 | customItemStart?: CustomItem; 45 | customItemEnd?: CustomItem; 46 | }; 47 | 48 | export const GlHeader = memo( 49 | forwardRef((props, ref_forwarded) => { 50 | const { 51 | links, 52 | className, 53 | customItemEnd, 54 | customItemStart, 55 | enableDarkModeSwitch, 56 | githubButtonSize, 57 | githubRepoUrl, 58 | showGithubStarCount, 59 | title, 60 | titleDark, 61 | titleSmallScreen, 62 | titleSmallScreenDark, 63 | customBreakpoint, 64 | } = props; 65 | 66 | const [isMenuUnfolded, setIsMenuUnfolded] = useState(false); 67 | const [isSmallDevice, setIsSmallDevice] = useState( 68 | undefined, 69 | ); 70 | const [breakpoint, setBreakpoint] = useState( 71 | undefined, 72 | ); 73 | 74 | const toggleMenuUnfolded = useConstCallback(() => { 75 | setIsMenuUnfolded(!isMenuUnfolded); 76 | }); 77 | 78 | const ref = useStateRef(null); 79 | 80 | const { ref: ref_useClickAway } = useClickAway({ 81 | "onClickAway": () => { 82 | setIsMenuUnfolded(false); 83 | }, 84 | }); 85 | 86 | const { 87 | domRect: { height: headerHeight, width: headerWidth }, 88 | } = useDomRect({ ref }); 89 | 90 | const setRootElement = useMergeRefs([ 91 | ref, 92 | ref_forwarded, 93 | ref_useClickAway, 94 | ]); 95 | 96 | const { 97 | ref: titleRef, 98 | domRect: { width: titleWidth }, 99 | } = useDomRect(); 100 | 101 | const { 102 | ref: buttonsAndLinksRef, 103 | domRect: { width: buttonsAndLinksWidth }, 104 | } = useDomRect(); 105 | 106 | useEffect(() => { 107 | if (isSmallDevice) { 108 | return; 109 | } 110 | setIsMenuUnfolded(false); 111 | }, [isSmallDevice]); 112 | 113 | useEffect(() => { 114 | if ( 115 | isSmallDevice || 116 | titleWidth === 0 || 117 | buttonsAndLinksWidth === 0 || 118 | headerWidth === 0 || 119 | customBreakpoint !== undefined 120 | ) { 121 | return; 122 | } 123 | 124 | const contentWidth = 125 | titleWidth + 126 | buttonsAndLinksWidth + 127 | theme.spacing(7) + 128 | 2 * theme.paddingRightLeft; 129 | 130 | if (headerWidth < contentWidth && breakpoint !== undefined) { 131 | return; 132 | } 133 | 134 | setBreakpoint(contentWidth); 135 | }, [titleWidth, buttonsAndLinksWidth, headerWidth, isSmallDevice]); 136 | 137 | useEvt( 138 | ctx => { 139 | if (!ref.current) { 140 | return; 141 | } 142 | const scrollableParent = getScrollableParent({ 143 | "element": ref.current, 144 | "doReturnElementIfScrollable": true, 145 | }); 146 | 147 | Evt.from(ctx, scrollableParent, "scroll").attach(() => { 148 | const { scrollTop } = scrollableParent; 149 | 150 | if (headerHeight < scrollTop) { 151 | setIsMenuUnfolded(false); 152 | } 153 | }); 154 | }, 155 | [ref.current, headerHeight], 156 | ); 157 | 158 | const getCustomItemBehavior = useCallback( 159 | (customItem: CustomItem | undefined): Behavior => { 160 | if (customItem === undefined) { 161 | return "normal"; 162 | } 163 | return customItem.behaviorOnSmallDevice ?? "normal"; 164 | }, 165 | [ 166 | customItemStart?.behaviorOnSmallDevice, 167 | customItemEnd?.behaviorOnSmallDevice, 168 | ], 169 | ); 170 | 171 | const { theme, classes, cx } = useStyles({ 172 | isSmallDevice, 173 | "customItemStartSmallBehavior": 174 | getCustomItemBehavior(customItemStart), 175 | "customItemEndSmallBehavior": getCustomItemBehavior(customItemEnd), 176 | "classesOverrides": props.classes, 177 | }); 178 | 179 | useEffect(() => { 180 | if (customBreakpoint !== undefined) { 181 | setIsSmallDevice(theme.windowInnerWidth < customBreakpoint); 182 | return; 183 | } 184 | if (breakpoint === undefined) { 185 | return; 186 | } 187 | setIsSmallDevice( 188 | theme.windowInnerWidth < breakpoint || 189 | theme.windowInnerWidth < breakpointsValues.sm, 190 | ); 191 | }, [theme.windowInnerWidth, breakpoint]); 192 | 193 | return ( 194 |
198 |
199 | {(() => { 200 | return ( 201 |
205 | {(() => { 206 | const transformElementIfString = ( 207 | element: ReactNode, 208 | ) => { 209 | if (typeof element === "string") { 210 | return ( 211 | 217 | {element} 218 | 219 | ); 220 | } 221 | return element; 222 | }; 223 | 224 | if ( 225 | theme.windowInnerWidth >= 226 | breakpointsValues.md 227 | ) { 228 | if (!theme.isDarkModeEnabled) { 229 | return transformElementIfString( 230 | title, 231 | ); 232 | } 233 | return transformElementIfString( 234 | titleDark ?? title, 235 | ); 236 | } 237 | 238 | if (!theme.isDarkModeEnabled) { 239 | return transformElementIfString( 240 | titleSmallScreen ?? title, 241 | ); 242 | } 243 | 244 | return transformElementIfString( 245 | titleSmallScreenDark ?? 246 | titleSmallScreen ?? 247 | titleDark ?? 248 | title, 249 | ); 250 | })()} 251 |
252 | ); 253 | })()} 254 | 255 |
259 | {customItemStart !== undefined && ( 260 |
266 | {customItemStart.item} 267 |
268 | )} 269 |
270 | ({ 277 | ...link, 278 | "classes": { 279 | "link": classes.link, 280 | "underline": classes.underline, 281 | }, 282 | "className": classes.linkRoot, 283 | }))} 284 | type="largeScreen" 285 | /> 286 |
287 | {githubRepoUrl !== undefined && ( 288 | 294 | )} 295 | {enableDarkModeSwitch !== undefined && 296 | enableDarkModeSwitch && ( 297 | 300 | )} 301 | 302 | {customItemEnd !== undefined && ( 303 |
309 | {customItemEnd.item} 310 |
311 | )} 312 | 313 |
317 | 318 |
319 |
320 |
321 | {(customItemStart !== undefined || 322 | customItemEnd !== undefined) && ( 323 |
324 | {customItemStart !== undefined && ( 325 |
331 | {customItemStart.item} 332 |
333 | )} 334 | 335 | {customItemEnd !== undefined && ( 336 |
342 | {customItemEnd.item} 343 |
344 | )} 345 |
346 | )} 347 | ({ 354 | ...link, 355 | "classes": { 356 | "link": classes.linkSmallScreen, 357 | "root": classes.linkRootSmallScreen, 358 | "underline": classes.underlineSmallScreen, 359 | }, 360 | }))} 361 | className={classes.smallDeviceLinks} 362 | type="smallScreen" 363 | isUnfolded={isMenuUnfolded} 364 | /> 365 |
366 | ); 367 | }), 368 | ); 369 | 370 | const useStyles = tss 371 | .withName({ GlHeader }) 372 | .withParams<{ 373 | isSmallDevice: boolean | undefined; 374 | customItemStartSmallBehavior: Behavior; 375 | customItemEndSmallBehavior: Behavior; 376 | }>() 377 | .create( 378 | ({ 379 | theme, 380 | isSmallDevice, 381 | customItemEndSmallBehavior, 382 | customItemStartSmallBehavior, 383 | }) => { 384 | function getSmallDeviceCustomItemDisplay(behavior: Behavior) { 385 | if (isSmallDevice && behavior === "wrap") { 386 | return undefined; 387 | } 388 | return "none"; 389 | } 390 | 391 | function getCustomItemDisplay(behavior: Behavior) { 392 | if (!isSmallDevice) { 393 | return undefined; 394 | } 395 | return behavior === "normal" ? undefined : "none"; 396 | } 397 | 398 | return { 399 | "root": { 400 | "padding": theme.spacing({ 401 | "rightLeft": `${theme.paddingRightLeft}px`, 402 | "topBottom": `${theme.spacing(3)}px`, 403 | }), 404 | "position": "relative", 405 | "opacity": isSmallDevice === undefined ? 0 : 1, 406 | "maxWidth": "100%", 407 | "overflowX": !isSmallDevice ? "hidden" : undefined, 408 | "overflowY": !isSmallDevice ? "hidden" : undefined, 409 | "backdropFilter": "blur(10px)", 410 | }, 411 | "largeScreenContentWrapper": { 412 | "display": "flex", 413 | "justifyContent": "space-between", 414 | "alignItems": "center", 415 | }, 416 | "titleWrapper": { 417 | "marginRight": isSmallDevice ? undefined : theme.spacing(8), 418 | }, 419 | "titleText": { 420 | "whiteSpace": "nowrap", 421 | }, 422 | "linkAndButtonWrapper": { 423 | "display": "grid", 424 | "gridAutoFlow": "column", 425 | "alignItems": "center", 426 | "gap": theme.spacing(3), 427 | }, 428 | "link": { 429 | "marginTop": theme.spacing(1), 430 | }, 431 | "smallDeviceLinks": { 432 | "position": "absolute", 433 | "top": "100%", 434 | "left": 0, 435 | "width": "100%", 436 | }, 437 | "unfoldIconWrapper": { 438 | "display": isSmallDevice ? "flex" : "none", 439 | "alignItems": "center", 440 | }, 441 | "links": { 442 | "order": 2, 443 | "display": isSmallDevice ? "none" : "flex", 444 | "pointerEvents": isSmallDevice ? "none" : undefined, 445 | }, 446 | "linksWrapperLargeScreen": { 447 | "order": isSmallDevice ? -1 : undefined, 448 | }, 449 | "smallDeviceCustomItemsWrapper": { 450 | "display": !isSmallDevice ? "none" : "grid", 451 | "gridAutoFlow": "row", 452 | "alignItems": "end", 453 | ...(() => { 454 | const value = theme.spacing(3); 455 | return { 456 | "gap": value, 457 | ...theme.spacing.topBottom("margin", `${value}px`), 458 | }; 459 | })(), 460 | }, 461 | "commonSmallDeviceCustomItemWrapper": { 462 | "display": "flex", 463 | "justifyContent": "flex-end", 464 | }, 465 | "smallDeviceCustomItemStartWrapper": { 466 | "display": getSmallDeviceCustomItemDisplay( 467 | customItemStartSmallBehavior, 468 | ), 469 | }, 470 | "smallDeviceCustomItemEndWrapper": { 471 | "display": getSmallDeviceCustomItemDisplay( 472 | customItemEndSmallBehavior, 473 | ), 474 | }, 475 | "customItemStartWrapper": { 476 | "display": getCustomItemDisplay( 477 | customItemStartSmallBehavior, 478 | ), 479 | }, 480 | "customItemEndWrapper": { 481 | "display": getCustomItemDisplay(customItemEndSmallBehavior), 482 | }, 483 | "commonCustomItemWrapper": {}, 484 | "unfoldIcon": {}, 485 | "githubStar": {}, 486 | "darkModeSwitch": {}, 487 | "linkRoot": {}, 488 | "underline": {}, 489 | "linkRootSmallScreen": {}, 490 | "underlineSmallScreen": {}, 491 | "linkSmallScreen": {}, 492 | "linksContentWrapper": {}, 493 | "linksContentWrapperSmallScreen": {}, 494 | "linksOverline": {}, 495 | }; 496 | }, 497 | ); 498 | --------------------------------------------------------------------------------