feat(web): install lucide-react and replace unicode icons (#67)
This commit is contained in:
10
web/package-lock.json
generated
10
web/package-lock.json
generated
@@ -8,6 +8,7 @@
|
|||||||
"name": "silo-web",
|
"name": "silo-web",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"lucide-react": "^0.564.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-router-dom": "^7.0.0"
|
"react-router-dom": "^7.0.0"
|
||||||
@@ -1499,6 +1500,15 @@
|
|||||||
"yallist": "^3.0.2"
|
"yallist": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lucide-react": {
|
||||||
|
"version": "0.564.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.564.0.tgz",
|
||||||
|
"integrity": "sha512-JJ8GVTQqFwuliifD48U6+h7DXEHdkhJ/E87kksGByII3qHxtPciVb8T8woQONHBQgHVOl7rSMrrip3SeVNy7Fg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"lucide-react": "^0.564.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-router-dom": "^7.0.0"
|
"react-router-dom": "^7.0.0"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from "react";
|
||||||
|
import { Check } from "lucide-react";
|
||||||
|
|
||||||
export interface ContextMenuItem {
|
export interface ContextMenuItem {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -24,76 +25,95 @@ export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
|
|||||||
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
|
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
|
||||||
};
|
};
|
||||||
const handleKey = (e: KeyboardEvent) => {
|
const handleKey = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') onClose();
|
if (e.key === "Escape") onClose();
|
||||||
};
|
};
|
||||||
const handleScroll = () => onClose();
|
const handleScroll = () => onClose();
|
||||||
|
|
||||||
document.addEventListener('mousedown', handleClick);
|
document.addEventListener("mousedown", handleClick);
|
||||||
document.addEventListener('keydown', handleKey);
|
document.addEventListener("keydown", handleKey);
|
||||||
window.addEventListener('scroll', handleScroll, true);
|
window.addEventListener("scroll", handleScroll, true);
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('mousedown', handleClick);
|
document.removeEventListener("mousedown", handleClick);
|
||||||
document.removeEventListener('keydown', handleKey);
|
document.removeEventListener("keydown", handleKey);
|
||||||
window.removeEventListener('scroll', handleScroll, true);
|
window.removeEventListener("scroll", handleScroll, true);
|
||||||
};
|
};
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
// Clamp position to viewport
|
// Clamp position to viewport
|
||||||
const style: React.CSSProperties = {
|
const style: React.CSSProperties = {
|
||||||
position: 'fixed',
|
position: "fixed",
|
||||||
left: Math.min(x, window.innerWidth - 220),
|
left: Math.min(x, window.innerWidth - 220),
|
||||||
top: Math.min(y, window.innerHeight - items.length * 32 - 16),
|
top: Math.min(y, window.innerHeight - items.length * 32 - 16),
|
||||||
zIndex: 9999,
|
zIndex: 9999,
|
||||||
backgroundColor: 'var(--ctp-surface0)',
|
backgroundColor: "var(--ctp-surface0)",
|
||||||
border: '1px solid var(--ctp-surface1)',
|
border: "1px solid var(--ctp-surface1)",
|
||||||
borderRadius: '0.5rem',
|
borderRadius: "0.5rem",
|
||||||
padding: '0.25rem 0',
|
padding: "0.25rem 0",
|
||||||
minWidth: 200,
|
minWidth: 200,
|
||||||
boxShadow: '0 4px 12px rgba(0,0,0,0.4)',
|
boxShadow: "0 4px 12px rgba(0,0,0,0.4)",
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref} style={style}>
|
<div ref={ref} style={style}>
|
||||||
{items.map((item, i) =>
|
{items.map((item, i) =>
|
||||||
item.divider ? (
|
item.divider ? (
|
||||||
<div key={i} style={{ borderTop: '1px solid var(--ctp-surface1)', margin: '0.25rem 0' }} />
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
borderTop: "1px solid var(--ctp-surface1)",
|
||||||
|
margin: "0.25rem 0",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
key={i}
|
key={i}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (item.onToggle) item.onToggle();
|
if (item.onToggle) item.onToggle();
|
||||||
else if (item.onClick) { item.onClick(); onClose(); }
|
else if (item.onClick) {
|
||||||
|
item.onClick();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
disabled={item.disabled}
|
disabled={item.disabled}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
gap: '0.5rem',
|
gap: "0.5rem",
|
||||||
width: '100%',
|
width: "100%",
|
||||||
padding: '0.35rem 0.75rem',
|
padding: "0.35rem 0.75rem",
|
||||||
background: 'none',
|
background: "none",
|
||||||
border: 'none',
|
border: "none",
|
||||||
color: item.disabled ? 'var(--ctp-overlay0)' : 'var(--ctp-text)',
|
color: item.disabled ? "var(--ctp-overlay0)" : "var(--ctp-text)",
|
||||||
fontSize: '0.85rem',
|
fontSize: "0.85rem",
|
||||||
cursor: item.disabled ? 'default' : 'pointer',
|
cursor: item.disabled ? "default" : "pointer",
|
||||||
textAlign: 'left',
|
textAlign: "left",
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
if (!item.disabled) e.currentTarget.style.backgroundColor = 'var(--ctp-surface1)';
|
if (!item.disabled)
|
||||||
|
e.currentTarget.style.backgroundColor = "var(--ctp-surface1)";
|
||||||
}}
|
}}
|
||||||
onMouseLeave={(e) => {
|
onMouseLeave={(e) => {
|
||||||
e.currentTarget.style.backgroundColor = 'transparent';
|
e.currentTarget.style.backgroundColor = "transparent";
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{item.checked !== undefined && (
|
{item.checked !== undefined && (
|
||||||
<span style={{
|
<span
|
||||||
width: 16, height: 16, display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
style={{
|
||||||
border: '1px solid var(--ctp-overlay0)', borderRadius: 3,
|
width: 16,
|
||||||
backgroundColor: item.checked ? 'var(--ctp-mauve)' : 'transparent',
|
height: 16,
|
||||||
color: item.checked ? 'var(--ctp-crust)' : 'transparent',
|
display: "inline-flex",
|
||||||
fontSize: '0.7rem', fontWeight: 700, flexShrink: 0,
|
alignItems: "center",
|
||||||
}}>
|
justifyContent: "center",
|
||||||
{item.checked ? '✓' : ''}
|
border: "1px solid var(--ctp-overlay0)",
|
||||||
|
borderRadius: 3,
|
||||||
|
backgroundColor: item.checked
|
||||||
|
? "var(--ctp-mauve)"
|
||||||
|
: "transparent",
|
||||||
|
color: item.checked ? "var(--ctp-crust)" : "transparent",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.checked ? <Check size={14} /> : ""}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{item.label}
|
{item.label}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
import { useState, useRef, useEffect, useCallback } from "react";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
|
||||||
export interface TagOption {
|
export interface TagOption {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -12,34 +13,45 @@ interface TagInputProps {
|
|||||||
searchFn: (query: string) => Promise<TagOption[]>;
|
searchFn: (query: string) => Promise<TagOption[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TagInput({ value, onChange, placeholder, searchFn }: TagInputProps) {
|
export function TagInput({
|
||||||
const [query, setQuery] = useState('');
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
searchFn,
|
||||||
|
}: TagInputProps) {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
const [results, setResults] = useState<TagOption[]>([]);
|
const [results, setResults] = useState<TagOption[]>([]);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [highlighted, setHighlighted] = useState(0);
|
const [highlighted, setHighlighted] = useState(0);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | undefined>(
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
// Debounced search
|
// Debounced search
|
||||||
const search = useCallback(
|
const search = useCallback(
|
||||||
(q: string) => {
|
(q: string) => {
|
||||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
if (q.trim() === '') {
|
if (q.trim() === "") {
|
||||||
// Show all results when input is empty but focused
|
// Show all results when input is empty but focused
|
||||||
debounceRef.current = setTimeout(() => {
|
debounceRef.current = setTimeout(() => {
|
||||||
searchFn('').then((opts) => {
|
searchFn("")
|
||||||
setResults(opts.filter((o) => !value.includes(o.id)));
|
.then((opts) => {
|
||||||
setHighlighted(0);
|
setResults(opts.filter((o) => !value.includes(o.id)));
|
||||||
}).catch(() => setResults([]));
|
setHighlighted(0);
|
||||||
|
})
|
||||||
|
.catch(() => setResults([]));
|
||||||
}, 100);
|
}, 100);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
debounceRef.current = setTimeout(() => {
|
debounceRef.current = setTimeout(() => {
|
||||||
searchFn(q).then((opts) => {
|
searchFn(q)
|
||||||
setResults(opts.filter((o) => !value.includes(o.id)));
|
.then((opts) => {
|
||||||
setHighlighted(0);
|
setResults(opts.filter((o) => !value.includes(o.id)));
|
||||||
}).catch(() => setResults([]));
|
setHighlighted(0);
|
||||||
|
})
|
||||||
|
.catch(() => setResults([]));
|
||||||
}, 200);
|
}, 200);
|
||||||
},
|
},
|
||||||
[searchFn, value],
|
[searchFn, value],
|
||||||
@@ -53,17 +65,20 @@ export function TagInput({ value, onChange, placeholder, searchFn }: TagInputPro
|
|||||||
// Close on click outside
|
// Close on click outside
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (e: MouseEvent) => {
|
const handler = (e: MouseEvent) => {
|
||||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
if (
|
||||||
|
containerRef.current &&
|
||||||
|
!containerRef.current.contains(e.target as Node)
|
||||||
|
) {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
document.addEventListener('mousedown', handler);
|
document.addEventListener("mousedown", handler);
|
||||||
return () => document.removeEventListener('mousedown', handler);
|
return () => document.removeEventListener("mousedown", handler);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const select = (id: string) => {
|
const select = (id: string) => {
|
||||||
onChange([...value, id]);
|
onChange([...value, id]);
|
||||||
setQuery('');
|
setQuery("");
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
inputRef.current?.focus();
|
inputRef.current?.focus();
|
||||||
};
|
};
|
||||||
@@ -73,22 +88,22 @@ export function TagInput({ value, onChange, placeholder, searchFn }: TagInputPro
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
if (e.key === 'Backspace' && query === '' && value.length > 0) {
|
if (e.key === "Backspace" && query === "" && value.length > 0) {
|
||||||
onChange(value.slice(0, -1));
|
onChange(value.slice(0, -1));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.key === 'Escape') {
|
if (e.key === "Escape") {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!open || results.length === 0) return;
|
if (!open || results.length === 0) return;
|
||||||
if (e.key === 'ArrowDown') {
|
if (e.key === "ArrowDown") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setHighlighted((h) => (h + 1) % results.length);
|
setHighlighted((h) => (h + 1) % results.length);
|
||||||
} else if (e.key === 'ArrowUp') {
|
} else if (e.key === "ArrowUp") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setHighlighted((h) => (h - 1 + results.length) % results.length);
|
setHighlighted((h) => (h - 1 + results.length) % results.length);
|
||||||
} else if (e.key === 'Enter') {
|
} else if (e.key === "Enter") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (results[highlighted]) select(results[highlighted].id);
|
if (results[highlighted]) select(results[highlighted].id);
|
||||||
}
|
}
|
||||||
@@ -99,19 +114,19 @@ export function TagInput({ value, onChange, placeholder, searchFn }: TagInputPro
|
|||||||
for (const r of results) labelMap.current.set(r.id, r.label);
|
for (const r of results) labelMap.current.set(r.id, r.label);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} style={{ position: 'relative' }}>
|
<div ref={containerRef} style={{ position: "relative" }}>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
flexWrap: 'wrap',
|
flexWrap: "wrap",
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
gap: '0.25rem',
|
gap: "0.25rem",
|
||||||
padding: '0.25rem 0.5rem',
|
padding: "0.25rem 0.5rem",
|
||||||
backgroundColor: 'var(--ctp-base)',
|
backgroundColor: "var(--ctp-base)",
|
||||||
border: '1px solid var(--ctp-surface1)',
|
border: "1px solid var(--ctp-surface1)",
|
||||||
borderRadius: '0.3rem',
|
borderRadius: "0.3rem",
|
||||||
cursor: 'text',
|
cursor: "text",
|
||||||
minHeight: '1.8rem',
|
minHeight: "1.8rem",
|
||||||
}}
|
}}
|
||||||
onClick={() => inputRef.current?.focus()}
|
onClick={() => inputRef.current?.focus()}
|
||||||
>
|
>
|
||||||
@@ -119,14 +134,14 @@ export function TagInput({ value, onChange, placeholder, searchFn }: TagInputPro
|
|||||||
<span
|
<span
|
||||||
key={id}
|
key={id}
|
||||||
style={{
|
style={{
|
||||||
display: 'inline-flex',
|
display: "inline-flex",
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
gap: '0.25rem',
|
gap: "0.25rem",
|
||||||
padding: '0.1rem 0.5rem',
|
padding: "0.1rem 0.5rem",
|
||||||
borderRadius: '1rem',
|
borderRadius: "1rem",
|
||||||
backgroundColor: 'rgba(203,166,247,0.15)',
|
backgroundColor: "rgba(203,166,247,0.15)",
|
||||||
color: 'var(--ctp-mauve)',
|
color: "var(--ctp-mauve)",
|
||||||
fontSize: '0.75rem',
|
fontSize: "0.75rem",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{labelMap.current.get(id) ?? id}
|
{labelMap.current.get(id) ?? id}
|
||||||
@@ -137,16 +152,16 @@ export function TagInput({ value, onChange, placeholder, searchFn }: TagInputPro
|
|||||||
remove(id);
|
remove(id);
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
background: 'none',
|
background: "none",
|
||||||
border: 'none',
|
border: "none",
|
||||||
cursor: 'pointer',
|
cursor: "pointer",
|
||||||
color: 'var(--ctp-mauve)',
|
color: "var(--ctp-mauve)",
|
||||||
padding: 0,
|
padding: 0,
|
||||||
fontSize: '0.8rem',
|
|
||||||
lineHeight: 1,
|
lineHeight: 1,
|
||||||
|
display: "inline-flex",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
×
|
<X size={14} />
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
@@ -166,30 +181,30 @@ export function TagInput({ value, onChange, placeholder, searchFn }: TagInputPro
|
|||||||
placeholder={value.length === 0 ? placeholder : undefined}
|
placeholder={value.length === 0 ? placeholder : undefined}
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: '4rem',
|
minWidth: "4rem",
|
||||||
border: 'none',
|
border: "none",
|
||||||
outline: 'none',
|
outline: "none",
|
||||||
background: 'transparent',
|
background: "transparent",
|
||||||
color: 'var(--ctp-text)',
|
color: "var(--ctp-text)",
|
||||||
fontSize: '0.85rem',
|
fontSize: "0.85rem",
|
||||||
padding: '0.1rem 0',
|
padding: "0.1rem 0",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{open && results.length > 0 && (
|
{open && results.length > 0 && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: "absolute",
|
||||||
top: '100%',
|
top: "100%",
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
zIndex: 10,
|
zIndex: 10,
|
||||||
marginTop: '0.2rem',
|
marginTop: "0.2rem",
|
||||||
backgroundColor: 'var(--ctp-surface0)',
|
backgroundColor: "var(--ctp-surface0)",
|
||||||
border: '1px solid var(--ctp-surface1)',
|
border: "1px solid var(--ctp-surface1)",
|
||||||
borderRadius: '0.3rem',
|
borderRadius: "0.3rem",
|
||||||
maxHeight: '160px',
|
maxHeight: "160px",
|
||||||
overflowY: 'auto',
|
overflowY: "auto",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{results.map((opt, i) => (
|
{results.map((opt, i) => (
|
||||||
@@ -201,15 +216,15 @@ export function TagInput({ value, onChange, placeholder, searchFn }: TagInputPro
|
|||||||
}}
|
}}
|
||||||
onMouseEnter={() => setHighlighted(i)}
|
onMouseEnter={() => setHighlighted(i)}
|
||||||
style={{
|
style={{
|
||||||
padding: '0.25rem 0.5rem',
|
padding: "0.25rem 0.5rem",
|
||||||
height: '28px',
|
height: "28px",
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
fontSize: '0.8rem',
|
fontSize: "0.8rem",
|
||||||
cursor: 'pointer',
|
cursor: "pointer",
|
||||||
color: 'var(--ctp-text)',
|
color: "var(--ctp-text)",
|
||||||
backgroundColor:
|
backgroundColor:
|
||||||
i === highlighted ? 'var(--ctp-surface1)' : 'transparent',
|
i === highlighted ? "var(--ctp-surface1)" : "transparent",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{opt.label}
|
{opt.label}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { Plus, Download } from "lucide-react";
|
||||||
import { get, post, put, del } from "../../api/client";
|
import { get, post, put, del } from "../../api/client";
|
||||||
import type { BOMEntry } from "../../api/types";
|
import type { BOMEntry } from "../../api/types";
|
||||||
|
|
||||||
@@ -233,9 +234,14 @@ export function BOMTab({ partNumber, isEditor }: BOMTabProps) {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
window.location.href = `/api/items/${encodeURIComponent(partNumber)}/bom/export.csv`;
|
window.location.href = `/api/items/${encodeURIComponent(partNumber)}/bom/export.csv`;
|
||||||
}}
|
}}
|
||||||
style={toolBtnStyle}
|
style={{
|
||||||
|
...toolBtnStyle,
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.35rem",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Export CSV
|
<Download size={14} /> Export CSV
|
||||||
</button>
|
</button>
|
||||||
{isEditor && (
|
{isEditor && (
|
||||||
<button
|
<button
|
||||||
@@ -244,9 +250,14 @@ export function BOMTab({ partNumber, isEditor }: BOMTabProps) {
|
|||||||
setEditIdx(null);
|
setEditIdx(null);
|
||||||
setForm(emptyForm);
|
setForm(emptyForm);
|
||||||
}}
|
}}
|
||||||
style={toolBtnStyle}
|
style={{
|
||||||
|
...toolBtnStyle,
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.35rem",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
+ Add
|
<Plus size={14} /> Add
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
|
import { X } from "lucide-react";
|
||||||
import { get } from "../../api/client";
|
import { get } from "../../api/client";
|
||||||
import type { Item } from "../../api/types";
|
import type { Item } from "../../api/types";
|
||||||
import { MainTab } from "./MainTab";
|
import { MainTab } from "./MainTab";
|
||||||
@@ -131,9 +132,13 @@ export function ItemDetail({
|
|||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
style={{ ...headerBtnStyle, fontSize: "1rem" }}
|
style={{
|
||||||
|
...headerBtnStyle,
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
×
|
<X size={14} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useCallback } from "react";
|
import { useState, useCallback } from "react";
|
||||||
|
import { ChevronUp, ChevronDown } from "lucide-react";
|
||||||
import type { Item } from "../../api/types";
|
import type { Item } from "../../api/types";
|
||||||
import { ContextMenu, type ContextMenuItem } from "../ContextMenu";
|
import { ContextMenu, type ContextMenuItem } from "../ContextMenu";
|
||||||
|
|
||||||
@@ -191,8 +192,18 @@ export function ItemTable({
|
|||||||
>
|
>
|
||||||
{col.label}
|
{col.label}
|
||||||
{sortKey === col.key && (
|
{sortKey === col.key && (
|
||||||
<span style={{ marginLeft: 4 }}>
|
<span
|
||||||
{sortDir === "asc" ? "▲" : "▼"}
|
style={{
|
||||||
|
marginLeft: 4,
|
||||||
|
display: "inline-flex",
|
||||||
|
verticalAlign: "middle",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sortDir === "asc" ? (
|
||||||
|
<ChevronUp size={14} />
|
||||||
|
) : (
|
||||||
|
<ChevronDown size={14} />
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</th>
|
</th>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { Columns2, Rows2, Plus, Download, Upload } from "lucide-react";
|
||||||
import { get } from "../../api/client";
|
import { get } from "../../api/client";
|
||||||
import type { Project } from "../../api/types";
|
import type { Project } from "../../api/types";
|
||||||
import type { ItemFilters } from "../../hooks/useItems";
|
import type { ItemFilters } from "../../hooks/useItems";
|
||||||
@@ -127,20 +128,42 @@ export function ItemsToolbar({
|
|||||||
onLayoutChange(layout === "horizontal" ? "vertical" : "horizontal")
|
onLayoutChange(layout === "horizontal" ? "vertical" : "horizontal")
|
||||||
}
|
}
|
||||||
title={`Switch to ${layout === "horizontal" ? "vertical" : "horizontal"} layout`}
|
title={`Switch to ${layout === "horizontal" ? "vertical" : "horizontal"} layout`}
|
||||||
style={toolBtnStyle}
|
style={{
|
||||||
|
...toolBtnStyle,
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{layout === "horizontal" ? "⬌" : "⬍"}
|
{layout === "horizontal" ? <Columns2 size={14} /> : <Rows2 size={14} />}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Export */}
|
{/* Export */}
|
||||||
<button onClick={onExport} style={toolBtnStyle} title="Export CSV">
|
<button
|
||||||
Export
|
onClick={onExport}
|
||||||
|
style={{
|
||||||
|
...toolBtnStyle,
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.35rem",
|
||||||
|
}}
|
||||||
|
title="Export CSV"
|
||||||
|
>
|
||||||
|
<Download size={14} /> Export
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Import (editor only) */}
|
{/* Import (editor only) */}
|
||||||
{isEditor && (
|
{isEditor && (
|
||||||
<button onClick={onImport} style={toolBtnStyle} title="Import CSV">
|
<button
|
||||||
Import
|
onClick={onImport}
|
||||||
|
style={{
|
||||||
|
...toolBtnStyle,
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.35rem",
|
||||||
|
}}
|
||||||
|
title="Import CSV"
|
||||||
|
>
|
||||||
|
<Upload size={14} /> Import
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -152,9 +175,12 @@ export function ItemsToolbar({
|
|||||||
...toolBtnStyle,
|
...toolBtnStyle,
|
||||||
backgroundColor: "var(--ctp-mauve)",
|
backgroundColor: "var(--ctp-mauve)",
|
||||||
color: "var(--ctp-crust)",
|
color: "var(--ctp-crust)",
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.35rem",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
+ New
|
<Plus size={14} /> New
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
|
import { X } from "lucide-react";
|
||||||
import { get, post, del } from "../../api/client";
|
import { get, post, del } from "../../api/client";
|
||||||
import type { Item, Project, Revision } from "../../api/types";
|
import type { Item, Project, Revision } from "../../api/types";
|
||||||
|
|
||||||
@@ -192,11 +193,11 @@ export function MainTab({ item, onReload, isEditor }: MainTabProps) {
|
|||||||
border: "none",
|
border: "none",
|
||||||
color: "var(--ctp-overlay0)",
|
color: "var(--ctp-overlay0)",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
fontSize: "0.8rem",
|
|
||||||
padding: 0,
|
padding: 0,
|
||||||
|
display: "inline-flex",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
×
|
<X size={14} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from "react";
|
||||||
import { post } from '../../api/client';
|
import { X, Plus } from "lucide-react";
|
||||||
import type { Item } from '../../api/types';
|
import { post } from "../../api/client";
|
||||||
|
import type { Item } from "../../api/types";
|
||||||
|
|
||||||
interface PropertiesTabProps {
|
interface PropertiesTabProps {
|
||||||
item: Item;
|
item: Item;
|
||||||
@@ -8,24 +9,24 @@ interface PropertiesTabProps {
|
|||||||
isEditor: boolean;
|
isEditor: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Mode = 'form' | 'json';
|
type Mode = "form" | "json";
|
||||||
|
|
||||||
interface PropRow {
|
interface PropRow {
|
||||||
key: string;
|
key: string;
|
||||||
value: string;
|
value: string;
|
||||||
type: 'string' | 'number' | 'boolean';
|
type: "string" | "number" | "boolean";
|
||||||
}
|
}
|
||||||
|
|
||||||
function detectType(v: unknown): PropRow['type'] {
|
function detectType(v: unknown): PropRow["type"] {
|
||||||
if (typeof v === 'number') return 'number';
|
if (typeof v === "number") return "number";
|
||||||
if (typeof v === 'boolean') return 'boolean';
|
if (typeof v === "boolean") return "boolean";
|
||||||
return 'string';
|
return "string";
|
||||||
}
|
}
|
||||||
|
|
||||||
function toRows(props: Record<string, unknown>): PropRow[] {
|
function toRows(props: Record<string, unknown>): PropRow[] {
|
||||||
return Object.entries(props).map(([key, value]) => ({
|
return Object.entries(props).map(([key, value]) => ({
|
||||||
key,
|
key,
|
||||||
value: String(value ?? ''),
|
value: String(value ?? ""),
|
||||||
type: detectType(value),
|
type: detectType(value),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -35,17 +36,26 @@ function fromRows(rows: PropRow[]): Record<string, unknown> {
|
|||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
if (!row.key.trim()) continue;
|
if (!row.key.trim()) continue;
|
||||||
switch (row.type) {
|
switch (row.type) {
|
||||||
case 'number': obj[row.key] = Number(row.value) || 0; break;
|
case "number":
|
||||||
case 'boolean': obj[row.key] = row.value === 'true'; break;
|
obj[row.key] = Number(row.value) || 0;
|
||||||
default: obj[row.key] = row.value;
|
break;
|
||||||
|
case "boolean":
|
||||||
|
obj[row.key] = row.value === "true";
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
obj[row.key] = row.value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return obj;
|
return obj;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PropertiesTab({ item, onReload, isEditor }: PropertiesTabProps) {
|
export function PropertiesTab({
|
||||||
|
item,
|
||||||
|
onReload,
|
||||||
|
isEditor,
|
||||||
|
}: PropertiesTabProps) {
|
||||||
const props = item.properties ?? {};
|
const props = item.properties ?? {};
|
||||||
const [mode, setMode] = useState<Mode>('form');
|
const [mode, setMode] = useState<Mode>("form");
|
||||||
const [rows, setRows] = useState<PropRow[]>(toRows(props));
|
const [rows, setRows] = useState<PropRow[]>(toRows(props));
|
||||||
const [jsonText, setJsonText] = useState(JSON.stringify(props, null, 2));
|
const [jsonText, setJsonText] = useState(JSON.stringify(props, null, 2));
|
||||||
const [jsonError, setJsonError] = useState<string | null>(null);
|
const [jsonError, setJsonError] = useState<string | null>(null);
|
||||||
@@ -62,18 +72,20 @@ export function PropertiesTab({ item, onReload, isEditor }: PropertiesTabProps)
|
|||||||
setRows(toRows(parsed));
|
setRows(toRows(parsed));
|
||||||
setJsonError(null);
|
setJsonError(null);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setJsonError(e instanceof Error ? e.message : 'Invalid JSON');
|
setJsonError(e instanceof Error ? e.message : "Invalid JSON");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const switchMode = (m: Mode) => {
|
const switchMode = (m: Mode) => {
|
||||||
if (m === 'json') syncFormToJson();
|
if (m === "json") syncFormToJson();
|
||||||
else syncJsonToForm();
|
else syncJsonToForm();
|
||||||
setMode(m);
|
setMode(m);
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateRow = (idx: number, field: keyof PropRow, value: string) => {
|
const updateRow = (idx: number, field: keyof PropRow, value: string) => {
|
||||||
setRows((prev) => prev.map((r, i) => i === idx ? { ...r, [field]: value } : r));
|
setRows((prev) =>
|
||||||
|
prev.map((r, i) => (i === idx ? { ...r, [field]: value } : r)),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeRow = (idx: number) => {
|
const removeRow = (idx: number) => {
|
||||||
@@ -81,72 +93,112 @@ export function PropertiesTab({ item, onReload, isEditor }: PropertiesTabProps)
|
|||||||
};
|
};
|
||||||
|
|
||||||
const addRow = () => {
|
const addRow = () => {
|
||||||
setRows((prev) => [...prev, { key: '', value: '', type: 'string' }]);
|
setRows((prev) => [...prev, { key: "", value: "", type: "string" }]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
let properties: Record<string, unknown>;
|
let properties: Record<string, unknown>;
|
||||||
if (mode === 'json') {
|
if (mode === "json") {
|
||||||
try {
|
try {
|
||||||
properties = JSON.parse(jsonText) as Record<string, unknown>;
|
properties = JSON.parse(jsonText) as Record<string, unknown>;
|
||||||
} catch {
|
} catch {
|
||||||
setJsonError('Invalid JSON');
|
setJsonError("Invalid JSON");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
properties = fromRows(rows);
|
properties = fromRows(rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
const comment = prompt('Revision comment (optional):') ?? '';
|
const comment = prompt("Revision comment (optional):") ?? "";
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
await post(`/api/items/${encodeURIComponent(item.part_number)}/revisions`, { properties, comment });
|
await post(
|
||||||
|
`/api/items/${encodeURIComponent(item.part_number)}/revisions`,
|
||||||
|
{ properties, comment },
|
||||||
|
);
|
||||||
onReload();
|
onReload();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert(e instanceof Error ? e.message : 'Failed to save properties');
|
alert(e instanceof Error ? e.message : "Failed to save properties");
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const inputStyle: React.CSSProperties = {
|
const inputStyle: React.CSSProperties = {
|
||||||
padding: '0.25rem 0.4rem', fontSize: '0.8rem',
|
padding: "0.25rem 0.4rem",
|
||||||
backgroundColor: 'var(--ctp-base)', border: '1px solid var(--ctp-surface1)',
|
fontSize: "0.8rem",
|
||||||
borderRadius: '0.3rem', color: 'var(--ctp-text)',
|
backgroundColor: "var(--ctp-base)",
|
||||||
|
border: "1px solid var(--ctp-surface1)",
|
||||||
|
borderRadius: "0.3rem",
|
||||||
|
color: "var(--ctp-text)",
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* Mode toggle */}
|
{/* Mode toggle */}
|
||||||
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '0.5rem', alignItems: 'center' }}>
|
<div
|
||||||
<button onClick={() => switchMode('form')} style={mode === 'form' ? activeTabBtn : tabBtn}>Form</button>
|
style={{
|
||||||
<button onClick={() => switchMode('json')} style={mode === 'json' ? activeTabBtn : tabBtn}>JSON</button>
|
display: "flex",
|
||||||
|
gap: "0.5rem",
|
||||||
|
marginBottom: "0.5rem",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => switchMode("form")}
|
||||||
|
style={mode === "form" ? activeTabBtn : tabBtn}
|
||||||
|
>
|
||||||
|
Form
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => switchMode("json")}
|
||||||
|
style={mode === "json" ? activeTabBtn : tabBtn}
|
||||||
|
>
|
||||||
|
JSON
|
||||||
|
</button>
|
||||||
<span style={{ flex: 1 }} />
|
<span style={{ flex: 1 }} />
|
||||||
{isEditor && (
|
{isEditor && (
|
||||||
<button onClick={() => void handleSave()} disabled={saving} style={{
|
<button
|
||||||
padding: '0.3rem 0.75rem', fontSize: '0.8rem', border: 'none', borderRadius: '0.3rem',
|
onClick={() => void handleSave()}
|
||||||
backgroundColor: 'var(--ctp-mauve)', color: 'var(--ctp-crust)', cursor: 'pointer',
|
disabled={saving}
|
||||||
opacity: saving ? 0.6 : 1,
|
style={{
|
||||||
}}>
|
padding: "0.3rem 0.75rem",
|
||||||
{saving ? 'Saving...' : 'Save (New Revision)'}
|
fontSize: "0.8rem",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "0.3rem",
|
||||||
|
backgroundColor: "var(--ctp-mauve)",
|
||||||
|
color: "var(--ctp-crust)",
|
||||||
|
cursor: "pointer",
|
||||||
|
opacity: saving ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{saving ? "Saving..." : "Save (New Revision)"}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{mode === 'form' ? (
|
{mode === "form" ? (
|
||||||
<div>
|
<div>
|
||||||
{rows.map((row, idx) => (
|
{rows.map((row, idx) => (
|
||||||
<div key={idx} style={{ display: 'flex', gap: '0.3rem', marginBottom: '0.25rem', alignItems: 'center' }}>
|
<div
|
||||||
|
key={idx}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: "0.3rem",
|
||||||
|
marginBottom: "0.25rem",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
value={row.key}
|
value={row.key}
|
||||||
onChange={(e) => updateRow(idx, 'key', e.target.value)}
|
onChange={(e) => updateRow(idx, "key", e.target.value)}
|
||||||
placeholder="Key"
|
placeholder="Key"
|
||||||
style={{ ...inputStyle, width: 140 }}
|
style={{ ...inputStyle, width: 140 }}
|
||||||
disabled={!isEditor}
|
disabled={!isEditor}
|
||||||
/>
|
/>
|
||||||
<select
|
<select
|
||||||
value={row.type}
|
value={row.type}
|
||||||
onChange={(e) => updateRow(idx, 'type', e.target.value)}
|
onChange={(e) => updateRow(idx, "type", e.target.value)}
|
||||||
style={{ ...inputStyle, width: 80 }}
|
style={{ ...inputStyle, width: 80 }}
|
||||||
disabled={!isEditor}
|
disabled={!isEditor}
|
||||||
>
|
>
|
||||||
@@ -154,44 +206,90 @@ export function PropertiesTab({ item, onReload, isEditor }: PropertiesTabProps)
|
|||||||
<option value="number">num</option>
|
<option value="number">num</option>
|
||||||
<option value="boolean">bool</option>
|
<option value="boolean">bool</option>
|
||||||
</select>
|
</select>
|
||||||
{row.type === 'boolean' ? (
|
{row.type === "boolean" ? (
|
||||||
<select value={row.value} onChange={(e) => updateRow(idx, 'value', e.target.value)} style={{ ...inputStyle, flex: 1 }} disabled={!isEditor}>
|
<select
|
||||||
|
value={row.value}
|
||||||
|
onChange={(e) => updateRow(idx, "value", e.target.value)}
|
||||||
|
style={{ ...inputStyle, flex: 1 }}
|
||||||
|
disabled={!isEditor}
|
||||||
|
>
|
||||||
<option value="true">true</option>
|
<option value="true">true</option>
|
||||||
<option value="false">false</option>
|
<option value="false">false</option>
|
||||||
</select>
|
</select>
|
||||||
) : (
|
) : (
|
||||||
<input
|
<input
|
||||||
type={row.type === 'number' ? 'number' : 'text'}
|
type={row.type === "number" ? "number" : "text"}
|
||||||
value={row.value}
|
value={row.value}
|
||||||
onChange={(e) => updateRow(idx, 'value', e.target.value)}
|
onChange={(e) => updateRow(idx, "value", e.target.value)}
|
||||||
placeholder="Value"
|
placeholder="Value"
|
||||||
style={{ ...inputStyle, flex: 1 }}
|
style={{ ...inputStyle, flex: 1 }}
|
||||||
disabled={!isEditor}
|
disabled={!isEditor}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{isEditor && (
|
{isEditor && (
|
||||||
<button onClick={() => removeRow(idx)} style={{ background: 'none', border: 'none', color: 'var(--ctp-red)', cursor: 'pointer', fontSize: '0.9rem' }}>×</button>
|
<button
|
||||||
|
onClick={() => removeRow(idx)}
|
||||||
|
style={{
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
color: "var(--ctp-red)",
|
||||||
|
cursor: "pointer",
|
||||||
|
display: "inline-flex",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{isEditor && (
|
{isEditor && (
|
||||||
<button onClick={addRow} style={{ ...tabBtn, marginTop: '0.25rem' }}>+ Add Property</button>
|
<button
|
||||||
|
onClick={addRow}
|
||||||
|
style={{
|
||||||
|
...tabBtn,
|
||||||
|
marginTop: "0.25rem",
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.35rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus size={14} /> Add Property
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
<textarea
|
<textarea
|
||||||
value={jsonText}
|
value={jsonText}
|
||||||
onChange={(e) => { setJsonText(e.target.value); setJsonError(null); }}
|
onChange={(e) => {
|
||||||
|
setJsonText(e.target.value);
|
||||||
|
setJsonError(null);
|
||||||
|
}}
|
||||||
disabled={!isEditor}
|
disabled={!isEditor}
|
||||||
style={{
|
style={{
|
||||||
width: '100%', minHeight: 200, padding: '0.5rem',
|
width: "100%",
|
||||||
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.8rem',
|
minHeight: 200,
|
||||||
backgroundColor: 'var(--ctp-base)', border: '1px solid var(--ctp-surface1)',
|
padding: "0.5rem",
|
||||||
borderRadius: '0.4rem', color: 'var(--ctp-text)', resize: 'vertical',
|
fontFamily: "'JetBrains Mono', monospace",
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
backgroundColor: "var(--ctp-base)",
|
||||||
|
border: "1px solid var(--ctp-surface1)",
|
||||||
|
borderRadius: "0.4rem",
|
||||||
|
color: "var(--ctp-text)",
|
||||||
|
resize: "vertical",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{jsonError && <div style={{ color: 'var(--ctp-red)', fontSize: '0.8rem', marginTop: '0.25rem' }}>{jsonError}</div>}
|
{jsonError && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
color: "var(--ctp-red)",
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
marginTop: "0.25rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{jsonError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -199,11 +297,17 @@ export function PropertiesTab({ item, onReload, isEditor }: PropertiesTabProps)
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tabBtn: React.CSSProperties = {
|
const tabBtn: React.CSSProperties = {
|
||||||
padding: '0.25rem 0.5rem', fontSize: '0.8rem', border: 'none', borderRadius: '0.3rem',
|
padding: "0.25rem 0.5rem",
|
||||||
backgroundColor: 'var(--ctp-surface0)', color: 'var(--ctp-subtext1)', cursor: 'pointer',
|
fontSize: "0.8rem",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "0.3rem",
|
||||||
|
backgroundColor: "var(--ctp-surface0)",
|
||||||
|
color: "var(--ctp-subtext1)",
|
||||||
|
cursor: "pointer",
|
||||||
};
|
};
|
||||||
|
|
||||||
const activeTabBtn: React.CSSProperties = {
|
const activeTabBtn: React.CSSProperties = {
|
||||||
...tabBtn,
|
...tabBtn,
|
||||||
backgroundColor: 'var(--ctp-surface1)', color: 'var(--ctp-mauve)',
|
backgroundColor: "var(--ctp-surface1)",
|
||||||
|
color: "var(--ctp-mauve)",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from "react";
|
||||||
import { get, post } from '../../api/client';
|
import { Download } from "lucide-react";
|
||||||
import type { Revision, RevisionComparison } from '../../api/types';
|
import { get, post } from "../../api/client";
|
||||||
|
import type { Revision, RevisionComparison } from "../../api/types";
|
||||||
|
|
||||||
interface RevisionsTabProps {
|
interface RevisionsTabProps {
|
||||||
partNumber: string;
|
partNumber: string;
|
||||||
@@ -8,28 +9,35 @@ interface RevisionsTabProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const statusColors: Record<string, string> = {
|
const statusColors: Record<string, string> = {
|
||||||
draft: 'var(--ctp-overlay1)',
|
draft: "var(--ctp-overlay1)",
|
||||||
review: 'var(--ctp-yellow)',
|
review: "var(--ctp-yellow)",
|
||||||
released: 'var(--ctp-green)',
|
released: "var(--ctp-green)",
|
||||||
obsolete: 'var(--ctp-red)',
|
obsolete: "var(--ctp-red)",
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatDate(s: string) {
|
function formatDate(s: string) {
|
||||||
if (!s) return '';
|
if (!s) return "";
|
||||||
return new Date(s).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
|
return new Date(s).toLocaleDateString("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RevisionsTab({ partNumber, isEditor }: RevisionsTabProps) {
|
export function RevisionsTab({ partNumber, isEditor }: RevisionsTabProps) {
|
||||||
const [revisions, setRevisions] = useState<Revision[]>([]);
|
const [revisions, setRevisions] = useState<Revision[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [compareFrom, setCompareFrom] = useState('');
|
const [compareFrom, setCompareFrom] = useState("");
|
||||||
const [compareTo, setCompareTo] = useState('');
|
const [compareTo, setCompareTo] = useState("");
|
||||||
const [comparison, setComparison] = useState<RevisionComparison | null>(null);
|
const [comparison, setComparison] = useState<RevisionComparison | null>(null);
|
||||||
|
|
||||||
const load = () => {
|
const load = () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
get<Revision[]>(`/api/items/${encodeURIComponent(partNumber)}/revisions`)
|
get<Revision[]>(`/api/items/${encodeURIComponent(partNumber)}/revisions`)
|
||||||
.then((r) => { setRevisions(r); setLoading(false); })
|
.then((r) => {
|
||||||
|
setRevisions(r);
|
||||||
|
setLoading(false);
|
||||||
|
})
|
||||||
.catch(() => setLoading(false));
|
.catch(() => setLoading(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -39,97 +47,177 @@ export function RevisionsTab({ partNumber, isEditor }: RevisionsTabProps) {
|
|||||||
if (!compareFrom || !compareTo) return;
|
if (!compareFrom || !compareTo) return;
|
||||||
try {
|
try {
|
||||||
const result = await get<RevisionComparison>(
|
const result = await get<RevisionComparison>(
|
||||||
`/api/items/${encodeURIComponent(partNumber)}/revisions/compare?from=${compareFrom}&to=${compareTo}`
|
`/api/items/${encodeURIComponent(partNumber)}/revisions/compare?from=${compareFrom}&to=${compareTo}`,
|
||||||
);
|
);
|
||||||
setComparison(result);
|
setComparison(result);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert(e instanceof Error ? e.message : 'Compare failed');
|
alert(e instanceof Error ? e.message : "Compare failed");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStatusChange = async (rev: number, status: string) => {
|
const handleStatusChange = async (rev: number, status: string) => {
|
||||||
try {
|
try {
|
||||||
await fetch(`/api/items/${encodeURIComponent(partNumber)}/revisions/${rev}`, {
|
await fetch(
|
||||||
method: 'PATCH',
|
`/api/items/${encodeURIComponent(partNumber)}/revisions/${rev}`,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
{
|
||||||
credentials: 'include',
|
method: "PATCH",
|
||||||
body: JSON.stringify({ status }),
|
headers: { "Content-Type": "application/json" },
|
||||||
});
|
credentials: "include",
|
||||||
|
body: JSON.stringify({ status }),
|
||||||
|
},
|
||||||
|
);
|
||||||
load();
|
load();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert(e instanceof Error ? e.message : 'Status update failed');
|
alert(e instanceof Error ? e.message : "Status update failed");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRollback = async (rev: number) => {
|
const handleRollback = async (rev: number) => {
|
||||||
if (!confirm(`Rollback to revision ${rev}? This creates a new revision with data from rev ${rev}.`)) return;
|
if (
|
||||||
const comment = prompt('Rollback comment:') ?? `Rollback to rev ${rev}`;
|
!confirm(
|
||||||
|
`Rollback to revision ${rev}? This creates a new revision with data from rev ${rev}.`,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
const comment = prompt("Rollback comment:") ?? `Rollback to rev ${rev}`;
|
||||||
try {
|
try {
|
||||||
await post(`/api/items/${encodeURIComponent(partNumber)}/revisions/${rev}/rollback`, { comment });
|
await post(
|
||||||
|
`/api/items/${encodeURIComponent(partNumber)}/revisions/${rev}/rollback`,
|
||||||
|
{ comment },
|
||||||
|
);
|
||||||
load();
|
load();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert(e instanceof Error ? e.message : 'Rollback failed');
|
alert(e instanceof Error ? e.message : "Rollback failed");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) return <div style={{ color: 'var(--ctp-subtext0)' }}>Loading revisions...</div>;
|
if (loading)
|
||||||
|
return (
|
||||||
|
<div style={{ color: "var(--ctp-subtext0)" }}>Loading revisions...</div>
|
||||||
|
);
|
||||||
|
|
||||||
const selectStyle: React.CSSProperties = {
|
const selectStyle: React.CSSProperties = {
|
||||||
padding: '0.25rem 0.4rem', fontSize: '0.8rem',
|
padding: "0.25rem 0.4rem",
|
||||||
backgroundColor: 'var(--ctp-surface0)', border: '1px solid var(--ctp-surface1)',
|
fontSize: "0.8rem",
|
||||||
borderRadius: '0.3rem', color: 'var(--ctp-text)',
|
backgroundColor: "var(--ctp-surface0)",
|
||||||
|
border: "1px solid var(--ctp-surface1)",
|
||||||
|
borderRadius: "0.3rem",
|
||||||
|
color: "var(--ctp-text)",
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* Compare controls */}
|
{/* Compare controls */}
|
||||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', marginBottom: '0.75rem' }}>
|
<div
|
||||||
<select value={compareFrom} onChange={(e) => setCompareFrom(e.target.value)} style={selectStyle}>
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: "0.5rem",
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: "0.75rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
value={compareFrom}
|
||||||
|
onChange={(e) => setCompareFrom(e.target.value)}
|
||||||
|
style={selectStyle}
|
||||||
|
>
|
||||||
<option value="">From rev...</option>
|
<option value="">From rev...</option>
|
||||||
{revisions.map((r) => <option key={r.id} value={r.revision_number}>Rev {r.revision_number}</option>)}
|
{revisions.map((r) => (
|
||||||
|
<option key={r.id} value={r.revision_number}>
|
||||||
|
Rev {r.revision_number}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
</select>
|
</select>
|
||||||
<select value={compareTo} onChange={(e) => setCompareTo(e.target.value)} style={selectStyle}>
|
<select
|
||||||
|
value={compareTo}
|
||||||
|
onChange={(e) => setCompareTo(e.target.value)}
|
||||||
|
style={selectStyle}
|
||||||
|
>
|
||||||
<option value="">To rev...</option>
|
<option value="">To rev...</option>
|
||||||
{revisions.map((r) => <option key={r.id} value={r.revision_number}>Rev {r.revision_number}</option>)}
|
{revisions.map((r) => (
|
||||||
|
<option key={r.id} value={r.revision_number}>
|
||||||
|
Rev {r.revision_number}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
</select>
|
</select>
|
||||||
<button onClick={() => void handleCompare()} disabled={!compareFrom || !compareTo} style={{
|
<button
|
||||||
padding: '0.25rem 0.5rem', fontSize: '0.8rem', border: 'none', borderRadius: '0.3rem',
|
onClick={() => void handleCompare()}
|
||||||
backgroundColor: 'var(--ctp-mauve)', color: 'var(--ctp-crust)', cursor: 'pointer',
|
disabled={!compareFrom || !compareTo}
|
||||||
opacity: (!compareFrom || !compareTo) ? 0.5 : 1,
|
style={{
|
||||||
}}>
|
padding: "0.25rem 0.5rem",
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "0.3rem",
|
||||||
|
backgroundColor: "var(--ctp-mauve)",
|
||||||
|
color: "var(--ctp-crust)",
|
||||||
|
cursor: "pointer",
|
||||||
|
opacity: !compareFrom || !compareTo ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
Compare
|
Compare
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Compare results */}
|
{/* Compare results */}
|
||||||
{comparison && (
|
{comparison && (
|
||||||
<div style={{
|
<div
|
||||||
padding: '0.5rem', backgroundColor: 'var(--ctp-surface0)', borderRadius: '0.4rem',
|
style={{
|
||||||
fontSize: '0.8rem', marginBottom: '0.75rem', fontFamily: "'JetBrains Mono', monospace",
|
padding: "0.5rem",
|
||||||
}}>
|
backgroundColor: "var(--ctp-surface0)",
|
||||||
|
borderRadius: "0.4rem",
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
marginBottom: "0.75rem",
|
||||||
|
fontFamily: "'JetBrains Mono', monospace",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{comparison.status_changed && (
|
{comparison.status_changed && (
|
||||||
<div>Status: <span style={{ color: 'var(--ctp-red)' }}>{comparison.status_changed.from}</span> → <span style={{ color: 'var(--ctp-green)' }}>{comparison.status_changed.to}</span></div>
|
<div>
|
||||||
|
Status:{" "}
|
||||||
|
<span style={{ color: "var(--ctp-red)" }}>
|
||||||
|
{comparison.status_changed.from}
|
||||||
|
</span>{" "}
|
||||||
|
→{" "}
|
||||||
|
<span style={{ color: "var(--ctp-green)" }}>
|
||||||
|
{comparison.status_changed.to}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{comparison.file_changed && (
|
||||||
|
<div style={{ color: "var(--ctp-yellow)" }}>File changed</div>
|
||||||
)}
|
)}
|
||||||
{comparison.file_changed && <div style={{ color: 'var(--ctp-yellow)' }}>File changed</div>}
|
|
||||||
{Object.entries(comparison.added).map(([k, v]) => (
|
{Object.entries(comparison.added).map(([k, v]) => (
|
||||||
<div key={k} style={{ color: 'var(--ctp-green)' }}>+ {k}: {String(v)}</div>
|
<div key={k} style={{ color: "var(--ctp-green)" }}>
|
||||||
|
+ {k}: {String(v)}
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
{Object.entries(comparison.removed).map(([k, v]) => (
|
{Object.entries(comparison.removed).map(([k, v]) => (
|
||||||
<div key={k} style={{ color: 'var(--ctp-red)' }}>- {k}: {String(v)}</div>
|
<div key={k} style={{ color: "var(--ctp-red)" }}>
|
||||||
|
- {k}: {String(v)}
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
{Object.entries(comparison.changed).map(([k, c]) => (
|
{Object.entries(comparison.changed).map(([k, c]) => (
|
||||||
<div key={k} style={{ color: 'var(--ctp-yellow)' }}>~ {k}: {String(c.from)} → {String(c.to)}</div>
|
<div key={k} style={{ color: "var(--ctp-yellow)" }}>
|
||||||
|
~ {k}: {String(c.from)} → {String(c.to)}
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
{!comparison.status_changed && !comparison.file_changed &&
|
{!comparison.status_changed &&
|
||||||
Object.keys(comparison.added).length === 0 && Object.keys(comparison.removed).length === 0 &&
|
!comparison.file_changed &&
|
||||||
Object.keys(comparison.changed).length === 0 && (
|
Object.keys(comparison.added).length === 0 &&
|
||||||
<div style={{ color: 'var(--ctp-subtext0)' }}>No differences</div>
|
Object.keys(comparison.removed).length === 0 &&
|
||||||
)}
|
Object.keys(comparison.changed).length === 0 && (
|
||||||
|
<div style={{ color: "var(--ctp-subtext0)" }}>No differences</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Revisions table */}
|
{/* Revisions table */}
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.8rem' }}>
|
<table
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
borderCollapse: "collapse",
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style={thStyle}>Rev</th>
|
<th style={thStyle}>Rev</th>
|
||||||
@@ -143,17 +231,32 @@ export function RevisionsTab({ partNumber, isEditor }: RevisionsTabProps) {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{revisions.map((rev, idx) => (
|
{revisions.map((rev, idx) => (
|
||||||
<tr key={rev.id} style={{ backgroundColor: idx % 2 === 0 ? 'var(--ctp-base)' : 'var(--ctp-surface0)' }}>
|
<tr
|
||||||
|
key={rev.id}
|
||||||
|
style={{
|
||||||
|
backgroundColor:
|
||||||
|
idx % 2 === 0 ? "var(--ctp-base)" : "var(--ctp-surface0)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<td style={tdStyle}>{rev.revision_number}</td>
|
<td style={tdStyle}>{rev.revision_number}</td>
|
||||||
<td style={tdStyle}>
|
<td style={tdStyle}>
|
||||||
{isEditor ? (
|
{isEditor ? (
|
||||||
<select
|
<select
|
||||||
value={rev.status}
|
value={rev.status}
|
||||||
onChange={(e) => void handleStatusChange(rev.revision_number, e.target.value)}
|
onChange={(e) =>
|
||||||
|
void handleStatusChange(
|
||||||
|
rev.revision_number,
|
||||||
|
e.target.value,
|
||||||
|
)
|
||||||
|
}
|
||||||
style={{
|
style={{
|
||||||
padding: '0.1rem 0.3rem', fontSize: '0.75rem', border: 'none', borderRadius: '0.3rem',
|
padding: "0.1rem 0.3rem",
|
||||||
backgroundColor: 'transparent', color: statusColors[rev.status] ?? 'var(--ctp-text)',
|
fontSize: "0.75rem",
|
||||||
cursor: 'pointer',
|
border: "none",
|
||||||
|
borderRadius: "0.3rem",
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
color: statusColors[rev.status] ?? "var(--ctp-text)",
|
||||||
|
cursor: "pointer",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<option value="draft">draft</option>
|
<option value="draft">draft</option>
|
||||||
@@ -162,27 +265,58 @@ export function RevisionsTab({ partNumber, isEditor }: RevisionsTabProps) {
|
|||||||
<option value="obsolete">obsolete</option>
|
<option value="obsolete">obsolete</option>
|
||||||
</select>
|
</select>
|
||||||
) : (
|
) : (
|
||||||
<span style={{ color: statusColors[rev.status] ?? 'var(--ctp-text)' }}>{rev.status}</span>
|
<span
|
||||||
|
style={{
|
||||||
|
color: statusColors[rev.status] ?? "var(--ctp-text)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{rev.status}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td style={tdStyle}>{formatDate(rev.created_at)}</td>
|
<td style={tdStyle}>{formatDate(rev.created_at)}</td>
|
||||||
<td style={tdStyle}>{rev.created_by ?? '—'}</td>
|
<td style={tdStyle}>{rev.created_by ?? "—"}</td>
|
||||||
<td style={{ ...tdStyle, maxWidth: 150, overflow: 'hidden', textOverflow: 'ellipsis' }}>{rev.comment ?? ''}</td>
|
<td
|
||||||
|
style={{
|
||||||
|
...tdStyle,
|
||||||
|
maxWidth: 150,
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{rev.comment ?? ""}
|
||||||
|
</td>
|
||||||
<td style={tdStyle}>
|
<td style={tdStyle}>
|
||||||
{rev.file_key ? (
|
{rev.file_key ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => { window.location.href = `/api/items/${encodeURIComponent(partNumber)}/file/${rev.revision_number}`; }}
|
onClick={() => {
|
||||||
style={{ background: 'none', border: 'none', color: 'var(--ctp-sapphire)', cursor: 'pointer', fontSize: '0.8rem' }}
|
window.location.href = `/api/items/${encodeURIComponent(partNumber)}/file/${rev.revision_number}`;
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
color: "var(--ctp-sapphire)",
|
||||||
|
cursor: "pointer",
|
||||||
|
display: "inline-flex",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
↓
|
<Download size={14} />
|
||||||
</button>
|
</button>
|
||||||
) : '—'}
|
) : (
|
||||||
|
"—"
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
{isEditor && (
|
{isEditor && (
|
||||||
<td style={tdStyle}>
|
<td style={tdStyle}>
|
||||||
<button
|
<button
|
||||||
onClick={() => void handleRollback(rev.revision_number)}
|
onClick={() => void handleRollback(rev.revision_number)}
|
||||||
style={{ background: 'none', border: 'none', color: 'var(--ctp-peach)', cursor: 'pointer', fontSize: '0.75rem' }}
|
style={{
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
color: "var(--ctp-peach)",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
}}
|
||||||
title="Rollback to this revision"
|
title="Rollback to this revision"
|
||||||
>
|
>
|
||||||
Rollback
|
Rollback
|
||||||
@@ -198,10 +332,18 @@ export function RevisionsTab({ partNumber, isEditor }: RevisionsTabProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const thStyle: React.CSSProperties = {
|
const thStyle: React.CSSProperties = {
|
||||||
padding: '0.3rem 0.5rem', textAlign: 'left', borderBottom: '1px solid var(--ctp-surface1)',
|
padding: "0.3rem 0.5rem",
|
||||||
color: 'var(--ctp-subtext1)', fontWeight: 600, fontSize: '0.7rem', textTransform: 'uppercase', letterSpacing: '0.05em',
|
textAlign: "left",
|
||||||
|
borderBottom: "1px solid var(--ctp-surface1)",
|
||||||
|
color: "var(--ctp-subtext1)",
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: "0.7rem",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.05em",
|
||||||
};
|
};
|
||||||
|
|
||||||
const tdStyle: React.CSSProperties = {
|
const tdStyle: React.CSSProperties = {
|
||||||
padding: '0.25rem 0.5rem', borderBottom: '1px solid var(--ctp-surface0)', whiteSpace: 'nowrap',
|
padding: "0.25rem 0.5rem",
|
||||||
|
borderBottom: "1px solid var(--ctp-surface0)",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState, useCallback, type FormEvent } from "react";
|
import { useEffect, useState, useCallback, type FormEvent } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { Plus, ChevronUp, ChevronDown } from "lucide-react";
|
||||||
import { get, post, put, del } from "../api/client";
|
import { get, post, put, del } from "../api/client";
|
||||||
import { useAuth } from "../hooks/useAuth";
|
import { useAuth } from "../hooks/useAuth";
|
||||||
import type {
|
import type {
|
||||||
@@ -180,7 +181,17 @@ export function ProjectsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const sortArrow = (key: typeof sortKey) =>
|
const sortArrow = (key: typeof sortKey) =>
|
||||||
sortKey === key ? (sortAsc ? " \u25B2" : " \u25BC") : "";
|
sortKey === key ? (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
marginLeft: 4,
|
||||||
|
display: "inline-flex",
|
||||||
|
verticalAlign: "middle",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sortAsc ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||||
|
</span>
|
||||||
|
) : null;
|
||||||
|
|
||||||
if (loading)
|
if (loading)
|
||||||
return <p style={{ color: "var(--ctp-subtext0)" }}>Loading projects...</p>;
|
return <p style={{ color: "var(--ctp-subtext0)" }}>Loading projects...</p>;
|
||||||
@@ -199,8 +210,16 @@ export function ProjectsPage() {
|
|||||||
>
|
>
|
||||||
<h2>Projects ({projects.length})</h2>
|
<h2>Projects ({projects.length})</h2>
|
||||||
{isEditor && mode === "list" && (
|
{isEditor && mode === "list" && (
|
||||||
<button onClick={openCreate} style={btnPrimaryStyle}>
|
<button
|
||||||
+ New Project
|
onClick={openCreate}
|
||||||
|
style={{
|
||||||
|
...btnPrimaryStyle,
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.35rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus size={14} /> New Project
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState, type FormEvent } from "react";
|
import { useEffect, useState, type FormEvent } from "react";
|
||||||
|
import { ChevronRight, ChevronDown, Plus } from "lucide-react";
|
||||||
import { get, post, put, del } from "../api/client";
|
import { get, post, put, del } from "../api/client";
|
||||||
import { useAuth } from "../hooks/useAuth";
|
import { useAuth } from "../hooks/useAuth";
|
||||||
import type { Schema, SchemaSegment } from "../api/types";
|
import type { Schema, SchemaSegment } from "../api/types";
|
||||||
@@ -282,10 +283,13 @@ function SchemaCard({
|
|||||||
color: "var(--ctp-sapphire)",
|
color: "var(--ctp-sapphire)",
|
||||||
userSelect: "none",
|
userSelect: "none",
|
||||||
marginTop: "1rem",
|
marginTop: "1rem",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.25rem",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isExpanded ? "\u25BC" : "\u25B6"} View Segments (
|
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}{" "}
|
||||||
{schema.segments.length})
|
View Segments ({schema.segments.length})
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isExpanded &&
|
{isExpanded &&
|
||||||
@@ -642,9 +646,15 @@ function SegmentBlock({
|
|||||||
!(isThisSegment(editState) && editState!.mode === "add") && (
|
!(isThisSegment(editState) && editState!.mode === "add") && (
|
||||||
<button
|
<button
|
||||||
onClick={() => onStartAdd(schemaName, segment.name)}
|
onClick={() => onStartAdd(schemaName, segment.name)}
|
||||||
style={{ ...btnTinyPrimaryStyle, marginTop: "0.5rem" }}
|
style={{
|
||||||
|
...btnTinyPrimaryStyle,
|
||||||
|
marginTop: "0.5rem",
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.35rem",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
+ Add Value
|
<Plus size={14} /> Add Value
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user