diff --git a/apps/frontend/AGENTS.md b/apps/frontend/AGENTS.md
index 9cc2f5bc..56d05262 100644
--- a/apps/frontend/AGENTS.md
+++ b/apps/frontend/AGENTS.md
@@ -73,6 +73,21 @@ Import direction is one-way and must stay that way: `api.ts` ← `authClient.ts`
- Tailwind v4 via `@tailwindcss/postcss` (`postcss.config.mjs`), `@import "tailwindcss"` in `globals.css`. Custom theme tokens in the `@theme` block (`--color-core-green`, `--color-primary-*`, fonts Roboto Slab / PT Sans, heading/body sizes).
- Chakra UI v3 unstyled components (`Table.Root`, `Dialog`, `Field`, `Button`, `Input`, ...) under `ChakraProvider defaultSystem`. Emotion is a Chakra dep. Inline styles appear alongside Tailwind classes in layout components.
+## Shared UI
+
+Two families of component are **the** way to do their job — don't hand-roll a second one.
+
+**Tables — `components/DataTable.tsx`.** Every list view (expenses, reports, donors, donations) renders through it, so the green header row, column widths, empty state, row-click behaviour and loading skeleton stay identical. Columns are data: `{ key, header, width, align, cell, skeleton }`. Pass `selection` (see `reports/page.tsx`) for the leading checkbox column — the page keeps owning the selected ids, since that is what its bulk actions need. `ExpensesTable` is a thin wrapper that fixes the expense column set; add domain wrappers like that rather than re-deriving columns per page.
+
+**Loading — `Spinner` / `LoadingState` / `Skeleton` / `TableSkeletonRows`.** No more `
Loading…
`.
+
+- `LoadingState` for a region whose content has not arrived (`variant="section"` reserves height; `"inline"` for menus and dialog bodies). The label is the accessible name and is hidden unless `showLabel`.
+- `DataTable isLoading` for tables — skeleton rows keep the header and column widths on screen. Set `skeletonRows` to the page size so nothing resizes when data lands.
+- Chakra's `Button loading` prop for in-flight actions; it renders its own spinner.
+- `Spinner` is the primitive; it takes its colour from `currentColor` and only gets a `label` when nothing around it is already `role="status"`.
+
+The animations live in `globals.css` (`.branch-spinner`, `.branch-skeleton`, and their keyframes), not in the components — one timing curve for the whole app, and `FullPageSpinner` can render before any component library is mounted. Both honour `prefers-reduced-motion`.
+
## Conventions
- Page/interactive components start with `'use client'`.
diff --git a/apps/frontend/src/app/components/DataTable.tsx b/apps/frontend/src/app/components/DataTable.tsx
new file mode 100644
index 00000000..b9f9357c
--- /dev/null
+++ b/apps/frontend/src/app/components/DataTable.tsx
@@ -0,0 +1,195 @@
+'use client';
+
+import type React from 'react';
+import { Checkbox, Table } from '@chakra-ui/react';
+import TableSkeletonRows, { type SkeletonColumn } from './TableSkeletonRows';
+
+export interface DataTableColumn {
+ /** Stable identity for the column; doubles as the React key. */
+ key: string;
+ header: React.ReactNode;
+ /** Width for the `
`; percentages keep the table fluid. */
+ width?: string;
+ align?: 'left' | 'center' | 'right';
+ cell: (row: T) => React.ReactNode;
+ /** Shape of this column's loading placeholder — a pill, a short bar, etc. */
+ skeleton?: SkeletonColumn;
+}
+
+/**
+ * Row selection is driven from outside: the pages that support it already own
+ * the selected ids because that is what their bulk actions operate on.
+ */
+export interface DataTableSelection {
+ isSelected: (row: T) => boolean;
+ onToggleRow: (row: T) => void;
+ allSelected: boolean;
+ someSelected: boolean;
+ onToggleAll: () => void;
+ /** Accessible name for the header checkbox, e.g. "Select all reports". */
+ label?: string;
+ disabled?: boolean;
+}
+
+interface DataTableProps {
+ columns: DataTableColumn[];
+ rows: T[];
+ rowKey: (row: T) => React.Key;
+ /** Replaces the body with skeleton rows, keeping the header and widths. */
+ isLoading?: boolean;
+ loadingLabel?: string;
+ /** Ideally the page size, so the table does not resize when data lands. */
+ skeletonRows?: number;
+ emptyMessage?: React.ReactNode;
+ onRowClick?: (row: T) => void;
+ selection?: DataTableSelection;
+ variant?: 'line' | 'outline';
+}
+
+const CHECKBOX_CONTROL_CSS = {
+ backgroundColor: 'var(--color-core-white)',
+ borderColor: 'var(--color-core-green)',
+ '&[data-state="checked"]': {
+ backgroundColor: 'var(--color-primary-800)',
+ borderColor: 'var(--color-core-green)',
+ },
+};
+
+/**
+ * The app's one table. Every list view goes through here so the green header
+ * row, column sizing, empty state and loading skeleton stay identical
+ * everywhere — previously each page rebuilt all four by hand and they drifted.
+ *
+ * Columns are data, not markup: give each one a `cell` renderer and, where the
+ * default bar is wrong, a `skeleton` shape.
+ */
+export default function DataTable({
+ columns,
+ rows,
+ rowKey,
+ isLoading = false,
+ loadingLabel = 'Loading…',
+ skeletonRows = 5,
+ emptyMessage = 'Nothing to show yet.',
+ onRowClick,
+ selection,
+ variant,
+}: DataTableProps) {
+ const columnCount = columns.length + (selection ? 1 : 0);
+ const hasWidths = columns.some((column) => column.width);
+
+ const skeletonColumns: SkeletonColumn[] = [
+ // The checkbox slot gets a square rather than a bar, so the loading table
+ // reads as the same shape as the loaded one.
+ ...(selection ? [{ width: '18px', height: 18 } as SkeletonColumn] : []),
+ ...columns.map((column) => ({
+ align: column.align,
+ ...column.skeleton,
+ })),
+ ];
+
+ return (
+
+ {hasWidths && (
+
+ {selection && }
+ {columns.map((column) => (
+
+ ))}
+
+ )}
+
+
+
+ {selection && (
+
+
+
+
+
+
+ )}
+ {columns.map((column) => (
+
+
{column.header}
+
+ ))}
+
+
+
+
+ {isLoading ? (
+
+ ) : rows.length === 0 ? (
+
+
+ {emptyMessage}
+
+
+ ) : (
+ rows.map((row) => (
+ onRowClick(row) : undefined}
+ // Rows that act like buttons have to be reachable without a
+ // mouse; the target check keeps Enter on a nested control (a
+ // receipt link, a checkbox) from also opening the row.
+ tabIndex={onRowClick ? 0 : undefined}
+ onKeyDown={
+ onRowClick
+ ? (event) => {
+ if (event.target !== event.currentTarget) return;
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault();
+ onRowClick(row);
+ }
+ }
+ : undefined
+ }
+ cursor={onRowClick ? 'pointer' : undefined}
+ _hover={onRowClick ? { backgroundColor: 'var(--color-primary-100)' } : undefined}
+ >
+ {selection && (
+ event.stopPropagation()}>
+ selection.onToggleRow(row)}
+ disabled={selection.disabled}
+ >
+
+
+
+
+ )}
+ {columns.map((column) => (
+
+ {column.cell(row)}
+
+ ))}
+
+ ))
+ )}
+
+
+ );
+}
diff --git a/apps/frontend/src/app/components/ExpensesTable.tsx b/apps/frontend/src/app/components/ExpensesTable.tsx
index 3f5c693c..3303a2b8 100644
--- a/apps/frontend/src/app/components/ExpensesTable.tsx
+++ b/apps/frontend/src/app/components/ExpensesTable.tsx
@@ -1,5 +1,7 @@
-import { Table } from '@chakra-ui/react';
+'use client';
+
import { Expenditure } from '@/types';
+import DataTable, { type DataTableColumn } from './DataTable';
import StatusBadge from './StatusBadge';
interface ExpensesTableProps {
@@ -9,6 +11,17 @@ interface ExpensesTableProps {
projectNames?: Record;
onViewReceipt?: (expenditure: Expenditure) => void;
onRowClick?: (expenditure: Expenditure) => void;
+ /** Fills the body with skeleton rows, keeping the header and widths in place. */
+ isLoading?: boolean;
+ /** Set this to the page size so the table does not resize when data lands. */
+ skeletonRows?: number;
+}
+
+function formatAmount(amount: string) {
+ return `$${parseFloat(amount).toLocaleString('en-US', {
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+ })}`;
}
export default function ExpensesTable({
@@ -17,98 +30,107 @@ export default function ExpensesTable({
projectNames = {},
onViewReceipt,
onRowClick,
+ isLoading = false,
+ skeletonRows = 5,
}: ExpensesTableProps) {
- const columnCount = showProject ? 7 : 6;
-
- return (
-
-
-
-
-
- {showProject && }
-
-
-
-
-
-
-
-
Expense ID
-
Date
-
Type of Expense
- {showProject && (
-
Project
- )}
-
Amount
-
Receipt
-
Status
-
-
+ // Percentages are shared out among whichever columns are on, so dropping one
+ // widens the rest instead of leaving a gap at the end of the row.
+ const widths = showProject
+ ? { id: '11.5%', date: '15.3%', type: '16.6%', project: '21.8%', amount: '14%', receipt: '11%', status: '9.8%' }
+ : { id: '14%', date: '19%', type: '21%', project: '0', amount: '18%', receipt: '14%', status: '14%' };
-
- {expenditures.length === 0 ? (
-
-
- No expenditures found.
-
-
+ const columns: DataTableColumn[] = [
+ {
+ key: 'id',
+ header: 'Expense ID',
+ width: widths.id,
+ cell: (e) => `#${String(e.expenditure_id).padStart(6, '0')}`,
+ skeleton: { width: '80%' },
+ },
+ {
+ key: 'date',
+ header: 'Date',
+ width: widths.date,
+ cell: (e) =>
+ new Date(e.spent_on).toLocaleDateString('en-US', {
+ month: '2-digit',
+ day: '2-digit',
+ year: 'numeric',
+ }),
+ skeleton: { width: '75%' },
+ },
+ {
+ key: 'type',
+ header: 'Type of Expense',
+ width: widths.type,
+ cell: (e) => e.category ?? '—',
+ },
+ ...(showProject
+ ? [
+ {
+ key: 'project',
+ header: 'Project',
+ width: widths.project,
+ cell: (e: Expenditure) => projectNames[e.project_id] ?? '---',
+ },
+ ]
+ : []),
+ {
+ key: 'amount',
+ header: 'Amount',
+ width: widths.amount,
+ cell: (e) => formatAmount(e.amount),
+ skeleton: { width: '60%' },
+ },
+ {
+ key: 'receipt',
+ header: 'Receipt',
+ width: widths.receipt,
+ cell: (e) =>
+ e.receipt_url ? (
+
) : (
- expenditures.map((e) => (
- onRowClick(e) : undefined}
- style={onRowClick ? { cursor: 'pointer' } : undefined}
- >
- #{String(e.expenditure_id).padStart(6, '0')}
-
- {new Date(e.spent_on).toLocaleDateString('en-US', {
- month: '2-digit',
- day: '2-digit',
- year: 'numeric',
- })}
-
- {e.category ?? '—'}
- {showProject && (
- {projectNames[e.project_id] ?? '---'}
- )}
-
- ${parseFloat(e.amount).toLocaleString('en-US', {
- minimumFractionDigits: 2,
- maximumFractionDigits: 2,
- })}
-
-
- {e.receipt_url ? (
-
- ) : (
- '---'
- )}
-
-
-
-
-
- ))
- )}
-
-
+ '---'
+ ),
+ skeleton: { width: '70%' },
+ },
+ {
+ key: 'status',
+ header: 'Status',
+ width: widths.status,
+ cell: (e) => ,
+ // Matches the pill the loaded row shows rather than a text bar.
+ skeleton: { width: '81px', height: 29, className: '!rounded-[14px]' },
+ },
+ ];
+
+ return (
+ e.expenditure_id}
+ onRowClick={onRowClick}
+ isLoading={isLoading}
+ loadingLabel="Loading expenses…"
+ skeletonRows={skeletonRows}
+ emptyMessage="No expenditures found."
+ />
);
}
diff --git a/apps/frontend/src/app/components/FullPageSpinner.tsx b/apps/frontend/src/app/components/FullPageSpinner.tsx
index e9663b5d..26057840 100644
--- a/apps/frontend/src/app/components/FullPageSpinner.tsx
+++ b/apps/frontend/src/app/components/FullPageSpinner.tsx
@@ -1,5 +1,7 @@
'use client';
+import Spinner from './Spinner';
+
/**
* Neutral full-viewport placeholder shown while the session resolves or a
* redirect is in flight.
@@ -7,7 +9,8 @@
* Deliberately reveals nothing about the app shell — the whole point of the
* guard is that unauthenticated visitors never see it — and deliberately has no
* component-library dependency, because it renders from AuthGate and the root
- * page, above anything that could be relied on to be mounted.
+ * page, above anything that could be relied on to be mounted. `Spinner` is
+ * plain markup over `globals.css`, which the root layout always loads.
*/
export default function FullPageSpinner({
label = 'Loading…',
@@ -25,19 +28,10 @@ export default function FullPageSpinner({
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#f9fafb',
+ color: '#2E6038',
}}
>
-
-
+
);
}
diff --git a/apps/frontend/src/app/components/LoadingState.tsx b/apps/frontend/src/app/components/LoadingState.tsx
new file mode 100644
index 00000000..aa2a010d
--- /dev/null
+++ b/apps/frontend/src/app/components/LoadingState.tsx
@@ -0,0 +1,50 @@
+'use client';
+
+import Spinner, { type SpinnerSize } from './Spinner';
+
+interface LoadingStateProps {
+ /**
+ * Announced to screen readers, and shown as text when `showLabel` is set.
+ * Say what is loading — several of these can be on screen at once.
+ */
+ label?: string;
+ /** Renders the label under the spinner. Off by default: the spinner says it. */
+ showLabel?: boolean;
+ size?: SpinnerSize;
+ /**
+ * `section` reserves vertical space so a page region does not collapse and
+ * then jolt when the data lands; `inline` hugs its content for tight spots
+ * such as a dropdown menu or a dialog body.
+ */
+ variant?: 'section' | 'inline';
+ className?: string;
+}
+
+/**
+ * The standard placeholder for a region whose content has not arrived yet:
+ * a centred spinner in place of the old "Loading…" paragraphs.
+ *
+ * For tables prefer `TableSkeletonRows`, which keeps the header and column
+ * widths on screen instead of blanking the whole grid.
+ */
+export default function LoadingState({
+ label = 'Loading…',
+ showLabel = false,
+ size = 'md',
+ variant = 'section',
+ className = '',
+}: LoadingStateProps) {
+ const spacing = variant === 'section' ? 'min-h-[240px] !py-10' : '!py-3';
+
+ return (
+
+
+ {showLabel &&
{label}
}
+
+ );
+}
diff --git a/apps/frontend/src/app/components/ReviewExpenseModal.tsx b/apps/frontend/src/app/components/ReviewExpenseModal.tsx
index cf8643ec..33ea27f7 100644
--- a/apps/frontend/src/app/components/ReviewExpenseModal.tsx
+++ b/apps/frontend/src/app/components/ReviewExpenseModal.tsx
@@ -13,6 +13,7 @@ import {
type ExpenditureDetail,
type ExpenditureStatus,
} from '@/types';
+import LoadingState from './LoadingState';
import StatusBadge from './StatusBadge';
interface ReviewExpenseModalProps {
@@ -157,7 +158,7 @@ export default function ReviewExpenseModal({
- {loading &&
Loading expense...
}
+ {loading && }
{loadError &&
{loadError}
}
{!loading && !loadError && detail && (
diff --git a/apps/frontend/src/app/components/Skeleton.tsx b/apps/frontend/src/app/components/Skeleton.tsx
new file mode 100644
index 00000000..54e2af12
--- /dev/null
+++ b/apps/frontend/src/app/components/Skeleton.tsx
@@ -0,0 +1,37 @@
+'use client';
+
+import type { CSSProperties } from 'react';
+
+interface SkeletonProps {
+ /** Any CSS length; percentages let a bar track its cell. */
+ width?: string | number;
+ height?: string | number;
+ /** Offsets the shimmer so a stack of skeletons animates as a wave. */
+ delayMs?: number;
+ className?: string;
+}
+
+/**
+ * A shimmering placeholder bar. Purely decorative — it is `aria-hidden`, so
+ * whatever renders it owns the `role="status"` announcement.
+ */
+export default function Skeleton({
+ width = '100%',
+ height = 14,
+ delayMs = 0,
+ className = '',
+}: SkeletonProps) {
+ return (
+
+ );
+}
diff --git a/apps/frontend/src/app/components/Spinner.tsx b/apps/frontend/src/app/components/Spinner.tsx
new file mode 100644
index 00000000..846d4c76
--- /dev/null
+++ b/apps/frontend/src/app/components/Spinner.tsx
@@ -0,0 +1,51 @@
+'use client';
+
+/**
+ * The app's one spinner. Every "something is in flight" affordance should end
+ * up here rather than rolling its own div or borrowing Chakra's, so the size
+ * ramp and timing stay consistent.
+ *
+ * Colour comes from `currentColor` — set a text colour on the parent (or via
+ * `className`) to put a spinner on a dark surface.
+ */
+export type SpinnerSize = 'xs' | 'sm' | 'md' | 'lg';
+
+/** Ring thickness scales with the diameter, otherwise small sizes read as blobs. */
+const SIZES: Record = {
+ xs: { box: 14, border: 2 },
+ sm: { box: 20, border: 2 },
+ md: { box: 32, border: 3 },
+ lg: { box: 40, border: 4 },
+};
+
+interface SpinnerProps {
+ size?: SpinnerSize;
+ /**
+ * Accessible name. Provide it only when the spinner is the sole indication
+ * that something is loading — when it sits inside an element that is already
+ * `role="status"` (LoadingState, FullPageSpinner), leave it off so screen
+ * readers announce the region once instead of twice.
+ */
+ label?: string;
+ className?: string;
+}
+
+export default function Spinner({ size = 'md', label, className = '' }: SpinnerProps) {
+ const { box, border } = SIZES[size];
+
+ const ring = (
+
+ );
+
+ if (!label) return ring;
+
+ return (
+
+ {ring}
+
+ );
+}
diff --git a/apps/frontend/src/app/components/TableSkeletonRows.tsx b/apps/frontend/src/app/components/TableSkeletonRows.tsx
new file mode 100644
index 00000000..7864a624
--- /dev/null
+++ b/apps/frontend/src/app/components/TableSkeletonRows.tsx
@@ -0,0 +1,86 @@
+'use client';
+
+import { Table } from '@chakra-ui/react';
+import Skeleton from './Skeleton';
+
+export interface SkeletonColumn {
+ /**
+ * Width of the bar inside the cell. Percentages get a per-row jitter so the
+ * block reads like text rather than a bar chart; absolute lengths are left
+ * exactly as given, for fixed slots such as a checkbox or an icon.
+ */
+ width?: string;
+ align?: 'left' | 'center' | 'right';
+ height?: number;
+ /** Extra classes on the bar, e.g. `!rounded-full` for a status pill. */
+ className?: string;
+}
+
+interface TableSkeletonRowsProps {
+ /** Ideally the page size, so the table does not resize when data lands. */
+ rows?: number;
+ /** A count for evenly-filled cells, or per-column shapes. */
+ columns: number | SkeletonColumn[];
+ label?: string;
+}
+
+/** Deterministic — `Math.random()` here would churn on every re-render. */
+const JITTER = [1, 0.82, 0.93, 0.71, 0.88, 0.78];
+
+/**
+ * Skeleton rows to drop inside a `Table.Body` while its data loads, so the
+ * header, column widths and page height stay put and the table fades in
+ * instead of popping.
+ *
+ * ```tsx
+ *
+ * {loading ? : rows.map(...)}
+ *
+ * ```
+ */
+export default function TableSkeletonRows({
+ rows = 5,
+ columns,
+ label = 'Loading…',
+}: TableSkeletonRowsProps) {
+ const shape: SkeletonColumn[] =
+ typeof columns === 'number' ? Array.from({ length: columns }, () => ({})) : columns;
+
+ return (
+ <>
+ {Array.from({ length: rows }, (_, rowIndex) => (
+
+ {shape.map((column, columnIndex) => {
+ const width = column.width ?? '70%';
+ const jitter = JITTER[(rowIndex * 3 + columnIndex) % JITTER.length];
+ const align = column.align ?? 'left';
+
+ return (
+
+ {rowIndex === 0 && columnIndex === 0 && (
+
+ {label}
+
+ )}
+
+
+
+
+ );
+ })}
+
+ ))}
+ >
+ );
+}
diff --git a/apps/frontend/src/app/dashboard/page.tsx b/apps/frontend/src/app/dashboard/page.tsx
index dfe5e999..f98d6c1f 100644
--- a/apps/frontend/src/app/dashboard/page.tsx
+++ b/apps/frontend/src/app/dashboard/page.tsx
@@ -8,6 +8,7 @@ import Header from '../components/Header';
import ProjectCard from '../components/ProjectCard';
import SummaryStatCard from '../components/SummaryStatCard';
import ExpensesBarChart from '../components/ExpensesBarChart';
+import LoadingState from '../components/LoadingState';
import { useApi } from '@/hooks/useApi';
import type { DashboardResponse } from '@/types/dashboard';
@@ -53,7 +54,7 @@ export default function DashboardPage() {
}
- {/* Table */}
- {!loading && !error && (
+ {/* Table — the skeleton lives inside it, so the header and column
+ widths stay put while the rows load. */}
+ {!error && (
and . They
+ live here rather than in the components so every loading affordance in the
+ app shares one timing curve, and so a spinner can render before any
+ component library is mounted (see FullPageSpinner). */
+
+@keyframes branch-spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+@keyframes branch-shimmer {
+ to {
+ transform: translateX(100%);
+ }
+}
+
+/* Takes its colour from `currentColor`, so a spinner on a dark surface only
+ needs a text colour rather than its own variant. */
+.branch-spinner {
+ display: inline-block;
+ flex-shrink: 0;
+ border-radius: 9999px;
+ border-style: solid;
+ border-color: color-mix(in srgb, currentColor 22%, transparent);
+ border-top-color: currentColor;
+ animation: branch-spin 0.7s linear infinite;
+}
+
+.branch-skeleton {
+ position: relative;
+ overflow: hidden;
+ background-color: var(--color-black-100);
+ border-radius: 4px;
+}
+
+.branch-skeleton::after {
+ content: '';
+ position: absolute;
+ inset: 0;
+ transform: translateX(-100%);
+ background-image: linear-gradient(
+ 90deg,
+ transparent,
+ color-mix(in srgb, var(--color-core-white) 70%, transparent),
+ transparent
+ );
+ animation: branch-shimmer 1.4s ease-in-out infinite;
+ /* Staggered per row so a table of skeletons reads as one wave. */
+ animation-delay: var(--branch-skeleton-delay, 0s);
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .branch-spinner {
+ animation-duration: 2.4s;
+ }
+
+ .branch-skeleton::after {
+ animation: none;
+ }
+}
\ No newline at end of file
diff --git a/apps/frontend/src/app/projects/[id]/ProjectDetailClient.tsx b/apps/frontend/src/app/projects/[id]/ProjectDetailClient.tsx
index 62c71009..7593b31e 100644
--- a/apps/frontend/src/app/projects/[id]/ProjectDetailClient.tsx
+++ b/apps/frontend/src/app/projects/[id]/ProjectDetailClient.tsx
@@ -5,6 +5,7 @@ import { FaEdit } from 'react-icons/fa';
import { RxCaretRight } from 'react-icons/rx';
import NavBar from '../../components/Navbar';
import ExpensesTable from '../../components/ExpensesTable';
+import LoadingState from '../../components/LoadingState';
import StaffCard from '../../components/StaffCard';
import { useApi } from '@/hooks/useApi';
import { Project, Expenditure, Member } from '@/types';
@@ -60,7 +61,7 @@ export default function ProjectPage() {
-
Loading project...
+
);
diff --git a/apps/frontend/src/app/projects/page.tsx b/apps/frontend/src/app/projects/page.tsx
index 1034e880..d2b09f10 100644
--- a/apps/frontend/src/app/projects/page.tsx
+++ b/apps/frontend/src/app/projects/page.tsx
@@ -5,6 +5,7 @@ import Link from 'next/link';
import NavBar from '../components/Navbar';
import Header from '../components/Header';
import ProjectCard from '../components/ProjectCard';
+import LoadingState from '../components/LoadingState';
import { useApi } from '@/hooks/useApi';
/**
@@ -65,7 +66,7 @@ export default function ProjectsListPage() {
Projects
- {isLoading &&