Initial commit: dashboard

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-10-03 23:17:36 +03:30
co-authored by Cursor
commit 3db32212ad
177 changed files with 29988 additions and 0 deletions
+268
View File
@@ -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>
);
}