diff --git a/.env.sample b/.env.sample index 9d6267dc..39177e96 100644 --- a/.env.sample +++ b/.env.sample @@ -25,3 +25,4 @@ SUPABASE_SERVICE_KEY='' ################################################################################ NEXT_PUBLIC_ALCHEMY_ID='' +NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID='' diff --git a/.eslintrc.js b/.eslintrc.js index dd29da1a..3f51273a 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -11,6 +11,7 @@ module.exports = { 'next', 'prettier', ], + ignorePatterns: ['/generated/*'], overrides: [], parser: '@typescript-eslint/parser', parserOptions: { diff --git a/.vscode/settings.json b/.vscode/settings.json index 8e779832..a6fd7139 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,7 +3,7 @@ "editor.rulers": [80, 100], "editor.tabSize": 2, "editor.codeActionsOnSave": { - "source.fixAll.eslint": true + "source.fixAll.eslint": "explicit" }, "editor.formatOnSave": true, "eslint.validate": ["javascript", "javascriptreact"], diff --git a/LICENSE b/LICENSE index b13261a5..af5d5fb8 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2023 5/9 +Copyright (c) 2024 5/9 Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 9a921018..46a0a815 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -[**5/9**](https://twitter.com/fiveoutofnine)'s personal website: [**fiveoutofnine.com**](https://fiveoutofnine.com). +[**5/9**](https://x.com/fiveoutofnine)'s personal website: [**fiveoutofnine.com**](https://fiveoutofnine.com). ## Structure @@ -9,10 +9,19 @@ The site is divided into 4 categories: - [`/design`](https://fiveoutofnine.com/design), documentation for my design system. - Other projects, pages, etc. -## Running Locally +## Local development + +### Installation ```sh git clone https://github.com/fiveoutofnine/www.git pnpm install pnpm run dev ``` + +### Building + +```sh +supabase gen types typescript --project-id $PROJECT_ID > generated/database.types.ts +pnpm dlx next build +``` diff --git a/components/pages/home/header.tsx b/app/(components)/header.tsx similarity index 70% rename from components/pages/home/header.tsx rename to app/(components)/header.tsx index 924536be..16332189 100644 --- a/components/pages/home/header.tsx +++ b/app/(components)/header.tsx @@ -1,12 +1,11 @@ -import type { FC } from 'react'; - -import { Github, Twitter } from 'lucide-react'; +import { Github } from 'lucide-react'; import FiveoutofnineAvatar from '@/components/common/fiveoutofnine-avatar'; +import LogoIcon from '@/components/common/logo-icon'; import LinkPreview from '@/components/templates/link-preview'; import { Button } from '@/components/ui'; -const FiveoutofnineHeader: FC = () => { +const FiveoutofnineHeader: React.FC = () => { return (
@@ -18,16 +17,6 @@ const FiveoutofnineHeader: FC = () => {
5/9
Working on{' '} - - Waterfall - {' '} - and{' '} { {/* Links (desktop) */}
- @@ -80,6 +64,4 @@ const FiveoutofnineHeader: FC = () => { ); }; -FiveoutofnineHeader.displayName = 'FiveoutofnineHeader'; - export default FiveoutofnineHeader; diff --git a/components/pages/home/featured-works/works/bit-twiddling.tsx b/app/(components)/works/bit-twiddling.tsx similarity index 83% rename from components/pages/home/featured-works/works/bit-twiddling.tsx rename to app/(components)/works/bit-twiddling.tsx index c37b19bd..c3d3c338 100644 --- a/components/pages/home/featured-works/works/bit-twiddling.tsx +++ b/app/(components)/works/bit-twiddling.tsx @@ -1,11 +1,9 @@ -import type { FC } from 'react'; - import { ExternalLink, PenTool } from 'lucide-react'; import FeatureDisplayMinimal from '@/components/templates/feature-display-minimal'; import { Button } from '@/components/ui'; -const BitTwiddlingFeature: FC = () => { +const BitTwiddlingFeature: React.FC = () => { return ( { ); }; -BitTwiddlingFeature.displayName = 'BitTwiddlingFeature'; - export default BitTwiddlingFeature; diff --git a/components/pages/home/featured-works/works/chess.tsx b/app/(components)/works/chess.tsx similarity index 60% rename from components/pages/home/featured-works/works/chess.tsx rename to app/(components)/works/chess.tsx index 67487cdb..4925b8d8 100644 --- a/components/pages/home/featured-works/works/chess.tsx +++ b/app/(components)/works/chess.tsx @@ -1,20 +1,22 @@ -import { type FC, useState } from 'react'; +'use client'; + +import { useState } from 'react'; import clsx from 'clsx'; import { ChevronLeft, ChevronRight } from 'lucide-react'; -import type { ChessFeature } from '@/lib/types/chess'; import { CHESS_NFT_FALLBACK } from '@/lib/constants/chess-nfts'; +import type { ChessFeature } from '@/lib/types/chess'; import ChessPiece from '@/components/common/chess-piece'; import CategoryTag from '@/components/templates/category-tag'; import FeatureDisplay from '@/components/templates/feature-display'; import { Button, IconButton, Tooltip } from '@/components/ui'; -const ChessFeature: FC = () => { +const ChessFeature: React.FC = () => { return ( { } button={ - - - + } tags={[, ]} > @@ -48,7 +48,7 @@ const ChessFeature: FC = () => { ); }; -const ChessFeatureDetail: FC = () => { +const ChessFeatureDetail: React.FC = () => { const [nft, setNft] = useState(CHESS_NFT_FALLBACK); const fetchNextMove = async () => { @@ -75,44 +75,56 @@ const ChessFeatureDetail: FC = () => { return (
- + + aria-label="View NFT mint transaction on Etherscan" + > +
` with an ID to tighten the selector. + // There have been issues with `section` selectors being + // erroneously applied to unexpected elements (e.g. extensions). + .replace('
', '
') + .replace('}section{', '}section#__fiveoutofnine-chess-display{'), + }} + /> + - + - - - + + + + - - + + +
@@ -134,34 +146,34 @@ const ChessFeatureDetail: FC = () => {
1 ? 'bg-purple-9' : nft.userMove.from === index - ? 'bg-blue-3' - : nft.userMove.to === index - ? 'bg-blue-9' - : nft.contractMove.from === index - ? 'bg-red-3' - : nft.contractMove.to === index - ? 'bg-red-9' - : (2709 >> index % 12) & 1 - ? 'bg-gray-9' - : 'bg-gray-4', + ? 'bg-blue-3' + : nft.userMove.to === index + ? 'bg-blue-9' + : nft.contractMove.from === index + ? 'bg-red-3' + : nft.contractMove.to === index + ? 'bg-red-9' + : (2709 >> index % 12) & 1 + ? 'bg-gray-9' + : 'bg-gray-4', )} > {piece === '1' || piece === '9' ? ( - + ) : piece === '2' || piece === 'a' ? ( - + ) : piece === '3' || piece === 'b' ? ( - + ) : piece === '4' || piece === 'c' ? ( - + ) : piece === '5' || piece === 'd' ? ( - + ) : piece === '6' || piece === 'e' ? ( - + ) : null}
); @@ -169,14 +181,14 @@ const ChessFeatureDetail: FC = () => {
-
User
+
User
{getPieceNotation(nft.userMove.from)} ->{' '} {getPieceNotation(nft.userMove.to)}
-
Contract
+
Contract
{getPieceNotation(nft.contractMove.from)} ->{' '} {getPieceNotation(nft.contractMove.to)} diff --git a/components/pages/home/featured-works/works/colormap-registry.tsx b/app/(components)/works/colormap-registry/detail.tsx similarity index 55% rename from components/pages/home/featured-works/works/colormap-registry.tsx rename to app/(components)/works/colormap-registry/detail.tsx index 0e36eea4..f50547fd 100644 --- a/components/pages/home/featured-works/works/colormap-registry.tsx +++ b/app/(components)/works/colormap-registry/detail.tsx @@ -1,42 +1,19 @@ -import { type FC, type PointerEvent, type UIEvent, useCallback, useState } from 'react'; +'use client'; +import { useCallback, useState } from 'react'; + +import ColormapRegistryFeatureDetailModal from './modal'; import { TooltipWithBounds, useTooltip, useTooltipInPortal } from '@visx/tooltip'; import clsx from 'clsx'; import { LayoutGroup, motion } from 'framer-motion'; -import { ArrowLeft, ExternalLink, Github } from 'lucide-react'; +import { ArrowLeft, CaseLower, CaseUpper, Copy } from 'lucide-react'; import { COLORMAPS } from '@/lib/constants/colormaps'; import { getColormapValue } from '@/lib/utils'; -import CategoryTag from '@/components/templates/category-tag'; -import FeatureDisplay from '@/components/templates/feature-display'; -import { Button, IconButton } from '@/components/ui'; - -const ColormapRegistryFeature: FC = () => { - return ( - } - button={ - - } - tags={[]} - > - - - ); -}; +import { Dropdown, IconButton, toast } from '@/components/ui'; -const ColormapRegistryFeatureDetail: FC = () => { +const ColormapRegistryFeatureDetail: React.FC = () => { const [selected, setSelected] = useState(); const [scrollIsAtTop, setScrollIsAtTop] = useState(true); const [scrollIsAtBottom, setScrollIsAtBottom] = useState(false); @@ -49,19 +26,38 @@ const ColormapRegistryFeatureDetail: FC = () => { const { showTooltip, hideTooltip, tooltipOpen, tooltipLeft } = useTooltip({ tooltipOpen: true, tooltipLeft: undefined, - tooltipTop: 62, + // `(border_offset) + (tooltip_height + colormap_height) / 2 = -2 + (12 + 110) / 2`. + tooltipTop: 65, }); const handlePointerMove = useCallback( - (event: PointerEvent) => { - // Coordinates should be relative to the container + (event: React.PointerEvent) => { + // Coordinates should be relative to the container. const tooltipLeft = ('clientX' in event ? event.clientX : 0) - containerBounds.left; - showTooltip({ tooltipLeft, tooltipTop: 62 }); + showTooltip({ tooltipLeft, tooltipTop: 65 }); }, [showTooltip, containerBounds], ); - const handleScroll = (event: UIEvent) => { + const copyColorValue = () => { + const position = (0xff * (tooltipLeft ?? 0)) / containerBounds.width; + const tooltipColor = getColormapValue(COLORMAPS[selected!].data, position); + const tooltipColorHex = + '#' + + tooltipColor.r.toString(16).padStart(2, '0') + + tooltipColor.g.toString(16).padStart(2, '0') + + tooltipColor.b.toString(16).padStart(2, '0'); + + navigator.clipboard.writeText(tooltipColorHex); + toast({ + intent: 'success', + title: 'Copied color value to clipboard!', + description: `${tooltipColorHex} at position ${Math.round(position)}.`, + hasCloseButton: true, + }); + }; + + const handleScroll = (event: React.UIEvent) => { const target = event.target as HTMLFieldSetElement; const scrollTop = target.scrollTop; const scrollHeight = target.scrollHeight; @@ -81,10 +77,8 @@ const ColormapRegistryFeatureDetail: FC = () => { tabIndex={-1} > {COLORMAPS.map((colormap, index) => { - const tooltipColor = getColormapValue( - colormap.data, - (0xff * (tooltipLeft ?? 0)) / containerBounds.width, - ); + const position = (0xff * (tooltipLeft ?? 0)) / containerBounds.width; + const tooltipColor = getColormapValue(colormap.data, position); const tooltipColorHex = '#' + tooltipColor.r.toString(16).padStart(2, '0') + @@ -130,6 +124,7 @@ const ColormapRegistryFeatureDetail: FC = () => { ref={containerRef} onPointerMove={handlePointerMove} onMouseLeave={hideTooltip} + onClick={copyColorValue} > { initial={{ height: 0, opacity: 0 }} animate={{ height: 32, opacity: 1 }} transition={{ type: 'spring', delay: 0.1, duration: 0.25 }} + onClick={(e) => e.stopPropagation()} > { > - {colormap.name} - - - + + + + + + + Copy ID as + {[ + { + label: 'bytes8', + value: colormap.hash.slice(0, 18), + icon: , + }, + { + label: 'bytes32', + value: colormap.hash, + icon: , + }, + ].map(({ label, value, icon }) => ( + { + navigator.clipboard.writeText(value); + toast({ + intent: 'success', + title: 'Copied ID to clipboard!', + description: `${value}`, + hasCloseButton: true, + }); + }} + > + + {label} + + + ))} + + + + {tooltipOpen && tooltipLeft !== undefined ? ( <> -
+
+
+
{ 'pointer-events-none absolute left-0 top-0 h-6 w-full bg-gradient-to-b from-gray-3 transition-opacity', scrollIsAtTop || selected !== undefined ? 'opacity-0' : 'opacity-100', )} + aria-hidden={true} /> {/* Bottom gradient to hide overflow */}
{ 'pointer-events-none absolute bottom-0 left-0 h-6 w-full bg-gradient-to-t from-gray-3 transition-opacity', scrollIsAtBottom || selected !== undefined ? 'opacity-0' : 'opacity-100', )} + aria-hidden={true} />
); }; -export default ColormapRegistryFeature; +export default ColormapRegistryFeatureDetail; diff --git a/app/(components)/works/colormap-registry/index.tsx b/app/(components)/works/colormap-registry/index.tsx new file mode 100644 index 00000000..8fe23220 --- /dev/null +++ b/app/(components)/works/colormap-registry/index.tsx @@ -0,0 +1,32 @@ +import ColormapRegistryFeatureDetail from './detail'; +import { ExternalLink, Github } from 'lucide-react'; + +import CategoryTag from '@/components/templates/category-tag'; +import FeatureDisplay from '@/components/templates/feature-display'; +import { Button } from '@/components/ui'; + +const ColormapRegistryFeature: React.FC = () => { + return ( + } + button={ + + } + tags={[]} + > + + + ); +}; + +export default ColormapRegistryFeature; diff --git a/app/(components)/works/colormap-registry/modal.tsx b/app/(components)/works/colormap-registry/modal.tsx new file mode 100644 index 00000000..db7986b9 --- /dev/null +++ b/app/(components)/works/colormap-registry/modal.tsx @@ -0,0 +1,157 @@ +import { useState } from 'react'; + +import clsx from 'clsx'; +import { Fingerprint } from 'lucide-react'; + +import { COLORMAPS } from '@/lib/constants/colormaps'; +import { useMediaQuery } from '@/lib/hooks/useMediaQuery'; + +import { Badge, CodeBlock, Dialog, Drawer, IconButton, Tooltip } from '@/components/ui'; + +// ----------------------------------------------------------------------------- +// Constants +// ----------------------------------------------------------------------------- + +const SEGMENT_DATA_DEFINITION = + 'The data the registry uses to linearly interpolate the color values for R, G, and B.'; + +// ----------------------------------------------------------------------------- +// Props +// ----------------------------------------------------------------------------- + +type ColormapRegistryFeatureDetailModal = { + data: (typeof COLORMAPS)[number]; +}; + +// ----------------------------------------------------------------------------- +// Component +// ----------------------------------------------------------------------------- + +export const ColormapRegistryFeatureDetailModal: React.FC = ({ + data, +}) => { + const [open, setOpen] = useState(false); + const isSmallScreen = useMediaQuery('(max-width: 768px)'); // `md` breakpoint. + + if (!isSmallScreen) { + return ( + + + + + + + + + {/* Prevent the tooltip from getting focused upon dialog open/close. */} + e.preventDefault()} + onCloseAutoFocus={(e) => e.preventDefault()} + > + {data.name} + + The{' '} + + segment data + {' '} + definition for the colormap. + + + + + {data.data.r.length} red + + + {data.data.g.length} green + + + {data.data.b.length} blue + + + + + ); + } + + return ( + + + + + + + + + {/* Prevent the tooltip from getting focused upon drawer open/close. */} + e.preventDefault()} + onCloseAutoFocus={(e) => e.preventDefault()} + > + {data.name} + + The{' '} + + segment data + {' '} + definition for the colormap. + + + + + {data.data.r.length} red + + + {data.data.g.length} green + + + {data.data.b.length} blue + + + + + ); +}; + +const ColormapRegistryFeatureDetailModalCodeBlock: React.FC<{ + data: (typeof COLORMAPS)[number]; + isSmallScreen: boolean; +}> = ({ data, isSmallScreen }) => { + return ( + {`const data = ${JSON.stringify(data.data, null, 2)};`} + ); +}; + +export default ColormapRegistryFeatureDetailModal; diff --git a/components/pages/home/featured-works/works/cool-contracts.tsx b/app/(components)/works/cool-contracts.tsx similarity index 82% rename from components/pages/home/featured-works/works/cool-contracts.tsx rename to app/(components)/works/cool-contracts.tsx index c8a15040..4e23c2a8 100644 --- a/components/pages/home/featured-works/works/cool-contracts.tsx +++ b/app/(components)/works/cool-contracts.tsx @@ -1,11 +1,9 @@ -import type { FC } from 'react'; - import { ExternalLink, Github } from 'lucide-react'; import FeatureDisplayMinimal from '@/components/templates/feature-display-minimal'; import { Button } from '@/components/ui'; -const CoolContractsFeature: FC = () => { +const CoolContractsFeature: React.FC = () => { return ( { ); }; -CoolContractsFeature.displayName = 'CoolContractsFeature'; - export default CoolContractsFeature; diff --git a/components/pages/home/featured-works/works/running/bar-chart.tsx b/app/(components)/works/running/bar-chart.tsx similarity index 74% rename from components/pages/home/featured-works/works/running/bar-chart.tsx rename to app/(components)/works/running/bar-chart.tsx index 03bcd280..f4baed56 100644 --- a/components/pages/home/featured-works/works/running/bar-chart.tsx +++ b/app/(components)/works/running/bar-chart.tsx @@ -1,4 +1,6 @@ -import { type FC, Fragment, useEffect, useMemo, useState } from 'react'; +'use client'; + +import { Fragment, useEffect, useMemo, useState } from 'react'; import { Info } from 'lucide-react'; import { @@ -24,22 +26,25 @@ import { Tooltip } from '@/components/ui'; type RunningFeatureDetailBarChartProps = { mileageLogs: MileageLog[]; unit: LengthUnit; + lastUpdated?: Date; }; // ----------------------------------------------------------------------------- // Component // ----------------------------------------------------------------------------- -const RunningFeatureDetailBarChart: FC = ({ +const RunningFeatureDetailBarChart: React.FC = ({ mileageLogs, unit, + lastUpdated, }) => { // 20.43 is a precomputed value to fit the width when the unit is set to km. const [yAxisWidth, setYAxisWidth] = useState(20.43); - const currentDay = new Date().getUTCDate(); - const currentMonth = new Date().getUTCMonth(); - const currentYear = new Date().getUTCFullYear(); + const currentDate = lastUpdated ? new Date(lastUpdated) : new Date(); + const currentDay = currentDate.getUTCDate(); + const currentMonth = currentDate.getUTCMonth(); + const currentYear = currentDate.getUTCFullYear(); // Calculate the average distance per day for each month. const [data, totalDays] = useMemo(() => { @@ -88,25 +93,33 @@ const RunningFeatureDetailBarChart: FC = ({ return ( -
+ - {formatValueToPrecision(total, 2, false)} + {formatValueToPrecision(total, 2, false)} {unitName + ' '} {unit.description ? ( - - - - + + ) : null} -
-
+ +
{data.length > 0 ? `${data[0].date.toLocaleDateString('en-US', { month: 'short', @@ -129,19 +142,17 @@ const RunningFeatureDetailBarChart: FC = ({ tickLine={false} tickSize={4} /> - { - formatValueToPrecision(value, 1, true)} - tickLine={false} - tickSize={4} - /> - } + formatValueToPrecision(value, 1, true)} + tickLine={false} + tickSize={4} + /> { const monthName = label @@ -155,7 +166,7 @@ const RunningFeatureDetailBarChart: FC = ({ tabIndex={-1} >
- + {/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */} {/* @ts-ignore */} {formatValueToPrecision(payload[0].value, 2, false)} @@ -169,10 +180,13 @@ const RunningFeatureDetailBarChart: FC = ({ /> ( )} /> diff --git a/app/(components)/works/running/detail.tsx b/app/(components)/works/running/detail.tsx new file mode 100644 index 00000000..2b43c1cd --- /dev/null +++ b/app/(components)/works/running/detail.tsx @@ -0,0 +1,108 @@ +'use client'; + +import { useState } from 'react'; + +import RunningFeatureDetailBarChart from './bar-chart'; +import RunningFeatureDetailHeatmap from './heatmap'; +import clsx from 'clsx'; +import { ArrowRightLeft, BarChart, Grid } from 'lucide-react'; + +import { LENGTH_UNITS } from '@/lib/constants/units'; +import type { MileageLog } from '@/lib/types/running'; + +import { IconButton, Tabs, Tooltip } from '@/components/ui'; + +// ----------------------------------------------------------------------------- +// Props +// ----------------------------------------------------------------------------- + +type RunningFeatureDetailProps = { + mileageLogs: MileageLog[]; + runningLogs: MileageLog[]; + lastUpdated?: Date; +}; + +// ----------------------------------------------------------------------------- +// Component +// ----------------------------------------------------------------------------- + +const RunningFeatureDetail: React.FC = ({ + mileageLogs, + runningLogs, + lastUpdated, +}) => { + const [unitIndex, setUnitIndex] = useState(0); + + const handleUnitChange = () => { + setUnitIndex((unitIndex + 1) % LENGTH_UNITS.length); + }; + + return ( + +
+ + {[ + { + value: 'running-bar', + label: 'Bar graph', + icon: , + 'aria-label': 'Running bar graph', + }, + { + value: 'running-heatmap', + label: 'Heatmap', + icon: , + 'aria-label': 'Running heatmap', + }, + ].map(({ value, label, icon, ...rest }) => ( + + + {icon} + + + ))} + +
+ + + + + +
+
+ {[ + { + value: 'running-bar', + className: 'p-2', + children: ( + + ), + }, + { + value: 'running-heatmap', + children: ( + + ), + }, + ].map(({ className, value, children }) => ( + +
+ {children} +
+
+ ))} +
+ ); +}; + +export default RunningFeatureDetail; diff --git a/components/pages/home/featured-works/works/running/heatmap.tsx b/app/(components)/works/running/heatmap.tsx similarity index 94% rename from components/pages/home/featured-works/works/running/heatmap.tsx rename to app/(components)/works/running/heatmap.tsx index c7573633..13d7ac57 100644 --- a/components/pages/home/featured-works/works/running/heatmap.tsx +++ b/app/(components)/works/running/heatmap.tsx @@ -1,12 +1,6 @@ -import { - type FC, - Fragment, - type PointerEvent, - type UIEvent, - useCallback, - useMemo, - useState, -} from 'react'; +'use client'; + +import { Fragment, useCallback, useMemo, useState } from 'react'; import { TooltipWithBounds, useTooltip, useTooltipInPortal } from '@visx/tooltip'; import clsx from 'clsx'; @@ -34,7 +28,7 @@ type RunningFeatureDetailHeatmapProps = { // Component // ----------------------------------------------------------------------------- -const RunningFeatureDetailHeatmap: FC = ({ +const RunningFeatureDetailHeatmap: React.FC = ({ runningLogs, unit, }) => { @@ -62,7 +56,7 @@ const RunningFeatureDetailHeatmap: FC = ({ }); const handlePointerMove = useCallback( - (event: PointerEvent) => { + (event: React.PointerEvent) => { // Coordinates should be relative to the container const tooltipLeft = ('clientX' in event ? event.clientX : 0) - containerBounds.left; const tooltipTop = ('clientY' in event ? event.clientY : 0) - containerBounds.top; @@ -83,7 +77,7 @@ const RunningFeatureDetailHeatmap: FC = ({ [containerBounds.left, containerBounds.top, unit.scalar, showTooltip], ); - const handleScroll = (event: UIEvent) => { + const handleScroll = (event: React.UIEvent) => { const target = event.target as HTMLFieldSetElement; const scrollLeft = target.scrollLeft; const scrollWidth = target.scrollWidth; @@ -152,13 +146,13 @@ const RunningFeatureDetailHeatmap: FC = ({
- {formatValueToPrecision(total, 2, false)} + {formatValueToPrecision(total, 2, false)} {`${unit.spaceBefore ? ' ' : ''}${unit.name}`}{' '} {unit.description ? ( - + ) : null} @@ -173,7 +167,7 @@ const RunningFeatureDetailHeatmap: FC = ({ aria-label="Select year to view running logs from" > {yearsLogged.map((year) => ( - {year} + ))}
@@ -211,7 +205,7 @@ const RunningFeatureDetailHeatmap: FC = ({ } y={12} fontSize={12} - className="fill-gray-11" + className="select-none fill-gray-11" > {firstDayOfMonth.toLocaleDateString('en-US', { month: 'short', @@ -267,7 +261,7 @@ const RunningFeatureDetailHeatmap: FC = ({ top={tooltipTop} left={tooltipLeft} offsetLeft={-SQUARE_SIZE} - className="pointer-events-none absolute left-0 top-0 z-50 rounded border border-gray-6 bg-gray-3 px-2 py-1 text-sm text-gray-12 shadow-md transition-colors" + className="pointer-events-none absolute left-0 top-0 z-50 rounded border border-gray-6 bg-gray-3 px-2 py-1 text-sm font-light text-gray-12 shadow-md transition-colors" style={{}} > {JSON.parse(tooltipData).value} @@ -288,6 +282,7 @@ const RunningFeatureDetailHeatmap: FC = ({ 'pointer-events-none absolute bottom-0 left-0 h-[112px] w-4 bg-gradient-to-r from-gray-3 transition-opacity', scrollIsAtLeft ? 'opacity-0' : 'opacity-100', )} + aria-hidden={true} /> {/* Right gradient to hide overflow */}
= ({ 'pointer-events-none absolute bottom-0 right-0 h-[112px] w-4 bg-gradient-to-l from-gray-3 transition-opacity', scrollIsAtRight ? 'opacity-0' : 'opacity-100', )} + aria-hidden={true} />
@@ -310,7 +306,7 @@ const RunningFeatureDetailHeatmap: FC = ({ diff --git a/app/(components)/works/running/index.tsx b/app/(components)/works/running/index.tsx new file mode 100644 index 00000000..a4f3de3e --- /dev/null +++ b/app/(components)/works/running/index.tsx @@ -0,0 +1,95 @@ +import RunningFeatureDetail from './detail'; +import type { Database } from '@/generated/database.types'; +import { createClient } from '@supabase/supabase-js'; +import { Footprints } from 'lucide-react'; + +import type { MileageLog } from '@/lib/types/running'; + +import FeatureDisplay from '@/components/templates/feature-display'; + +// ----------------------------------------------------------------------------- +// Services +// ----------------------------------------------------------------------------- + +const supabase = createClient( + process.env.NEXT_PUBLIC_SUPABASE_URL, + process.env.SUPABASE_SERVICE_KEY, +); + +// ----------------------------------------------------------------------------- +// Component +// ----------------------------------------------------------------------------- + +const RunningFeature: React.FC = async () => { + // --------------------------------------------------------------------------- + // Fetch mileage logs + // --------------------------------------------------------------------------- + + const { data: mileageLogData } = await supabase + .from('monthly_mileage') + .select('time, value') + .order('time', { ascending: false }) + .limit(12); + + const mileageLogs: MileageLog[] = (mileageLogData ?? []) + .map((item) => ({ + date: item.time, + value: item.value ?? 0, + })) + .sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()); + + const { data: lastUpdated } = await supabase + .from('hourly_mileage') + .select('time') + .order('time', { ascending: false }) + .limit(1) + .single(); + + // --------------------------------------------------------------------------- + // Fetch daily running logs + // --------------------------------------------------------------------------- + + const response = await fetch( + `https://content-sheets.googleapis.com/v4/spreadsheets/${process.env.GOOGLE_SHEETS_ID_RUNNING}/values/Log!B5:C?` + + new URLSearchParams({ + valueRenderOption: 'FORMATTED_VALUE', + dateTimeRenderOption: 'FORMATTED_STRING', + majorDimension: 'ROWS', + key: process.env.GOOGLE_SHEETS_API_KEY_RUNNING, + }), + { + // Revalidate once a day. + next: { revalidate: 86400 }, + }, + ); + + const runningLogs: MileageLog[] = ( + (response.ok ? (await response.json()).values ?? [] : []) as string[][] + ) + .filter((item) => item.length == 2) + .map((log) => ({ + date: log[0], + value: parseFloat(log[1]), + })); + + // --------------------------------------------------------------------------- + // Component + // --------------------------------------------------------------------------- + + return ( + } + > + + + ); +}; + +export default RunningFeature; diff --git a/app/(components)/works/tx-dot-cool/detail.tsx b/app/(components)/works/tx-dot-cool/detail.tsx new file mode 100644 index 00000000..ffc529f9 --- /dev/null +++ b/app/(components)/works/tx-dot-cool/detail.tsx @@ -0,0 +1,291 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; + +import clsx from 'clsx'; +import { ConnectKitButton } from 'connectkit'; +import { ArrowUp, ExternalLink, MessageCircle, Wallet } from 'lucide-react'; +import { + useAccount, + useChainId, + useSendTransaction, + useSwitchChain, + useWaitForTransactionReceipt, +} from 'wagmi'; + +import ChainIcon from '@/components/common/chain-icon'; +import { Badge, Button, Dropdown, IconButton, toast, Tooltip } from '@/components/ui'; + +// ----------------------------------------------------------------------------- +// Constants +// ----------------------------------------------------------------------------- + +/** + * On-chain messages + * [`0xA85572Cd96f1643458f17340b6f0D6549Af482F5`](https://etherscan.io/address/0xA85572Cd96f1643458f17340b6f0D6549Af482F5) + * (fiveoutofnine.eth) signed to itself on Ethereum. + */ +/* eslint-disable prettier/prettier */ +const FIVEOUTOFNINE_MESSAGES = [ + { content: 'what\'s up I\'m 5/9', txHash: '0x6f4760c765e4c8be8f770e8a7e76310cd4ddda401a4eda6f4dac93ec768f8d76' }, + { content: 'send me messages', txHash: '0x2ea7a28b99903556811918fdf84104b72d0d572910220cfef78bb56de6470e41' }, + { content: 'or ETH', txHash: '0x4f1f8421221336e5a62bf2687f9db85c49bee06a3a25b1a6e3e79c72bb02caec' }, +]; + +// ----------------------------------------------------------------------------- +// Component +// ----------------------------------------------------------------------------- + +const TxDotCoolFeatureDetail: React.FC = () => { + const [mounted, setMounted] = useState(false); + const [userMessage, setUserInput] = useState(''); + const { address } = useAccount(); + const chainId = useChainId(); + const { chains, switchChain } = useSwitchChain(); + + const messagesEndRef = useRef(null); + + const { data, sendTransaction } = useSendTransaction(); + + const { isLoading } = useWaitForTransactionReceipt({ hash: data }); + + // Scroll messages into view on load and set mounted. + useEffect( + () => { + setMounted(true); + messagesEndRef.current?.scrollIntoView({ block: 'nearest', inline: 'nearest' }); + }, + [], + ); + + // Default to `etherscan.io` for block explorer. + const blockExplorer = + chains.find((chain) => chain.id === chainId)?.blockExplorers?.default.url ?? + 'https://etherscan.io'; + + return ( +
+
+ {/* Header */} +
+
+ +
fiveoutofnine.eth
+
+ + + +
+ {/* Chat */} +
+ {FIVEOUTOFNINE_MESSAGES.map((message) => ( + + {message.content} + + ))} +
+
+
+ + {/* Message input */} +
+
+
e.preventDefault()}> + setUserInput(e.target.value)} + autoComplete="off" + /> + {/* Overflow gradient */} +
+ + {/* Chain selector */} + {address && mounted ? ( + // Only display if address is connected. + + + + + + + + + + + Send message on + {chains + .toSorted((a, b) => a.id - b.id) + .map((chain) => { + const selected = chainId === chain.id; + + return ( + + + + ); + })} + + + + ) : null} + + {!address || !mounted ? ( + // Connect wallet + + {({ show }) => ( + + + + + + )} + + ) : ( + // Send message + { + e.preventDefault(); + sendTransaction( + { + chainId, + to: process.env.NEXT_PUBLIC_FIVEOUTOFNINE_ADDRESS, + data: `0x${userMessage + .split('') + .map((_, i) => userMessage.charCodeAt(i).toString(16)) + .join('')}`, + }, + { + onError(error) { + toast({ + title: 'Transaction fail', + description: error.message, + intent: 'fail', + hasCloseButton: true, + }); + }, + onSuccess(hash) { + toast({ + title: 'Transaction sent', + description: 'Your message has been sent to fiveoutofnine.eth.', + intent: 'info', + action: ( + + ), + hasCloseButton: true, + }); + setUserInput(''); + }, + onSettled(data, error) { + if (data && error) { + toast({ + title: 'Transaction fail', + description: error.message, + intent: 'fail', + }); + } else if (data) { + toast({ + title: 'Message sent', + description: 'Message sent to fiveoutonine.eth!', + intent: 'success', + action: ( + + ), + hasCloseButton: true, + }); + } + }, + }, + ); + }} + type="submit" + > + + + )} + +
+
+
+ ); +}; + +export default TxDotCoolFeatureDetail; diff --git a/app/(components)/works/tx-dot-cool/index.tsx b/app/(components)/works/tx-dot-cool/index.tsx new file mode 100644 index 00000000..61321971 --- /dev/null +++ b/app/(components)/works/tx-dot-cool/index.tsx @@ -0,0 +1,43 @@ +import TxDotCoolFeatureDetail from './detail'; +import { ExternalLink } from 'lucide-react'; + +import CategoryTag from '@/components/templates/category-tag'; +import FeatureDisplay from '@/components/templates/feature-display'; +import { Button } from '@/components/ui'; + +const TxDotCoolFeature: React.FC = () => { + return ( + + tx.cool + tx.cool's logo + + + } + button={ + + } + tags={[]} + > + + + ); +}; + +export default TxDotCoolFeature; diff --git a/components/pages/home/featured-works/works/typing/index.tsx b/app/(components)/works/typing/detail.tsx similarity index 71% rename from components/pages/home/featured-works/works/typing/index.tsx rename to app/(components)/works/typing/detail.tsx index 8084bd4a..fee1113c 100644 --- a/components/pages/home/featured-works/works/typing/index.tsx +++ b/app/(components)/works/typing/detail.tsx @@ -1,42 +1,33 @@ -import { type ChangeEvent, type FC, useEffect, useMemo, useRef, useState } from 'react'; +'use client'; + +import { useEffect, useMemo, useRef, useState } from 'react'; -import TypingFeatureDetailTimer from './timer'; import clsx from 'clsx'; -import { ChevronRight, ExternalLink, Keyboard, RotateCw } from 'lucide-react'; +import { ChevronRight, RotateCw } from 'lucide-react'; import { SHORT_QUOTES } from '@/lib/constants/typing'; import FiveoutofnineAvatar from '@/components/common/fiveoutofnine-avatar'; -import CategoryTag from '@/components/templates/category-tag'; -import FeatureDisplay from '@/components/templates/feature-display'; -import { Button, IconButton, Tooltip } from '@/components/ui'; +import { IconButton, Tooltip } from '@/components/ui'; -const TypingFeature: FC = () => { - return ( - } - button={ - - } - tags={[]} - > - - - ); +// ----------------------------------------------------------------------------- +// Props +// ----------------------------------------------------------------------------- + +type TypingFeatureDetailProps = { + seed: number; }; -const TypingFeatureDetail: FC = () => { - const [quote, setQuote] = useState<(typeof SHORT_QUOTES)[0]>(SHORT_QUOTES[0]); +// ----------------------------------------------------------------------------- +// Component +// ----------------------------------------------------------------------------- + +const TypingFeatureDetail: React.FC = ({ seed }) => { + const [quote, setQuote] = useState<(typeof SHORT_QUOTES)[0]>( + // We clamp it by `0.99999` in case `seed` is out of bounds (i.e not in the + // range `[0, 1)`). + SHORT_QUOTES[Math.floor(SHORT_QUOTES.length * Math.min(seed, 0.99999))], + ); const [typedLocked, setTypedLocked] = useState(''); const [typedPending, setTypedPending] = useState(''); const [startTime, setStartTime] = useState(); @@ -74,7 +65,7 @@ const TypingFeatureDetail: FC = () => { return res; }, [textWords, typedWords]); - const onChange = (e: ChangeEvent) => { + const onChange = (e: React.ChangeEvent) => { const value = e.target.value; if ((typed.length === 0 && value === ' ') || (typed.endsWith(' ') && value.endsWith(' '))) @@ -133,9 +124,6 @@ const TypingFeatureDetail: FC = () => { resetTest(); }; - // Randomize text on page load. - useEffect(() => setQuote(getRandomQuote()), []); - // Timer to update WPM. useEffect(() => { if (!startTime) return; @@ -243,23 +231,21 @@ const TypingFeatureDetail: FC = () => {
WPM
{wpm ?? '–'}
- -
- -
quote.wpm - ? 'text-red-9' - : 'text-green-9' - : 'text-gray-11', - )} - > - {quote.wpm} -
+
+ +
quote.wpm + ? 'text-red-9' + : 'text-green-9' + : 'text-gray-11', + )} + > + {quote.wpm}
- +
{/* Time passed */} { {/* Buttons */}
- + { - + @@ -292,6 +278,62 @@ const TypingFeatureDetail: FC = () => { ); }; -TypingFeature.displayName = 'TypingFeature'; +// ----------------------------------------------------------------------------- +// Timer +// ----------------------------------------------------------------------------- + +const TypingFeatureDetailTimer: React.FC<{ + startTime?: Date; + endTime?: Date; + fiveoutofnineTime: number; +}> = ({ startTime, endTime, fiveoutofnineTime }) => { + const [timePassed, setTimePassed] = useState(); + + // Timer to update the time passed. + useEffect(() => { + if (!startTime) { + setTimePassed(undefined); + return; + } + + const interval = setInterval(() => { + const timePassed = (Date.now() - startTime.getTime()) / 1000; + + setTimePassed(timePassed); + }, 50); + + if (endTime) { + const timePassed = (Date.now() - startTime.getTime()) / 1000; + + setTimePassed(timePassed); + clearInterval(interval); + return; + } + + return () => clearInterval(interval); + }, [startTime, endTime]); + + return ( +
+
Time
+
{timePassed ? `${timePassed}s` : '–'}
+
+ +
+ {fiveoutofnineTime}s +
+
+
+ ); +}; -export default TypingFeature; +export default TypingFeatureDetail; diff --git a/app/(components)/works/typing/index.tsx b/app/(components)/works/typing/index.tsx new file mode 100644 index 00000000..fd297d6b --- /dev/null +++ b/app/(components)/works/typing/index.tsx @@ -0,0 +1,32 @@ +import TypingFeatureDetail from './detail'; +import { ExternalLink, Keyboard } from 'lucide-react'; + +import CategoryTag from '@/components/templates/category-tag'; +import FeatureDisplay from '@/components/templates/feature-display'; +import { Button } from '@/components/ui'; + +const TypingFeature: React.FC = () => { + return ( + } + button={ + + } + tags={[]} + > + + + ); +}; + +export default TypingFeature; diff --git a/app/blog/page.tsx b/app/blog/page.tsx new file mode 100644 index 00000000..b922d4e2 --- /dev/null +++ b/app/blog/page.tsx @@ -0,0 +1,72 @@ +import type { Metadata } from 'next'; +import Image from 'next/image'; + +import { ChevronLeft } from 'lucide-react'; + +import ContainerLayout from '@/components/layouts/container'; +import { Button } from '@/components/ui'; + +// ----------------------------------------------------------------------------- +// Metadata +// ----------------------------------------------------------------------------- + +const title = '5/9 Blog'; +const description = 'writing'; +const images = [ + { + url: 'https://fiveoutofnine.com/static/og/blog.png', + alt: '5/9 Blog Open Graph image', + width: 1200, + height: 630, + }, +]; + +export const metadata: Metadata = { + title: { + default: title, + template: `${title} | %s`, + }, + description, + openGraph: { + title, + description, + images, + url: 'https://fiveoutofnine.com/design', + siteName: 'fiveoutofnine', + locale: 'en_US', + type: 'website', + }, + twitter: { + title, + description, + images, + card: 'summary_large_image', + creator: '@fiveoutofnine', + creatorId: '1269561030272643076', + }, +}; + +// ----------------------------------------------------------------------------- +// Page +// ----------------------------------------------------------------------------- + +export default function Page() { + return ( + +

Blog

+
Work in progress.
+ Chu Totoro walking + +
+ ); +} diff --git a/app/design/(components)/component-feature.tsx b/app/design/(components)/component-feature.tsx new file mode 100644 index 00000000..acad286e --- /dev/null +++ b/app/design/(components)/component-feature.tsx @@ -0,0 +1,58 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +import clsx from 'clsx'; +import { ArrowRight } from 'lucide-react'; + +import { IconButton } from '@/components/ui'; + +// ----------------------------------------------------------------------------- +// Props +// ----------------------------------------------------------------------------- + +type ComponentFeatureProps = { + className?: string; + href: string; + children?: React.ReactNode; +}; + +// ----------------------------------------------------------------------------- +// Component +// ----------------------------------------------------------------------------- + +export const ComponentFeature: React.FC = ({ + className, + href, + children, +}) => { + const [isMounted, setIsMounted] = useState(false); + + useEffect(() => setIsMounted(true), []); + + const isMobile = isMounted ? /iPhone|iPad|iPod|Android/i.test(navigator.userAgent) : false; + + return ( +
+ {children} + + + +
+ ); +}; + +export default ComponentFeature; diff --git a/app/design/(components)/components-display/accordion.tsx b/app/design/(components)/components-display/accordion.tsx new file mode 100644 index 00000000..c39b48ae --- /dev/null +++ b/app/design/(components)/components-display/accordion.tsx @@ -0,0 +1,55 @@ +'use client'; + +import * as Accordion from '@radix-ui/react-accordion'; +import { ChevronRight } from 'lucide-react'; + +import { CodeBlock } from '@/components/ui'; +import type { CodeBlockProps } from '@/components/ui/code-block/types'; + +// ----------------------------------------------------------------------------- +// Props +// ----------------------------------------------------------------------------- + +type DesignComponentsDisplayAccordionProps = Pick & { + code: string; + sourceInitiallyDisplayed?: boolean; +}; + +// ----------------------------------------------------------------------------- +// Component +// ----------------------------------------------------------------------------- + +const DesignComponentsDisplayAccordion: React.FC = ({ + highlightLines, + code, + sourceInitiallyDisplayed = false, +}) => { + return ( + + + + + + + View source + + + + {code} + + + + + ); +}; + +export default DesignComponentsDisplayAccordion; diff --git a/components/pages/design/components-display.tsx b/app/design/(components)/components-display/index.tsx similarity index 67% rename from components/pages/design/components-display.tsx rename to app/design/(components)/components-display/index.tsx index fcb2b6c2..939b53b7 100644 --- a/components/pages/design/components-display.tsx +++ b/app/design/(components)/components-display/index.tsx @@ -1,25 +1,33 @@ -import { Children, type FC, isValidElement, type ReactNode, useCallback, useMemo } from 'react'; +import { Children, isValidElement } from 'react'; -import * as Accordion from '@radix-ui/react-accordion'; +import DesignComponentsDisplayAccordion from './accordion'; import clsx from 'clsx'; -import { ChevronRight } from 'lucide-react'; import prettier from 'prettier'; -import babel from 'prettier/parser-babel'; import { twMerge } from 'tailwind-merge'; -import ToastButton from '@/components/pages/design/toast-button'; -import { Badge, Button, CodeBlock, HoverCard, IconButton, Select, Tooltip } from '@/components/ui'; -import type { CodeBlockProps } from '@/components/ui/code-block/types'; - -const COMPONENT_NAMES = [ +import { ToastButton } from '@/components/templates/mdx'; +import { Badge, Button, + ButtonGroup, CodeBlock, HoverCard, IconButton, Select, - ToastButton, Tooltip, +} from '@/components/ui'; +import type { CodeBlockProps } from '@/components/ui/code-block/types'; + +const COMPONENT_NAMES = [ + { component: Badge, displayName: 'Badge' }, + { component: Button, displayName: 'Button' }, + { component: ButtonGroup, displayName: 'ButtonGroup' }, + { component: CodeBlock, displayName: 'CodeBlock' }, + { component: HoverCard, displayName: 'HoverCard' }, + { component: IconButton, displayName: 'IconButton' }, + { component: Select, displayName: 'Select' }, + { component: ToastButton, displayName: 'ToastButton' }, + { component: Tooltip, displayName: 'Tooltip' }, ]; // ----------------------------------------------------------------------------- @@ -29,6 +37,7 @@ const COMPONENT_NAMES = [ type DesignComponentsDisplayProps = JSX.IntrinsicElements['div'] & Pick & { showSource?: boolean; + source?: string; sourceInitiallyDisplayed?: boolean; }; @@ -36,15 +45,16 @@ type DesignComponentsDisplayProps = JSX.IntrinsicElements['div'] & // Component // ----------------------------------------------------------------------------- -const DesignComponentsDisplay: FC = ({ +const DesignComponentsDisplay: React.FC = async ({ className, highlightLines, showSource = true, sourceInitiallyDisplayed = false, + source, children, ...rest }) => { - const getJsxString = useCallback((node: ReactNode): string => { + const getJsxString = (node: React.ReactNode): string => { if (!isValidElement(node)) { if (!node) return 'undefined'; @@ -52,7 +62,7 @@ const DesignComponentsDisplay: FC = ({ if (nodeString.indexOf('\n') > -1) return `\n{\n\`${node}\`\n}\n`; return nodeString; } - let children: ReactNode | undefined | null; + let children: React.ReactNode | undefined | null; // Attempt to figure out the component's name. let componentName = undefined; @@ -64,7 +74,7 @@ const DesignComponentsDisplay: FC = ({ // way, we also retain the full name, rather than the minified name // webpack gives. for (let i = 0; i < COMPONENT_NAMES.length; ++i) { - if (node.type === COMPONENT_NAMES[i]) { + if (node.type === COMPONENT_NAMES[i].component) { componentName = COMPONENT_NAMES[i].displayName; break; } @@ -94,35 +104,51 @@ const DesignComponentsDisplay: FC = ({ const propString = Object.entries(node.props) .map((prop) => { if (prop[0] === 'children') { - children = prop[1] as ReactNode; + children = prop[1] as React.ReactNode; return; } if (typeof prop[1] === 'string') return `${prop[0]}="${prop[1]}"`; else if (isValidElement(prop[1])) return `${prop[0]}={${getJsxString(prop[1])}}`; + else if (typeof prop[1] === 'object') return `${prop[0]}={${JSON.stringify(prop[1])}}`; return `${prop[0]}={${prop[1]}}`; }) .join(' ') .trim(); const componentChildren = Children.toArray(children); - const childrenJsxString = componentChildren.map((child) => getJsxString(child)).join(); + const childrenJsxString = componentChildren.map((child) => getJsxString(child)).join(''); return componentChildren.length > 0 ? `<${componentName} ${propString}>${childrenJsxString}` : `<${componentName} ${propString}/>`; - }, []); + }; - const code = useMemo(() => { + const code = await (async () => { if (!showSource) return ''; + else if (source) { + // If `source` is provided, skip trying to determine the source from + // `children`. + try { + return await prettier.format(source, { + bracketSpacing: true, + semi: true, + trailingComma: 'all', + printWidth: 100, + tabWidth: 2, + singleQuote: true, + parser: 'babel', + }); + } catch (e) { + return source; + } + } try { const componentChildren = Children.toArray(children).filter((child) => isValidElement(child)); - return prettier - .format( - `\n${componentChildren + return ( + await prettier.format( + `\n${componentChildren .map((child) => getJsxString(child)) .join('\n')}`, { @@ -133,14 +159,15 @@ const DesignComponentsDisplay: FC = ({ tabWidth: 2, singleQuote: true, parser: 'babel', - plugins: [babel], }, ) + ) + .replace(';', '') .trim(); } catch (e) { return ''; } - }, [children, className, getJsxString, showSource]); + })(); return (
@@ -157,23 +184,11 @@ const DesignComponentsDisplay: FC = ({ {children}
{code.length > 0 ? ( - - - - - View source - - - - {code} - - - - + ) : null}
); diff --git a/components/pages/design/nav-bar.tsx b/app/design/(components)/nav-bar.tsx similarity index 50% rename from components/pages/design/nav-bar.tsx rename to app/design/(components)/nav-bar.tsx index aafb4d71..99e0f30d 100644 --- a/components/pages/design/nav-bar.tsx +++ b/app/design/(components)/nav-bar.tsx @@ -1,48 +1,53 @@ -import { type FC, Fragment, useMemo, useState } from 'react'; +'use client'; + +import { usePathname } from 'next/navigation'; +import { Fragment, useMemo, useState } from 'react'; -import * as Dialog from '@radix-ui/react-dialog'; import clsx from 'clsx'; import { ChevronRight, Menu, X } from 'lucide-react'; import { DESIGN_COMPONENT_PAGES, DESIGN_PAGES } from '@/lib/constants/site'; import { useMediaQuery } from '@/lib/hooks/useMediaQuery'; -import type { PageSlug } from '@/lib/types/site'; -import { Button, IconButton } from '@/components/ui'; +import { Button, Drawer, IconButton } from '@/components/ui'; // ----------------------------------------------------------------------------- // Props // ----------------------------------------------------------------------------- -type DesignNavBarProps = { - selected?: PageSlug; +type DesignNavBarInternalProps = { + selected: string; + onTrigger?: () => void; }; // ----------------------------------------------------------------------------- // Component // ----------------------------------------------------------------------------- -const DesignNavBar: FC = (props) => { +const DesignNavBar: React.FC = () => { + const pathname = usePathname() ?? ''; + return ( - - + + ); }; -const DesignNavBarDesktop: FC = ({ selected }) => { +const DesignNavBarDesktop: React.FC = ({ selected }) => { return ( - + ); }; -const DesignNavBarMobile: FC = ({ selected }) => { +const DesignNavBarMobile: React.FC = ({ selected }) => { const [open, setOpen] = useState(false); const isSmallScreen = useMediaQuery('(max-width: 768px)'); // `md` breakpoint @@ -57,38 +62,33 @@ const DesignNavBarMobile: FC = ({ selected }) => { }, [selected]); return ( - +
- + - {open ? : } + {open ? : } - +
  1. {selectedSectionName} - +
  2. {selectedPageName}
- - - e.preventDefault()} asChild> - - - -
+ e.preventDefault()}> + setOpen(false)} /> + + ); }; -const DesignNavBarInternal: FC = ({ selected }) => { +const DesignNavBarInternal: React.FC = ({ selected, onTrigger }) => { return (
Foundations
@@ -96,19 +96,19 @@ const DesignNavBarInternal: FC = ({ selected }) => { const pageSelected = selected === page.slug; return ( -
- -
+ ); })} @@ -117,19 +117,19 @@ const DesignNavBarInternal: FC = ({ selected }) => { const pageSelected = selected === page.slug; return ( -
- -
+ ); })}
diff --git a/app/design/(components)/page-nav.tsx b/app/design/(components)/page-nav.tsx new file mode 100644 index 00000000..085cccb2 --- /dev/null +++ b/app/design/(components)/page-nav.tsx @@ -0,0 +1,55 @@ +'use client'; + +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; +import { useMemo } from 'react'; + +import { ArrowLeft, ArrowRight } from 'lucide-react'; + +import { DESIGN_COMPONENT_PAGES, DESIGN_PAGES } from '@/lib/constants/site'; + +const DesignPageNav: React.FC = () => { + const pathname = usePathname(); + + const pages = useMemo(() => DESIGN_PAGES.concat(DESIGN_COMPONENT_PAGES), []); + const prevPage = useMemo(() => { + const index = pages.findIndex((page) => page.slug === pathname); + + return index > 0 ? pages[index - 1] : null; + }, [pages, pathname]); + + const nextPage = useMemo(() => { + const index = pages.findIndex((page) => page.slug === pathname); + + return index !== -1 && index < pages.length - 1 ? pages[index + 1] : null; + }, [pages, pathname]); + + return ( +
+ {prevPage ? ( + + + {prevPage.name} + + ) : ( +