refactor: use shadcn components in NodeEditDialog, add Textarea component
- Replace raw <textarea> with shadcn Textarea component - Replace raw <button> with shadcn Button (variant='destructive' + icon) - Remove custom className overrides fighting admin dark theme - Add shadcn preference rule to AGENTS.md
This commit is contained in:
parent
103340271b
commit
fe863845bb
3 changed files with 45 additions and 36 deletions
|
|
@ -135,6 +135,8 @@ In development, `postgresAdapter` uses `push: true` to auto-sync schema.
|
||||||
|
|
||||||
shadcn/ui components live in `src/components/ui/`. Use `bunx shadcn@latest add <component>` to add new ones. Frontend components in `src/components/frontend/`. Payload admin custom components referenced in `payload.config.ts` under `admin.components`.
|
shadcn/ui components live in `src/components/ui/`. Use `bunx shadcn@latest add <component>` to add new ones. Frontend components in `src/components/frontend/`. Payload admin custom components referenced in `payload.config.ts` under `admin.components`.
|
||||||
|
|
||||||
|
**Rule**: always prefer shadcn/ui components (Dialog, Button, Input, Textarea, Select, etc.) over raw HTML elements or custom-built alternatives. Only build new components when no existing shadcn component fits the need. Do not override shadcn's default dark theme classes with custom `bg-*` `border-*` overrides — the admin panel's theme handles styling.
|
||||||
|
|
||||||
## Environment
|
## Environment
|
||||||
|
|
||||||
- `.env` — local dev (PostgreSQL connection string + PAYLOAD_SECRET)
|
- `.env` — local dev (PostgreSQL connection string + PAYLOAD_SECRET)
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,9 @@ import {
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { PlusIcon, XIcon } from "lucide-react";
|
||||||
import type { Node } from "@xyflow/react";
|
import type { Node } from "@xyflow/react";
|
||||||
|
|
||||||
interface NodeEditDialogProps {
|
interface NodeEditDialogProps {
|
||||||
|
|
@ -66,18 +68,16 @@ export const NodeEditDialog: FC<NodeEditDialogProps> = ({
|
||||||
value={(nodeData.title as string) ?? ""}
|
value={(nodeData.title as string) ?? ""}
|
||||||
onChange={(e) => updateField("title", e.target.value)}
|
onChange={(e) => updateField("title", e.target.value)}
|
||||||
placeholder="Beat title"
|
placeholder="Beat title"
|
||||||
className="bg-zinc-800 border-zinc-700"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="beat-text">Text</Label>
|
<Label htmlFor="beat-text">Text</Label>
|
||||||
<textarea
|
<Textarea
|
||||||
id="beat-text"
|
id="beat-text"
|
||||||
value={(nodeData.text as string) ?? ""}
|
value={(nodeData.text as string) ?? ""}
|
||||||
onChange={(e) => updateField("text", e.target.value)}
|
onChange={(e) => updateField("text", e.target.value)}
|
||||||
placeholder="Describe the story beat..."
|
placeholder="Describe the story beat..."
|
||||||
rows={4}
|
rows={4}
|
||||||
className="flex w-full rounded-md border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-white placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -94,14 +94,13 @@ export const NodeEditDialog: FC<NodeEditDialogProps> = ({
|
||||||
value={(nodeData.question as string) ?? ""}
|
value={(nodeData.question as string) ?? ""}
|
||||||
onChange={(e) => updateField("question", e.target.value)}
|
onChange={(e) => updateField("question", e.target.value)}
|
||||||
placeholder="What decision does the player face?"
|
placeholder="What decision does the player face?"
|
||||||
className="bg-zinc-800 border-zinc-700"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Options</Label>
|
<Label>Options</Label>
|
||||||
{options.map((opt, i) => (
|
{options.map((opt, i) => (
|
||||||
<div key={opt.id} className="flex items-center gap-2">
|
<div key={opt.id} className="flex items-center gap-2">
|
||||||
<span className="text-xs text-zinc-500 w-4">{i + 1}.</span>
|
<span className="text-xs text-muted-foreground w-4 shrink-0">{i + 1}.</span>
|
||||||
<Input
|
<Input
|
||||||
value={opt.label}
|
value={opt.label}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
|
|
@ -111,17 +110,18 @@ export const NodeEditDialog: FC<NodeEditDialogProps> = ({
|
||||||
updateField("options", updated);
|
updateField("options", updated);
|
||||||
}}
|
}}
|
||||||
placeholder="Option label"
|
placeholder="Option label"
|
||||||
className="bg-zinc-800 border-zinc-700"
|
|
||||||
/>
|
/>
|
||||||
<button
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="icon"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const updated = options.filter((o) => o.id !== opt.id);
|
const updated = options.filter((o) => o.id !== opt.id);
|
||||||
updateField("options", updated);
|
updateField("options", updated);
|
||||||
}}
|
}}
|
||||||
className="text-xs text-red-400 hover:text-red-300 cursor-pointer"
|
className="size-8 shrink-0"
|
||||||
>
|
>
|
||||||
Remove
|
<XIcon className="size-3" />
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -134,9 +134,9 @@ export const NodeEditDialog: FC<NodeEditDialogProps> = ({
|
||||||
];
|
];
|
||||||
updateField("options", updated);
|
updateField("options", updated);
|
||||||
}}
|
}}
|
||||||
className="border-zinc-700 text-zinc-300 hover:bg-zinc-800 text-xs"
|
|
||||||
>
|
>
|
||||||
+ Add option
|
<PlusIcon className="size-3 mr-1" />
|
||||||
|
Add option
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -154,18 +154,16 @@ export const NodeEditDialog: FC<NodeEditDialogProps> = ({
|
||||||
value={(nodeData.title as string) ?? ""}
|
value={(nodeData.title as string) ?? ""}
|
||||||
onChange={(e) => updateField("title", e.target.value)}
|
onChange={(e) => updateField("title", e.target.value)}
|
||||||
placeholder="Outcome title"
|
placeholder="Outcome title"
|
||||||
className="bg-zinc-800 border-zinc-700"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="outcome-text">Text</Label>
|
<Label htmlFor="outcome-text">Text</Label>
|
||||||
<textarea
|
<Textarea
|
||||||
id="outcome-text"
|
id="outcome-text"
|
||||||
value={(nodeData.text as string) ?? ""}
|
value={(nodeData.text as string) ?? ""}
|
||||||
onChange={(e) => updateField("text", e.target.value)}
|
onChange={(e) => updateField("text", e.target.value)}
|
||||||
placeholder="Describe the outcome..."
|
placeholder="Describe the outcome..."
|
||||||
rows={3}
|
rows={3}
|
||||||
className="flex w-full rounded-md border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-white placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-emerald-500 resize-none"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
@ -181,7 +179,6 @@ export const NodeEditDialog: FC<NodeEditDialogProps> = ({
|
||||||
updateField("effects", updated);
|
updateField("effects", updated);
|
||||||
}}
|
}}
|
||||||
placeholder="Type (e.g. add_flag)"
|
placeholder="Type (e.g. add_flag)"
|
||||||
className="bg-zinc-800 border-zinc-700 flex-1"
|
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
value={eff.value}
|
value={eff.value}
|
||||||
|
|
@ -192,17 +189,18 @@ export const NodeEditDialog: FC<NodeEditDialogProps> = ({
|
||||||
updateField("effects", updated);
|
updateField("effects", updated);
|
||||||
}}
|
}}
|
||||||
placeholder="Value"
|
placeholder="Value"
|
||||||
className="bg-zinc-800 border-zinc-700 flex-1"
|
|
||||||
/>
|
/>
|
||||||
<button
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="icon"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const updated = effects.filter((_, j) => j !== i);
|
const updated = effects.filter((_, j) => j !== i);
|
||||||
updateField("effects", updated);
|
updateField("effects", updated);
|
||||||
}}
|
}}
|
||||||
className="text-xs text-red-400 hover:text-red-300 cursor-pointer"
|
className="size-8 shrink-0"
|
||||||
>
|
>
|
||||||
Remove
|
<XIcon className="size-3" />
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -212,9 +210,9 @@ export const NodeEditDialog: FC<NodeEditDialogProps> = ({
|
||||||
const updated = [...effects, { type: "", value: "" }];
|
const updated = [...effects, { type: "", value: "" }];
|
||||||
updateField("effects", updated);
|
updateField("effects", updated);
|
||||||
}}
|
}}
|
||||||
className="border-zinc-700 text-zinc-300 hover:bg-zinc-800 text-xs"
|
|
||||||
>
|
>
|
||||||
+ Add effect
|
<PlusIcon className="size-3 mr-1" />
|
||||||
|
Add effect
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -222,7 +220,7 @@ export const NodeEditDialog: FC<NodeEditDialogProps> = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return <p className="text-sm text-zinc-500">Unknown node type.</p>;
|
return <p className="text-sm text-muted-foreground">Unknown node type.</p>;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -237,25 +235,16 @@ export const NodeEditDialog: FC<NodeEditDialogProps> = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="bg-zinc-900 border-zinc-700 text-white max-w-lg">
|
<DialogContent className="max-w-lg">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Edit {typeLabel}</DialogTitle>
|
<DialogTitle>Edit {typeLabel}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{renderFields()}
|
{renderFields()}
|
||||||
<DialogFooter className="gap-2">
|
<DialogFooter className="gap-2">
|
||||||
<Button
|
<Button variant="outline" onClick={handleCancel}>
|
||||||
variant="outline"
|
|
||||||
onClick={handleCancel}
|
|
||||||
className="border-zinc-700 text-zinc-300 hover:bg-zinc-800"
|
|
||||||
>
|
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button onClick={handleSave}>Save</Button>
|
||||||
onClick={handleSave}
|
|
||||||
className="bg-blue-600 hover:bg-blue-700 text-white"
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
|
||||||
18
src/components/ui/textarea.tsx
Normal file
18
src/components/ui/textarea.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
data-slot="textarea"
|
||||||
|
className={cn(
|
||||||
|
"flex field-sizing-content min-h-16 w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:aria-invalid:ring-destructive/40",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Textarea }
|
||||||
Loading…
Reference in a new issue