chore(repo): initialize planarchy workspace

This commit is contained in:
2026-03-14 14:31:09 +01:00
commit dd55d0e78b
769 changed files with 166461 additions and 0 deletions
@@ -0,0 +1,530 @@
"use client";
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import Link from "next/link";
import type { Resource, SkillEntry } from "@planarchy/shared";
import { RESOURCE_COLUMNS } from "@planarchy/shared";
import { BlueprintTarget } from "@planarchy/shared";
import { trpc } from "~/lib/trpc/client.js";
import { ResourceModal } from "~/components/resources/ResourceModal.js";
import { ImportModal } from "~/components/resources/ImportModal.js";
import { BulkEditModal } from "~/components/resources/BulkEditModal.js";
import { useSelection } from "~/hooks/useSelection.js";
import { BatchActionBar } from "~/components/ui/BatchActionBar.js";
import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js";
import { FilterBar } from "~/components/ui/FilterBar.js";
import { FilterChips } from "~/components/ui/FilterChips.js";
import { CustomFieldFilterBar } from "~/components/ui/CustomFieldFilterBar.js";
import { useFilters } from "~/hooks/useFilters.js";
import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { ColumnTogglePanel } from "~/components/ui/ColumnTogglePanel.js";
import { InfiniteScrollSentinel } from "~/components/ui/InfiniteScrollSentinel.js";
import { useTableSort } from "~/hooks/useTableSort.js";
import { usePermissions } from "~/hooks/usePermissions.js";
import { useColumnConfig } from "~/hooks/useColumnConfig.js";
import { useViewPrefs } from "~/hooks/useViewPrefs.js";
import { useRowOrder } from "~/hooks/useRowOrder.js";
import { DraggableTableRow } from "~/components/ui/DraggableTableRow.js";
type ModalState =
| { type: "closed" }
| { type: "create" }
| { type: "edit"; resource: Resource }
| { type: "import" }
| { type: "bulkEdit" };
type ConfirmState =
| { type: "closed" }
| { type: "batchDeactivate"; ids: string[] }
| { type: "deactivate"; resource: Resource };
type ActiveFilter = "active" | "inactive" | "all";
type ResourceListPage = {
resources: Resource[];
total: number;
nextCursor?: string | null;
};
export function ResourcesClient() {
const [search, setSearch] = useState("");
const [chapterFilter, setChapterFilter] = useState("");
const [isActiveFilter, setIsActiveFilter] = useState<ActiveFilter>("active");
const [modal, setModal] = useState<ModalState>({ type: "closed" });
const [confirm, setConfirm] = useState<ConfirmState>({ type: "closed" });
const selection = useSelection();
const utils = trpc.useUtils();
const { canViewScores, canViewCosts } = usePermissions();
const { customFieldFilters, setCustomFieldFilter, clearFilters: clearCustomFilters } = useFilters();
// ─── Custom field columns from global blueprints ──────────────────────────
const { data: globalFieldDefs } = trpc.blueprint.getGlobalFieldDefs.useQuery(
{ target: BlueprintTarget.RESOURCE },
{ staleTime: 300_000 },
);
const customColumns = useMemo(
() =>
(globalFieldDefs ?? [])
.filter((f) => f.showInList)
.map((f) => ({
key: `custom_${f.key}`,
label: f.label,
defaultVisible: false,
hideable: true,
isCustom: true,
fieldType: f.type as string,
})),
[globalFieldDefs],
);
const filterableFields = useMemo(
() => (globalFieldDefs ?? []).filter((f) => f.isFilterable),
[globalFieldDefs],
);
// ─── Column visibility ────────────────────────────────────────────────────
const { allColumns, visibleColumns, visibleKeys, setVisible } = useColumnConfig(
"resources",
RESOURCE_COLUMNS,
customColumns,
);
const defaultKeys = useMemo(
() => RESOURCE_COLUMNS.filter((c) => c.defaultVisible).map((c) => c.key),
[],
);
// ─── Infinite query (cursor-based) ────────────────────────────────────────
const {
data,
isLoading,
isFetchingNextPage,
fetchNextPage,
hasNextPage,
// Keep this boundary shallow; the full TRPC inference here trips TS depth limits.
} = (trpc.resource.list.useInfiniteQuery as any)(
{
isActive: isActiveFilter === "all" ? undefined : isActiveFilter === "active",
search: search || undefined,
chapter: chapterFilter || undefined,
includeRoles: true,
limit: 50,
...(customFieldFilters.length > 0 ? { customFieldFilters } : {}),
},
{
getNextPageParam: (lastPage: ResourceListPage) => lastPage.nextCursor ?? undefined,
initialCursor: undefined,
placeholderData: (prev: { pages: ResourceListPage[] } | undefined) => prev,
staleTime: 20_000,
},
) as {
data:
| {
pages: ResourceListPage[];
}
| undefined;
isLoading: boolean;
isFetchingNextPage: boolean;
fetchNextPage: () => Promise<unknown>;
hasNextPage: boolean | undefined;
};
const resources = useMemo(
() => (data?.pages.flatMap((p) => p.resources) ?? []) as unknown as Resource[],
[data],
);
const total = data?.pages[0]?.total ?? 0;
// ─── Sort + row order (per-user persistence) ──────────────────────────────
const viewPrefs = useViewPrefs("resources");
const { sorted, sortField, sortDir, toggle, reset } = useTableSort<Resource>(resources, {
initialField: viewPrefs.savedSort?.field ?? null,
initialDir: viewPrefs.savedSort?.dir ?? null,
onSortChange: (field, dir) => {
viewPrefs.setSavedSort(field && dir ? { field, dir } : null);
},
});
const { orderedRows: displayedResources, reorder, isCustomOrder, resetOrder } = useRowOrder(
sorted,
viewPrefs,
sortField,
reset,
);
const rowDragRef = useRef<string | null>(null);
const resourceIds: string[] = displayedResources.map((r) => r.id);
// Performance note: cursor-based infinite scroll (50 rows/page) keeps DOM nodes bounded.
// True virtualizer is not needed for typical resource counts (<500).
// ─── Chargeability stats ──────────────────────────────────────────────────
const { data: chargeabilityData } = trpc.resource.getChargeabilityStats.useQuery(
{},
{ enabled: canViewCosts, placeholderData: (prev) => prev, staleTime: 60_000 },
);
const chargeabilityMap = useMemo(
() => new Map((chargeabilityData ?? []).map((s) => [s.id, s])),
[chargeabilityData],
);
// ─── Chapters filter ──────────────────────────────────────────────────────
const { data: chapterData } = trpc.resource.chapters.useQuery(
undefined,
{ placeholderData: (prev) => prev, staleTime: 60_000 },
);
const chapters = chapterData ?? [];
// ─── Mutations ────────────────────────────────────────────────────────────
const deactivateMutation = trpc.resource.deactivate.useMutation({
onSuccess: async () => { await utils.resource.list.invalidate(); },
});
const batchDeactivateMutation = trpc.resource.batchDeactivate.useMutation({
onSuccess: async () => {
await utils.resource.list.invalidate();
selection.clear();
},
});
useEffect(() => {
selection.clear();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [search, chapterFilter, isActiveFilter]);
function closeModal() { setModal({ type: "closed" }); }
function handleConfirm() {
if (confirm.type === "deactivate") {
deactivateMutation.mutate({ id: confirm.resource.id });
} else if (confirm.type === "batchDeactivate") {
batchDeactivateMutation.mutate({ ids: confirm.ids });
}
setConfirm({ type: "closed" });
}
function clearAll() {
setSearch("");
setChapterFilter("");
setIsActiveFilter("active");
clearCustomFilters();
}
const handleFetchNext = useCallback(() => {
if (hasNextPage && !isFetchingNextPage) void fetchNextPage();
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
const chips = [
...(search ? [{ label: `Search: "${search}"`, onRemove: () => setSearch("") }] : []),
...(chapterFilter ? [{ label: `Chapter: ${chapterFilter}`, onRemove: () => setChapterFilter("") }] : []),
...(isActiveFilter !== "active" ? [{ label: isActiveFilter === "all" ? "Showing all" : "Inactive only", onRemove: () => setIsActiveFilter("active") }] : []),
...customFieldFilters.map((f) => ({
label: `${f.key}: ${f.value}`,
onRemove: () => setCustomFieldFilter(f.key, "", f.type),
})),
];
return (
<div className="p-6 pb-24">
{/* Page header */}
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Resources</h1>
{!isLoading && (
<p className="text-gray-500 text-sm mt-1">{total} resource{total !== 1 ? "s" : ""}</p>
)}
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setModal({ type: "import" })}
className="flex items-center gap-2 px-4 py-2 bg-white border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 text-sm font-medium transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" /></svg>
Import
</button>
<button
type="button"
onClick={() => setModal({ type: "create" })}
className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" /></svg>
New Resource
</button>
</div>
</div>
{/* Filters + Column toggle */}
<FilterBar>
<input
type="search"
placeholder="Search by name, EID, email..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full max-w-xs px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm"
/>
{chapters.length > 0 && (
<select
value={chapterFilter}
onChange={(e) => setChapterFilter(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm bg-white"
>
<option value="">All Chapters</option>
{chapters.map((c) => <option key={c} value={c}>{c}</option>)}
</select>
)}
<select
value={isActiveFilter}
onChange={(e) => setIsActiveFilter(e.target.value as ActiveFilter)}
className="px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm bg-white"
>
<option value="active">Active only</option>
<option value="inactive">Inactive only</option>
<option value="all">All resources</option>
</select>
<ColumnTogglePanel
allColumns={allColumns}
visibleKeys={visibleKeys}
onSetVisible={setVisible}
defaultKeys={defaultKeys}
/>
{isCustomOrder && (
<button
type="button"
onClick={resetOrder}
className="text-xs text-gray-500 hover:text-gray-700 underline whitespace-nowrap"
title="Clear manual row order"
>
Reset order
</button>
)}
</FilterBar>
{filterableFields.length > 0 && (
<div className="mb-2">
<CustomFieldFilterBar
filterableFields={filterableFields}
activeFilters={customFieldFilters}
onSetFilter={setCustomFieldFilter}
/>
</div>
)}
{chips.length > 0 && (
<div className="mb-3">
<FilterChips chips={chips} onClearAll={clearAll} />
</div>
)}
{/* Table */}
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
{isLoading && resources.length === 0 ? (
<div className="p-12 text-center text-gray-400 text-sm animate-pulse">Loading resources</div>
) : (
<>
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
{/* Drag handle column */}
<th className="w-8 px-2" />
<th className="px-4 py-3 w-10">
<input
type="checkbox"
checked={selection.isAllSelected(resourceIds)}
ref={(el) => { if (el) el.indeterminate = selection.isIndeterminate(resourceIds); }}
onChange={() => selection.toggleAll(resourceIds)}
className="rounded border-gray-300"
/>
</th>
{visibleColumns.map((col) => {
if (col.isCustom) {
return (
<th key={col.key} className="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{col.label}
</th>
);
}
switch (col.key) {
case "eid":
return <SortableColumnHeader key={col.key} label="EID" field="eid" sortField={sortField} sortDir={sortDir} onSort={toggle} tooltip="Unique employee identifier used across all Planarchy records." />;
case "displayName":
return <SortableColumnHeader key={col.key} label="Name / Email" field="displayName" sortField={sortField} sortDir={sortDir} onSort={toggle} />;
case "chapter":
return <SortableColumnHeader key={col.key} label="Chapter" field="chapter" sortField={sortField} sortDir={sortDir} onSort={toggle} />;
case "lcr":
return <SortableColumnHeader key={col.key} label="LCR (€/h)" field="lcrCents" sortField={sortField} sortDir={sortDir} onSort={toggle} tooltip="Labour Cost Rate — the resource's hourly cost in EUR. Used to calculate project budgets (LCR × hours/day × working days)." />;
case "chargeability":
return <SortableColumnHeader key={col.key} label="Chargeability (actual)" field="chargeabilityTarget" sortField={sortField} sortDir={sortDir} onSort={toggle} tooltip="Actual = CONFIRMED+ACTIVE allocations on non-DRAFT projects ÷ available working hours this month × 100. Expected (in parentheses) includes DRAFT projects. Target is the management-set goal." tooltipWidth="w-80" />;
case "valueScore":
return canViewScores
? <SortableColumnHeader key={col.key} label="Score" field="valueScore" sortField={sortField} sortDir={sortDir} onSort={toggle} tooltip="Composite price/quality score 0100. Weights: Skill Depth 30% · Cost Efficiency 25% · Skill Breadth 15% · Chargeability 15% · Experience 15%. Recompute in Admin → Settings." tooltipWidth="w-72" />
: null;
case "roles":
return <th key={col.key} className="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Roles <InfoTooltip content="Primary role (★) and additional roles assigned to this resource. Used for open demand and staffing suggestions." /></th>;
case "isActive":
return <th key={col.key} className="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Skills <InfoTooltip content="Skills from the resource's skill matrix. Shows first 3; hover the +N badge for more." /></th>;
default:
return <th key={col.key} className="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{col.label}</th>;
}
})}
<th className="px-3 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{displayedResources.map((resource) => {
const skills = resource.skills as unknown as SkillEntry[];
const isSelected = selection.selectedIds.has(resource.id);
const isDeactivating =
deactivateMutation.isPending &&
(deactivateMutation.variables as { id: string } | undefined)?.id === resource.id;
const dynFields = (resource as unknown as { dynamicFields?: Record<string, unknown> }).dynamicFields ?? {};
return (
<DraggableTableRow
key={resource.id}
id={resource.id}
dragRef={rowDragRef}
onDrop={(draggedId) => reorder(draggedId, resource.id)}
className={`hover:bg-gray-50 transition-colors ${isSelected ? "bg-brand-50" : ""}`}
>
<td className="px-4 py-3">
<input type="checkbox" checked={isSelected} onChange={() => selection.toggle(resource.id)} className="rounded border-gray-300" />
</td>
{visibleColumns.map((col) => {
if (col.isCustom) {
const fieldKey = col.key.replace(/^custom_/, "");
const val = dynFields[fieldKey];
return <td key={col.key} className="px-3 py-3 text-sm text-gray-700">{val != null ? String(val) : "—"}</td>;
}
switch (col.key) {
case "eid":
return <td key={col.key} className="px-4 py-3 text-sm font-mono text-gray-600">{resource.eid}</td>;
case "displayName":
return (
<td key={col.key} className="px-4 py-3">
<Link href={`/resources/${resource.id}`} className="text-sm font-medium text-gray-900 hover:text-brand-600 hover:underline transition-colors">{resource.displayName}</Link>
<div className="text-xs text-gray-500">{resource.email}</div>
</td>
);
case "chapter":
return <td key={col.key} className="px-4 py-3 text-sm text-gray-600">{resource.chapter ?? "—"}</td>;
case "lcr":
return <td key={col.key} className="px-4 py-3 text-sm text-gray-900">{(resource.lcrCents / 100).toFixed(0)} {resource.currency}</td>;
case "chargeability": {
if (!canViewCosts) return <td key={col.key} className="px-4 py-3 text-sm text-gray-500">{resource.chargeabilityTarget}%</td>;
const stats = chargeabilityMap.get(resource.id);
const actual = stats?.actualChargeability;
const expected = stats?.expectedChargeability;
const target = resource.chargeabilityTarget;
const color = actual == null ? "text-gray-400" : actual >= target ? "text-green-700" : actual >= target - 20 ? "text-amber-600" : "text-red-600";
return (
<td key={col.key} className="px-4 py-3 text-sm">
<div>
<span className={`font-medium ${color}`}>{actual != null ? `${actual}%` : "—"}</span>
{expected != null && expected !== actual && <span className="text-xs text-gray-400 ml-1">({expected}% exp.)</span>}
<div className="text-xs text-gray-400">Target: {target}%</div>
</div>
</td>
);
}
case "valueScore": {
if (!canViewScores) return null;
const score = (resource as unknown as { valueScore?: number | null }).valueScore;
return (
<td key={col.key} className="px-4 py-3 text-sm">
{score != null ? (
<span className={`inline-block px-2 py-0.5 rounded-full text-xs font-semibold ${score >= 70 ? "bg-green-100 text-green-700" : score >= 40 ? "bg-amber-100 text-amber-700" : "bg-red-100 text-red-700"}`}>{score}</span>
) : (
<span className="text-gray-400 text-xs"></span>
)}
</td>
);
}
case "roles": {
const rr = ((resource as unknown as { resourceRoles?: { isPrimary: boolean; role: { id: string; name: string; color: string | null } }[] }).resourceRoles ?? []);
return (
<td key={col.key} className="px-4 py-3">
<div className="flex flex-wrap gap-1">
{rr.map((r) => (
<span key={r.role.id} className="inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded-full font-medium" style={{ backgroundColor: `${r.role.color ?? "#6366f1"}22`, color: r.role.color ?? "#6366f1" }}>
{r.isPrimary && <span className="text-[10px]"></span>}
{r.role.name}
</span>
))}
{rr.length === 0 && <span className="text-xs text-gray-400"></span>}
</div>
</td>
);
}
case "isActive":
return (
<td key={col.key} className="px-4 py-3">
<div className="flex flex-wrap gap-1">
{skills.slice(0, 3).map((s) => (
<span key={s.skill} className="inline-block px-2 py-0.5 text-xs bg-brand-50 text-brand-700 rounded-full">{s.skill}</span>
))}
{skills.length > 3 && <span className="text-xs text-gray-400">+{skills.length - 3}</span>}
</div>
</td>
);
default:
return <td key={col.key} className="px-4 py-3 text-sm text-gray-600"></td>;
}
})}
<td className="px-4 py-3 text-right whitespace-nowrap">
<button type="button" onClick={() => setModal({ type: "edit", resource: resource as unknown as Resource })} className="text-xs font-medium text-brand-600 hover:text-brand-800 transition-colors mr-3">Edit</button>
<button type="button" onClick={() => setConfirm({ type: "deactivate", resource: resource as unknown as Resource })} disabled={isDeactivating} className="text-xs font-medium text-red-600 hover:text-red-800 transition-colors disabled:opacity-50">{isDeactivating ? "Deactivating…" : "Deactivate"}</button>
</td>
</DraggableTableRow>
);
})}
</tbody>
</table>
{displayedResources.length === 0 && !isLoading && (
<div className="text-center py-12 text-gray-500 text-sm">No resources found.</div>
)}
{/* Infinite scroll trigger */}
<InfiniteScrollSentinel onVisible={handleFetchNext} isLoading={isFetchingNextPage} />
</>
)}
</div>
<BatchActionBar
count={selection.count}
onClear={selection.clear}
actions={[
...(filterableFields.length > 0 ? [{
label: "Edit Custom Fields",
variant: "default" as const,
onClick: () => setModal({ type: "bulkEdit" }),
disabled: false,
}] : []),
{
label: `Deactivate ${selection.count > 0 ? `(${selection.count})` : ""}`,
variant: "danger" as const,
onClick: () => setConfirm({ type: "batchDeactivate", ids: selection.selectedArray }),
disabled: batchDeactivateMutation.isPending,
},
]}
/>
{modal.type === "create" && <ResourceModal mode="create" onClose={closeModal} />}
{modal.type === "edit" && <ResourceModal mode="edit" resource={modal.resource} onClose={closeModal} />}
{modal.type === "import" && <ImportModal onClose={closeModal} />}
{modal.type === "bulkEdit" && (
<BulkEditModal
selectedIds={selection.selectedArray}
fieldDefs={filterableFields}
onClose={closeModal}
onSuccess={selection.clear}
/>
)}
{confirm.type === "deactivate" && (
<ConfirmDialog title="Deactivate Resource" message={`Deactivate "${confirm.resource.displayName}" (${confirm.resource.eid})? This will remove them from the active resource list.`} confirmLabel="Deactivate" variant="danger" onConfirm={handleConfirm} onCancel={() => setConfirm({ type: "closed" })} />
)}
{confirm.type === "batchDeactivate" && (
<ConfirmDialog title="Deactivate Resources" message={`Deactivate ${confirm.ids.length} selected resource${confirm.ids.length !== 1 ? "s" : ""}?`} confirmLabel="Deactivate All" variant="danger" onConfirm={handleConfirm} onCancel={() => setConfirm({ type: "closed" })} />
)}
</div>
);
}
@@ -0,0 +1,25 @@
import type { Metadata } from "next";
import { createCaller } from "~/server/trpc.js";
import { ResourceDetail } from "~/components/resources/ResourceDetail.js";
export async function generateMetadata(
{ params }: { params: Promise<{ id: string }> },
): Promise<Metadata> {
const { id } = await params;
try {
const trpc = await createCaller();
const resource = await trpc.resource.getById({ id });
return { title: `${resource.displayName} — Resources | Planarchy` };
} catch {
return { title: "Resource — Planarchy" };
}
}
export default async function ResourceDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
return <ResourceDetail resourceId={id} />;
}
@@ -0,0 +1,52 @@
export default function ResourcesLoading() {
return (
<div className="flex flex-col h-full gap-4 animate-pulse">
{/* Page header */}
<div className="flex items-center justify-between">
<div className="h-7 w-32 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="h-9 w-28 bg-gray-200 dark:bg-gray-700 rounded-lg" />
</div>
{/* Filter bar */}
<div className="flex gap-2">
<div className="h-9 flex-1 bg-gray-100 dark:bg-gray-800 rounded-lg" />
<div className="h-9 w-36 bg-gray-100 dark:bg-gray-800 rounded-lg" />
<div className="h-9 w-36 bg-gray-100 dark:bg-gray-800 rounded-lg" />
<div className="h-9 w-28 bg-gray-100 dark:bg-gray-800 rounded-lg" />
</div>
{/* Table */}
<div className="flex-1 border border-gray-200 dark:border-gray-700 rounded-xl overflow-hidden">
{/* Header */}
<div className="flex gap-3 px-4 py-3 bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
<div className="h-3 w-4 bg-gray-300 dark:bg-gray-600 rounded" />
<div className="h-3 w-10 bg-gray-300 dark:bg-gray-600 rounded" />
<div className="h-3 w-28 bg-gray-300 dark:bg-gray-600 rounded" />
<div className="h-3 w-20 bg-gray-300 dark:bg-gray-600 rounded" />
<div className="h-3 w-16 bg-gray-300 dark:bg-gray-600 rounded" />
<div className="h-3 w-24 bg-gray-300 dark:bg-gray-600 rounded" />
<div className="h-3 w-14 bg-gray-300 dark:bg-gray-600 rounded" />
<div className="h-3 flex-1 bg-gray-300 dark:bg-gray-600 rounded" />
<div className="h-3 w-20 bg-gray-300 dark:bg-gray-600 rounded" />
</div>
{/* Rows */}
{[...Array(10)].map((_, i) => (
<div key={i} className="flex items-center gap-3 px-4 py-3 border-b border-gray-100 dark:border-gray-800">
<div className="h-4 w-4 bg-gray-100 dark:bg-gray-700 rounded" />
<div className="h-3 w-10 bg-gray-200 dark:bg-gray-700 rounded font-mono" />
<div className="h-3 w-32 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="h-3 w-20 bg-gray-100 dark:bg-gray-800 rounded" />
<div className="h-3 w-14 bg-gray-100 dark:bg-gray-800 rounded" />
<div className="h-5 w-20 bg-gray-100 dark:bg-gray-800 rounded-full" />
<div className="h-5 w-12 bg-gray-100 dark:bg-gray-800 rounded-full" />
<div className="flex gap-1 flex-1">
<div className="h-5 w-16 bg-gray-100 dark:bg-gray-800 rounded-full" />
<div className="h-5 w-16 bg-gray-100 dark:bg-gray-800 rounded-full" />
</div>
<div className="h-3 w-16 bg-gray-100 dark:bg-gray-800 rounded" />
</div>
))}
</div>
</div>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { Suspense } from "react";
import { ResourcesClient } from "./ResourcesClient.js";
export default function ResourcesPage() {
return (
<Suspense>
<ResourcesClient />
</Suspense>
);
}