feat(web): add CategoryPicker searchable selector component
Replace the flat <select> dropdown for category selection in CreateItemPane with a searchable, scrollable CategoryPicker component. New files: - web/src/hooks/useCategories.ts: fetches and caches category enum values from GET /api/schemas/kindred-rd, extracts the category segment values map - web/src/components/items/CategoryPicker.tsx: scrollable list with search input filtering by code and description, selected item highlighted with mauve, breadcrumb showing current selection Modified: - web/src/components/items/CreateItemPane.tsx: replaced <select> with <CategoryPicker>, uses useCategories hook instead of fetching full schema inline Closes #13
This commit is contained in:
153
web/src/components/items/CategoryPicker.tsx
Normal file
153
web/src/components/items/CategoryPicker.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import { useState, useMemo, useRef, useEffect } from "react";
|
||||
|
||||
interface CategoryPickerProps {
|
||||
value: string;
|
||||
onChange: (code: string) => void;
|
||||
categories: Record<string, string>;
|
||||
}
|
||||
|
||||
export function CategoryPicker({
|
||||
value,
|
||||
onChange,
|
||||
categories,
|
||||
}: CategoryPickerProps) {
|
||||
const [search, setSearch] = useState("");
|
||||
const selectedRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const entries = useMemo(() => {
|
||||
const all = Object.entries(categories).sort(([a], [b]) =>
|
||||
a.localeCompare(b),
|
||||
);
|
||||
if (!search) return all;
|
||||
const q = search.toLowerCase();
|
||||
return all.filter(
|
||||
([code, desc]) =>
|
||||
code.toLowerCase().includes(q) || desc.toLowerCase().includes(q),
|
||||
);
|
||||
}, [categories, search]);
|
||||
|
||||
// Scroll selected into view on mount.
|
||||
useEffect(() => {
|
||||
selectedRef.current?.scrollIntoView({ block: "nearest" });
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
border: "1px solid var(--ctp-surface1)",
|
||||
borderRadius: "0.4rem",
|
||||
backgroundColor: "var(--ctp-base)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{/* Search */}
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search categories..."
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "0.4rem 0.5rem",
|
||||
fontSize: "0.8rem",
|
||||
border: "none",
|
||||
borderBottom: "1px solid var(--ctp-surface1)",
|
||||
backgroundColor: "var(--ctp-mantle)",
|
||||
color: "var(--ctp-text)",
|
||||
outline: "none",
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Scrollable list */}
|
||||
<div style={{ maxHeight: 200, overflowY: "auto" }}>
|
||||
{entries.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
padding: "0.75rem",
|
||||
textAlign: "center",
|
||||
color: "var(--ctp-subtext0)",
|
||||
fontSize: "0.8rem",
|
||||
}}
|
||||
>
|
||||
No categories found
|
||||
</div>
|
||||
) : (
|
||||
entries.map(([code, desc]) => {
|
||||
const isSelected = code === value;
|
||||
return (
|
||||
<div
|
||||
key={code}
|
||||
ref={isSelected ? selectedRef : undefined}
|
||||
onClick={() => onChange(code)}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
padding: "0.3rem 0.5rem",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.8rem",
|
||||
backgroundColor: isSelected
|
||||
? "rgba(203,166,247,0.12)"
|
||||
: "transparent",
|
||||
color: isSelected
|
||||
? "var(--ctp-mauve)"
|
||||
: "var(--ctp-text)",
|
||||
fontWeight: isSelected ? 600 : 400,
|
||||
transition: "background-color 0.1s",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isSelected)
|
||||
e.currentTarget.style.backgroundColor =
|
||||
"var(--ctp-surface0)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isSelected)
|
||||
e.currentTarget.style.backgroundColor = "transparent";
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
flexShrink: 0,
|
||||
width: 48,
|
||||
}}
|
||||
>
|
||||
{code}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{desc}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Selected breadcrumb */}
|
||||
{value && categories[value] && (
|
||||
<div
|
||||
style={{
|
||||
padding: "0.3rem 0.5rem",
|
||||
fontSize: "0.75rem",
|
||||
color: "var(--ctp-subtext0)",
|
||||
borderTop: "1px solid var(--ctp-surface0)",
|
||||
backgroundColor: "var(--ctp-mantle)",
|
||||
}}
|
||||
>
|
||||
Selected:{" "}
|
||||
<span style={{ color: "var(--ctp-mauve)", fontWeight: 600 }}>
|
||||
{value}
|
||||
</span>{" "}
|
||||
— {categories[value]}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { get, post } from '../../api/client';
|
||||
import type { Schema, Project } from '../../api/types';
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { get, post } from "../../api/client";
|
||||
import type { Project } from "../../api/types";
|
||||
import { TagInput, type TagOption } from "../TagInput";
|
||||
import { CategoryPicker } from "./CategoryPicker";
|
||||
import { useCategories } from "../../hooks/useCategories";
|
||||
|
||||
interface CreateItemPaneProps {
|
||||
onCreated: (partNumber: string) => void;
|
||||
@@ -8,41 +11,66 @@ interface CreateItemPaneProps {
|
||||
}
|
||||
|
||||
export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
|
||||
const [schema, setSchema] = useState<Schema | null>(null);
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [category, setCategory] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [sourcingType, setSourcingType] = useState('manufactured');
|
||||
const [sourcingLink, setSourcingLink] = useState('');
|
||||
const [longDescription, setLongDescription] = useState('');
|
||||
const [standardCost, setStandardCost] = useState('');
|
||||
const { categories } = useCategories();
|
||||
const [category, setCategory] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [sourcingType, setSourcingType] = useState("manufactured");
|
||||
const [sourcingLink, setSourcingLink] = useState("");
|
||||
const [longDescription, setLongDescription] = useState("");
|
||||
const [standardCost, setStandardCost] = useState("");
|
||||
const [selectedProjects, setSelectedProjects] = useState<string[]>([]);
|
||||
const [catProps, setCatProps] = useState<Record<string, string>>({});
|
||||
const [catPropDefs, setCatPropDefs] = useState<Record<string, { type: string }>>({});
|
||||
const [catPropDefs, setCatPropDefs] = useState<
|
||||
Record<string, { type: string }>
|
||||
>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
get<Schema>('/api/schemas/kindred-rd').then(setSchema).catch(() => {});
|
||||
get<Project[]>('/api/projects').then(setProjects).catch(() => {});
|
||||
}, []);
|
||||
const searchProjects = useCallback(
|
||||
async (query: string): Promise<TagOption[]> => {
|
||||
const all = await get<Project[]>("/api/projects");
|
||||
const q = query.toLowerCase();
|
||||
return all
|
||||
.filter(
|
||||
(p) =>
|
||||
!q ||
|
||||
p.code.toLowerCase().includes(q) ||
|
||||
(p.name ?? "").toLowerCase().includes(q),
|
||||
)
|
||||
.map((p) => ({
|
||||
id: p.code,
|
||||
label: p.code + (p.name ? " \u2014 " + p.name : ""),
|
||||
}));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!category) { setCatPropDefs({}); setCatProps({}); return; }
|
||||
get<Record<string, { type: string }>>(`/api/schemas/kindred-rd/properties?category=${encodeURIComponent(category)}`)
|
||||
if (!category) {
|
||||
setCatPropDefs({});
|
||||
setCatProps({});
|
||||
return;
|
||||
}
|
||||
get<Record<string, { type: string }>>(
|
||||
`/api/schemas/kindred-rd/properties?category=${encodeURIComponent(category)}`,
|
||||
)
|
||||
.then((defs) => {
|
||||
setCatPropDefs(defs);
|
||||
const defaults: Record<string, string> = {};
|
||||
for (const key of Object.keys(defs)) defaults[key] = '';
|
||||
for (const key of Object.keys(defs)) defaults[key] = "";
|
||||
setCatProps(defaults);
|
||||
})
|
||||
.catch(() => { setCatPropDefs({}); setCatProps({}); });
|
||||
.catch(() => {
|
||||
setCatPropDefs({});
|
||||
setCatProps({});
|
||||
});
|
||||
}, [category]);
|
||||
|
||||
const categories = schema?.segments.find((s) => s.name === 'category')?.values ?? {};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!category) { setError('Category is required'); return; }
|
||||
if (!category) {
|
||||
setError("Category is required");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
|
||||
@@ -50,14 +78,14 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
|
||||
for (const [k, v] of Object.entries(catProps)) {
|
||||
if (!v) continue;
|
||||
const def = catPropDefs[k];
|
||||
if (def?.type === 'number') properties[k] = Number(v);
|
||||
else if (def?.type === 'boolean') properties[k] = v === 'true';
|
||||
if (def?.type === "number") properties[k] = Number(v);
|
||||
else if (def?.type === "boolean") properties[k] = v === "true";
|
||||
else properties[k] = v;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await post<{ part_number: string }>('/api/items', {
|
||||
schema: 'kindred-rd',
|
||||
const result = await post<{ part_number: string }>("/api/items", {
|
||||
schema: "kindred-rd",
|
||||
category,
|
||||
description,
|
||||
projects: selectedProjects.length > 0 ? selectedProjects : undefined,
|
||||
@@ -69,63 +97,97 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
|
||||
});
|
||||
onCreated(result.part_number);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to create item');
|
||||
setError(e instanceof Error ? e.message : "Failed to create item");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleProject = (code: string) => {
|
||||
setSelectedProjects((prev) =>
|
||||
prev.includes(code) ? prev.filter((p) => p !== code) : [...prev, code]
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: '0.75rem',
|
||||
padding: '0.5rem 0.75rem',
|
||||
borderBottom: '1px solid var(--ctp-surface1)',
|
||||
backgroundColor: 'var(--ctp-mantle)',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<span style={{ color: 'var(--ctp-green)', fontWeight: 600, fontSize: '0.9rem' }}>New Item</span>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.75rem",
|
||||
padding: "0.5rem 0.75rem",
|
||||
borderBottom: "1px solid var(--ctp-surface1)",
|
||||
backgroundColor: "var(--ctp-mantle)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
color: "var(--ctp-green)",
|
||||
fontWeight: 600,
|
||||
fontSize: "0.9rem",
|
||||
}}
|
||||
>
|
||||
New Item
|
||||
</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<button onClick={() => void handleSubmit()} disabled={saving} style={{
|
||||
padding: '0.3rem 0.75rem', fontSize: '0.8rem', border: 'none', borderRadius: '0.3rem',
|
||||
backgroundColor: 'var(--ctp-green)', color: 'var(--ctp-crust)', cursor: 'pointer',
|
||||
opacity: saving ? 0.6 : 1,
|
||||
}}>
|
||||
{saving ? 'Creating...' : 'Create'}
|
||||
<button
|
||||
onClick={() => void handleSubmit()}
|
||||
disabled={saving}
|
||||
style={{
|
||||
padding: "0.3rem 0.75rem",
|
||||
fontSize: "0.8rem",
|
||||
border: "none",
|
||||
borderRadius: "0.3rem",
|
||||
backgroundColor: "var(--ctp-green)",
|
||||
color: "var(--ctp-crust)",
|
||||
cursor: "pointer",
|
||||
opacity: saving ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
{saving ? "Creating..." : "Create"}
|
||||
</button>
|
||||
<button onClick={onCancel} style={headerBtnStyle}>
|
||||
Cancel
|
||||
</button>
|
||||
<button onClick={onCancel} style={headerBtnStyle}>Cancel</button>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: '0.75rem' }}>
|
||||
<div style={{ flex: 1, overflow: "auto", padding: "0.75rem" }}>
|
||||
{error && (
|
||||
<div style={{ color: 'var(--ctp-red)', backgroundColor: 'rgba(243,139,168,0.1)', padding: '0.5rem', borderRadius: '0.3rem', marginBottom: '0.5rem', fontSize: '0.85rem' }}>
|
||||
<div
|
||||
style={{
|
||||
color: "var(--ctp-red)",
|
||||
backgroundColor: "rgba(243,139,168,0.1)",
|
||||
padding: "0.5rem",
|
||||
borderRadius: "0.3rem",
|
||||
marginBottom: "0.5rem",
|
||||
fontSize: "0.85rem",
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FormGroup label="Category *">
|
||||
<select value={category} onChange={(e) => setCategory(e.target.value)} style={inputStyle}>
|
||||
<option value="">Select category...</option>
|
||||
{Object.entries(categories).map(([code, name]) => (
|
||||
<option key={code} value={code}>{code} — {name}</option>
|
||||
))}
|
||||
</select>
|
||||
<CategoryPicker
|
||||
value={category}
|
||||
onChange={setCategory}
|
||||
categories={categories}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup label="Description">
|
||||
<input value={description} onChange={(e) => setDescription(e.target.value)} style={inputStyle} placeholder="Item description" />
|
||||
<input
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
style={inputStyle}
|
||||
placeholder="Item description"
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup label="Sourcing Type">
|
||||
<select value={sourcingType} onChange={(e) => setSourcingType(e.target.value)} style={inputStyle}>
|
||||
<select
|
||||
value={sourcingType}
|
||||
onChange={(e) => setSourcingType(e.target.value)}
|
||||
style={inputStyle}
|
||||
>
|
||||
<option value="manufactured">Manufactured</option>
|
||||
<option value="purchased">Purchased</option>
|
||||
<option value="outsourced">Outsourced</option>
|
||||
@@ -133,55 +195,85 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup label="Sourcing Link">
|
||||
<input value={sourcingLink} onChange={(e) => setSourcingLink(e.target.value)} style={inputStyle} placeholder="URL" />
|
||||
<input
|
||||
value={sourcingLink}
|
||||
onChange={(e) => setSourcingLink(e.target.value)}
|
||||
style={inputStyle}
|
||||
placeholder="URL"
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup label="Standard Cost">
|
||||
<input type="number" step="0.01" value={standardCost} onChange={(e) => setStandardCost(e.target.value)} style={inputStyle} placeholder="0.00" />
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={standardCost}
|
||||
onChange={(e) => setStandardCost(e.target.value)}
|
||||
style={inputStyle}
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup label="Long Description">
|
||||
<textarea value={longDescription} onChange={(e) => setLongDescription(e.target.value)} style={{ ...inputStyle, minHeight: 60, resize: 'vertical' }} placeholder="Detailed description..." />
|
||||
<textarea
|
||||
value={longDescription}
|
||||
onChange={(e) => setLongDescription(e.target.value)}
|
||||
style={{ ...inputStyle, minHeight: 60, resize: "vertical" }}
|
||||
placeholder="Detailed description..."
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
{/* Project tags */}
|
||||
<FormGroup label="Projects">
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
|
||||
{projects.map((p) => (
|
||||
<button
|
||||
key={p.code}
|
||||
onClick={() => toggleProject(p.code)}
|
||||
style={{
|
||||
padding: '0.15rem 0.5rem', fontSize: '0.75rem', border: 'none', borderRadius: '1rem', cursor: 'pointer',
|
||||
backgroundColor: selectedProjects.includes(p.code) ? 'rgba(203,166,247,0.3)' : 'var(--ctp-surface0)',
|
||||
color: selectedProjects.includes(p.code) ? 'var(--ctp-mauve)' : 'var(--ctp-subtext0)',
|
||||
}}
|
||||
>
|
||||
{p.code}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<TagInput
|
||||
value={selectedProjects}
|
||||
onChange={setSelectedProjects}
|
||||
placeholder="Search projects\u2026"
|
||||
searchFn={searchProjects}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
{/* Category properties */}
|
||||
{Object.keys(catPropDefs).length > 0 && (
|
||||
<>
|
||||
<div style={{ borderTop: '1px solid var(--ctp-surface1)', margin: '0.75rem 0 0.5rem', paddingTop: '0.5rem' }}>
|
||||
<span style={{ fontSize: '0.8rem', color: 'var(--ctp-subtext0)', fontWeight: 600 }}>Category Properties</span>
|
||||
<div
|
||||
style={{
|
||||
borderTop: "1px solid var(--ctp-surface1)",
|
||||
margin: "0.75rem 0 0.5rem",
|
||||
paddingTop: "0.5rem",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.8rem",
|
||||
color: "var(--ctp-subtext0)",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Category Properties
|
||||
</span>
|
||||
</div>
|
||||
{Object.entries(catPropDefs).map(([key, def]) => (
|
||||
<FormGroup key={key} label={key}>
|
||||
{def.type === 'boolean' ? (
|
||||
<select value={catProps[key] ?? ''} onChange={(e) => setCatProps({ ...catProps, [key]: e.target.value })} style={inputStyle}>
|
||||
{def.type === "boolean" ? (
|
||||
<select
|
||||
value={catProps[key] ?? ""}
|
||||
onChange={(e) =>
|
||||
setCatProps({ ...catProps, [key]: e.target.value })
|
||||
}
|
||||
style={inputStyle}
|
||||
>
|
||||
<option value="">—</option>
|
||||
<option value="true">true</option>
|
||||
<option value="false">false</option>
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
type={def.type === 'number' ? 'number' : 'text'}
|
||||
value={catProps[key] ?? ''}
|
||||
onChange={(e) => setCatProps({ ...catProps, [key]: e.target.value })}
|
||||
type={def.type === "number" ? "number" : "text"}
|
||||
value={catProps[key] ?? ""}
|
||||
onChange={(e) =>
|
||||
setCatProps({ ...catProps, [key]: e.target.value })
|
||||
}
|
||||
style={inputStyle}
|
||||
/>
|
||||
)}
|
||||
@@ -194,22 +286,45 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function FormGroup({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
function FormGroup({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div style={{ marginBottom: '0.6rem' }}>
|
||||
<label style={{ display: 'block', fontSize: '0.75rem', color: 'var(--ctp-subtext0)', marginBottom: '0.2rem' }}>{label}</label>
|
||||
<div style={{ marginBottom: "0.6rem" }}>
|
||||
<label
|
||||
style={{
|
||||
display: "block",
|
||||
fontSize: "0.75rem",
|
||||
color: "var(--ctp-subtext0)",
|
||||
marginBottom: "0.2rem",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: '100%', padding: '0.35rem 0.5rem', fontSize: '0.85rem',
|
||||
backgroundColor: 'var(--ctp-base)', border: '1px solid var(--ctp-surface1)',
|
||||
borderRadius: '0.3rem', color: 'var(--ctp-text)',
|
||||
width: "100%",
|
||||
padding: "0.35rem 0.5rem",
|
||||
fontSize: "0.85rem",
|
||||
backgroundColor: "var(--ctp-base)",
|
||||
border: "1px solid var(--ctp-surface1)",
|
||||
borderRadius: "0.3rem",
|
||||
color: "var(--ctp-text)",
|
||||
};
|
||||
|
||||
const headerBtnStyle: React.CSSProperties = {
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
color: 'var(--ctp-subtext1)', fontSize: '0.8rem', padding: '0.2rem 0.4rem',
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
color: "var(--ctp-subtext1)",
|
||||
fontSize: "0.8rem",
|
||||
padding: "0.2rem 0.4rem",
|
||||
};
|
||||
|
||||
28
web/src/hooks/useCategories.ts
Normal file
28
web/src/hooks/useCategories.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { get } from "../api/client";
|
||||
import type { Schema } from "../api/types";
|
||||
|
||||
// Module-level cache to avoid refetching across mounts.
|
||||
let cached: Record<string, string> | null = null;
|
||||
|
||||
export function useCategories() {
|
||||
const [categories, setCategories] = useState<Record<string, string>>(
|
||||
cached ?? {},
|
||||
);
|
||||
const [loading, setLoading] = useState(cached === null);
|
||||
|
||||
useEffect(() => {
|
||||
if (cached) return;
|
||||
get<Schema>("/api/schemas/kindred-rd")
|
||||
.then((schema) => {
|
||||
const seg = schema.segments.find((s) => s.name === "category");
|
||||
const vals = seg?.values ?? {};
|
||||
cached = vals;
|
||||
setCategories(vals);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
return { categories, loading };
|
||||
}
|
||||
Reference in New Issue
Block a user