Compare commits
1 Commits
dev
...
ai-alt-tex
| Author | SHA1 | Date | |
|---|---|---|---|
|
eb4391d1d7
|
86
src/actions/artworks/generateAltText.ts
Normal file
86
src/actions/artworks/generateAltText.ts
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { getImageBufferFromS3Key } from "@/utils/getImageBufferFromS3";
|
||||||
|
|
||||||
|
export async function generateAltTextForArtwork(
|
||||||
|
artworkId: string,
|
||||||
|
prompt?: string,
|
||||||
|
) {
|
||||||
|
const serviceUrl = process.env.ALT_TEXT_SERVICE_URL;
|
||||||
|
if (!serviceUrl) {
|
||||||
|
throw new Error("ALT_TEXT_SERVICE_URL is not set");
|
||||||
|
}
|
||||||
|
|
||||||
|
const artwork = await prisma.artwork.findUnique({
|
||||||
|
where: { id: artworkId },
|
||||||
|
select: {
|
||||||
|
variants: {
|
||||||
|
where: { type: "original" },
|
||||||
|
select: { s3Key: true },
|
||||||
|
take: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const original = artwork?.variants?.[0];
|
||||||
|
if (!original?.s3Key) {
|
||||||
|
throw new Error("Original image variant not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = await getImageBufferFromS3Key(original.s3Key);
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
const bytes = new Uint8Array(buffer);
|
||||||
|
formData.append(
|
||||||
|
"image",
|
||||||
|
new Blob([bytes], { type: "image/jpeg" }),
|
||||||
|
"artwork.jpg",
|
||||||
|
);
|
||||||
|
if (prompt && prompt.trim().length > 0) {
|
||||||
|
formData.append("prompt", prompt.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 120000);
|
||||||
|
|
||||||
|
let response: Response | null = null;
|
||||||
|
let lastError: unknown = null;
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||||
|
try {
|
||||||
|
response = await fetch(`${serviceUrl}/caption`, {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
} catch (err) {
|
||||||
|
lastError = err;
|
||||||
|
if (attempt === 0) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 750));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
if (!response) {
|
||||||
|
throw new Error(`Alt text service failed: ${String(lastError)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
throw new Error(`Alt text service failed: ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as { altText?: string; error?: string };
|
||||||
|
if (data.error) {
|
||||||
|
throw new Error(`Alt text service error: ${data.error}`);
|
||||||
|
}
|
||||||
|
if (!data.altText) {
|
||||||
|
throw new Error(`Alt text service returned no result: ${JSON.stringify(data)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.altText.trim();
|
||||||
|
}
|
||||||
@ -1,32 +1,50 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
|
import { generateAltTextForArtwork } from "@/actions/artworks/generateAltText";
|
||||||
import { updateArtwork } from "@/actions/artworks/updateArtwork";
|
import { updateArtwork } from "@/actions/artworks/updateArtwork";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Calendar } from "@/components/ui/calendar";
|
import { Calendar } from "@/components/ui/calendar";
|
||||||
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 MultipleSelector from "@/components/ui/multiselect";
|
import MultipleSelector from "@/components/ui/multiselect";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { ArtTag } from "@/generated/prisma/client";
|
import type { ArtTag } from "@/generated/prisma/client";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { artworkSchema } from "@/schemas/artworks/imageSchema";
|
import { artworkSchema } from "@/schemas/artworks/imageSchema";
|
||||||
import { ArtworkWithRelations, CategoryWithTags } from "@/types/Artwork";
|
import type { ArtworkWithRelations, CategoryWithTags } from "@/types/Artwork";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useTransition } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { z } from "zod/v4";
|
import type { z } from "zod/v4";
|
||||||
|
|
||||||
export default function EditArtworkForm({ artwork, categories, tags }:
|
export default function EditArtworkForm({
|
||||||
{
|
artwork,
|
||||||
artwork: ArtworkWithRelations,
|
categories,
|
||||||
categories: CategoryWithTags[]
|
tags,
|
||||||
tags: ArtTag[]
|
}: {
|
||||||
}) {
|
artwork: ArtworkWithRelations;
|
||||||
|
categories: CategoryWithTags[];
|
||||||
|
tags: ArtTag[];
|
||||||
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const [isGeneratingAlt, startAltTransition] = useTransition();
|
||||||
const form = useForm<z.infer<typeof artworkSchema>>({
|
const form = useForm<z.infer<typeof artworkSchema>>({
|
||||||
resolver: zodResolver(artworkSchema),
|
resolver: zodResolver(artworkSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@ -41,19 +59,21 @@ export default function EditArtworkForm({ artwork, categories, tags }:
|
|||||||
notes: artwork.notes || "",
|
notes: artwork.notes || "",
|
||||||
month: artwork.month || undefined,
|
month: artwork.month || undefined,
|
||||||
year: artwork.year || undefined,
|
year: artwork.year || undefined,
|
||||||
creationDate: artwork.creationDate ? new Date(artwork.creationDate) : undefined,
|
creationDate: artwork.creationDate
|
||||||
categoryIds: artwork.categories?.map(cat => cat.id) ?? [],
|
? new Date(artwork.creationDate)
|
||||||
tagIds: artwork.tags?.map(tag => tag.id) ?? [],
|
: undefined,
|
||||||
|
categoryIds: artwork.categories?.map((cat) => cat.id) ?? [],
|
||||||
|
tagIds: artwork.tags?.map((tag) => tag.id) ?? [],
|
||||||
newCategoryNames: [],
|
newCategoryNames: [],
|
||||||
newTagNames: []
|
newTagNames: [],
|
||||||
}
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
async function onSubmit(values: z.infer<typeof artworkSchema>) {
|
async function onSubmit(values: z.infer<typeof artworkSchema>) {
|
||||||
const updatedArtwork = await updateArtwork(values, artwork.id)
|
const updatedArtwork = await updateArtwork(values, artwork.id);
|
||||||
if (updatedArtwork) {
|
if (updatedArtwork) {
|
||||||
toast.success("Artwork updated")
|
toast.success("Artwork updated");
|
||||||
router.push(`/artworks`)
|
router.push(`/artworks`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -80,10 +100,42 @@ export default function EditArtworkForm({ artwork, categories, tags }:
|
|||||||
name="altText"
|
name="altText"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Alt Text</FormLabel>
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<FormLabel>Alt Text</FormLabel>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={isGeneratingAlt}
|
||||||
|
onClick={() =>
|
||||||
|
startAltTransition(async () => {
|
||||||
|
try {
|
||||||
|
const alt = await generateAltTextForArtwork(
|
||||||
|
artwork.id,
|
||||||
|
field.value,
|
||||||
|
);
|
||||||
|
form.setValue("altText", alt, {
|
||||||
|
shouldDirty: true,
|
||||||
|
shouldValidate: true,
|
||||||
|
});
|
||||||
|
toast.success("Alt text generated");
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
toast.error("Alt text generation failed");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isGeneratingAlt ? "Generating..." : "Generate"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} placeholder="Alt for this image" />
|
<Input {...field} placeholder="Alt for this image" />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Generates a caption from the original image. CPU-only can take
|
||||||
|
10–30s.
|
||||||
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
@ -95,7 +147,10 @@ export default function EditArtworkForm({ artwork, categories, tags }:
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Description</FormLabel>
|
<FormLabel>Description</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Textarea {...field} placeholder="A descriptive text to the image" />
|
<Textarea
|
||||||
|
{...field}
|
||||||
|
placeholder="A descriptive text to the image"
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@ -125,9 +180,11 @@ export default function EditArtworkForm({ artwork, categories, tags }:
|
|||||||
<Input
|
<Input
|
||||||
{...field}
|
{...field}
|
||||||
type="number"
|
type="number"
|
||||||
value={field.value ?? ''}
|
value={field.value ?? ""}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
field.onChange(e.target.value === '' ? undefined : +e.target.value)
|
field.onChange(
|
||||||
|
e.target.value === "" ? undefined : +e.target.value,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@ -145,9 +202,11 @@ export default function EditArtworkForm({ artwork, categories, tags }:
|
|||||||
<Input
|
<Input
|
||||||
{...field}
|
{...field}
|
||||||
type="number"
|
type="number"
|
||||||
value={field.value ?? ''}
|
value={field.value ?? ""}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
field.onChange(e.target.value === '' ? undefined : +e.target.value)
|
field.onChange(
|
||||||
|
e.target.value === "" ? undefined : +e.target.value,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@ -170,10 +229,12 @@ export default function EditArtworkForm({ artwork, categories, tags }:
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
"pl-3 text-left font-normal",
|
"pl-3 text-left font-normal",
|
||||||
!field.value && "text-muted-foreground"
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{field.value ? format(field.value, "PPP") : "Pick a date"}
|
{field.value
|
||||||
|
? format(field.value, "PPP")
|
||||||
|
: "Pick a date"}
|
||||||
</Button>
|
</Button>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
@ -182,7 +243,7 @@ export default function EditArtworkForm({ artwork, categories, tags }:
|
|||||||
mode="single"
|
mode="single"
|
||||||
selected={field.value}
|
selected={field.value}
|
||||||
onSelect={(date) => {
|
onSelect={(date) => {
|
||||||
field.onChange(date)
|
field.onChange(date);
|
||||||
}}
|
}}
|
||||||
initialFocus
|
initialFocus
|
||||||
fromYear={1990}
|
fromYear={1990}
|
||||||
@ -236,17 +297,23 @@ export default function EditArtworkForm({ artwork, categories, tags }:
|
|||||||
onChange={(options) => {
|
onChange={(options) => {
|
||||||
const values = options.map((o) => o.value);
|
const values = options.map((o) => o.value);
|
||||||
|
|
||||||
const existingIds = values.filter((v) => !v.startsWith("__new__:"));
|
const existingIds = values.filter(
|
||||||
|
(v) => !v.startsWith("__new__:"),
|
||||||
|
);
|
||||||
const newNames = values
|
const newNames = values
|
||||||
.filter((v) => v.startsWith("__new__:"))
|
.filter((v) => v.startsWith("__new__:"))
|
||||||
.map((v) => v.replace("__new__:", "").trim())
|
.map((v) => v.replace("__new__:", "").trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
field.onChange(existingIds);
|
field.onChange(existingIds);
|
||||||
form.setValue("newCategoryNames", Array.from(new Set(newNames)), {
|
form.setValue(
|
||||||
shouldDirty: true,
|
"newCategoryNames",
|
||||||
shouldValidate: true,
|
Array.from(new Set(newNames)),
|
||||||
});
|
{
|
||||||
|
shouldDirty: true,
|
||||||
|
shouldValidate: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@ -276,7 +343,8 @@ export default function EditArtworkForm({ artwork, categories, tags }:
|
|||||||
.map((t) => {
|
.map((t) => {
|
||||||
let group = "Other tags";
|
let group = "Other tags";
|
||||||
if (selectedTagIds.includes(t.id)) group = "Selected";
|
if (selectedTagIds.includes(t.id)) group = "Selected";
|
||||||
else if (preferredTagIds.has(t.id)) group = "From selected categories";
|
else if (preferredTagIds.has(t.id))
|
||||||
|
group = "From selected categories";
|
||||||
|
|
||||||
return { label: t.name, value: t.id, group };
|
return { label: t.name, value: t.id, group };
|
||||||
})
|
})
|
||||||
@ -301,12 +369,19 @@ export default function EditArtworkForm({ artwork, categories, tags }:
|
|||||||
<MultipleSelector
|
<MultipleSelector
|
||||||
options={tagOptions}
|
options={tagOptions}
|
||||||
groupBy="group"
|
groupBy="group"
|
||||||
groupOrder={["Selected", "From selected categories", "Other tags"]}
|
groupOrder={[
|
||||||
|
"Selected",
|
||||||
|
"From selected categories",
|
||||||
|
"Other tags",
|
||||||
|
]}
|
||||||
showSelectedInDropdown
|
showSelectedInDropdown
|
||||||
placeholder="Select or type to create tags"
|
placeholder="Select or type to create tags"
|
||||||
hidePlaceholderWhenSelected
|
hidePlaceholderWhenSelected
|
||||||
selectFirstItem
|
selectFirstItem
|
||||||
value={[...selectedExistingOptions, ...selectedNewOptions]}
|
value={[
|
||||||
|
...selectedExistingOptions,
|
||||||
|
...selectedNewOptions,
|
||||||
|
]}
|
||||||
creatable
|
creatable
|
||||||
createOption={(raw) => ({
|
createOption={(raw) => ({
|
||||||
value: `__new__:${raw}`,
|
value: `__new__:${raw}`,
|
||||||
@ -316,17 +391,23 @@ export default function EditArtworkForm({ artwork, categories, tags }:
|
|||||||
onChange={(options) => {
|
onChange={(options) => {
|
||||||
const values = options.map((o) => o.value);
|
const values = options.map((o) => o.value);
|
||||||
|
|
||||||
const existingIds = values.filter((v) => !v.startsWith("__new__:"));
|
const existingIds = values.filter(
|
||||||
|
(v) => !v.startsWith("__new__:"),
|
||||||
|
);
|
||||||
const newNames = values
|
const newNames = values
|
||||||
.filter((v) => v.startsWith("__new__:"))
|
.filter((v) => v.startsWith("__new__:"))
|
||||||
.map((v) => v.replace("__new__:", "").trim())
|
.map((v) => v.replace("__new__:", "").trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
field.onChange(existingIds);
|
field.onChange(existingIds);
|
||||||
form.setValue("newTagNames", Array.from(new Set(newNames)), {
|
form.setValue(
|
||||||
shouldDirty: true,
|
"newTagNames",
|
||||||
shouldValidate: true,
|
Array.from(new Set(newNames)),
|
||||||
});
|
{
|
||||||
|
shouldDirty: true,
|
||||||
|
shouldValidate: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@ -347,7 +428,10 @@ export default function EditArtworkForm({ artwork, categories, tags }:
|
|||||||
<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>
|
||||||
)}
|
)}
|
||||||
@ -359,10 +443,15 @@ export default function EditArtworkForm({ artwork, categories, tags }:
|
|||||||
<FormItem className="flex items-center justify-between rounded-lg border p-4">
|
<FormItem className="flex items-center justify-between rounded-lg border p-4">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<FormLabel>NSFW</FormLabel>
|
<FormLabel>NSFW</FormLabel>
|
||||||
<FormDescription>This image contains sensitive or adult content.</FormDescription>
|
<FormDescription>
|
||||||
|
This image contains sensitive or adult content.
|
||||||
|
</FormDescription>
|
||||||
</div>
|
</div>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
@ -374,10 +463,15 @@ export default function EditArtworkForm({ artwork, categories, tags }:
|
|||||||
<FormItem className="flex items-center justify-between rounded-lg border p-4">
|
<FormItem className="flex items-center justify-between rounded-lg border p-4">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<FormLabel>Publish</FormLabel>
|
<FormLabel>Publish</FormLabel>
|
||||||
<FormDescription>Will this image be published.</FormDescription>
|
<FormDescription>
|
||||||
|
Will this image be published.
|
||||||
|
</FormDescription>
|
||||||
</div>
|
</div>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
@ -389,10 +483,15 @@ export default function EditArtworkForm({ artwork, categories, tags }:
|
|||||||
<FormItem className="flex items-center justify-between rounded-lg border p-4">
|
<FormItem className="flex items-center justify-between rounded-lg border p-4">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<FormLabel>Set as header image</FormLabel>
|
<FormLabel>Set as header image</FormLabel>
|
||||||
<FormDescription>Will be the main banner image. Choose a fitting one.</FormDescription>
|
<FormDescription>
|
||||||
|
Will be the main banner image. Choose a fitting one.
|
||||||
|
</FormDescription>
|
||||||
</div>
|
</div>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
@ -444,10 +543,16 @@ export default function EditArtworkForm({ artwork, categories, tags }:
|
|||||||
/> */}
|
/> */}
|
||||||
<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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user