feat(web): FE overhaul — SWR centralized data layer + dashboard + islands
CI / typecheck + tests (push) Canceled after 0s
CI / build + deploy (Nix) — api (push) Canceled after 0s
CI / build + deploy (Nix) — mcp (push) Canceled after 0s
CI / build + deploy (Nix) — worker (push) Canceled after 0s
CI / build + deploy (web) (push) Canceled after 0s
CI / typecheck + tests (push) Canceled after 0s
CI / build + deploy (Nix) — api (push) Canceled after 0s
CI / build + deploy (Nix) — mcp (push) Canceled after 0s
CI / build + deploy (Nix) — worker (push) Canceled after 0s
CI / build + deploy (web) (push) Canceled after 0s
- Add SWR foundation (lib/fetcher, lib/swr, lib/api) with typed hooks: useSections/useDocuments/useSearch/useRelated/useRevisions/useQueueStatus - Replace ad-hoc useEffect+fetch in Header/Sidebar/CommandMenu/search with SWR (dedup 4s, focus throttle 10s, debounced search 180ms) - Doc page: RelatedGrid + RevisionList as SWR client islands (RSC hero stays) - DocForm: SWR mutate on create/update for live sidebar invalidation - New APIs: /api/related, /api/revisions, /api/tags, /api/queue/status, /api/docs filtering (?section=&status=) - New UI: DocumentCard, SectionCard, SearchBar, TagCloud, QueueBadge, EmptyState, dashboard (/dashboard with QueueStrip live + section stats) - Layout wraps SWRProvider; no TanStack code (SWR-only per spec) - Verified: next build passes (16.3.4 Turbopack), no manual fetch sprawl remains Co-Authored-By: internal-model
This commit is contained in:
1 parent
1025454d87
commit
4330f47c65
36 files changed
+1393
-415
No files matched your search
@@ -0,0 +1,77 @@
|
||||
import type { Document, DocumentMeta, SectionInfo } from "@mcpedia/types";
|
||||
import { fetcher } from "./fetcher";
|
||||
|
||||
export interface SearchApiHit {
|
||||
slug: string;
|
||||
title: string;
|
||||
section: string;
|
||||
score: number;
|
||||
snippet: string;
|
||||
}
|
||||
|
||||
export interface SearchApiResponse {
|
||||
results: SearchApiHit[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface DocsApiResponse {
|
||||
docs?: DocumentMeta[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface SectionsApiResponse extends Array<SectionInfo> {}
|
||||
|
||||
export interface SingleDocResponse {
|
||||
doc: Document;
|
||||
}
|
||||
|
||||
export interface RelatedResponse {
|
||||
results: DocumentMeta[];
|
||||
}
|
||||
|
||||
export interface RevisionsResponse {
|
||||
revisions: { id: string; slug: string; revisionNo: number; title: string; reason: string; createdAt: string; bodyLength: number }[];
|
||||
}
|
||||
|
||||
export interface QueueStatusResponse {
|
||||
waiting: number;
|
||||
active: number;
|
||||
completed: number;
|
||||
failed: number;
|
||||
delayed: number;
|
||||
}
|
||||
|
||||
export interface TagsResponse {
|
||||
tags: { tag: string; count: number }[];
|
||||
}
|
||||
|
||||
export function docsUrl(section?: string, status = "published"): string {
|
||||
const p = new URLSearchParams();
|
||||
if (section) p.set("section", section);
|
||||
if (status) p.set("status", status);
|
||||
const qs = p.toString();
|
||||
return qs ? `/api/docs?${qs}` : "/api/docs";
|
||||
}
|
||||
|
||||
export function searchUrl(q: string, mode = "hybrid", limit = 30): string {
|
||||
return `/api/search?q=${encodeURIComponent(q)}&mode=${mode}&limit=${limit}`;
|
||||
}
|
||||
|
||||
export type FetchDocs = () => Promise<DocumentMeta[]>;
|
||||
export const fetchDocs: FetchDocs = async () => {
|
||||
const data = await fetcher<DocsApiResponse | DocumentMeta[]>("/api/docs");
|
||||
if (Array.isArray(data)) return data;
|
||||
return data.docs ?? [];
|
||||
};
|
||||
|
||||
export const fetchSections = () => fetcher<SectionInfo[]>("/api/sections");
|
||||
export const fetchSearch = (q: string, mode = "hybrid", limit = 30) =>
|
||||
fetcher<SearchApiResponse>(searchUrl(q, mode, limit));
|
||||
export const fetchRelated = (slug: string, limit = 5) =>
|
||||
fetcher<RelatedResponse>(`/api/related?slug=${encodeURIComponent(slug)}&limit=${limit}`);
|
||||
export const fetchRevisions = (slug: string, limit = 20) =>
|
||||
fetcher<RevisionsResponse>(`/api/revisions?slug=${encodeURIComponent(slug)}&limit=${limit}`);
|
||||
export const fetchSingleDoc = (slug: string) =>
|
||||
fetcher<SingleDocResponse>(`/api/docs/${encodeURIComponent(slug)}`);
|
||||
export const fetchQueueStatus = () => fetcher<QueueStatusResponse>("/api/queue/status");
|
||||
export const fetchTags = () => fetcher<TagsResponse>("/api/tags");
|
||||
@@ -0,0 +1,32 @@
|
||||
export class FetchError extends Error {
|
||||
status: number;
|
||||
info: unknown;
|
||||
constructor(message: string, status: number, info: unknown) {
|
||||
super(message);
|
||||
this.name = "FetchError";
|
||||
this.status = status;
|
||||
this.info = info;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetcher<T>(url: string): Promise<T> {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
let info: unknown = null;
|
||||
try {
|
||||
info = await res.json();
|
||||
} catch {
|
||||
try {
|
||||
info = await res.text();
|
||||
} catch {
|
||||
info = null;
|
||||
}
|
||||
}
|
||||
const msg =
|
||||
(info as { error?: string })?.error ||
|
||||
(typeof info === "string" ? info : null) ||
|
||||
`Request failed: ${res.status} ${res.statusText}`;
|
||||
throw new FetchError(msg, res.status, info);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { SWRConfig } from "swr";
|
||||
|
||||
export default function SWRProvider({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<SWRConfig
|
||||
value={{
|
||||
dedupingInterval: 4_000,
|
||||
focusThrottleInterval: 10_000,
|
||||
revalidateOnFocus: true,
|
||||
shouldRetryOnError: false,
|
||||
errorRetryCount: 0,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</SWRConfig>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user