codingstairs
NotesEDULifeContact
⌕Search⌘K
koen

Navigation

  • Intro
  • Blog
  • Life

Get in touch

Send without signing in. Add your email if you'd like a reply.

  • Leave a message anonymously →
  • ✉ warragon112@gmail.com
  • KakaoTalk Open Chat ↗

© 2026 codingstairs

  • Notes
  • EDU
  • Search
  • Life
  • Contact
  • Legal
  • RSS
  • GitHub
EDU›Central admin platform — many domains behind one hub›Step 4

Step 4

AdminResourceTable component

0 views

AdminResourceTable component

The first few list pages can be written inline, but after ten pages the shared component clearly wins. Hand it title · icon · columns · rows · pagination and each page fits in 30–70 lines.

1. Props

export interface ResourceTableProps<Row> {
  title: string;
  subtitle?: string;
  icon: ReactNode;
  iconColor?: 'blue' | 'green' | 'amber' | 'rose' | 'violet';
  actions?: ReactNode;
  search?: { placeholder: string; value: string; onChange: (v: string) => void };
  filters?: ReactNode;
  columns: Array<{
    header: string;
    key: string;
    render?: (row: Row) => ReactNode;
    className?: string;
  }>;
  rows: Row[];
  rowKey: (row: Row) => string | number;
  emptyState?: { title: string; description?: string; action?: ReactNode };
  pagination?: {
    page: number; pageSize: number; total: number;
    onPageChange: (p: number) => void;
  };
}

2. Implementation highlights

const colorMap = {
  blue:   { bg: 'bg-blue-50',   fg: 'text-blue-600'   },
  green:  { bg: 'bg-green-50',  fg: 'text-green-600'  },
  amber:  { bg: 'bg-amber-50',  fg: 'text-amber-600'  },
  rose:   { bg: 'bg-rose-50',   fg: 'text-rose-600'   },
  violet: { bg: 'bg-violet-50', fg: 'text-violet-600' },
} as const;

Tailwind class strings must be complete at build time (text-${color}-600 gets purged), hence a typed lookup.

3. Usage — posts list

'use client';

export function PostsView({ initialRows }: { initialRows: Post[] }) {
  const [q, setQ] = useState('');
  const rows = initialRows.filter((p) =>
    p.title.toLowerCase().includes(q.toLowerCase())
  );

  return (
    <AdminResourceTable<Post>
      title="Blog posts"
      subtitle={`${rows.length} total`}
      icon={<FileText className="h-7 w-7" />}
      iconColor="blue"
      search={{ placeholder: 'search title', value: q, onChange: setQ }}
      columns={[
        { header: 'title', key: 'title' },
        { header: 'published', key: 'published', render: (p) => p.published ? '✓' : '—' },
        { header: 'created', key: 'created_at', render: (p) => p.created_at.slice(0, 10) },
      ]}
      rows={rows}
      rowKey={(p) => p.id}
      emptyState={{ title: 'no posts', description: 'write your first post' }}
    />
  );
}

4. URL-first search and pagination

const params = useSearchParams();
const router = useRouter();
const q = params.get('q') ?? '';

const onQChange = (v: string) => {
  const u = new URLSearchParams(params);
  if (v) u.set('q', v); else u.delete('q');
  u.delete('page');
  router.push(`?${u.toString()}`);
};

Refresh-safe, shareable, predictable.

5. Responsiveness and a11y

  • Admins rarely use mobile; overflow-x-auto on the table is usually enough
  • <th scope="col"> / <th scope="row"> for screen readers
  • Empty state belongs inside <tr><td colSpan>

Closing

It feels premature for the first few pages. Past the fifth it consistently pays off. Start minimal (title · columns · rows · pagination) and grow props only when real needs arise.

Next

  • 05-oauth-allowlist

← Step 3

Connecting multiple PostgreSQL pools

Step 5 →

OAuth 2 providers + allow-list