-
setOpen((current) => !current)}
- type="button"
- >
-
- {inbox.unread > 0 && {inbox.unread > 9 ? '9+' : inbox.unread} }
-
- {open && (
-
-
-
Notifications
- {inbox.unread > 0 && (
- void markAllRead()} type="button">
- mark all read
-
- )}
-
-
- {(error || inbox.checkFailed) && (
-
- {error || 'Could not check for new notifications. Trying again in a minute.'}
-
- )}
- {recent.error ? (
-
{recent.error}
- ) : !recent.data ? (
-
-
-
- ) : recent.data.results.length === 0 ? (
-
- Nothing yet. Turn on release notifications for a title in your library to hear about
- new episodes, chapters and releases here.
-
- ) : (
-
- )}
+ <>
+
+
Notifications
+ {inbox.unread > 0 && (
+ void markAllRead()} type="button">
+ mark all read
+
+ )}
+
+
+ {(error || inbox.checkFailed) && (
+
+ {error || 'Could not check for new notifications. Trying again in a minute.'}
+
+ )}
+ {recent.error ? (
+
{recent.error}
+ ) : !recent.data ? (
+
+
-
- See all notifications
-
-
- )}
-
+ ) : recent.data.results.length === 0 ? (
+
+ Nothing yet. Turn on release notifications for a title in your library to hear about
+ new episodes, chapters and releases here.
+
+ ) : (
+
+ )}
+
+
+ See all notifications
+
+ >
+ );
+}
+
+export function NotificationBell() {
+ const inbox = useInbox();
+ return (
+
+
+ {inbox.unread > 0 && {inbox.unread > 9 ? '9+' : inbox.unread} }
+ >
+ }
+ triggerLabel={inbox.unread ? `Notifications, ${inbox.unread} unread` : 'Notifications'}
+ >
+ {(close) => }
+
);
}
diff --git a/app/frontend/src/components/NotificationList.tsx b/app/frontend/src/components/NotificationList.tsx
index 930739f..5d79a47 100644
--- a/app/frontend/src/components/NotificationList.tsx
+++ b/app/frontend/src/components/NotificationList.tsx
@@ -1,8 +1,8 @@
import { Link } from 'react-router';
+import { timeAgo, useSnackbar } from '@project-graphite/ui';
import { useAdultBlur } from '../adultContent';
-import { timeAgo, useInbox, type InboxNotification } from '../inbox';
+import { useInbox, type InboxNotification } from '../inbox';
import { itemHref } from '../reviews';
-import { useSnackbar } from '../snackbar';
import { Poster } from './Poster';
function NotificationRow({
@@ -34,7 +34,7 @@ function NotificationRow({
);
return href ? (
{
onOpen?.();
void inbox
@@ -46,7 +46,7 @@ function NotificationRow({
{content}
) : (
-
{content}
+
{content}
);
}
diff --git a/app/frontend/src/components/Outage.tsx b/app/frontend/src/components/Outage.tsx
deleted file mode 100644
index 207f946..0000000
--- a/app/frontend/src/components/Outage.tsx
+++ /dev/null
@@ -1,95 +0,0 @@
-import { useEffect, useState, type ReactNode } from 'react';
-import { onOutage } from '../api';
-
-const firstRetryMs = 3_000;
-const longestRetryMs = 15_000;
-
-async function serverHealthy() {
- try {
- const response = await fetch('/api/v1/health', {
- cache: 'no-store',
- signal: AbortSignal.timeout(5_000),
- });
- return response.ok;
- } catch {
- return false;
- }
-}
-
-function OutagePage({ onRecovered }: { onRecovered: () => void }) {
- const [attempt, setAttempt] = useState(0);
- const [checking, setChecking] = useState(false);
- const offline = typeof navigator !== 'undefined' && navigator.onLine === false;
-
- useEffect(() => {
- let cancelled = false;
- const delay = Math.min(longestRetryMs, firstRetryMs * 2 ** attempt);
- const timer = window.setTimeout(async () => {
- setChecking(true);
- const healthy = await serverHealthy();
- if (cancelled) return;
- if (healthy) {
- onRecovered();
- return;
- }
- setChecking(false);
- setAttempt((current) => current + 1);
- }, delay);
- return () => {
- cancelled = true;
- window.clearTimeout(timer);
- };
- }, [attempt, onRecovered]);
-
- async function retry() {
- setChecking(true);
- if (await serverHealthy()) {
- onRecovered();
- return;
- }
- setChecking(false);
- setAttempt((current) => current + 1);
- }
-
- return (
-
-
{offline ? 'offline' : 'temporarily unavailable'}
-
- {offline ? 'You are offline' : 'Graphite Tracker is taking a short break'}
-
-
- {offline
- ? 'Reconnect to the internet to keep tracking.'
- : 'The server is probably being updated. This usually takes under a minute, and the page reloads by itself once it is back.'}
-
-
- void retry()} type="button">
- {checking ? 'Checking…' : 'Try again now'}
-
-
- {checking ? 'checking the server' : `retrying automatically · attempt ${attempt + 1}`}
-
-
-
- );
-}
-
-function reloadPage() {
- window.location.reload();
-}
-
-export function OutageGate({ children }: { children: ReactNode }) {
- const [down, setDown] = useState(false);
-
- useEffect(
- () =>
- onOutage(() => {
- void serverHealthy().then((healthy) => {
- if (!healthy) setDown(true);
- });
- }),
- [],
- );
-
- return down ?
: children;
-}
diff --git a/app/frontend/src/components/Pagination.tsx b/app/frontend/src/components/Pagination.tsx
deleted file mode 100644
index 65f90e1..0000000
--- a/app/frontend/src/components/Pagination.tsx
+++ /dev/null
@@ -1,40 +0,0 @@
-import { Link } from 'react-router';
-
-interface PaginationProps {
- page: number;
- totalPages: number;
- pageHref(page: number): string;
-}
-
-export function Pagination({ page, totalPages, pageHref }: PaginationProps) {
- const lastPage = Math.min(totalPages, 500);
- if (lastPage <= 1) {
- return null;
- }
-
- return (
-
- {page > 1 ? (
-
- Previous
-
- ) : (
-
- Previous
-
- )}
-
- Page {page.toLocaleString()} of {lastPage.toLocaleString()}
-
- {page < lastPage ? (
-
- Next
-
- ) : (
-
- Next
-
- )}
-
- );
-}
diff --git a/app/frontend/src/components/PasswordDialog.tsx b/app/frontend/src/components/PasswordDialog.tsx
index 680ac2a..a18c5f1 100644
--- a/app/frontend/src/components/PasswordDialog.tsx
+++ b/app/frontend/src/components/PasswordDialog.tsx
@@ -1,7 +1,5 @@
import { useState, type FormEvent, type ReactNode } from 'react';
-import { errorMessage } from '../api';
-import { Dialog } from './Dialog';
-import { TextField } from './Field';
+import { Dialog, errorMessage, TextField } from '@project-graphite/ui';
export function PasswordDialog({
children,
diff --git a/app/frontend/src/components/Poster.tsx b/app/frontend/src/components/Poster.tsx
index b006ef9..84bb79c 100644
--- a/app/frontend/src/components/Poster.tsx
+++ b/app/frontend/src/components/Poster.tsx
@@ -1,5 +1,5 @@
import { Link } from 'react-router';
-import { SmoothImage } from './SmoothImage';
+import { SmoothImage } from '@project-graphite/ui';
export const posterGridClass =
'grid grid-cols-2 gap-x-4 gap-y-8 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5';
diff --git a/app/frontend/src/components/ResendVerification.tsx b/app/frontend/src/components/ResendVerification.tsx
index 7cc8100..36a341b 100644
--- a/app/frontend/src/components/ResendVerification.tsx
+++ b/app/frontend/src/components/ResendVerification.tsx
@@ -1,5 +1,6 @@
import { useState } from 'react';
-import { apiRequest, errorMessage } from '../api';
+import { errorMessage } from '@project-graphite/ui';
+import { apiRequest } from '../api';
export function ResendVerification({ email }: { email: string }) {
const [state, setState] = useState<'idle' | 'sending' | 'sent'>('idle');
diff --git a/app/frontend/src/components/ReviewCard.tsx b/app/frontend/src/components/ReviewCard.tsx
index 23351e0..cf8c724 100644
--- a/app/frontend/src/components/ReviewCard.tsx
+++ b/app/frontend/src/components/ReviewCard.tsx
@@ -1,9 +1,8 @@
import { useState, type FormEvent } from 'react';
import { Link } from 'react-router';
-import { errorMessage } from '../api';
+import { Dialog, errorMessage } from '@project-graphite/ui';
import { useAuth } from '../auth';
import { itemHref, reportReasons, type ItemSummary, type PublicReview } from '../reviews';
-import { Dialog } from './Dialog';
function ReportDialog({ onClose, review }: { onClose: () => void; review: PublicReview }) {
const auth = useAuth();
diff --git a/app/frontend/src/components/ReviewEditor.tsx b/app/frontend/src/components/ReviewEditor.tsx
index e4e9a00..b4adbb6 100644
--- a/app/frontend/src/components/ReviewEditor.tsx
+++ b/app/frontend/src/components/ReviewEditor.tsx
@@ -1,10 +1,8 @@
import { useState, type FormEvent } from 'react';
-import { errorMessage } from '../api';
+import { ConfirmDialog, errorMessage, Toggle } from '@project-graphite/ui';
import { useAuth } from '../auth';
import { reviewBodyLimit, type OwnReview } from '../reviews';
-import { ConfirmDialog } from './ConfirmDialog';
import { ReviewCard } from './ReviewCard';
-import { Toggle } from './Toggle';
export function ReviewEditor({
itemId,
diff --git a/app/frontend/src/components/Shell.tsx b/app/frontend/src/components/Shell.tsx
index 5351715..87f78c3 100644
--- a/app/frontend/src/components/Shell.tsx
+++ b/app/frontend/src/components/Shell.tsx
@@ -1,20 +1,29 @@
import { useEffect, useState } from 'react';
import { Link, NavLink, Outlet, useLocation, useNavigate } from 'react-router';
-import { errorMessage } from '../api';
+import {
+ AppShell,
+ errorMessage,
+ OutageGate,
+ Skeleton,
+ SnackbarProvider,
+ type ShellNavItem,
+ type ShellTab,
+} from '@project-graphite/ui';
+import { onOutage } from '../api';
import { useAuth } from '../auth';
import { FooterSourceProvider, useCurrentFooterSource } from '../footerSource';
import { InboxProvider } from '../inbox';
-import { SnackbarProvider } from '../snackbar';
import { AccountMenu } from './AccountMenu';
import { Attribution } from './Attribution';
-import { Icon, type IconName } from './Icon';
import { NotificationBell } from './NotificationBell';
-import { OutageGate } from './Outage';
-import { Skeleton } from './Skeleton';
const navClass = ({ isActive }: { isActive: boolean }) =>
`nav-link whitespace-nowrap no-underline ${isActive ? 'text-ink' : 'text-muted hover:text-ink'}`;
+function within(pathname: string, path: string) {
+ return pathname === path || pathname.startsWith(`${path}/`);
+}
+
function SiteFooter() {
const source = useCurrentFooterSource();
return (
@@ -56,58 +65,30 @@ function ScrollToTop() {
return null;
}
-function MobileTabBar() {
- const auth = useAuth();
- const { pathname } = useLocation();
- const profile = auth.user ? `/users/${auth.user.handle}` : null;
- const tabs: Array<{ to: string; label: string; icon: IconName; active: boolean }> = [
- { to: '/', label: 'Home', icon: 'home', active: pathname === '/' || pathname === '/search' },
- {
- to: '/discover/movie/recent',
- label: 'Discover',
- icon: 'compass',
- active: pathname.startsWith('/discover') || pathname.startsWith('/titles'),
- },
- { to: '/games', label: 'Games', icon: 'gamepad', active: pathname.startsWith('/games') },
- {
- to: '/library',
- label: 'Library',
- icon: 'library',
- active: pathname.startsWith('/library') || pathname.startsWith('/import'),
- },
- profile
- ? { to: profile, label: 'Profile', icon: 'user', active: pathname.startsWith(profile) }
- : { to: '/login', label: 'Sign in', icon: 'user', active: pathname === '/login' },
- ];
-
+function GraphiteMark() {
return (
-
- {tabs.map((tab, index) =>
- index === tabs.length - 1 && !auth.ready ? (
-
-
-
-
- ) : (
-
-
- {tab.label}
-
- ),
- )}
-
+
+
+
+
);
}
export function Shell() {
const auth = useAuth();
const navigate = useNavigate();
+ const { pathname } = useLocation();
const [error, setError] = useState('');
+ const profile = auth.user ? `/users/${auth.user.handle}` : null;
async function signOut() {
setError('');
@@ -119,86 +100,81 @@ export function Shell() {
}
}
+ const nav: ShellNavItem[] = [
+ { href: '/discover/movie/recent', label: 'discover', active: within(pathname, '/discover/movie/recent') },
+ { href: '/games', label: 'games', active: within(pathname, '/games') },
+ ...(auth.user ? [{ href: '/library', label: 'library', active: within(pathname, '/library') }] : []),
+ ];
+
+ const tabs: ShellTab[] = [
+ { href: '/', label: 'Home', icon: 'home', active: pathname === '/' || pathname === '/search' },
+ {
+ href: '/discover/movie/recent',
+ label: 'Discover',
+ icon: 'compass',
+ active: pathname.startsWith('/discover') || pathname.startsWith('/titles'),
+ },
+ { href: '/games', label: 'Games', icon: 'gamepad', active: pathname.startsWith('/games') },
+ {
+ href: '/library',
+ label: 'Library',
+ icon: 'library',
+ active: pathname.startsWith('/library') || pathname.startsWith('/import'),
+ },
+ profile
+ ? { href: profile, label: 'Profile', icon: 'user', active: pathname.startsWith(profile), loading: !auth.ready }
+ : { href: '/login', label: 'Sign in', icon: 'user', active: pathname === '/login', loading: !auth.ready },
+ ];
+
return (
-
-
-
- skip to content
-
-
-
-
-
-
-
-
- Graphite Tracker
-
-
-
- discover
+
+
+
+
+
+ ) : auth.user ? (
+ <>
+
+ void signOut()} />
+ >
+ ) : (
+ <>
+ `${navClass(state)} mono-sm px-2`} to="/login">
+ sign in
-
- games
-
- {auth.user && (
-
- library
-
- )}
-
-
- {!auth.ready ? (
-
-
-
-
- ) : auth.user ? (
- <>
-
-
void signOut()} />
- >
- ) : (
- <>
- `${navClass(state)} mono-sm px-2`} to="/login">
- sign in
-
-
- Create account
-
- >
- )}
-
-
- {error && (
+
+ Create account
+
+ >
+ )
+ }
+ alert={
+ error && (
{error}
- )}
-
-
-
-
-
-
-
-
-
+ )
+ }
+ brand={{ href: '/', mark: , name: 'Graphite Tracker' }}
+ footer={ }
+ nav={nav}
+ tabs={tabs}
+ >
+
+
+
+
diff --git a/app/frontend/src/components/Skeleton.tsx b/app/frontend/src/components/Skeleton.tsx
index 6f87123..9e74269 100644
--- a/app/frontend/src/components/Skeleton.tsx
+++ b/app/frontend/src/components/Skeleton.tsx
@@ -1,28 +1,13 @@
-import type { ReactNode } from 'react';
+import {
+ actionSkeletonClass,
+ ListSkeleton,
+ PageSkeleton,
+ Placeholder,
+ Skeleton,
+ TabsSkeleton,
+} from '@project-graphite/ui';
import { posterGridClass } from './Poster';
-export function Skeleton({ className = '' }: { className?: string }) {
- return
;
-}
-
-export function Placeholder({
- children,
- className = '',
- label,
-}: {
- children: ReactNode;
- className?: string;
- label: string;
-}) {
- return (
-
- {children}
-
- );
-}
-
-export const actionSkeletonClass = 'h-[2.375rem] rounded-lg';
-
function PosterCardSkeleton({ action }: { action: boolean }) {
return (
@@ -63,101 +48,6 @@ export function PosterRowSkeleton({ label = 'Loading titles' }: { label?: string
);
}
-export function ListSkeleton({
- label = 'Loading',
- rows = 5,
-}: {
- label?: string;
- rows?: number;
-}) {
- return (
-
- {Array.from({ length: rows }, (_, index) => (
-
-
-
-
- ))}
-
- );
-}
-
-export function LinesSkeleton({
- className = '',
- label = 'Loading',
- lines = 3,
-}: {
- className?: string;
- label?: string;
- lines?: number;
-}) {
- return (
-
- {Array.from({ length: lines }, (_, index) => (
-
- ))}
-
- );
-}
-
-export function FormSkeleton({
- fields = 3,
- label = 'Loading settings',
-}: {
- fields?: number;
- label?: string;
-}) {
- return (
-
-
- {Array.from({ length: fields }, (_, index) => (
-
-
-
-
- ))}
-
-
- );
-}
-
-export function PageSkeleton({
- children,
- label = 'Loading page',
-}: {
- children?: ReactNode;
- label?: string;
-}) {
- return (
-
-
-
-
- {children ?? (
-
-
-
-
-
- )}
-
-
- );
-}
-
-export function TabsSkeleton({ count = 4 }: { count?: number }) {
- return (
-
- {Array.from({ length: count }, (_, index) => (
-
- ))}
-
- );
-}
-
export function ProfileSkeleton() {
return (
@@ -180,24 +70,6 @@ export function ProfileSkeleton() {
);
}
-export function FormPanelSkeleton({ fields = 2, label }: { fields?: number; label: string }) {
- return (
-
-
-
-
- {Array.from({ length: fields }, (_, index) => (
-
-
-
-
- ))}
-
-
-
- );
-}
-
export function TitleSkeleton({ backdrop }: { backdrop: boolean }) {
return (
diff --git a/app/frontend/src/components/SmoothImage.tsx b/app/frontend/src/components/SmoothImage.tsx
deleted file mode 100644
index f8264de..0000000
--- a/app/frontend/src/components/SmoothImage.tsx
+++ /dev/null
@@ -1,35 +0,0 @@
-import { useEffect, useRef, useState, type ReactNode } from 'react';
-
-export function SmoothImage({
- alt,
- fallback = null,
- src,
-}: {
- alt: string;
- fallback?: ReactNode;
- src: string;
-}) {
- const image = useRef(null);
- const [state, setState] = useState<'loading' | 'loaded' | 'failed'>('loading');
-
- useEffect(() => {
- if (image.current?.complete && image.current.naturalWidth > 0) setState('loaded');
- }, []);
-
- if (state === 'failed') return fallback;
- return (
- <>
- {state === 'loading' && }
- setState('failed')}
- onLoad={() => setState('loaded')}
- ref={image}
- referrerPolicy="no-referrer"
- src={src}
- />
- >
- );
-}
diff --git a/app/frontend/src/components/TitleReviews.tsx b/app/frontend/src/components/TitleReviews.tsx
index 5fbf2e4..7d9e3b5 100644
--- a/app/frontend/src/components/TitleReviews.tsx
+++ b/app/frontend/src/components/TitleReviews.tsx
@@ -1,5 +1,6 @@
import { useState } from 'react';
import { Link } from 'react-router';
+import { LinesSkeleton, ListSkeleton, Skeleton } from '@project-graphite/ui';
import { useAuth } from '../auth';
import { countLabel, type CatalogDetails } from '../catalog';
import type { LibraryEntry } from '../library';
@@ -7,7 +8,6 @@ import type { OwnReview, TitleReviews as TitleReviewsPage } from '../reviews';
import { useResource } from '../useResource';
import { ReviewCard } from './ReviewCard';
import { ReviewEditor } from './ReviewEditor';
-import { LinesSkeleton, ListSkeleton, Skeleton } from './Skeleton';
const notInLibrary =
'Add this title to your library and finish or drop it to rate and review it.';
diff --git a/app/frontend/src/components/Toggle.tsx b/app/frontend/src/components/Toggle.tsx
deleted file mode 100644
index 4d810ac..0000000
--- a/app/frontend/src/components/Toggle.tsx
+++ /dev/null
@@ -1,40 +0,0 @@
-import { useId } from 'react';
-
-export function Toggle({
- checked,
- description,
- disabled = false,
- label,
- onChange,
-}: {
- checked: boolean;
- description?: string;
- disabled?: boolean;
- label: string;
- onChange: (checked: boolean) => void;
-}) {
- const labelId = useId();
- return (
-
-
-
- {label}
-
- {description && {description} }
-
- onChange(!checked)}
- role="switch"
- type="button"
- >
-
-
-
- );
-}
diff --git a/app/frontend/src/components/YearPicker.tsx b/app/frontend/src/components/YearPicker.tsx
index 0dffdea..bac8d57 100644
--- a/app/frontend/src/components/YearPicker.tsx
+++ b/app/frontend/src/components/YearPicker.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useId, useRef, useState, type KeyboardEvent } from 'react';
+import { GridListbox } from '@project-graphite/ui';
const firstYear = 1950;
const lastYear = new Date().getFullYear() + 2;
@@ -9,9 +9,9 @@ const decades = Array.from(
const start = (Math.floor(lastYear / 10) - index) * 10;
return {
label: `${start}s`,
- years: Array.from({ length: 10 }, (_, offset) => start + 9 - offset).filter(
- (year) => year >= firstYear && year <= lastYear,
- ),
+ options: Array.from({ length: 10 }, (_, offset) => start + 9 - offset)
+ .filter((year) => year >= firstYear && year <= lastYear)
+ .map((year) => ({ label: String(year), value: String(year) })),
};
},
);
@@ -25,117 +25,5 @@ export function YearPicker({
label: string;
name: string;
}) {
- const [value, setValue] = useState(defaultValue);
- const [open, setOpen] = useState(false);
- const root = useRef(null);
- const trigger = useRef(null);
- const listbox = useRef(null);
- const labelId = useId();
-
- useEffect(() => {
- if (!open) return;
- const selected =
- listbox.current?.querySelector('[aria-selected="true"]') ??
- listbox.current?.querySelector('[role="option"]');
- const panel = listbox.current;
- if (selected && panel) {
- panel.scrollTop = selected.offsetTop - (panel.clientHeight - selected.offsetHeight) / 2;
- selected.focus({ preventScroll: true });
- }
- const close = (event: PointerEvent) => {
- if (!root.current?.contains(event.target as Node)) setOpen(false);
- };
- document.addEventListener('pointerdown', close);
- return () => document.removeEventListener('pointerdown', close);
- }, [open]);
-
- function choose(next: string) {
- setValue(next);
- setOpen(false);
- trigger.current?.focus();
- }
-
- function move(event: KeyboardEvent) {
- const options = [...(listbox.current?.querySelectorAll('[role="option"]') ?? [])];
- const current = options.indexOf(document.activeElement as HTMLElement);
- const step = { ArrowRight: 1, ArrowLeft: -1, ArrowDown: 5, ArrowUp: -5 }[event.key];
- if (event.key === 'Escape') {
- event.preventDefault();
- setOpen(false);
- trigger.current?.focus();
- } else if (event.key === 'Tab') {
- setOpen(false);
- } else if (step !== undefined || event.key === 'Home' || event.key === 'End') {
- event.preventDefault();
- const target =
- event.key === 'Home'
- ? 0
- : event.key === 'End'
- ? options.length - 1
- : Math.min(options.length - 1, Math.max(0, current + (step ?? 0)));
- options[target]?.focus();
- }
- }
-
- return (
-
-
{label}
-
-
-
setOpen((current) => !current)}
- ref={trigger}
- type="button"
- >
- {value || 'Any year'}
-
-
-
-
- {open && (
- <>
-
setOpen(false)} />
-
-
choose('')}
- role="option"
- type="button"
- >
- Any year
-
- {decades.map((decade) => (
-
-
{decade.label}
- {decade.years.map((year) => (
-
choose(String(year))}
- role="option"
- type="button"
- >
- {year}
-
- ))}
-
- ))}
-
- >
- )}
-
-
- );
+ return
;
}
diff --git a/app/frontend/src/inbox.tsx b/app/frontend/src/inbox.tsx
index 658163b..5740431 100644
--- a/app/frontend/src/inbox.tsx
+++ b/app/frontend/src/inbox.tsx
@@ -7,9 +7,9 @@ import {
useState,
type ReactNode,
} from 'react';
+import { useSnackbar } from '@project-graphite/ui';
import { useAuth } from './auth';
import { itemHref, type ItemSummary } from './reviews';
-import { useSnackbar } from './snackbar';
export interface InboxNotification {
id: string;
@@ -39,23 +39,6 @@ const InboxContext = createContext
({
markRead: async () => {},
});
-const relativeTime = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' });
-
-export function timeAgo(date: string) {
- const seconds = (new Date(date).getTime() - Date.now()) / 1000;
- const [unit, size] = (
- [
- ['year', 31_536_000],
- ['month', 2_592_000],
- ['week', 604_800],
- ['day', 86_400],
- ['hour', 3_600],
- ['minute', 60],
- ] as const
- ).find(([, length]) => Math.abs(seconds) >= length) ?? ['second', 1];
- return relativeTime.format(Math.round(seconds / size), unit);
-}
-
export function InboxProvider({ children }: { children: ReactNode }) {
const { request, user } = useAuth();
const show = useSnackbar();
@@ -78,7 +61,7 @@ export function InboxProvider({ children }: { children: ReactNode }) {
show({
message: notification.item.title,
detail: notification.release,
- action: href ? { label: 'Open', to: href } : undefined,
+ action: href ? { label: 'Open', href } : undefined,
});
}
} catch {
diff --git a/app/frontend/src/pages/AccountSettingsPage.tsx b/app/frontend/src/pages/AccountSettingsPage.tsx
index 330eef8..5729927 100644
--- a/app/frontend/src/pages/AccountSettingsPage.tsx
+++ b/app/frontend/src/pages/AccountSettingsPage.tsx
@@ -1,10 +1,7 @@
import { useState, type ReactNode } from 'react';
import { useNavigate } from 'react-router';
-import { errorMessage } from '../api';
+import { ConfirmDialog, errorMessage, FormSkeleton, TextField } from '@project-graphite/ui';
import { useAuth } from '../auth';
-import { ConfirmDialog } from '../components/ConfirmDialog';
-import { TextField } from '../components/Field';
-import { FormSkeleton } from '../components/Skeleton';
import { useResource } from '../useResource';
import { emailAddress, password as newPassword, required, useFormErrors } from '../validation';
import type { Me } from './SettingsPage';
diff --git a/app/frontend/src/pages/AdminPage.tsx b/app/frontend/src/pages/AdminPage.tsx
index 752acce..c3a51ea 100644
--- a/app/frontend/src/pages/AdminPage.tsx
+++ b/app/frontend/src/pages/AdminPage.tsx
@@ -1,13 +1,17 @@
import { useState, type FormEvent, type ReactNode } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router';
-import { errorMessage, type Page } from '../api';
+import {
+ EmptyState,
+ errorMessage,
+ LinesSkeleton,
+ ListSkeleton,
+ Pagination,
+ Toggle,
+} from '@project-graphite/ui';
+import type { Page } from '../api';
import { useAuth, type UserRole } from '../auth';
import { catalogCategories, categoryLabels, type CatalogCategory } from '../catalog';
-import { EmptyState } from '../components/EmptyState';
-import { Pagination } from '../components/Pagination';
import { PasswordDialog } from '../components/PasswordDialog';
-import { LinesSkeleton, ListSkeleton } from '../components/Skeleton';
-import { Toggle } from '../components/Toggle';
import { itemHref, reportReasons, type ItemSummary } from '../reviews';
import { useSiteSettings, type SiteSettings } from '../site';
import type { SourceSettings } from '../sources';
@@ -238,7 +242,7 @@ function Listing({
) : (
{children(resource.data.results)}
-
+
);
}
diff --git a/app/frontend/src/pages/DiscoverPage.tsx b/app/frontend/src/pages/DiscoverPage.tsx
index c2576ef..0097f94 100644
--- a/app/frontend/src/pages/DiscoverPage.tsx
+++ b/app/frontend/src/pages/DiscoverPage.tsx
@@ -1,5 +1,6 @@
import { useState, type FormEvent } from 'react';
import { Link, NavLink, useNavigate, useSearchParams } from 'react-router';
+import { EmptyState, Icon, Pagination, Skeleton } from '@project-graphite/ui';
import { useAuth } from '../auth';
import {
categoryLabels,
@@ -12,10 +13,7 @@ import {
} from '../catalog';
import { useCatalogSearch } from '../catalogSearch';
import { CatalogGrid } from '../components/CatalogCard';
-import { EmptyState } from '../components/EmptyState';
-import { Pagination } from '../components/Pagination';
-import { PosterGridSkeleton, Skeleton } from '../components/Skeleton';
-import { Icon } from '../components/Icon';
+import { PosterGridSkeleton } from '../components/Skeleton';
import { YearPicker } from '../components/YearPicker';
import { useFooterSource } from '../footerSource';
import { useSiteSettings } from '../site';
@@ -382,7 +380,7 @@ export function DiscoverPage({
) : (
)}
-
+
)
)}
diff --git a/app/frontend/src/pages/ImportBatchPage.tsx b/app/frontend/src/pages/ImportBatchPage.tsx
index 19d045e..0afcc6a 100644
--- a/app/frontend/src/pages/ImportBatchPage.tsx
+++ b/app/frontend/src/pages/ImportBatchPage.tsx
@@ -1,14 +1,18 @@
import { useEffect, useState } from 'react';
import { Link, useNavigate, useParams, useSearchParams } from 'react-router';
-import { errorMessage, type Page } from '../api';
+import {
+ ConfirmDialog,
+ EmptyState,
+ errorMessage,
+ ListSkeleton,
+ PageSkeleton,
+ Pagination,
+} from '@project-graphite/ui';
+import type { Page } from '../api';
import { useAdultBlur } from '../adultContent';
import { useAuth } from '../auth';
import { categoryLabels } from '../catalog';
-import { ConfirmDialog } from '../components/ConfirmDialog';
-import { EmptyState } from '../components/EmptyState';
-import { Pagination } from '../components/Pagination';
import { Poster } from '../components/Poster';
-import { ListSkeleton, PageSkeleton } from '../components/Skeleton';
import {
importApps,
importStateLabels,
@@ -337,6 +341,7 @@ export function ImportBatchPage() {
))}
`/import/${id}?match=${match}&page=${next}`}
totalPages={candidates.data.totalPages}
diff --git a/app/frontend/src/pages/ImportPage.tsx b/app/frontend/src/pages/ImportPage.tsx
index ff95ac7..0baca8b 100644
--- a/app/frontend/src/pages/ImportPage.tsx
+++ b/app/frontend/src/pages/ImportPage.tsx
@@ -1,8 +1,7 @@
import { useState, type FormEvent } from 'react';
import { Link, useNavigate } from 'react-router';
-import { errorMessage } from '../api';
+import { errorMessage, ListSkeleton } from '@project-graphite/ui';
import { useAuth } from '../auth';
-import { ListSkeleton } from '../components/Skeleton';
import { importApps, importStateLabels, type ImportDetail, type ImportSummary } from '../imports';
import { useResource } from '../useResource';
diff --git a/app/frontend/src/pages/LibraryPage.tsx b/app/frontend/src/pages/LibraryPage.tsx
index b1ceee5..32b0b62 100644
--- a/app/frontend/src/pages/LibraryPage.tsx
+++ b/app/frontend/src/pages/LibraryPage.tsx
@@ -1,12 +1,11 @@
import type { FormEvent } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router';
+import { EmptyState, ListSkeleton, Pagination, Skeleton } from '@project-graphite/ui';
import type { Page } from '../api';
import { catalogCategories, categoryLabels, countLabel } from '../catalog';
-import { EmptyState } from '../components/EmptyState';
import { LibraryCard } from '../components/LibraryCard';
-import { Pagination } from '../components/Pagination';
import { posterGridClass } from '../components/Poster';
-import { ListSkeleton, PosterGridSkeleton, Skeleton } from '../components/Skeleton';
+import { PosterGridSkeleton } from '../components/Skeleton';
import { libraryStateLabels, libraryStates, type LibraryEntry } from '../library';
import { useResource } from '../useResource';
@@ -155,7 +154,7 @@ export function LibraryPage() {
))}
)}
-
+
)
)}
diff --git a/app/frontend/src/pages/LoginPage.tsx b/app/frontend/src/pages/LoginPage.tsx
index c6ec1fd..8cee4c6 100644
--- a/app/frontend/src/pages/LoginPage.tsx
+++ b/app/frontend/src/pages/LoginPage.tsx
@@ -1,8 +1,7 @@
import { useState, type FormEvent } from 'react';
import { Link } from 'react-router';
-import { errorMessage } from '../api';
+import { errorMessage, TextField } from '@project-graphite/ui';
import { useAuth } from '../auth';
-import { TextField } from '../components/Field';
import { ResendVerification } from '../components/ResendVerification';
import { emailAddress, required, useFormErrors } from '../validation';
diff --git a/app/frontend/src/pages/NotificationSettingsPage.tsx b/app/frontend/src/pages/NotificationSettingsPage.tsx
index 536149c..5f41891 100644
--- a/app/frontend/src/pages/NotificationSettingsPage.tsx
+++ b/app/frontend/src/pages/NotificationSettingsPage.tsx
@@ -1,9 +1,8 @@
import { useState } from 'react';
import { Link } from 'react-router';
-import { errorMessage } from '../api';
+import { errorMessage, FormSkeleton } from '@project-graphite/ui';
import { useAuth } from '../auth';
import { catalogCategories, categoryLabels } from '../catalog';
-import { FormSkeleton } from '../components/Skeleton';
import type { NotificationPreferences } from '../notifications';
import { useResource } from '../useResource';
diff --git a/app/frontend/src/pages/NotificationsPage.tsx b/app/frontend/src/pages/NotificationsPage.tsx
index 3d8832c..4b32e65 100644
--- a/app/frontend/src/pages/NotificationsPage.tsx
+++ b/app/frontend/src/pages/NotificationsPage.tsx
@@ -1,10 +1,8 @@
import { useState } from 'react';
import { Link, useSearchParams } from 'react-router';
-import { errorMessage, type Page } from '../api';
-import { EmptyState } from '../components/EmptyState';
+import { EmptyState, errorMessage, ListSkeleton, Pagination } from '@project-graphite/ui';
+import type { Page } from '../api';
import { NotificationList } from '../components/NotificationList';
-import { Pagination } from '../components/Pagination';
-import { ListSkeleton } from '../components/Skeleton';
import { useInbox, type InboxNotification } from '../inbox';
import { useResource } from '../useResource';
@@ -65,6 +63,7 @@ export function NotificationsPage() {
)}
{notifications.data && (
`/notifications?page=${next}`}
totalPages={notifications.data.totalPages}
diff --git a/app/frontend/src/pages/PasswordPages.tsx b/app/frontend/src/pages/PasswordPages.tsx
index ef60cee..f00e6c5 100644
--- a/app/frontend/src/pages/PasswordPages.tsx
+++ b/app/frontend/src/pages/PasswordPages.tsx
@@ -1,7 +1,7 @@
import { useState, type FormEvent } from 'react';
import { Link, useSearchParams } from 'react-router';
-import { apiRequest, errorMessage } from '../api';
-import { TextField } from '../components/Field';
+import { errorMessage, TextField } from '@project-graphite/ui';
+import { apiRequest } from '../api';
import { emailAddress, password, required, useFormErrors } from '../validation';
export function ForgotPasswordPage() {
diff --git a/app/frontend/src/pages/ProfilePage.tsx b/app/frontend/src/pages/ProfilePage.tsx
index 45397c0..a54b8fd 100644
--- a/app/frontend/src/pages/ProfilePage.tsx
+++ b/app/frontend/src/pages/ProfilePage.tsx
@@ -1,14 +1,13 @@
import type { ReactNode } from 'react';
import { Link, NavLink, useParams, useSearchParams } from 'react-router';
+import { EmptyState, ListSkeleton, Pagination } from '@project-graphite/ui';
import { useAdultBlur } from '../adultContent';
import type { Page } from '../api';
import { useAuth } from '../auth';
import { catalogCategories, categoryLabels, countLabel } from '../catalog';
-import { EmptyState } from '../components/EmptyState';
-import { Pagination } from '../components/Pagination';
import { Poster, posterGridClass } from '../components/Poster';
import { ReviewCard } from '../components/ReviewCard';
-import { ListSkeleton, PosterGridSkeleton, ProfileSkeleton } from '../components/Skeleton';
+import { PosterGridSkeleton, ProfileSkeleton } from '../components/Skeleton';
import {
libraryStateLabels,
libraryStates,
@@ -123,6 +122,7 @@ function SectionPage({
children(list.data.results)
)}
`/users/${handle}?tab=${section}&page=${next}${filter}`}
totalPages={list.data.totalPages}
diff --git a/app/frontend/src/pages/RegisterPage.tsx b/app/frontend/src/pages/RegisterPage.tsx
index 77efbb9..b346110 100644
--- a/app/frontend/src/pages/RegisterPage.tsx
+++ b/app/frontend/src/pages/RegisterPage.tsx
@@ -1,8 +1,7 @@
import { useState, type FormEvent } from 'react';
import { Link } from 'react-router';
-import { errorMessage } from '../api';
+import { errorMessage, TextField } from '@project-graphite/ui';
import { useAuth } from '../auth';
-import { TextField } from '../components/Field';
import { ResendVerification } from '../components/ResendVerification';
import { atLeast, atMost, emailAddress, password, required, useFormErrors } from '../validation';
diff --git a/app/frontend/src/pages/SearchPage.tsx b/app/frontend/src/pages/SearchPage.tsx
index 151140c..9239978 100644
--- a/app/frontend/src/pages/SearchPage.tsx
+++ b/app/frontend/src/pages/SearchPage.tsx
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState, type FormEvent } from 'react';
import { useSearchParams } from 'react-router';
+import { EmptyState, Skeleton } from '@project-graphite/ui';
import {
catalogCategories,
categoryLabels,
@@ -9,8 +10,6 @@ import {
} from '../catalog';
import { useCatalogSearch } from '../catalogSearch';
import { CatalogRow } from '../components/CatalogRow';
-import { EmptyState } from '../components/EmptyState';
-import { Skeleton } from '../components/Skeleton';
import { useResource } from '../useResource';
import { searchQuery, useFormErrors } from '../validation';
diff --git a/app/frontend/src/pages/SettingsPage.tsx b/app/frontend/src/pages/SettingsPage.tsx
index 68df018..857adb9 100644
--- a/app/frontend/src/pages/SettingsPage.tsx
+++ b/app/frontend/src/pages/SettingsPage.tsx
@@ -1,11 +1,15 @@
import { useState, type FormEvent } from 'react';
import { Link, Navigate, NavLink, Outlet, useParams } from 'react-router';
-import { errorMessage } from '../api';
+import {
+ Dialog,
+ errorMessage,
+ FormSkeleton,
+ LinesSkeleton,
+ TextAreaField,
+ TextField,
+ Toggle,
+} from '@project-graphite/ui';
import { useAuth } from '../auth';
-import { Dialog } from '../components/Dialog';
-import { TextAreaField, TextField } from '../components/Field';
-import { FormSkeleton, LinesSkeleton } from '../components/Skeleton';
-import { Toggle } from '../components/Toggle';
import { useSiteSettings } from '../site';
import { OwnerTabs } from './ProfilePage';
import { useResource } from '../useResource';
diff --git a/app/frontend/src/pages/SourcesPage.tsx b/app/frontend/src/pages/SourcesPage.tsx
index 27413dd..e41ef27 100644
--- a/app/frontend/src/pages/SourcesPage.tsx
+++ b/app/frontend/src/pages/SourcesPage.tsx
@@ -1,8 +1,7 @@
import { useState } from 'react';
-import { errorMessage } from '../api';
+import { errorMessage, Placeholder, Skeleton } from '@project-graphite/ui';
import { useAuth } from '../auth';
import { catalogCategories, categoryLabels } from '../catalog';
-import { Placeholder, Skeleton } from '../components/Skeleton';
import type { SourceSettings } from '../sources';
import { useResource } from '../useResource';
diff --git a/app/frontend/src/pages/TitleDetailsPage.tsx b/app/frontend/src/pages/TitleDetailsPage.tsx
index b6fb6e6..e34cd9c 100644
--- a/app/frontend/src/pages/TitleDetailsPage.tsx
+++ b/app/frontend/src/pages/TitleDetailsPage.tsx
@@ -1,5 +1,6 @@
import { useState } from 'react';
import { Link, Navigate, useParams, useSearchParams } from 'react-router';
+import { EmptyState, Placeholder, Skeleton, SmoothImage } from '@project-graphite/ui';
import { useAdultBlur } from '../adultContent';
import { useAuth } from '../auth';
import {
@@ -12,11 +13,9 @@ import {
} from '../catalog';
import { AddToListButton } from '../components/CatalogCard';
import { Credits } from '../components/Credits';
-import { EmptyState } from '../components/EmptyState';
import { LibraryEntryEditor } from '../components/LibraryEntryEditor';
import { Poster } from '../components/Poster';
-import { SmoothImage } from '../components/SmoothImage';
-import { Placeholder, Skeleton, TitleSkeleton } from '../components/Skeleton';
+import { TitleSkeleton } from '../components/Skeleton';
import { TitleReviews } from '../components/TitleReviews';
import { useFooterSource } from '../footerSource';
import { useSiteSettings } from '../site';
diff --git a/app/frontend/src/pages/TrackGamesPage.tsx b/app/frontend/src/pages/TrackGamesPage.tsx
index 489c74f..1b0f8a3 100644
--- a/app/frontend/src/pages/TrackGamesPage.tsx
+++ b/app/frontend/src/pages/TrackGamesPage.tsx
@@ -1,15 +1,14 @@
import type { FormEvent } from 'react';
import { Link, useSearchParams } from 'react-router';
+import { EmptyState, Pagination, Skeleton } from '@project-graphite/ui';
import type { Page } from '../api';
import { useAuth } from '../auth';
import { countLabel, type CatalogResponse } from '../catalog';
import { useCatalogSearch } from '../catalogSearch';
import { CatalogGrid } from '../components/CatalogCard';
-import { EmptyState } from '../components/EmptyState';
import { LibraryCard } from '../components/LibraryCard';
-import { Pagination } from '../components/Pagination';
import { posterGridClass } from '../components/Poster';
-import { PosterGridSkeleton, Skeleton } from '../components/Skeleton';
+import { PosterGridSkeleton } from '../components/Skeleton';
import { useFooterSource } from '../footerSource';
import type { LibraryEntry } from '../library';
import { useResource, type Resource } from '../useResource';
@@ -158,6 +157,7 @@ export function TrackGamesPage() {
)}
`/games?q=${encodeURIComponent(query)}&page=${next}`}
totalPages={results.data.totalPages}
diff --git a/app/frontend/src/pages/UnsubscribePage.tsx b/app/frontend/src/pages/UnsubscribePage.tsx
index 6c3f01e..c6213b8 100644
--- a/app/frontend/src/pages/UnsubscribePage.tsx
+++ b/app/frontend/src/pages/UnsubscribePage.tsx
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { Link, useSearchParams } from 'react-router';
-import { apiRequest, errorMessage } from '../api';
+import { errorMessage } from '@project-graphite/ui';
+import { apiRequest } from '../api';
import { categoryLabels, type CatalogCategory } from '../catalog';
type Unsubscribed =
diff --git a/app/frontend/src/pages/VerifyPage.tsx b/app/frontend/src/pages/VerifyPage.tsx
index a88af35..36a83d2 100644
--- a/app/frontend/src/pages/VerifyPage.tsx
+++ b/app/frontend/src/pages/VerifyPage.tsx
@@ -1,8 +1,7 @@
import { useState, type FormEvent } from 'react';
import { Link, useSearchParams } from 'react-router';
-import { errorMessage } from '../api';
+import { errorMessage, TextField } from '@project-graphite/ui';
import { useAuth } from '../auth';
-import { TextField } from '../components/Field';
import { required, useFormErrors } from '../validation';
export function VerifyPage() {
diff --git a/app/frontend/src/snackbar.tsx b/app/frontend/src/snackbar.tsx
deleted file mode 100644
index d7d35f4..0000000
--- a/app/frontend/src/snackbar.tsx
+++ /dev/null
@@ -1,101 +0,0 @@
-import {
- createContext,
- useCallback,
- useContext,
- useEffect,
- useRef,
- useState,
- type ReactNode,
-} from 'react';
-import { Link } from 'react-router';
-
-interface Snack {
- id: number;
- message: string;
- detail?: string;
- action?: { label: string; to: string };
-}
-
-type ShowSnack = (snack: Omit) => void;
-
-const SnackbarContext = createContext(() => {});
-
-const visibleMs = 6_000;
-
-function Snackbar({ onDismiss, snack }: { onDismiss: (id: number) => void; snack: Snack }) {
- const [paused, setPaused] = useState(false);
- const [leaving, setLeaving] = useState(false);
-
- const leave = useCallback(() => {
- if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) onDismiss(snack.id);
- else setLeaving(true);
- }, [onDismiss, snack.id]);
-
- useEffect(() => {
- if (paused || leaving) return;
- const timer = window.setTimeout(leave, visibleMs);
- return () => window.clearTimeout(timer);
- }, [leave, leaving, paused]);
-
- return (
- {
- if (leaving && event.target === event.currentTarget) onDismiss(snack.id);
- }}
- onBlur={() => setPaused(false)}
- onFocus={() => setPaused(true)}
- onMouseEnter={() => setPaused(true)}
- onMouseLeave={() => setPaused(false)}
- >
-
-
{snack.message}
- {snack.detail &&
{snack.detail}
}
-
- {snack.action && (
-
- {snack.action.label}
-
- )}
-
- ×
-
-
- );
-}
-
-export function SnackbarProvider({ children }: { children: ReactNode }) {
- const [snacks, setSnacks] = useState([]);
- const nextId = useRef(0);
-
- const show = useCallback((snack) => {
- nextId.current += 1;
- const id = nextId.current;
- setSnacks((current) => [...current.slice(-2), { ...snack, id }]);
- }, []);
-
- const dismiss = useCallback(
- (id: number) => setSnacks((current) => current.filter((snack) => snack.id !== id)),
- [],
- );
-
- return (
-
- {children}
-
- {snacks.map((snack) => (
-
- ))}
-
-
- );
-}
-
-export function useSnackbar() {
- return useContext(SnackbarContext);
-}
diff --git a/app/frontend/src/styles/global.css b/app/frontend/src/styles/global.css
index b314b35..74a4560 100644
--- a/app/frontend/src/styles/global.css
+++ b/app/frontend/src/styles/global.css
@@ -1,369 +1,8 @@
@import 'tailwindcss';
-
-@theme {
- --color-paper: #0c0c0e;
- --color-surface: #131316;
- --color-line: #26262c;
- --color-line-soft: #1c1c21;
- --color-ink: #e8e8ea;
- --color-muted: #8b8b95;
- --color-faint: #7d7d87;
- --color-danger: #f2a09a;
- --font-sans: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
- --font-mono: ui-monospace, 'SF Mono', 'JetBrains Mono', Menlo, Consolas, monospace;
-}
-
-@utility shell {
- margin-inline: auto;
- width: min(100% - 2rem, 72rem);
-}
-
-@utility page-title {
- margin: 0.75rem 0 0;
- font-size: clamp(1.95rem, 4.6vw, 2.6rem);
- font-weight: 640;
- line-height: 1.1;
- letter-spacing: -0.036em;
-}
-
-@utility mono-sm {
- font-family: var(--font-mono);
- font-size: 0.82rem;
-}
-
-@utility rule-link {
- border-bottom: 1px solid var(--color-line);
- padding-bottom: 1px;
- color: var(--color-ink);
- text-decoration: none;
- transition: border-color 0.2s ease;
-
- &:hover {
- border-bottom-color: var(--color-ink);
- }
-}
-
-:root {
- color-scheme: dark;
- accent-color: var(--color-ink);
- font-family: var(--font-sans);
- background: var(--color-paper);
- color: var(--color-ink);
- font-synthesis: none;
- scroll-padding-top: 5rem;
- scrollbar-color: var(--color-line) var(--color-paper);
- scrollbar-gutter: stable;
-}
-
-@layer base {
- * {
- scrollbar-color: var(--color-line) transparent;
- scrollbar-width: thin;
- }
-}
-
-@supports not (scrollbar-color: auto) {
- ::-webkit-scrollbar {
- width: 11px;
- height: 11px;
- }
-
- ::-webkit-scrollbar-track {
- background: transparent;
- }
-
- ::-webkit-scrollbar-thumb {
- border: 3px solid transparent;
- border-radius: 999px;
- background: var(--color-line) padding-box;
- }
-
- ::-webkit-scrollbar-thumb:hover {
- background-color: var(--color-muted);
- }
-}
-
-body {
- margin: 0;
- min-width: 320px;
- background: var(--color-paper);
- line-height: 1.55;
- -webkit-font-smoothing: antialiased;
- -webkit-tap-highlight-color: transparent;
-}
-
-@layer base {
- input:not([type='checkbox'], [type='radio']),
- select,
- textarea {
- width: 100%;
- border: 1px solid var(--color-line);
- border-radius: 0.5rem;
- background: var(--color-surface);
- padding: 0.75rem 0.875rem;
- color: var(--color-ink);
- transition: border-color 160ms ease;
- }
-
- input:not([type='checkbox'], [type='radio']):hover,
- select:hover,
- textarea:hover {
- border-color: var(--color-muted);
- }
-
- textarea {
- resize: vertical;
- font: inherit;
- line-height: 1.55;
- scrollbar-gutter: stable;
- }
-
- select {
- appearance: none;
- background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='none' viewBox='0 0 24 24'%3E%3Cpath d='m6 9 6 6 6-6' stroke='%238b8b95' stroke-linecap='round' stroke-width='2'/%3E%3C/svg%3E");
- background-position: right 0.875rem center;
- background-repeat: no-repeat;
- padding-right: 2.5rem;
- }
-
- input::placeholder {
- color: var(--color-faint);
- }
-
- input[aria-invalid='true'],
- textarea[aria-invalid='true'],
- .file-picker[aria-invalid='true'] {
- border-color: var(--color-danger);
- }
-
- input[type='search']::-webkit-search-cancel-button,
- input[type='search']::-webkit-search-decoration {
- appearance: none;
- }
-
- input::-webkit-outer-spin-button,
- input::-webkit-inner-spin-button {
- margin: 0;
- appearance: none;
- }
-
- input[type='number'] {
- appearance: textfield;
- }
-}
-
-:focus-visible {
- outline: 2px solid var(--color-ink);
- outline-offset: 3px;
-}
+@import '@project-graphite/ui/theme.css';
+@source '../../../node_modules/@project-graphite/ui/dist';
@layer components {
- .skip-link {
- position: fixed;
- top: 0.75rem;
- left: 0.75rem;
- z-index: 50;
- transform: translateY(-200%);
- border-radius: 0.5rem;
- border: 1px solid var(--color-line);
- background: var(--color-surface);
- padding: 0.6rem 0.9rem;
- color: var(--color-ink);
- }
-
- .skip-link:focus {
- transform: translateY(0);
- }
-
- .eyebrow {
- color: var(--color-faint);
- font-family: var(--font-mono);
- font-size: 0.75rem;
- text-transform: lowercase;
- }
-
- .primary-button,
- .secondary-button {
- align-items: center;
- justify-content: center;
- border-radius: 0.5rem;
- padding: 0.75rem 1rem;
- font-weight: 620;
- text-decoration: none;
- transition:
- background-color 160ms ease,
- border-color 160ms ease,
- color 160ms ease,
- transform 120ms ease;
- }
-
- .primary-button {
- border: 1px solid var(--color-ink);
- background: var(--color-ink);
- color: var(--color-paper);
- }
-
- .primary-button:hover {
- background: #fff;
- }
-
- .secondary-button {
- border: 1px solid var(--color-line);
- background: transparent;
- color: var(--color-ink);
- }
-
- .secondary-button:hover {
- border-color: var(--color-muted);
- background: var(--color-line-soft);
- }
-
- .primary-button:disabled,
- .secondary-button:disabled {
- cursor: not-allowed;
- opacity: 0.55;
- }
-
- .text-button {
- border: 0;
- background: transparent;
- padding: 0;
- color: var(--color-muted);
- transition: color 160ms ease;
- }
-
- .text-button:hover {
- color: var(--color-ink);
- }
-
- .form-panel {
- width: min(100%, 32rem);
- margin-inline: auto;
- border: 1px solid var(--color-line);
- border-radius: 1rem;
- background: color-mix(in srgb, var(--color-surface) 92%, transparent);
- padding: clamp(1.5rem, 5vw, 2.5rem);
- }
-
- .field-label {
- display: grid;
- gap: 0.45rem;
- color: var(--color-muted);
- font-size: 0.85rem;
- }
-
- .error-message,
- .notice {
- border: 1px solid var(--color-line);
- border-radius: 0.5rem;
- background: var(--color-surface);
- padding: 0.75rem;
- font-size: 0.875rem;
- }
-
- .error-message {
- border-color: color-mix(in srgb, var(--color-danger) 45%, var(--color-line));
- border-left: 3px solid var(--color-danger);
- color: var(--color-ink);
- }
-
- .field-error,
- .field-hint {
- font-size: 0.8rem;
- line-height: 1.4;
- }
-
- .field-error {
- color: var(--color-danger);
- }
-
- .field-hint {
- color: var(--color-faint);
- }
-
- .file-picker {
- display: flex;
- align-items: center;
- gap: 0.75rem;
- border: 1px solid var(--color-line);
- border-radius: 0.5rem;
- background: var(--color-surface);
- padding: 0.4rem 0.875rem 0.4rem 0.4rem;
- cursor: pointer;
- transition: border-color 160ms ease;
- }
-
- .file-picker:hover {
- border-color: var(--color-muted);
- }
-
- .file-picker:focus-within {
- outline: 2px solid var(--color-ink);
- outline-offset: 3px;
- }
-
- .notice {
- color: var(--color-muted);
- }
-
- .tab-link {
- position: relative;
- padding: 0.75rem 1rem;
- font-size: 0.875rem;
- font-weight: 600;
- white-space: nowrap;
- color: var(--color-muted);
- text-decoration: none;
- transition: color 160ms ease;
- }
-
- .tab-link::after,
- .nav-link::after {
- position: absolute;
- right: 0;
- left: 0;
- height: 2px;
- border-radius: 2px;
- background: var(--color-ink);
- content: '';
- transform: scaleX(0);
- transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
- }
-
- .tab-link::after {
- bottom: 0;
- }
-
- .tab-link:hover {
- color: var(--color-ink);
- }
-
- .tab-link[aria-current='page'],
- .tab-link.active {
- color: var(--color-ink);
- }
-
- .tab-link[aria-current='page']::after,
- .tab-link.active::after,
- .nav-link[aria-current='page']::after {
- transform: scaleX(1);
- }
-
- .nav-link {
- position: relative;
- padding-block: 0.4rem;
- transition: color 160ms ease;
- }
-
- .nav-link::after {
- bottom: 0;
- height: 1px;
- }
-
- .nav-link:hover::after {
- transform: scaleX(0.35);
- }
-
.poster-link {
transition: transform 200ms ease, border-color 200ms ease;
}
@@ -372,13 +11,6 @@ body {
border-color: var(--color-muted);
}
- .poster-image {
- transition:
- opacity 300ms ease,
- filter 250ms ease,
- transform 250ms ease;
- }
-
.poster-blurred img {
filter: blur(14px);
transform: scale(1.1);
@@ -402,295 +34,6 @@ body {
font-size: 0.7rem;
}
- .toggle {
- position: relative;
- flex: none;
- width: 2.75rem;
- height: 1.5rem;
- border: 1px solid var(--color-line);
- border-radius: 999px;
- background: var(--color-line-soft);
- padding: 0;
- transition:
- background-color 160ms ease,
- border-color 160ms ease;
- }
-
- .toggle[aria-checked='true'] {
- border-color: var(--color-ink);
- background: var(--color-ink);
- }
-
- .toggle:disabled {
- cursor: not-allowed;
- }
-
- .toggle-knob {
- position: absolute;
- top: 2px;
- left: 2px;
- width: calc(1.5rem - 6px);
- height: calc(1.5rem - 6px);
- border-radius: 999px;
- background: var(--color-muted);
- transition:
- transform 160ms ease,
- background-color 160ms ease;
- }
-
- .toggle[aria-checked='true'] .toggle-knob {
- transform: translateX(1.25rem);
- background: var(--color-paper);
- }
-
- .select-trigger {
- display: flex;
- width: 100%;
- align-items: center;
- justify-content: space-between;
- gap: 0.75rem;
- border: 1px solid var(--color-line);
- border-radius: 0.5rem;
- background: var(--color-surface);
- padding: 0.75rem 0.875rem;
- text-align: left;
- transition: border-color 160ms ease;
- }
-
- .select-trigger:hover,
- .select-trigger[aria-expanded='true'] {
- border-color: var(--color-muted);
- }
-
- .select-trigger .chevron {
- flex: none;
- color: var(--color-muted);
- transition: transform 160ms ease;
- }
-
- .select-trigger[aria-expanded='true'] .chevron {
- transform: rotate(180deg);
- }
-
- .popover-panel {
- z-index: 40;
- border: 1px solid var(--color-line);
- border-radius: 0.75rem;
- background: var(--color-surface);
- box-shadow: 0 18px 40px -12px rgb(0 0 0 / 0.7);
- }
-
- .year-panel {
- position: absolute;
- top: calc(100% + 0.4rem);
- left: 0;
- display: grid;
- grid-template-columns: repeat(5, minmax(0, 1fr));
- gap: 0.25rem;
- width: 19rem;
- max-height: 20rem;
- overflow-y: auto;
- overscroll-behavior: contain;
- padding: 0.5rem;
- scrollbar-gutter: stable;
- }
-
- .year-option {
- border: 1px solid transparent;
- border-radius: 0.4rem;
- background: transparent;
- padding: 0.45rem 0;
- color: var(--color-muted);
- font-family: var(--font-mono);
- font-size: 0.8rem;
- transition:
- background-color 120ms ease,
- color 120ms ease;
- }
-
- .year-option:hover,
- .year-option:focus-visible {
- outline: none;
- background: var(--color-line-soft);
- color: var(--color-ink);
- }
-
- .year-option[aria-selected='true'] {
- border-color: var(--color-ink);
- background: var(--color-ink);
- color: var(--color-paper);
- }
-
- .sheet-backdrop {
- display: none;
- }
-
- @media (max-width: 39.99rem) {
- .sheet-backdrop {
- position: fixed;
- inset: 0;
- z-index: 44;
- display: block;
- background: color-mix(in srgb, var(--color-paper) 70%, transparent);
- }
-
- .year-panel {
- position: fixed;
- z-index: 45;
- top: auto;
- right: 0;
- bottom: 0;
- left: 0;
- width: 100%;
- max-height: 60dvh;
- border-radius: 1rem 1rem 0 0;
- padding: 1rem 1rem calc(1rem + env(safe-area-inset-bottom));
- }
- }
-
- .icon-button {
- position: relative;
- display: inline-flex;
- width: 2.5rem;
- height: 2.5rem;
- align-items: center;
- justify-content: center;
- border: 1px solid transparent;
- border-radius: 999px;
- background: transparent;
- color: var(--color-muted);
- transition:
- background-color 160ms ease,
- color 160ms ease,
- border-color 160ms ease;
- }
-
- .icon-button:hover,
- .icon-button[aria-expanded='true'] {
- border-color: var(--color-line);
- background: var(--color-line-soft);
- color: var(--color-ink);
- }
-
- .count-badge {
- position: absolute;
- top: 0.1rem;
- right: 0.05rem;
- min-width: 1.1rem;
- border: 2px solid var(--color-paper);
- border-radius: 999px;
- background: var(--color-ink);
- padding: 0 0.25rem;
- color: var(--color-paper);
- font-family: var(--font-mono);
- font-size: 0.62rem;
- font-weight: 700;
- line-height: 1rem;
- text-align: center;
- }
-
- .notification-panel {
- position: absolute;
- top: calc(100% + 0.6rem);
- right: 0;
- width: 23rem;
- overflow: hidden;
- font-family: var(--font-sans);
- font-size: 1rem;
- }
-
- .notification-panel-body {
- max-height: min(26rem, 60dvh);
- overflow-y: auto;
- overscroll-behavior: contain;
- scrollbar-gutter: stable;
- }
-
- .notification-row {
- display: flex;
- align-items: center;
- gap: 0.85rem;
- border-bottom: 1px solid var(--color-line-soft);
- padding: 0.75rem 1rem;
- color: inherit;
- text-decoration: none;
- transition: background-color 140ms ease;
- }
-
- a.notification-row:hover {
- background: var(--color-line-soft);
- }
-
- li:last-child > .notification-row {
- border-bottom: 0;
- }
-
- .unread-dot {
- width: 0.5rem;
- height: 0.5rem;
- flex: none;
- border-radius: 999px;
- background: var(--color-ink);
- }
-
- .snackbar-region {
- position: fixed;
- right: 1rem;
- bottom: 1rem;
- z-index: 60;
- display: grid;
- width: min(24rem, calc(100vw - 2rem));
- gap: 0.6rem;
- pointer-events: none;
- }
-
- .snackbar {
- display: flex;
- align-items: center;
- gap: 0.75rem;
- border: 1px solid var(--color-line);
- border-radius: 0.75rem;
- background: var(--color-surface);
- padding: 0.75rem 0.75rem 0.75rem 1rem;
- box-shadow: 0 18px 40px -12px rgb(0 0 0 / 0.75);
- pointer-events: auto;
- }
-
- @media (max-width: 47.99rem) {
- .snackbar-region {
- right: 0.75rem;
- bottom: calc(4.75rem + env(safe-area-inset-bottom));
- left: 0.75rem;
- width: auto;
- }
- }
-
- @media (max-width: 39.99rem) {
- .account-menu {
- position: fixed;
- top: 4.25rem;
- right: 0.75rem;
- }
-
- .notification-panel {
- position: fixed;
- top: 4.25rem;
- right: 0.75rem;
- left: 0.75rem;
- width: auto;
- }
- }
-
- .scroll-x {
- overflow-x: auto;
- overscroll-behavior-inline: contain;
- scrollbar-width: none;
- }
-
- .scroll-x::-webkit-scrollbar {
- display: none;
- }
-
@media (pointer: coarse) {
.cast-row {
padding-bottom: 0;
@@ -702,121 +45,6 @@ body {
}
}
- .chip-row {
- display: flex;
- gap: 0.5rem;
- margin-inline: -1rem;
- overflow-x: auto;
- padding-inline: 1rem;
- scrollbar-width: none;
- }
-
- .chip-row::-webkit-scrollbar {
- display: none;
- }
-
- .chip-row > * {
- flex: none;
- }
-
- @media (min-width: 40rem) {
- .chip-row {
- flex-wrap: wrap;
- margin-inline: 0;
- overflow: visible;
- padding-inline: 0;
- }
- }
-
- .avatar-button {
- display: inline-flex;
- width: 2.25rem;
- height: 2.25rem;
- align-items: center;
- justify-content: center;
- border: 1px solid var(--color-line);
- border-radius: 999px;
- background: var(--color-line-soft);
- color: var(--color-ink);
- font-size: 0.9rem;
- font-weight: 620;
- transition:
- border-color 160ms ease,
- background-color 160ms ease;
- }
-
- .avatar-button:hover,
- .avatar-button[aria-expanded='true'] {
- border-color: var(--color-muted);
- background: var(--color-line);
- }
-
- .account-menu {
- position: absolute;
- top: calc(100% + 0.6rem);
- right: 0;
- display: grid;
- width: 15rem;
- overflow: hidden;
- }
-
- .menu-item {
- display: block;
- border: 0;
- background: transparent;
- padding: 0.7rem 1rem;
- color: var(--color-muted);
- font-size: 0.9rem;
- text-align: left;
- text-decoration: none;
- transition:
- background-color 140ms ease,
- color 140ms ease;
- }
-
- .menu-item:hover,
- .menu-item:focus-visible {
- outline: none;
- background: var(--color-line-soft);
- color: var(--color-ink);
- }
-
- .tab-bar {
- position: fixed;
- right: 0;
- bottom: 0;
- left: 0;
- z-index: 30;
- display: grid;
- grid-template-columns: repeat(5, minmax(0, 1fr));
- border-top: 1px solid var(--color-line-soft);
- background: color-mix(in srgb, var(--color-paper) 92%, transparent);
- padding-bottom: env(safe-area-inset-bottom);
- backdrop-filter: blur(14px);
- }
-
- .tab-bar-item {
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 0.2rem;
- padding: 0.6rem 0 0.55rem;
- color: var(--color-faint);
- font-size: 0.68rem;
- text-decoration: none;
- transition: color 160ms ease;
- }
-
- .tab-bar-item[aria-current='page'] {
- color: var(--color-ink);
- }
-
- .skeleton {
- display: block;
- border-radius: 0.375rem;
- background: var(--color-line-soft);
- }
-
.catalog-row {
display: grid;
grid-auto-columns: calc((100% - 2rem) / 2.3);
@@ -863,133 +91,7 @@ body {
}
@media (prefers-reduced-motion: no-preference) {
- html {
- scroll-behavior: smooth;
- }
-
- .page-enter {
- animation: page-enter 240ms ease-out backwards;
- }
-
- .fade-in {
- animation: fade-in 220ms ease-out backwards;
- }
-
- .skeleton {
- background: linear-gradient(
- 90deg,
- var(--color-line-soft) 0%,
- var(--color-line) 45%,
- var(--color-line-soft) 90%
- );
- background-size: 200% 100%;
- animation: shimmer 1.8s ease-in-out infinite;
- }
-
.poster-link:hover {
transform: translateY(-3px);
}
-
- .primary-button:active:not(:disabled),
- .secondary-button:active:not(:disabled),
- .icon-button:active,
- .avatar-button:active {
- transform: scale(0.95);
- }
-
- .tab-bar-item svg {
- transition: transform 200ms cubic-bezier(0.2, 0.8, 0.2, 1);
- }
-
- .tab-bar-item:active svg {
- transform: scale(0.88);
- }
-
- .tab-bar-item[aria-current='page'] svg {
- transform: translateY(-1px);
- }
-
- .count-badge {
- animation: badge-pop 260ms cubic-bezier(0.2, 0.8, 0.2, 1.4) both;
- }
-
- .snackbar.snackbar-leaving {
- animation: snackbar-leave 180ms ease-in both;
- }
-
- .field-error {
- animation: fade-in 160ms ease-out backwards;
- }
-
- dialog[open] {
- animation: dialog-enter 220ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
- }
-
- dialog[open]::backdrop {
- animation: fade-in 220ms ease-out both;
- }
-
- .snackbar {
- animation: snackbar-enter 260ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
- }
-
- .popover-panel {
- animation: popover-enter 180ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
- }
-}
-
-@keyframes page-enter {
- from {
- opacity: 0;
- }
-}
-
-@keyframes fade-in {
- from {
- opacity: 0;
- }
-}
-
-@keyframes shimmer {
- from {
- background-position: 200% 0;
- }
-
- to {
- background-position: -200% 0;
- }
-}
-
-@keyframes snackbar-enter {
- from {
- opacity: 0;
- transform: translateY(12px) scale(0.98);
- }
-}
-
-@keyframes snackbar-leave {
- to {
- opacity: 0;
- transform: translateY(8px) scale(0.98);
- }
-}
-
-@keyframes badge-pop {
- from {
- transform: scale(0.4);
- }
-}
-
-@keyframes popover-enter {
- from {
- opacity: 0;
- transform: translateY(-4px) scale(0.98);
- }
-}
-
-@keyframes dialog-enter {
- from {
- opacity: 0;
- transform: translateY(10px) scale(0.98);
- }
}
diff --git a/app/frontend/src/useDismiss.ts b/app/frontend/src/useDismiss.ts
deleted file mode 100644
index 2deafa3..0000000
--- a/app/frontend/src/useDismiss.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-import { useEffect, type RefObject } from 'react';
-
-export function useDismiss(open: boolean, root: RefObject, dismiss: () => void) {
- useEffect(() => {
- if (!open) return;
- const outside = (event: PointerEvent) => {
- if (!root.current?.contains(event.target as Node)) dismiss();
- };
- const escape = (event: KeyboardEvent) => {
- if (event.key === 'Escape') dismiss();
- };
- document.addEventListener('pointerdown', outside);
- document.addEventListener('keydown', escape);
- return () => {
- document.removeEventListener('pointerdown', outside);
- document.removeEventListener('keydown', escape);
- };
- }, [dismiss, open, root]);
-}
diff --git a/app/frontend/src/useResource.ts b/app/frontend/src/useResource.ts
index 8b86aee..205fc9b 100644
--- a/app/frontend/src/useResource.ts
+++ b/app/frontend/src/useResource.ts
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from 'react';
-import { ApiError, errorMessage, isAbortError } from './api';
+import { errorMessage, isAbortError } from '@project-graphite/ui';
+import { ApiError } from './api';
import { useAuth } from './auth';
export type Resource = ReturnType>;
diff --git a/app/frontend/test/skeletons.spec.tsx b/app/frontend/test/skeletons.spec.tsx
index 2ddfd56..9f1b9b2 100644
--- a/app/frontend/test/skeletons.spec.tsx
+++ b/app/frontend/test/skeletons.spec.tsx
@@ -2,9 +2,9 @@ import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { MemoryRouter } from 'react-router';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { SmoothImage } from '@project-graphite/ui';
import { App } from '../src/App';
import { AuthProvider } from '../src/auth';
-import { SmoothImage } from '../src/components/SmoothImage';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
diff --git a/app/package-lock.json b/app/package-lock.json
index 3847063..8b9b28a 100644
--- a/app/package-lock.json
+++ b/app/package-lock.json
@@ -18,16 +18,19 @@
"frontend": {
"name": "graphite-tracker-frontend",
"version": "0.1.0",
+ "dependencies": {
+ "@project-graphite/ui": "https://github.com/project-graphite/graphite-ui/releases/download/v0.1.0/project-graphite-ui-0.1.0.tgz"
+ },
"devDependencies": {
"@tailwindcss/vite": "4.3.3",
- "@types/node": "^26.6.2",
+ "@types/node": "26.6.2",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"@vitejs/plugin-react": "6.1.1",
"happy-dom": "20.14.5",
"react": "19.3.0",
"react-dom": "19.3.0",
- "react-router": "^8.4.0",
+ "react-router": "8.4.0",
"tailwindcss": "4.3.3",
"typescript": "6.0.2",
"vite": "8.3.0",
@@ -1468,6 +1471,16 @@
"react-dom": "^18.0.0 || ^19.0.0"
}
},
+ "node_modules/@project-graphite/ui": {
+ "version": "0.1.0",
+ "resolved": "https://github.com/project-graphite/graphite-ui/releases/download/v0.1.0/project-graphite-ui-0.1.0.tgz",
+ "integrity": "sha512-CSIbX7oejO2/DPBvlu/yDfRPNhWVeTXJMDLO/FOLCF202ZEaTCQiLE6R0AsNYej9hGlPt5GGmc3SDAp6+MNZxA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0"
+ }
+ },
"node_modules/@radix-ui/primitive": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
@@ -7157,7 +7170,7 @@
"@nestjs/cli": "12.0.5",
"@nestjs/testing": "12.1.0",
"@types/cookie-parser": "1.4.10",
- "@types/node": "^26.6.2",
+ "@types/node": "26.6.2",
"@types/passport-jwt": "4.0.1",
"typescript": "6.0.2",
"vitest": "5.0.1"