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:
parent
d1df5b16ed
commit
1bb6ddf403
8 changed files with 193 additions and 31 deletions
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
33
src/features/guides/components/Frame.tsx
Normal file
33
src/features/guides/components/Frame.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
src/features/guides/components/Step.tsx
Normal file
27
src/features/guides/components/Step.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
19
src/features/guides/routes/NewGuide.css
Normal file
19
src/features/guides/routes/NewGuide.css
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue