diff --git a/AGENTS.md b/AGENTS.md index ffdb8d3..a09fb51 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,6 +25,7 @@ The codebase uses Docker to run third-party services locally (Meilisearch for se - Use `== null` (double equals null) instead of either `=== null` or `=== undefined` (triple equals null / undefined), and the same for `!=`. This is to make `null` and `undefined` mean the same thing everywhere in our codebase to avoid any potential serialisation/deserialisation confusion or issues. - Avoid use of `any` unless absolutely necessary. - Prefer early-exit if statements rather than nested if statements. +- Avoid the use of barrel files # CSS diff --git a/src/app/layout.tsx b/src/app/layout.tsx index e11e426..502f9b6 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,5 +1,6 @@ import { ApiProvider } from 'app/api/api_provider'; import { MaintenanceBanner } from 'app/maintenance_banner'; +import { SkeletonProvider } from 'app/skeleton_provider'; import type { Metadata } from 'next'; import { getFlags } from 'services/server_context'; import { SessionProvider } from 'session/session_provider'; @@ -8,7 +9,7 @@ import { ThemeProvider } from 'ui/base/theme'; import { NavBar } from 'ui/nav_bar/nav_bar'; import './globals.css'; import styles from './layout.module.css'; -import { SkeletonProvider } from 'app/skeleton_provider'; +import { ToastProvider } from 'ui/base/toast/toast'; export const metadata: Metadata = { title: 'ParaDB', @@ -35,6 +36,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) ) : null}
{children}
+ diff --git a/src/app/map_list_presenter.ts b/src/app/map_list_presenter.ts index 34a8753..9926f64 100644 --- a/src/app/map_list_presenter.ts +++ b/src/app/map_list_presenter.ts @@ -4,6 +4,7 @@ import { makeAutoObservable, runInAction } from 'mobx'; import { computedFn } from 'mobx-utils'; import { MapSortableAttributes, PDMap, mapSortableAttributes } from 'schema/maps'; import { TableSortStore } from 'ui/base/table/table_presenter'; +import { ToastIntent, showToast } from 'ui/base/toast/toast'; import { getMapFileLink } from 'utils/maps'; const SEARCH_LIMIT = 20; diff --git a/src/app/page.tsx b/src/app/page.tsx index 7101cf1..7ca4f48 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -2,7 +2,9 @@ import { useApi } from 'app/api/api_provider'; import { MapListPresenter, MapListStore } from 'app/map_list_presenter'; +import { useSkeletonRef } from 'app/skeleton_provider'; import classNames from 'classnames'; +import useInfiniteScroll from 'hooks/useInfiniteScroll'; import { action, computed, observable, reaction } from 'mobx'; import { observer } from 'mobx-react'; import { useSearchParams } from 'next/navigation'; @@ -18,9 +20,6 @@ import { KnownDifficulty, difficultyColors, parseDifficulty } from 'utils/diffic import { RoutePath, routeFor } from 'utils/routes'; import styles from './page.module.css'; import { Search } from './search'; -import useInfiniteScroll from 'hooks/useInfiniteScroll'; -import { useSkeletonRef } from 'app/skeleton_provider'; -import { Tooltip } from 'ui/base/tooltip/tooltip'; export default function Page() { return ( diff --git a/src/ui/base/design_system/design_tokens.ts b/src/ui/base/design_system/design_tokens.ts index e13d1dd..ef6dd29 100644 --- a/src/ui/base/design_system/design_tokens.ts +++ b/src/ui/base/design_system/design_tokens.ts @@ -12,6 +12,7 @@ export const colors = { green: '#4c4', red: '#f00', purple: '#9b15f1', + accent: '#9b15f1', } as const; export const metrics = { diff --git a/src/ui/base/toast/toast.module.css b/src/ui/base/toast/toast.module.css new file mode 100644 index 0000000..b93cb0a --- /dev/null +++ b/src/ui/base/toast/toast.module.css @@ -0,0 +1,64 @@ +.toastRegion { + position: fixed; + right: calc(var(--gridBaseline) * 5); + bottom: calc(var(--gridBaseline) * 5); + z-index: 1000; + + display: flex; + flex-direction: column; + gap: var(--gridBaseline); +} + +.toast { + display: flex; + align-items: center; + gap: var(--gridBaseline); + + padding: calc(var(--gridBaseline) * 1.5) calc(var(--gridBaseline) * 2); + background: var(--colorBackground); + border: 1px solid var(--colorAccent); + + animation: slideIn 0.2s ease-out; +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateY(100%); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.content { + flex: 1; +} + +.closeButton { + display: flex; + align-items: center; + justify-content: center; + margin-left: var(--gridBaseline); + + background: transparent; + border: none; + color: var(--colorForeground); + cursor: pointer; + opacity: 0.6; + transition: opacity 0.15s ease; +} + +.closeButton:hover { + opacity: 1; +} + +/* Intent variants */ +.success { + border-color: var(--colorGreen); +} + +.error { + border-color: var(--colorRed); +} diff --git a/src/ui/base/toast/toast.tsx b/src/ui/base/toast/toast.tsx new file mode 100644 index 0000000..9e151b2 --- /dev/null +++ b/src/ui/base/toast/toast.tsx @@ -0,0 +1,69 @@ +'use client'; + +import { ToastQueue } from '@react-stately/toast'; +import classNames from 'classnames'; +import { + Button, + UNSTABLE_Toast as Toast, + UNSTABLE_ToastContent as ToastContent, + UNSTABLE_ToastRegion as ToastRegion, +} from 'react-aria-components'; +import { T } from 'ui/base/text/text'; +import styles from './toast.module.css'; + +const intentStyles: Record = { + [ToastIntent.DEFAULT]: styles.default, + [ToastIntent.ERROR]: styles.error, + [ToastIntent.SUCCESS]: styles.success, +}; + +export const enum ToastIntent { + DEFAULT = 'default', + SUCCESS = 'success', + ERROR = 'error', +} + +export type ToastData = { + message: string; + intent?: ToastIntent; +}; + +const toastQueue = new ToastQueue({ maxVisibleToasts: 5 }); + +/** + * Shows a toast notification. + * @param message The message to display + * @param intent The visual intent: 'default' (purple border), 'success' (green), or 'error' (red) + * @param timeout Duration in ms before auto-dismiss (default: 5000) + */ +export function showToast( + message: string, + intent: ToastIntent = ToastIntent.DEFAULT, + timeout: number = 5000 +): void { + toastQueue.add({ message, intent }, { timeout }); +} + +export function ToastProvider() { + return ( + + {({ toast }) => { + const intent = toast.content.intent ?? 'default'; + return ( + + + {toast.content.message} + + + + ); + }} + + ); +} diff --git a/src/ui/base/tooltip/tooltip.module.css b/src/ui/base/tooltip/tooltip.module.css index 40fa53d..4dc3193 100644 --- a/src/ui/base/tooltip/tooltip.module.css +++ b/src/ui/base/tooltip/tooltip.module.css @@ -3,7 +3,7 @@ color: var(--colorForeground); padding: var(--gridBaseline) calc(var(--gridBaseline) * 1.5); border-radius: calc(var(--gridBaseline) * 0.5); - border: 1px solid var(--colorPurple); + border: 1px solid var(--colorAccent); box-sizing: border-box; font-size: 14px; max-width: calc(var(--gridBaseline) * 32); diff --git a/src/ui/base/tooltip/tooltip.tsx b/src/ui/base/tooltip/tooltip.tsx index 44f2c3a..7a7e393 100644 --- a/src/ui/base/tooltip/tooltip.tsx +++ b/src/ui/base/tooltip/tooltip.tsx @@ -30,7 +30,7 @@ export const Tooltip = (props: TooltipProps) => { - + {content}