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
71
web/assets/silo-auth.svg
Normal 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
@@ -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 |
77
web/assets/silo-commit.svg
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 |
@@ -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" });
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
222
web/src/components/TagInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"composite": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
|
||||