@smartmemory/wiki-components 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -3
- package/package.json +1 -1
- package/src/AreaDocs.jsx +26 -0
- package/src/Sources.jsx +41 -0
- package/src/data-client.d.ts +25 -2
- package/src/data-client.jsx +2 -0
- package/src/index.d.ts +13 -0
- package/src/index.js +6 -1
- package/src/lib.js +16 -1
- package/src/parts/DocRow.jsx +17 -2
- package/src/parts/FreshnessBadge.jsx +15 -0
- package/src/parts/OverdueBadge.jsx +9 -0
- package/src/useNow.js +7 -0
package/README.md
CHANGED
|
@@ -26,7 +26,7 @@ npm install @smartmemory/wiki-components react react-dom date-fns lucide-react
|
|
|
26
26
|
|
|
27
27
|
## Contract
|
|
28
28
|
|
|
29
|
-
`src/data-client.d.ts` is the contract:
|
|
29
|
+
`src/data-client.d.ts` is the contract: seven calls (`identity`, `featureHub`, `decisions`, `decisionsPage`, `sourcesPage`, `docsByArea`, `search`), the endpoint and
|
|
30
30
|
request body each maps to on the wiki API, and the result types (from `wiki/contracts/query.schema.json`). The same list is
|
|
31
31
|
runtime data in `DATA_CLIENT_CALLS`; `ui/test/wiki-components.test.jsx` pins the typed file, the table and the app's client
|
|
32
32
|
to each other. `<WikiDataProvider>` refuses a client missing a method.
|
|
@@ -34,14 +34,16 @@ to each other. `<WikiDataProvider>` refuses a client missing a method.
|
|
|
34
34
|
## Use
|
|
35
35
|
|
|
36
36
|
```jsx
|
|
37
|
-
import { WikiDataProvider, FeatureHub, Search, Decisions, wikiPath } from "@smartmemory/wiki-components";
|
|
37
|
+
import { WikiDataProvider, FeatureHub, Search, Decisions, Sources, AreaDocs, wikiPath } from "@smartmemory/wiki-components";
|
|
38
38
|
|
|
39
39
|
<WikiDataProvider client={myClient}>
|
|
40
40
|
<FeatureHub featureId="CORE-1" workspace="wiki-eng" />
|
|
41
41
|
<Search query="recall" workspaces={["wiki-eng", "wiki-gtm"]}
|
|
42
|
-
onNavigate={(target) => /* {kind: "search", query?, workspace?} | {kind: "note", workspace, docId} */ go(target)}
|
|
42
|
+
onNavigate={(target) => /* {kind: "search", query?, workspace?} | {kind: "note", workspace, docId} | {kind: "area", area} */ go(target)}
|
|
43
43
|
hrefFor={(target) => `http://127.0.0.1:8791/ui${wikiPath(target)}`} />
|
|
44
44
|
<Decisions workspace="wiki-eng" />
|
|
45
|
+
<Sources workspace="wiki-eng" />
|
|
46
|
+
<AreaDocs area="search" workspace="wiki-eng" onNavigate={go} hrefFor={href} />
|
|
45
47
|
</WikiDataProvider>
|
|
46
48
|
```
|
|
47
49
|
|
|
@@ -50,6 +52,15 @@ import { WikiDataProvider, FeatureHub, Search, Decisions, wikiPath } from "@smar
|
|
|
50
52
|
which resolves `{items, total, next_cursor, generated_at}`. `cursor` is the previous page's `next_cursor`, opaque.
|
|
51
53
|
The paging pieces are exported for the next screens: `usePagedCall(key, run, {size})` (the cursor stack in component
|
|
52
54
|
state, no react-query), `Table`, `Pager`.
|
|
55
|
+
- `Sources` is the paged list of the workspace's ingestion sources, 50 a page, with status, last success, indexing age,
|
|
56
|
+
publication delay and an overdue badge (an unknown value reads "unknown", never 0 or "on time"). Its data is
|
|
57
|
+
`client.sourcesPage({workspace, cursor?, size?})` (`POST /query/sources`, `filters: {}`, `page: {size, cursor?}`), which
|
|
58
|
+
resolves `{items, total, next_cursor, generated_at}` with `SourceItem` rows. Also exported: `FreshnessBadge`,
|
|
59
|
+
`OverdueBadge`, `useNow`, `ago`, `fmtDuration`.
|
|
60
|
+
- `AreaDocs` is the paged list of the docs filed under one area (`area` prop, required), 50 a page. Its data is
|
|
61
|
+
`client.docsByArea({workspace, area, cursor?, size?})` (`POST /query/docs_by_area`, `filters: {area}`, `page: {size, cursor?}`),
|
|
62
|
+
which resolves `{items, total, next_cursor, generated_at}` with `DocItem` rows. With `onNavigate` or `hrefFor`, each doc
|
|
63
|
+
lists its areas as links, the new NavTarget `{kind: "area", area}` (`wikiPath` gives `/area/<area>`; `areaUrl` is exported).
|
|
53
64
|
- `workspace` / `workspaces` are optional: without them a screen calls `client.identity()` and uses the caller's grants
|
|
54
65
|
(FeatureHub the first one).
|
|
55
66
|
- `onNavigate(target)` receives a plain left click on a link and the search box's submit. `hrefFor(target)` is each link's
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@smartmemory/wiki-components",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "The wiki's feature hub and search screens as router-free React components fed by an injected DataClient (WIKI-UI-COMPONENTS-1). Ships JSX source for a bundler host (Vite).",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Regression Analytics LLC",
|
package/src/AreaDocs.jsx
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { useDataClient, useWorkspaces } from "./data-client";
|
|
3
|
+
import { usePagedCall } from "./paged";
|
|
4
|
+
import ListPage from "./parts/ListPage";
|
|
5
|
+
import PageHeader from "./parts/PageHeader";
|
|
6
|
+
import DocRow from "./parts/DocRow";
|
|
7
|
+
import Loading from "./parts/Loading";
|
|
8
|
+
import Pager from "./parts/Pager";
|
|
9
|
+
import ErrorBox from "./parts/ErrorBox";
|
|
10
|
+
|
|
11
|
+
/** The docs filed under one area: `area` of `workspace` (default: the first of identity().workspaces), 50 a page with a
|
|
12
|
+
* pager. Each doc lists its areas as links: `onNavigate({kind: "area", area})` follows one, `hrefFor(target)` is its href.
|
|
13
|
+
* Data: DataClient docsByArea. */
|
|
14
|
+
export default function AreaDocs({ area, workspace: given, onNavigate, hrefFor }) {
|
|
15
|
+
const client = useDataClient();
|
|
16
|
+
const ws = useWorkspaces(given ? [given] : undefined);
|
|
17
|
+
const workspace = given || ws.workspaces?.[0];
|
|
18
|
+
const p = usePagedCall(["docs_by_area", workspace, area], (page) => client.docsByArea({ workspace, area, ...page }), { enabled: !!workspace && !!area });
|
|
19
|
+
const error = p.error || ws.error;
|
|
20
|
+
return (
|
|
21
|
+
<ListPage header={<PageHeader eyebrow="Area" title={area}>{p.total != null && `${p.total} docs`}</PageHeader>} foot={<Pager p={p} />}>
|
|
22
|
+
<ErrorBox error={error} />
|
|
23
|
+
{p.isLoading || ws.isLoading ? <Loading /> : !error && p.items.length === 0 ? <p className="text-sm text-neutral-400">No docs in this area yet.</p> : p.items.map((d) => <DocRow key={d.doc_id} doc={d} onNavigate={onNavigate} hrefFor={hrefFor} />)}
|
|
24
|
+
</ListPage>
|
|
25
|
+
);
|
|
26
|
+
}
|
package/src/Sources.jsx
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { useDataClient, useWorkspaces } from "./data-client";
|
|
3
|
+
import { usePagedCall } from "./paged";
|
|
4
|
+
import { fmtDuration } from "./lib";
|
|
5
|
+
import ListPage from "./parts/ListPage";
|
|
6
|
+
import PageHeader from "./parts/PageHeader";
|
|
7
|
+
import Table from "./parts/Table";
|
|
8
|
+
import Pager from "./parts/Pager";
|
|
9
|
+
import StatusChip from "./parts/StatusChip";
|
|
10
|
+
import FreshnessBadge from "./parts/FreshnessBadge";
|
|
11
|
+
import OverdueBadge from "./parts/OverdueBadge";
|
|
12
|
+
|
|
13
|
+
/** The sources list: every ingestion source of `workspace` (default: the first of identity().workspaces), 50 a page with a
|
|
14
|
+
* pager, each with its status, last success, indexing age, publication delay and overdue flag. `onNavigate` / `hrefFor`
|
|
15
|
+
* are accepted for the host contract shared by the screens; the list has no links. Data: DataClient sourcesPage. */
|
|
16
|
+
export default function Sources({ workspace: given }) {
|
|
17
|
+
const client = useDataClient();
|
|
18
|
+
const ws = useWorkspaces(given ? [given] : undefined);
|
|
19
|
+
const workspace = given || ws.workspaces?.[0];
|
|
20
|
+
const p = usePagedCall(["sources", workspace], (page) => client.sourcesPage({ workspace, ...page }), { enabled: !!workspace });
|
|
21
|
+
return (
|
|
22
|
+
<ListPage header={<PageHeader title="Sources">Where the indexer reads from, and how fresh each one is.</PageHeader>} foot={<Pager p={p} />}>
|
|
23
|
+
<Table head={["Source", "Plugin", "Host", "Docs", "Coverage", "Status", "Last success", "Indexing age", "Publication delay", "Overdue"]} loading={p.isLoading || ws.isLoading} error={p.error || ws.error} count={p.items.length}>
|
|
24
|
+
{p.items.map((s) => (
|
|
25
|
+
<tr key={s.source_id} className="align-top hover:bg-neutral-50/60">
|
|
26
|
+
<td className="px-3 py-2.5 font-medium">{s.source_id}</td>
|
|
27
|
+
<td className="px-3 py-2.5 font-mono text-xs text-neutral-600">{s.plugin}</td>
|
|
28
|
+
<td className="px-3 py-2.5 font-mono text-xs text-neutral-600">{s.host}</td>
|
|
29
|
+
<td className="px-3 py-2.5 tabular-nums">{s.discovered.toLocaleString()}</td>
|
|
30
|
+
<td className="px-3 py-2.5 text-xs text-neutral-600">{s.coverage}</td>
|
|
31
|
+
<td className="px-3 py-2.5"><StatusChip status={s.status} /></td>
|
|
32
|
+
<td className="px-3 py-2.5"><FreshnessBadge at={s.last_success} /></td>
|
|
33
|
+
<td className="px-3 py-2.5 tabular-nums text-xs text-neutral-600">{fmtDuration(s.indexing_age_s)}</td>
|
|
34
|
+
<td className="px-3 py-2.5 tabular-nums text-xs text-neutral-600">{fmtDuration(s.publication_delay_s)}</td>
|
|
35
|
+
<td className="px-3 py-2.5"><OverdueBadge overdue={s.overdue} /></td>
|
|
36
|
+
</tr>
|
|
37
|
+
))}
|
|
38
|
+
</Table>
|
|
39
|
+
</ListPage>
|
|
40
|
+
);
|
|
41
|
+
}
|
package/src/data-client.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// WIKI-UI-COMPONENTS-1: the DataClient contract. Every call the FeatureHub, Search and
|
|
1
|
+
// WIKI-UI-COMPONENTS-1: the DataClient contract. Every call the FeatureHub, Search, Decisions, Sources and AreaDocs screens make, and nothing else.
|
|
2
2
|
// A host (the wiki's /ui/ app, Compose's wiki tab) passes one object with these async methods to <WikiDataProvider>.
|
|
3
3
|
// The components never fetch: each call below is the whole of their I/O.
|
|
4
4
|
//
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
// featureHub POST /query/feature_hub {query: "feature_hub", workspace, filters: {feature_id}, page: {size: 200}}
|
|
11
11
|
// decisions POST /query/decisions {query: "decisions", workspace, filters: {feature_id}, page: {size: 50}}
|
|
12
12
|
// decisionsPage POST /query/decisions {query: "decisions", workspace, filters: {}, page: {size: 50, cursor?}}
|
|
13
|
+
// sourcesPage POST /query/sources {query: "sources", workspace, filters: {}, page: {size: 50, cursor?}}
|
|
14
|
+
// docsByArea POST /query/docs_by_area {query: "docs_by_area", workspace, filters: {area}, page: {size: 50, cursor?}}
|
|
13
15
|
// search POST /query/search {query: "search", workspace, filters: {q}, page: {}}
|
|
14
16
|
//
|
|
15
17
|
// The same table is runtime data in data-client.jsx (DATA_CLIENT_CALLS); a test pins the two to each other.
|
|
@@ -89,6 +91,20 @@ export interface QueryResult<T> {
|
|
|
89
91
|
generated_at: string;
|
|
90
92
|
}
|
|
91
93
|
|
|
94
|
+
/** query.schema.json $defs/source_item. `overdue` and the two durations are null when unknown (never shown as 0 or "on time"). */
|
|
95
|
+
export interface SourceItem {
|
|
96
|
+
source_id: string;
|
|
97
|
+
plugin: string;
|
|
98
|
+
host: "us1" | "mac";
|
|
99
|
+
status: "ok" | "offline" | "failed" | "cursor_lost";
|
|
100
|
+
coverage: "full_text" | "excerpt" | "metadata";
|
|
101
|
+
last_success: string | null;
|
|
102
|
+
discovered: number;
|
|
103
|
+
indexing_age_s: number | null;
|
|
104
|
+
publication_delay_s: number | null;
|
|
105
|
+
overdue: boolean | null;
|
|
106
|
+
}
|
|
107
|
+
|
|
92
108
|
/** x-named-queries.feature_hub.result: the feature's docs plus the feature itself (null when it is not on the roadmap). */
|
|
93
109
|
export interface FeatureHubResult extends QueryResult<DocItem> {
|
|
94
110
|
feature: FeatureItem | null;
|
|
@@ -110,6 +126,12 @@ export interface DataClient {
|
|
|
110
126
|
/** POST /query/decisions, filters {}, page {size (default 50), cursor?}: one page of every decision of the workspace, newest first.
|
|
111
127
|
* `cursor` is the previous page's `next_cursor`, opaque. */
|
|
112
128
|
decisionsPage(args: { workspace: Workspace; cursor?: string; size?: number }): Promise<QueryResult<EntryItem>>;
|
|
129
|
+
/** POST /query/sources, filters {}, page {size (default 50), cursor?}: one page of the workspace's ingestion sources.
|
|
130
|
+
* `cursor` is the previous page's `next_cursor`, opaque. */
|
|
131
|
+
sourcesPage(args: { workspace: Workspace; cursor?: string; size?: number }): Promise<QueryResult<SourceItem>>;
|
|
132
|
+
/** POST /query/docs_by_area, filters {area} (required, 1 to 200 characters), page {size (default 50), cursor?}: one page of the live docs
|
|
133
|
+
* filed under `area`. `cursor` is the previous page's `next_cursor`, opaque. */
|
|
134
|
+
docsByArea(args: { workspace: Workspace; area: string; cursor?: string; size?: number }): Promise<QueryResult<DocItem>>;
|
|
113
135
|
/** POST /query/search, filters {q} (1 to 256 characters), page {}. One call per workspace searched. */
|
|
114
136
|
search(args: { workspace: Workspace; q: string }): Promise<SearchResult>;
|
|
115
137
|
}
|
|
@@ -118,4 +140,5 @@ export interface DataClient {
|
|
|
118
140
|
* (the /ui/ app: wikiPath()). */
|
|
119
141
|
export type NavTarget =
|
|
120
142
|
| { kind: "search"; query?: string; workspace?: Workspace }
|
|
121
|
-
| { kind: "note"; workspace: Workspace; docId: string }
|
|
143
|
+
| { kind: "note"; workspace: Workspace; docId: string }
|
|
144
|
+
| { kind: "area"; area: string };
|
package/src/data-client.jsx
CHANGED
|
@@ -7,6 +7,8 @@ export const DATA_CLIENT_CALLS = [
|
|
|
7
7
|
{ method: "featureHub", endpoint: "POST /query/feature_hub", query: "feature_hub", page: { size: 200 } },
|
|
8
8
|
{ method: "decisions", endpoint: "POST /query/decisions", query: "decisions", page: { size: 50 } },
|
|
9
9
|
{ method: "decisionsPage", endpoint: "POST /query/decisions", query: "decisions", page: { size: 50 } },
|
|
10
|
+
{ method: "sourcesPage", endpoint: "POST /query/sources", query: "sources", page: { size: 50 } },
|
|
11
|
+
{ method: "docsByArea", endpoint: "POST /query/docs_by_area", query: "docs_by_area", page: { size: 50 } },
|
|
10
12
|
{ method: "search", endpoint: "POST /query/search", query: "search", page: {} },
|
|
11
13
|
];
|
|
12
14
|
|
package/src/index.d.ts
CHANGED
|
@@ -25,6 +25,12 @@ export const DATA_CLIENT_CALLS: { method: keyof DataClient; endpoint: string; qu
|
|
|
25
25
|
/** Every recorded decision of `workspace` (default: the first of identity().workspaces), 50 a page with a pager. */
|
|
26
26
|
export function Decisions(props: { workspace?: Workspace | null } & NavProps): ReactElement;
|
|
27
27
|
|
|
28
|
+
/** Every ingestion source of `workspace` (default: the first of identity().workspaces), 50 a page with a pager. */
|
|
29
|
+
export function Sources(props: { workspace?: Workspace | null } & NavProps): ReactElement;
|
|
30
|
+
|
|
31
|
+
/** The docs filed under `area` of `workspace` (default: the first of identity().workspaces), 50 a page with a pager. Each doc's other areas link to their own AreaDocs through `onNavigate({kind: "area", area})`. */
|
|
32
|
+
export function AreaDocs(props: { area: string; workspace?: Workspace | null } & NavProps): ReactElement;
|
|
33
|
+
|
|
28
34
|
/** One DataClient call paged by cursor. `run({cursor?, size})` fetches a page; a new `key` restarts at page one. */
|
|
29
35
|
export function usePagedCall<T>(
|
|
30
36
|
key: unknown,
|
|
@@ -32,9 +38,16 @@ export function usePagedCall<T>(
|
|
|
32
38
|
opts?: { size?: number; enabled?: boolean },
|
|
33
39
|
): { items: T[]; result?: QueryResult<T>; total?: number; isLoading: boolean; error: Error | null; index: number; size: number; next: (() => void) | null; prev: (() => void) | null };
|
|
34
40
|
|
|
41
|
+
export function FreshnessBadge(props: { at?: string | null }): ReactElement;
|
|
42
|
+
export function OverdueBadge(props: { overdue?: boolean | null }): ReactElement;
|
|
43
|
+
export function ago(d?: string | null): string;
|
|
44
|
+
export function fmtDuration(s?: number | null): string;
|
|
45
|
+
export function useNow(ms?: number): number;
|
|
35
46
|
export function Table(props: { head: ReactNode[]; children?: ReactNode; loading?: boolean; error?: Error | null; empty?: string; count?: number }): ReactElement;
|
|
36
47
|
export function Pager(props: { p: { total?: number; index: number; size: number; items: unknown[]; next: (() => void) | null; prev: (() => void) | null } }): ReactElement | null;
|
|
37
48
|
|
|
38
49
|
/** A NavTarget as a path of the wiki's /ui/ app (relative to its basename, e.g. "/search?q=x"). */
|
|
39
50
|
export function wikiPath(target: NavTarget): string;
|
|
40
51
|
export function wsName(workspace: Workspace): string;
|
|
52
|
+
/** The path of an area's docs page in the wiki's /ui/ app. */
|
|
53
|
+
export function areaUrl(area: string): string;
|
package/src/index.js
CHANGED
|
@@ -3,9 +3,12 @@
|
|
|
3
3
|
export { default as FeatureHub, prefixOnly } from "./FeatureHub";
|
|
4
4
|
export { default as Search } from "./Search";
|
|
5
5
|
export { default as Decisions } from "./Decisions";
|
|
6
|
+
export { default as Sources } from "./Sources";
|
|
7
|
+
export { default as AreaDocs } from "./AreaDocs";
|
|
6
8
|
export { usePagedCall } from "./paged";
|
|
7
9
|
export { WikiDataProvider, useDataClient, useClientCall, DATA_CLIENT_CALLS } from "./data-client";
|
|
8
|
-
export { wikiPath, wsName, wsUrl, noteUrl, toDate, fmtDate, summaryOf } from "./lib";
|
|
10
|
+
export { wikiPath, wsName, wsUrl, noteUrl, areaUrl, toDate, fmtDate, ago, fmtDuration, summaryOf } from "./lib";
|
|
11
|
+
export { default as useNow } from "./useNow";
|
|
9
12
|
// The parts the screens are built from; the /ui/ app's other pages use them too (ui/src/components/wiki/* re-export them).
|
|
10
13
|
export { default as ErrorBox } from "./parts/ErrorBox";
|
|
11
14
|
export { default as Section } from "./parts/Section";
|
|
@@ -22,3 +25,5 @@ export { default as SearchBox } from "./parts/SearchBox";
|
|
|
22
25
|
export { default as NavLink } from "./parts/NavLink";
|
|
23
26
|
export { default as Table } from "./parts/Table";
|
|
24
27
|
export { default as Pager } from "./parts/Pager";
|
|
28
|
+
export { default as FreshnessBadge } from "./parts/FreshnessBadge";
|
|
29
|
+
export { default as OverdueBadge } from "./parts/OverdueBadge";
|
package/src/lib.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { format } from "date-fns";
|
|
1
|
+
import { format, formatDistanceToNowStrict } from "date-fns";
|
|
2
2
|
|
|
3
3
|
// Helpers the screens share with the rest of the /ui/ app (ui/src/lib/wiki.js re-exports them: one home).
|
|
4
4
|
|
|
@@ -10,16 +10,31 @@ export const wsName = (ws) => {
|
|
|
10
10
|
return WS_NAMES[tail] || tail.split(/[-_]+/).filter(Boolean).map((w) => w[0].toUpperCase() + w.slice(1)).join(" ") || "Workspace";
|
|
11
11
|
};
|
|
12
12
|
export const wsUrl = (ws) => `/w/${encodeURIComponent(ws)}`;
|
|
13
|
+
export const areaUrl = (a) => `/area/${encodeURIComponent(a)}`;
|
|
13
14
|
export const noteUrl = (ws, id) => `${wsUrl(ws)}/note?id=${encodeURIComponent(id)}`;
|
|
14
15
|
|
|
15
16
|
export const toDate = (d) => (d ? new Date(d.length <= 10 || /([zZ]|[+-]\d\d:?\d\d)$/.test(d) ? d : d + "Z") : null);
|
|
16
17
|
export const fmtDate = (d) => (d ? format(toDate(d), "MMM d, yyyy") : "—");
|
|
18
|
+
export const ago = (d) => (d ? formatDistanceToNowStrict(toDate(d), { addSuffix: true }) : "never");
|
|
19
|
+
|
|
20
|
+
/** Whole seconds as "45s", "5m", "1h 30m", "2d 1h". A missing value is "unknown", never 0 or blank. */
|
|
21
|
+
export const fmtDuration = (s) => {
|
|
22
|
+
if (s === null || s === undefined || !Number.isFinite(s) || s < 0) return "unknown";
|
|
23
|
+
if (s < 60) return `${Math.floor(s)}s`;
|
|
24
|
+
const m = Math.floor(s / 60);
|
|
25
|
+
if (m < 60) return `${m}m`;
|
|
26
|
+
const h = Math.floor(m / 60);
|
|
27
|
+
if (h < 24) return m % 60 ? `${h}h ${m % 60}m` : `${h}h`;
|
|
28
|
+
const d = Math.floor(h / 24);
|
|
29
|
+
return h % 24 ? `${d}d ${h % 24}h` : `${d}d`;
|
|
30
|
+
};
|
|
17
31
|
/** A feature's index summary: the trimmed string, or null when absent or blank (nothing is drawn for null). */
|
|
18
32
|
export const summaryOf = (feature) => (typeof feature?.summary === "string" && feature.summary.trim() ? feature.summary.trim() : null);
|
|
19
33
|
|
|
20
34
|
/** A NavTarget (data-client.d.ts) as a route of the wiki's own /ui/ app, relative to its basename. */
|
|
21
35
|
export function wikiPath(target) {
|
|
22
36
|
if (target.kind === "note") return noteUrl(target.workspace, target.docId);
|
|
37
|
+
if (target.kind === "area") return areaUrl(target.area);
|
|
23
38
|
if (target.kind === "search") {
|
|
24
39
|
const p = new URLSearchParams({ ...(target.query ? { q: target.query } : {}), ...(target.workspace ? { workspace: target.workspace } : {}) });
|
|
25
40
|
return `/search?${p}`;
|
package/src/parts/DocRow.jsx
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import TypeBadge from "./TypeBadge";
|
|
3
|
+
import NavLink from "./NavLink";
|
|
3
4
|
import { fmtDate } from "../lib";
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
|
|
6
|
+
/** One doc. With `onNavigate` or `hrefFor` (a screen that links, e.g. AreaDocs) the doc's areas are listed under the row,
|
|
7
|
+
* each a NavTarget {kind: "area"}; without them the row is exactly as before. */
|
|
8
|
+
export default function DocRow({ doc, right, onNavigate, hrefFor }) {
|
|
9
|
+
const links = (onNavigate || hrefFor) && doc.areas?.length > 0;
|
|
10
|
+
const row = (
|
|
7
11
|
<div className="grid items-center gap-3 rounded-md px-3 py-2 hover:bg-neutral-50 grid-cols-[6.5rem_1fr_auto] sm:grid-cols-[6.5rem_1fr_16rem_6.5rem]">
|
|
8
12
|
<TypeBadge type={doc.kind} />
|
|
9
13
|
<span className="truncate font-medium text-neutral-900">{doc.title}</span>
|
|
@@ -12,4 +16,15 @@ export default function DocRow({ doc, right }) {
|
|
|
12
16
|
{right}
|
|
13
17
|
</div>
|
|
14
18
|
);
|
|
19
|
+
if (!links) return row;
|
|
20
|
+
return (
|
|
21
|
+
<div>
|
|
22
|
+
{row}
|
|
23
|
+
<div className="flex flex-wrap gap-1.5 px-3 pb-2 pl-[7.75rem]" data-testid="area-links">
|
|
24
|
+
{doc.areas.map((a) => (
|
|
25
|
+
<NavLink key={a} target={{ kind: "area", area: a }} hrefFor={hrefFor} onNavigate={onNavigate} className="rounded-full border border-neutral-200 px-2 py-0.5 text-[11px] text-neutral-600 hover:border-blue-500 hover:text-neutral-900">{a}</NavLink>
|
|
26
|
+
))}
|
|
27
|
+
</div>
|
|
28
|
+
</div>
|
|
29
|
+
);
|
|
15
30
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import useNow from "../useNow";
|
|
3
|
+
import { ago, toDate } from "../lib";
|
|
4
|
+
|
|
5
|
+
export default function FreshnessBadge({ at }) {
|
|
6
|
+
const now = useNow(30000);
|
|
7
|
+
const mins = at ? (now - toDate(at).getTime()) / 60000 : Infinity;
|
|
8
|
+
const tone = mins < 10 ? "bg-emerald-50 text-emerald-700 border-emerald-200" : mins < 60 ? "bg-amber-50 text-amber-700 border-amber-200" : "bg-red-50 text-red-700 border-red-200";
|
|
9
|
+
const dot = mins < 10 ? "bg-emerald-500" : mins < 60 ? "bg-amber-500" : "bg-red-500";
|
|
10
|
+
return (
|
|
11
|
+
<span className={`inline-flex items-center gap-1.5 whitespace-nowrap rounded-full border px-2 py-0.5 text-[11px] font-medium ${tone}`}>
|
|
12
|
+
<span className={`h-1.5 w-1.5 rounded-full ${dot}`} />{ago(at)}
|
|
13
|
+
</span>
|
|
14
|
+
);
|
|
15
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
|
|
3
|
+
/** overdue: true = past its freshness line, false = within it, null/undefined = unknown (never shown as "on time"). */
|
|
4
|
+
export default function OverdueBadge({ overdue }) {
|
|
5
|
+
const [tone, label] = overdue === true ? ["bg-red-50 text-red-700 border-red-200", "overdue"]
|
|
6
|
+
: overdue === false ? ["bg-emerald-50 text-emerald-700 border-emerald-200", "on time"]
|
|
7
|
+
: ["bg-neutral-50 text-neutral-500 border-neutral-200", "unknown"];
|
|
8
|
+
return <span className={`inline-flex whitespace-nowrap rounded-full border px-2 py-0.5 text-[11px] font-medium ${tone}`}>{label}</span>;
|
|
9
|
+
}
|
package/src/useNow.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
|
|
3
|
+
export default function useNow(ms = 30000) {
|
|
4
|
+
const [now, setNow] = useState(Date.now());
|
|
5
|
+
useEffect(() => { const t = setInterval(() => setNow(Date.now()), ms); return () => clearInterval(t); }, [ms]);
|
|
6
|
+
return now;
|
|
7
|
+
}
|