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 ( return (
<label> <label>
<span>File</span> <span>File</span>
<input type="file" name="file" accept="images/*" multiple /> <input
type="file"
name="file"
accept="images/*"
multiple
onChange={(e) => onChange(e)}
/>
</label> </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, deletedAt: undefined,
}); });
const [frames, setFrames] = useState<Frame[]>([]); const [frames, setFrames] = useState<Frame[]>([]);
const [steps, setSteps] = useState<Steps[]>([]);
const handleUpdateTitle = (title: string) => { const handleUpdateTitle = (title: string) => {
setGuide((prev) => ({ ...prev, title })); setGuide((prev) => ({ ...prev, title }));
@ -21,12 +20,21 @@ export default function useNewGuide() {
setGuide((prev) => ({ ...prev, description })); setGuide((prev) => ({ ...prev, description }));
}; };
const handleAddFrames = (newFiles: File[]) => { const handleAddFrames = (e: React.ChangeEvent<HTMLInputElement>) => {
const newFrameObjects = newFiles.map((file) => ({ const newFiles = e.target.files;
id: crypto.randomUUID(), if (!newFiles) return;
const newFilesArray = Array.from(newFiles);
const newFrameObjects = newFilesArray.map((file) => {
const frameId = crypto.randomUUID();
return {
id: frameId,
file: file, file: file,
guideId: guide.id, guideId: guide.id,
})); steps: [{ id: crypto.randomUUID(), text: "", frameId: frameId }],
};
});
setFrames((prev) => [...prev, ...newFrameObjects]); setFrames((prev) => [...prev, ...newFrameObjects]);
}; };
@ -36,31 +44,53 @@ export default function useNewGuide() {
}; };
const handleAddSteps = (frameId: string) => { const handleAddSteps = (frameId: string) => {
setSteps((prev) => [ setFrames((prev) =>
...prev, prev.map((frame) =>
{ frame.id === frameId
id: crypto.randomUUID(), ? {
text: "", ...frame,
frameId: frameId, steps: [
}, ...frame.steps,
]); { id: crypto.randomUUID(), text: "", frameId: frameId },
],
}
: frame,
),
);
}; };
const handleUpdateStepText = (stepId: string, text: string) => { const handleUpdateStep = (stepId: string, text: string) => {
setSteps((prev) => setFrames((prev) =>
prev.map((step) => (step.id === stepId ? { ...step, text } : step)), prev.map((frame) =>
frame.steps
? {
...frame,
steps: frame.steps.map((step) =>
step.id === stepId ? { ...step, text } : step,
),
}
: frame,
),
); );
}; };
const handleRemoveStep = (stepId: string) => { 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 handleSaveGuide = () => {
const compiledGuide = { const compiledGuide = {
...guide, ...guide,
frames, frames,
steps,
}; };
console.log(compiledGuide); console.log(compiledGuide);
@ -71,14 +101,12 @@ export default function useNewGuide() {
setGuide, setGuide,
frames, frames,
setFrames, setFrames,
steps,
setSteps,
handleUpdateTitle, handleUpdateTitle,
handleUpdateDescription, handleUpdateDescription,
handleAddFrames, handleAddFrames,
handleAddSteps, handleAddSteps,
handleRemoveFrame, handleRemoveFrame,
handleUpdateStepText, handleUpdateStep,
handleRemoveStep, handleRemoveStep,
handleSaveGuide, 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 FileUpload from "../components/FileUpload";
import useNewGuide from "../hooks/useNewGuide";
import Frame from "../components/Frame";
export default function NewGuide() { 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 ( return (
<div> <div className="new-guide">
<form> <form className="new-guide__form" onSubmit={handleSubmitForm}>
<label> <label>
<span>Title</span> <span>Title</span>
<input type="text" name="title" /> <input type="text" name="title" />
@ -13,7 +32,19 @@ export default function NewGuide() {
<textarea name="description" /> <textarea name="description" />
</label> </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> </form>
</div> </div>
); );

View file

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

View file

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