Files

269 lines
7.0 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}