feat(web): redesign CreateItemPane with two-column layout
Rewrite CreateItemPane from single-column scrolling form to a
two-column CSS Grid layout (1fr 280px):
Left column (scrollable form):
- Identity section: Type select, Description input, CategoryPicker
- Sourcing section: Sourcing Type, Standard Cost, Sourcing Link
- Details section: Long Description textarea, Projects TagInput
- Category Properties: dynamic fields from schema (2-column sub-grid)
- Section headers with uppercase labels and horizontal dividers
Right column (sidebar):
- Metadata: auto-assigned revision ('A'), created by (current user)
- Attachments: FileDropZone with presigned upload integration
- Thumbnail: 4:3 preview box, click to upload image
Submission flow:
1. POST /api/items with form data
2. Associate uploaded attachments via POST /api/items/{pn}/files
3. Set thumbnail via PUT /api/items/{pn}/thumbnail
4. File failures are non-blocking (item already created)
Integrates: CategoryPicker (#13), TagInput (#11), FileDropZone (#14),
useFileUpload presigned upload hook (#12)
Closes #15
This commit is contained in:
@@ -1,9 +1,15 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { get, post } from "../../api/client";
|
||||
import { get, post, put } from "../../api/client";
|
||||
import type { Project } from "../../api/types";
|
||||
import { TagInput, type TagOption } from "../TagInput";
|
||||
import { CategoryPicker } from "./CategoryPicker";
|
||||
import { FileDropZone } from "./FileDropZone";
|
||||
import { useCategories } from "../../hooks/useCategories";
|
||||
import {
|
||||
useFileUpload,
|
||||
type PendingAttachment,
|
||||
} from "../../hooks/useFileUpload";
|
||||
import { useAuth } from "../../hooks/useAuth";
|
||||
|
||||
interface CreateItemPaneProps {
|
||||
onCreated: (partNumber: string) => void;
|
||||
@@ -11,7 +17,12 @@ interface CreateItemPaneProps {
|
||||
}
|
||||
|
||||
export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
|
||||
const { user } = useAuth();
|
||||
const { categories } = useCategories();
|
||||
const { upload } = useFileUpload();
|
||||
|
||||
// Form state.
|
||||
const [itemType, setItemType] = useState("part");
|
||||
const [category, setCategory] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [sourcingType, setSourcingType] = useState("manufactured");
|
||||
@@ -23,28 +34,17 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
|
||||
const [catPropDefs, setCatPropDefs] = useState<
|
||||
Record<string, { type: string }>
|
||||
>({});
|
||||
|
||||
// Attachments.
|
||||
const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
|
||||
const [thumbnailFile, setThumbnailFile] = useState<PendingAttachment | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const searchProjects = useCallback(
|
||||
async (query: string): Promise<TagOption[]> => {
|
||||
const all = await get<Project[]>("/api/projects");
|
||||
const q = query.toLowerCase();
|
||||
return all
|
||||
.filter(
|
||||
(p) =>
|
||||
!q ||
|
||||
p.code.toLowerCase().includes(q) ||
|
||||
(p.name ?? "").toLowerCase().includes(q),
|
||||
)
|
||||
.map((p) => ({
|
||||
id: p.code,
|
||||
label: p.code + (p.name ? " \u2014 " + p.name : ""),
|
||||
}));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Load category-specific properties.
|
||||
useEffect(() => {
|
||||
if (!category) {
|
||||
setCatPropDefs({});
|
||||
@@ -66,6 +66,87 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
|
||||
});
|
||||
}, [category]);
|
||||
|
||||
const searchProjects = useCallback(
|
||||
async (query: string): Promise<TagOption[]> => {
|
||||
const all = await get<Project[]>("/api/projects");
|
||||
const q = query.toLowerCase();
|
||||
return all
|
||||
.filter(
|
||||
(p) =>
|
||||
!q ||
|
||||
p.code.toLowerCase().includes(q) ||
|
||||
(p.name ?? "").toLowerCase().includes(q),
|
||||
)
|
||||
.map((p) => ({
|
||||
id: p.code,
|
||||
label: p.code + (p.name ? " \u2014 " + p.name : ""),
|
||||
}));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleFilesAdded = useCallback(
|
||||
(files: PendingAttachment[]) => {
|
||||
const startIdx = attachments.length;
|
||||
setAttachments((prev) => [...prev, ...files]);
|
||||
|
||||
// Upload each file.
|
||||
files.forEach((f, i) => {
|
||||
const idx = startIdx + i;
|
||||
// Mark uploading.
|
||||
setAttachments((prev) =>
|
||||
prev.map((a, j) =>
|
||||
j === idx ? { ...a, uploadStatus: "uploading" } : a,
|
||||
),
|
||||
);
|
||||
|
||||
upload(f.file, (progress) => {
|
||||
setAttachments((prev) =>
|
||||
prev.map((a, j) =>
|
||||
j === idx ? { ...a, uploadProgress: progress } : a,
|
||||
),
|
||||
);
|
||||
}).then((result) => {
|
||||
setAttachments((prev) =>
|
||||
prev.map((a, j) => (j === idx ? result : a)),
|
||||
);
|
||||
});
|
||||
});
|
||||
},
|
||||
[attachments.length, upload],
|
||||
);
|
||||
|
||||
const handleFileRemoved = useCallback((index: number) => {
|
||||
setAttachments((prev) => prev.filter((_, i) => i !== index));
|
||||
}, []);
|
||||
|
||||
const handleThumbnailSelect = useCallback(() => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = ".png,.jpg,.jpeg,.webp";
|
||||
input.onchange = () => {
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const pending: PendingAttachment = {
|
||||
file,
|
||||
objectKey: "",
|
||||
uploadProgress: 0,
|
||||
uploadStatus: "uploading",
|
||||
};
|
||||
setThumbnailFile(pending);
|
||||
|
||||
upload(file, (progress) => {
|
||||
setThumbnailFile((prev) =>
|
||||
prev ? { ...prev, uploadProgress: progress } : null,
|
||||
);
|
||||
}).then((result) => {
|
||||
setThumbnailFile(result);
|
||||
});
|
||||
};
|
||||
input.click();
|
||||
}, [upload]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!category) {
|
||||
setError("Category is required");
|
||||
@@ -88,6 +169,7 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
|
||||
schema: "kindred-rd",
|
||||
category,
|
||||
description,
|
||||
item_type: itemType,
|
||||
projects: selectedProjects.length > 0 ? selectedProjects : undefined,
|
||||
properties: Object.keys(properties).length > 0 ? properties : undefined,
|
||||
sourcing_type: sourcingType || undefined,
|
||||
@@ -95,7 +177,41 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
|
||||
long_description: longDescription || undefined,
|
||||
standard_cost: standardCost ? Number(standardCost) : undefined,
|
||||
});
|
||||
onCreated(result.part_number);
|
||||
|
||||
const pn = result.part_number;
|
||||
|
||||
// Associate uploaded attachments.
|
||||
const completed = attachments.filter(
|
||||
(a) => a.uploadStatus === "complete" && a.objectKey,
|
||||
);
|
||||
for (const att of completed) {
|
||||
try {
|
||||
await post(`/api/items/${encodeURIComponent(pn)}/files`, {
|
||||
object_key: att.objectKey,
|
||||
filename: att.file.name,
|
||||
content_type: att.file.type || "application/octet-stream",
|
||||
size: att.file.size,
|
||||
});
|
||||
} catch {
|
||||
// File association failure is non-blocking.
|
||||
}
|
||||
}
|
||||
|
||||
// Set thumbnail.
|
||||
if (
|
||||
thumbnailFile?.uploadStatus === "complete" &&
|
||||
thumbnailFile.objectKey
|
||||
) {
|
||||
try {
|
||||
await put(`/api/items/${encodeURIComponent(pn)}/thumbnail`, {
|
||||
object_key: thumbnailFile.objectKey,
|
||||
});
|
||||
} catch {
|
||||
// Thumbnail failure is non-blocking.
|
||||
}
|
||||
}
|
||||
|
||||
onCreated(pn);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to create item");
|
||||
} finally {
|
||||
@@ -106,17 +222,7 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.75rem",
|
||||
padding: "0.5rem 0.75rem",
|
||||
borderBottom: "1px solid var(--ctp-surface1)",
|
||||
backgroundColor: "var(--ctp-mantle)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<div style={headerStyle}>
|
||||
<span
|
||||
style={{
|
||||
color: "var(--ctp-green)",
|
||||
@@ -131,48 +237,46 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
|
||||
onClick={() => void handleSubmit()}
|
||||
disabled={saving}
|
||||
style={{
|
||||
padding: "0.3rem 0.75rem",
|
||||
fontSize: "0.8rem",
|
||||
border: "none",
|
||||
borderRadius: "0.3rem",
|
||||
...actionBtnStyle,
|
||||
backgroundColor: "var(--ctp-green)",
|
||||
color: "var(--ctp-crust)",
|
||||
cursor: "pointer",
|
||||
opacity: saving ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
{saving ? "Creating..." : "Create"}
|
||||
</button>
|
||||
<button onClick={onCancel} style={headerBtnStyle}>
|
||||
<button onClick={onCancel} style={cancelBtnStyle}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div style={{ flex: 1, overflow: "auto", padding: "0.75rem" }}>
|
||||
{error && (
|
||||
{/* Two-column body */}
|
||||
<div
|
||||
style={{
|
||||
color: "var(--ctp-red)",
|
||||
backgroundColor: "rgba(243,139,168,0.1)",
|
||||
padding: "0.5rem",
|
||||
borderRadius: "0.3rem",
|
||||
marginBottom: "0.5rem",
|
||||
fontSize: "0.85rem",
|
||||
flex: 1,
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 280px",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{/* Left: form */}
|
||||
<div style={{ overflow: "auto", padding: "0.75rem" }}>
|
||||
{error && <div style={errorStyle}>{error}</div>}
|
||||
|
||||
<FormGroup label="Category *">
|
||||
<CategoryPicker
|
||||
value={category}
|
||||
onChange={setCategory}
|
||||
categories={categories}
|
||||
/>
|
||||
{/* Identity section */}
|
||||
<SectionHeader>Identity</SectionHeader>
|
||||
<div style={fieldGridStyle}>
|
||||
<FormGroup label="Type *">
|
||||
<select
|
||||
value={itemType}
|
||||
onChange={(e) => setItemType(e.target.value)}
|
||||
style={inputStyle}
|
||||
>
|
||||
<option value="part">Part</option>
|
||||
<option value="assembly">Assembly</option>
|
||||
<option value="consumable">Consumable</option>
|
||||
<option value="tool">Tool</option>
|
||||
</select>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup label="Description">
|
||||
<input
|
||||
value={description}
|
||||
@@ -181,7 +285,20 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
|
||||
placeholder="Item description"
|
||||
/>
|
||||
</FormGroup>
|
||||
<div style={{ gridColumn: "1 / -1" }}>
|
||||
<FormGroup label="Category *">
|
||||
<CategoryPicker
|
||||
value={category}
|
||||
onChange={setCategory}
|
||||
categories={categories}
|
||||
/>
|
||||
</FormGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sourcing section */}
|
||||
<SectionHeader>Sourcing</SectionHeader>
|
||||
<div style={fieldGridStyle}>
|
||||
<FormGroup label="Sourcing Type">
|
||||
<select
|
||||
value={sourcingType}
|
||||
@@ -190,19 +307,8 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
|
||||
>
|
||||
<option value="manufactured">Manufactured</option>
|
||||
<option value="purchased">Purchased</option>
|
||||
<option value="outsourced">Outsourced</option>
|
||||
</select>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup label="Sourcing Link">
|
||||
<input
|
||||
value={sourcingLink}
|
||||
onChange={(e) => setSourcingLink(e.target.value)}
|
||||
style={inputStyle}
|
||||
placeholder="URL"
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup label="Standard Cost">
|
||||
<input
|
||||
type="number"
|
||||
@@ -213,7 +319,20 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</FormGroup>
|
||||
<div style={{ gridColumn: "1 / -1" }}>
|
||||
<FormGroup label="Sourcing Link">
|
||||
<input
|
||||
value={sourcingLink}
|
||||
onChange={(e) => setSourcingLink(e.target.value)}
|
||||
style={inputStyle}
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</FormGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Details section */}
|
||||
<SectionHeader>Details</SectionHeader>
|
||||
<FormGroup label="Long Description">
|
||||
<textarea
|
||||
value={longDescription}
|
||||
@@ -222,8 +341,6 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
|
||||
placeholder="Detailed description..."
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
{/* Project tags */}
|
||||
<FormGroup label="Projects">
|
||||
<TagInput
|
||||
value={selectedProjects}
|
||||
@@ -236,23 +353,10 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
|
||||
{/* Category properties */}
|
||||
{Object.keys(catPropDefs).length > 0 && (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
borderTop: "1px solid var(--ctp-surface1)",
|
||||
margin: "0.75rem 0 0.5rem",
|
||||
paddingTop: "0.5rem",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.8rem",
|
||||
color: "var(--ctp-subtext0)",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Category Properties
|
||||
</span>
|
||||
</div>
|
||||
<SectionHeader>
|
||||
{categories[category] ?? category} Properties
|
||||
</SectionHeader>
|
||||
<div style={fieldGridStyle}>
|
||||
{Object.entries(catPropDefs).map(([key, def]) => (
|
||||
<FormGroup key={key} label={key}>
|
||||
{def.type === "boolean" ? (
|
||||
@@ -263,7 +367,7 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
|
||||
}
|
||||
style={inputStyle}
|
||||
>
|
||||
<option value="">—</option>
|
||||
<option value="">---</option>
|
||||
<option value="true">true</option>
|
||||
<option value="false">false</option>
|
||||
</select>
|
||||
@@ -279,9 +383,162 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
|
||||
)}
|
||||
</FormGroup>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: sidebar */}
|
||||
<div
|
||||
style={{
|
||||
borderLeft: "1px solid var(--ctp-surface1)",
|
||||
overflow: "auto",
|
||||
padding: "0.75rem",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.75rem",
|
||||
}}
|
||||
>
|
||||
{/* Metadata */}
|
||||
<SidebarSection title="Metadata">
|
||||
<MetaRow label="Revision" value="A" />
|
||||
<MetaRow
|
||||
label="Created By"
|
||||
value={user?.display_name ?? user?.username ?? "---"}
|
||||
/>
|
||||
</SidebarSection>
|
||||
|
||||
{/* Attachments */}
|
||||
<SidebarSection title="Attachments">
|
||||
<FileDropZone
|
||||
files={attachments}
|
||||
onFilesAdded={handleFilesAdded}
|
||||
onFileRemoved={handleFileRemoved}
|
||||
accept=".FCStd,.step,.stl,.pdf,.png,.jpg,.dxf"
|
||||
/>
|
||||
</SidebarSection>
|
||||
|
||||
{/* Thumbnail */}
|
||||
<SidebarSection title="Thumbnail">
|
||||
<div
|
||||
onClick={handleThumbnailSelect}
|
||||
style={{
|
||||
aspectRatio: "4/3",
|
||||
borderRadius: "0.4rem",
|
||||
border: "1px dashed var(--ctp-surface1)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
cursor: "pointer",
|
||||
overflow: "hidden",
|
||||
backgroundColor: "var(--ctp-mantle)",
|
||||
}}
|
||||
>
|
||||
{thumbnailFile?.uploadStatus === "complete" ? (
|
||||
<img
|
||||
src={URL.createObjectURL(thumbnailFile.file)}
|
||||
alt="Thumbnail preview"
|
||||
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
||||
/>
|
||||
) : thumbnailFile?.uploadStatus === "uploading" ? (
|
||||
<span
|
||||
style={{ fontSize: "0.8rem", color: "var(--ctp-subtext0)" }}
|
||||
>
|
||||
Uploading... {thumbnailFile.uploadProgress}%
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
style={{ fontSize: "0.8rem", color: "var(--ctp-subtext0)" }}
|
||||
>
|
||||
Click to upload
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</SidebarSection>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Sub-components ---
|
||||
|
||||
function SectionHeader({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
margin: "0.75rem 0 0.5rem",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.7rem",
|
||||
fontWeight: 600,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
color: "var(--ctp-subtext0)",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
height: 1,
|
||||
backgroundColor: "var(--ctp-surface1)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarSection({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
borderBottom: "1px solid var(--ctp-surface0)",
|
||||
paddingBottom: "0.75rem",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.7rem",
|
||||
fontWeight: 600,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
color: "var(--ctp-subtext0)",
|
||||
marginBottom: "0.4rem",
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetaRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
fontSize: "0.8rem",
|
||||
padding: "0.15rem 0",
|
||||
}}
|
||||
>
|
||||
<span style={{ color: "var(--ctp-subtext0)" }}>{label}</span>
|
||||
<span style={{ color: "var(--ctp-text)" }}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -310,6 +567,36 @@ function FormGroup({
|
||||
);
|
||||
}
|
||||
|
||||
// --- Styles ---
|
||||
|
||||
const headerStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.75rem",
|
||||
padding: "0.5rem 0.75rem",
|
||||
borderBottom: "1px solid var(--ctp-surface1)",
|
||||
backgroundColor: "var(--ctp-mantle)",
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
const actionBtnStyle: React.CSSProperties = {
|
||||
padding: "0.3rem 0.75rem",
|
||||
fontSize: "0.8rem",
|
||||
border: "none",
|
||||
borderRadius: "0.3rem",
|
||||
color: "var(--ctp-crust)",
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
const cancelBtnStyle: React.CSSProperties = {
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
color: "var(--ctp-subtext1)",
|
||||
fontSize: "0.8rem",
|
||||
padding: "0.2rem 0.4rem",
|
||||
};
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: "100%",
|
||||
padding: "0.35rem 0.5rem",
|
||||
@@ -318,13 +605,20 @@ const inputStyle: React.CSSProperties = {
|
||||
border: "1px solid var(--ctp-surface1)",
|
||||
borderRadius: "0.3rem",
|
||||
color: "var(--ctp-text)",
|
||||
boxSizing: "border-box",
|
||||
};
|
||||
|
||||
const headerBtnStyle: React.CSSProperties = {
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
color: "var(--ctp-subtext1)",
|
||||
fontSize: "0.8rem",
|
||||
padding: "0.2rem 0.4rem",
|
||||
const fieldGridStyle: React.CSSProperties = {
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr",
|
||||
gap: "0 1rem",
|
||||
};
|
||||
|
||||
const errorStyle: React.CSSProperties = {
|
||||
color: "var(--ctp-red)",
|
||||
backgroundColor: "rgba(243,139,168,0.1)",
|
||||
padding: "0.5rem",
|
||||
borderRadius: "0.3rem",
|
||||
marginBottom: "0.5rem",
|
||||
fontSize: "0.85rem",
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user