Add tags to commssion types and custom types. Add button for example images to cards

This commit is contained in:
2026-02-02 17:00:03 +01:00
parent 93a327c634
commit c915df904d
25 changed files with 617 additions and 367 deletions

View File

@ -0,0 +1,16 @@
-- CreateTable
CREATE TABLE "_CommissionCustomCardTags" (
"A" TEXT NOT NULL,
"B" TEXT NOT NULL,
CONSTRAINT "_CommissionCustomCardTags_AB_pkey" PRIMARY KEY ("A","B")
);
-- CreateIndex
CREATE INDEX "_CommissionCustomCardTags_B_index" ON "_CommissionCustomCardTags"("B");
-- AddForeignKey
ALTER TABLE "_CommissionCustomCardTags" ADD CONSTRAINT "_CommissionCustomCardTags_A_fkey" FOREIGN KEY ("A") REFERENCES "CommissionCustomCard"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_CommissionCustomCardTags" ADD CONSTRAINT "_CommissionCustomCardTags_B_fkey" FOREIGN KEY ("B") REFERENCES "Tag"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -229,6 +229,7 @@ model Tag {
categoryParents TagCategory[] @relation("TagCategoryParent") categoryParents TagCategory[] @relation("TagCategoryParent")
artworks Artwork[] @relation("ArtworkTags") artworks Artwork[] @relation("ArtworkTags")
commissionTypes CommissionType[] @relation("CommissionTypeTags") commissionTypes CommissionType[] @relation("CommissionTypeTags")
commissionCustomCards CommissionCustomCard[] @relation("CommissionCustomCardTags")
miniatures Miniature[] @relation("MiniatureTags") miniatures Miniature[] @relation("MiniatureTags")
} }
@ -315,6 +316,7 @@ model CommissionCustomCard {
isVisible Boolean @default(true) isVisible Boolean @default(true)
isSpecialOffer Boolean @default(false) isSpecialOffer Boolean @default(false)
tags Tag[] @relation("CommissionCustomCardTags")
options CommissionCustomCardOption[] options CommissionCustomCardOption[]
extras CommissionCustomCardExtra[] extras CommissionCustomCardExtra[]
requests CommissionRequest[] requests CommissionRequest[]

View File

@ -25,6 +25,9 @@ export async function createCommissionCustomCard(
referenceImageUrl: data.referenceImageUrl ?? null, referenceImageUrl: data.referenceImageUrl ?? null,
isVisible: data.isVisible ?? true, isVisible: data.isVisible ?? true,
isSpecialOffer: data.isSpecialOffer ?? false, isSpecialOffer: data.isSpecialOffer ?? false,
tags: data.tagIds?.length
? { connect: data.tagIds.map((id) => ({ id })) }
: undefined,
options: { options: {
create: create:
data.options?.map((opt, index) => ({ data.options?.map((opt, index) => ({

View File

@ -20,6 +20,12 @@ export async function updateCommissionCustomCard(
referenceImageUrl: data.referenceImageUrl ?? null, referenceImageUrl: data.referenceImageUrl ?? null,
isVisible: data.isVisible ?? true, isVisible: data.isVisible ?? true,
isSpecialOffer: data.isSpecialOffer ?? false, isSpecialOffer: data.isSpecialOffer ?? false,
tags: data.tagIds
? {
set: [],
connect: data.tagIds.map((id) => ({ id })),
}
: undefined,
options: { options: {
deleteMany: {}, deleteMany: {},
create: data.options?.map((opt, index) => ({ create: data.options?.map((opt, index) => ({

View File

@ -1,7 +1,7 @@
"use server" "use server";
import { prisma } from "@/lib/prisma" import { prisma } from "@/lib/prisma";
import { commissionTypeSchema } from "@/schemas/commissionType" import { commissionTypeSchema } from "@/schemas/commissionType";
export async function createCommissionOption(data: { name: string }) { export async function createCommissionOption(data: { name: string }) {
return await prisma.commissionOption.create({ return await prisma.commissionOption.create({
@ -9,7 +9,7 @@ export async function createCommissionOption(data: { name: string }) {
name: data.name, name: data.name,
description: "", description: "",
}, },
}) });
} }
export async function createCommissionExtra(data: { name: string }) { export async function createCommissionExtra(data: { name: string }) {
@ -18,37 +18,41 @@ export async function createCommissionExtra(data: { name: string }) {
name: data.name, name: data.name,
description: "", description: "",
}, },
}) });
} }
export async function createCommissionCustomInput(data: { export async function createCommissionCustomInput(data: {
name: string name: string;
fieldId: string fieldId: string;
}) { }) {
return await prisma.commissionCustomInput.create({ return await prisma.commissionCustomInput.create({
data: { data: {
name: data.name, name: data.name,
fieldId: data.fieldId, fieldId: data.fieldId,
}, },
}) });
} }
export async function createCommissionType(formData: commissionTypeSchema) { export async function createCommissionType(formData: commissionTypeSchema) {
const parsed = commissionTypeSchema.safeParse(formData) const parsed = commissionTypeSchema.safeParse(formData);
if (!parsed.success) { if (!parsed.success) {
console.error("Validation failed", parsed.error) console.error("Validation failed", parsed.error);
throw new Error("Invalid input") throw new Error("Invalid input");
} }
const data = parsed.data const data = parsed.data;
const created = await prisma.commissionType.create({ const created = await prisma.commissionType.create({
data: { data: {
name: data.name, name: data.name,
description: data.description, description: data.description,
tags: data.tagIds?.length
? { connect: data.tagIds.map((id) => ({ id })) }
: undefined,
options: { options: {
create: data.options?.map((opt, index) => ({ create:
data.options?.map((opt, index) => ({
option: { connect: { id: opt.optionId } }, option: { connect: { id: opt.optionId } },
price: opt.price, price: opt.price,
pricePercent: opt.pricePercent, pricePercent: opt.pricePercent,
@ -57,7 +61,8 @@ export async function createCommissionType(formData: commissionTypeSchema) {
})) || [], })) || [],
}, },
extras: { extras: {
create: data.extras?.map((ext, index) => ({ create:
data.extras?.map((ext, index) => ({
extra: { connect: { id: ext.extraId } }, extra: { connect: { id: ext.extraId } },
price: ext.price, price: ext.price,
pricePercent: ext.pricePercent, pricePercent: ext.pricePercent,
@ -66,7 +71,8 @@ export async function createCommissionType(formData: commissionTypeSchema) {
})) || [], })) || [],
}, },
customInputs: { customInputs: {
create: data.customInputs?.map((c, index) => ({ create:
data.customInputs?.map((c, index) => ({
customInput: { connect: { id: c.customInputId } }, customInput: { connect: { id: c.customInputId } },
label: c.label, label: c.label,
inputType: c.inputType, inputType: c.inputType,
@ -75,7 +81,7 @@ export async function createCommissionType(formData: commissionTypeSchema) {
})) || [], })) || [],
}, },
}, },
}) });
return created return created;
} }

View File

@ -1,20 +1,26 @@
"use server" "use server";
import { prisma } from "@/lib/prisma" import { prisma } from "@/lib/prisma";
import { commissionTypeSchema } from "@/schemas/commissionType" import { commissionTypeSchema } from "@/schemas/commissionType";
import * as z from "zod/v4" import type * as z from "zod/v4";
export async function updateCommissionType( export async function updateCommissionType(
id: string, id: string,
rawData: z.infer<typeof commissionTypeSchema> rawData: z.infer<typeof commissionTypeSchema>,
) { ) {
const data = commissionTypeSchema.parse(rawData) const data = commissionTypeSchema.parse(rawData);
const updated = await prisma.commissionType.update({ const updated = await prisma.commissionType.update({
where: { id }, where: { id },
data: { data: {
name: data.name, name: data.name,
description: data.description, description: data.description,
tags: data.tagIds
? {
set: [],
connect: data.tagIds.map((id) => ({ id })),
}
: undefined,
options: { options: {
deleteMany: {}, deleteMany: {},
create: data.options?.map((opt, index) => ({ create: data.options?.map((opt, index) => ({
@ -37,7 +43,8 @@ export async function updateCommissionType(
}, },
customInputs: { customInputs: {
deleteMany: {}, deleteMany: {},
create: data.customInputs?.map((c, index) => ({ create:
data.customInputs?.map((c, index) => ({
customInput: { connect: { id: c.customInputId } }, customInput: { connect: { id: c.customInputId } },
label: c.label, label: c.label,
inputType: c.inputType, inputType: c.inputType,
@ -51,7 +58,7 @@ export async function updateCommissionType(
extras: true, extras: true,
customInputs: true, customInputs: true,
}, },
}) });
return updated return updated;
} }

View File

@ -1,5 +0,0 @@
"use server";
export async function migrateArtTags() {
throw new Error("Migration disabled: ArtTag models removed.");
}

View File

@ -1,75 +0,0 @@
"use server";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
type JoinMigrationResult = {
ok: boolean;
copied: number;
oldExists: boolean;
newExists: boolean;
droppedOld: boolean;
message?: string;
};
export async function migrateArtworkTagJoin(
opts: { dropOld?: boolean } = {},
): Promise<JoinMigrationResult> {
const dropOld = Boolean(opts.dropOld);
const [oldRow, newRow] = await Promise.all([
prisma.$queryRaw<{ name: string | null }[]>`
select to_regclass('_ArtworkTagsV2')::text as name;
`,
prisma.$queryRaw<{ name: string | null }[]>`
select to_regclass('_ArtworkTags')::text as name;
`,
]);
const oldExists = Boolean(oldRow?.[0]?.name);
const newExists = Boolean(newRow?.[0]?.name);
if (!newExists) {
return {
ok: false,
copied: 0,
oldExists,
newExists,
droppedOld: false,
message: "New join table _ArtworkTags does not exist. Run the migration first.",
};
}
if (!oldExists) {
return {
ok: true,
copied: 0,
oldExists,
newExists,
droppedOld: false,
message: "Old join table _ArtworkTagsV2 not found. Nothing to copy.",
};
}
const copied = await prisma.$executeRawUnsafe(`
INSERT INTO "_ArtworkTags" ("A","B")
SELECT "A","B" FROM "_ArtworkTagsV2"
ON CONFLICT ("A","B") DO NOTHING;
`);
let droppedOld = false;
if (dropOld) {
await prisma.$executeRawUnsafe(`DROP TABLE "_ArtworkTagsV2";`);
droppedOld = true;
}
revalidatePath("/tags");
return {
ok: true,
copied: Number(copied ?? 0),
oldExists,
newExists,
droppedOld,
};
}

View File

@ -10,17 +10,19 @@ export default async function CommissionCustomCardEditPage({
}) { }) {
const { id } = await params; const { id } = await params;
const [card, options, extras, images] = await Promise.all([ const [card, options, extras, images, tags] = await Promise.all([
prisma.commissionCustomCard.findUnique({ prisma.commissionCustomCard.findUnique({
where: { id }, where: { id },
include: { include: {
options: { orderBy: { sortIndex: "asc" } }, options: { orderBy: { sortIndex: "asc" } },
extras: { orderBy: { sortIndex: "asc" } }, extras: { orderBy: { sortIndex: "asc" } },
tags: true,
}, },
}), }),
prisma.commissionOption.findMany({ orderBy: [{ sortIndex: "asc" }, { name: "asc" }] }), prisma.commissionOption.findMany({ orderBy: [{ sortIndex: "asc" }, { name: "asc" }] }),
prisma.commissionExtra.findMany({ orderBy: [{ sortIndex: "asc" }, { name: "asc" }] }), prisma.commissionExtra.findMany({ orderBy: [{ sortIndex: "asc" }, { name: "asc" }] }),
listCommissionCustomCardImages(), listCommissionCustomCardImages(),
prisma.tag.findMany({ orderBy: [{ sortIndex: "asc" }, { name: "asc" }] }),
]); ]);
if (!card) { if (!card) {
@ -37,6 +39,7 @@ export default async function CommissionCustomCardEditPage({
allOptions={options} allOptions={options}
allExtras={extras} allExtras={extras}
images={images} images={images}
allTags={tags}
/> />
</div> </div>
); );

View File

@ -3,10 +3,11 @@ import NewCustomCardForm from "@/components/commissions/customCards/NewCustomCar
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
export default async function CommissionCustomCardsNewPage() { export default async function CommissionCustomCardsNewPage() {
const [options, extras, images] = await Promise.all([ const [options, extras, images, tags] = await Promise.all([
prisma.commissionOption.findMany({ orderBy: [{ sortIndex: "asc" }, { name: "asc" }] }), prisma.commissionOption.findMany({ orderBy: [{ sortIndex: "asc" }, { name: "asc" }] }),
prisma.commissionExtra.findMany({ orderBy: [{ sortIndex: "asc" }, { name: "asc" }] }), prisma.commissionExtra.findMany({ orderBy: [{ sortIndex: "asc" }, { name: "asc" }] }),
listCommissionCustomCardImages(), listCommissionCustomCardImages(),
prisma.tag.findMany({ orderBy: [{ sortIndex: "asc" }, { name: "asc" }] }),
]); ]);
return ( return (
@ -14,7 +15,7 @@ export default async function CommissionCustomCardsNewPage() {
<div className="flex gap-4 justify-between pb-8"> <div className="flex gap-4 justify-between pb-8">
<h1 className="text-2xl font-bold mb-4">New Custom Commission Card</h1> <h1 className="text-2xl font-bold mb-4">New Custom Commission Card</h1>
</div> </div>
<NewCustomCardForm options={options} extras={extras} images={images} /> <NewCustomCardForm options={options} extras={extras} images={images} tags={tags} />
</div> </div>
); );
} }

View File

@ -11,8 +11,12 @@ export default async function CommissionTypesEditPage({ params }: { params: { id
options: { include: { option: true }, orderBy: { sortIndex: "asc" } }, options: { include: { option: true }, orderBy: { sortIndex: "asc" } },
extras: { include: { extra: true }, orderBy: { sortIndex: "asc" } }, extras: { include: { extra: true }, orderBy: { sortIndex: "asc" } },
customInputs: { include: { customInput: true }, orderBy: { sortIndex: "asc" } }, customInputs: { include: { customInput: true }, orderBy: { sortIndex: "asc" } },
tags: true,
}, },
}) })
const tags = await prisma.tag.findMany({
orderBy: [{ sortIndex: "asc" }, { name: "asc" }],
});
const options = await prisma.commissionOption.findMany({ const options = await prisma.commissionOption.findMany({
orderBy: [{ sortIndex: "asc" }, { name: "asc" }], orderBy: [{ sortIndex: "asc" }, { name: "asc" }],
}); });
@ -32,7 +36,12 @@ export default async function CommissionTypesEditPage({ params }: { params: { id
<div className="flex gap-4 justify-between pb-8"> <div className="flex gap-4 justify-between pb-8">
<h1 className="text-2xl font-bold mb-4">Edit Commission Type</h1> <h1 className="text-2xl font-bold mb-4">Edit Commission Type</h1>
</div> </div>
<EditTypeForm type={commissionType} allOptions={options} allExtras={extras} /> <EditTypeForm
type={commissionType}
allOptions={options}
allExtras={extras}
allTags={tags}
/>
</div> </div>
); );
} }

View File

@ -2,6 +2,9 @@ import NewTypeForm from "@/components/commissions/types/NewTypeForm";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
export default async function CommissionTypesNewPage() { export default async function CommissionTypesNewPage() {
const tags = await prisma.tag.findMany({
orderBy: [{ sortIndex: "asc" }, { name: "asc" }],
});
const options = await prisma.commissionOption.findMany({ const options = await prisma.commissionOption.findMany({
orderBy: [{ sortIndex: "asc" }, { name: "asc" }], orderBy: [{ sortIndex: "asc" }, { name: "asc" }],
}); });
@ -17,7 +20,12 @@ export default async function CommissionTypesNewPage() {
<div className="flex gap-4 justify-between pb-8"> <div className="flex gap-4 justify-between pb-8">
<h1 className="text-2xl font-bold mb-4">New Commission Type</h1> <h1 className="text-2xl font-bold mb-4">New Commission Type</h1>
</div> </div>
<NewTypeForm options={options} extras={extras} customInputs={customInputs} /> <NewTypeForm
options={options}
extras={extras}
customInputs={customInputs}
tags={tags}
/>
</div> </div>
); );

View File

@ -1,20 +1,9 @@
import { migrateArtworkTagJoin } from "@/actions/tags/migrateArtworkTagJoin";
import TagTabs from "@/components/tags/TagTabs"; import TagTabs from "@/components/tags/TagTabs";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { PlusCircleIcon } from "lucide-react"; import { PlusCircleIcon } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
async function migrateArtworkTagJoinCopy() {
"use server";
await migrateArtworkTagJoin();
}
async function migrateArtworkTagJoinDropOld() {
"use server";
await migrateArtworkTagJoin({ dropOld: true });
}
export default async function ArtTagsPage() { export default async function ArtTagsPage() {
const items = await prisma.tag.findMany({ const items = await prisma.tag.findMany({
include: { include: {
@ -56,22 +45,10 @@ export default async function ArtTagsPage() {
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl"> <h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">
Tags Tags
</h1> </h1>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">Manage tags.</p>
Manage tags, aliases, categories, and usage across artworks.
</p>
</div> </div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center"> <div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<form action={migrateArtworkTagJoinCopy}>
<Button type="submit" variant="secondary" className="h-11">
Copy tag relations
</Button>
</form>
<form action={migrateArtworkTagJoinDropOld}>
<Button type="submit" variant="destructive" className="h-11">
Copy + drop old
</Button>
</form>
<Button asChild className="h-11 gap-2"> <Button asChild className="h-11 gap-2">
<Link href="/tags/new"> <Link href="/tags/new">
<PlusCircleIcon className="h-4 w-4" /> <PlusCircleIcon className="h-4 w-4" />
@ -79,17 +56,15 @@ export default async function ArtTagsPage() {
</Link> </Link>
</Button> </Button>
</div> </div>
</header > </header>
{ {rows.length > 0 ? (
rows.length > 0 ? (
<TagTabs tags={rows} /> <TagTabs tags={rows} />
) : ( ) : (
<p className="text-muted-foreground"> <p className="text-muted-foreground">
There are no tags yet. Consider adding some! There are no tags yet. Consider adding some!
</p> </p>
) )}
} </div>
</div >
); );
} }

View File

@ -14,7 +14,7 @@ import {
} from "@/components/ui/form"; } from "@/components/ui/form";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import type { CommissionExtra, CommissionOption } from "@/generated/prisma/client"; import type { CommissionExtra, CommissionOption, Tag } from "@/generated/prisma/client";
import { import {
commissionCustomCardSchema, commissionCustomCardSchema,
type CommissionCustomCardValues, type CommissionCustomCardValues,
@ -26,6 +26,7 @@ import { toast } from "sonner";
import { CommissionExtraField } from "../types/form/CommissionExtraField"; import { CommissionExtraField } from "../types/form/CommissionExtraField";
import { CommissionOptionField } from "../types/form/CommissionOptionField"; import { CommissionOptionField } from "../types/form/CommissionOptionField";
import { CustomCardImagePicker } from "./CustomCardImagePicker"; import { CustomCardImagePicker } from "./CustomCardImagePicker";
import MultipleSelector from "@/components/ui/multiselect";
type CustomCardOption = { type CustomCardOption = {
optionId: string; optionId: string;
@ -48,6 +49,7 @@ type CustomCardWithItems = {
referenceImageUrl: string | null; referenceImageUrl: string | null;
isVisible: boolean; isVisible: boolean;
isSpecialOffer: boolean; isSpecialOffer: boolean;
tags: Tag[];
options: CustomCardOption[]; options: CustomCardOption[];
extras: CustomCardExtra[]; extras: CustomCardExtra[];
}; };
@ -57,6 +59,7 @@ type Props = {
allOptions: CommissionOption[]; allOptions: CommissionOption[];
allExtras: CommissionExtra[]; allExtras: CommissionExtra[];
images: CommissionCustomCardImageItem[]; images: CommissionCustomCardImageItem[];
allTags: Tag[];
}; };
export default function EditCustomCardForm({ export default function EditCustomCardForm({
@ -64,6 +67,7 @@ export default function EditCustomCardForm({
allOptions, allOptions,
allExtras, allExtras,
images, images,
allTags,
}: Props) { }: Props) {
const router = useRouter(); const router = useRouter();
const form = useForm<CommissionCustomCardValues>({ const form = useForm<CommissionCustomCardValues>({
@ -74,6 +78,7 @@ export default function EditCustomCardForm({
isVisible: card.isVisible, isVisible: card.isVisible,
isSpecialOffer: card.isSpecialOffer, isSpecialOffer: card.isSpecialOffer,
referenceImageUrl: card.referenceImageUrl ?? null, referenceImageUrl: card.referenceImageUrl ?? null,
tagIds: card.tags.map((t) => t.id),
options: card.options.map((o) => ({ options: card.options.map((o) => ({
optionId: o.optionId, optionId: o.optionId,
price: o.price ?? undefined, price: o.price ?? undefined,
@ -171,6 +176,37 @@ export default function EditCustomCardForm({
render={() => <CustomCardImagePicker form={form} initialImages={images} />} render={() => <CustomCardImagePicker form={form} initialImages={images} />}
/> />
<FormField
control={form.control}
name="tagIds"
render={({ field }) => {
const selectedIds = field.value ?? [];
const selectedOptions = allTags
.filter((t) => selectedIds.includes(t.id))
.map((t) => ({ label: t.name, value: t.id }));
return (
<FormItem>
<FormLabel>Tags</FormLabel>
<FormControl>
<MultipleSelector
options={allTags.map((t) => ({ label: t.name, value: t.id }))}
placeholder="Select tags for this custom card"
hidePlaceholderWhenSelected
selectFirstItem
value={selectedOptions}
onChange={(options) => field.onChange(options.map((o) => o.value))}
/>
</FormControl>
<FormDescription>
Used to link this custom card to tagged artworks.
</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
<CommissionOptionField options={allOptions} /> <CommissionOptionField options={allOptions} />
<CommissionExtraField extras={allExtras} /> <CommissionExtraField extras={allExtras} />

View File

@ -14,7 +14,7 @@ import {
} from "@/components/ui/form"; } from "@/components/ui/form";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import type { CommissionExtra, CommissionOption } from "@/generated/prisma/client"; import type { CommissionExtra, CommissionOption, Tag } from "@/generated/prisma/client";
import { import {
commissionCustomCardSchema, commissionCustomCardSchema,
type CommissionCustomCardValues, type CommissionCustomCardValues,
@ -26,14 +26,16 @@ import { toast } from "sonner";
import { CommissionExtraField } from "../types/form/CommissionExtraField"; import { CommissionExtraField } from "../types/form/CommissionExtraField";
import { CommissionOptionField } from "../types/form/CommissionOptionField"; import { CommissionOptionField } from "../types/form/CommissionOptionField";
import { CustomCardImagePicker } from "./CustomCardImagePicker"; import { CustomCardImagePicker } from "./CustomCardImagePicker";
import MultipleSelector from "@/components/ui/multiselect";
type Props = { type Props = {
options: CommissionOption[]; options: CommissionOption[];
extras: CommissionExtra[]; extras: CommissionExtra[];
images: CommissionCustomCardImageItem[]; images: CommissionCustomCardImageItem[];
tags: Tag[];
}; };
export default function NewCustomCardForm({ options, extras, images }: Props) { export default function NewCustomCardForm({ options, extras, images, tags }: Props) {
const router = useRouter(); const router = useRouter();
const form = useForm<CommissionCustomCardValues>({ const form = useForm<CommissionCustomCardValues>({
resolver: zodResolver(commissionCustomCardSchema), resolver: zodResolver(commissionCustomCardSchema),
@ -43,6 +45,7 @@ export default function NewCustomCardForm({ options, extras, images }: Props) {
isVisible: true, isVisible: true,
isSpecialOffer: false, isSpecialOffer: false,
referenceImageUrl: null, referenceImageUrl: null,
tagIds: [],
options: [], options: [],
extras: [], extras: [],
}, },
@ -131,6 +134,37 @@ export default function NewCustomCardForm({ options, extras, images }: Props) {
render={() => <CustomCardImagePicker form={form} initialImages={images} />} render={() => <CustomCardImagePicker form={form} initialImages={images} />}
/> />
<FormField
control={form.control}
name="tagIds"
render={({ field }) => {
const selectedIds = field.value ?? [];
const selectedOptions = tags
.filter((t) => selectedIds.includes(t.id))
.map((t) => ({ label: t.name, value: t.id }));
return (
<FormItem>
<FormLabel>Tags</FormLabel>
<FormControl>
<MultipleSelector
options={tags.map((t) => ({ label: t.name, value: t.id }))}
placeholder="Select tags for this custom card"
hidePlaceholderWhenSelected
selectFirstItem
value={selectedOptions}
onChange={(options) => field.onChange(options.map((o) => o.value))}
/>
</FormControl>
<FormDescription>
Used to link this custom card to tagged artworks.
</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
<CommissionOptionField options={options} /> <CommissionOptionField options={options} />
<CommissionExtraField extras={extras} /> <CommissionExtraField extras={extras} />

View File

@ -1,10 +1,28 @@
"use client" "use client";
import { updateCommissionType } from "@/actions/commissions/types/updateType"; import { updateCommissionType } from "@/actions/commissions/types/updateType";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import type { CommissionCustomInput, CommissionExtra, CommissionOption, CommissionType, CommissionTypeCustomInput, CommissionTypeExtra, CommissionTypeOption } from "@/generated/prisma/client"; import MultipleSelector from "@/components/ui/multiselect";
import type {
CommissionCustomInput,
CommissionExtra,
CommissionOption,
CommissionType,
CommissionTypeCustomInput,
CommissionTypeExtra,
CommissionTypeOption,
Tag,
} from "@/generated/prisma/client";
import { commissionTypeSchema } from "@/schemas/commissionType"; import { commissionTypeSchema } from "@/schemas/commissionType";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
@ -15,25 +33,35 @@ import { CommissionExtraField } from "./form/CommissionExtraField";
import { CommissionOptionField } from "./form/CommissionOptionField"; import { CommissionOptionField } from "./form/CommissionOptionField";
type CommissionTypeWithConnections = CommissionType & { type CommissionTypeWithConnections = CommissionType & {
options: (CommissionTypeOption & { option: CommissionOption })[] options: (CommissionTypeOption & { option: CommissionOption })[];
extras: (CommissionTypeExtra & { extra: CommissionExtra })[] extras: (CommissionTypeExtra & { extra: CommissionExtra })[];
customInputs: (CommissionTypeCustomInput & { customInput: CommissionCustomInput })[] customInputs: (CommissionTypeCustomInput & {
} customInput: CommissionCustomInput;
})[];
tags: Tag[];
};
type Props = { type Props = {
type: CommissionTypeWithConnections type: CommissionTypeWithConnections;
allOptions: CommissionOption[], allOptions: CommissionOption[];
allExtras: CommissionExtra[], allExtras: CommissionExtra[];
allTags: Tag[];
// allCustomInputs: CommissionCustomInput[] // allCustomInputs: CommissionCustomInput[]
} };
export default function EditTypeForm({ type, allOptions, allExtras }: Props) { export default function EditTypeForm({
type,
allOptions,
allExtras,
allTags,
}: Props) {
const router = useRouter(); const router = useRouter();
const form = useForm<z.infer<typeof commissionTypeSchema>>({ const form = useForm<z.infer<typeof commissionTypeSchema>>({
resolver: zodResolver(commissionTypeSchema), resolver: zodResolver(commissionTypeSchema),
defaultValues: { defaultValues: {
name: type.name, name: type.name,
description: type.description ?? "", description: type.description ?? "",
tagIds: type.tags.map((t) => t.id),
options: type.options.map((o) => ({ options: type.options.map((o) => ({
optionId: o.optionId, optionId: o.optionId,
price: o.price ?? undefined, price: o.price ?? undefined,
@ -54,16 +82,16 @@ export default function EditTypeForm({ type, allOptions, allExtras }: Props) {
required: f.required, required: f.required,
})), })),
}, },
}) });
async function onSubmit(values: z.infer<typeof commissionTypeSchema>) { async function onSubmit(values: z.infer<typeof commissionTypeSchema>) {
try { try {
await updateCommissionType(type.id, values) await updateCommissionType(type.id, values);
toast.success("Commission type updated.") toast.success("Commission type updated.");
router.push("/commissions/types") router.push("/commissions/types");
} catch (err) { } catch (err) {
console.error(err) console.error(err);
toast("Failed to create commission type.") toast("Failed to create commission type.");
} }
} }
@ -80,7 +108,9 @@ export default function EditTypeForm({ type, allOptions, allExtras }: Props) {
<FormControl> <FormControl>
<Input {...field} /> <Input {...field} />
</FormControl> </FormControl>
<FormDescription>The name of the commission type.</FormDescription> <FormDescription>
The name of the commission type.
</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
@ -99,6 +129,41 @@ export default function EditTypeForm({ type, allOptions, allExtras }: Props) {
</FormItem> </FormItem>
)} )}
/> />
<FormField
control={form.control}
name="tagIds"
render={({ field }) => {
const selectedIds = field.value ?? [];
const selectedOptions = allTags
.filter((t) => selectedIds.includes(t.id))
.map((t) => ({ label: t.name, value: t.id }));
return (
<FormItem>
<FormLabel>Tags</FormLabel>
<FormControl>
<MultipleSelector
options={allTags.map((t) => ({
label: t.name,
value: t.id,
}))}
placeholder="Select tags for this commission type"
hidePlaceholderWhenSelected
selectFirstItem
value={selectedOptions}
onChange={(options) =>
field.onChange(options.map((o) => o.value))
}
/>
</FormControl>
<FormDescription>
Used to link this commission type to tagged artworks.
</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
<CommissionOptionField options={allOptions} /> <CommissionOptionField options={allOptions} />
<CommissionExtraField extras={allExtras} /> <CommissionExtraField extras={allExtras} />
@ -106,7 +171,13 @@ export default function EditTypeForm({ type, allOptions, allExtras }: Props) {
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<Button type="submit">Submit</Button> <Button type="submit">Submit</Button>
<Button type="reset" variant="secondary" onClick={() => router.back()}>Cancel</Button> <Button
type="reset"
variant="secondary"
onClick={() => router.back()}
>
Cancel
</Button>
</div> </div>
</form> </form>
</Form> </Form>

View File

@ -5,7 +5,15 @@ import { updateCommissionTypeSortOrder } from "@/actions/commissions/types/updat
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { CommissionCustomInput, CommissionExtra, CommissionOption, CommissionType, CommissionTypeCustomInput, CommissionTypeExtra, CommissionTypeOption } from "@/generated/prisma/client"; import {
CommissionCustomInput,
CommissionExtra,
CommissionOption,
CommissionType,
CommissionTypeCustomInput,
CommissionTypeExtra,
CommissionTypeOption,
} from "@/generated/prisma/client";
import { import {
closestCenter, closestCenter,
DndContext, DndContext,

View File

@ -1,47 +1,68 @@
"use client" "use client";
import { createCommissionType } from "@/actions/commissions/types/newType"; import { createCommissionType } from "@/actions/commissions/types/newType";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { CommissionCustomInput, CommissionExtra, CommissionOption } from "@/generated/prisma/client"; import MultipleSelector from "@/components/ui/multiselect";
import type {
CommissionCustomInput,
CommissionExtra,
CommissionOption,
Tag,
} from "@/generated/prisma/client";
import { commissionTypeSchema } from "@/schemas/commissionType"; import { commissionTypeSchema } from "@/schemas/commissionType";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import * as z from "zod/v4"; import type * as z from "zod/v4";
import { CommissionCustomInputField } from "./form/CommissionCustomInputField"; import { CommissionCustomInputField } from "./form/CommissionCustomInputField";
import { CommissionExtraField } from "./form/CommissionExtraField"; import { CommissionExtraField } from "./form/CommissionExtraField";
import { CommissionOptionField } from "./form/CommissionOptionField"; import { CommissionOptionField } from "./form/CommissionOptionField";
type Props = { type Props = {
options: CommissionOption[], options: CommissionOption[];
extras: CommissionExtra[], extras: CommissionExtra[];
customInputs: CommissionCustomInput[] customInputs: CommissionCustomInput[];
} tags: Tag[];
};
export default function NewTypeForm({ options, extras, customInputs }: Props) { export default function NewTypeForm({
options,
extras,
customInputs,
tags,
}: Props) {
const router = useRouter(); const router = useRouter();
const form = useForm<z.infer<typeof commissionTypeSchema>>({ const form = useForm<z.infer<typeof commissionTypeSchema>>({
resolver: zodResolver(commissionTypeSchema), resolver: zodResolver(commissionTypeSchema),
defaultValues: { defaultValues: {
name: "", name: "",
description: "", description: "",
tagIds: [],
options: [], options: [],
extras: [], extras: [],
}, },
}) });
async function onSubmit(values: z.infer<typeof commissionTypeSchema>) { async function onSubmit(values: z.infer<typeof commissionTypeSchema>) {
try { try {
const created = await createCommissionType(values) const created = await createCommissionType(values);
console.log("CommissionType created:", created) console.log("CommissionType created:", created);
toast("Commission type created.") toast("Commission type created.");
router.push("/commissions/types") router.push("/commissions/types");
} catch (err) { } catch (err) {
console.error(err) console.error(err);
toast("Failed to create commission type.") toast("Failed to create commission type.");
} }
} }
@ -58,7 +79,9 @@ export default function NewTypeForm({ options, extras, customInputs }: Props) {
<FormControl> <FormControl>
<Input {...field} /> <Input {...field} />
</FormControl> </FormControl>
<FormDescription>The name of the commission type.</FormDescription> <FormDescription>
The name of the commission type.
</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
@ -77,6 +100,41 @@ export default function NewTypeForm({ options, extras, customInputs }: Props) {
</FormItem> </FormItem>
)} )}
/> />
<FormField
control={form.control}
name="tagIds"
render={({ field }) => {
const selectedIds = field.value ?? [];
const selectedOptions = tags
.filter((t) => selectedIds.includes(t.id))
.map((t) => ({ label: t.name, value: t.id }));
return (
<FormItem>
<FormLabel>Tags</FormLabel>
<FormControl>
<MultipleSelector
options={tags.map((t) => ({
label: t.name,
value: t.id,
}))}
placeholder="Select tags for this commission type"
hidePlaceholderWhenSelected
selectFirstItem
value={selectedOptions}
onChange={(options) =>
field.onChange(options.map((o) => o.value))
}
/>
</FormControl>
<FormDescription>
Used to link this commission type to tagged artworks.
</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
<CommissionOptionField options={options} /> <CommissionOptionField options={options} />
<CommissionExtraField extras={extras} /> <CommissionExtraField extras={extras} />
@ -84,7 +142,13 @@ export default function NewTypeForm({ options, extras, customInputs }: Props) {
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<Button type="submit">Submit</Button> <Button type="submit">Submit</Button>
<Button type="reset" variant="secondary" onClick={() => router.back()}>Cancel</Button> <Button
type="reset"
variant="secondary"
onClick={() => router.back()}
>
Cancel
</Button>
</div> </div>
</form> </form>
</Form> </Form>

View File

@ -1,18 +1,32 @@
"use client" "use client";
import { updateTag } from "@/actions/tags/updateTag"; import { updateTag } from "@/actions/tags/updateTag";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { ArtCategory, Tag, TagAlias } from "@/generated/prisma/client"; import type { ArtCategory, Tag, TagAlias } from "@/generated/prisma/client";
import { TagFormInput, tagSchema } from "@/schemas/artworks/tagSchema"; import { type TagFormInput, tagSchema } from "@/schemas/artworks/tagSchema";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import MultipleSelector from "../ui/multiselect"; import MultipleSelector from "../ui/multiselect";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select"; import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../ui/select";
import { Switch } from "../ui/switch"; import { Switch } from "../ui/switch";
import AliasEditor from "./AliasEditor"; import AliasEditor from "./AliasEditor";
@ -42,19 +56,19 @@ export default function EditTagForm({
isParent: tag.isParent ?? false, isParent: tag.isParent ?? false,
showOnAnimalPage: tag.showOnAnimalPage ?? false, showOnAnimalPage: tag.showOnAnimalPage ?? false,
isVisible: tag.isVisible ?? true, isVisible: tag.isVisible ?? true,
aliases: tag.aliases?.map(a => a.alias) ?? [] aliases: tag.aliases?.map((a) => a.alias) ?? [],
} },
}) });
async function onSubmit(values: TagFormInput) { async function onSubmit(values: TagFormInput) {
try { try {
const updated = await updateTag(tag.id, values) const updated = await updateTag(tag.id, values);
console.log("Tag updated:", updated) console.log("Tag updated:", updated);
toast("Tag updated.") toast("Tag updated.");
router.push("/tags") router.push("/tags");
} catch (err) { } catch (err) {
console.error(err) console.error(err);
toast("Failed to update tag.") toast("Failed to update tag.");
} }
} }
@ -64,6 +78,10 @@ export default function EditTagForm({
return ( return (
<div className="flex flex-col gap-8"> <div className="flex flex-col gap-8">
<p className="text-sm text-muted-foreground">
Tags can be used across artworks, commission types, and future miniatures. Category links
are optional and control category-specific behavior.
</p>
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8"> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
{/* String */} {/* String */}
@ -87,7 +105,10 @@ export default function EditTagForm({
<FormItem> <FormItem>
<FormLabel>Description</FormLabel> <FormLabel>Description</FormLabel>
<FormControl> <FormControl>
<Textarea {...field} placeholder="A descriptive text (optional)" /> <Textarea
{...field}
placeholder="A descriptive text (optional)"
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@ -98,14 +119,14 @@ export default function EditTagForm({
name="categoryIds" name="categoryIds"
render={({ field }) => { render={({ field }) => {
const selectedOptions = categories const selectedOptions = categories
.filter(cat => field.value?.includes(cat.id)) .filter((cat) => field.value?.includes(cat.id))
.map(cat => ({ label: cat.name, value: cat.id })); .map((cat) => ({ label: cat.name, value: cat.id }));
return ( return (
<FormItem> <FormItem>
<FormLabel>Categories</FormLabel> <FormLabel>Categories</FormLabel>
<FormControl> <FormControl>
<MultipleSelector <MultipleSelector
defaultOptions={categories.map(cat => ({ defaultOptions={categories.map((cat) => ({
label: cat.name, label: cat.name,
value: cat.id, value: cat.id,
}))} }))}
@ -114,14 +135,14 @@ export default function EditTagForm({
selectFirstItem selectFirstItem
value={selectedOptions} value={selectedOptions}
onChange={(options) => { onChange={(options) => {
const ids = options.map(option => option.value); const ids = options.map((option) => option.value);
field.onChange(ids); field.onChange(ids);
}} }}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
) );
}} }}
/> />
<FormField <FormField
@ -159,7 +180,10 @@ export default function EditTagForm({
<FormItem> <FormItem>
<FormLabel>Aliases</FormLabel> <FormLabel>Aliases</FormLabel>
<FormControl> <FormControl>
<AliasEditor value={field.value ?? []} onChange={field.onChange} /> <AliasEditor
value={field.value ?? []}
onChange={field.onChange}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@ -176,7 +200,10 @@ export default function EditTagForm({
<FormDescription></FormDescription> <FormDescription></FormDescription>
</div> </div>
<FormControl> <FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} /> <Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl> </FormControl>
</FormItem> </FormItem>
)} )}
@ -193,7 +220,10 @@ export default function EditTagForm({
<FormDescription></FormDescription> <FormDescription></FormDescription>
</div> </div>
<FormControl> <FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} /> <Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl> </FormControl>
</FormItem> </FormItem>
)} )}
@ -210,7 +240,10 @@ export default function EditTagForm({
<FormDescription></FormDescription> <FormDescription></FormDescription>
</div> </div>
<FormControl> <FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} /> <Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl> </FormControl>
</FormItem> </FormItem>
)} )}
@ -218,10 +251,16 @@ export default function EditTagForm({
</div> </div>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<Button type="submit">Submit</Button> <Button type="submit">Submit</Button>
<Button type="reset" variant="secondary" onClick={() => router.back()}>Cancel</Button> <Button
type="reset"
variant="secondary"
onClick={() => router.back()}
>
Cancel
</Button>
</div> </div>
</form> </form>
</Form> </Form>
</div > </div>
); );
} }

View File

@ -1,23 +1,42 @@
"use client" "use client";
import { createTag } from "@/actions/tags/createTag"; import { createTag } from "@/actions/tags/createTag";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { ArtCategory, Tag } from "@/generated/prisma/client"; import type { ArtCategory, Tag } from "@/generated/prisma/client";
import { TagFormInput, tagSchema } from "@/schemas/artworks/tagSchema"; import { type TagFormInput, tagSchema } from "@/schemas/artworks/tagSchema";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import MultipleSelector from "../ui/multiselect"; import MultipleSelector from "../ui/multiselect";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select"; import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../ui/select";
import { Switch } from "../ui/switch"; import { Switch } from "../ui/switch";
import AliasEditor from "./AliasEditor"; import AliasEditor from "./AliasEditor";
export default function NewTagForm({
export default function NewTagForm({ categories, allTags }: { categories: ArtCategory[], allTags: Tag[] }) { categories,
allTags,
}: {
categories: ArtCategory[];
allTags: Tag[];
}) {
const router = useRouter(); const router = useRouter();
const form = useForm<TagFormInput>({ const form = useForm<TagFormInput>({
resolver: zodResolver(tagSchema), resolver: zodResolver(tagSchema),
@ -30,18 +49,18 @@ export default function NewTagForm({ categories, allTags }: { categories: ArtCat
showOnAnimalPage: false, showOnAnimalPage: false,
isVisible: true, isVisible: true,
aliases: [], aliases: [],
} },
}) });
async function onSubmit(values: TagFormInput) { async function onSubmit(values: TagFormInput) {
try { try {
const created = await createTag(values) const created = await createTag(values);
console.log("Tag created:", created) console.log("Tag created:", created);
toast("Tag created.") toast("Tag created.");
router.push("/tags") router.push("/tags");
} catch (err) { } catch (err) {
console.error(err) console.error(err);
toast("Failed to create tag.") toast("Failed to create tag.");
} }
} }
@ -51,6 +70,11 @@ export default function NewTagForm({ categories, allTags }: { categories: ArtCat
return ( return (
<div className="flex flex-col gap-8"> <div className="flex flex-col gap-8">
<p className="text-sm text-muted-foreground">
Tags can be used across artworks, commission types, and future
miniatures. Category links are optional and control category-specific
behavior.
</p>
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8"> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
{/* String */} {/* String */}
@ -74,7 +98,10 @@ export default function NewTagForm({ categories, allTags }: { categories: ArtCat
<FormItem> <FormItem>
<FormLabel>Description</FormLabel> <FormLabel>Description</FormLabel>
<FormControl> <FormControl>
<Textarea {...field} placeholder="A descriptive text (optional)" /> <Textarea
{...field}
placeholder="A descriptive text (optional)"
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@ -85,14 +112,14 @@ export default function NewTagForm({ categories, allTags }: { categories: ArtCat
name="categoryIds" name="categoryIds"
render={({ field }) => { render={({ field }) => {
const selectedOptions = categories const selectedOptions = categories
.filter(cat => field.value?.includes(cat.id)) .filter((cat) => field.value?.includes(cat.id))
.map(cat => ({ label: cat.name, value: cat.id })); .map((cat) => ({ label: cat.name, value: cat.id }));
return ( return (
<FormItem> <FormItem>
<FormLabel>Categories</FormLabel> <FormLabel>Categories</FormLabel>
<FormControl> <FormControl>
<MultipleSelector <MultipleSelector
defaultOptions={categories.map(cat => ({ defaultOptions={categories.map((cat) => ({
label: cat.name, label: cat.name,
value: cat.id, value: cat.id,
}))} }))}
@ -101,14 +128,14 @@ export default function NewTagForm({ categories, allTags }: { categories: ArtCat
selectFirstItem selectFirstItem
value={selectedOptions} value={selectedOptions}
onChange={(options) => { onChange={(options) => {
const ids = options.map(option => option.value); const ids = options.map((option) => option.value);
field.onChange(ids); field.onChange(ids);
}} }}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
) );
}} }}
/> />
<FormField <FormField
@ -146,7 +173,10 @@ export default function NewTagForm({ categories, allTags }: { categories: ArtCat
<FormItem> <FormItem>
<FormLabel>Aliases</FormLabel> <FormLabel>Aliases</FormLabel>
<FormControl> <FormControl>
<AliasEditor value={field.value ?? []} onChange={field.onChange} /> <AliasEditor
value={field.value ?? []}
onChange={field.onChange}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@ -163,7 +193,10 @@ export default function NewTagForm({ categories, allTags }: { categories: ArtCat
<FormDescription></FormDescription> <FormDescription></FormDescription>
</div> </div>
<FormControl> <FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} /> <Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl> </FormControl>
</FormItem> </FormItem>
)} )}
@ -180,7 +213,10 @@ export default function NewTagForm({ categories, allTags }: { categories: ArtCat
<FormDescription></FormDescription> <FormDescription></FormDescription>
</div> </div>
<FormControl> <FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} /> <Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl> </FormControl>
</FormItem> </FormItem>
)} )}
@ -197,7 +233,10 @@ export default function NewTagForm({ categories, allTags }: { categories: ArtCat
<FormDescription></FormDescription> <FormDescription></FormDescription>
</div> </div>
<FormControl> <FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} /> <Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl> </FormControl>
</FormItem> </FormItem>
)} )}
@ -205,10 +244,16 @@ export default function NewTagForm({ categories, allTags }: { categories: ArtCat
</div> </div>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<Button type="submit">Submit</Button> <Button type="submit">Submit</Button>
<Button type="reset" variant="secondary" onClick={() => router.back()}>Cancel</Button> <Button
type="reset"
variant="secondary"
onClick={() => router.back()}
>
Cancel
</Button>
</div> </div>
</form> </form>
</Form> </Form>
</div > </div>
); );
} }

View File

@ -480,8 +480,7 @@ const MultipleSelector = React.forwardRef<MultipleSelectorRef, MultipleSelectorP
} // When onSearch is provided, we don't want to filter the options. You can still override it. } // When onSearch is provided, we don't want to filter the options. You can still override it.
filter={commandFilter()} filter={commandFilter()}
> >
<button <div
type="button"
className={cn( className={cn(
'min-h-10 rounded-md border border-input text-base ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 md:text-sm', 'min-h-10 rounded-md border border-input text-base ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 md:text-sm',
{ {
@ -490,11 +489,6 @@ const MultipleSelector = React.forwardRef<MultipleSelectorRef, MultipleSelectorP
}, },
className, className,
)} )}
onClick={() => {
if (disabled) return;
inputRef?.current?.focus();
}}
disabled={disabled}
> >
<div className="relative flex flex-wrap gap-1"> <div className="relative flex flex-wrap gap-1">
{selected.map((option) => { {selected.map((option) => {
@ -581,7 +575,7 @@ const MultipleSelector = React.forwardRef<MultipleSelectorRef, MultipleSelectorP
<X /> <X />
</button> </button>
</div> </div>
</button> </div>
<div className="relative"> <div className="relative">
{open && ( {open && (
<CommandList <CommandList
@ -597,7 +591,10 @@ const MultipleSelector = React.forwardRef<MultipleSelectorRef, MultipleSelectorP
}} }}
> >
{isLoading ? ( {isLoading ? (
<>{loadingIndicator}</> // biome-ignore lint: lint/complexity/noUselessFragments
<>
{loadingIndicator}
</>
) : ( ) : (
<> <>
{EmptyItem()} {EmptyItem()}
@ -605,7 +602,6 @@ const MultipleSelector = React.forwardRef<MultipleSelectorRef, MultipleSelectorP
{!selectFirstItem && <CommandItem value="-" className="hidden" />} {!selectFirstItem && <CommandItem value="-" className="hidden" />}
{orderedGroupEntries.map(([key, dropdowns]) => ( {orderedGroupEntries.map(([key, dropdowns]) => (
<CommandGroup key={key} heading={key} className="h-full overflow-auto"> <CommandGroup key={key} heading={key} className="h-full overflow-auto">
<>
{dropdowns.map((option) => { {dropdowns.map((option) => {
const alreadySelected = selected.some((s) => s.value === option.value); const alreadySelected = selected.some((s) => s.value === option.value);
const disabledItem = option.disable || (showSelectedInDropdown && alreadySelected); const disabledItem = option.disable || (showSelectedInDropdown && alreadySelected);
@ -645,7 +641,6 @@ const MultipleSelector = React.forwardRef<MultipleSelectorRef, MultipleSelectorP
</CommandItem> </CommandItem>
); );
})} })}
</>
</CommandGroup> </CommandGroup>
))} ))}
</> </>

View File

@ -1,27 +1,27 @@
"use client" "use client";
import * as SelectPrimitive from "@radix-ui/react-select" import * as SelectPrimitive from "@radix-ui/react-select";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react" import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import * as React from "react" import type * as React from "react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Select({ function Select({
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) { }: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} /> return <SelectPrimitive.Root data-slot="select" {...props} />;
} }
function SelectGroup({ function SelectGroup({
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) { }: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} /> return <SelectPrimitive.Group data-slot="select-group" {...props} />;
} }
function SelectValue({ function SelectValue({
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) { }: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} /> return <SelectPrimitive.Value data-slot="select-value" {...props} />;
} }
function SelectTrigger({ function SelectTrigger({
@ -30,7 +30,7 @@ function SelectTrigger({
children, children,
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & { }: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default" size?: "sm" | "default";
}) { }) {
return ( return (
<SelectPrimitive.Trigger <SelectPrimitive.Trigger
@ -38,7 +38,7 @@ function SelectTrigger({
data-size={size} data-size={size}
className={cn( className={cn(
"border-input data-placeholder:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "border-input data-placeholder:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className className,
)} )}
{...props} {...props}
> >
@ -47,7 +47,7 @@ function SelectTrigger({
<ChevronDownIcon className="size-4 opacity-50" /> <ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon> </SelectPrimitive.Icon>
</SelectPrimitive.Trigger> </SelectPrimitive.Trigger>
) );
} }
function SelectContent({ function SelectContent({
@ -65,7 +65,7 @@ function SelectContent({
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-32 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md", "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-32 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" && position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className className,
)} )}
position={position} position={position}
align={align} align={align}
@ -76,7 +76,7 @@ function SelectContent({
className={cn( className={cn(
"p-1", "p-1",
position === "popper" && position === "popper" &&
"h-(--radix-select-trigger-height) w-full min-w-(--radix-select-trigger-width) scroll-my-1" "h-(--radix-select-trigger-height) w-full min-w-(--radix-select-trigger-width) scroll-my-1",
)} )}
> >
{children} {children}
@ -84,7 +84,7 @@ function SelectContent({
<SelectScrollDownButton /> <SelectScrollDownButton />
</SelectPrimitive.Content> </SelectPrimitive.Content>
</SelectPrimitive.Portal> </SelectPrimitive.Portal>
) );
} }
function SelectLabel({ function SelectLabel({
@ -97,7 +97,7 @@ function SelectLabel({
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)} className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props} {...props}
/> />
) );
} }
function SelectItem({ function SelectItem({
@ -110,7 +110,7 @@ function SelectItem({
data-slot="select-item" data-slot="select-item"
className={cn( className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2", "focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className className,
)} )}
{...props} {...props}
> >
@ -124,7 +124,7 @@ function SelectItem({
</span> </span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item> </SelectPrimitive.Item>
) );
} }
function SelectSeparator({ function SelectSeparator({
@ -137,7 +137,7 @@ function SelectSeparator({
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)} className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props} {...props}
/> />
) );
} }
function SelectScrollUpButton({ function SelectScrollUpButton({
@ -149,13 +149,13 @@ function SelectScrollUpButton({
data-slot="select-scroll-up-button" data-slot="select-scroll-up-button"
className={cn( className={cn(
"flex cursor-default items-center justify-center py-1", "flex cursor-default items-center justify-center py-1",
className className,
)} )}
{...props} {...props}
> >
<ChevronUpIcon className="size-4" /> <ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton> </SelectPrimitive.ScrollUpButton>
) );
} }
function SelectScrollDownButton({ function SelectScrollDownButton({
@ -167,13 +167,13 @@ function SelectScrollDownButton({
data-slot="select-scroll-down-button" data-slot="select-scroll-down-button"
className={cn( className={cn(
"flex cursor-default items-center justify-center py-1", "flex cursor-default items-center justify-center py-1",
className className,
)} )}
{...props} {...props}
> >
<ChevronDownIcon className="size-4" /> <ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton> </SelectPrimitive.ScrollDownButton>
) );
} }
export { export {
@ -187,5 +187,5 @@ export {
SelectSeparator, SelectSeparator,
SelectTrigger, SelectTrigger,
SelectValue SelectValue
} };

View File

@ -28,6 +28,7 @@ export const commissionCustomCardSchema = z.object({
isVisible: z.boolean(), isVisible: z.boolean(),
isSpecialOffer: z.boolean(), isSpecialOffer: z.boolean(),
referenceImageUrl: z.string().nullable().optional(), referenceImageUrl: z.string().nullable().optional(),
tagIds: z.array(z.string()).optional(),
options: z.array(optionField).optional(), options: z.array(optionField).optional(),
extras: z.array(extraField).optional(), extras: z.array(extraField).optional(),
}); });

View File

@ -32,6 +32,7 @@ const customInputsField = z.object({
export const commissionTypeSchema = z.object({ export const commissionTypeSchema = z.object({
name: z.string().min(1, "Name is required. Min 1 character."), name: z.string().min(1, "Name is required. Min 1 character."),
description: z.string().optional(), description: z.string().optional(),
tagIds: z.array(z.string()).optional(),
options: z.array(optionField).optional(), options: z.array(optionField).optional(),
extras: z.array(extraField).optional(), extras: z.array(extraField).optional(),
customInputs: z.array(customInputsField).optional(), customInputs: z.array(customInputsField).optional(),