mirror of
https://github.com/theoludwig/theoludwig.git
synced 2025-05-29 22:37:44 +02:00
perf!: monorepo setup + fully static + webp images
BREAKING CHANGE: minimum supported Node.js >= 22.0.0 and pnpm >= 9.5.0
This commit is contained in:
14
packages/ui/.eslintrc.json
Normal file
14
packages/ui/.eslintrc.json
Normal file
@ -0,0 +1,14 @@
|
||||
{
|
||||
"root": true,
|
||||
"extends": ["@repo/eslint-config/nextjs/.eslintrc.json"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["*.ts", "*.tsx"],
|
||||
"plugins": ["@typescript-eslint"],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"project": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
55
packages/ui/package.json
Normal file
55
packages/ui/package.json
Normal file
@ -0,0 +1,55 @@
|
||||
{
|
||||
"name": "@repo/ui",
|
||||
"version": "3.3.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./About": "./src/About/About.tsx",
|
||||
"./design/Button": "./src/design/Button/Button.tsx",
|
||||
"./design/Link": "./src/design/Link/Link.tsx",
|
||||
"./design/Section": "./src/design/Section/Section.tsx",
|
||||
"./design/Spinner": "./src/design/Spinner/Spinner.tsx",
|
||||
"./design/Typography": "./src/design/Typography/Typography.tsx",
|
||||
"./Errors/ErrorNotFound": "./src/Errors/ErrorNotFound/ErrorNotFound.tsx",
|
||||
"./Errors/ErrorServer": "./src/Errors/ErrorServer/ErrorServer.tsx",
|
||||
"./Footer": "./src/Footer/Footer.tsx",
|
||||
"./Header": "./src/Header/Header.tsx",
|
||||
"./Interests": "./src/Interests/Interests.tsx",
|
||||
"./Header/SwitchTheme": "./src/Header/SwitchTheme.tsx",
|
||||
"./MainLayout": "./src/MainLayout/MainLayout.tsx",
|
||||
"./OpenSource": "./src/OpenSource/OpenSource.tsx",
|
||||
"./Portfolio": "./src/Portfolio/Portfolio.tsx",
|
||||
"./Skills": "./src/Skills/Skills.tsx"
|
||||
},
|
||||
"scripts": {
|
||||
"lint:eslint": "eslint src --max-warnings 0 --report-unused-disable-directives",
|
||||
"lint:typescript": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@repo/config-tailwind": "workspace:*",
|
||||
"@repo/utils": "workspace:*",
|
||||
"@repo/i18n": "workspace:*",
|
||||
"@repo/react-hooks": "workspace:*",
|
||||
"cva": "catalog:",
|
||||
"next": "catalog:",
|
||||
"next-intl": "catalog:",
|
||||
"next-themes": "catalog:",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"react-icons": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/eslint-config": "workspace:*",
|
||||
"@repo/config-typescript": "workspace:*",
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-dom": "catalog:",
|
||||
"@total-typescript/ts-reset": "catalog:",
|
||||
"@storybook/blocks": "catalog:",
|
||||
"@storybook/react": "catalog:",
|
||||
"@storybook/test": "catalog:",
|
||||
"eslint": "catalog:",
|
||||
"postcss": "catalog:",
|
||||
"tailwindcss": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
}
|
7
packages/ui/postcss.config.js
Normal file
7
packages/ui/postcss.config.js
Normal file
@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
},
|
||||
}
|
||||
|
||||
export default config
|
16
packages/ui/src/About/About.stories.tsx
Normal file
16
packages/ui/src/About/About.stories.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react"
|
||||
|
||||
import { About as AboutComponent } from "./About"
|
||||
|
||||
const meta = {
|
||||
title: "Feature/About",
|
||||
component: AboutComponent,
|
||||
} satisfies Meta<typeof AboutComponent>
|
||||
|
||||
export default meta
|
||||
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const About: Story = {
|
||||
args: {},
|
||||
}
|
27
packages/ui/src/About/About.tsx
Normal file
27
packages/ui/src/About/About.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import { Section, SectionContent } from "../design/Section/Section"
|
||||
import { AboutDescription } from "./AboutDescription"
|
||||
import { AboutIntroduction } from "./AboutIntroduction"
|
||||
import { AboutList } from "./AboutList/AboutList"
|
||||
import { AboutLogo } from "./AboutLogo"
|
||||
import { SocialMediaList } from "./SocialMediaList/SocialMediaList"
|
||||
|
||||
export interface AboutProps {}
|
||||
|
||||
export const About: React.FC<AboutProps> = () => {
|
||||
return (
|
||||
<Section verticalSpacing horizontalSpacing id="about">
|
||||
<SectionContent shadowContainer>
|
||||
<div className="flex flex-col items-center justify-center md:flex-row md:pt-10">
|
||||
<AboutLogo />
|
||||
<div>
|
||||
<AboutIntroduction />
|
||||
<AboutList />
|
||||
<AboutDescription />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SocialMediaList />
|
||||
</SectionContent>
|
||||
</Section>
|
||||
)
|
||||
}
|
21
packages/ui/src/About/AboutDescription.tsx
Normal file
21
packages/ui/src/About/AboutDescription.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Button } from "../design/Button/Button"
|
||||
import { Typography } from "../design/Typography/Typography"
|
||||
|
||||
export interface AboutDescriptionProps {}
|
||||
|
||||
export const AboutDescription: React.FC<AboutDescriptionProps> = () => {
|
||||
const t = useTranslations()
|
||||
|
||||
return (
|
||||
<div className="dark:text-gray my-6 max-w-md text-center text-black">
|
||||
<Typography as="p" variant="text1" className="my-6">
|
||||
{t.rich("home.about.description")}
|
||||
</Typography>
|
||||
|
||||
<Button href="/curriculum-vitae/index.html" variant="outline">
|
||||
Curriculum vitæ ({t("locales.fr-FR")})
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
20
packages/ui/src/About/AboutIntroduction.tsx
Normal file
20
packages/ui/src/About/AboutIntroduction.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Typography } from "../design/Typography/Typography"
|
||||
|
||||
export interface AboutIntroductionProps {}
|
||||
|
||||
export const AboutIntroduction: React.FC<AboutIntroductionProps> = () => {
|
||||
const t = useTranslations()
|
||||
|
||||
return (
|
||||
<div className="border-b border-black dark:border-white">
|
||||
<Typography as="h1" variant="h1">
|
||||
{t("meta.title")}
|
||||
</Typography>
|
||||
|
||||
<Typography as="h2" variant="text1" className="my-3">
|
||||
{t("meta.description")}
|
||||
</Typography>
|
||||
</div>
|
||||
)
|
||||
}
|
26
packages/ui/src/About/AboutList/AboutItem.tsx
Normal file
26
packages/ui/src/About/AboutList/AboutItem.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
export interface AboutItemProps {
|
||||
label: string
|
||||
value: string
|
||||
link?: string
|
||||
}
|
||||
|
||||
export const AboutItem: React.FC<AboutItemProps> = (props) => {
|
||||
const { label, value, link } = props
|
||||
|
||||
return (
|
||||
<li className="flex items-center justify-between sm:justify-start">
|
||||
<strong className="w-24 text-sm text-black lg:w-32 dark:text-white">
|
||||
{label}
|
||||
</strong>
|
||||
<span className="dark:text-gray block text-sm font-normal text-black">
|
||||
{link != null ? (
|
||||
<a className="hover:underline" href={link}>
|
||||
{value}
|
||||
</a>
|
||||
) : (
|
||||
value
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
}
|
27
packages/ui/src/About/AboutList/AboutItemBirthDate.tsx
Normal file
27
packages/ui/src/About/AboutList/AboutItemBirthDate.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
"use client"
|
||||
|
||||
import { BIRTH_DATE } from "@repo/utils/constants"
|
||||
import { getAge, getISODate } from "@repo/utils/dates"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { useMemo } from "react"
|
||||
import { AboutItem } from "./AboutItem"
|
||||
|
||||
export interface AboutItemBirthDateProps {}
|
||||
|
||||
export const AboutItemBirthDate: React.FC<AboutItemBirthDateProps> = () => {
|
||||
const t = useTranslations()
|
||||
|
||||
const age = useMemo(() => {
|
||||
return getAge(BIRTH_DATE)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<AboutItem
|
||||
label={t("home.about.birth-date.label")}
|
||||
value={t("home.about.birth-date.value", {
|
||||
age,
|
||||
birthDate: getISODate(BIRTH_DATE),
|
||||
})}
|
||||
/>
|
||||
)
|
||||
}
|
30
packages/ui/src/About/AboutList/AboutList.tsx
Normal file
30
packages/ui/src/About/AboutList/AboutList.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import { useTranslations } from "next-intl"
|
||||
import { AboutItem } from "./AboutItem"
|
||||
import { AboutItemBirthDate } from "./AboutItemBirthDate"
|
||||
|
||||
export interface AboutListProps {}
|
||||
|
||||
export const AboutList: React.FC<AboutListProps> = () => {
|
||||
const t = useTranslations()
|
||||
|
||||
return (
|
||||
<ul className="my-6 list-none space-y-3">
|
||||
<AboutItem
|
||||
label={t("home.about.pronouns.label")}
|
||||
value={t("home.about.pronouns.value")}
|
||||
/>
|
||||
<AboutItemBirthDate />
|
||||
<AboutItem
|
||||
label={t("home.about.nationality.label")}
|
||||
value={t("home.about.nationality.value")}
|
||||
/>
|
||||
<AboutItem
|
||||
label={t("home.about.email.label")}
|
||||
value={t("home.about.email.value", {
|
||||
email: "contact@theoludwig.fr",
|
||||
})}
|
||||
link="mailto:contact@theoludwig.fr"
|
||||
/>
|
||||
</ul>
|
||||
)
|
||||
}
|
21
packages/ui/src/About/AboutLogo.tsx
Normal file
21
packages/ui/src/About/AboutLogo.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import { useTranslations } from "next-intl"
|
||||
import Image from "next/image"
|
||||
|
||||
export interface AboutLogoProps {}
|
||||
|
||||
export const AboutLogo: React.FC<AboutLogoProps> = () => {
|
||||
const t = useTranslations()
|
||||
|
||||
return (
|
||||
<div className="max-h-[370px] max-w-[370px] px-2 py-6">
|
||||
<Image
|
||||
quality={100}
|
||||
src="/images/logo.webp"
|
||||
alt={t("meta.title")}
|
||||
width={800}
|
||||
height={800}
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -0,0 +1,10 @@
|
||||
import { Icon } from "./Icon"
|
||||
|
||||
export const EmailIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
|
||||
return (
|
||||
<Icon {...props}>
|
||||
<title>Email</title>
|
||||
<path d="M15.61 12c0 1.99-1.62 3.61-3.61 3.61-1.99 0-3.61-1.62-3.61-3.61 0-1.99 1.62-3.61 3.61-3.61 1.99 0 3.61 1.62 3.61 3.61M12 0C5.383 0 0 5.383 0 12s5.383 12 12 12c2.424 0 4.761-.722 6.76-2.087l.034-.024-1.617-1.879-.027.017A9.494 9.494 0 0112 21.54c-5.26 0-9.54-4.28-9.54-9.54 0-5.26 4.28-9.54 9.54-9.54 5.26 0 9.54 4.28 9.54 9.54a9.63 9.63 0 01-.225 2.05c-.301 1.239-1.169 1.618-1.82 1.568-.654-.053-1.42-.52-1.426-1.661V12A6.076 6.076 0 0012 5.93 6.076 6.076 0 005.93 12 6.076 6.076 0 0012 18.07a6.02 6.02 0 004.3-1.792 3.9 3.9 0 003.32 1.805c.874 0 1.74-.292 2.437-.821.719-.547 1.256-1.336 1.553-2.285.047-.154.135-.504.135-.507l.002-.013c.175-.76.253-1.52.253-2.457 0-6.617-5.383-12-12-12" />
|
||||
</Icon>
|
||||
)
|
||||
}
|
@ -0,0 +1,10 @@
|
||||
import { Icon } from "./Icon"
|
||||
|
||||
export const GitHubIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
|
||||
return (
|
||||
<Icon {...props}>
|
||||
<title>GitHub</title>
|
||||
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
|
||||
</Icon>
|
||||
)
|
||||
}
|
@ -0,0 +1,10 @@
|
||||
import { Icon } from "./Icon"
|
||||
|
||||
export const GitLabIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
|
||||
return (
|
||||
<Icon {...props}>
|
||||
<title>GitLab</title>
|
||||
<path d="M4.845.904c-.435 0-.82.28-.955.692C2.639 5.449 1.246 9.728.07 13.335a1.437 1.437 0 00.522 1.607l11.071 8.045c.2.145.472.144.67-.004l11.073-8.04a1.436 1.436 0 00.522-1.61c-1.285-3.942-2.683-8.256-3.817-11.746a1.004 1.004 0 00-.957-.684.987.987 0 00-.949.69l-2.405 7.408H8.203l-2.41-7.408a.987.987 0 00-.942-.69h-.006zm-.006 1.42l2.173 6.678H2.675zm14.326 0l2.168 6.678h-4.341zm-10.593 7.81h6.862c-1.142 3.52-2.288 7.04-3.434 10.559L8.572 10.135zm-5.514.005h4.321l3.086 9.5zm13.567 0h4.325c-2.467 3.17-4.95 6.328-7.411 9.502 1.028-3.167 2.059-6.334 3.086-9.502zM2.1 10.762l6.977 8.947-7.817-5.682a.305.305 0 01-.112-.341zm19.798 0l.952 2.922a.305.305 0 01-.11.341v.002l-7.82 5.68.026-.035z" />
|
||||
</Icon>
|
||||
)
|
||||
}
|
@ -0,0 +1,19 @@
|
||||
import { classNames } from "@repo/config-tailwind/classNames"
|
||||
|
||||
export const Icon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
|
||||
const { children, className, ...rest } = props
|
||||
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
className={classNames(
|
||||
"size-8 fill-current text-black dark:text-white",
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
)
|
||||
}
|
@ -0,0 +1,10 @@
|
||||
import { Icon } from "./Icon"
|
||||
|
||||
export const NPMIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
|
||||
return (
|
||||
<Icon {...props}>
|
||||
<title>npm</title>
|
||||
<path d="M1.763 0C.786 0 0 .786 0 1.763v20.474C0 23.214.786 24 1.763 24h20.474c.977 0 1.763-.786 1.763-1.763V1.763C24 .786 23.214 0 22.237 0zM5.13 5.323l13.837.019-.009 13.836h-3.464l.01-10.382h-3.456L12.04 19.17H5.113z" />
|
||||
</Icon>
|
||||
)
|
||||
}
|
@ -0,0 +1,10 @@
|
||||
import { Icon } from "./Icon"
|
||||
|
||||
export const TwitchIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
|
||||
return (
|
||||
<Icon {...props}>
|
||||
<title>Twitch</title>
|
||||
<path d="M11.571 4.714h1.715v5.143H11.57zm4.715 0H18v5.143h-1.714zM6 0L1.714 4.286v15.428h5.143V24l4.286-4.286h3.428L22.286 12V0zm14.571 11.143l-3.428 3.428h-3.429l-3 3v-3H6.857V1.714h13.714z" />
|
||||
</Icon>
|
||||
)
|
||||
}
|
@ -0,0 +1,10 @@
|
||||
import { Icon } from "./Icon"
|
||||
|
||||
export const TwitterIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
|
||||
return (
|
||||
<Icon {...props}>
|
||||
<title>Twitter</title>
|
||||
<path d="M23.953 4.57a10 10 0 01-2.825.775 4.958 4.958 0 002.163-2.723c-.951.555-2.005.959-3.127 1.184a4.92 4.92 0 00-8.384 4.482C7.69 8.095 4.067 6.13 1.64 3.162a4.822 4.822 0 00-.666 2.475c0 1.71.87 3.213 2.188 4.096a4.904 4.904 0 01-2.228-.616v.06a4.923 4.923 0 003.946 4.827 4.996 4.996 0 01-2.212.085 4.936 4.936 0 004.604 3.417 9.867 9.867 0 01-6.102 2.105c-.39 0-.779-.023-1.17-.067a13.995 13.995 0 007.557 2.209c9.053 0 13.998-7.496 13.998-13.985 0-.21 0-.42-.015-.63A9.935 9.935 0 0024 4.59z" />
|
||||
</Icon>
|
||||
)
|
||||
}
|
@ -0,0 +1,10 @@
|
||||
import { Icon } from "./Icon"
|
||||
|
||||
export const YouTubeIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
|
||||
return (
|
||||
<Icon {...props}>
|
||||
<title>YouTube</title>
|
||||
<path d="M23.498 6.186a3.016 3.016 0 00-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 00.502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 002.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 002.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z" />
|
||||
</Icon>
|
||||
)
|
||||
}
|
21
packages/ui/src/About/SocialMediaList/SocialMediaItem.tsx
Normal file
21
packages/ui/src/About/SocialMediaList/SocialMediaItem.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
export interface SocialMediaItemProps extends React.PropsWithChildren {
|
||||
link: string
|
||||
ariaLabel: string
|
||||
}
|
||||
|
||||
export const SocialMediaItem: React.FC<SocialMediaItemProps> = (props) => {
|
||||
const { link, ariaLabel, children } = props
|
||||
|
||||
return (
|
||||
<li className="mx-4 my-1 inline-block">
|
||||
<a
|
||||
href={link}
|
||||
aria-label={ariaLabel}
|
||||
target="_blank"
|
||||
className="relative inline-block bg-transparent transition-all duration-300 ease-in-out hover:scale-110"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
</li>
|
||||
)
|
||||
}
|
53
packages/ui/src/About/SocialMediaList/SocialMediaList.tsx
Normal file
53
packages/ui/src/About/SocialMediaList/SocialMediaList.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
import { EmailIcon } from "./SocialMediaIcons/EmailIcon"
|
||||
import { GitHubIcon } from "./SocialMediaIcons/GitHubIcon"
|
||||
import { GitLabIcon } from "./SocialMediaIcons/GitLabIcon"
|
||||
import { NPMIcon } from "./SocialMediaIcons/NPMIcon"
|
||||
import { TwitchIcon } from "./SocialMediaIcons/TwitchIcon"
|
||||
import { TwitterIcon } from "./SocialMediaIcons/TwitterIcon"
|
||||
import { YouTubeIcon } from "./SocialMediaIcons/YouTubeIcon"
|
||||
import { SocialMediaItem } from "./SocialMediaItem"
|
||||
|
||||
export interface SocialMediaListProps {}
|
||||
|
||||
export const SocialMediaList: React.FC<SocialMediaListProps> = () => {
|
||||
return (
|
||||
<ul className="mt-6 list-none text-center">
|
||||
<SocialMediaItem link="https://github.com/theoludwig" ariaLabel="GitHub">
|
||||
<GitHubIcon />
|
||||
</SocialMediaItem>
|
||||
|
||||
<SocialMediaItem link="https://gitlab.com/theoludwig" ariaLabel="GitLab">
|
||||
<GitLabIcon />
|
||||
</SocialMediaItem>
|
||||
|
||||
<SocialMediaItem link="https://www.npmjs.com/~theoludwig" ariaLabel="npm">
|
||||
<NPMIcon />
|
||||
</SocialMediaItem>
|
||||
|
||||
<SocialMediaItem
|
||||
link="https://twitter.com/theoludwig_"
|
||||
ariaLabel="Twitter"
|
||||
>
|
||||
<TwitterIcon />
|
||||
</SocialMediaItem>
|
||||
|
||||
<SocialMediaItem
|
||||
link="https://www.youtube.com/@theo_ludwig"
|
||||
ariaLabel="YouTube"
|
||||
>
|
||||
<YouTubeIcon />
|
||||
</SocialMediaItem>
|
||||
|
||||
<SocialMediaItem
|
||||
link="https://www.twitch.tv/theoludwig"
|
||||
ariaLabel="Twitch"
|
||||
>
|
||||
<TwitchIcon />
|
||||
</SocialMediaItem>
|
||||
|
||||
<SocialMediaItem link="mailto:contact@theoludwig.fr" ariaLabel="Email">
|
||||
<EmailIcon />
|
||||
</SocialMediaItem>
|
||||
</ul>
|
||||
)
|
||||
}
|
@ -0,0 +1,16 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react"
|
||||
|
||||
import { ErrorNotFound as ErrorNotFoundComponent } from "./ErrorNotFound"
|
||||
|
||||
const meta = {
|
||||
title: "Errors/ErrorNotFound",
|
||||
component: ErrorNotFoundComponent,
|
||||
} satisfies Meta<typeof ErrorNotFoundComponent>
|
||||
|
||||
export default meta
|
||||
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const ErrorNotFound: Story = {
|
||||
args: {},
|
||||
}
|
26
packages/ui/src/Errors/ErrorNotFound/ErrorNotFound.tsx
Normal file
26
packages/ui/src/Errors/ErrorNotFound/ErrorNotFound.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import { useTranslations } from "next-intl"
|
||||
import { MainLayout } from "../../MainLayout/MainLayout"
|
||||
import { Link } from "../../design/Link/Link"
|
||||
import { Section } from "../../design/Section/Section"
|
||||
import { Typography } from "../../design/Typography/Typography"
|
||||
|
||||
export interface ErrorNotFoundProps {}
|
||||
|
||||
export const ErrorNotFound: React.FC<ErrorNotFoundProps> = () => {
|
||||
const t = useTranslations()
|
||||
|
||||
return (
|
||||
<MainLayout center>
|
||||
<Section horizontalSpacing>
|
||||
<Typography variant="h1" as="h1">
|
||||
{t("errors.error")} 404 - {t("errors.not-found")}
|
||||
</Typography>
|
||||
|
||||
<Typography variant="text1" as="p" className="mt-4">
|
||||
{t("errors.page-doesnt-exist")}{" "}
|
||||
<Link href="/">{t("errors.return-to-home-page")}</Link>
|
||||
</Typography>
|
||||
</Section>
|
||||
</MainLayout>
|
||||
)
|
||||
}
|
22
packages/ui/src/Errors/ErrorServer/ErrorServer.stories.tsx
Normal file
22
packages/ui/src/Errors/ErrorServer/ErrorServer.stories.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react"
|
||||
import { expect, fn, userEvent, within } from "@storybook/test"
|
||||
|
||||
import { ErrorServer as ErrorServerComponent } from "./ErrorServer"
|
||||
|
||||
const meta = {
|
||||
title: "Errors/ErrorServer",
|
||||
component: ErrorServerComponent,
|
||||
} satisfies Meta<typeof ErrorServerComponent>
|
||||
|
||||
export default meta
|
||||
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const ErrorServer: Story = {
|
||||
args: { reset: fn(), error: new Error("Server error") },
|
||||
play: async ({ canvasElement, args }) => {
|
||||
const canvas = within(canvasElement)
|
||||
await userEvent.click(canvas.getByText("Try again?"))
|
||||
await expect(args.reset).toHaveBeenCalled()
|
||||
},
|
||||
}
|
37
packages/ui/src/Errors/ErrorServer/ErrorServer.tsx
Normal file
37
packages/ui/src/Errors/ErrorServer/ErrorServer.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
"use client"
|
||||
|
||||
import { useTranslations } from "next-intl"
|
||||
import { useEffect } from "react"
|
||||
import { MainLayout } from "../../MainLayout/MainLayout"
|
||||
import { Button } from "../../design/Button/Button"
|
||||
import { Section } from "../../design/Section/Section"
|
||||
import { Typography } from "../../design/Typography/Typography"
|
||||
|
||||
export interface ErrorServerProps {
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
export const ErrorServer: React.FC<ErrorServerProps> = (props) => {
|
||||
const { error, reset } = props
|
||||
|
||||
const t = useTranslations()
|
||||
|
||||
useEffect(() => {
|
||||
console.error(error)
|
||||
}, [error])
|
||||
|
||||
return (
|
||||
<MainLayout center>
|
||||
<Section horizontalSpacing>
|
||||
<Typography variant="h1" as="h1">
|
||||
{t("errors.error")} 500 - {t("errors.server-error")}
|
||||
</Typography>
|
||||
|
||||
<Typography variant="text1" as="p" className="mt-4">
|
||||
<Button onClick={reset}>{t("errors.try-again")}</Button>
|
||||
</Typography>
|
||||
</Section>
|
||||
</MainLayout>
|
||||
)
|
||||
}
|
18
packages/ui/src/Footer/Footer.stories.tsx
Normal file
18
packages/ui/src/Footer/Footer.stories.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react"
|
||||
|
||||
import { Footer as FooterComponent } from "./Footer"
|
||||
|
||||
const meta = {
|
||||
title: "User Interface/Footer",
|
||||
component: FooterComponent,
|
||||
} satisfies Meta<typeof FooterComponent>
|
||||
|
||||
export default meta
|
||||
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Footer: Story = {
|
||||
args: {
|
||||
version: "1.0.0",
|
||||
},
|
||||
}
|
34
packages/ui/src/Footer/Footer.tsx
Normal file
34
packages/ui/src/Footer/Footer.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { GIT_REPO_LINK } from "@repo/utils/constants"
|
||||
import { Link } from "../design/Link/Link"
|
||||
|
||||
export interface FooterProps {
|
||||
version: string
|
||||
}
|
||||
|
||||
export const Footer: React.FC<FooterProps> = (props) => {
|
||||
const { version } = props
|
||||
|
||||
const t = useTranslations()
|
||||
|
||||
return (
|
||||
<footer className="bg-background dark:bg-background-dark border-gray-darker dark:border-gray-darker-dark flex flex-col items-center justify-center border-t-2 p-6 text-lg">
|
||||
<p>
|
||||
<Link href="/">{t("meta.title")}</Link> |{" "}
|
||||
{t("footer.all-rights-reserved")}
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Version{" "}
|
||||
<Link
|
||||
href={`${GIT_REPO_LINK}/releases/tag/v${version}`}
|
||||
target="_blank"
|
||||
isExternal={false}
|
||||
>
|
||||
{version}
|
||||
</Link>
|
||||
</p>
|
||||
</footer>
|
||||
)
|
||||
}
|
16
packages/ui/src/Header/Header.stories.tsx
Normal file
16
packages/ui/src/Header/Header.stories.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react"
|
||||
|
||||
import { Header as HeaderComponent } from "./Header"
|
||||
|
||||
const meta = {
|
||||
title: "User Interface/Header",
|
||||
component: HeaderComponent,
|
||||
} satisfies Meta<typeof HeaderComponent>
|
||||
|
||||
export default meta
|
||||
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Header: Story = {
|
||||
args: {},
|
||||
}
|
38
packages/ui/src/Header/Header.tsx
Normal file
38
packages/ui/src/Header/Header.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
import { useTranslations } from "next-intl"
|
||||
import Image from "next/image"
|
||||
import { Link } from "../design/Link/Link"
|
||||
import { Locales } from "./Locales/Locales"
|
||||
import { SwitchTheme } from "./SwitchTheme"
|
||||
|
||||
export interface HeaderProps {}
|
||||
|
||||
export const Header: React.FC<HeaderProps> = () => {
|
||||
const t = useTranslations()
|
||||
|
||||
return (
|
||||
<header className="bg-background dark:bg-background-dark border-gray-darker dark:border-gray-darker-dark sticky top-0 z-50 flex w-full justify-between gap-4 border-b-2 px-6 py-2">
|
||||
<h1>
|
||||
<Link href="/" className="flex items-center justify-center">
|
||||
<Image
|
||||
quality={100}
|
||||
className="w-16"
|
||||
src="/images/logo.webp"
|
||||
width={800}
|
||||
height={800}
|
||||
alt={`${t("meta.title")} Logo`}
|
||||
priority
|
||||
/>
|
||||
<strong className="ml-1 hidden sm:block sm:text-xl">
|
||||
{t("meta.title")}
|
||||
</strong>
|
||||
</Link>
|
||||
</h1>
|
||||
|
||||
<div className="flex items-center justify-between gap-6">
|
||||
<Link href="/blog">Blog</Link>
|
||||
<Locales />
|
||||
<SwitchTheme />
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
16
packages/ui/src/Header/Locales/Arrow.tsx
Normal file
16
packages/ui/src/Header/Locales/Arrow.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
export const Arrow: React.FC = () => {
|
||||
return (
|
||||
<svg
|
||||
width="12"
|
||||
height="8"
|
||||
viewBox="0 0 12 8"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
className="fill-current text-black dark:text-white"
|
||||
d="M9.8024 0.292969L5.61855 4.58597L1.43469 0.292969L0.0566406 1.70697L5.61855 7.41397L11.1805 1.70697L9.8024 0.292969Z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
26
packages/ui/src/Header/Locales/LocaleFlag.tsx
Normal file
26
packages/ui/src/Header/Locales/LocaleFlag.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import type { Locale } from "@repo/i18n/config"
|
||||
import { useTranslations } from "next-intl"
|
||||
import Image from "next/image"
|
||||
|
||||
export interface LocaleFlagProps {
|
||||
locale: Locale
|
||||
}
|
||||
|
||||
export const LocaleFlag: React.FC<LocaleFlagProps> = (props) => {
|
||||
const { locale } = props
|
||||
|
||||
const t = useTranslations()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Image
|
||||
quality={100}
|
||||
width={35}
|
||||
height={35}
|
||||
src={`/images/locales/${locale}.svg`}
|
||||
alt={`Flag of ${t(`locales.${locale}`)}`}
|
||||
/>
|
||||
<p className="mx-2 text-base font-semibold">{t(`locales.${locale}`)}</p>
|
||||
</>
|
||||
)
|
||||
}
|
86
packages/ui/src/Header/Locales/Locales.tsx
Normal file
86
packages/ui/src/Header/Locales/Locales.tsx
Normal file
@ -0,0 +1,86 @@
|
||||
"use client"
|
||||
|
||||
import { classNames } from "@repo/config-tailwind/classNames"
|
||||
import type { Locale } from "@repo/i18n/config"
|
||||
import { LOCALES } from "@repo/i18n/config"
|
||||
import { usePathname, useRouter } from "@repo/i18n/navigation"
|
||||
import { useLocale } from "next-intl"
|
||||
import { useEffect, useRef } from "react"
|
||||
|
||||
import { useBoolean } from "@repo/react-hooks/useBoolean"
|
||||
import { Arrow } from "./Arrow"
|
||||
import { LocaleFlag } from "./LocaleFlag"
|
||||
|
||||
export interface LocalesProps {}
|
||||
|
||||
export const Locales: React.FC<LocalesProps> = () => {
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const localeCurrent = useLocale() as Locale
|
||||
|
||||
const {
|
||||
value: isVisibleMenu,
|
||||
toggle: toggleMenu,
|
||||
setFalse: hideMenu,
|
||||
} = useBoolean()
|
||||
const languageClickRef = useRef<HTMLButtonElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickEvent = (event: MouseEvent): void => {
|
||||
if (languageClickRef.current == null || event.target == null) {
|
||||
return
|
||||
}
|
||||
if (!languageClickRef.current.contains(event.target as Node)) {
|
||||
hideMenu()
|
||||
}
|
||||
}
|
||||
|
||||
window.document.addEventListener("click", handleClickEvent)
|
||||
|
||||
return () => {
|
||||
return window.removeEventListener("click", handleClickEvent)
|
||||
}
|
||||
}, [hideMenu])
|
||||
|
||||
if (pathname.startsWith("/blog")) {
|
||||
return <></>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<button
|
||||
ref={languageClickRef}
|
||||
className="flex items-center"
|
||||
onClick={toggleMenu}
|
||||
>
|
||||
<LocaleFlag locale={localeCurrent} />
|
||||
<Arrow />
|
||||
</button>
|
||||
|
||||
<ul
|
||||
className={classNames(
|
||||
"shadow-lightFlag dark:shadow-darkFlag bg-background dark:bg-background-dark absolute top-14 z-10 mt-4 flex w-32 list-none flex-col items-center justify-center rounded-lg p-0",
|
||||
{ hidden: !isVisibleMenu },
|
||||
)}
|
||||
>
|
||||
{LOCALES.filter((locale) => {
|
||||
return locale !== localeCurrent
|
||||
}).map((locale) => {
|
||||
return (
|
||||
<li key={locale} className="w-full">
|
||||
<button
|
||||
className="flex h-12 w-full items-center justify-center rounded-lg hover:bg-[#4f545c]/20"
|
||||
onClick={() => {
|
||||
router.replace(pathname, { locale, scroll: false })
|
||||
router.refresh()
|
||||
}}
|
||||
>
|
||||
<LocaleFlag locale={locale} />
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
107
packages/ui/src/Header/SwitchTheme.tsx
Normal file
107
packages/ui/src/Header/SwitchTheme.tsx
Normal file
@ -0,0 +1,107 @@
|
||||
"use client"
|
||||
|
||||
import { classNames } from "@repo/config-tailwind/classNames"
|
||||
import { useIsMounted } from "@repo/react-hooks/useIsMounted"
|
||||
import {
|
||||
ThemeProvider as NextThemeProvider,
|
||||
useTheme as useNextTheme,
|
||||
} from "next-themes"
|
||||
|
||||
export const THEMES = ["light", "dark"] as const
|
||||
export type Theme = (typeof THEMES)[number]
|
||||
export const THEME_DEFAULT = "dark" as Theme
|
||||
|
||||
export interface ThemeProviderProps extends React.PropsWithChildren {}
|
||||
|
||||
export const ThemeProvider: React.FC<ThemeProviderProps> = (props) => {
|
||||
const { children } = props
|
||||
|
||||
return (
|
||||
<NextThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme={THEME_DEFAULT}
|
||||
enableSystem={false}
|
||||
>
|
||||
{children}
|
||||
</NextThemeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export interface UseThemeOutput {
|
||||
theme: Theme
|
||||
toggleTheme: () => void
|
||||
}
|
||||
|
||||
export const useTheme = (): UseThemeOutput => {
|
||||
const { setTheme, theme: themeData } = useNextTheme()
|
||||
const { isMounted } = useIsMounted()
|
||||
|
||||
const theme = isMounted ? (themeData as Theme) : THEME_DEFAULT
|
||||
|
||||
const toggleTheme: UseThemeOutput["toggleTheme"] = () => {
|
||||
const newTheme = theme === "dark" ? "light" : "dark"
|
||||
setTheme(newTheme)
|
||||
}
|
||||
|
||||
return {
|
||||
theme,
|
||||
toggleTheme,
|
||||
}
|
||||
}
|
||||
|
||||
export interface SwitchThemeProps {}
|
||||
|
||||
export const SwitchTheme: React.FC<SwitchThemeProps> = () => {
|
||||
const { theme, toggleTheme } = useTheme()
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center" onClick={toggleTheme}>
|
||||
<div className="relative inline-block cursor-pointer touch-pan-x select-none border-0 bg-transparent p-0">
|
||||
<div className="h-[24px] w-[50px] rounded-[30px] bg-[#4d4d4d] p-0 text-white transition-all duration-200 ease-in-out">
|
||||
<div
|
||||
className={classNames(
|
||||
"absolute inset-y-0 left-[8px] my-auto h-[10px] w-[14px] leading-[0] transition-opacity duration-[250ms] ease-in-out",
|
||||
{
|
||||
"opacity-100": theme === "dark",
|
||||
"opacity-0": theme === "light",
|
||||
},
|
||||
)}
|
||||
>
|
||||
<span className="relative flex size-[10px] items-center justify-center">
|
||||
🌜
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={classNames(
|
||||
"absolute inset-y-0 right-[10px] my-auto size-[10px] leading-[0]",
|
||||
{
|
||||
"opacity-100": theme === "light",
|
||||
"opacity-0": theme === "dark",
|
||||
},
|
||||
)}
|
||||
>
|
||||
<span className="relative flex size-[10px] items-center justify-center">
|
||||
🌞
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={classNames(
|
||||
"absolute top-px box-border size-[22px] rounded-[50%] bg-[#fafafa] text-white transition-all duration-[250ms] ease-in-out",
|
||||
{
|
||||
"left-[27px]": theme === "dark",
|
||||
"left-0": theme === "light",
|
||||
},
|
||||
)}
|
||||
style={{ border: "1px solid #4d4d4d" }}
|
||||
/>
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label="Dark mode toggle"
|
||||
className="absolute -m-px hidden size-px overflow-hidden border-0 p-0"
|
||||
defaultChecked
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
26
packages/ui/src/Interests/InterestItem.tsx
Normal file
26
packages/ui/src/Interests/InterestItem.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import { Typography } from "../design/Typography/Typography"
|
||||
|
||||
export interface InterestItemProps {
|
||||
title: string
|
||||
description: React.ReactNode
|
||||
}
|
||||
|
||||
export const InterestItem: React.FC<InterestItemProps> = (props) => {
|
||||
const { title, description } = props
|
||||
|
||||
return (
|
||||
<div className="my-6 text-center">
|
||||
<Typography as="h3" variant="h4">
|
||||
{title}
|
||||
</Typography>
|
||||
|
||||
<Typography
|
||||
as="p"
|
||||
variant="text1"
|
||||
className="dark:text-gray my-2 text-black"
|
||||
>
|
||||
{description}
|
||||
</Typography>
|
||||
</div>
|
||||
)
|
||||
}
|
16
packages/ui/src/Interests/Interests.stories.tsx
Normal file
16
packages/ui/src/Interests/Interests.stories.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react"
|
||||
|
||||
import { Interests as InterestsComponent } from "./Interests"
|
||||
|
||||
const meta = {
|
||||
title: "Feature/Interests",
|
||||
component: InterestsComponent,
|
||||
} satisfies Meta<typeof InterestsComponent>
|
||||
|
||||
export default meta
|
||||
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Interests: Story = {
|
||||
args: {},
|
||||
}
|
74
packages/ui/src/Interests/Interests.tsx
Normal file
74
packages/ui/src/Interests/Interests.tsx
Normal file
@ -0,0 +1,74 @@
|
||||
import { GIT_REPO_LINK } from "@repo/utils/constants"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { FaGit, FaMicrochip } from "react-icons/fa"
|
||||
import { Link } from "../design/Link/Link"
|
||||
import {
|
||||
Section,
|
||||
SectionContent,
|
||||
SectionTitle,
|
||||
} from "../design/Section/Section"
|
||||
import { InterestItem } from "./InterestItem"
|
||||
|
||||
export interface InterestsProps {}
|
||||
|
||||
export const Interests: React.FC<InterestsProps> = () => {
|
||||
const t = useTranslations()
|
||||
|
||||
const items = [
|
||||
{
|
||||
id: "code",
|
||||
title: t("home.interests.code.title"),
|
||||
description: t.rich("home.interests.code.description", {
|
||||
"abbr-ux": (children) => {
|
||||
return <abbr title="User Experience">{children}</abbr>
|
||||
},
|
||||
}),
|
||||
Icon: FaMicrochip,
|
||||
},
|
||||
{
|
||||
id: "open-source",
|
||||
title: t("home.interests.open-source.title"),
|
||||
description: t.rich("home.interests.open-source.description", {
|
||||
"github-link": (children) => {
|
||||
return (
|
||||
<Link href={GIT_REPO_LINK} target="_blank">
|
||||
{children}
|
||||
</Link>
|
||||
)
|
||||
},
|
||||
}),
|
||||
Icon: FaGit,
|
||||
},
|
||||
] as const
|
||||
|
||||
return (
|
||||
<Section verticalSpacing horizontalSpacing id="interests">
|
||||
<SectionTitle>{t("home.interests.title")}</SectionTitle>
|
||||
<SectionContent shadowContainer>
|
||||
<div className="max-w-full">
|
||||
{items.map((item) => {
|
||||
return (
|
||||
<InterestItem
|
||||
key={item.id}
|
||||
title={item.title}
|
||||
description={item.description}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="my-4 flex justify-center">
|
||||
<ul className="m-0 flex w-96 list-none justify-around p-0">
|
||||
{items.map((item) => {
|
||||
return (
|
||||
<li className="m-2 size-8" key={item.id} title={item.title}>
|
||||
<item.Icon className="text-primary dark:text-primary-dark block size-full" />
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</SectionContent>
|
||||
</Section>
|
||||
)
|
||||
}
|
24
packages/ui/src/MainLayout/MainLayout.tsx
Normal file
24
packages/ui/src/MainLayout/MainLayout.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import { classNames } from "@repo/config-tailwind/classNames"
|
||||
|
||||
export interface MainLayoutProps
|
||||
extends React.ComponentPropsWithoutRef<"main"> {
|
||||
className?: string
|
||||
center?: boolean
|
||||
}
|
||||
|
||||
export const MainLayout: React.FC<MainLayoutProps> = (props) => {
|
||||
const { className, center = false, ...rest } = props
|
||||
|
||||
return (
|
||||
<main
|
||||
className={classNames(
|
||||
"min-h-[calc(100vh-188px)] md:mx-auto md:max-w-4xl lg:max-w-7xl",
|
||||
{
|
||||
"flex flex-col items-center justify-center text-center": center,
|
||||
},
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
16
packages/ui/src/OpenSource/OpenSource.stories.tsx
Normal file
16
packages/ui/src/OpenSource/OpenSource.stories.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react"
|
||||
|
||||
import { OpenSource as OpenSourceComponent } from "./OpenSource"
|
||||
|
||||
const meta = {
|
||||
title: "Feature/OpenSource",
|
||||
component: OpenSourceComponent,
|
||||
} satisfies Meta<typeof OpenSourceComponent>
|
||||
|
||||
export default meta
|
||||
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const OpenSource: Story = {
|
||||
args: {},
|
||||
}
|
47
packages/ui/src/OpenSource/OpenSource.tsx
Normal file
47
packages/ui/src/OpenSource/OpenSource.tsx
Normal file
@ -0,0 +1,47 @@
|
||||
import { useTranslations } from "next-intl"
|
||||
import {
|
||||
Section,
|
||||
SectionDescription,
|
||||
SectionTitle,
|
||||
} from "../design/Section/Section"
|
||||
import { Repository } from "./Repository"
|
||||
|
||||
export interface OpenSourceProps {}
|
||||
|
||||
export const OpenSource: React.FC<OpenSourceProps> = () => {
|
||||
const t = useTranslations()
|
||||
|
||||
return (
|
||||
<Section verticalSpacing horizontalSpacing id="open-source">
|
||||
<SectionTitle>{t("home.open-source.title")}</SectionTitle>
|
||||
<SectionDescription>
|
||||
{t("home.open-source.description")}
|
||||
</SectionDescription>
|
||||
|
||||
<div className="flex max-w-full flex-col items-center">
|
||||
<ul className="grid list-none grid-cols-1 gap-6 md:w-10/12 md:grid-cols-2">
|
||||
<Repository
|
||||
name="nodejs/node"
|
||||
description="Node.js JavaScript runtime ✨🐢🚀✨"
|
||||
href="https://github.com/nodejs/node/commits?author=theoludwig"
|
||||
/>
|
||||
<Repository
|
||||
name="standard/standard"
|
||||
description="🌟 JavaScript Style Guide, with linter & automatic code fixer"
|
||||
href="https://github.com/standard/standard/commits?author=theoludwig"
|
||||
/>
|
||||
<Repository
|
||||
name="DefinitelyTyped/DefinitelyTyped"
|
||||
description="High quality TypeScript type definitions."
|
||||
href="https://github.com/DefinitelyTyped/DefinitelyTyped/commits?author=theoludwig"
|
||||
/>
|
||||
<Repository
|
||||
name="vercel/next.js"
|
||||
description="The React Framework"
|
||||
href="https://github.com/vercel/next.js/commits?author=theoludwig"
|
||||
/>
|
||||
</ul>
|
||||
</div>
|
||||
</Section>
|
||||
)
|
||||
}
|
32
packages/ui/src/OpenSource/Repository.tsx
Normal file
32
packages/ui/src/OpenSource/Repository.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import { GitHubIcon } from "../About/SocialMediaList/SocialMediaIcons/GitHubIcon"
|
||||
import { SectionContent } from "../design/Section/Section"
|
||||
import { Typography } from "../design/Typography/Typography"
|
||||
|
||||
export interface RepositoryProps {
|
||||
name: string
|
||||
description: string
|
||||
href: string
|
||||
}
|
||||
|
||||
export const Repository: React.FC<RepositoryProps> = (props) => {
|
||||
const { name, description, href } = props
|
||||
|
||||
return (
|
||||
<li>
|
||||
<a href={href} target="_blank">
|
||||
<SectionContent
|
||||
className="relative cursor-pointer p-6 transition-all duration-300 ease-in-out hover:scale-[1.03] sm:p-6"
|
||||
shadowContainer
|
||||
>
|
||||
<Typography as="h3" variant="text1" className="flex items-center">
|
||||
<GitHubIcon className="mr-2 h-6" />
|
||||
<span className="text-primary dark:text-primary-dark font-semibold">
|
||||
{name}
|
||||
</span>
|
||||
</Typography>
|
||||
<p className="mt-4">{description}</p>
|
||||
</SectionContent>
|
||||
</a>
|
||||
</li>
|
||||
)
|
||||
}
|
16
packages/ui/src/Portfolio/Portfolio.stories.tsx
Normal file
16
packages/ui/src/Portfolio/Portfolio.stories.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react"
|
||||
|
||||
import { Portfolio as PortfolioComponent } from "./Portfolio"
|
||||
|
||||
const meta = {
|
||||
title: "Feature/Portfolio",
|
||||
component: PortfolioComponent,
|
||||
} satisfies Meta<typeof PortfolioComponent>
|
||||
|
||||
export default meta
|
||||
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Portfolio: Story = {
|
||||
args: {},
|
||||
}
|
38
packages/ui/src/Portfolio/Portfolio.tsx
Normal file
38
packages/ui/src/Portfolio/Portfolio.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Section, SectionTitle } from "../design/Section/Section"
|
||||
import { PortfolioItem, type PortfolioProject } from "./PortfolioItem"
|
||||
|
||||
export interface PortfolioProps {}
|
||||
|
||||
export const Portfolio: React.FC<PortfolioProps> = () => {
|
||||
const t = useTranslations()
|
||||
|
||||
const items: PortfolioProject[] = [
|
||||
{
|
||||
id: "carolo",
|
||||
title: t("home.portfolio.carolo.title"),
|
||||
description: t("home.portfolio.carolo.description"),
|
||||
link: "https://carolo.theoludwig.fr/",
|
||||
image: "/images/portfolio/Carolo.webp",
|
||||
},
|
||||
{
|
||||
id: "leon",
|
||||
title: t("home.portfolio.leon.title"),
|
||||
description: t("home.portfolio.leon.description"),
|
||||
link: "https://getleon.ai/",
|
||||
image: "/images/portfolio/Leon.webp",
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<Section verticalSpacing horizontalSpacing id="portfolio">
|
||||
<SectionTitle>{t("home.portfolio.title")}</SectionTitle>
|
||||
|
||||
<ul className="flex w-full list-none flex-wrap justify-center gap-12 px-3">
|
||||
{items.map((item) => {
|
||||
return <PortfolioItem key={item.id} portfolioProject={item} />
|
||||
})}
|
||||
</ul>
|
||||
</Section>
|
||||
)
|
||||
}
|
53
packages/ui/src/Portfolio/PortfolioItem.tsx
Normal file
53
packages/ui/src/Portfolio/PortfolioItem.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
import Image from "next/image"
|
||||
import { SectionContent } from "../design/Section/Section"
|
||||
import { Typography } from "../design/Typography/Typography"
|
||||
|
||||
export interface PortfolioProject {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
image: string
|
||||
link: string
|
||||
}
|
||||
|
||||
export interface PortfolioItemProps {
|
||||
portfolioProject: PortfolioProject
|
||||
}
|
||||
|
||||
export const PortfolioItem: React.FC<PortfolioItemProps> = (props) => {
|
||||
const { portfolioProject } = props
|
||||
const { title, description, link, image } = portfolioProject
|
||||
|
||||
return (
|
||||
<li>
|
||||
<a
|
||||
className="group inline-flex justify-center"
|
||||
target="_blank"
|
||||
href={link}
|
||||
aria-label={title}
|
||||
>
|
||||
<SectionContent
|
||||
className="relative cursor-pointer items-center p-0 sm:p-0"
|
||||
shadowContainer
|
||||
>
|
||||
<div className="flex justify-center">
|
||||
<Image
|
||||
quality={100}
|
||||
className="size-auto transition-opacity duration-500 group-hover:opacity-20 dark:group-hover:opacity-5"
|
||||
width={300}
|
||||
height={300}
|
||||
src={image}
|
||||
alt={title}
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute bottom-0 h-auto overflow-hidden text-center opacity-0 transition-opacity duration-500 group-hover:opacity-100">
|
||||
<Typography variant="h4" as="h3" className="my-6">
|
||||
{title}
|
||||
</Typography>
|
||||
<p className="mx-4 my-6 font-semibold">{description}</p>
|
||||
</div>
|
||||
</SectionContent>
|
||||
</a>
|
||||
</li>
|
||||
)
|
||||
}
|
51
packages/ui/src/Skills/SkillItem.tsx
Normal file
51
packages/ui/src/Skills/SkillItem.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
"use client"
|
||||
|
||||
import Image from "next/image"
|
||||
import { useMemo } from "react"
|
||||
import { Link } from "../design/Link/Link"
|
||||
import { useTheme } from "../Header/SwitchTheme"
|
||||
import type { SkillName } from "./skills"
|
||||
import { skills } from "./skills"
|
||||
|
||||
export interface SkillItemProps {
|
||||
skillName: SkillName
|
||||
}
|
||||
|
||||
export const SkillItem: React.FC<SkillItemProps> = (props) => {
|
||||
const { skillName } = props
|
||||
|
||||
const skill = skills[skillName]
|
||||
|
||||
const { theme } = useTheme()
|
||||
|
||||
const skillImage = useMemo(() => {
|
||||
if (typeof skill.image === "string") {
|
||||
return skill.image
|
||||
}
|
||||
if (theme === "light") {
|
||||
return skill.image.light
|
||||
}
|
||||
return skill.image.dark
|
||||
}, [skill.image, theme])
|
||||
|
||||
return (
|
||||
<li>
|
||||
<Link
|
||||
href={skill.link}
|
||||
className="mx-2 max-w-xl flex-col items-center justify-center text-center"
|
||||
target="_blank"
|
||||
isExternal={false}
|
||||
>
|
||||
<Image
|
||||
className="inline size-16"
|
||||
quality={100}
|
||||
width={64}
|
||||
height={64}
|
||||
alt={`Logo of ${skillName}`}
|
||||
src={skillImage}
|
||||
/>
|
||||
<p className="mt-1 font-semibold">{skillName}</p>
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
}
|
16
packages/ui/src/Skills/Skills.stories.tsx
Normal file
16
packages/ui/src/Skills/Skills.stories.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react"
|
||||
|
||||
import { Skills as SkillsComponent } from "./Skills"
|
||||
|
||||
const meta = {
|
||||
title: "Feature/Skills",
|
||||
component: SkillsComponent,
|
||||
} satisfies Meta<typeof SkillsComponent>
|
||||
|
||||
export default meta
|
||||
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Skills: Story = {
|
||||
args: {},
|
||||
}
|
45
packages/ui/src/Skills/Skills.tsx
Normal file
45
packages/ui/src/Skills/Skills.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Section, SectionTitle } from "../design/Section/Section"
|
||||
import { SkillItem } from "./SkillItem"
|
||||
import { SkillsSection } from "./SkillsSection"
|
||||
|
||||
export interface SkillsProps {}
|
||||
|
||||
export const Skills: React.FC<SkillsProps> = () => {
|
||||
const t = useTranslations()
|
||||
|
||||
return (
|
||||
<Section verticalSpacing horizontalSpacing id="skills">
|
||||
<SectionTitle>{t("home.skills.title")}</SectionTitle>
|
||||
|
||||
<SkillsSection title={t("home.skills.programming-languages")}>
|
||||
<SkillItem skillName="TypeScript" />
|
||||
<SkillItem skillName="Python" />
|
||||
<SkillItem skillName="C/C++" />
|
||||
<SkillItem skillName="PHP" />
|
||||
</SkillsSection>
|
||||
|
||||
<SkillsSection title={t("home.skills.frontend")}>
|
||||
<SkillItem skillName="HTML" />
|
||||
<SkillItem skillName="CSS" />
|
||||
<SkillItem skillName="Tailwind CSS" />
|
||||
<SkillItem skillName="React.js (+ Next.js)" />
|
||||
</SkillsSection>
|
||||
|
||||
<SkillsSection title={t("home.skills.backend")}>
|
||||
<SkillItem skillName="Laravel" />
|
||||
<SkillItem skillName="Node.js" />
|
||||
<SkillItem skillName="Fastify" />
|
||||
<SkillItem skillName="PostgreSQL" />
|
||||
</SkillsSection>
|
||||
|
||||
<SkillsSection title={t("home.skills.software-tools")}>
|
||||
<SkillItem skillName="GNU/Linux" />
|
||||
<SkillItem skillName="Arch Linux" />
|
||||
<SkillItem skillName="Visual Studio Code" />
|
||||
<SkillItem skillName="Git" />
|
||||
<SkillItem skillName="Docker" />
|
||||
</SkillsSection>
|
||||
</Section>
|
||||
)
|
||||
}
|
25
packages/ui/src/Skills/SkillsSection.tsx
Normal file
25
packages/ui/src/Skills/SkillsSection.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import { SectionContent } from "../design/Section/Section"
|
||||
import { Typography } from "../design/Typography/Typography"
|
||||
|
||||
export interface SkillsSectionProps extends React.PropsWithChildren {
|
||||
title: string
|
||||
}
|
||||
|
||||
export const SkillsSection: React.FC<SkillsSectionProps> = (props) => {
|
||||
const { title, children } = props
|
||||
|
||||
return (
|
||||
<section className="mb-12">
|
||||
<SectionContent shadowContainer className="mx-auto w-full px-4 py-6">
|
||||
<Typography
|
||||
variant="h4"
|
||||
as="h3"
|
||||
className="mb-6 border-b border-black pb-3 dark:border-white"
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
<ul className="flex list-none flex-wrap justify-around">{children}</ul>
|
||||
</SectionContent>
|
||||
</section>
|
||||
)
|
||||
}
|
115
packages/ui/src/Skills/skills.ts
Normal file
115
packages/ui/src/Skills/skills.ts
Normal file
@ -0,0 +1,115 @@
|
||||
export interface Skill {
|
||||
link: string
|
||||
image: string | { [key: string]: string }
|
||||
}
|
||||
|
||||
export const skills = {
|
||||
JavaScript: {
|
||||
link: "https://developer.mozilla.org/docs/Web/JavaScript",
|
||||
image: "/images/skills/JavaScript.webp",
|
||||
},
|
||||
TypeScript: {
|
||||
link: "https://www.typescriptlang.org/",
|
||||
image: "/images/skills/TypeScript.webp",
|
||||
},
|
||||
Python: {
|
||||
link: "https://www.python.org/",
|
||||
image: "/images/skills/Python.webp",
|
||||
},
|
||||
"C/C++": {
|
||||
link: "https://isocpp.org/",
|
||||
image: "/images/skills/C-Cpp.webp",
|
||||
},
|
||||
PHP: {
|
||||
link: "https://www.php.net/",
|
||||
image: "/images/skills/PHP.webp",
|
||||
},
|
||||
Laravel: {
|
||||
link: "https://laravel.com/",
|
||||
image: "/images/skills/Laravel.webp",
|
||||
},
|
||||
Dart: {
|
||||
link: "https://dart.dev/",
|
||||
image: "/images/skills/Dart.webp",
|
||||
},
|
||||
Flutter: {
|
||||
link: "https://flutter.dev/",
|
||||
image: "/images/skills/Flutter.webp",
|
||||
},
|
||||
HTML: {
|
||||
link: "https://developer.mozilla.org/docs/Web/HTML",
|
||||
image: "/images/skills/HTML.webp",
|
||||
},
|
||||
CSS: {
|
||||
link: "https://developer.mozilla.org/docs/Web/CSS",
|
||||
image: "/images/skills/CSS.webp",
|
||||
},
|
||||
"Tailwind CSS": {
|
||||
link: "https://tailwindcss.com/",
|
||||
image: "/images/skills/TailwindCSS.webp",
|
||||
},
|
||||
SASS: {
|
||||
link: "https://sass-lang.com/",
|
||||
image: "/images/skills/SASS.svg",
|
||||
},
|
||||
"React.js (+ Next.js)": {
|
||||
link: "https://reactjs.org/",
|
||||
image: "/images/skills/ReactJS.webp",
|
||||
},
|
||||
"Node.js": {
|
||||
link: "https://nodejs.org/",
|
||||
image: "/images/skills/NodeJS.webp",
|
||||
},
|
||||
Fastify: {
|
||||
link: "https://www.fastify.io/",
|
||||
image: {
|
||||
light: "/images/skills/Fastify-light.webp",
|
||||
dark: "/images/skills/Fastify-dark.webp",
|
||||
},
|
||||
},
|
||||
Prisma: {
|
||||
link: "https://www.prisma.io/",
|
||||
image: {
|
||||
light: "/images/skills/Prisma-light.webp",
|
||||
dark: "/images/skills/Prisma-dark.webp",
|
||||
},
|
||||
},
|
||||
PostgreSQL: {
|
||||
link: "https://www.postgresql.org/",
|
||||
image: "/images/skills/PostgreSQL.webp",
|
||||
},
|
||||
MySQL: {
|
||||
link: "https://www.mysql.com/",
|
||||
image: "/images/skills/MySQL.webp",
|
||||
},
|
||||
Strapi: {
|
||||
link: "https://strapi.io/",
|
||||
image: "/images/skills/Strapi.webp",
|
||||
},
|
||||
"Visual Studio Code": {
|
||||
link: "https://code.visualstudio.com/",
|
||||
image: "/images/skills/VisualStudioCode.webp",
|
||||
},
|
||||
Git: {
|
||||
link: "https://git-scm.com/",
|
||||
image: "/images/skills/Git.webp",
|
||||
},
|
||||
Ubuntu: {
|
||||
link: "https://ubuntu.com/",
|
||||
image: "/images/skills/Ubuntu.webp",
|
||||
},
|
||||
"Arch Linux": {
|
||||
link: "https://archlinux.org/",
|
||||
image: "/images/skills/ArchLinux.webp",
|
||||
},
|
||||
"GNU/Linux": {
|
||||
link: "https://www.gnu.org/",
|
||||
image: "/images/skills/GNU-Linux.webp",
|
||||
},
|
||||
Docker: {
|
||||
link: "https://www.docker.com/",
|
||||
image: "/images/skills/Docker.webp",
|
||||
},
|
||||
} as const
|
||||
|
||||
export type SkillName = keyof typeof skills
|
148
packages/ui/src/design/Button/Button.stories.tsx
Normal file
148
packages/ui/src/design/Button/Button.stories.tsx
Normal file
@ -0,0 +1,148 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react"
|
||||
import { expect, fn, userEvent, within } from "@storybook/test"
|
||||
import { FaCheck } from "react-icons/fa6"
|
||||
|
||||
import type { ButtonLinkProps } from "./Button"
|
||||
import { Button } from "./Button"
|
||||
|
||||
const meta = {
|
||||
title: "Design System/Button",
|
||||
component: Button,
|
||||
tags: ["autodocs"],
|
||||
args: { onClick: fn() },
|
||||
} satisfies Meta<typeof Button>
|
||||
|
||||
export default meta
|
||||
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const ButtonContainer: React.FC<React.PropsWithChildren> = (props) => {
|
||||
const { children } = props
|
||||
|
||||
return <div className="flex gap-4">{children}</div>
|
||||
}
|
||||
|
||||
export const Component: Story = {
|
||||
args: {
|
||||
children: "Button",
|
||||
},
|
||||
play: async ({ canvasElement, args }) => {
|
||||
const canvas = within(canvasElement)
|
||||
await userEvent.click(canvas.getByText("Button"))
|
||||
await expect(args.onClick).toHaveBeenCalled()
|
||||
},
|
||||
}
|
||||
|
||||
export const Variants: Story = {
|
||||
render: (args) => {
|
||||
return (
|
||||
<ButtonContainer>
|
||||
<Button variant="solid" {...args}>
|
||||
Solid
|
||||
</Button>
|
||||
<Button variant="outline" {...args}>
|
||||
Outline
|
||||
</Button>
|
||||
</ButtonContainer>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const Sizes: Story = {
|
||||
render: (args) => {
|
||||
return (
|
||||
<ButtonContainer>
|
||||
<Button size="small" {...args}>
|
||||
Small
|
||||
</Button>
|
||||
<Button size="medium" {...args}>
|
||||
Medium
|
||||
</Button>
|
||||
<Button size="large" {...args}>
|
||||
Large
|
||||
</Button>
|
||||
</ButtonContainer>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
render: (args) => {
|
||||
return (
|
||||
<ButtonContainer>
|
||||
<Button variant="solid" disabled {...args}>
|
||||
Solid
|
||||
</Button>
|
||||
<Button variant="outline" disabled {...args}>
|
||||
Outline
|
||||
</Button>
|
||||
</ButtonContainer>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const Loading: Story = {
|
||||
render: (args) => {
|
||||
return (
|
||||
<ButtonContainer>
|
||||
<Button variant="solid" isLoading {...args}>
|
||||
Solid
|
||||
</Button>
|
||||
<Button variant="outline" isLoading {...args}>
|
||||
Outline
|
||||
</Button>
|
||||
</ButtonContainer>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const Icons: Story = {
|
||||
render: (args) => {
|
||||
return (
|
||||
<ButtonContainer>
|
||||
<Button leftIcon={<FaCheck size={18} />} {...args}>
|
||||
Left Icon
|
||||
</Button>
|
||||
<Button rightIcon={<FaCheck size={18} />} {...args}>
|
||||
Right Icon
|
||||
</Button>
|
||||
</ButtonContainer>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const Link: Story = {
|
||||
args: {
|
||||
children: "Link",
|
||||
href: "/",
|
||||
},
|
||||
play: async ({ canvasElement, args }) => {
|
||||
const canvas = within(canvasElement)
|
||||
await expect(
|
||||
canvas.getByRole("link", {
|
||||
name: "Link",
|
||||
}),
|
||||
).toHaveAttribute("href", args.href)
|
||||
},
|
||||
}
|
||||
|
||||
export const LinkWithIcons: Story = {
|
||||
args: {
|
||||
href: "/",
|
||||
},
|
||||
render: (args) => {
|
||||
return (
|
||||
<ButtonContainer>
|
||||
<Button leftIcon={<FaCheck size={18} />} {...(args as ButtonLinkProps)}>
|
||||
Link Left Icon
|
||||
</Button>
|
||||
<Button
|
||||
rightIcon={<FaCheck size={18} />}
|
||||
{...(args as ButtonLinkProps)}
|
||||
>
|
||||
Link Right Icon
|
||||
</Button>
|
||||
</ButtonContainer>
|
||||
)
|
||||
},
|
||||
}
|
111
packages/ui/src/design/Button/Button.tsx
Normal file
111
packages/ui/src/design/Button/Button.tsx
Normal file
@ -0,0 +1,111 @@
|
||||
import { classNames } from "@repo/config-tailwind/classNames"
|
||||
import { Link as NextLink } from "@repo/i18n/navigation"
|
||||
import type { VariantProps } from "cva"
|
||||
import { cva } from "cva"
|
||||
|
||||
import { Spinner } from "../Spinner/Spinner"
|
||||
import { Ripple } from "./Ripple"
|
||||
|
||||
const buttonVariants = cva({
|
||||
base: "relative inline-flex items-center justify-center overflow-hidden rounded-md text-base font-semibold transition duration-150 ease-in-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
variants: {
|
||||
variant: {
|
||||
solid: "bg-primary hover:bg-primary/80 text-white",
|
||||
outline:
|
||||
"dark:border-primary-dark/60 dark:text-primary-dark dark:hover:border-primary-dark border-primary/60 text-primary hover:border-primary hover:bg-gray border bg-transparent dark:hover:bg-transparent",
|
||||
},
|
||||
size: {
|
||||
small: "h-9 rounded-md px-3",
|
||||
medium: "h-10 px-4 py-2",
|
||||
large: "h-11 rounded-md px-8",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "solid",
|
||||
size: "medium",
|
||||
},
|
||||
})
|
||||
|
||||
interface ButtonBaseProps extends VariantProps<typeof buttonVariants> {
|
||||
leftIcon?: React.ReactNode
|
||||
rightIcon?: React.ReactNode
|
||||
disabled?: boolean
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
interface ButtonElementProps extends React.ComponentPropsWithoutRef<"button"> {}
|
||||
interface LinkElementProps
|
||||
extends React.ComponentPropsWithoutRef<typeof NextLink> {}
|
||||
|
||||
export type ButtonLinkProps = ButtonBaseProps &
|
||||
LinkElementProps & { href: string }
|
||||
export type ButtonButtonProps = ButtonBaseProps &
|
||||
ButtonElementProps & { href?: never }
|
||||
|
||||
export type ButtonProps = ButtonButtonProps | ButtonLinkProps
|
||||
|
||||
/**
|
||||
* Buttons allow users to take actions, and make choices, with a single click.
|
||||
* @param props
|
||||
* @returns
|
||||
*/
|
||||
export const Button: React.FC<ButtonProps> = (props) => {
|
||||
const rippleColor =
|
||||
props.variant === "outline" ? "rgb(30, 64, 175)" : "rgb(229, 231, 235)"
|
||||
|
||||
if (typeof props.href === "string") {
|
||||
const { variant, size, leftIcon, rightIcon, className, children, ...rest } =
|
||||
props
|
||||
|
||||
return (
|
||||
<NextLink
|
||||
className={classNames(buttonVariants({ variant, size }), className)}
|
||||
{...rest}
|
||||
>
|
||||
{leftIcon != null ? <span className="mr-2">{leftIcon}</span> : null}
|
||||
<span>{children}</span>
|
||||
{rightIcon != null ? <span className="ml-2">{rightIcon}</span> : null}
|
||||
|
||||
<Ripple color={rippleColor} />
|
||||
</NextLink>
|
||||
)
|
||||
}
|
||||
|
||||
const {
|
||||
variant,
|
||||
size,
|
||||
leftIcon,
|
||||
rightIcon,
|
||||
className,
|
||||
isLoading = false,
|
||||
disabled = false,
|
||||
children,
|
||||
...rest
|
||||
} = props
|
||||
|
||||
const isDisabled = disabled || isLoading
|
||||
|
||||
const leftIconElement = isLoading ? (
|
||||
<Spinner size={18} className="text-inherit dark:text-inherit" />
|
||||
) : (
|
||||
leftIcon
|
||||
)
|
||||
|
||||
return (
|
||||
<button
|
||||
className={classNames(buttonVariants({ variant, size }), className)}
|
||||
disabled={isDisabled}
|
||||
{...rest}
|
||||
>
|
||||
{leftIconElement != null ? (
|
||||
<span className="mr-2">{leftIconElement}</span>
|
||||
) : null}
|
||||
<span>{children}</span>
|
||||
{rightIcon != null && !isLoading ? (
|
||||
<span className="ml-2">{rightIcon}</span>
|
||||
) : null}
|
||||
|
||||
<Ripple color={rippleColor} />
|
||||
</button>
|
||||
)
|
||||
}
|
91
packages/ui/src/design/Button/Ripple.tsx
Normal file
91
packages/ui/src/design/Button/Ripple.tsx
Normal file
@ -0,0 +1,91 @@
|
||||
"use client"
|
||||
|
||||
import { useLayoutEffect, useState } from "react"
|
||||
|
||||
const useDebouncedRippleCleanUp = (
|
||||
rippleCount: number,
|
||||
duration: number,
|
||||
cleanUpFunction: () => void,
|
||||
): void => {
|
||||
useLayoutEffect(() => {
|
||||
let bounce: ReturnType<typeof setTimeout> | undefined
|
||||
if (rippleCount > 0) {
|
||||
clearTimeout(bounce)
|
||||
|
||||
bounce = setTimeout(() => {
|
||||
cleanUpFunction()
|
||||
clearTimeout(bounce)
|
||||
}, duration * 4)
|
||||
}
|
||||
|
||||
return () => {
|
||||
return clearTimeout(bounce)
|
||||
}
|
||||
}, [rippleCount, duration, cleanUpFunction])
|
||||
}
|
||||
|
||||
export interface RippleProps {
|
||||
/**
|
||||
* The color of the ripple effect.
|
||||
*/
|
||||
color?: string
|
||||
|
||||
/**
|
||||
* The duration of the ripple animation in milliseconds.
|
||||
*/
|
||||
duration?: number
|
||||
}
|
||||
|
||||
interface RippleItem {
|
||||
x: number
|
||||
y: number
|
||||
size: number
|
||||
}
|
||||
|
||||
export const Ripple: React.FC<RippleProps> = (props) => {
|
||||
const { duration = 1_200, color = "rgb(229, 231, 235)" } = props
|
||||
const [rippleArray, setRippleArray] = useState<RippleItem[]>([])
|
||||
|
||||
useDebouncedRippleCleanUp(rippleArray.length, duration, () => {
|
||||
setRippleArray([])
|
||||
})
|
||||
|
||||
const addRipple: React.MouseEventHandler<HTMLDivElement> = (event) => {
|
||||
const rippleContainer = event.currentTarget.getBoundingClientRect()
|
||||
const size =
|
||||
rippleContainer.width > rippleContainer.height
|
||||
? rippleContainer.width
|
||||
: rippleContainer.height
|
||||
const x = event.pageX - rippleContainer.x - size / 2
|
||||
const y = event.pageY - rippleContainer.y - size / 2
|
||||
const newRipple: RippleItem = {
|
||||
x,
|
||||
y,
|
||||
size,
|
||||
}
|
||||
setRippleArray([...rippleArray, newRipple])
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0" onMouseDown={addRipple}>
|
||||
{rippleArray.map((ripple, index) => {
|
||||
return (
|
||||
<span
|
||||
key={"span" + index}
|
||||
className="absolute rounded-full opacity-75"
|
||||
style={{
|
||||
transform: "scale(0)",
|
||||
backgroundColor: color,
|
||||
animationName: "ripple",
|
||||
animationDuration: `${duration}ms`,
|
||||
top: ripple.y,
|
||||
left: ripple.x,
|
||||
width: ripple.size,
|
||||
height: ripple.size,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
29
packages/ui/src/design/Link/Link.stories.tsx
Normal file
29
packages/ui/src/design/Link/Link.stories.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react"
|
||||
|
||||
import { Link } from "./Link"
|
||||
|
||||
const meta = {
|
||||
title: "Design System/Link",
|
||||
component: Link,
|
||||
tags: ["autodocs"],
|
||||
} satisfies Meta<typeof Link>
|
||||
|
||||
export default meta
|
||||
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Component: Story = {
|
||||
args: {
|
||||
children: "Link",
|
||||
href: "/",
|
||||
},
|
||||
}
|
||||
|
||||
export const External: Story = {
|
||||
args: {
|
||||
children: "Link",
|
||||
href: "/",
|
||||
target: "_blank",
|
||||
isExternal: true,
|
||||
},
|
||||
}
|
35
packages/ui/src/design/Link/Link.tsx
Normal file
35
packages/ui/src/design/Link/Link.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
import { classNames } from "@repo/config-tailwind/classNames"
|
||||
import { Link as NextLink } from "@repo/i18n/navigation"
|
||||
import { FiExternalLink } from "react-icons/fi"
|
||||
|
||||
export interface LinkProps extends React.ComponentProps<typeof NextLink> {
|
||||
isExternal?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Link is an actionable text component with connection to another web pages.
|
||||
* @param props
|
||||
* @returns
|
||||
*/
|
||||
export const Link: React.FC<LinkProps> = (props) => {
|
||||
const { className, children, target, isExternal = true, ...rest } = props
|
||||
|
||||
return (
|
||||
<NextLink
|
||||
className={classNames(
|
||||
"text-primary dark:text-primary-dark inline-flex items-center gap-1 font-semibold hover:underline focus:rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
|
||||
className,
|
||||
)}
|
||||
target={target}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
|
||||
{target === "_blank" && isExternal ? (
|
||||
<FiExternalLink size={16} strokeWidth={2.5} />
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</NextLink>
|
||||
)
|
||||
}
|
39
packages/ui/src/design/Section/RevealFade.tsx
Normal file
39
packages/ui/src/design/Section/RevealFade.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef } from "react"
|
||||
|
||||
export interface RevealFadeProps extends React.PropsWithChildren {}
|
||||
|
||||
export const RevealFade: React.FC<RevealFadeProps> = (props) => {
|
||||
const { children } = props
|
||||
|
||||
const htmlElement = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
const className =
|
||||
"opacity-100 visible translate-y-0 transition-all duration-700 ease-in-out"
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new window.IntersectionObserver(
|
||||
(entries, observer) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.className = className
|
||||
observer.unobserve(entry.target)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
root: null,
|
||||
rootMargin: "0px",
|
||||
threshold: 0.28,
|
||||
},
|
||||
)
|
||||
observer.observe(htmlElement.current as HTMLDivElement)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div ref={htmlElement} className="invisible -translate-y-7 opacity-0">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
88
packages/ui/src/design/Section/Section.tsx
Normal file
88
packages/ui/src/design/Section/Section.tsx
Normal file
@ -0,0 +1,88 @@
|
||||
import { classNames } from "@repo/config-tailwind/classNames"
|
||||
import type { TypographyProps } from "../Typography/Typography"
|
||||
import { Typography } from "../Typography/Typography"
|
||||
|
||||
export * from "./RevealFade"
|
||||
|
||||
export interface SectionProps
|
||||
extends React.ComponentPropsWithoutRef<"section"> {
|
||||
verticalSpacing?: boolean
|
||||
horizontalSpacing?: boolean
|
||||
}
|
||||
|
||||
export const Section: React.FC<SectionProps> = (props) => {
|
||||
const {
|
||||
className,
|
||||
verticalSpacing = false,
|
||||
horizontalSpacing = false,
|
||||
...rest
|
||||
} = props
|
||||
|
||||
return (
|
||||
<section
|
||||
className={classNames(
|
||||
{
|
||||
"my-12": verticalSpacing,
|
||||
"mx-6": horizontalSpacing,
|
||||
},
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export interface SectionTitleProps extends TypographyProps<"h2"> {}
|
||||
|
||||
export const SectionTitle: React.FC<SectionTitleProps> = (props) => {
|
||||
const { className, ...rest } = props
|
||||
|
||||
return (
|
||||
<Typography
|
||||
as="h2"
|
||||
variant="h2"
|
||||
className={classNames("mb-4 text-center", className)}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export interface SectionDescriptionProps extends TypographyProps<"p"> {}
|
||||
|
||||
export const SectionDescription: React.FC<SectionDescriptionProps> = (
|
||||
props,
|
||||
) => {
|
||||
const { className, ...rest } = props
|
||||
|
||||
return (
|
||||
<Typography
|
||||
as="p"
|
||||
variant="text1"
|
||||
className={classNames("mb-4 text-center", className)}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export interface SectionContentProps
|
||||
extends React.ComponentPropsWithoutRef<"div"> {
|
||||
shadowContainer?: boolean
|
||||
}
|
||||
|
||||
export const SectionContent: React.FC<SectionContentProps> = (props) => {
|
||||
const { className, shadowContainer = false, ...rest } = props
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
"size-full max-w-full break-words px-6 py-4 sm:px-16",
|
||||
{
|
||||
"shadow-light dark:shadow-dark max-w-full rounded-2xl border border-solid border-black":
|
||||
shadowContainer,
|
||||
},
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
17
packages/ui/src/design/Spinner/Spinner.stories.tsx
Normal file
17
packages/ui/src/design/Spinner/Spinner.stories.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react"
|
||||
|
||||
import { Spinner } from "./Spinner"
|
||||
|
||||
const meta = {
|
||||
title: "Design System/Spinner",
|
||||
component: Spinner,
|
||||
tags: ["autodocs"],
|
||||
} satisfies Meta<typeof Spinner>
|
||||
|
||||
export default meta
|
||||
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Component: Story = {
|
||||
args: {},
|
||||
}
|
32
packages/ui/src/design/Spinner/Spinner.tsx
Normal file
32
packages/ui/src/design/Spinner/Spinner.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import { classNames } from "@repo/config-tailwind/classNames"
|
||||
|
||||
export interface SpinnerProps {
|
||||
size?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Spinner provide a visual cue that an action is processing.
|
||||
* @param props
|
||||
* @returns
|
||||
*/
|
||||
export const Spinner: React.FC<SpinnerProps> = (props) => {
|
||||
const { size = 50, className } = props
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
}}
|
||||
className={classNames(
|
||||
"text-primary dark:text-primary-dark flex animate-spin rounded-full border-2 border-current border-t-transparent",
|
||||
className,
|
||||
)}
|
||||
role="status"
|
||||
aria-label="loading"
|
||||
>
|
||||
<span className="sr-only">Loading...</span>
|
||||
</div>
|
||||
)
|
||||
}
|
101
packages/ui/src/design/Typography/Typography.stories.tsx
Normal file
101
packages/ui/src/design/Typography/Typography.stories.tsx
Normal file
@ -0,0 +1,101 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react"
|
||||
|
||||
import type { TypographyProps } from "./Typography"
|
||||
import { Typography } from "./Typography"
|
||||
|
||||
const meta = {
|
||||
title: "Design System/Typography",
|
||||
component: Typography,
|
||||
} satisfies Meta<typeof Typography>
|
||||
|
||||
export default meta
|
||||
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Component: Story = {
|
||||
args: {
|
||||
children: "Typography",
|
||||
},
|
||||
}
|
||||
|
||||
export const Variants: Story = {
|
||||
render: () => {
|
||||
return (
|
||||
<section className="my-6 space-y-4">
|
||||
{Array.from({ length: 6 }).map((_, index) => {
|
||||
const heading = `h${index + 1}`
|
||||
return (
|
||||
<Typography
|
||||
as={heading as TypographyProps["as"]}
|
||||
variant={heading as TypographyProps["variant"]}
|
||||
key={heading}
|
||||
>
|
||||
Heading {heading}
|
||||
</Typography>
|
||||
)
|
||||
})}
|
||||
|
||||
<Typography as="p" variant="text1">
|
||||
Text 1
|
||||
</Typography>
|
||||
<Typography as="p" variant="text2">
|
||||
Text 2
|
||||
</Typography>
|
||||
|
||||
<Typography as="p" variant="text1">
|
||||
<strong>Bold (Strong)</strong>
|
||||
</Typography>
|
||||
|
||||
<Typography as="p" variant="text1">
|
||||
<em>Italic (Emphasis)</em>
|
||||
</Typography>
|
||||
|
||||
<Typography as="p" variant="text1">
|
||||
<u>Underline</u>
|
||||
</Typography>
|
||||
|
||||
<Typography as="p" variant="text1">
|
||||
<del>Strikethrough</del>
|
||||
</Typography>
|
||||
|
||||
<Typography as="p" variant="text1">
|
||||
<mark>Highlighted</mark>
|
||||
</Typography>
|
||||
|
||||
<Typography as="p" variant="text1">
|
||||
<kbd>Ctrl + C</kbd> (Keyboard Input)
|
||||
</Typography>
|
||||
|
||||
<Typography as="p" variant="text1">
|
||||
<abbr title="Cascading Style Sheets">CSS</abbr> (Abbreviation or
|
||||
Acronym)
|
||||
</Typography>
|
||||
|
||||
<Typography as="p" variant="text1">
|
||||
<q>Citation</q>
|
||||
</Typography>
|
||||
|
||||
<Typography as="p" variant="text1">
|
||||
A <dfn id="def-validator">validator</dfn> is a program that checks for
|
||||
syntax errors in code or documents. (Definition)
|
||||
</Typography>
|
||||
|
||||
<Typography as="blockquote" variant="text1">
|
||||
A long Citation...
|
||||
<br />
|
||||
Second line...
|
||||
</Typography>
|
||||
|
||||
<ul>
|
||||
<li>Ordered list item 1</li>
|
||||
<li>Ordered list item 2</li>
|
||||
</ul>
|
||||
|
||||
<ol>
|
||||
<li>Unordered list item 1</li>
|
||||
<li>Unordered list item 2</li>
|
||||
</ol>
|
||||
</section>
|
||||
)
|
||||
},
|
||||
}
|
45
packages/ui/src/design/Typography/Typography.tsx
Normal file
45
packages/ui/src/design/Typography/Typography.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import { classNames } from "@repo/config-tailwind/classNames"
|
||||
import type { VariantProps } from "cva"
|
||||
import { cva } from "cva"
|
||||
|
||||
const typographyVariants = cva({
|
||||
variants: {
|
||||
variant: {
|
||||
h1: "text-primary dark:text-primary-dark text-4xl font-semibold",
|
||||
h2: "text-primary dark:text-primary-dark text-3xl font-semibold",
|
||||
h3: "text-primary dark:text-primary-dark text-2xl font-semibold",
|
||||
h4: "text-primary dark:text-primary-dark text-xl font-semibold",
|
||||
h5: "text-primary dark:text-primary-dark text-xl font-medium",
|
||||
h6: "text-primary dark:text-primary-dark text-lg font-medium",
|
||||
text1: "break-words text-base",
|
||||
text2: "break-words text-sm",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export type TypographyProps<Component extends React.ElementType = "p"> = {
|
||||
as?: Component
|
||||
} & React.ComponentPropsWithoutRef<Component> &
|
||||
VariantProps<typeof typographyVariants>
|
||||
|
||||
/**
|
||||
* Typography and styling abstraction component used to ensure consistency and standardize text throughout your application.
|
||||
* @param props
|
||||
* @returns
|
||||
*/
|
||||
export const Typography = <Component extends React.ElementType = "p">(
|
||||
props: TypographyProps<Component>,
|
||||
): React.ReactNode => {
|
||||
const { variant = "text1", as = "p", children, className, ...rest } = props
|
||||
|
||||
const ComponentAs = as
|
||||
|
||||
return (
|
||||
<ComponentAs
|
||||
className={classNames(typographyVariants({ variant }), className)}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</ComponentAs>
|
||||
)
|
||||
}
|
9
packages/ui/tailwind.config.js
Normal file
9
packages/ui/tailwind.config.js
Normal file
@ -0,0 +1,9 @@
|
||||
import sharedConfig from "@repo/config-tailwind"
|
||||
|
||||
/** @type {Pick<import('tailwindcss').Config, "presets" | "content">} */
|
||||
const config = {
|
||||
content: ["./src/**/*.tsx"],
|
||||
presets: [sharedConfig],
|
||||
}
|
||||
|
||||
export default config
|
14
packages/ui/tsconfig.json
Normal file
14
packages/ui/tsconfig.json
Normal file
@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "@repo/config-typescript/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
||||
"types": ["@total-typescript/ts-reset", "@repo/i18n/messages.d.ts"],
|
||||
"jsx": "preserve",
|
||||
|
||||
"noEmit": true
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user