Initial commit: dashboard
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,268 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user