Frontend
This guide covers the Next.js 15 web application.
Overview
The frontend is a React 19 application using:
- Next.js 15 with App Router
- TanStack Query v5 for data fetching
- Radix UI primitives via shadcn/ui
- Tailwind CSS for styling
- Geist font family
- openapi-fetch for type-safe API calls
Project structure
web/
├── app/ # Next.js App Router pages
│ ├── layout.tsx # Root layout with providers
│ ├── page.tsx # Home page
│ ├── authors/ # Author profile pages
│ ├── eprints/ # Eprint detail pages
│ ├── search/ # Search results page
│ ├── fields/ # Field taxonomy pages
│ └── governance/ # Governance and proposals
├── components/
│ ├── annotations/ # PDF text annotation and entity link components
│ ├── backlinks/ # Bluesky backlink display
│ ├── endorsements/ # Endorsement panel and badges
│ ├── enrichment/ # Citation enrichment display
│ ├── knowledge-graph/ # Field cards and relationships
│ ├── navigation/ # Header, nav, theme toggle
│ ├── eprints/ # Eprint cards, lists, PDF viewer
│ ├── providers/ # React context providers
│ ├── reviews/ # Review forms and threads
│ ├── search/ # Search input, facets, results
│ ├── share/ # Bluesky share components
│ ├── skeletons/ # Loading placeholders
│ ├── tags/ # Tag chips, clouds, inputs
│ └── ui/ # shadcn/ui primitives
├── lib/
│ ├── api/ # API client and types
│ ├── atproto/ # ATProto record creation
│ ├── auth/ # OAuth and session management
│ ├── bluesky/ # Bluesky API integration
│ ├── hooks/ # TanStack Query hooks
│ └── utils/ # Utility functions
└── styles/
└── globals.css # CSS variables and base styles
Getting started
Install dependencies:
cd web
pnpm install
Start the development server:
pnpm dev
Open http://localhost:3000.
Data fetching
Use TanStack Query hooks for all API calls:
import { useEprint, useEprints } from '@/lib/hooks';
// Fetch a single eprint
const { data, isLoading, error } = useEprint(uri);
// Fetch paginated list
const { data, isLoading } = useEprints({ limit: 10 });
Query configuration
The query client uses these defaults:
- staleTime: 30 seconds (data refetched after this period)
- gcTime: 5 minutes (cache garbage collection)
- retry: 1 attempt on failure
- refetchOnWindowFocus: true
Query keys
Query keys follow a flat array pattern:
// All eprints
['eprints'][
// Eprint list with filters
('eprints', 'list', { limit: 10, field: 'cs.AI' })
][
// Single eprint detail
('eprints', 'detail', 'at://did:plc:example/pub.chive.eprint.submission/123')
][
// Search results
('search', { q: 'neural networks', limit: 20 })
];
Components
UI primitives
Located in components/ui/. These follow the shadcn/ui pattern built on Radix UI:
| Component | Description |
|---|---|
Button | Primary action with variant and size props |
Card | Content container with header, content, footer |
Dialog | Modal dialogs with accessible focus management |
Input | Text input with consistent styling |
Textarea | Multi-line text input |
Select | Dropdown selection with Radix primitives |
Checkbox | Boolean input with indeterminate state |
RadioGroup | Single selection from options |
Tabs | Tabbed content panels |
Tooltip | Hover information overlays |
Popover | Click-triggered floating content |
DropdownMenu | Context menu with keyboard navigation |
ScrollArea | Styled scrollable container |
Skeleton | Loading placeholder animations |
Badge | Small labels and status indicators |
Avatar | User profile images with fallback |
Alert | Informational and error messages |
Separator | Visual divider |
Label | Form field labels |
Sonner | Toast notifications |
Example:
import { Button } from '@/components/ui/button';
import { Card, CardHeader, CardContent } from '@/components/ui/card';
<Card>
<CardHeader>
<h3>Title</h3>
</CardHeader>
<CardContent>
<Button variant="outline" size="sm">
Click me
</Button>
</CardContent>
</Card>;
Eprint components
Located in components/eprints/:
| Component | Description |
|---|---|
EprintCard | Summary card with title, authors, abstract. Supports default, compact, and featured variants |
EprintList | Paginated list of eprint cards |
EprintMetadata | Full metadata display (DOI, dates, versions) |
EprintMetrics | View counts, downloads, engagement stats |
EprintVersions | Version history timeline |
EprintSource | Source repository badge (arXiv, bioRxiv, etc.) |
AuthorChip | Clickable author name with avatar |
AuthorHeader | Full author profile header |
AuthorEprints | Paginated eprints by author |
AuthorStats | Author metrics (h-index, citations, eprints) |
FieldBadge | Field taxonomy badge |
OrcidBadge | ORCID identifier with verification |
PDFViewer | Embedded PDF display |
PDFAnnotationOverlay | Text selection and annotation layer |
PDFSelectionPopover | Context menu for PDF text selection |
PDFTextSelectionHandler | Captures text selections in PDF |
EprintAbstract | Expandable rich text abstract display |
StaticAbstract | Non-expandable truncated abstract for lists |
Example:
import { EprintCard, EprintCardSkeleton } from '@/components/eprints/eprint-card';
import { usePrefetchEprint } from '@/lib/hooks';
function EprintList({ eprints, isLoading }) {
const prefetch = usePrefetchEprint();
if (isLoading) {
return <EprintCardSkeleton />;
}
return eprints.map((eprint) => (
<EprintCard key={eprint.uri} eprint={eprint} onPrefetch={prefetch} />
));
}
Search components
Located in components/search/:
| Component | Description |
|---|---|
SearchInput | Search field with autocomplete support |
SearchInputWithParams | Search input synced with URL params |
InlineSearch | Compact search for headers |
SearchAutocomplete | Dropdown suggestions |
SearchHighlight | Highlights matching terms in results |
SearchEmpty | Empty state for no results |
SearchPagination | Page navigation controls |
FacetChip | Active filter indicator |
Example:
import { SearchInputWithParams } from '@/components/search/search-input';
<SearchInputWithParams
paramKey="q"
searchRoute="/search"
placeholder="Search eprints..."
size="lg"
/>;
Knowledge graph components
Located in components/knowledge-graph/:
| Component | Description |
|---|---|
FieldCard | Field node display with stats |
FieldHierarchy | Hierarchical field tree view |
NodeExternalIds | Links to Wikidata, LCSH, etc. |
FieldEprints | Eprints in a field |
FieldRelationships | Broader/narrower/related terms |
NodeSearch | Search for knowledge graph nodes |
KnowledgeGraphViewer | Interactive graph visualization |
The KnowledgeGraphViewer supports pagination for large graphs to prevent performance issues.
Endorsement components
Located in components/endorsements/:
| Component | Description |
|---|---|
EndorsementPanel | Full endorsement display with filtering |
EndorsementBadge | Contribution type badge |
EndorsementBadgeGroup | Grouped badges by type |
EndorsementSummaryBadge | Total count badge |
EndorsementList | List of endorsements |
EndorsementSummaryCompact | Compact summary for cards |
EndorsementIndicator | Minimal count indicator |
Example:
import { EndorsementPanel } from '@/components/endorsements/endorsement-panel';
<EndorsementPanel
eprintUri={eprint.uri}
onEndorse={() => setShowEndorseDialog(true)}
currentUserDid={user?.did}
/>;
Review components
Located in components/reviews/. Reviews are document-level discussion (no text span targeting):
| Component | Description |
|---|---|
ReviewForm | Create/edit review with character count |
InlineReplyForm | Compact reply form |
ReviewList | Paginated reviews |
ReviewThread | Threaded discussion display |
ReviewCard | Single review with actions |
AnnotationBodyRenderer | Renders rich text body content for both annotations and reviews |
TargetSpanPreview | Shows referenced text for document-level reviews |
ParentReviewPreview | Shows parent review when replying |
DeleteReviewDialog | Confirmation dialog for soft-deleting a review |
DocumentLocationCard | Shows annotation location context in the PDF |
Example:
import { ReviewForm } from '@/components/reviews/review-form';
<ReviewForm
eprintUri={eprint.uri}
onSubmit={async (data) => {
await createReview.mutateAsync(data);
}}
onCancel={() => setShowForm(false)}
isLoading={createReview.isPending}
/>;
Annotation components
Located in components/annotations/. These handle inline PDF annotations and entity linking:
| Component | Description |
|---|---|
AnnotationEditor | Rich text editor with @ and # triggers for knowledge graph references |
AnnotationPreview | Preview of annotation body content |
AnnotationSidebar | Sidebar listing annotations and entity links grouped by page |
EntityLinkDialog | Dialog for linking a text span to a Wikidata or graph entity |
NodeMentionAutocomplete | Autocomplete dropdown for @ and # node mentions |
WikidataSearch | Wikidata entity search for entity linking |
Example:
import { AnnotationEditor, EntityLinkDialog } from '@/components/annotations';
<AnnotationEditor value={body} onChange={setBody} placeholder="Add your annotation..." />;
<EntityLinkDialog
open={isOpen}
onOpenChange={setIsOpen}
selectedText="neural networks"
onLink={handleLink}
/>;
Tag components
Located in components/tags/:
| Component | Description |
|---|---|
TagChip | Clickable tag display |
TagCloud | Tag visualization by frequency |
TagInput | Autocomplete tag entry |
TagList | Horizontal tag list |
Form components
Located in components/forms/:
| Component | Description |
|---|---|
NodeAutocomplete | Flexible autocomplete for knowledge graph nodes |
FieldSearch | Search and select academic fields |
CreditAutocomplete | Select contribution credit types |
ConferenceAutocomplete | Search conferences |
JournalAutocomplete | Search journals |
FunderAutocomplete | Search funders/funding bodies |
DOIAutocomplete | Search by DOI |
LocationAutocomplete | Geographic location search |
EprintAuthorEditor | Edit author metadata |
ContributionTypeSelector | Select contribution types for endorsements |
The NodeAutocomplete component is the primary autocomplete for governance-controlled nodes. It supports filtering by kind and subkind:
import { NodeAutocomplete } from '@/components/forms/node-autocomplete';
// Select a license
<NodeAutocomplete
kind="object"
subkind="license"
label="License"
value={selectedLicenseUri}
onSelect={(node) => setValue('licenseUri', node.uri)}
/>
// Select an endorsement type
<NodeAutocomplete
kind="type"
subkind="endorsement-kind"
label="Contribution Type"
onSelect={(node) => setContributionType(node)}
/>
Editor components
Located in components/editor/:
| Component | Description |
|---|---|
RichTextRenderer | Renders rich text items with entity references |
CrossReferenceExtension | TipTap extension for [[ autocomplete of entity references |
See Frontend rich text for detailed documentation on the rich text system.
Adding new components
- Check if shadcn/ui has the component: https://ui.shadcn.com
- If yes, copy the component code to
components/ui/ - If no, create a new component following the CVA pattern
CVA (class-variance-authority) example:
import { cva, type VariantProps } from 'class-variance-authority';
const buttonVariants = cva('base-classes', {
variants: {
variant: {
default: 'bg-primary text-white',
outline: 'border border-input',
},
size: {
default: 'h-9 px-4',
sm: 'h-8 px-3',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
});
Theming
The app uses next-themes for dark mode:
- System preference detection
- Manual toggle (light/dark/system)
- No flash on page load
CSS variables in styles/globals.css define colors:
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
/* ... */
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
/* ... */
}
Mobile responsiveness
useIsMobile hook
The useIsMobile hook (lib/hooks/use-mobile.ts) detects viewport width using window.matchMedia. The breakpoint is 768px, matching Tailwind's md breakpoint.
import { useIsMobile } from '@/lib/hooks/use-mobile';
function MyComponent() {
const isMobile = useIsMobile();
return isMobile ? <MobileView /> : <DesktopView />;
}
SidebarLayout and Sheet drawers
SidebarLayout (components/layout/sidebar-layout.tsx) provides a responsive sidebar used by the dashboard, admin, and governance pages. On desktop, it renders a fixed-width sidebar alongside the main content. On mobile, it converts the sidebar into a Sheet drawer triggered by a menu button.
import { SidebarLayout } from '@/components/layout/sidebar-layout';
<SidebarLayout sidebar={<NavigationMenu />} sidebarWidth="md" sidebarTitle="Dashboard">
<DashboardContent />
</SidebarLayout>;
Props:
| Prop | Type | Default | Description |
|---|---|---|---|
sidebar | ReactNode | (required) | Sidebar content |
sidebarWidth | 'sm' | 'md' | 'lg' | 'md' | Desktop sidebar width |
sidebarPosition | 'left' | 'right' | 'left' | Which side the sidebar appears on |
stickyNavigation | boolean | false | Sticky positioning on desktop |
sidebarTitle | string | 'Navigation' | Label on the mobile Sheet trigger |
The Sheet automatically closes on route changes.
MobileSearch
MobileSearch (components/navigation/mobile-search.tsx) provides a search trigger visible only below the sm breakpoint. It opens a top Sheet with a search input. The desktop SearchBar is hidden at the same breakpoint using sm:hidden.
Admin table overflow
Admin tables (user management, proposal review) use overflow-x-auto on the table container to enable horizontal scrolling on narrow viewports:
<div className="overflow-x-auto">
<Table>{/* columns */}</Table>
</div>
Responsive grid conventions
Use these Tailwind breakpoint conventions for grid layouts:
| Layout | Classes | Description |
|---|---|---|
| Card grids | grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 | 1 column on mobile, 2 on tablet, 3 on desktop |
| Two-column | grid grid-cols-1 md:grid-cols-2 | Stacks on mobile, side-by-side on desktop |
| Sidebar + main | Use SidebarLayout instead of manual grid | Handles Sheet conversion automatically |
Hooks reference
All TanStack Query hooks are organized by domain and exported from lib/hooks/index.ts.
Eprint hooks
| Hook | Description |
|---|---|
useEprint(uri) | Fetch single eprint by AT-URI |
useEprints(params) | Paginated eprint list |
useEprintsByAuthor(did) | Eprints by author DID |
usePrefetchEprint() | Returns function to prefetch on hover |
import { useEprint, eprintKeys } from '@/lib/hooks';
const { data, isLoading, error } = useEprint('at://did:plc:abc/pub.chive.eprint.submission/123');
// Cache invalidation
queryClient.invalidateQueries({ queryKey: eprintKeys.all });
Search hooks
| Hook | Description |
|---|---|
useSearch(query) | Full-text search with pagination |
useInstantSearch(query) | Debounced instant search |
useFacetedSearch(query, facets) | Search with PMEST facet filters |
useFacetCounts(query) | Facet value counts |
useLiveFacetedSearch() | Combined search state and facets |
Facet utilities:
import {
addFacetValue,
removeFacetValue,
toggleFacetValue,
clearDimensionFilters,
countTotalFilters,
isFacetSelected,
} from '@/lib/hooks';
const newFacets = addFacetValue(currentFacets, 'fields', 'cs.AI');
Discovery hooks
| Hook | Description |
|---|---|
useSimilarPapers(uri) | Related papers by similarity (supports weights) |
useCitations(uri) | Citation network (citing/cited-by) |
useEnrichment(uri) | External metadata (Semantic Scholar, OpenAlex) |
useRecordInteraction() | Mutation to log user interactions |
usePrefetchSimilarPapers() | Prefetch similar papers on hover |
useDiscoverySettings() | User discovery preferences (including weights) |
useUpdateDiscoverySettings() | Mutation to update preferences |
useMutedAuthors() | Set of muted author DIDs |
useMuteAuthor() | Mutation to mute an author |
useUnmuteAuthor() | Mutation to unmute an author |
usePersonalizedAuthors() | Personalized author discovery for /authors page |
import { useSimilarPapers, useRecordInteraction, useDiscoverySettings } from '@/lib/hooks';
const { data: settings } = useDiscoverySettings();
const { data, isLoading } = useSimilarPapers(eprintUri, {
weights: settings?.relatedPapersWeights,
});
const { mutate: recordInteraction } = useRecordInteraction();
recordInteraction({
eprintUri,
type: 'dismiss',
recommendationId: 'rec-123',
});
Author hooks
| Hook | Description |
|---|---|
useAuthor(did) | Author profile by DID |
useAuthorProfile(did) | Extended profile with metrics |
useAuthorMetrics(did) | Author statistics |
usePrefetchAuthor() | Prefetch author on hover |
Utilities:
import { hasOrcid, formatOrcidUrl } from '@/lib/hooks';
if (hasOrcid(author)) {
console.log(formatOrcidUrl(author.orcid)); // https://orcid.org/0000-0002-...
}
Field hooks
| Hook | Description |
|---|---|
useField(id) | Single field by ID |
useFields() | All fields (for taxonomy display) |
useFieldChildren(id) | Narrower terms (via edges) |
useFieldEprints(id) | Eprints in field |
usePrefetchField() | Prefetch field on hover |
Node hooks (unified graph model)
| Hook | Description |
|---|---|
useNode(id) | Single node by ID |
useNodes(options) | List nodes with kind/subkind filters |
useNodeChildren(id) | Child nodes (via broader edges) |
useNodeParents(id) | Parent nodes (via broader edges) |
useSearchNodes(query) | Search nodes by label |
Edge hooks
| Hook | Description |
|---|---|
useEdges(nodeId) | Edges for a node |
useEdgesByRelation() | Edges filtered by relation slug |
Review hooks
| Hook | Description |
|---|---|
useReviews(eprintUri) | Document-level reviews for an eprint |
useReviewThread(reviewUri) | Threaded replies to a review |
useCreateReview() | Create document-level review mutation |
useDeleteReview() | Delete review mutation |
usePrefetchReviews() | Prefetch reviews on hover |
import { useReviews, useCreateReview } from '@/lib/hooks';
const { data: reviews, isLoading } = useReviews(eprintUri);
const createReview = useCreateReview();
await createReview.mutateAsync({
content: 'Great methodology!',
eprintUri,
motivation: 'commenting',
});
Annotation hooks
| Hook | Description |
|---|---|
useAnnotations(eprintUri, params?) | Annotations for an eprint (with optional motivation filter) |
useAnnotationsForPage(eprintUri, pageNumber) | Annotations targeting a specific PDF page |
useAnnotationThread(annotationUri) | Thread with parent and nested replies |
useCreateAnnotation() | Create inline annotation mutation |
useCreateEntityLink() | Create entity link mutation |
useDeleteAnnotation() | Delete annotation mutation |
useDeleteEntityLink() | Delete entity link mutation |
Query key factory:
import { annotationKeys } from '@/lib/hooks/use-annotations';
// Invalidate all annotations for an eprint
queryClient.invalidateQueries({ queryKey: annotationKeys.forEprint(eprintUri) });
// Invalidate a specific thread
queryClient.invalidateQueries({ queryKey: annotationKeys.thread(annotationUri) });
Example:
import { useAnnotations, useCreateAnnotation } from '@/lib/hooks';
const { data, isLoading } = useAnnotations(eprintUri);
const createAnnotation = useCreateAnnotation();
await createAnnotation.mutateAsync({
eprintUri,
content: 'Interesting claim here.',
target: { source: eprintUri, selector: { type: 'TextQuoteSelector', exact: 'selected text' } },
motivation: 'commenting',
});
Endorsement hooks
| Hook | Description |
|---|---|
useEndorsements(eprintUri) | All endorsements for eprint |
useEndorsementSummary(eprintUri) | Counts by contribution type |
useUserEndorsement(eprintUri, did) | Check if user has endorsed |
useCreateEndorsement() | Create endorsement mutation |
useUpdateEndorsement() | Update endorsement mutation |
useDeleteEndorsement() | Delete endorsement mutation |
usePrefetchEndorsements() | Prefetch endorsements on hover |
Constants:
import {
CONTRIBUTION_TYPES,
CONTRIBUTION_TYPE_LABELS,
CONTRIBUTION_TYPE_DESCRIPTIONS,
CONTRIBUTION_TYPE_CATEGORIES,
} from '@/lib/hooks/use-endorsement';
// CONTRIBUTION_TYPES: ['methodological', 'analytical', 'theoretical', ...]
// CONTRIBUTION_TYPE_CATEGORIES: { 'Core Research': [...], 'Technical': [...] }
Tag hooks
| Hook | Description |
|---|---|
useEprintTags(eprintUri) | Tags on an eprint |
useTagSuggestions(query) | Autocomplete suggestions |
useTrendingTags() | Popular tags |
useTagSearch(query) | Search all tags |
useTagDetail(tagId) | Single tag with stats |
useCreateTag() | Add tag mutation |
useDeleteTag() | Remove tag mutation |
usePrefetchTags() | Prefetch tags on hover |
Claiming hooks
| Hook | Description |
|---|---|
useUserClaims() | Current user's claims |
useClaim(claimId) | Single claim details |
useClaimableEprints(did) | Eprints available to claim |
usePendingClaims() | Claims awaiting approval |
useStartClaim() | Start claim mutation |
useCollectEvidence() | Gather verification evidence |
useCompleteClaim() | Submit claim for review |
useApproveClaim() | Approve claim (trusted editors) |
useRejectClaim() | Reject claim (trusted editors) |
usePaperSuggestions(profileMetadata) | Suggested papers to claim |
Activity hooks
| Hook | Description |
|---|---|
useActivityFeed(options) | User's activity feed |
useLogActivity() | Log activity mutation |
useMarkActivityFailed() | Mark activity as failed |
useActivityLogging() | Combined activity logging utilities |
import { useLogActivity, COLLECTIONS, generateRkey } from '@/lib/hooks';
const { mutate: logActivity } = useLogActivity();
logActivity({
category: 'read',
action: 'view',
targetUri: eprintUri,
collection: COLLECTIONS.EPRINT,
});
Profile autocomplete hooks
| Hook | Description |
|---|---|
useOrcidAutocomplete(query) | ORCID ID suggestions |
useAffiliationAutocomplete(query) | Institution suggestions |
useKeywordAutocomplete(query) | Research keyword suggestions |
useAuthorIdDiscovery(orcid) | Find matching author IDs |
Other hooks
| Hook | Description |
|---|---|
useTrending() | Trending eprints |
useBacklinks(eprintUri) | Bluesky posts referencing eprint |
useBacklinkCounts(eprintUri) | Backlink counts by source |
useShareToBluesky() | Share mutation for Bluesky |
useMentionAutocomplete(query) | @mention suggestions |
useGovernance* | Governance proposal hooks |
useIntegrations() | External service integrations |
Authentication
The frontend uses AT Protocol OAuth for authentication.
OAuth flow
Auth utilities
Located in lib/auth/:
| Module | Description |
|---|---|
oauth-client.ts | ATProto OAuth client setup |
session.ts | Session management |
middleware.ts | Route protection |
import { getCurrentAgent, isAuthenticated } from '@/lib/auth/oauth-client';
const agent = getCurrentAgent();
if (agent) {
// User is authenticated, can write to their PDS
await agent.com.atproto.repo.createRecord({
repo: agent.session.did,
collection: 'pub.chive.review.comment',
record: {
/* ... */
},
});
}
Writing to PDS
User content (reviews, endorsements, tags) is written directly to the user's PDS:
import { createEndorsementRecord } from '@/lib/atproto/record-creator';
import { getCurrentAgent } from '@/lib/auth/oauth-client';
const agent = getCurrentAgent();
if (!agent) throw new Error('Not authenticated');
await createEndorsementRecord(agent, {
eprintUri: 'at://did:plc:abc/pub.chive.eprint.submission/123',
contributions: ['methodological', 'empirical'],
comment: 'Excellent methodology!',
});
Page routes
Public routes
| Route | Description |
|---|---|
/ | Home page with trending and recent eprints |
/about | About Chive |
/search | Search results with faceted filtering |
/browse | Browse eprints by category |
/trending | Trending eprints and tags |
/eprints | Eprints listing |
/eprints/[...uri] | Eprint detail with reviews and endorsements |
/authors | Author discovery and search |
/authors/[did] | Author profile with their eprints |
/tags | Tag cloud and popular tags |
/tags/[tag] | Eprints for a specific tag |
/fields | Field taxonomy browser |
/fields/[id] | Field detail with eprints |
/fields/explore | Interactive field explorer |
/graph | Knowledge graph visualization |
/governance | Governance overview |
/governance/proposals | Proposal listings |
/governance/proposals/[id] | Proposal detail with voting |
/governance/proposals/new | Create new proposal |
/governance/moderation | Proposal review dashboard |
/governance/admin | Governance administration |
Authentication routes
| Route | Description |
|---|---|
/login | Login page |
/oauth/callback | OAuth callback handler |
/oauth/paper-callback | Paper OAuth callback |
/oauth/paper-popup | Paper OAuth popup flow |
/onboarding/link-accounts | Account linking onboarding |
Dashboard routes (authenticated)
| Route | Description |
|---|---|
/dashboard | User dashboard overview |
/dashboard/eprints | User's eprints |
/dashboard/claims | Authorship claims management |
/dashboard/reviews | User's reviews |
/dashboard/endorsements | User's endorsements |
/dashboard/notifications | Notifications center |
/dashboard/settings | User settings and preferences |
Submission routes (authenticated)
| Route | Description |
|---|---|
/submit | Eprint submission wizard |
/submit/claim/[source]/[externalId] | Claim external paper (arXiv, etc.) |
Alpha program routes
| Route | Description |
|---|---|
/apply | Alpha tester application |
/pending | Pending application status |
/coming-soon | Coming soon placeholder |
Testing
Unit tests
Run with Vitest:
pnpm test # Run once
pnpm test:watch # Watch mode
pnpm test:coverage # With coverage report
Test files are co-located with components:
components/ui/button.tsx
components/ui/button.test.tsx
Component tests
Playwright Component Testing for visual/interaction tests:
pnpm test:ct
Test files use .spec.tsx extension in tests/component/.
Storybook
View and develop components in isolation:
pnpm storybook
Opens at http://localhost:6006.
Story files are co-located:
components/ui/button.tsx
components/ui/button.stories.tsx
API client
The API client uses openapi-fetch with generated types:
import { api } from '@/lib/api/client';
// Type-safe API call
const { data, error } = await api.GET('/xrpc/pub.chive.eprint.getSubmission', {
params: { query: { uri } },
});
Regenerating types
When the backend API changes:
pnpm openapi:generate
This fetches /openapi.json from the backend and generates lib/api/schema.d.ts.
Server vs client components
Server Components (default):
- Data fetching at build/request time
- No client-side JavaScript
- Use for static content, layouts
Client Components ('use client'):
- Interactive UI (buttons, forms, toggles)
- Hooks (useState, useEffect)
- Browser APIs
Example:
// Server Component (default)
export default async function Page() {
const data = await fetchData(); // Runs on server
return <div>{data.title}</div>;
}
// Client Component
('use client');
export function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
Scripts
| Script | Description |
|---|---|
pnpm dev | Start development server |
pnpm build | Production build |
pnpm start | Start production server |
pnpm lint | Run ESLint |
pnpm test | Run unit tests |
pnpm test:ct | Run component tests |
pnpm storybook | Start Storybook |
pnpm openapi:generate | Regenerate API types |
Next steps
- Rich text system: RichTextRenderer, type definitions, and schema migration
- Eprint lifecycle: edit, version, and delete components
- API layer: backend endpoints the frontend calls