diff --git a/.github/workflows/prisma_migrate.yaml b/.github/workflows/prisma_migrate.yaml index 7825b1c..ff6b4ca 100644 --- a/.github/workflows/prisma_migrate.yaml +++ b/.github/workflows/prisma_migrate.yaml @@ -14,16 +14,12 @@ jobs: build: runs-on: ubuntu-latest - strategy: - matrix: - node-version: [18.x] - steps: - uses: actions/checkout@v4 - - name: Use Node.js ${{ matrix.node-version }} + - name: Use Node.js 20.x uses: actions/setup-node@v4 with: - node-version: ${{ matrix.node-version }} + node-version: 20.x - name: Set DATABASE_URL .env-variables run: | diff --git a/.github/workflows/pull_request.yaml b/.github/workflows/pull_request.yaml index 6d37979..5a66bcb 100644 --- a/.github/workflows/pull_request.yaml +++ b/.github/workflows/pull_request.yaml @@ -21,14 +21,33 @@ jobs: with: node-version: 20.x + - uses: pnpm/action-setup@v3 + name: Install pnpm + with: + version: 9 + run_install: false + + - name: Get pnpm store directory + shell: bash + run: | + echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + + - uses: actions/cache@v4 + name: Setup pnpm cache + with: + path: ${{ env.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + - name: Install dependencies - run: yarn + run: pnpm i - name: Lint - run: yarn lint + run: pnpm run lint - name: Typecheck - run: yarn typecheck + run: pnpm run typecheck - name: Build - run: yarn build + run: pnpm run build diff --git a/web/.gitignore b/web/.gitignore index eadc9aa..37fa352 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -10,4 +10,6 @@ node_modules /api/index.js /api/index.js.map -*.log \ No newline at end of file +*.log + +.DS_Store diff --git a/web/.prettierrc.json b/web/.prettierrc.json index e44f773..7efa41f 100644 --- a/web/.prettierrc.json +++ b/web/.prettierrc.json @@ -8,5 +8,6 @@ "singleQuote": true, "tabWidth": 2, "trailingComma": "all", - "endOfLine": "auto" + "endOfLine": "auto", + "plugins": ["prettier-plugin-tailwindcss"] } diff --git a/web/README.md b/web/README.md index 532fb61..52b4ce4 100644 --- a/web/README.md +++ b/web/README.md @@ -1,6 +1,6 @@ # Stats -**Tech-stack:** [Remix](https://remix.run/), [Prisma](https://www.prisma.io/), [MUI Joy UI](https://mui.com/joy-ui/getting-started/overview/), [Nivo](https://nivo.rocks/) +**Tech-stack:** [Remix](https://remix.run/), [Prisma](https://www.prisma.io/), [Tailwind](https://tailwindcss.com/), [Radix](https://www.radix-ui.com/), [Nivo](https://nivo.rocks/) ## Development diff --git a/web/app/components/ConfirmDialog.tsx b/web/app/components/ConfirmDialog.tsx deleted file mode 100644 index 5a6fb4a..0000000 --- a/web/app/components/ConfirmDialog.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import type { ButtonProps } from '@mui/joy'; -import { Box, Button, Divider, Modal, ModalDialog, Typography } from '@mui/joy'; -import type { ReactNode } from 'react'; -import { useState } from 'react'; - -export type ConfirmDialogProps = ButtonProps & { - onConfirm: () => void; - title: string; - description?: string; - children: ReactNode; - confirmText?: string; -}; - -export const ConfirmDialog = ({ onConfirm, title, description, children, confirmText = "I'm sure", ...props }: ConfirmDialogProps) => { - const [open, setOpen] = useState(false); - return ( - <> - - setOpen(false)} open={open}> - - - {title} - - - {description && ( - - {description} - - )} - - - - - - - - ); -}; diff --git a/web/app/components/ErrorBoundary.tsx b/web/app/components/ErrorBoundary.tsx index 3075528..2dc0f6e 100644 --- a/web/app/components/ErrorBoundary.tsx +++ b/web/app/components/ErrorBoundary.tsx @@ -1,21 +1,30 @@ -import styled from '@emotion/styled'; import { isRouteErrorResponse, useRouteError } from '@remix-run/react'; - -const Container = styled('div')` - background-color: #ff0000; - padding: 1rem; -`; +import { Typography } from './typography'; +import { Card } from './ui/card'; export const ErrorBoundary = () => { const error = useRouteError(); - return isRouteErrorResponse(error) ? ( - -

{`${error.status} - ${error.data}`}

-
- ) : ( - -

[ErrorBoundary]: There was an error: {(error as Error).message}

-
+ return ( + + Something went wrong + {isRouteErrorResponse(error) ? ( + {`${error.status} - ${error.data}`} + ) : error instanceof Error ? ( + <> + {`${error.name}: ${error.message}`} + {process.env.NODE_ENV !== 'production' && ( + + Stack: + + {error.stack} + + + )} + + ) : ( + Unknown error + )} + ); }; diff --git a/web/app/components/Icons.tsx b/web/app/components/Icons.tsx deleted file mode 100644 index 38f42c1..0000000 --- a/web/app/components/Icons.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import type { BoxProps } from '@mui/joy'; -import { Box } from '@mui/joy'; - -const IconBase = ({ sx, ...props }: IconProps) => ( - -); - -export type IconProps = BoxProps<'svg'>; - -export const Check = (props: IconProps) => ( - - - -); - -export const Add = (props: IconProps) => ( - - - -); diff --git a/web/app/components/LinkTabs.tsx b/web/app/components/LinkTabs.tsx index c84090e..45259db 100644 --- a/web/app/components/LinkTabs.tsx +++ b/web/app/components/LinkTabs.tsx @@ -1,7 +1,6 @@ -import type { TabsProps } from '@mui/joy'; -import { Tab, TabList, Tabs } from '@mui/joy'; import { Link, useLocation } from '@remix-run/react'; import { useState } from 'react'; +import { Tabs, TabsList, TabsProps, TabsTrigger } from './ui/tabs'; export type LinkTabsProps = Omit & { tabs: { @@ -16,18 +15,15 @@ export const LinkTabs = ({ tabs, baseLocation, ...props }: LinkTabsProps) => { const [defaultLocation] = useState(location.pathname); return ( - + {tabs.map((tab) => ( - - {tab.label} - + + + {tab.label} + + ))} - + ); }; diff --git a/web/app/components/Navbar.tsx b/web/app/components/Navbar.tsx index 040f3fa..fa4b9cd 100644 --- a/web/app/components/Navbar.tsx +++ b/web/app/components/Navbar.tsx @@ -1,24 +1,23 @@ import { - Avatar, - Box, - Card, - Chip, - List, - ListDivider, - ListItemDecorator, - listItemDecoratorClasses, - Option, - optionClasses, - Select, - Stack, - Typography, -} from '@mui/joy'; + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuPortal, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from '~/components/ui/dropdown-menu'; import type { Prisma, User } from '@prisma/client'; import type { SerializeFrom } from '@remix-run/node'; -import { Link, useLocation, useNavigate, useParams } from '@remix-run/react'; -import { Fragment, useCallback } from 'react'; +import { Link, useLocation, useParams } from '@remix-run/react'; -import { Add, Check } from './Icons'; +import { Card } from './ui/card'; +import { Button } from './ui/button'; +import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'; export type NavbarProps = { user: Pick; @@ -34,114 +33,63 @@ export type NavbarProps = { export const Navbar = ({ user, teams }: NavbarProps) => { const { teamSlug, projectSlug } = useParams(); const location = useLocation(); - const navigate = useNavigate(); - - const onSelect = useCallback( - (_: React.SyntheticEvent | null, value: string | null) => { - if (value) { - navigate(`/dashboard/${value}`); - } - }, - [navigate], - ); return ( - `1px solid ${palette.neutral.outlinedBorder}`, - position: 'sticky', - top: ({ spacing }) => spacing(1), - zIndex: 10, - background: ({ palette }) => palette.background.backdrop, - backdropFilter: `blur(5px)`, - }}> - - - - - - + +
+ + + + + + + + + My teams + + {teams.map((team) => ( + + + + {team.name} + + + + Projects + + + {team.projects.map((project) => ( + + + {project.name} + + + ))} + + + + + + ))} + + + + New Team + + + + + +
{location.pathname !== '/profile' && ( - - {user.name[0]} + + + {user.name[0]} )} diff --git a/web/app/components/container.tsx b/web/app/components/container.tsx new file mode 100644 index 0000000..88dd2db --- /dev/null +++ b/web/app/components/container.tsx @@ -0,0 +1,13 @@ +import { forwardRef } from 'react'; +import { cn } from '~/lib/utils'; + +export interface ContainerProps extends React.HTMLAttributes { + error?: boolean; +} + +const Container = forwardRef(({ className, ...props }, ref) => { + return
; +}); +Container.displayName = 'Container'; + +export { Container }; diff --git a/web/app/components/lazy/Globe.tsx b/web/app/components/lazy/Globe.tsx index 75670a0..e2ed8fa 100644 --- a/web/app/components/lazy/Globe.tsx +++ b/web/app/components/lazy/Globe.tsx @@ -1,6 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ // From: https://github.com/PaulieScanlon/paulie-dev-2023/blob/main/src/components/globe-all-cities.tsx -import { useTheme } from '@mui/joy'; import { useIsClient } from '~/hooks/useIsClient'; import { forwardRef } from 'react'; import type { GlobeMethods, GlobeProps } from 'react-globe.gl'; @@ -11,13 +10,12 @@ import geoJson from '../statistics/globe_countries.geojson.json'; const LazyGlobe = forwardRef((props, ref) => { const isClient = useIsClient(); - const { palette } = useTheme(); return ( <> {isClient && ( ({ lat: (Math.random() - 0.5) * 180, @@ -28,7 +26,7 @@ const LazyGlobe = forwardRef((props, ref) => { new THREE.Mesh( new THREE.SphereGeometry(0.3), new THREE.MeshBasicMaterial({ - color: palette.primary[700], + color: '#12467B', opacity: 0.9, transparent: true, }), @@ -36,7 +34,7 @@ const LazyGlobe = forwardRef((props, ref) => { } globeMaterial={ new THREE.MeshPhongMaterial({ - color: palette.primary[900], + color: '#051423', opacity: 0.7, transparent: true, }) diff --git a/web/app/components/repository-link.tsx b/web/app/components/repository-link.tsx new file mode 100644 index 0000000..7b41c46 --- /dev/null +++ b/web/app/components/repository-link.tsx @@ -0,0 +1,18 @@ +import { forwardRef } from 'react'; +import { cn } from '~/lib/utils'; +import { Typography, type TypographyProps } from './typography'; + +export type RepositoryLinkProps = Omit; + +const RepositoryLink = forwardRef(({ className, ...props }, ref) => { + return ( + + + @olros/stats + + + ); +}); +RepositoryLink.displayName = 'RepositoryLink'; + +export { RepositoryLink }; diff --git a/web/app/components/statistics/AggregatedCard.tsx b/web/app/components/statistics/AggregatedCard.tsx index 5b7f0df..dd7486c 100644 --- a/web/app/components/statistics/AggregatedCard.tsx +++ b/web/app/components/statistics/AggregatedCard.tsx @@ -1,4 +1,6 @@ -import { AspectRatio, Card, CardContent, Tooltip, Typography } from '@mui/joy'; +import { Typography } from '../typography'; +import { Card, CardContent } from '../ui/card'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip'; export type AggregatedCardProps = { emoji: string; @@ -9,23 +11,26 @@ export type AggregatedCardProps = { export const AggregatedCard = ({ count, emoji, title, tooltip }: AggregatedCardProps) => { return ( - - - {emoji} - - - + +
+ {emoji} +
+ + {tooltip ? ( - - {title} - + + + {title} + +

{tooltip}

+
+
+
) : ( title )}
- - {Intl.NumberFormat('en-GB', { notation: 'compact' }).format(count)} - + {Intl.NumberFormat('en-GB', { notation: 'compact' }).format(count)}
); diff --git a/web/app/components/statistics/BarChart.tsx b/web/app/components/statistics/BarChart.tsx index 6d47202..282f50e 100644 --- a/web/app/components/statistics/BarChart.tsx +++ b/web/app/components/statistics/BarChart.tsx @@ -1,7 +1,8 @@ -import { Box, Typography } from '@mui/joy'; import { Fragment } from 'react'; import type { TopData } from './loader.server'; +import { Typography } from '../typography'; +import { cn } from '~/lib/utils'; export type BarChartProps = { data: TopData[]; @@ -12,54 +13,41 @@ export type BarChartProps = { }; export const BarChart = ({ data, maxCount, title, countTitle, nullText }: BarChartProps) => { - const gridTemplateColumns = maxCount ? '1fr 4.5rem 4.5rem' : '1fr 4.5rem'; + const gridTemplateColumns = maxCount ? 'grid-cols-[1fr__4.5rem_4.5rem]' : 'grid-cols-[1fr__4.5rem]'; if (data.length === 0) { - return Found none registered with the current filters; + return Found none registered with the current filters; } return ( - - - {title} - +
+
+ {title} + {countTitle} {maxCount !== undefined && ( - + % )} - - +
+
{data.map((row) => ( - - palette.background.backdrop, - borderRadius: ({ radius }) => radius.xs, - }} - /> - - {decodeURIComponent(row.name || nullText || '')} - - - +
+
+ {decodeURIComponent(row.name || nullText || '')} +
+ {Intl.NumberFormat('en-GB', { notation: 'compact', maximumFractionDigits: 1 }).format(row.count)} {maxCount !== undefined && ( - + {Intl.NumberFormat('en-GB', { notation: 'compact', maximumFractionDigits: 1 }).format((row.count / maxCount) * 100)} )} ))} - - +
+
); }; diff --git a/web/app/components/statistics/Filters.tsx b/web/app/components/statistics/Filters.tsx index b7aa48c..cd3177e 100644 --- a/web/app/components/statistics/Filters.tsx +++ b/web/app/components/statistics/Filters.tsx @@ -1,8 +1,12 @@ -import { Button, Card, FormControl, FormLabel, Input, Option, Select, Stack } from '@mui/joy'; import { Form } from '@remix-run/react'; import { stats } from '~/stats'; import type { PERIOD } from './utils'; +import { Card } from '../ui/card'; +import { Button } from '../ui/button'; +import { Input } from '../ui/input'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'; +import { Label } from '../ui/label'; export type FiltersProps = { dateGte: string; @@ -13,26 +17,25 @@ export type FiltersProps = { export const Filters = ({ dateGte, dateLte, period }: FiltersProps) => { return ( - stats.event('update-filters')}> - - Period - - - - From date - - - - To date - - - - + ); }; diff --git a/web/app/components/statistics/GlobeWithCities.tsx b/web/app/components/statistics/GlobeWithCities.tsx index c959679..89c52ee 100644 --- a/web/app/components/statistics/GlobeWithCities.tsx +++ b/web/app/components/statistics/GlobeWithCities.tsx @@ -1,5 +1,4 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { Box, useTheme } from '@mui/joy'; import { useElementSize } from '~/hooks/useElementSize'; import { scaleSequentialSqrt } from 'd3-scale'; import { interpolateYlOrRd } from 'd3-scale-chromatic'; @@ -28,7 +27,6 @@ const countToAltitude = (count: number, maxCount: number) => Math.min(MAX_ALTITU export const GlobeWithCities = memo(({ data, ringsData = [], height = 400, disableUseInView = false }: GlobeWithCitiesProps) => { const { ref, inView } = useInView({ rootMargin: '200px 0px', triggerOnce: true, skip: disableUseInView, initialInView: disableUseInView }); - const { palette } = useTheme(); const globeEl = useRef(); const [boxRef, boxSize] = useElementSize(); @@ -62,14 +60,14 @@ export const GlobeWithCities = memo(({ data, ringsData = [], height = 400, disab lng: point.longitude, altitude: countToAltitude(point.count, data[0].count), radius: 0.3, - color: palette.primary[400], + color: '#4393E4', })), - [data, palette], + [data], ); return ( - - +
+
{inView && ( )} - - +
+
); }); diff --git a/web/app/components/statistics/HeatMapChart.tsx b/web/app/components/statistics/HeatMapChart.tsx index 6af24bd..d01fa94 100644 --- a/web/app/components/statistics/HeatMapChart.tsx +++ b/web/app/components/statistics/HeatMapChart.tsx @@ -1,4 +1,3 @@ -import { Box, useTheme } from '@mui/joy'; import { lazy, Suspense } from 'react'; import { useInView } from 'react-intersection-observer'; @@ -13,9 +12,8 @@ export type HeatMapProps = { export const HeatMapChart = ({ data, disableUseInView = false }: HeatMapProps) => { const { ref, inView } = useInView({ rootMargin: '200px 0px', triggerOnce: true, skip: disableUseInView, initialInView: disableUseInView }); - const theme = useTheme(); return ( - +
{inView && ( )} - +
); }; diff --git a/web/app/components/statistics/TimeRangeChart.tsx b/web/app/components/statistics/TimeRangeChart.tsx index b6ba4b0..a483a78 100644 --- a/web/app/components/statistics/TimeRangeChart.tsx +++ b/web/app/components/statistics/TimeRangeChart.tsx @@ -1,4 +1,3 @@ -import { Box, useTheme } from '@mui/joy'; import type { CalendarDatum } from '@nivo/calendar'; import { scaleSequentialSqrt } from 'd3-scale'; import { interpolateYlOrRd } from 'd3-scale-chromatic'; @@ -22,10 +21,9 @@ const colors = Array.from(Array(20)).map((_, i) => scaleSequentialSqrt(interpola export const TimeRangeChart = ({ trend, dateGte, dateLte, disableUseInView = false }: TimeRangeChartProps) => { const { ref, inView } = useInView({ rootMargin: '200px 0px', triggerOnce: true, skip: disableUseInView, initialInView: disableUseInView }); - const { palette } = useTheme(); const data = useMemo(() => trend.map((point) => ({ day: format(new Date(point.x), 'yyyy-MM-dd'), value: point.y })), [trend]); return ( - +
{inView && ( )} - +
); }; diff --git a/web/app/components/statistics/TrendChart.tsx b/web/app/components/statistics/TrendChart.tsx index afc0430..d44cbdf 100644 --- a/web/app/components/statistics/TrendChart.tsx +++ b/web/app/components/statistics/TrendChart.tsx @@ -1,26 +1,24 @@ -import { Box, Card, Typography, useTheme } from '@mui/joy'; import type { Serie } from '@nivo/line'; import { format } from 'date-fns'; import { lazy, Suspense, useMemo } from 'react'; import type { LoadStatisticsSerialized, TrendSerialized } from './loader.server'; +import { Card } from '../ui/card'; +import { Typography } from '../typography'; const ResponsiveLine = lazy(() => import('~/components/lazy/ResponsiveLine')); export type TrendChartProps = { period: LoadStatisticsSerialized['period']; trend: TrendSerialized[]; - dateGte: string; - dateLte: string; tooltipTitle: string; }; -export const TrendChart = ({ period, trend, dateGte, dateLte, tooltipTitle }: TrendChartProps) => { - const theme = useTheme(); +export const TrendChart = ({ period, trend, tooltipTitle }: TrendChartProps) => { const data = useMemo(() => [{ id: 'TrendChart', data: trend.map((point) => ({ ...point, x: new Date(point.x) })) }], [trend]); return ( - +
( - - - {tooltipTitle} - + + {tooltipTitle} {slice.points.map((point) => ( - + {`${format(slice.points[0].data.x as Date, 'eee, dd MMM yyyy')}: ${Intl.NumberFormat('en-GB', { notation: 'compact', maximumFractionDigits: 2, @@ -56,10 +52,10 @@ export const TrendChart = ({ period, trend, dateGte, dateLte, tooltipTitle }: Tr )} theme={{ - axis: { domain: { line: { stroke: theme.palette.background.level1 } } }, - grid: { line: { stroke: theme.palette.background.level1 } }, - legends: { text: { fill: theme.palette.text.primary } }, - text: { fill: theme.palette.text.primary }, + axis: { domain: { line: { stroke: 'var(--color-muted)' } } }, + grid: { line: { stroke: 'var(--color-muted)' } }, + legends: { text: { fill: 'var(--color-foreground)' } }, + text: { fill: 'var(--color-foreground)' }, }} xScale={{ type: 'time', @@ -70,6 +66,6 @@ export const TrendChart = ({ period, trend, dateGte, dateLte, tooltipTitle }: Tr yScale={{ type: 'linear' }} /> - +
); }; diff --git a/web/app/components/statistics/index.tsx b/web/app/components/statistics/index.tsx index 31e328a..47b5f4e 100644 --- a/web/app/components/statistics/index.tsx +++ b/web/app/components/statistics/index.tsx @@ -1,4 +1,3 @@ -import { Box, Button, ButtonGroup, Card, Chip, Stack, Typography } from '@mui/joy'; import { AggregatedCard } from '~/components/statistics/AggregatedCard'; import { Filters } from '~/components/statistics/Filters'; import { useState } from 'react'; @@ -10,6 +9,10 @@ import type { LoadStatisticsSerialized, TopData } from './loader.server'; import { TimeRangeChart } from './TimeRangeChart'; import { TrendChart } from './TrendChart'; import { CURRENT_VISITORS_LAST_MINUTES } from './utils'; +import { Card } from '../ui/card'; +import { Typography } from '../typography'; +import { ToggleGroup, ToggleGroupItem } from '../ui/toggle-group'; +import { Badge } from '../ui/badge'; export type StatisticsProps = { statistics: LoadStatisticsSerialized; @@ -25,9 +28,9 @@ export const Statistics = ({ statistics }: StatisticsProps) => { Device: statistics.topDevices, }; return ( - - - +
+
+
{ title='Current visitors' tooltip={`The number of visitors currently on your site. This does not depend on the applied filters. It includes all visitors who have loaded a page in the last ${CURRENT_VISITORS_LAST_MINUTES} minutes`} /> - +
- +
- - Pageviews trend - - + Pageviews trend + - +
- Top sources + Top sources - Top pages + Top pages - - Devices - +
+ Devices + {Object.keys(deviceStats).map((stat) => ( - + ))} - - + +
- Top custom events + Top custom events - +
- Unique visitors trend - + Unique visitors trend + Visitors are counted by creating a hash consisting of the visitor's{' '} - - User-Agent - + + User-Agent + {', '} - - IP-address - + + IP-address + {', '} - - a random secret - + + a random secret + {', and '} - - today's date - + + today's date + . It's therefore not possible to track users over multiple days, count returning users, or a total amount of users during a period of more than one day. - + - +
- Geolocations + Geolocations - Top locations + Top locations - Top time of day / week - The hours are in UTC-time + Top time of day / week + The hours are in UTC-time - Calendar + Calendar {statistics.period === 'hour' ? ( - Calendar is not supported when period is set to "Hour" + Calendar is not supported when period is set to "Hour" ) : ( )} - - +
+
); }; diff --git a/web/app/components/typography.tsx b/web/app/components/typography.tsx new file mode 100644 index 0000000..8358ac2 --- /dev/null +++ b/web/app/components/typography.tsx @@ -0,0 +1,46 @@ +import * as React from 'react'; +import { Slot } from '@radix-ui/react-slot'; +import { cva, type VariantProps } from 'class-variance-authority'; + +import { cn } from '~/lib/utils'; + +const typographyVariants = cva('', { + variants: { + variant: { + p: 'leading-5 [&:not(:first-child)]:mt-2', + h1: 'text-4xl font-extrabold tracking-tight lg:text-5xl', + h2: 'pb-2 text-3xl font-semibold tracking-tight first:mt-0', + h3: 'text-2xl font-semibold tracking-tight', + h4: 'text-xl font-semibold tracking-tight', + large: 'text-lg font-semibold', + small: 'text-sm font-small leading-none', + muted: 'text-sm text-muted-foreground', + }, + }, + defaultVariants: { + variant: 'p', + }, +}); + +export interface TypographyProps extends React.ParamHTMLAttributes, VariantProps { + asChild?: boolean; +} + +const variantToComponent: Record, string> = { + p: 'p', + h1: 'h1', + h2: 'h2', + h3: 'h3', + h4: 'h4', + large: 'p', + small: 'p', + muted: 'p', +}; + +const Typography = React.forwardRef(({ className, variant, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : variant ? variantToComponent[variant] : 'p'; + return ; +}); +Typography.displayName = 'Typography'; + +export { Typography, typographyVariants }; diff --git a/web/app/components/ui/alert-dialog.tsx b/web/app/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..270b281 --- /dev/null +++ b/web/app/components/ui/alert-dialog.tsx @@ -0,0 +1,94 @@ +import * as React from 'react'; +import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'; + +import { cn } from '~/lib/utils'; +import { buttonVariants } from '~/components/ui/button'; + +const AlertDialog = AlertDialogPrimitive.Root; + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger; + +const AlertDialogPortal = AlertDialogPrimitive.Portal; + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName; + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)); +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName; + +const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes) => ( +
+); +AlertDialogHeader.displayName = 'AlertDialogHeader'; + +const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes) => ( +
+); +AlertDialogFooter.displayName = 'AlertDialogFooter'; + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ); +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName; + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ); +AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName; + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ); +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName; + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName; + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +}; diff --git a/web/app/components/ui/alert.tsx b/web/app/components/ui/alert.tsx new file mode 100644 index 0000000..bb7dd30 --- /dev/null +++ b/web/app/components/ui/alert.tsx @@ -0,0 +1,36 @@ +import * as React from 'react'; +import { cva, type VariantProps } from 'class-variance-authority'; + +import { cn } from '~/lib/utils'; + +const alertVariants = cva( + 'relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7', + { + variants: { + variant: { + default: 'bg-background text-foreground', + destructive: 'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive', + }, + }, + defaultVariants: { + variant: 'default', + }, + }, +); + +const Alert = React.forwardRef & VariantProps>( + ({ className, variant, ...props }, ref) =>
, +); +Alert.displayName = 'Alert'; + +const AlertTitle = React.forwardRef>(({ className, ...props }, ref) => ( +
+)); +AlertTitle.displayName = 'AlertTitle'; + +const AlertDescription = React.forwardRef>(({ className, ...props }, ref) => ( +
+)); +AlertDescription.displayName = 'AlertDescription'; + +export { Alert, AlertTitle, AlertDescription }; diff --git a/web/app/components/ui/avatar.tsx b/web/app/components/ui/avatar.tsx new file mode 100644 index 0000000..4c442d5 --- /dev/null +++ b/web/app/components/ui/avatar.tsx @@ -0,0 +1,25 @@ +import * as React from 'react'; +import * as AvatarPrimitive from '@radix-ui/react-avatar'; + +import { cn } from '~/lib/utils'; + +const Avatar = React.forwardRef, React.ComponentPropsWithoutRef>( + ({ className, ...props }, ref) => ( + + ), +); +Avatar.displayName = AvatarPrimitive.Root.displayName; + +const AvatarImage = React.forwardRef, React.ComponentPropsWithoutRef>( + ({ className, ...props }, ref) => , +); +AvatarImage.displayName = AvatarPrimitive.Image.displayName; + +const AvatarFallback = React.forwardRef, React.ComponentPropsWithoutRef>( + ({ className, ...props }, ref) => ( + + ), +); +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; + +export { Avatar, AvatarImage, AvatarFallback }; diff --git a/web/app/components/ui/badge.tsx b/web/app/components/ui/badge.tsx new file mode 100644 index 0000000..1e795d5 --- /dev/null +++ b/web/app/components/ui/badge.tsx @@ -0,0 +1,34 @@ +import * as React from 'react'; +import { Slot } from '@radix-ui/react-slot'; +import { cva, type VariantProps } from 'class-variance-authority'; + +import { cn } from '~/lib/utils'; + +const badgeVariants = cva( + 'inline-flex items-center rounded-md border px-1.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2', + { + variants: { + variant: { + default: 'border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80', + secondary: 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80', + destructive: 'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80', + outline: 'text-foreground', + }, + }, + defaultVariants: { + variant: 'default', + }, + }, +); + +export interface BadgeProps extends React.HTMLAttributes, VariantProps { + asChild?: boolean; +} + +const Badge = React.forwardRef(({ className, variant, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : 'div'; + return ; +}); +Badge.displayName = 'Badge'; + +export { Badge, badgeVariants }; diff --git a/web/app/components/ui/button.tsx b/web/app/components/ui/button.tsx new file mode 100644 index 0000000..65adf47 --- /dev/null +++ b/web/app/components/ui/button.tsx @@ -0,0 +1,43 @@ +import * as React from 'react'; +import { Slot } from '@radix-ui/react-slot'; +import { cva, type VariantProps } from 'class-variance-authority'; + +import { cn } from '~/lib/utils'; + +const buttonVariants = cva( + 'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50', + { + variants: { + variant: { + default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90', + destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90', + outline: 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground', + secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80', + ghost: 'hover:bg-accent hover:text-accent-foreground', + link: 'text-primary underline-offset-4 hover:underline', + }, + size: { + default: 'h-9 px-4 py-2', + sm: 'h-8 rounded-md px-3 text-xs', + lg: 'h-10 rounded-md px-8', + icon: 'h-9 w-9', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + }, +); + +export interface ButtonProps extends React.ButtonHTMLAttributes, VariantProps { + asChild?: boolean; +} + +const Button = React.forwardRef(({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : 'button'; + return ; +}); +Button.displayName = 'Button'; + +export { Button, buttonVariants }; diff --git a/web/app/components/ui/card.tsx b/web/app/components/ui/card.tsx new file mode 100644 index 0000000..b2f704d --- /dev/null +++ b/web/app/components/ui/card.tsx @@ -0,0 +1,35 @@ +import * as React from 'react'; + +import { cn } from '~/lib/utils'; + +const Card = React.forwardRef>(({ className, ...props }, ref) => ( +
+)); +Card.displayName = 'Card'; + +const CardHeader = React.forwardRef>(({ className, ...props }, ref) => ( +
+)); +CardHeader.displayName = 'CardHeader'; + +const CardTitle = React.forwardRef>(({ className, ...props }, ref) => ( +

+)); +CardTitle.displayName = 'CardTitle'; + +const CardDescription = React.forwardRef>(({ className, ...props }, ref) => ( +

+)); +CardDescription.displayName = 'CardDescription'; + +const CardContent = React.forwardRef>(({ className, ...props }, ref) => ( +

+)); +CardContent.displayName = 'CardContent'; + +const CardFooter = React.forwardRef>(({ className, ...props }, ref) => ( +
+)); +CardFooter.displayName = 'CardFooter'; + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }; diff --git a/web/app/components/ui/checkbox.tsx b/web/app/components/ui/checkbox.tsx new file mode 100644 index 0000000..de5f7a5 --- /dev/null +++ b/web/app/components/ui/checkbox.tsx @@ -0,0 +1,24 @@ +import * as React from 'react'; +import * as CheckboxPrimitive from '@radix-ui/react-checkbox'; +import { CheckIcon } from '@radix-ui/react-icons'; + +import { cn } from '~/lib/utils'; + +const Checkbox = React.forwardRef, React.ComponentPropsWithoutRef>( + ({ className, ...props }, ref) => ( + + + + + + ), +); +Checkbox.displayName = CheckboxPrimitive.Root.displayName; + +export { Checkbox }; diff --git a/web/app/components/ui/dropdown-menu.tsx b/web/app/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..cacb43a --- /dev/null +++ b/web/app/components/ui/dropdown-menu.tsx @@ -0,0 +1,171 @@ +import * as React from 'react'; +import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'; +import { CheckIcon, ChevronRightIcon, DotFilledIcon } from '@radix-ui/react-icons'; + +import { cn } from '~/lib/utils'; + +const DropdownMenu = DropdownMenuPrimitive.Root; + +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; + +const DropdownMenuGroup = DropdownMenuPrimitive.Group; + +const DropdownMenuPortal = DropdownMenuPrimitive.Portal; + +const DropdownMenuSub = DropdownMenuPrimitive.Sub; + +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; + +const DropdownMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)); +DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName; + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName; + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)); +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)); +DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName; + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)); +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ); +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; + +const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes) => { + return ; +}; +DropdownMenuShortcut.displayName = 'DropdownMenuShortcut'; + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +}; diff --git a/web/app/components/ui/input.tsx b/web/app/components/ui/input.tsx new file mode 100644 index 0000000..e8bc696 --- /dev/null +++ b/web/app/components/ui/input.tsx @@ -0,0 +1,25 @@ +import * as React from 'react'; + +import { cn } from '~/lib/utils'; + +export interface InputProps extends React.InputHTMLAttributes { + error?: boolean; +} + +const Input = React.forwardRef(({ className, type, error, ...props }, ref) => { + return ( + + ); +}); +Input.displayName = 'Input'; + +export { Input }; diff --git a/web/app/components/ui/label.tsx b/web/app/components/ui/label.tsx new file mode 100644 index 0000000..3ce9c4e --- /dev/null +++ b/web/app/components/ui/label.tsx @@ -0,0 +1,15 @@ +import * as React from 'react'; +import * as LabelPrimitive from '@radix-ui/react-label'; +import { cva, type VariantProps } from 'class-variance-authority'; + +import { cn } from '~/lib/utils'; + +const labelVariants = cva('text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'); + +const Label = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & VariantProps +>(({ className, ...props }, ref) => ); +Label.displayName = LabelPrimitive.Root.displayName; + +export { Label }; diff --git a/web/app/components/ui/progress.tsx b/web/app/components/ui/progress.tsx new file mode 100644 index 0000000..e31544a --- /dev/null +++ b/web/app/components/ui/progress.tsx @@ -0,0 +1,15 @@ +import * as React from 'react'; +import * as ProgressPrimitive from '@radix-ui/react-progress'; + +import { cn } from '~/lib/utils'; + +const Progress = React.forwardRef, React.ComponentPropsWithoutRef>( + ({ className, value, ...props }, ref) => ( + + + + ), +); +Progress.displayName = ProgressPrimitive.Root.displayName; + +export { Progress }; diff --git a/web/app/components/ui/select.tsx b/web/app/components/ui/select.tsx new file mode 100644 index 0000000..1e50961 --- /dev/null +++ b/web/app/components/ui/select.tsx @@ -0,0 +1,117 @@ +import * as React from 'react'; +import { CaretSortIcon, CheckIcon, ChevronDownIcon, ChevronUpIcon } from '@radix-ui/react-icons'; +import * as SelectPrimitive from '@radix-ui/react-select'; + +import { cn } from '~/lib/utils'; + +const Select = SelectPrimitive.Root; + +const SelectGroup = SelectPrimitive.Group; + +const SelectValue = SelectPrimitive.Value; + +const SelectTrigger = React.forwardRef, React.ComponentPropsWithoutRef>( + ({ className, children, ...props }, ref) => ( + span]:line-clamp-1', + className, + )} + {...props}> + {children} + + + + + ), +); +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; + +const SelectScrollUpButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName; + +const SelectScrollDownButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName; + +const SelectContent = React.forwardRef, React.ComponentPropsWithoutRef>( + ({ className, children, position = 'popper', ...props }, ref) => ( + + + + + {children} + + + + + ), +); +SelectContent.displayName = SelectPrimitive.Content.displayName; + +const SelectLabel = React.forwardRef, React.ComponentPropsWithoutRef>( + ({ className, ...props }, ref) => , +); +SelectLabel.displayName = SelectPrimitive.Label.displayName; + +const SelectItem = React.forwardRef, React.ComponentPropsWithoutRef>( + ({ className, children, ...props }, ref) => ( + + + + + + + {children} + + ), +); +SelectItem.displayName = SelectPrimitive.Item.displayName; + +const SelectSeparator = React.forwardRef, React.ComponentPropsWithoutRef>( + ({ className, ...props }, ref) => , +); +SelectSeparator.displayName = SelectPrimitive.Separator.displayName; + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, +}; diff --git a/web/app/components/ui/separator.tsx b/web/app/components/ui/separator.tsx new file mode 100644 index 0000000..adeaba1 --- /dev/null +++ b/web/app/components/ui/separator.tsx @@ -0,0 +1,19 @@ +import * as React from 'react'; +import * as SeparatorPrimitive from '@radix-ui/react-separator'; + +import { cn } from '~/lib/utils'; + +const Separator = React.forwardRef, React.ComponentPropsWithoutRef>( + ({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => ( + + ), +); +Separator.displayName = SeparatorPrimitive.Root.displayName; + +export { Separator }; diff --git a/web/app/components/ui/tabs.tsx b/web/app/components/ui/tabs.tsx new file mode 100644 index 0000000..f61a798 --- /dev/null +++ b/web/app/components/ui/tabs.tsx @@ -0,0 +1,46 @@ +import * as React from 'react'; +import * as TabsPrimitive from '@radix-ui/react-tabs'; + +import { cn } from '~/lib/utils'; + +export type TabsProps = React.ComponentPropsWithoutRef; + +const Tabs = TabsPrimitive.Root; + +export type TabsListProps = React.ComponentPropsWithoutRef; + +const TabsList = React.forwardRef, TabsListProps>(({ className, ...props }, ref) => ( + +)); +TabsList.displayName = TabsPrimitive.List.displayName; + +export type TabsTriggerProps = React.ComponentPropsWithoutRef; + +const TabsTrigger = React.forwardRef, TabsTriggerProps>(({ className, ...props }, ref) => ( + +)); +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName; + +export type TabsContentProps = React.ComponentPropsWithoutRef; + +const TabsContent = React.forwardRef, TabsContentProps>(({ className, ...props }, ref) => ( + +)); +TabsContent.displayName = TabsPrimitive.Content.displayName; + +export { Tabs, TabsList, TabsTrigger, TabsContent }; diff --git a/web/app/components/ui/textarea.tsx b/web/app/components/ui/textarea.tsx new file mode 100644 index 0000000..774a257 --- /dev/null +++ b/web/app/components/ui/textarea.tsx @@ -0,0 +1,21 @@ +import * as React from 'react'; + +import { cn } from '~/lib/utils'; + +export interface TextareaProps extends React.TextareaHTMLAttributes {} + +const Textarea = React.forwardRef(({ className, ...props }, ref) => { + return ( +