diff --git a/packages/ui/next-env.d.ts b/packages/ui/next-env.d.ts new file mode 100644 index 0000000..4f11a03 --- /dev/null +++ b/packages/ui/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/packages/ui/src/ArticleLayout.jsx b/packages/ui/src/ArticleLayout.jsx deleted file mode 100644 index eeeba39..0000000 --- a/packages/ui/src/ArticleLayout.jsx +++ /dev/null @@ -1,67 +0,0 @@ -'use client' - -import { useContext } from 'react' -import { useRouter } from 'next/navigation' - -import { AppContext } from '@/app/providers' -import { Container } from '@/components/Container' -import { Prose } from '@/components/Prose' -import { formatDate } from '@/lib/formatDate' - -function ArrowLeftIcon(props) { - return ( - - ) -} - -export function ArticleLayout({ children, article, isRssFeed = false }) { - let router = useRouter() - let { previousPathname } = useContext(AppContext) - - if (isRssFeed) { - return children - } - - return ( - -
-
- {previousPathname && ( - - )} -
-
-

- {article.title} -

- -
- - {children} - -
-
-
-
- ) -} diff --git a/packages/ui/src/ArticleLayout/ArticleLayout.tsx b/packages/ui/src/ArticleLayout/ArticleLayout.tsx new file mode 100644 index 0000000..1700390 --- /dev/null +++ b/packages/ui/src/ArticleLayout/ArticleLayout.tsx @@ -0,0 +1,60 @@ +import { Container } from "../Container/Container"; +import { Prose } from "../Prose/Prose"; +import { ArticleLayoutBack } from "./ArticleLayoutBack"; +import { FC } from "react"; + +export function formatDate(dateString: string) { + return new Date(`${dateString}T00:00:00Z`).toLocaleDateString("en-US", { + day: "numeric", + month: "long", + year: "numeric", + timeZone: "UTC", + }); +} + +interface Article { + title: string; + description: string; + author: string; + date: string; +} + +export interface ArticleWithSlug extends Article { + slug: string; +} + +type ArticleLayoutProps = { + article: ArticleWithSlug; + children: React.ReactNode; +}; +export const ArticleLayout: FC = ({ + article, + children, +}) => { + return ( + +
+
+ +
+
+

+ {article.title} +

+ +
+ + {children} + +
+
+
+
+ ); +}; diff --git a/packages/ui/src/ArticleLayout/ArticleLayoutBack.tsx b/packages/ui/src/ArticleLayout/ArticleLayoutBack.tsx new file mode 100644 index 0000000..6218bf5 --- /dev/null +++ b/packages/ui/src/ArticleLayout/ArticleLayoutBack.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { FC, useEffect, useState } from "react"; + +const ArrowLeftIcon: FC> = (props) => { + return ( + + ); +}; + +export const ArticleLayoutBack: FC = () => { + let router = useRouter(); + const [hasPreviousRoute, setHasPreviousRoute] = useState(false); + useEffect(() => { + if (window?.history?.length > 0) { + setHasPreviousRoute(true); + } + }, []); + + if (!hasPreviousRoute) { + return null; + } + + return ( + + ); +}; diff --git a/packages/ui/src/Button.stories.ts b/packages/ui/src/Button/Button.stories.ts similarity index 56% rename from packages/ui/src/Button.stories.ts rename to packages/ui/src/Button/Button.stories.ts index 01eaa8c..782dba4 100644 --- a/packages/ui/src/Button.stories.ts +++ b/packages/ui/src/Button/Button.stories.ts @@ -2,17 +2,13 @@ import type { Meta, StoryObj } from "@storybook/react"; import { Button } from "./Button"; -// More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction#default-export const meta = { title: "Example/Button2", component: Button, parameters: { - // Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/react/configure/story-layout layout: "centered", }, - // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/react/writing-docs/autodocs tags: ["autodocs"], - // More on argTypes: https://storybook.js.org/docs/react/api/argtypes argTypes: {}, } satisfies Meta; @@ -23,11 +19,13 @@ type Story = StoryObj; export const Primary: Story = { args: { variant: "primary", + children: "Awesome primary button", }, }; export const Secondary: Story = { args: { variant: "secondary", + children: "Awesome secondary button", }, }; diff --git a/packages/ui/src/Button.tsx b/packages/ui/src/Button/Button.tsx similarity index 73% rename from packages/ui/src/Button.tsx rename to packages/ui/src/Button/Button.tsx index c17be50..475e61a 100644 --- a/packages/ui/src/Button.tsx +++ b/packages/ui/src/Button/Button.tsx @@ -1,36 +1,35 @@ -import { FC } from "react"; import Link from "next/link"; import clsx from "clsx"; +import { ComponentPropsWithoutRef } from "react"; const variantStyles = { primary: "bg-zinc-800 font-semibold text-zinc-100 hover:bg-zinc-700 active:bg-zinc-800 active:text-zinc-100/70 dark:bg-zinc-700 dark:hover:bg-zinc-600 dark:active:bg-zinc-700 dark:active:text-zinc-100/70", secondary: "bg-zinc-50 font-medium text-zinc-900 hover:bg-zinc-100 active:bg-zinc-100 active:text-zinc-900/60 dark:bg-zinc-800/50 dark:text-zinc-300 dark:hover:bg-zinc-800 dark:hover:text-zinc-50 dark:active:bg-zinc-800/50 dark:active:text-zinc-50/70", -} as const; +}; -/** @todo Сделать нормальный тип */ -type Props = { - href?: string; +type ButtonProps = { variant?: keyof typeof variantStyles; - className?: string; -}; +} & ( + | (ComponentPropsWithoutRef<"button"> & { href?: undefined }) + | ComponentPropsWithoutRef +); -export const Button: FC = ({ +export const Button = ({ variant = "primary", className, - href, ...props -}) => { +}: ButtonProps) => { className = clsx( "inline-flex items-center gap-2 justify-center rounded-md py-2 px-3 text-sm outline-offset-2 transition active:transition-none", variantStyles[variant], className, ); - return href ? ( - - ) : ( + return typeof props.href === "undefined" ? ( - ) -} - -function clamp(number, a, b) { - let min = Math.min(a, b) - let max = Math.max(a, b) - return Math.min(Math.max(number, min), max) -} - -function AvatarContainer({ className, ...props }) { - return ( -
- ) -} - -function Avatar({ large = false, className, ...props }) { - return ( - - - - ) -} - -export function Header() { - let isHomePage = usePathname() === '/' - - let headerRef = useRef() - let avatarRef = useRef() - let isInitial = useRef(true) - - useEffect(() => { - let downDelay = avatarRef.current?.offsetTop ?? 0 - let upDelay = 64 - - function setProperty(property, value) { - document.documentElement.style.setProperty(property, value) - } - - function removeProperty(property) { - document.documentElement.style.removeProperty(property) - } - - function updateHeaderStyles() { - let { top, height } = headerRef.current.getBoundingClientRect() - let scrollY = clamp( - window.scrollY, - 0, - document.body.scrollHeight - window.innerHeight - ) - - if (isInitial.current) { - setProperty('--header-position', 'sticky') - } - - setProperty('--content-offset', `${downDelay}px`) - - if (isInitial.current || scrollY < downDelay) { - setProperty('--header-height', `${downDelay + height}px`) - setProperty('--header-mb', `${-downDelay}px`) - } else if (top + height < -upDelay) { - let offset = Math.max(height, scrollY - upDelay) - setProperty('--header-height', `${offset}px`) - setProperty('--header-mb', `${height - offset}px`) - } else if (top === 0) { - setProperty('--header-height', `${scrollY + height}px`) - setProperty('--header-mb', `${-scrollY}px`) - } - - if (top === 0 && scrollY > 0 && scrollY >= downDelay) { - setProperty('--header-inner-position', 'fixed') - removeProperty('--header-top') - removeProperty('--avatar-top') - } else { - removeProperty('--header-inner-position') - setProperty('--header-top', '0px') - setProperty('--avatar-top', '0px') - } - } - - function updateAvatarStyles() { - if (!isHomePage) { - return - } - - let fromScale = 1 - let toScale = 36 / 64 - let fromX = 0 - let toX = 2 / 16 - - let scrollY = downDelay - window.scrollY - - let scale = (scrollY * (fromScale - toScale)) / downDelay + toScale - scale = clamp(scale, fromScale, toScale) - - let x = (scrollY * (fromX - toX)) / downDelay + toX - x = clamp(x, fromX, toX) - - setProperty( - '--avatar-image-transform', - `translate3d(${x}rem, 0, 0) scale(${scale})` - ) - - let borderScale = 1 / (toScale / scale) - let borderX = (-toX + x) * borderScale - let borderTransform = `translate3d(${borderX}rem, 0, 0) scale(${borderScale})` - - setProperty('--avatar-border-transform', borderTransform) - setProperty('--avatar-border-opacity', scale === toScale ? 1 : 0) - } - - function updateStyles() { - updateHeaderStyles() - updateAvatarStyles() - isInitial.current = false - } - - updateStyles() - window.addEventListener('scroll', updateStyles, { passive: true }) - window.addEventListener('resize', updateStyles) - - return () => { - window.removeEventListener('scroll', updateStyles) - window.removeEventListener('resize', updateStyles) - } - }, [isHomePage]) - - return ( - <> -
- {isHomePage && ( - <> -
- -
-
- - -
-
-
- - )} -
- -
-
- {!isHomePage && ( - - - - )} -
-
- - -
-
-
- -
-
-
-
-
-
- {isHomePage && ( -
- )} - - ) -} diff --git a/packages/ui/src/Header/Header.tsx b/packages/ui/src/Header/Header.tsx new file mode 100644 index 0000000..44d34ec --- /dev/null +++ b/packages/ui/src/Header/Header.tsx @@ -0,0 +1,227 @@ +import { + ComponentPropsWithoutRef, + CSSProperties, + FC, + Fragment, + ReactNode, +} from "react"; +import Image from "next/image"; +import Link from "next/link"; +import { Popover, Transition } from "@headlessui/react"; +import clsx from "clsx"; + +import { Container } from "../Container/Container"; +import avatarImage from "./avatar.jpg"; +import { ThemeToggle } from "./ThemeToggle"; +import { NavItemLink } from "./NavItemLink"; + +const CloseIcon: FC> = (props) => { + return ( + + ); +}; + +function ChevronDownIcon(props: ComponentPropsWithoutRef<"svg">) { + return ( + + ); +} + +type MobileNavItemProps = { + href: string; + children: ReactNode; +}; +const MobileNavItem: FC = ({ href, children }) => { + return ( +
  • + + {children} + +
  • + ); +}; + +const MobileNavigation: FC> = ( + props, +) => { + return ( + + + Menu + + + + + + + + +
    + + + +

    + Navigation +

    +
    + +
    +
    +
    +
    + ); +}; + +const DesktopNavigation: FC> = (props) => { + return ( + + ); +}; + +const AvatarContainer: FC> = ({ + className, + ...props +}) => { + return ( +
    + ); +}; + +type AvatarProps = Omit, "href"> & { + large?: boolean; +}; +const Avatar: FC = ({ large = false, className, ...props }) => { + return ( + + + + ); +}; + +export const Header = () => { + return ( + <> +
    +
    + +
    +
    + + + +
    +
    + + +
    +
    +
    + +
    +
    +
    +
    +
    +
    + + ); +}; diff --git a/packages/ui/src/Header/NavItemLink.tsx b/packages/ui/src/Header/NavItemLink.tsx new file mode 100644 index 0000000..7e133e0 --- /dev/null +++ b/packages/ui/src/Header/NavItemLink.tsx @@ -0,0 +1,32 @@ +"use client"; + +import Link from "next/link"; +import clsx from "clsx"; +import { FC, ReactNode } from "react"; +import { usePathname } from "next/navigation"; + +type NavItemLinkProps = { + href: string; + children: ReactNode; +}; +/** Этот компонент точно всегда будет клиентским. Для смены состояния активности нужно быть гидрированным */ +export const NavItemLink: FC = ({ href, children }) => { + let isActive = usePathname() === href; + + return ( + + {children} + {isActive && ( + + )} + + ); +}; diff --git a/packages/ui/src/Header/ThemeToggle.tsx b/packages/ui/src/Header/ThemeToggle.tsx new file mode 100644 index 0000000..d4340c7 --- /dev/null +++ b/packages/ui/src/Header/ThemeToggle.tsx @@ -0,0 +1,58 @@ +import { useEffect, useState } from "react"; + +/** + * @todo нужно придумать какое-то решение без использования react-контекста, что бы эксплуатировать серверные компоненты. + */ +export const ThemeToggle = () => { + let [resolvedTheme, setTheme] = useState("dark"); + let otherTheme = resolvedTheme === "dark" ? "light" : "dark"; + let [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + return ( + + ); +}; + +function SunIcon(props: React.ComponentPropsWithoutRef<"svg">) { + return ( + + ); +} + +function MoonIcon(props: React.ComponentPropsWithoutRef<"svg">) { + return ( + + ); +} diff --git a/packages/ui/src/Header/avatar.jpg b/packages/ui/src/Header/avatar.jpg new file mode 100644 index 0000000..869f6f1 Binary files /dev/null and b/packages/ui/src/Header/avatar.jpg differ diff --git a/packages/ui/src/Layout.jsx b/packages/ui/src/Layout/Layout.tsx similarity index 67% rename from packages/ui/src/Layout.jsx rename to packages/ui/src/Layout/Layout.tsx index f5b7eaa..440c71c 100644 --- a/packages/ui/src/Layout.jsx +++ b/packages/ui/src/Layout/Layout.tsx @@ -1,7 +1,8 @@ -import { Footer } from '@/components/Footer' -import { Header } from '@/components/Header' +import { Footer } from "../Footer/Footer"; +import { Header } from "../Header/Header"; +import type { FC, ReactNode } from "react"; -export function Layout({ children }) { +export const Layout: FC<{ children: ReactNode }> = ({ children }) => { return ( <>
    @@ -15,5 +16,5 @@ export function Layout({ children }) {
    - ) -} + ); +}; diff --git a/packages/ui/src/Prose.jsx b/packages/ui/src/Prose.jsx deleted file mode 100644 index 30ac3df..0000000 --- a/packages/ui/src/Prose.jsx +++ /dev/null @@ -1,7 +0,0 @@ -import clsx from 'clsx' - -export function Prose({ className, ...props }) { - return ( -
    - ) -} diff --git a/packages/ui/src/Prose/Prose.tsx b/packages/ui/src/Prose/Prose.tsx new file mode 100644 index 0000000..9e18bec --- /dev/null +++ b/packages/ui/src/Prose/Prose.tsx @@ -0,0 +1,11 @@ +import clsx from "clsx"; +import type { ComponentPropsWithoutRef, FC } from "react"; + +export const Prose: FC> = ({ + className, + ...props +}) => { + return ( +
    + ); +}; diff --git a/packages/ui/src/Section.jsx b/packages/ui/src/Section/Section.tsx similarity index 69% rename from packages/ui/src/Section.jsx rename to packages/ui/src/Section/Section.tsx index d6c55df..66fdc20 100644 --- a/packages/ui/src/Section.jsx +++ b/packages/ui/src/Section/Section.tsx @@ -1,7 +1,11 @@ -import { useId } from 'react' +import { FC, ReactNode, useId } from "react"; -export function Section({ title, children }) { - let id = useId() +type SectionProps = { + title: string; + children: ReactNode; +}; +export const Section: FC = ({ title, children }) => { + let id = useId(); return (
    {children}
    - ) -} + ); +}; diff --git a/packages/ui/src/SimpleLayout.jsx b/packages/ui/src/SimpleLayout/SimpleLayout.tsx similarity index 50% rename from packages/ui/src/SimpleLayout.jsx rename to packages/ui/src/SimpleLayout/SimpleLayout.tsx index bbcb2e8..fb58faa 100644 --- a/packages/ui/src/SimpleLayout.jsx +++ b/packages/ui/src/SimpleLayout/SimpleLayout.tsx @@ -1,6 +1,16 @@ -import { Container } from '@/components/Container' +import { Container } from "../Container/Container"; +import type { FC, ReactNode } from "react"; -export function SimpleLayout({ title, intro, children }) { +type SimpleLayoutProps = { + title: string; + intro: string; + children?: ReactNode; +}; +export const SimpleLayout: FC = ({ + title, + intro, + children, +}) => { return (
    @@ -11,7 +21,7 @@ export function SimpleLayout({ title, intro, children }) { {intro}

    -
    {children}
    + {children &&
    {children}
    }
    - ) -} + ); +}; diff --git a/packages/ui/src/SocialIcons.jsx b/packages/ui/src/SocialIcons/SocialIcons.tsx similarity index 90% rename from packages/ui/src/SocialIcons.jsx rename to packages/ui/src/SocialIcons/SocialIcons.tsx index ff2b66a..db3d709 100644 --- a/packages/ui/src/SocialIcons.jsx +++ b/packages/ui/src/SocialIcons/SocialIcons.tsx @@ -1,21 +1,23 @@ -export function TwitterIcon(props) { +import type { ComponentPropsWithoutRef, FC } from "react"; + +export const TwitterIcon: FC> = (props) => { return ( - ) -} + ); +}; -export function InstagramIcon(props) { +export const InstagramIcon: FC> = (props) => { return ( - ) -} + ); +}; -export function GitHubIcon(props) { +export const GitHubIcon: FC> = (props) => { return ( - ) -} + ); +}; -export function LinkedInIcon(props) { +export const LinkedInIcon: FC> = (props) => { return ( - ) -} + ); +}; diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json index 2ed2f12..7a61802 100644 --- a/packages/ui/tsconfig.json +++ b/packages/ui/tsconfig.json @@ -1,6 +1,6 @@ { "extends": "@sneg240/tsconfig/react-library.json", - "include": [".", "./.storybook"], + "include": ["src", ".storybook", "next-env.d.ts"], "compilerOptions": { "module": "esnext" },