diff --git a/app/frontend/package.json b/app/frontend/package.json index 98ee2d4..751c2d8 100644 --- a/app/frontend/package.json +++ b/app/frontend/package.json @@ -23,5 +23,8 @@ "typescript": "6.0.2", "vite": "8.3.0", "vitest": "5.0.1" + }, + "dependencies": { + "@project-graphite/ui": "https://github.com/project-graphite/graphite-ui/releases/download/v0.1.0/project-graphite-ui-0.1.0.tgz" } } diff --git a/app/frontend/src/App.tsx b/app/frontend/src/App.tsx index 6d60fe5..eed5f0d 100644 --- a/app/frontend/src/App.tsx +++ b/app/frontend/src/App.tsx @@ -1,16 +1,18 @@ import type { ReactNode } from 'react'; -import { Navigate, Route, Routes, useLocation, useParams, type Location } from 'react-router'; -import { useAuth } from './auth'; -import { catalogSections, discoverCategories } from './catalog'; -import { Shell } from './components/Shell'; +import { Link, Navigate, Route, Routes, useLocation, useParams, type Location } from 'react-router'; import { FormPanelSkeleton, FormSkeleton, ListSkeleton, PageSkeleton, - PosterGridSkeleton, TabsSkeleton, -} from './components/Skeleton'; + UiProvider, + type UiLinkProps, +} from '@project-graphite/ui'; +import { useAuth } from './auth'; +import { catalogSections, discoverCategories } from './catalog'; +import { Shell } from './components/Shell'; +import { PosterGridSkeleton } from './components/Skeleton'; import { AccountSettingsPage } from './pages/AccountSettingsPage'; import { AdminPage } from './pages/AdminPage'; import { DiscoverPage } from './pages/DiscoverPage'; @@ -105,53 +107,59 @@ function SignedOutLogin() { return auth.user ? : ; } +function RouterLink({ href, ...props }: UiLinkProps) { + return ; +} + export function App() { return ( - - }> - } /> - } /> - } /> - } /> - } /> - } /> - - - - } - > - - - } - /> - } /> - } /> - } /> - } /> - } /> - } /> - }> - } /> - } /> - } /> - } /> + + + }> + } /> + } /> + } /> + } /> + } /> + } /> + + + + } + > + + + } + /> + } /> + } /> + } /> + } /> + } /> + } /> + }> + } /> + } /> + } /> + } /> + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - + + ); } diff --git a/app/frontend/src/api.ts b/app/frontend/src/api.ts index 0724fdb..94e8181 100644 --- a/app/frontend/src/api.ts +++ b/app/frontend/src/api.ts @@ -1,3 +1,5 @@ +import { isAbortError } from '@project-graphite/ui'; + export class ApiError extends Error { constructor( message: string, @@ -35,14 +37,6 @@ function reportOutage() { const gatewayStatuses = [500, 502, 503, 504]; -export function isAbortError(reason: unknown) { - return reason instanceof DOMException && reason.name === 'AbortError'; -} - -export function errorMessage(reason: unknown, fallback: string) { - return reason instanceof Error ? reason.message : fallback; -} - export async function apiRequest( path: string, init: RequestInit = {}, diff --git a/app/frontend/src/catalogSearch.ts b/app/frontend/src/catalogSearch.ts index 60c37fb..319abb3 100644 --- a/app/frontend/src/catalogSearch.ts +++ b/app/frontend/src/catalogSearch.ts @@ -1,6 +1,7 @@ import { useState } from 'react'; import { useNavigate } from 'react-router'; -import { apiRequest, errorMessage } from './api'; +import { errorMessage } from '@project-graphite/ui'; +import { apiRequest } from './api'; import { titleHref, type CatalogCategory } from './catalog'; export function sourceLink(value: string) { diff --git a/app/frontend/src/components/AccountMenu.tsx b/app/frontend/src/components/AccountMenu.tsx index aa39c4e..7fdceb7 100644 --- a/app/frontend/src/components/AccountMenu.tsx +++ b/app/frontend/src/components/AccountMenu.tsx @@ -1,59 +1,29 @@ -import { useCallback, useRef, useState } from 'react'; -import { Link } from 'react-router'; +import { Menu } from '@project-graphite/ui'; import { useAuth } from '../auth'; -import { useDismiss } from '../useDismiss'; export function AccountMenu({ onSignOut }: { onSignOut: () => void }) { const auth = useAuth(); - const [open, setOpen] = useState(false); - const root = useRef(null); - const close = useCallback(() => setOpen(false), []); - useDismiss(open, root, close); if (!auth.user) return null; const { displayName, handle, role } = auth.user; - const items = [ - [`/users/${handle}`, 'Your profile'], - [`/users/${handle}/settings`, 'Settings'], - ['/notifications', 'Notifications'], - ...(role === 'member' ? [] : [['/admin', 'Admin']]), - ]; return ( -
- - {open && ( -
-
-

{displayName}

-

@{handle}

-
- {items.map(([to, label]) => ( - - {label} - - ))} - + +

{displayName}

+

@{handle}

- )} -
+ } + items={[ + { label: 'Your profile', href: `/users/${handle}` }, + { label: 'Settings', href: `/users/${handle}/settings` }, + { label: 'Notifications', href: '/notifications' }, + ...(role === 'member' ? [] : [{ label: 'Admin', href: '/admin' }]), + { label: 'Sign out', onSelect: onSignOut, separated: true }, + ]} + label="Account" + trigger={displayName.charAt(0).toUpperCase()} + triggerLabel={`Account menu for ${displayName}`} + /> ); } diff --git a/app/frontend/src/components/CatalogCard.tsx b/app/frontend/src/components/CatalogCard.tsx index 7eaf472..ae21d29 100644 --- a/app/frontend/src/components/CatalogCard.tsx +++ b/app/frontend/src/components/CatalogCard.tsx @@ -1,7 +1,7 @@ import { useState, type FormEvent } from 'react'; import { Link, useNavigate } from 'react-router'; +import { actionSkeletonClass, Dialog, errorMessage, Skeleton, useSnackbar } from '@project-graphite/ui'; import { useAdultBlur } from '../adultContent'; -import { errorMessage } from '../api'; import { useAuth } from '../auth'; import { catalogRef, titleHref, type CatalogCandidate } from '../catalog'; import { @@ -10,11 +10,8 @@ import { type LibraryEntry, type LibraryState, } from '../library'; -import { useSnackbar } from '../snackbar'; import { useLibraryStates } from '../useLibraryStates'; -import { Dialog } from './Dialog'; import { Poster, posterGridClass } from './Poster'; -import { actionSkeletonClass, Skeleton } from './Skeleton'; function AddToListDialog({ item, diff --git a/app/frontend/src/components/ConfirmDialog.tsx b/app/frontend/src/components/ConfirmDialog.tsx deleted file mode 100644 index b1161cd..0000000 --- a/app/frontend/src/components/ConfirmDialog.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { useState, type ReactNode } from 'react'; -import { errorMessage } from '../api'; -import { Dialog } from './Dialog'; - -export function ConfirmDialog({ - children, - confirmLabel, - eyebrow, - onClose, - onConfirm, - title, -}: { - children: ReactNode; - confirmLabel: string; - eyebrow: string; - onClose: () => void; - onConfirm: () => Promise; - title: string; -}) { - const [busy, setBusy] = useState(false); - const [error, setError] = useState(''); - - async function confirm() { - setBusy(true); - setError(''); - try { - await onConfirm(); - onClose(); - } catch (reason) { - setError(errorMessage(reason, 'Could not finish this')); - setBusy(false); - } - } - - return ( - -
{children}
- {error &&

{error}

} -
- - -
-
- ); -} diff --git a/app/frontend/src/components/Credits.tsx b/app/frontend/src/components/Credits.tsx index 474d7f3..0949eca 100644 --- a/app/frontend/src/components/Credits.tsx +++ b/app/frontend/src/components/Credits.tsx @@ -1,5 +1,5 @@ +import { SmoothImage } from '@project-graphite/ui'; import type { CatalogCategory, CatalogDetails } from '../catalog'; -import { SmoothImage } from './SmoothImage'; const headings: Record = { movie: 'Cast & crew', diff --git a/app/frontend/src/components/Dialog.tsx b/app/frontend/src/components/Dialog.tsx deleted file mode 100644 index 6b316fc..0000000 --- a/app/frontend/src/components/Dialog.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { useEffect, useId, useRef, type ReactNode } from 'react'; - -export function Dialog({ - children, - eyebrow, - onClose, - title, -}: { - children: ReactNode; - eyebrow: string; - onClose: () => void; - title: string; -}) { - const dialog = useRef(null); - const titleId = useId(); - - useEffect(() => { - dialog.current?.showModal(); - }, []); - - return ( - { - event.preventDefault(); - onClose(); - }} - onClick={(event) => { - if (event.target === event.currentTarget) onClose(); - }} - ref={dialog} - > -
-
-
-

{eyebrow}

-

- {title} -

-
- -
- {children} -
-
- ); -} diff --git a/app/frontend/src/components/EmptyState.tsx b/app/frontend/src/components/EmptyState.tsx deleted file mode 100644 index cb29709..0000000 --- a/app/frontend/src/components/EmptyState.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import type { ReactNode } from 'react'; - -export function EmptyState({ - children, - title, -}: { - children?: ReactNode; - title: string; -}) { - return ( -
-

{title}

- {children} -
- ); -} diff --git a/app/frontend/src/components/Field.tsx b/app/frontend/src/components/Field.tsx deleted file mode 100644 index fc9b9c5..0000000 --- a/app/frontend/src/components/Field.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { useId, type InputHTMLAttributes, type ReactNode, type TextareaHTMLAttributes } from 'react'; - -function describedBy(id: string, error?: string, hint?: ReactNode) { - return error ? `${id}-error` : hint ? `${id}-hint` : undefined; -} - -function FieldMessage({ error, hint, id }: { error?: string; hint?: ReactNode; id: string }) { - return error ? ( - - {error} - - ) : hint ? ( - - {hint} - - ) : null; -} - -export function TextField({ - className = '', - error, - hint, - label, - ...input -}: InputHTMLAttributes & { error?: string; hint?: ReactNode; label: ReactNode }) { - const id = useId(); - return ( - - ); -} - -export function TextAreaField({ - error, - hint, - label, - ...textarea -}: TextareaHTMLAttributes & { error?: string; hint?: ReactNode; label: ReactNode }) { - const id = useId(); - return ( -