269 lines
7.0 KiB
TypeScript
269 lines
7.0 KiB
TypeScript
"use client";
|
||
|
||
import { useRef, useState } from "react";
|
||
import ImageCropDialog from "@/components/ImageCropDialog";
|
||
import styles from "./CatalogForm.module.css";
|
||
|
||
export type LocalImage = {
|
||
key: string;
|
||
file?: File;
|
||
previewUrl: string;
|
||
mediaId?: string;
|
||
};
|
||
|
||
export function FeaturedImageField({
|
||
label = "Thumbnail",
|
||
value,
|
||
aspect = 1,
|
||
disabled,
|
||
onChange,
|
||
}: {
|
||
label?: string;
|
||
value: LocalImage | null;
|
||
aspect?: number;
|
||
disabled?: boolean;
|
||
onChange: (value: LocalImage | null) => void;
|
||
}) {
|
||
const inputRef = useRef<HTMLInputElement>(null);
|
||
const [cropSource, setCropSource] = useState<string | null>(null);
|
||
const [cropName, setCropName] = useState("image.jpg");
|
||
const wide = aspect !== 1;
|
||
|
||
return (
|
||
<div className={styles.thumbBlock}>
|
||
<div className={styles.blockHead}>
|
||
<span>{label}</span>
|
||
<div className={styles.inlineActions}>
|
||
<button
|
||
type="button"
|
||
className={styles.linkBtn}
|
||
disabled={disabled}
|
||
onClick={() => inputRef.current?.click()}
|
||
>
|
||
{value ? "Replace" : "Upload"}
|
||
</button>
|
||
{value ? (
|
||
<button
|
||
type="button"
|
||
className={styles.linkBtn}
|
||
disabled={disabled}
|
||
onClick={() => {
|
||
if (value.previewUrl.startsWith("blob:")) {
|
||
URL.revokeObjectURL(value.previewUrl);
|
||
}
|
||
onChange(null);
|
||
}}
|
||
>
|
||
Remove
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className={`${styles.thumbFrame}${wide ? ` ${styles.thumbFrameWide}` : ""}`}
|
||
disabled={disabled}
|
||
onClick={() => inputRef.current?.click()}
|
||
>
|
||
{value?.previewUrl ? (
|
||
<img src={value.previewUrl} alt="" />
|
||
) : (
|
||
<span>Add {label.toLowerCase()}</span>
|
||
)}
|
||
</button>
|
||
<input
|
||
ref={inputRef}
|
||
type="file"
|
||
accept="image/*"
|
||
className={styles.fileInput}
|
||
disabled={disabled}
|
||
onChange={(event) => {
|
||
const file = event.target.files?.[0];
|
||
event.target.value = "";
|
||
if (!file) return;
|
||
const url = URL.createObjectURL(file);
|
||
setCropName(file.name || "image.jpg");
|
||
setCropSource(url);
|
||
}}
|
||
/>
|
||
<ImageCropDialog
|
||
open={Boolean(cropSource)}
|
||
imageSrc={cropSource}
|
||
title={`Crop ${label.toLowerCase()}`}
|
||
aspect={aspect}
|
||
outputType="image/jpeg"
|
||
fileName={cropName.replace(/\.[^.]+$/, "") || "image"}
|
||
onCancel={() => {
|
||
if (cropSource?.startsWith("blob:")) URL.revokeObjectURL(cropSource);
|
||
setCropSource(null);
|
||
}}
|
||
onComplete={(file, previewUrl) => {
|
||
if (cropSource?.startsWith("blob:")) URL.revokeObjectURL(cropSource);
|
||
if (value?.previewUrl.startsWith("blob:")) {
|
||
URL.revokeObjectURL(value.previewUrl);
|
||
}
|
||
setCropSource(null);
|
||
onChange({
|
||
key: `${Date.now()}`,
|
||
file,
|
||
previewUrl,
|
||
});
|
||
}}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function GalleryImagesField({
|
||
items,
|
||
disabled,
|
||
onChange,
|
||
}: {
|
||
items: LocalImage[];
|
||
disabled?: boolean;
|
||
onChange: (items: LocalImage[]) => void;
|
||
}) {
|
||
const inputRef = useRef<HTMLInputElement>(null);
|
||
|
||
return (
|
||
<div className={styles.stack}>
|
||
<div className={styles.blockHead}>
|
||
<span>
|
||
Gallery images <em className={styles.optional}>(optional)</em>
|
||
</span>
|
||
<button
|
||
type="button"
|
||
className={styles.linkBtn}
|
||
disabled={disabled}
|
||
onClick={() => inputRef.current?.click()}
|
||
>
|
||
Add images
|
||
</button>
|
||
</div>
|
||
{items.length ? (
|
||
<div className={styles.galleryGrid}>
|
||
{items.map((item) => (
|
||
<div key={item.key} className={styles.galleryItem}>
|
||
<img src={item.previewUrl} alt="" />
|
||
<button
|
||
type="button"
|
||
disabled={disabled}
|
||
onClick={() => {
|
||
if (item.previewUrl.startsWith("blob:")) {
|
||
URL.revokeObjectURL(item.previewUrl);
|
||
}
|
||
onChange(items.filter((row) => row.key !== item.key));
|
||
}}
|
||
>
|
||
Remove
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<button
|
||
type="button"
|
||
className={styles.galleryAdd}
|
||
disabled={disabled}
|
||
onClick={() => inputRef.current?.click()}
|
||
>
|
||
Add images
|
||
</button>
|
||
)}
|
||
<input
|
||
ref={inputRef}
|
||
type="file"
|
||
accept="image/*"
|
||
multiple
|
||
className={styles.fileInput}
|
||
disabled={disabled}
|
||
onChange={(event) => {
|
||
const files = Array.from(event.target.files ?? []).filter((file) =>
|
||
file.type.startsWith("image/"),
|
||
);
|
||
event.target.value = "";
|
||
if (!files.length) return;
|
||
onChange([
|
||
...items,
|
||
...files.map((file) => ({
|
||
key: `${file.name}-${file.size}-${Date.now()}-${Math.random()}`,
|
||
file,
|
||
previewUrl: URL.createObjectURL(file),
|
||
})),
|
||
]);
|
||
}}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function TagsField({
|
||
tags,
|
||
disabled,
|
||
onChange,
|
||
}: {
|
||
tags: string[];
|
||
disabled?: boolean;
|
||
onChange: (tags: string[]) => void;
|
||
}) {
|
||
const [draft, setDraft] = useState("");
|
||
|
||
function addTag() {
|
||
const next = draft.trim();
|
||
if (!next) return;
|
||
if (tags.some((tag) => tag.toLowerCase() === next.toLowerCase())) {
|
||
setDraft("");
|
||
return;
|
||
}
|
||
onChange([...tags, next]);
|
||
setDraft("");
|
||
}
|
||
|
||
return (
|
||
<div className={styles.field}>
|
||
<span>
|
||
Tags <em className={styles.optional}>(optional)</em>
|
||
</span>
|
||
{tags.length ? (
|
||
<div className={styles.tags}>
|
||
{tags.map((tag) => (
|
||
<span key={tag} className={styles.tag}>
|
||
{tag}
|
||
<button
|
||
type="button"
|
||
disabled={disabled}
|
||
aria-label={`Remove ${tag}`}
|
||
onClick={() => onChange(tags.filter((item) => item !== tag))}
|
||
>
|
||
×
|
||
</button>
|
||
</span>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
<div className={styles.tagRow}>
|
||
<input
|
||
value={draft}
|
||
disabled={disabled}
|
||
placeholder="Add a tag"
|
||
onChange={(event) => setDraft(event.target.value)}
|
||
onKeyDown={(event) => {
|
||
if (event.key === "Enter") {
|
||
event.preventDefault();
|
||
addTag();
|
||
}
|
||
}}
|
||
/>
|
||
<button
|
||
type="button"
|
||
className={styles.tagAdd}
|
||
disabled={disabled}
|
||
onClick={addTag}
|
||
>
|
||
Add
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|