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}
+
+ toastQueue.close(toast.key)}
+ slot="close"
+ >
+ ✕
+
+
+ );
+ }}
+
+ );
+}
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}