refactor(guides): colocate steps within frames state

- Remove standalone steps state; steps now live inside each Frame object
- Initialize each frame with one default empty step on file upload
- Update handleAddSteps, handleUpdateStep, handleRemoveStep to operate
  on frames array via nested map/filter
- Rename handleUpdateStepText -> handleUpdateStep
- Rename schema types to StepType, FrameType, GuideType
- Add steps and title fields to FrameSchema
- Wire FileUpload onChange and render Frame components in NewGuide
- Add NewGuide.css and basic input/textarea border styles
This commit is contained in:
johannes-hernehult 2026-06-12 11:10:02 +02:00
parent d1df5b16ed
commit 1bb6ddf403
8 changed files with 193 additions and 31 deletions

View file

@ -1,8 +1,18 @@
export default function FileUpload() {
export default function FileUpload({
onChange,
}: {
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
}) {
return (
<label>
<span>File</span>
<input type="file" name="file" accept="images/*" multiple />
<input
type="file"
name="file"
accept="images/*"
multiple
onChange={(e) => onChange(e)}
/>
</label>
);
}

View file

@ -0,0 +1,33 @@
import type { FrameType, StepType } from "../../../lib/schemas";
import Step from "./Step";
export default function Frame({
frame,
onRemove,
addStep,
removeStep,
updateStep,
}: {
frame: FrameType;
onRemove: (frameId: string) => void;
addStep: (frameId: string) => void;
removeStep: (stepId: string) => void;
updateStep: (stepId: string, text: string) => void;
}) {
return (
<div>
<button onClick={() => onRemove(frame.id)}>Remove Frame</button>
<img src={URL.createObjectURL(frame.file)} alt={frame.title} />
<button onClick={() => addStep(frame.id)}>Add Step</button>
{frame.steps.map((step: StepType, index: number) => (
<Step
key={step.id}
step={step}
index={index}
removeStep={removeStep}
updateStep={updateStep}
/>
))}
</div>
);
}

View file

@ -0,0 +1,27 @@
import type { StepType } from "../../../lib/schemas";
export default function Step({
step,
removeStep,
updateStep,
index,
}: {
step: StepType;
removeStep: (stepId: string) => void;
updateStep: (stepId: string, text: string) => void;
index: number;
}) {
return (
<div className={"step " + index}>
<label>
<span>{index + 1}. </span>
<input
type="text"
value={step.text}
onChange={(e) => updateStep(step.id, e.target.value)}
/>
</label>
<button onClick={() => removeStep(step.id)}>Remove Step</button>
</div>
);
}

View file

@ -11,7 +11,6 @@ export default function useNewGuide() {
deletedAt: undefined,
});
const [frames, setFrames] = useState<Frame[]>([]);
const [steps, setSteps] = useState<Steps[]>([]);
const handleUpdateTitle = (title: string) => {
setGuide((prev) => ({ ...prev, title }));
@ -21,12 +20,21 @@ export default function useNewGuide() {
setGuide((prev) => ({ ...prev, description }));
};
const handleAddFrames = (newFiles: File[]) => {
const newFrameObjects = newFiles.map((file) => ({
id: crypto.randomUUID(),
file: file,
guideId: guide.id,
}));
const handleAddFrames = (e: React.ChangeEvent<HTMLInputElement>) => {
const newFiles = e.target.files;
if (!newFiles) return;
const newFilesArray = Array.from(newFiles);
const newFrameObjects = newFilesArray.map((file) => {
const frameId = crypto.randomUUID();
return {
id: frameId,
file: file,
guideId: guide.id,
steps: [{ id: crypto.randomUUID(), text: "", frameId: frameId }],
};
});
setFrames((prev) => [...prev, ...newFrameObjects]);
};
@ -36,31 +44,53 @@ export default function useNewGuide() {
};
const handleAddSteps = (frameId: string) => {
setSteps((prev) => [
...prev,
{
id: crypto.randomUUID(),
text: "",
frameId: frameId,
},
]);
setFrames((prev) =>
prev.map((frame) =>
frame.id === frameId
? {
...frame,
steps: [
...frame.steps,
{ id: crypto.randomUUID(), text: "", frameId: frameId },
],
}
: frame,
),
);
};
const handleUpdateStepText = (stepId: string, text: string) => {
setSteps((prev) =>
prev.map((step) => (step.id === stepId ? { ...step, text } : step)),
const handleUpdateStep = (stepId: string, text: string) => {
setFrames((prev) =>
prev.map((frame) =>
frame.steps
? {
...frame,
steps: frame.steps.map((step) =>
step.id === stepId ? { ...step, text } : step,
),
}
: frame,
),
);
};
const handleRemoveStep = (stepId: string) => {
setSteps((prev) => prev.filter((step) => step.id !== stepId));
setFrames((prev) =>
prev.map((frame) =>
frame.steps
? {
...frame,
steps: frame.steps.filter((step) => step.id !== stepId),
}
: frame,
),
);
};
const handleSaveGuide = () => {
const compiledGuide = {
...guide,
frames,
steps,
};
console.log(compiledGuide);
@ -71,14 +101,12 @@ export default function useNewGuide() {
setGuide,
frames,
setFrames,
steps,
setSteps,
handleUpdateTitle,
handleUpdateDescription,
handleAddFrames,
handleAddSteps,
handleRemoveFrame,
handleUpdateStepText,
handleUpdateStep,
handleRemoveStep,
handleSaveGuide,
};

View file

@ -0,0 +1,19 @@
.new-guide {
display: flex;
flex-direction: column;
align-items: center;
}
.new-guide__form {
display: flex;
flex-direction: column;
gap: 2rem;
width: 100%;
max-width: 540px;
}
.new-guide__form label {
display: flex;
flex-direction: column;
}

View file

@ -1,9 +1,28 @@
import "./NewGuide.css";
import FileUpload from "../components/FileUpload";
import useNewGuide from "../hooks/useNewGuide";
import Frame from "../components/Frame";
export default function NewGuide() {
const {
frames,
handleUpdateTitle,
handleUpdateDescription,
handleAddFrames,
handleAddSteps,
handleRemoveFrame,
handleRemoveStep,
handleUpdateStep,
handleSaveGuide,
} = useNewGuide();
const handleSubmitForm = (e: React.SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
console.log(e.currentTarget);
};
return (
<div>
<form>
<div className="new-guide">
<form className="new-guide__form" onSubmit={handleSubmitForm}>
<label>
<span>Title</span>
<input type="text" name="title" />
@ -13,7 +32,19 @@ export default function NewGuide() {
<textarea name="description" />
</label>
<FileUpload />
<FileUpload onChange={handleAddFrames} />
{frames.length > 0 &&
frames.map((frame, index) => (
<Frame
key={index}
frame={frame}
onRemove={handleRemoveFrame}
addStep={handleAddSteps}
removeStep={handleRemoveStep}
updateStep={handleUpdateStep}
/>
))}
</form>
</div>
);

View file

@ -8,8 +8,10 @@ export const StepsSchema = z.object({
export const FrameSchema = z.object({
id: z.uuid().default(() => crypto.randomUUID()),
title: z.string().min(1, "Title is required"),
file: z.instanceof(File),
guideId: z.uuid(),
steps: z.array(StepsSchema),
});
export const GuideSchema = z.object({
@ -21,6 +23,6 @@ export const GuideSchema = z.object({
deletedAt: z.date().optional(),
});
export type Steps = z.infer<typeof StepsSchema>;
export type Frame = z.infer<typeof FrameSchema>;
export type Guide = z.infer<typeof GuideSchema>;
export type StepType = z.infer<typeof StepsSchema>;
export type FrameType = z.infer<typeof FrameSchema>;
export type GuideType = z.infer<typeof GuideSchema>;

View file

@ -68,3 +68,15 @@ textarea {
font-size: inherit;
color: inherit;
}
input,
textarea {
border: 3px solid var(--color-border);
border-radius: 4px;
padding: 0.25rem;
}
textarea {
resize: none;
height: 5rem;
}