feat: production release with React SPA, file attachments, and deploy tooling

Backend:
- Add file_handlers.go: presigned upload/download for item attachments
- Add item_files.go: item file and thumbnail DB operations
- Add migration 011: item_files table and thumbnail_key column
- Update items/projects/relationships DB with extended field support
- Update routes: React SPA serving from web/dist, file upload endpoints
- Update auth handlers and middleware for cookie + bearer token auth
- Remove Go HTML templates (replaced by React SPA)
- Update storage client for presigned URL generation

Frontend:
- Add TagInput component for tag/keyword entry
- Add SVG assets for Silo branding and UI icons
- Update API client and types for file uploads, auth, extended fields
- Update AuthContext for session-based auth flow
- Update LoginPage, ProjectsPage, SchemasPage, SettingsPage
- Fix tsconfig.node.json

Deployment:
- Update config.prod.yaml: single-binary SPA layout at /opt/silo
- Update silod.service: ReadOnlyPaths for /opt/silo
- Add scripts/deploy.sh: build, package, ship, migrate, start
- Update docker-compose.yaml and Dockerfile
- Add frontend-spec.md design document
This commit is contained in:
Forbes
2026-02-07 13:35:22 -06:00
parent d61f939d84
commit 50923cf56d
49 changed files with 4674 additions and 7915 deletions

71
web/assets/silo-auth.svg Normal file
View File

@@ -0,0 +1,71 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="#cba6f7"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
version="1.1"
id="svg1"
sodipodi:docname="silo-auth.svg"
inkscape:version="1.4.3 (0d15f75042, 2025-12-25)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs1" />
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="34.176828"
inkscape:cx="8.0171279"
inkscape:cy="9.3630691"
inkscape:window-width="2560"
inkscape:window-height="1371"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg1" />
<!-- Padlock body -->
<rect
width="24"
height="24"
rx="3"
fill="#313244"
id="rect1-6"
inkscape:label="rect1"
x="0"
y="0"
style="stroke:none;stroke-width:0.749719;stroke-dasharray:none" />
<path
d="M8 11V7a4 4 0 0 1 8 0v4"
fill="none"
stroke="#89dceb"
id="path1" />
<rect
x="5"
y="11"
width="14"
height="10"
rx="2"
fill="#313244"
stroke="#cba6f7"
id="rect1" />
<!-- Padlock shackle -->
<!-- Keyhole -->
<circle
cx="12"
cy="16"
r="1.5"
fill="#89dceb"
stroke="none"
id="circle1" />
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

103
web/assets/silo-bom.svg Normal file
View File

@@ -0,0 +1,103 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="#cba6f7"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
version="1.1"
id="svg5"
sodipodi:docname="silo-bom.svg"
inkscape:version="1.4.3 (0d15f75042, 2025-12-25)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs5" />
<sodipodi:namedview
id="namedview5"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="24.166667"
inkscape:cx="5.337931"
inkscape:cy="20.193103"
inkscape:window-width="2560"
inkscape:window-height="1371"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg5" />
<!-- Outer box -->
<rect
width="24"
height="24"
rx="3"
fill="#313244"
id="rect1-7"
inkscape:label="rect1"
x="0"
y="0"
style="stroke:none;stroke-width:0.75" />
<rect
x="3"
y="3"
width="18"
height="18"
rx="2"
fill="#313244"
id="rect1" />
<!-- List lines (BOM rows) -->
<line
x1="8.5062122"
y1="8"
x2="18.035316"
y2="8"
stroke="#89dceb"
stroke-width="1.46426"
id="line1" />
<line
x1="8.5415297"
y1="12"
x2="18"
y2="12"
stroke="#89dceb"
stroke-width="1.45882"
id="line2" />
<line
x1="8.5062122"
y1="16"
x2="18.035316"
y2="16"
stroke="#89dceb"
stroke-width="1.46426"
id="line3" />
<!-- Hierarchy dots -->
<circle
cx="5.7157421"
cy="8"
r="0.67500001"
fill="#cba6f7"
id="circle3"
style="stroke-width:1.35" />
<circle
cx="5.7157421"
cy="12"
r="0.67500001"
fill="#cba6f7"
id="circle4"
style="stroke-width:1.35" />
<circle
cx="5.7157421"
cy="16"
r="0.67500001"
fill="#cba6f7"
id="circle5"
style="stroke-width:1.35" />
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -0,0 +1,77 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="#cba6f7"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
version="1.1"
id="svg2"
sodipodi:docname="silo-commit.svg"
inkscape:version="1.4.3 (0d15f75042, 2025-12-25)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs2" />
<sodipodi:namedview
id="namedview2"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="34.176828"
inkscape:cx="12.347547"
inkscape:cy="11.528279"
inkscape:window-width="2560"
inkscape:window-height="1371"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg2" />
<!-- Git commit style -->
<rect
width="24"
height="24"
rx="3"
fill="#313244"
id="rect1"
inkscape:label="rect1"
x="0"
y="0"
style="stroke:none;stroke-width:0.75" />
<line
x1="12"
y1="2"
x2="12"
y2="8"
stroke="#cba6f7"
id="line1" />
<line
x1="12"
y1="16"
x2="12"
y2="22"
stroke="#cba6f7"
id="line2" />
<circle
cx="12"
cy="12"
r="4"
fill="#313244"
stroke="#a6e3a1"
id="circle1" />
<!-- Checkmark inside -->
<polyline
points="9.5 12 11 13.5 14.5 10"
stroke="#a6e3a1"
stroke-width="1.5"
fill="none"
id="polyline2"
transform="matrix(0.6781425,0,0,0.6781425,3.9573771,4.1768251)" />
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

6
web/assets/silo-info.svg Normal file
View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#cba6f7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<!-- Info circle -->
<circle cx="12" cy="12" r="10" fill="#313244"/>
<line x1="12" y1="16" x2="12" y2="12" stroke="#89dceb" stroke-width="2"/>
<circle cx="12" cy="8" r="0.5" fill="#89dceb" stroke="#89dceb"/>
</svg>

After

Width:  |  Height:  |  Size: 377 B

96
web/assets/silo-new.svg Normal file
View File

@@ -0,0 +1,96 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="#cba6f7"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
version="1.1"
id="svg2"
sodipodi:docname="silo-new.svg"
inkscape:version="1.4.3 (0d15f75042, 2025-12-25)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs2" />
<sodipodi:namedview
id="namedview2"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="32"
inkscape:cx="5.96875"
inkscape:cy="12.09375"
inkscape:window-width="2560"
inkscape:window-height="1371"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="g2" />
<!-- Folder open icon -->
<!-- Search magnifier -->
<rect
width="24"
height="24"
rx="3"
fill="#313244"
id="rect1"
inkscape:label="rect1"
x="0"
y="0"
style="stroke:none;stroke-width:0.749719;stroke-dasharray:none" />
<path
d="M 6.1818179,7.6363633 V 18.63072 c 0,0.969697 1.9393931,2.096551 5.8181811,2.096551 3.878787,0 5.624399,-1.126981 5.640104,-2.096551 L 17.81818,7.6363633"
fill="#313244"
stroke="#cba6f7"
stroke-width="2"
id="path1"
sodipodi:nodetypes="csssc"
style="fill:#45475a;fill-opacity:1;stroke-width:0.909091;stroke-dasharray:none" />
<ellipse
cx="11.999998"
cy="7.6363635"
rx="5.818182"
ry="2.181818"
fill="#45475a"
stroke="#cba6f7"
stroke-width="2"
id="ellipse1"
style="stroke:#cba6f7;stroke-width:0.909091;stroke-dasharray:none;stroke-opacity:1" />
<path
d="m 6.1818179,7.6363633 c 0,-7.272727 11.6363621,-7.272727 11.6363621,0"
fill="none"
stroke="#cba6f7"
stroke-width="4.65698"
stroke-linecap="round"
id="path2"
style="fill:#45475a;fill-opacity:1;stroke-width:0.909091;stroke-dasharray:none"
sodipodi:nodetypes="cc" />
<g
id="g2"
transform="translate(-13.871014,1.4359614)">
<line
x1="30.375973"
y1="17.251537"
x2="33.911507"
y2="17.251539"
stroke="#a6e3a1"
stroke-width="1.5"
id="line2" />
<line
x1="32.143738"
y1="15.483771"
x2="32.143738"
y2="19.019306"
stroke="#a6e3a1"
stroke-width="1.5"
id="line2-6" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

97
web/assets/silo-open.svg Normal file
View File

@@ -0,0 +1,97 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="#cba6f7"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
version="1.1"
id="svg2"
sodipodi:docname="silo-open.svg"
inkscape:version="1.4.3 (0d15f75042, 2025-12-25)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs2" />
<sodipodi:namedview
id="namedview2"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="32"
inkscape:cx="5.96875"
inkscape:cy="12.09375"
inkscape:window-width="2560"
inkscape:window-height="1371"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg2" />
<!-- Folder open icon -->
<!-- Search magnifier -->
<rect
width="24"
height="24"
rx="3"
fill="#313244"
id="rect1"
inkscape:label="rect1"
x="0"
y="0"
style="stroke:none;stroke-width:0.749719;stroke-dasharray:none" />
<path
d="M 6.1818179,7.6363633 V 18.63072 c 0,0.969697 1.9393931,2.096551 5.8181811,2.096551 3.878787,0 5.624399,-1.126981 5.640104,-2.096551 L 17.81818,7.6363633"
fill="#313244"
stroke="#cba6f7"
stroke-width="2"
id="path1"
sodipodi:nodetypes="csssc"
style="fill:#45475a;fill-opacity:1;stroke-width:0.909091;stroke-dasharray:none" />
<ellipse
cx="11.999998"
cy="7.6363635"
rx="5.818182"
ry="2.181818"
fill="#45475a"
stroke="#cba6f7"
stroke-width="2"
id="ellipse1"
style="stroke:#cba6f7;stroke-width:0.909091;stroke-dasharray:none;stroke-opacity:1" />
<path
d="m 6.1818179,7.6363633 c 0,-7.272727 11.6363621,-7.272727 11.6363621,0"
fill="none"
stroke="#cba6f7"
stroke-width="4.65698"
stroke-linecap="round"
id="path2"
style="fill:#45475a;fill-opacity:1;stroke-width:0.909091;stroke-dasharray:none"
sodipodi:nodetypes="cc" />
<g
id="g2"
transform="translate(-13.871014,1.4359614)">
<line
x1="30.121014"
y1="14.814038"
x2="32.621014"
y2="17.314039"
stroke="#a6e3a1"
stroke-width="1.5"
id="line2" />
<circle
cx="28.460087"
cy="13.153111"
r="2.0712578"
fill="#1e1e2e"
stroke="#a6e3a1"
stroke-width="1.03563"
id="circle2"
style="fill:none" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

7
web/assets/silo-pull.svg Normal file
View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#cba6f7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<!-- Cloud -->
<path d="M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z" fill="#313244"/>
<!-- Download arrow -->
<path d="M12 13v5m0 0l-2-2m2 2l2-2" stroke="#89b4fa" stroke-width="2"/>
<line x1="12" y1="9" x2="12" y2="13" stroke="#89b4fa" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 428 B

48
web/assets/silo-push.svg Normal file
View File

@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="#cba6f7"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
version="1.1"
id="svg2"
sodipodi:docname="silo-push.svg"
inkscape:version="1.4.3 (0d15f75042, 2025-12-25)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs2" />
<sodipodi:namedview
id="namedview2"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="48.333333"
inkscape:cx="12"
inkscape:cy="12"
inkscape:window-width="2560"
inkscape:window-height="1371"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg2" />
<!-- Cloud -->
<path
d="M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z"
fill="#313244"
id="path1" />
<!-- Upload arrow -->
<path
d="m 11.462069,16.262069 v -5 m 0,0 -1.9999997,2 m 1.9999997,-2 2,2"
stroke="#a6e3a1"
stroke-width="2"
id="path2" />
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

109
web/assets/silo-save.svg Normal file
View File

@@ -0,0 +1,109 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="#cba6f7"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
version="1.1"
id="svg2"
sodipodi:docname="silo-save.svg"
inkscape:version="1.4.3 (0d15f75042, 2025-12-25)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs2">
<marker
style="overflow:visible"
id="Triangle"
refX="0"
refY="0"
orient="auto-start-reverse"
inkscape:stockid="Triangle arrow"
markerWidth="0.33"
markerHeight="0.33"
viewBox="0 0 1 1"
inkscape:isstock="true"
inkscape:collect="always"
preserveAspectRatio="xMidYMid">
<path
transform="scale(0.5)"
style="fill:context-stroke;fill-rule:evenodd;stroke:context-stroke;stroke-width:1pt"
d="M 5.77,0 -2.88,5 V -5 Z"
id="path135" />
</marker>
</defs>
<sodipodi:namedview
id="namedview2"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="32"
inkscape:cx="5.96875"
inkscape:cy="12.09375"
inkscape:window-width="2560"
inkscape:window-height="1371"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="g2" />
<!-- Folder open icon -->
<!-- Search magnifier -->
<rect
width="24"
height="24"
rx="3"
fill="#313244"
id="rect1"
inkscape:label="rect1"
x="0"
y="0"
style="stroke:none;stroke-width:0.749719;stroke-dasharray:none" />
<path
d="M 6.1818179,7.6363633 V 18.63072 c 0,0.969697 1.9393931,2.096551 5.8181811,2.096551 3.878787,0 5.624399,-1.126981 5.640104,-2.096551 L 17.81818,7.6363633"
fill="#313244"
stroke="#cba6f7"
stroke-width="2"
id="path1"
sodipodi:nodetypes="csssc"
style="fill:#45475a;fill-opacity:1;stroke-width:0.909091;stroke-dasharray:none" />
<ellipse
cx="11.999998"
cy="7.6363635"
rx="5.818182"
ry="2.181818"
fill="#45475a"
stroke="#cba6f7"
stroke-width="2"
id="ellipse1"
style="stroke:#cba6f7;stroke-width:0.909091;stroke-dasharray:none;stroke-opacity:1" />
<path
d="m 6.1818179,7.6363633 c 0,-7.272727 11.6363621,-7.272727 11.6363621,0"
fill="none"
stroke="#cba6f7"
stroke-width="4.65698"
stroke-linecap="round"
id="path2"
style="fill:#45475a;fill-opacity:1;stroke-width:0.909091;stroke-dasharray:none"
sodipodi:nodetypes="cc" />
<g
id="g2"
transform="translate(-13.871014,1.4359614)">
<line
x1="29.299988"
y1="15.638699"
x2="29.299988"
y2="20.238056"
stroke="#a6e3a1"
stroke-width="1.71085"
id="line2-6"
style="stroke-width:1.711;stroke-dasharray:none;marker-start:url(#Triangle)" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

77
web/assets/silo.svg Normal file
View File

@@ -0,0 +1,77 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
viewBox="0 0 64 64"
fill="none"
version="1.1"
id="svg6"
sodipodi:docname="silo.svg"
inkscape:version="1.4.3 (0d15f75042, 2025-12-25)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs6" />
<sodipodi:namedview
id="namedview6"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="12.81631"
inkscape:cx="31.366282"
inkscape:cy="33.472973"
inkscape:window-width="2560"
inkscape:window-height="1371"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg6" />
<!-- Silo icon - grain silo with database/sync symbolism -->
<!-- Uses Catppuccin Mocha colors -->
<!-- Silo body (cylindrical tower) -->
<rect
width="64"
height="64"
rx="8"
fill="#313244"
id="rect1"
inkscape:label="rect1"
x="0"
y="0"
style="stroke-width:2" />
<path
d="M 16,20 V 50.234483 C 16,52.901149 21.333333,56 32,56 42.666667,56 47.4671,52.9008 47.510287,50.234483 L 48,20"
fill="#313244"
stroke="#cba6f7"
stroke-width="2"
id="path1"
sodipodi:nodetypes="csssc"
style="fill:#45475a;fill-opacity:1;stroke-width:2.5;stroke-dasharray:none" />
<!-- Silo dome/roof -->
<ellipse
cx="32"
cy="20"
rx="16"
ry="6"
fill="#45475a"
stroke="#cba6f7"
stroke-width="2"
id="ellipse1"
style="stroke:#cba6f7;stroke-opacity:1;stroke-width:2.5;stroke-dasharray:none" />
<path
d="M 16,20 C 16,0 48,0 48,20"
fill="none"
stroke="#cba6f7"
stroke-width="4.65698"
stroke-linecap="round"
id="path2"
style="stroke-width:2.5;stroke-dasharray:none;fill:#45475a;fill-opacity:1"
sodipodi:nodetypes="cc" />
<!-- Horizontal bands (like database rows / silo rings) -->
<!-- Base ellipse -->
<!-- Sync arrows (circular) - represents upload/download -->
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -1,4 +1,4 @@
import type { ErrorResponse } from './types';
import type { ErrorResponse } from "./types";
export class ApiError extends Error {
constructor(
@@ -7,29 +7,28 @@ export class ApiError extends Error {
message?: string,
) {
super(message ?? error);
this.name = 'ApiError';
this.name = "ApiError";
}
}
async function request<T>(url: string, options?: RequestInit): Promise<T> {
const res = await fetch(url, {
...options,
credentials: 'include',
credentials: "include",
headers: {
'Content-Type': 'application/json',
"Content-Type": "application/json",
...options?.headers,
},
});
if (res.status === 401) {
window.location.href = '/login';
throw new ApiError(401, 'unauthorized');
throw new ApiError(401, "unauthorized");
}
if (!res.ok) {
let body: ErrorResponse | undefined;
try {
body = await res.json() as ErrorResponse;
body = (await res.json()) as ErrorResponse;
} catch {
// non-JSON error response
}
@@ -53,18 +52,18 @@ export function get<T>(url: string): Promise<T> {
export function post<T>(url: string, body?: unknown): Promise<T> {
return request<T>(url, {
method: 'POST',
method: "POST",
body: body != null ? JSON.stringify(body) : undefined,
});
}
export function put<T>(url: string, body?: unknown): Promise<T> {
return request<T>(url, {
method: 'PUT',
method: "PUT",
body: body != null ? JSON.stringify(body) : undefined,
});
}
export function del(url: string): Promise<void> {
return request<void>(url, { method: 'DELETE' });
return request<void>(url, { method: "DELETE" });
}

View File

@@ -218,6 +218,48 @@ export interface PropertyDef {
export type PropertySchema = Record<string, PropertyDef>;
// API Token
export interface ApiToken {
id: string;
name: string;
token_prefix: string;
last_used_at?: string;
expires_at?: string;
created_at: string;
}
export interface ApiTokenCreated extends ApiToken {
token: string;
}
// Auth config (public endpoint)
export interface AuthConfig {
oidc_enabled: boolean;
local_enabled: boolean;
}
// Project requests
export interface CreateProjectRequest {
code: string;
name?: string;
description?: string;
}
export interface UpdateProjectRequest {
name?: string;
description?: string;
}
// Schema enum value requests
export interface CreateSchemaValueRequest {
code: string;
description: string;
}
export interface UpdateSchemaValueRequest {
description: string;
}
// Revision comparison
export interface RevisionComparison {
from: number;

View File

@@ -0,0 +1,222 @@
import { useState, useRef, useEffect, useCallback } from 'react';
export interface TagOption {
id: string;
label: string;
}
interface TagInputProps {
value: string[];
onChange: (ids: string[]) => void;
placeholder?: string;
searchFn: (query: string) => Promise<TagOption[]>;
}
export function TagInput({ value, onChange, placeholder, searchFn }: TagInputProps) {
const [query, setQuery] = useState('');
const [results, setResults] = useState<TagOption[]>([]);
const [open, setOpen] = useState(false);
const [highlighted, setHighlighted] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
// Debounced search
const search = useCallback(
(q: string) => {
if (debounceRef.current) clearTimeout(debounceRef.current);
if (q.trim() === '') {
// Show all results when input is empty but focused
debounceRef.current = setTimeout(() => {
searchFn('').then((opts) => {
setResults(opts.filter((o) => !value.includes(o.id)));
setHighlighted(0);
}).catch(() => setResults([]));
}, 100);
return;
}
debounceRef.current = setTimeout(() => {
searchFn(q).then((opts) => {
setResults(opts.filter((o) => !value.includes(o.id)));
setHighlighted(0);
}).catch(() => setResults([]));
}, 200);
},
[searchFn, value],
);
// Re-filter when value changes (exclude newly selected)
useEffect(() => {
if (open) search(query);
}, [value]); // eslint-disable-line react-hooks/exhaustive-deps
// Close on click outside
useEffect(() => {
const handler = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, []);
const select = (id: string) => {
onChange([...value, id]);
setQuery('');
setOpen(false);
inputRef.current?.focus();
};
const remove = (id: string) => {
onChange(value.filter((v) => v !== id));
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Backspace' && query === '' && value.length > 0) {
onChange(value.slice(0, -1));
return;
}
if (e.key === 'Escape') {
setOpen(false);
return;
}
if (!open || results.length === 0) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
setHighlighted((h) => (h + 1) % results.length);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setHighlighted((h) => (h - 1 + results.length) % results.length);
} else if (e.key === 'Enter') {
e.preventDefault();
if (results[highlighted]) select(results[highlighted].id);
}
};
// Find label for a selected id from latest results (fallback to id)
const labelMap = useRef(new Map<string, string>());
for (const r of results) labelMap.current.set(r.id, r.label);
return (
<div ref={containerRef} style={{ position: 'relative' }}>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
gap: '0.25rem',
padding: '0.25rem 0.5rem',
backgroundColor: 'var(--ctp-base)',
border: '1px solid var(--ctp-surface1)',
borderRadius: '0.3rem',
cursor: 'text',
minHeight: '1.8rem',
}}
onClick={() => inputRef.current?.focus()}
>
{value.map((id) => (
<span
key={id}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '0.25rem',
padding: '0.1rem 0.5rem',
borderRadius: '1rem',
backgroundColor: 'rgba(203,166,247,0.15)',
color: 'var(--ctp-mauve)',
fontSize: '0.75rem',
}}
>
{labelMap.current.get(id) ?? id}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
remove(id);
}}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
color: 'var(--ctp-mauve)',
padding: 0,
fontSize: '0.8rem',
lineHeight: 1,
}}
>
×
</button>
</span>
))}
<input
ref={inputRef}
value={query}
onChange={(e) => {
setQuery(e.target.value);
setOpen(true);
search(e.target.value);
}}
onFocus={() => {
setOpen(true);
search(query);
}}
onKeyDown={handleKeyDown}
placeholder={value.length === 0 ? placeholder : undefined}
style={{
flex: 1,
minWidth: '4rem',
border: 'none',
outline: 'none',
background: 'transparent',
color: 'var(--ctp-text)',
fontSize: '0.85rem',
padding: '0.1rem 0',
}}
/>
</div>
{open && results.length > 0 && (
<div
style={{
position: 'absolute',
top: '100%',
left: 0,
right: 0,
zIndex: 10,
marginTop: '0.2rem',
backgroundColor: 'var(--ctp-surface0)',
border: '1px solid var(--ctp-surface1)',
borderRadius: '0.3rem',
maxHeight: '160px',
overflowY: 'auto',
}}
>
{results.map((opt, i) => (
<div
key={opt.id}
onMouseDown={(e) => {
e.preventDefault();
select(opt.id);
}}
onMouseEnter={() => setHighlighted(i)}
style={{
padding: '0.25rem 0.5rem',
height: '28px',
display: 'flex',
alignItems: 'center',
fontSize: '0.8rem',
cursor: 'pointer',
color: 'var(--ctp-text)',
backgroundColor:
i === highlighted ? 'var(--ctp-surface1)' : 'transparent',
}}
>
{opt.label}
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -1,37 +1,86 @@
import { createContext, useEffect, useState, type ReactNode } from 'react';
import type { User } from '../api/types';
import { get } from '../api/client';
import {
createContext,
useEffect,
useState,
useCallback,
type ReactNode,
} from "react";
import type { User } from "../api/types";
import { get } from "../api/client";
export interface AuthContextValue {
user: User | null;
loading: boolean;
login: (username: string, password: string) => Promise<void>;
logout: () => Promise<void>;
refresh: () => Promise<void>;
}
export const AuthContext = createContext<AuthContextValue>({
user: null,
loading: true,
login: async () => {},
logout: async () => {},
refresh: async () => {},
});
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
get<User>('/api/auth/me')
.then(setUser)
.catch(() => setUser(null))
.finally(() => setLoading(false));
const fetchUser = useCallback(async () => {
try {
const u = await get<User>("/api/auth/me");
setUser(u);
} catch {
setUser(null);
}
}, []);
useEffect(() => {
fetchUser().finally(() => setLoading(false));
}, [fetchUser]);
const login = async (username: string, password: string) => {
const body = new URLSearchParams({ username, password });
const res = await fetch("/login", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body,
redirect: "manual",
});
// Go handler returns 302 on success, JSON error on failure
if (
res.type === "opaqueredirect" ||
res.status === 302 ||
res.status === 0
) {
await fetchUser();
return;
}
// Parse JSON error response
let message = "Invalid username or password";
try {
const err = await res.json();
if (err.message) message = err.message;
} catch {
// non-JSON response, use default message
}
throw new Error(message);
};
const logout = async () => {
await fetch('/logout', { method: 'POST', credentials: 'include' });
window.location.href = '/login';
await fetch("/logout", { method: "POST", credentials: "include" });
setUser(null);
};
const refresh = async () => {
await fetchUser();
};
return (
<AuthContext.Provider value={{ user, loading, logout }}>
<AuthContext.Provider value={{ user, loading, login, logout, refresh }}>
{children}
</AuthContext.Provider>
);

View File

@@ -1,10 +1,10 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { get } from '../api/client';
import type { Item, FuzzyResult } from '../api/types';
import { useState, useEffect, useCallback, useRef } from "react";
import { get } from "../api/client";
import type { Item, FuzzyResult } from "../api/types";
export interface ItemFilters {
search: string;
searchScope: 'all' | 'part_number' | 'description';
searchScope: "all" | "part_number" | "description";
type: string;
project: string;
page: number;
@@ -12,10 +12,10 @@ export interface ItemFilters {
}
const defaultFilters: ItemFilters = {
search: '',
searchScope: 'all',
type: '',
project: '',
search: "",
searchScope: "all",
type: "",
project: "",
page: 1,
pageSize: 50,
};
@@ -25,7 +25,7 @@ export function useItems() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [filters, setFilters] = useState<ItemFilters>(defaultFilters);
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const fetchItems = useCallback(async (f: ItemFilters) => {
setLoading(true);
@@ -34,23 +34,23 @@ export function useItems() {
let result: Item[];
if (f.search) {
const params = new URLSearchParams({ q: f.search });
if (f.searchScope !== 'all') params.set('fields', f.searchScope);
if (f.type) params.set('type', f.type);
if (f.project) params.set('project', f.project);
params.set('limit', String(f.pageSize));
if (f.searchScope !== "all") params.set("fields", f.searchScope);
if (f.type) params.set("type", f.type);
if (f.project) params.set("project", f.project);
params.set("limit", String(f.pageSize));
result = await get<FuzzyResult[]>(`/api/items/search?${params}`);
} else {
const params = new URLSearchParams();
if (f.type) params.set('type', f.type);
if (f.project) params.set('project', f.project);
params.set('limit', String(f.pageSize));
params.set('offset', String((f.page - 1) * f.pageSize));
if (f.type) params.set("type", f.type);
if (f.project) params.set("project", f.project);
params.set("limit", String(f.pageSize));
params.set("offset", String((f.page - 1) * f.pageSize));
const qs = params.toString();
result = await get<Item[]>(`/api/items${qs ? `?${qs}` : ''}`);
result = await get<Item[]>(`/api/items${qs ? `?${qs}` : ""}`);
}
setItems(result);
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to load items');
setError(e instanceof Error ? e.message : "Failed to load items");
} finally {
setLoading(false);
}
@@ -76,7 +76,7 @@ export function useItems() {
setFilters((prev) => {
const next = { ...prev, ...partial };
// Reset to page 1 when filters change (but not when page itself changes)
if (!('page' in partial)) next.page = 1;
if (!("page" in partial)) next.page = 1;
return next;
});
}, []);

View File

@@ -1,22 +1,200 @@
import { useEffect } from 'react';
import { useEffect, useState, type FormEvent } from "react";
import { useAuth } from "../hooks/useAuth";
import { get } from "../api/client";
import type { AuthConfig } from "../api/types";
export function LoginPage() {
// During transition, redirect to the Go-served login page
const { login } = useAuth();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [submitting, setSubmitting] = useState(false);
const [oidcEnabled, setOidcEnabled] = useState(false);
useEffect(() => {
window.location.href = '/login';
get<AuthConfig>("/api/auth/config")
.then((cfg) => setOidcEnabled(cfg.oidc_enabled))
.catch(() => {});
}, []);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
if (!username.trim() || !password) return;
setError("");
setSubmitting(true);
try {
await login(username.trim(), password);
} catch (err) {
setError(err instanceof Error ? err.message : "Login failed");
} finally {
setSubmitting(false);
}
};
return (
<div
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: '100vh',
backgroundColor: 'var(--ctp-base)',
}}
>
<p style={{ color: 'var(--ctp-subtext0)' }}>Redirecting to login...</p>
<div style={containerStyle}>
<div style={cardStyle}>
<h1 style={titleStyle}>Silo</h1>
<p style={subtitleStyle}>Product Lifecycle Management</p>
{error && <div style={errorStyle}>{error}</div>}
<form onSubmit={handleSubmit}>
<div style={formGroupStyle}>
<label style={labelStyle}>Username</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Username or LDAP uid"
autoFocus
required
style={inputStyle}
/>
</div>
<div style={formGroupStyle}>
<label style={labelStyle}>Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
required
style={inputStyle}
/>
</div>
<button type="submit" disabled={submitting} style={btnPrimaryStyle}>
{submitting ? "Signing in..." : "Sign In"}
</button>
</form>
{oidcEnabled && (
<>
<div style={dividerStyle}>
<span style={dividerLineStyle} />
<span
style={{
padding: "0 1rem",
color: "var(--ctp-overlay0)",
fontSize: "0.85rem",
}}
>
or
</span>
<span style={dividerLineStyle} />
</div>
<a href="/auth/oidc" style={btnOidcStyle}>
Sign in with Keycloak
</a>
</>
)}
</div>
</div>
);
}
const containerStyle: React.CSSProperties = {
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "100vh",
backgroundColor: "var(--ctp-base)",
};
const cardStyle: React.CSSProperties = {
backgroundColor: "var(--ctp-surface0)",
borderRadius: "1rem",
padding: "2.5rem",
width: "100%",
maxWidth: 400,
margin: "1rem",
};
const titleStyle: React.CSSProperties = {
color: "var(--ctp-mauve)",
textAlign: "center",
fontSize: "2rem",
fontWeight: 700,
marginBottom: "0.25rem",
};
const subtitleStyle: React.CSSProperties = {
color: "var(--ctp-subtext0)",
textAlign: "center",
fontSize: "0.9rem",
marginBottom: "2rem",
};
const errorStyle: React.CSSProperties = {
color: "var(--ctp-red)",
background: "rgba(243, 139, 168, 0.1)",
border: "1px solid rgba(243, 139, 168, 0.2)",
padding: "0.75rem 1rem",
borderRadius: "0.5rem",
marginBottom: "1rem",
fontSize: "0.9rem",
};
const formGroupStyle: React.CSSProperties = {
marginBottom: "1.25rem",
};
const labelStyle: React.CSSProperties = {
display: "block",
marginBottom: "0.5rem",
fontWeight: 500,
color: "var(--ctp-subtext1)",
fontSize: "0.9rem",
};
const inputStyle: React.CSSProperties = {
width: "100%",
padding: "0.75rem 1rem",
backgroundColor: "var(--ctp-base)",
border: "1px solid var(--ctp-surface1)",
borderRadius: "0.5rem",
color: "var(--ctp-text)",
fontSize: "1rem",
boxSizing: "border-box",
};
const btnPrimaryStyle: React.CSSProperties = {
display: "block",
width: "100%",
padding: "0.75rem 1.5rem",
borderRadius: "0.5rem",
fontWeight: 600,
fontSize: "1rem",
cursor: "pointer",
border: "none",
backgroundColor: "var(--ctp-mauve)",
color: "var(--ctp-crust)",
textAlign: "center",
};
const dividerStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
margin: "1.5rem 0",
};
const dividerLineStyle: React.CSSProperties = {
flex: 1,
borderTop: "1px solid var(--ctp-surface1)",
};
const btnOidcStyle: React.CSSProperties = {
display: "block",
width: "100%",
padding: "0.75rem 1.5rem",
borderRadius: "0.5rem",
fontWeight: 600,
fontSize: "1rem",
cursor: "pointer",
border: "none",
backgroundColor: "var(--ctp-blue)",
color: "var(--ctp-crust)",
textAlign: "center",
textDecoration: "none",
boxSizing: "border-box",
};

View File

@@ -1,49 +1,438 @@
import { useEffect, useState } from 'react';
import { get } from '../api/client';
import type { Project } from '../api/types';
import { useEffect, useState, useCallback, type FormEvent } from "react";
import { useNavigate } from "react-router-dom";
import { get, post, put, del } from "../api/client";
import { useAuth } from "../hooks/useAuth";
import type {
Project,
Item,
CreateProjectRequest,
UpdateProjectRequest,
} from "../api/types";
type Mode = "list" | "create" | "edit" | "delete";
interface ProjectWithCount extends Project {
itemCount: number;
}
export function ProjectsPage() {
const [projects, setProjects] = useState<Project[]>([]);
const { user } = useAuth();
const navigate = useNavigate();
const isEditor = user?.role === "admin" || user?.role === "editor";
const [projects, setProjects] = useState<ProjectWithCount[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
get<Project[]>('/api/projects')
.then(setProjects)
.catch((e: Error) => setError(e.message))
.finally(() => setLoading(false));
const [mode, setMode] = useState<Mode>("list");
const [editingProject, setEditingProject] = useState<ProjectWithCount | null>(
null,
);
// Form state
const [formCode, setFormCode] = useState("");
const [formName, setFormName] = useState("");
const [formDesc, setFormDesc] = useState("");
const [formError, setFormError] = useState("");
const [formSubmitting, setFormSubmitting] = useState(false);
// Sort state
const [sortKey, setSortKey] = useState<
"code" | "name" | "itemCount" | "created_at"
>("code");
const [sortAsc, setSortAsc] = useState(true);
const loadProjects = useCallback(async () => {
try {
const list = await get<Project[]>("/api/projects");
const withCounts = await Promise.all(
list.map(async (p) => {
try {
const items = await get<Item[]>(`/api/projects/${p.code}/items`);
return { ...p, itemCount: items.length };
} catch {
return { ...p, itemCount: 0 };
}
}),
);
setProjects(withCounts);
setError(null);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load projects");
} finally {
setLoading(false);
}
}, []);
if (loading) return <p style={{ color: 'var(--ctp-subtext0)' }}>Loading projects...</p>;
if (error) return <p style={{ color: 'var(--ctp-red)' }}>Error: {error}</p>;
useEffect(() => {
loadProjects();
}, [loadProjects]);
const openCreate = () => {
setFormCode("");
setFormName("");
setFormDesc("");
setFormError("");
setMode("create");
};
const openEdit = (p: ProjectWithCount) => {
setEditingProject(p);
setFormCode(p.code);
setFormName(p.name ?? "");
setFormDesc(p.description ?? "");
setFormError("");
setMode("edit");
};
const openDelete = (p: ProjectWithCount) => {
setEditingProject(p);
setMode("delete");
};
const cancel = () => {
setMode("list");
setEditingProject(null);
setFormError("");
};
const handleCreate = async (e: FormEvent) => {
e.preventDefault();
setFormError("");
setFormSubmitting(true);
try {
const req: CreateProjectRequest = {
code: formCode.toUpperCase(),
name: formName || undefined,
description: formDesc || undefined,
};
await post("/api/projects", req);
cancel();
await loadProjects();
} catch (e) {
setFormError(e instanceof Error ? e.message : "Failed to create project");
} finally {
setFormSubmitting(false);
}
};
const handleEdit = async (e: FormEvent) => {
e.preventDefault();
if (!editingProject) return;
setFormError("");
setFormSubmitting(true);
try {
const req: UpdateProjectRequest = {
name: formName,
description: formDesc,
};
await put(`/api/projects/${editingProject.code}`, req);
cancel();
await loadProjects();
} catch (e) {
setFormError(e instanceof Error ? e.message : "Failed to update project");
} finally {
setFormSubmitting(false);
}
};
const handleDelete = async () => {
if (!editingProject) return;
setFormSubmitting(true);
try {
await del(`/api/projects/${editingProject.code}`);
cancel();
await loadProjects();
} catch (e) {
setFormError(e instanceof Error ? e.message : "Failed to delete project");
} finally {
setFormSubmitting(false);
}
};
const handleSort = (key: typeof sortKey) => {
if (sortKey === key) {
setSortAsc(!sortAsc);
} else {
setSortKey(key);
setSortAsc(true);
}
};
const sorted = [...projects].sort((a, b) => {
let cmp = 0;
if (sortKey === "code") cmp = a.code.localeCompare(b.code);
else if (sortKey === "name")
cmp = (a.name ?? "").localeCompare(b.name ?? "");
else if (sortKey === "itemCount") cmp = a.itemCount - b.itemCount;
else if (sortKey === "created_at")
cmp = a.created_at.localeCompare(b.created_at);
return sortAsc ? cmp : -cmp;
});
const formatDate = (s: string) => {
const d = new Date(s);
return d.toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
};
const sortArrow = (key: typeof sortKey) =>
sortKey === key ? (sortAsc ? " \u25B2" : " \u25BC") : "";
if (loading)
return <p style={{ color: "var(--ctp-subtext0)" }}>Loading projects...</p>;
if (error) return <p style={{ color: "var(--ctp-red)" }}>Error: {error}</p>;
return (
<div>
<h2 style={{ marginBottom: '1rem' }}>Projects ({projects.length})</h2>
<div style={{
backgroundColor: 'var(--ctp-surface0)',
borderRadius: '0.75rem',
padding: '1rem',
overflowX: 'auto',
}}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
{/* Header */}
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "1rem",
}}
>
<h2>Projects ({projects.length})</h2>
{isEditor && mode === "list" && (
<button onClick={openCreate} style={btnPrimaryStyle}>
+ New Project
</button>
)}
</div>
{/* Create / Edit form */}
{(mode === "create" || mode === "edit") && (
<div style={formPaneStyle}>
<div
style={{
...formHeaderStyle,
backgroundColor:
mode === "create" ? "var(--ctp-green)" : "var(--ctp-blue)",
}}
>
<strong>
{mode === "create"
? "New Project"
: `Edit ${editingProject?.code}`}
</strong>
<button onClick={cancel} style={formCloseStyle}>
Cancel
</button>
</div>
<form
onSubmit={mode === "create" ? handleCreate : handleEdit}
style={{ padding: "1rem" }}
>
{formError && <div style={errorBannerStyle}>{formError}</div>}
{mode === "create" && (
<div style={fieldStyle}>
<label style={labelStyle}>
Code (2-10 characters, uppercase)
</label>
<input
type="text"
value={formCode}
onChange={(e) => setFormCode(e.target.value)}
placeholder="e.g., PROJ-A"
required
minLength={2}
maxLength={10}
style={{ ...inputStyle, textTransform: "uppercase" }}
/>
</div>
)}
<div style={fieldStyle}>
<label style={labelStyle}>Name</label>
<input
type="text"
value={formName}
onChange={(e) => setFormName(e.target.value)}
placeholder="Project name"
style={inputStyle}
/>
</div>
<div style={fieldStyle}>
<label style={labelStyle}>Description</label>
<input
type="text"
value={formDesc}
onChange={(e) => setFormDesc(e.target.value)}
placeholder="Project description"
style={inputStyle}
/>
</div>
<div
style={{
display: "flex",
gap: "0.5rem",
justifyContent: "flex-end",
}}
>
<button type="button" onClick={cancel} style={btnSecondaryStyle}>
Cancel
</button>
<button
type="submit"
disabled={formSubmitting}
style={btnPrimaryStyle}
>
{formSubmitting
? "Saving..."
: mode === "create"
? "Create Project"
: "Save Changes"}
</button>
</div>
</form>
</div>
)}
{/* Delete confirmation */}
{mode === "delete" && editingProject && (
<div style={formPaneStyle}>
<div
style={{ ...formHeaderStyle, backgroundColor: "var(--ctp-red)" }}
>
<strong>Delete Project</strong>
<button onClick={cancel} style={formCloseStyle}>
Cancel
</button>
</div>
<div style={{ padding: "1.5rem", textAlign: "center" }}>
{formError && <div style={errorBannerStyle}>{formError}</div>}
<p>
Are you sure you want to permanently delete project{" "}
<strong style={{ color: "var(--ctp-peach)" }}>
{editingProject.code}
</strong>
?
</p>
<p
style={{
color: "var(--ctp-red)",
marginTop: "0.5rem",
fontSize: "0.85rem",
}}
>
This action cannot be undone.
</p>
<div
style={{
display: "flex",
gap: "0.5rem",
justifyContent: "center",
marginTop: "1.5rem",
}}
>
<button onClick={cancel} style={btnSecondaryStyle}>
Cancel
</button>
<button
onClick={handleDelete}
disabled={formSubmitting}
style={btnDangerStyle}
>
{formSubmitting ? "Deleting..." : "Delete Permanently"}
</button>
</div>
</div>
</div>
)}
{/* Table */}
<div style={tableContainerStyle}>
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead>
<tr>
<th style={thStyle}>Code</th>
<th style={thStyle}>Name</th>
<th style={thStyle} onClick={() => handleSort("code")}>
Code{sortArrow("code")}
</th>
<th style={thStyle} onClick={() => handleSort("name")}>
Name{sortArrow("name")}
</th>
<th style={thStyle}>Description</th>
<th style={thStyle} onClick={() => handleSort("itemCount")}>
Items{sortArrow("itemCount")}
</th>
<th style={thStyle} onClick={() => handleSort("created_at")}>
Created{sortArrow("created_at")}
</th>
{isEditor && <th style={thStyle}>Actions</th>}
</tr>
</thead>
<tbody>
{projects.map((p) => (
<tr key={p.id}>
<td style={{ ...tdStyle, fontFamily: "'JetBrains Mono', monospace", color: 'var(--ctp-peach)' }}>
{p.code}
{sorted.length === 0 ? (
<tr>
<td
colSpan={isEditor ? 6 : 5}
style={{
...tdStyle,
textAlign: "center",
padding: "2rem",
color: "var(--ctp-subtext0)",
}}
>
No projects found. Create your first project to start
organizing items.
</td>
<td style={tdStyle}>{p.name}</td>
<td style={tdStyle}>{p.description}</td>
</tr>
))}
) : (
sorted.map((p, i) => (
<tr
key={p.id}
style={{
backgroundColor:
i % 2 === 0 ? "var(--ctp-base)" : "var(--ctp-surface0)",
}}
>
<td style={tdStyle}>
<span
onClick={() =>
navigate(`/?project=${encodeURIComponent(p.code)}`)
}
style={{
color: "var(--ctp-peach)",
fontFamily: "'JetBrains Mono', monospace",
fontWeight: 500,
cursor: "pointer",
}}
>
{p.code}
</span>
</td>
<td style={tdStyle}>{p.name || "-"}</td>
<td style={tdStyle}>{p.description || "-"}</td>
<td style={tdStyle}>{p.itemCount}</td>
<td style={tdStyle}>{formatDate(p.created_at)}</td>
{isEditor && (
<td style={tdStyle}>
<div style={{ display: "flex", gap: "0.25rem" }}>
<button
onClick={() => openEdit(p)}
style={btnSmallStyle}
>
Edit
</button>
<button
onClick={() => openDelete(p)}
style={{
...btnSmallStyle,
backgroundColor: "var(--ctp-surface2)",
}}
>
Delete
</button>
</div>
</td>
)}
</tr>
))
)}
</tbody>
</table>
</div>
@@ -51,18 +440,129 @@ export function ProjectsPage() {
);
}
const thStyle: React.CSSProperties = {
padding: '0.75rem 1rem',
textAlign: 'left',
borderBottom: '1px solid var(--ctp-surface1)',
color: 'var(--ctp-subtext1)',
// Styles
const btnPrimaryStyle: React.CSSProperties = {
padding: "0.5rem 1rem",
borderRadius: "0.4rem",
border: "none",
backgroundColor: "var(--ctp-mauve)",
color: "var(--ctp-crust)",
fontWeight: 600,
fontSize: '0.85rem',
textTransform: 'uppercase',
letterSpacing: '0.05em',
fontSize: "0.85rem",
cursor: "pointer",
};
const btnSecondaryStyle: React.CSSProperties = {
padding: "0.5rem 1rem",
borderRadius: "0.4rem",
border: "none",
backgroundColor: "var(--ctp-surface1)",
color: "var(--ctp-text)",
fontSize: "0.85rem",
cursor: "pointer",
};
const btnDangerStyle: React.CSSProperties = {
padding: "0.5rem 1rem",
borderRadius: "0.4rem",
border: "none",
backgroundColor: "var(--ctp-red)",
color: "var(--ctp-crust)",
fontWeight: 600,
fontSize: "0.85rem",
cursor: "pointer",
};
const btnSmallStyle: React.CSSProperties = {
padding: "0.3rem 0.6rem",
borderRadius: "0.3rem",
border: "none",
backgroundColor: "var(--ctp-surface1)",
color: "var(--ctp-text)",
fontSize: "0.8rem",
cursor: "pointer",
};
const formPaneStyle: React.CSSProperties = {
backgroundColor: "var(--ctp-surface0)",
borderRadius: "0.5rem",
marginBottom: "1rem",
overflow: "hidden",
};
const formHeaderStyle: React.CSSProperties = {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "0.5rem 1rem",
color: "var(--ctp-crust)",
fontSize: "0.9rem",
};
const formCloseStyle: React.CSSProperties = {
background: "none",
border: "none",
color: "inherit",
cursor: "pointer",
fontSize: "0.85rem",
fontWeight: 600,
};
const errorBannerStyle: React.CSSProperties = {
color: "var(--ctp-red)",
background: "rgba(243, 139, 168, 0.1)",
border: "1px solid rgba(243, 139, 168, 0.2)",
padding: "0.5rem 0.75rem",
borderRadius: "0.4rem",
marginBottom: "0.75rem",
fontSize: "0.85rem",
};
const fieldStyle: React.CSSProperties = {
marginBottom: "0.75rem",
};
const labelStyle: React.CSSProperties = {
display: "block",
marginBottom: "0.35rem",
fontWeight: 500,
color: "var(--ctp-subtext1)",
fontSize: "0.85rem",
};
const inputStyle: React.CSSProperties = {
width: "100%",
padding: "0.5rem 0.75rem",
backgroundColor: "var(--ctp-base)",
border: "1px solid var(--ctp-surface1)",
borderRadius: "0.4rem",
color: "var(--ctp-text)",
fontSize: "0.9rem",
boxSizing: "border-box",
};
const tableContainerStyle: React.CSSProperties = {
backgroundColor: "var(--ctp-surface0)",
borderRadius: "0.75rem",
padding: "0.5rem",
overflowX: "auto",
};
const thStyle: React.CSSProperties = {
padding: "0.5rem 0.75rem",
textAlign: "left",
borderBottom: "1px solid var(--ctp-surface1)",
color: "var(--ctp-subtext1)",
fontWeight: 600,
fontSize: "0.8rem",
textTransform: "uppercase",
letterSpacing: "0.05em",
cursor: "pointer",
userSelect: "none",
};
const tdStyle: React.CSSProperties = {
padding: '0.75rem 1rem',
borderBottom: '1px solid var(--ctp-surface1)',
padding: "0.35rem 0.75rem",
borderBottom: "1px solid var(--ctp-surface1)",
fontSize: "0.85rem",
};

View File

@@ -1,70 +1,737 @@
import { useEffect, useState } from 'react';
import { get } from '../api/client';
import type { Schema } from '../api/types';
import { useEffect, useState, type FormEvent } from "react";
import { get, post, put, del } from "../api/client";
import { useAuth } from "../hooks/useAuth";
import type { Schema, SchemaSegment } from "../api/types";
interface EnumEditState {
schemaName: string;
segmentName: string;
code: string;
description: string;
mode: "add" | "edit" | "delete";
}
export function SchemasPage() {
const { user } = useAuth();
const isEditor = user?.role === "admin" || user?.role === "editor";
const [schemas, setSchemas] = useState<Schema[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [editState, setEditState] = useState<EnumEditState | null>(null);
const [formError, setFormError] = useState("");
const [submitting, setSubmitting] = useState(false);
const loadSchemas = async () => {
try {
const list = await get<Schema[]>("/api/schemas");
setSchemas(list.filter((s) => s.name));
setError(null);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load schemas");
} finally {
setLoading(false);
}
};
useEffect(() => {
get<Schema[]>('/api/schemas')
.then(setSchemas)
.catch((e: Error) => setError(e.message))
.finally(() => setLoading(false));
loadSchemas();
}, []);
if (loading) return <p style={{ color: 'var(--ctp-subtext0)' }}>Loading schemas...</p>;
if (error) return <p style={{ color: 'var(--ctp-red)' }}>Error: {error}</p>;
const toggleExpand = (key: string) => {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
};
const startAdd = (schemaName: string, segmentName: string) => {
setEditState({
schemaName,
segmentName,
code: "",
description: "",
mode: "add",
});
setFormError("");
};
const startEdit = (
schemaName: string,
segmentName: string,
code: string,
description: string,
) => {
setEditState({ schemaName, segmentName, code, description, mode: "edit" });
setFormError("");
};
const startDelete = (
schemaName: string,
segmentName: string,
code: string,
) => {
setEditState({
schemaName,
segmentName,
code,
description: "",
mode: "delete",
});
setFormError("");
};
const cancelEdit = () => {
setEditState(null);
setFormError("");
};
const handleAddValue = async (e: FormEvent) => {
e.preventDefault();
if (!editState) return;
setFormError("");
setSubmitting(true);
try {
await post(
`/api/schemas/${editState.schemaName}/segments/${editState.segmentName}/values`,
{ code: editState.code, description: editState.description },
);
cancelEdit();
await loadSchemas();
} catch (e) {
setFormError(e instanceof Error ? e.message : "Failed to add value");
} finally {
setSubmitting(false);
}
};
const handleUpdateValue = async (e: FormEvent) => {
e.preventDefault();
if (!editState) return;
setFormError("");
setSubmitting(true);
try {
await put(
`/api/schemas/${editState.schemaName}/segments/${editState.segmentName}/values/${editState.code}`,
{ description: editState.description },
);
cancelEdit();
await loadSchemas();
} catch (e) {
setFormError(e instanceof Error ? e.message : "Failed to update value");
} finally {
setSubmitting(false);
}
};
const handleDeleteValue = async () => {
if (!editState) return;
setSubmitting(true);
try {
await del(
`/api/schemas/${editState.schemaName}/segments/${editState.segmentName}/values/${editState.code}`,
);
cancelEdit();
await loadSchemas();
} catch (e) {
setFormError(e instanceof Error ? e.message : "Failed to delete value");
} finally {
setSubmitting(false);
}
};
if (loading)
return <p style={{ color: "var(--ctp-subtext0)" }}>Loading schemas...</p>;
if (error) return <p style={{ color: "var(--ctp-red)" }}>Error: {error}</p>;
return (
<div>
<h2 style={{ marginBottom: '1rem' }}>Schemas ({schemas.length})</h2>
<div style={{
backgroundColor: 'var(--ctp-surface0)',
borderRadius: '0.75rem',
padding: '1rem',
overflowX: 'auto',
}}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr>
<th style={thStyle}>Name</th>
<th style={thStyle}>Format</th>
<th style={thStyle}>Description</th>
<th style={thStyle}>Segments</th>
</tr>
</thead>
<tbody>
{schemas.map((s) => (
<tr key={s.name}>
<td style={{ ...tdStyle, fontFamily: "'JetBrains Mono', monospace", color: 'var(--ctp-peach)' }}>
{s.name}
</td>
<td style={{ ...tdStyle, fontFamily: "'JetBrains Mono', monospace" }}>{s.format}</td>
<td style={tdStyle}>{s.description}</td>
<td style={tdStyle}>{s.segments.length}</td>
</tr>
))}
</tbody>
</table>
</div>
<h2 style={{ marginBottom: "1rem" }}>
Part Numbering Schemas ({schemas.length})
</h2>
{schemas.length === 0 ? (
<div style={emptyStyle}>No schemas found.</div>
) : (
schemas.map((schema) => (
<SchemaCard
key={schema.name}
schema={schema}
expanded={expanded}
toggleExpand={toggleExpand}
isEditor={isEditor}
editState={editState}
formError={formError}
submitting={submitting}
onStartAdd={startAdd}
onStartEdit={startEdit}
onStartDelete={startDelete}
onCancelEdit={cancelEdit}
onAdd={handleAddValue}
onUpdate={handleUpdateValue}
onDelete={handleDeleteValue}
onEditStateChange={setEditState}
/>
))
)}
</div>
);
}
const thStyle: React.CSSProperties = {
padding: '0.75rem 1rem',
textAlign: 'left',
borderBottom: '1px solid var(--ctp-surface1)',
color: 'var(--ctp-subtext1)',
// --- Sub-components (local to this file) ---
interface SchemaCardProps {
schema: Schema;
expanded: Set<string>;
toggleExpand: (key: string) => void;
isEditor: boolean;
editState: EnumEditState | null;
formError: string;
submitting: boolean;
onStartAdd: (schemaName: string, segmentName: string) => void;
onStartEdit: (
schemaName: string,
segmentName: string,
code: string,
desc: string,
) => void;
onStartDelete: (
schemaName: string,
segmentName: string,
code: string,
) => void;
onCancelEdit: () => void;
onAdd: (e: FormEvent) => void;
onUpdate: (e: FormEvent) => void;
onDelete: () => void;
onEditStateChange: (state: EnumEditState | null) => void;
}
function SchemaCard({
schema,
expanded,
toggleExpand,
isEditor,
editState,
formError,
submitting,
onStartAdd,
onStartEdit,
onStartDelete,
onCancelEdit,
onAdd,
onUpdate,
onDelete,
onEditStateChange,
}: SchemaCardProps) {
const segKey = `seg-${schema.name}`;
const isExpanded = expanded.has(segKey);
return (
<div style={cardStyle}>
<h3 style={{ color: "var(--ctp-mauve)", marginBottom: "0.5rem" }}>
{schema.name}
</h3>
{schema.description && (
<p style={{ color: "var(--ctp-subtext0)", marginBottom: "1rem" }}>
{schema.description}
</p>
)}
<p style={{ marginBottom: "0.5rem" }}>
<strong>Format:</strong> <code style={codeStyle}>{schema.format}</code>
</p>
<p style={{ marginBottom: "1rem" }}>
<strong>Version:</strong> {schema.version}
</p>
{schema.examples && schema.examples.length > 0 && (
<>
<p style={{ marginBottom: "0.5rem" }}>
<strong>Examples:</strong>
</p>
<div
style={{
display: "flex",
gap: "0.5rem",
flexWrap: "wrap",
marginBottom: "1rem",
}}
>
{schema.examples.map((ex) => (
<span
key={ex}
style={{
...codeStyle,
fontFamily: "'JetBrains Mono', monospace",
}}
>
{ex}
</span>
))}
</div>
</>
)}
<div
onClick={() => toggleExpand(segKey)}
style={{
cursor: "pointer",
color: "var(--ctp-sapphire)",
userSelect: "none",
marginTop: "1rem",
}}
>
{isExpanded ? "\u25BC" : "\u25B6"} View Segments (
{schema.segments.length})
</div>
{isExpanded &&
schema.segments.map((seg) => (
<SegmentBlock
key={seg.name}
schemaName={schema.name}
segment={seg}
isEditor={isEditor}
editState={editState}
formError={formError}
submitting={submitting}
onStartAdd={onStartAdd}
onStartEdit={onStartEdit}
onStartDelete={onStartDelete}
onCancelEdit={onCancelEdit}
onAdd={onAdd}
onUpdate={onUpdate}
onDelete={onDelete}
onEditStateChange={onEditStateChange}
/>
))}
</div>
);
}
interface SegmentBlockProps {
schemaName: string;
segment: SchemaSegment;
isEditor: boolean;
editState: EnumEditState | null;
formError: string;
submitting: boolean;
onStartAdd: (schemaName: string, segmentName: string) => void;
onStartEdit: (
schemaName: string,
segmentName: string,
code: string,
desc: string,
) => void;
onStartDelete: (
schemaName: string,
segmentName: string,
code: string,
) => void;
onCancelEdit: () => void;
onAdd: (e: FormEvent) => void;
onUpdate: (e: FormEvent) => void;
onDelete: () => void;
onEditStateChange: (state: EnumEditState | null) => void;
}
function SegmentBlock({
schemaName,
segment,
isEditor,
editState,
formError,
submitting,
onStartAdd,
onStartEdit,
onStartDelete,
onCancelEdit,
onAdd,
onUpdate,
onDelete,
onEditStateChange,
}: SegmentBlockProps) {
const isThisSegment = (es: EnumEditState | null) =>
es !== null &&
es.schemaName === schemaName &&
es.segmentName === segment.name;
const entries = segment.values
? Object.entries(segment.values).sort((a, b) => a[0].localeCompare(b[0]))
: [];
return (
<div style={segmentStyle}>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "0.5rem",
}}
>
<h4 style={{ margin: 0, color: "var(--ctp-blue)" }}>{segment.name}</h4>
<span style={typeBadgeStyle}>{segment.type}</span>
</div>
{segment.description && (
<p
style={{
color: "var(--ctp-subtext0)",
marginBottom: "0.5rem",
fontSize: "0.85rem",
}}
>
{segment.description}
</p>
)}
{segment.type === "enum" && entries.length > 0 && (
<div style={{ marginTop: "0.5rem", overflowX: "auto" }}>
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead>
<tr>
<th style={thStyle}>Code</th>
<th style={thStyle}>Description</th>
{isEditor && (
<th style={{ ...thStyle, width: 120 }}>Actions</th>
)}
</tr>
</thead>
<tbody>
{entries.map(([code, desc]) => {
const isEditingThis =
isThisSegment(editState) &&
editState!.code === code &&
editState!.mode === "edit";
const isDeletingThis =
isThisSegment(editState) &&
editState!.code === code &&
editState!.mode === "delete";
if (isEditingThis) {
return (
<tr key={code}>
<td style={tdStyle}>
<code style={{ fontSize: "0.85rem" }}>{code}</code>
</td>
<td style={tdStyle}>
<form
onSubmit={onUpdate}
style={{
display: "flex",
gap: "0.5rem",
alignItems: "center",
}}
>
<input
type="text"
value={editState!.description}
onChange={(e) =>
onEditStateChange({
...editState!,
description: e.target.value,
})
}
required
style={inlineInputStyle}
autoFocus
/>
<button
type="submit"
disabled={submitting}
style={btnTinyPrimaryStyle}
>
Save
</button>
<button
type="button"
onClick={onCancelEdit}
style={btnTinyStyle}
>
Cancel
</button>
</form>
{formError && (
<div
style={{
color: "var(--ctp-red)",
fontSize: "0.75rem",
marginTop: "0.25rem",
}}
>
{formError}
</div>
)}
</td>
{isEditor && <td style={tdStyle} />}
</tr>
);
}
if (isDeletingThis) {
return (
<tr
key={code}
style={{ backgroundColor: "rgba(243, 139, 168, 0.1)" }}
>
<td style={tdStyle}>
<code style={{ fontSize: "0.85rem" }}>{code}</code>
</td>
<td style={tdStyle}>
<span
style={{
color: "var(--ctp-red)",
fontSize: "0.85rem",
}}
>
Delete this value?
</span>
{formError && (
<div
style={{
color: "var(--ctp-red)",
fontSize: "0.75rem",
marginTop: "0.25rem",
}}
>
{formError}
</div>
)}
</td>
<td style={tdStyle}>
<div style={{ display: "flex", gap: "0.25rem" }}>
<button
onClick={onDelete}
disabled={submitting}
style={{
...btnTinyStyle,
backgroundColor: "var(--ctp-red)",
color: "var(--ctp-crust)",
}}
>
{submitting ? "..." : "Delete"}
</button>
<button onClick={onCancelEdit} style={btnTinyStyle}>
Cancel
</button>
</div>
</td>
</tr>
);
}
return (
<tr key={code}>
<td style={tdStyle}>
<code style={{ fontSize: "0.85rem" }}>{code}</code>
</td>
<td style={tdStyle}>{desc}</td>
{isEditor && (
<td style={tdStyle}>
<div style={{ display: "flex", gap: "0.25rem" }}>
<button
onClick={() =>
onStartEdit(schemaName, segment.name, code, desc)
}
style={btnTinyStyle}
>
Edit
</button>
<button
onClick={() =>
onStartDelete(schemaName, segment.name, code)
}
style={{
...btnTinyStyle,
backgroundColor: "var(--ctp-surface2)",
}}
>
Delete
</button>
</div>
</td>
)}
</tr>
);
})}
{/* Add row */}
{isThisSegment(editState) && editState!.mode === "add" && (
<tr>
<td style={tdStyle}>
<input
type="text"
value={editState!.code}
onChange={(e) =>
onEditStateChange({
...editState!,
code: e.target.value,
})
}
placeholder="Code"
required
style={inlineInputStyle}
autoFocus
/>
</td>
<td style={tdStyle}>
<form
onSubmit={onAdd}
style={{
display: "flex",
gap: "0.5rem",
alignItems: "center",
}}
>
<input
type="text"
value={editState!.description}
onChange={(e) =>
onEditStateChange({
...editState!,
description: e.target.value,
})
}
placeholder="Description"
required
style={inlineInputStyle}
/>
<button
type="submit"
disabled={submitting}
style={btnTinyPrimaryStyle}
>
Add
</button>
<button
type="button"
onClick={onCancelEdit}
style={btnTinyStyle}
>
Cancel
</button>
</form>
{formError && (
<div
style={{
color: "var(--ctp-red)",
fontSize: "0.75rem",
marginTop: "0.25rem",
}}
>
{formError}
</div>
)}
</td>
{isEditor && <td style={tdStyle} />}
</tr>
)}
</tbody>
</table>
</div>
)}
{segment.type === "enum" &&
isEditor &&
!(isThisSegment(editState) && editState!.mode === "add") && (
<button
onClick={() => onStartAdd(schemaName, segment.name)}
style={{ ...btnTinyPrimaryStyle, marginTop: "0.5rem" }}
>
+ Add Value
</button>
)}
</div>
);
}
// --- Styles ---
const cardStyle: React.CSSProperties = {
backgroundColor: "var(--ctp-surface0)",
borderRadius: "0.75rem",
padding: "1.25rem",
marginBottom: "1rem",
};
const codeStyle: React.CSSProperties = {
background: "var(--ctp-surface1)",
padding: "0.25rem 0.5rem",
borderRadius: "0.25rem",
fontSize: "0.85rem",
};
const segmentStyle: React.CSSProperties = {
marginTop: "1rem",
padding: "1rem",
background: "var(--ctp-base)",
borderRadius: "0.5rem",
};
const typeBadgeStyle: React.CSSProperties = {
display: "inline-block",
padding: "0.15rem 0.5rem",
borderRadius: "0.25rem",
fontSize: "0.75rem",
fontWeight: 600,
fontSize: '0.85rem',
textTransform: 'uppercase',
letterSpacing: '0.05em',
backgroundColor: "rgba(166, 227, 161, 0.15)",
color: "var(--ctp-green)",
};
const emptyStyle: React.CSSProperties = {
textAlign: "center",
padding: "2rem",
color: "var(--ctp-subtext0)",
};
const thStyle: React.CSSProperties = {
padding: "0.4rem 0.75rem",
textAlign: "left",
borderBottom: "1px solid var(--ctp-surface1)",
color: "var(--ctp-subtext1)",
fontWeight: 600,
fontSize: "0.8rem",
textTransform: "uppercase",
letterSpacing: "0.05em",
};
const tdStyle: React.CSSProperties = {
padding: '0.75rem 1rem',
borderBottom: '1px solid var(--ctp-surface1)',
padding: "0.3rem 0.75rem",
borderBottom: "1px solid var(--ctp-surface1)",
fontSize: "0.85rem",
};
const btnTinyStyle: React.CSSProperties = {
padding: "0.2rem 0.5rem",
borderRadius: "0.25rem",
border: "none",
backgroundColor: "var(--ctp-surface1)",
color: "var(--ctp-text)",
fontSize: "0.75rem",
cursor: "pointer",
};
const btnTinyPrimaryStyle: React.CSSProperties = {
padding: "0.2rem 0.5rem",
borderRadius: "0.25rem",
border: "none",
backgroundColor: "var(--ctp-mauve)",
color: "var(--ctp-crust)",
fontSize: "0.75rem",
fontWeight: 600,
cursor: "pointer",
};
const inlineInputStyle: React.CSSProperties = {
padding: "0.25rem 0.5rem",
backgroundColor: "var(--ctp-surface0)",
border: "1px solid var(--ctp-surface1)",
borderRadius: "0.25rem",
color: "var(--ctp-text)",
fontSize: "0.85rem",
width: "100%",
boxSizing: "border-box",
};

View File

@@ -1,28 +1,488 @@
import { useAuth } from '../hooks/useAuth';
import { useEffect, useState, type FormEvent } from "react";
import { get, post, del } from "../api/client";
import { useAuth } from "../hooks/useAuth";
import type { ApiToken, ApiTokenCreated } from "../api/types";
export function SettingsPage() {
const { user } = useAuth();
const [tokens, setTokens] = useState<ApiToken[]>([]);
const [tokensLoading, setTokensLoading] = useState(true);
const [tokensError, setTokensError] = useState<string | null>(null);
// Create token form
const [tokenName, setTokenName] = useState("");
const [creating, setCreating] = useState(false);
const [createError, setCreateError] = useState("");
// Newly created token (show once)
const [newToken, setNewToken] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
// Revoke confirmation
const [revoking, setRevoking] = useState<string | null>(null);
const loadTokens = async () => {
try {
const list = await get<ApiToken[]>("/api/auth/tokens");
setTokens(list);
setTokensError(null);
} catch (e) {
setTokensError(e instanceof Error ? e.message : "Failed to load tokens");
} finally {
setTokensLoading(false);
}
};
useEffect(() => {
loadTokens();
}, []);
const handleCreateToken = async (e: FormEvent) => {
e.preventDefault();
if (!tokenName.trim()) return;
setCreateError("");
setCreating(true);
try {
const result = await post<ApiTokenCreated>("/api/auth/tokens", {
name: tokenName.trim(),
});
setNewToken(result.token);
setCopied(false);
setTokenName("");
await loadTokens();
} catch (e) {
setCreateError(e instanceof Error ? e.message : "Failed to create token");
} finally {
setCreating(false);
}
};
const handleRevoke = async (id: string) => {
try {
await del(`/api/auth/tokens/${id}`);
setRevoking(null);
await loadTokens();
} catch {
// silently fail — token may already be revoked
}
};
const copyToken = async () => {
if (!newToken) return;
try {
await navigator.clipboard.writeText(newToken);
setCopied(true);
} catch {
// fallback: select the text
}
};
const formatDate = (s?: string) => {
if (!s) return "Never";
const d = new Date(s);
return (
d.toLocaleDateString() +
" " +
d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
);
};
return (
<div>
<h2 style={{ marginBottom: '1rem' }}>Settings</h2>
<div style={{
backgroundColor: 'var(--ctp-surface0)',
borderRadius: '0.75rem',
padding: '1.5rem',
}}>
<h2 style={{ marginBottom: "1rem" }}>Settings</h2>
{/* Account Card */}
<div style={cardStyle}>
<h3 style={cardTitleStyle}>Account</h3>
{user ? (
<>
<p>Username: <strong>{user.username}</strong></p>
<p>Display name: <strong>{user.display_name}</strong></p>
<p>Email: <strong>{user.email}</strong></p>
<p>Role: <strong>{user.role}</strong></p>
<p>Auth source: <strong>{user.auth_source}</strong></p>
</>
<dl style={dlStyle}>
<dt style={dtStyle}>Username</dt>
<dd style={ddStyle}>{user.username}</dd>
<dt style={dtStyle}>Display Name</dt>
<dd style={ddStyle}>
{user.display_name || <span style={mutedStyle}>Not set</span>}
</dd>
<dt style={dtStyle}>Email</dt>
<dd style={ddStyle}>
{user.email || <span style={mutedStyle}>Not set</span>}
</dd>
<dt style={dtStyle}>Auth Source</dt>
<dd style={ddStyle}>{user.auth_source}</dd>
<dt style={dtStyle}>Role</dt>
<dd style={ddStyle}>
<span
style={{
display: "inline-block",
padding: "0.15rem 0.5rem",
borderRadius: "1rem",
fontSize: "0.8rem",
fontWeight: 600,
...roleBadgeStyles[user.role],
}}
>
{user.role}
</span>
</dd>
</dl>
) : (
<p style={{ color: 'var(--ctp-subtext0)' }}>Not logged in</p>
<p style={mutedStyle}>Not logged in</p>
)}
</div>
{/* API Tokens Card */}
<div style={cardStyle}>
<h3 style={cardTitleStyle}>API Tokens</h3>
<p
style={{
color: "var(--ctp-subtext0)",
marginBottom: "1.25rem",
fontSize: "0.9rem",
}}
>
API tokens allow the FreeCAD plugin and scripts to authenticate with
Silo. Tokens inherit your role permissions.
</p>
{/* New token banner */}
{newToken && (
<div style={newTokenBannerStyle}>
<p
style={{
color: "var(--ctp-green)",
fontWeight: 600,
marginBottom: "0.5rem",
}}
>
Your new API token (copy it now it won't be shown again):
</p>
<code style={tokenDisplayStyle}>{newToken}</code>
<div
style={{
display: "flex",
gap: "0.5rem",
alignItems: "center",
marginTop: "0.5rem",
}}
>
<button onClick={copyToken} style={btnCopyStyle}>
{copied ? "Copied!" : "Copy to clipboard"}
</button>
<button onClick={() => setNewToken(null)} style={btnDismissStyle}>
Dismiss
</button>
</div>
<p
style={{
color: "var(--ctp-subtext0)",
fontSize: "0.85rem",
marginTop: "0.5rem",
}}
>
Store this token securely. You will not be able to see it again.
</p>
</div>
)}
{/* Create token form */}
<form onSubmit={handleCreateToken} style={createFormStyle}>
<div style={{ flex: 1, minWidth: 200 }}>
<label style={labelStyle}>Token Name</label>
<input
type="text"
value={tokenName}
onChange={(e) => setTokenName(e.target.value)}
placeholder="e.g., FreeCAD workstation"
required
style={inputStyle}
/>
</div>
<button
type="submit"
disabled={creating}
style={{ ...btnPrimaryStyle, alignSelf: "flex-end" }}
>
{creating ? "Creating..." : "Create Token"}
</button>
</form>
{createError && <div style={errorStyle}>{createError}</div>}
{/* Token list */}
{tokensLoading ? (
<p style={mutedStyle}>Loading tokens...</p>
) : tokensError ? (
<p style={{ color: "var(--ctp-red)", fontSize: "0.85rem" }}>
{tokensError}
</p>
) : (
<div style={{ overflowX: "auto", marginTop: "1rem" }}>
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead>
<tr>
<th style={thStyle}>Name</th>
<th style={thStyle}>Prefix</th>
<th style={thStyle}>Created</th>
<th style={thStyle}>Last Used</th>
<th style={thStyle}>Expires</th>
<th style={thStyle}>Actions</th>
</tr>
</thead>
<tbody>
{tokens.length === 0 ? (
<tr>
<td
colSpan={6}
style={{
...tdStyle,
textAlign: "center",
padding: "2rem",
color: "var(--ctp-subtext0)",
}}
>
No API tokens yet. Create one to get started.
</td>
</tr>
) : (
tokens.map((t) => (
<tr key={t.id}>
<td style={tdStyle}>{t.name}</td>
<td style={tdStyle}>
<span
style={{
fontFamily: "'JetBrains Mono', monospace",
color: "var(--ctp-peach)",
}}
>
{t.token_prefix}...
</span>
</td>
<td style={tdStyle}>{formatDate(t.created_at)}</td>
<td style={tdStyle}>
{t.last_used_at ? (
formatDate(t.last_used_at)
) : (
<span style={mutedStyle}>Never</span>
)}
</td>
<td style={tdStyle}>
{t.expires_at ? (
formatDate(t.expires_at)
) : (
<span style={mutedStyle}>Never</span>
)}
</td>
<td style={tdStyle}>
{revoking === t.id ? (
<div
style={{
display: "flex",
gap: "0.25rem",
alignItems: "center",
}}
>
<button
onClick={() => handleRevoke(t.id)}
style={btnRevokeConfirmStyle}
>
Confirm
</button>
<button
onClick={() => setRevoking(null)}
style={btnTinyStyle}
>
Cancel
</button>
</div>
) : (
<button
onClick={() => setRevoking(t.id)}
style={btnDangerStyle}
>
Revoke
</button>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}
// --- Styles ---
const roleBadgeStyles: Record<string, React.CSSProperties> = {
admin: { background: "rgba(203, 166, 247, 0.2)", color: "var(--ctp-mauve)" },
editor: { background: "rgba(137, 180, 250, 0.2)", color: "var(--ctp-blue)" },
viewer: { background: "rgba(148, 226, 213, 0.2)", color: "var(--ctp-teal)" },
};
const cardStyle: React.CSSProperties = {
backgroundColor: "var(--ctp-surface0)",
borderRadius: "0.75rem",
padding: "1.5rem",
marginBottom: "1.5rem",
};
const cardTitleStyle: React.CSSProperties = {
marginBottom: "1rem",
fontSize: "1.1rem",
};
const dlStyle: React.CSSProperties = {
display: "grid",
gridTemplateColumns: "auto 1fr",
gap: "0.5rem 1.5rem",
};
const dtStyle: React.CSSProperties = {
color: "var(--ctp-subtext0)",
fontWeight: 500,
fontSize: "0.9rem",
};
const ddStyle: React.CSSProperties = {
margin: 0,
fontSize: "0.9rem",
};
const mutedStyle: React.CSSProperties = {
color: "var(--ctp-overlay0)",
};
const newTokenBannerStyle: React.CSSProperties = {
background: "rgba(166, 227, 161, 0.1)",
border: "1px solid rgba(166, 227, 161, 0.3)",
borderRadius: "0.75rem",
padding: "1.25rem",
marginBottom: "1.5rem",
};
const tokenDisplayStyle: React.CSSProperties = {
display: "block",
padding: "0.75rem 1rem",
background: "var(--ctp-base)",
border: "1px solid var(--ctp-surface1)",
borderRadius: "0.5rem",
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
fontSize: "0.85rem",
color: "var(--ctp-peach)",
wordBreak: "break-all",
};
const createFormStyle: React.CSSProperties = {
display: "flex",
gap: "0.75rem",
alignItems: "flex-end",
flexWrap: "wrap",
marginBottom: "0.5rem",
};
const labelStyle: React.CSSProperties = {
display: "block",
marginBottom: "0.35rem",
fontWeight: 500,
color: "var(--ctp-subtext1)",
fontSize: "0.85rem",
};
const inputStyle: React.CSSProperties = {
width: "100%",
padding: "0.5rem 0.75rem",
backgroundColor: "var(--ctp-base)",
border: "1px solid var(--ctp-surface1)",
borderRadius: "0.4rem",
color: "var(--ctp-text)",
fontSize: "0.9rem",
boxSizing: "border-box",
};
const btnPrimaryStyle: React.CSSProperties = {
padding: "0.5rem 1rem",
borderRadius: "0.4rem",
border: "none",
backgroundColor: "var(--ctp-mauve)",
color: "var(--ctp-crust)",
fontWeight: 600,
fontSize: "0.85rem",
cursor: "pointer",
whiteSpace: "nowrap",
};
const btnCopyStyle: React.CSSProperties = {
padding: "0.4rem 0.75rem",
background: "var(--ctp-surface1)",
border: "none",
borderRadius: "0.4rem",
color: "var(--ctp-text)",
cursor: "pointer",
fontSize: "0.85rem",
};
const btnDismissStyle: React.CSSProperties = {
padding: "0.4rem 0.75rem",
background: "none",
border: "none",
color: "var(--ctp-subtext0)",
cursor: "pointer",
fontSize: "0.85rem",
};
const btnDangerStyle: React.CSSProperties = {
background: "rgba(243, 139, 168, 0.15)",
color: "var(--ctp-red)",
border: "none",
padding: "0.3rem 0.6rem",
borderRadius: "0.3rem",
cursor: "pointer",
fontSize: "0.8rem",
};
const btnRevokeConfirmStyle: React.CSSProperties = {
background: "var(--ctp-red)",
color: "var(--ctp-crust)",
border: "none",
padding: "0.2rem 0.5rem",
borderRadius: "0.25rem",
cursor: "pointer",
fontSize: "0.75rem",
fontWeight: 600,
};
const btnTinyStyle: React.CSSProperties = {
padding: "0.2rem 0.5rem",
borderRadius: "0.25rem",
border: "none",
backgroundColor: "var(--ctp-surface1)",
color: "var(--ctp-text)",
fontSize: "0.75rem",
cursor: "pointer",
};
const errorStyle: React.CSSProperties = {
color: "var(--ctp-red)",
fontSize: "0.85rem",
marginTop: "0.25rem",
};
const thStyle: React.CSSProperties = {
padding: "0.5rem 0.75rem",
textAlign: "left",
borderBottom: "1px solid var(--ctp-surface1)",
color: "var(--ctp-subtext1)",
fontWeight: 600,
fontSize: "0.8rem",
textTransform: "uppercase",
letterSpacing: "0.05em",
};
const tdStyle: React.CSSProperties = {
padding: "0.4rem 0.75rem",
borderBottom: "1px solid var(--ctp-surface1)",
fontSize: "0.85rem",
};

View File

@@ -8,7 +8,8 @@
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"composite": true,
"emitDeclarationOnly": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,