304 lines
9.3 KiB
TypeScript
304 lines
9.3 KiB
TypeScript
import React from "react";
|
|
import { ChevronDown, ChevronUp, Check, Upload } from "lucide-react";
|
|
|
|
export const IS: React.CSSProperties = {
|
|
width: "100%",
|
|
padding: "8px 12px",
|
|
border: "1px solid var(--border)",
|
|
borderRadius: "var(--radius-button)",
|
|
background: "var(--input-background)",
|
|
fontSize: "var(--text-sm)",
|
|
fontFamily: "var(--font-noto-sans)",
|
|
color: "var(--card-foreground)",
|
|
outline: "none",
|
|
boxSizing: "border-box",
|
|
};
|
|
export const SS: React.CSSProperties = { ...IS, cursor: "pointer", appearance: "none", paddingRight: "32px" };
|
|
|
|
export function Label({ text, required }: { text: string; required?: boolean }) {
|
|
return (
|
|
<label
|
|
style={{
|
|
display: "block",
|
|
fontSize: "var(--text-sm)",
|
|
fontFamily: "var(--font-noto-sans)",
|
|
fontWeight: "var(--font-weight-medium)",
|
|
color: "var(--card-foreground)",
|
|
marginBottom: "5px",
|
|
}}
|
|
>
|
|
{required && <span style={{ color: "var(--destructive)", marginRight: "2px" }}>*</span>}
|
|
{text}
|
|
</label>
|
|
);
|
|
}
|
|
|
|
export function Field({ children, error }: { children: React.ReactNode; error?: string }) {
|
|
return (
|
|
<div style={{ display: "flex", flexDirection: "column" }}>
|
|
{children}
|
|
{error ? (
|
|
<p style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--destructive)", margin: "5px 0 0", lineHeight: 1.35 }}>{error}</p>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function Row({ children }: { children: React.ReactNode }) {
|
|
return <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "12px" }}>{children}</div>;
|
|
}
|
|
|
|
export function SelectWrap({ children }: { children: React.ReactNode }) {
|
|
return (
|
|
<div style={{ position: "relative" }}>
|
|
{children}
|
|
<ChevronDown size={14} style={{ position: "absolute", right: 10, top: "50%", transform: "translateY(-50%)", color: "var(--muted)", pointerEvents: "none" }} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function RadioGroup({
|
|
name,
|
|
value,
|
|
onChange,
|
|
options,
|
|
disabled,
|
|
}: {
|
|
name: string;
|
|
value: string;
|
|
onChange: (v: string) => void;
|
|
options: { label: string; value: string }[];
|
|
disabled?: boolean;
|
|
}) {
|
|
return (
|
|
<div style={{ display: "flex", gap: "20px", alignItems: "center", flexWrap: "wrap", opacity: disabled ? 0.55 : 1 }}>
|
|
{options.map((o) => (
|
|
<label
|
|
key={o.value}
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: "6px",
|
|
cursor: disabled ? "not-allowed" : "pointer",
|
|
fontSize: "var(--text-sm)",
|
|
fontFamily: "var(--font-noto-sans)",
|
|
color: "var(--card-foreground)",
|
|
}}
|
|
>
|
|
<input
|
|
type="radio"
|
|
name={name}
|
|
value={o.value}
|
|
checked={value === o.value}
|
|
disabled={disabled}
|
|
onChange={() => onChange(o.value)}
|
|
style={{ accentColor: "var(--primary)", width: "16px", height: "16px", cursor: disabled ? "not-allowed" : "pointer" }}
|
|
/>
|
|
{o.label}
|
|
</label>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function UploadZone({
|
|
label,
|
|
preview,
|
|
error,
|
|
inputRef,
|
|
onFile,
|
|
}: {
|
|
label: string;
|
|
preview: string | null;
|
|
error?: string;
|
|
inputRef: React.RefObject<HTMLInputElement | null>;
|
|
onFile: (dataUrl: string) => void;
|
|
}) {
|
|
return (
|
|
<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}>
|
|
<div
|
|
onClick={() => inputRef.current?.click()}
|
|
onDragOver={(e) => e.preventDefault()}
|
|
onDrop={(e) => {
|
|
e.preventDefault();
|
|
const file = e.dataTransfer.files[0];
|
|
if (file?.type.startsWith("image/")) {
|
|
const r = new FileReader();
|
|
r.onload = () => {
|
|
if (typeof r.result === "string") onFile(r.result);
|
|
};
|
|
r.readAsDataURL(file);
|
|
}
|
|
}}
|
|
style={{
|
|
border: `1.5px dashed ${error ? "var(--destructive)" : "var(--primary)"}`,
|
|
borderRadius: "var(--radius-button)",
|
|
background: "var(--secondary)",
|
|
padding: "16px 12px",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
alignItems: "center",
|
|
gap: "6px",
|
|
cursor: "pointer",
|
|
}}
|
|
>
|
|
<input
|
|
ref={inputRef}
|
|
type="file"
|
|
accept="image/*"
|
|
style={{ display: "none" }}
|
|
onChange={(e) => {
|
|
const file = e.target.files?.[0];
|
|
if (file) {
|
|
const r = new FileReader();
|
|
r.onload = () => {
|
|
if (typeof r.result === "string") onFile(r.result);
|
|
};
|
|
r.readAsDataURL(file);
|
|
}
|
|
}}
|
|
/>
|
|
{preview ? (
|
|
<img src={preview} alt="" style={{ maxHeight: 72, maxWidth: "100%", objectFit: "contain", borderRadius: 4 }} />
|
|
) : (
|
|
<Upload size={22} style={{ color: "var(--primary)" }} />
|
|
)}
|
|
<p style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", textAlign: "center", margin: 0 }}>
|
|
Drag and drop or <span style={{ color: "var(--primary)", textDecoration: "underline" }}>select {label}</span>
|
|
</p>
|
|
</div>
|
|
{error ? <p style={{ fontSize: "var(--text-xs)", color: "var(--destructive)", margin: 0 }}>{error}</p> : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function StepCircle({ n, state }: { n: number; state: "todo" | "active" | "done" }) {
|
|
const bg = state === "todo" ? "var(--muted)" : "var(--primary)";
|
|
return (
|
|
<div
|
|
style={{
|
|
width: 28,
|
|
height: 28,
|
|
borderRadius: "50%",
|
|
background: bg,
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
{state === "done" ? (
|
|
<Check size={13} style={{ color: "#fff" }} />
|
|
) : (
|
|
<span style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-inter)", color: "#fff", fontWeight: "var(--font-weight-medium)" }}>{n}</span>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function AccordionSection({
|
|
n,
|
|
title,
|
|
subtitle,
|
|
open,
|
|
completed,
|
|
onToggle,
|
|
children,
|
|
onNext,
|
|
nextLabel,
|
|
nextDisabled,
|
|
}: {
|
|
n: number;
|
|
title: string;
|
|
subtitle: string;
|
|
open: boolean;
|
|
completed: boolean;
|
|
onToggle: () => void;
|
|
children: React.ReactNode;
|
|
onNext: () => void;
|
|
nextLabel: string;
|
|
nextDisabled?: boolean;
|
|
}) {
|
|
const state: "todo" | "active" | "done" = open ? "active" : completed ? "done" : "todo";
|
|
return (
|
|
<div style={{ borderBottom: "1px solid var(--border)" }}>
|
|
<button
|
|
type="button"
|
|
onClick={onToggle}
|
|
style={{
|
|
width: "100%",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: "12px",
|
|
padding: "14px 20px",
|
|
background: "none",
|
|
border: "none",
|
|
cursor: "pointer",
|
|
textAlign: "left",
|
|
}}
|
|
>
|
|
<StepCircle n={n} state={state} />
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<p
|
|
style={{
|
|
fontSize: "var(--text-sm)",
|
|
fontFamily: "var(--font-inter)",
|
|
fontWeight: "var(--font-weight-medium)",
|
|
color: open ? "var(--primary)" : "var(--card-foreground)",
|
|
margin: 0,
|
|
}}
|
|
>
|
|
{title}
|
|
</p>
|
|
{!open && <p style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", margin: 0 }}>{subtitle}</p>}
|
|
</div>
|
|
{open ? <ChevronUp size={18} style={{ color: "var(--muted)" }} /> : <ChevronDown size={18} style={{ color: "var(--muted)" }} />}
|
|
</button>
|
|
{open && (
|
|
<div style={{ padding: "0 20px 16px" }}>
|
|
<p style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", margin: "0 0 14px 40px" }}>{subtitle}</p>
|
|
<div style={{ display: "flex", flexDirection: "column", gap: "12px" }}>{children}</div>
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "flex-end",
|
|
gap: "12px",
|
|
marginTop: "18px",
|
|
paddingTop: "12px",
|
|
borderTop: "1px solid var(--border)",
|
|
}}
|
|
>
|
|
{completed && (
|
|
<div style={{ display: "flex", alignItems: "center", gap: "5px", fontSize: "var(--text-xs)", color: "var(--primary)" }}>
|
|
<Check size={12} /> Complete
|
|
</div>
|
|
)}
|
|
<button
|
|
type="button"
|
|
disabled={nextDisabled}
|
|
onClick={onNext}
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: "6px",
|
|
padding: "6px 16px",
|
|
borderRadius: "var(--radius-button)",
|
|
background: nextDisabled ? "var(--secondary)" : "var(--primary)",
|
|
color: nextDisabled ? "var(--muted)" : "var(--primary-foreground)",
|
|
border: "none",
|
|
cursor: nextDisabled ? "not-allowed" : "pointer",
|
|
fontSize: "var(--text-sm)",
|
|
fontFamily: "var(--font-noto-sans)",
|
|
fontWeight: "var(--font-weight-medium)",
|
|
}}
|
|
>
|
|
{nextLabel}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|