Enhance RustyCMS: Update .gitignore to include demo assets, improve admin UI dependency management, and add new translations for asset management. Implement asset date filtering and enhance content forms with asset previews. Introduce caching mechanisms for improved performance and add support for draft status in content entries.
This commit is contained in:
@@ -1,13 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useId, useEffect, useRef } from "react";
|
||||
import { useId, useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useForm, Controller } from "react-hook-form";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Icon } from "@iconify/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
import type { SchemaDefinition, FieldDefinition } from "@/lib/api";
|
||||
import { checkSlug } from "@/lib/api";
|
||||
import { checkSlug, getBaseUrl, fetchAssets } from "@/lib/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
@@ -20,6 +21,13 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ReferenceArrayField } from "./ReferenceArrayField";
|
||||
import { ReferenceField } from "./ReferenceField";
|
||||
import { ReferenceOrInlineField } from "./ReferenceOrInlineField";
|
||||
@@ -173,6 +181,146 @@ function SlugField({
|
||||
);
|
||||
}
|
||||
|
||||
/** True if this string field is used for image/asset URLs (by description or name). */
|
||||
function isImageUrlField(def: FieldDefinition, name: string): boolean {
|
||||
const desc = def.description && String(def.description).toLowerCase();
|
||||
if (desc && (desc.includes("image") || desc.includes("asset"))) return true;
|
||||
if (name === "image" || name === "imageUrl" || name === "asset") return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function assetPreviewUrl(value: string | undefined): string | null {
|
||||
if (!value || typeof value !== "string") return null;
|
||||
const v = value.trim();
|
||||
if (!v) return null;
|
||||
if (v.startsWith("http://") || v.startsWith("https://")) return v;
|
||||
if (v.startsWith("/api/assets/")) return getBaseUrl() + v;
|
||||
if (/\.(jpg|jpeg|png|webp|gif|avif|svg)(\?|$)/i.test(v)) return getBaseUrl() + (v.startsWith("/") ? v : "/" + v);
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Asset picker content: grid of assets, click to select URL. Use inside DialogContent. */
|
||||
function AssetPickerContent({ onSelect }: { onSelect: (url: string) => void }) {
|
||||
const t = useTranslations("ContentForm");
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["assets", "all"],
|
||||
queryFn: () => fetchAssets(),
|
||||
});
|
||||
const assets = data?.assets ?? [];
|
||||
|
||||
return (
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
{isLoading && (
|
||||
<p className="text-muted-foreground text-sm">{t("loadingAssets")}</p>
|
||||
)}
|
||||
{error && (
|
||||
<p className="text-destructive text-sm">{String((error as Error).message)}</p>
|
||||
)}
|
||||
{!isLoading && !error && assets.length === 0 && (
|
||||
<p className="text-muted-foreground text-sm">{t("noAssets")}</p>
|
||||
)}
|
||||
{!isLoading && assets.length > 0 && (
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 gap-3">
|
||||
{assets.map((asset) => {
|
||||
const src = getBaseUrl() + asset.url;
|
||||
return (
|
||||
<button
|
||||
key={asset.url}
|
||||
type="button"
|
||||
onClick={() => onSelect(asset.url)}
|
||||
className="flex flex-col items-center rounded-lg border border-input bg-muted/30 p-2 hover:bg-muted/60 focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||
>
|
||||
<span className="aspect-square w-full overflow-hidden rounded border bg-background flex items-center justify-center">
|
||||
{/\.(svg)$/i.test(asset.filename) ? (
|
||||
<img src={src} alt="" className="max-h-full max-w-full object-contain" />
|
||||
) : (
|
||||
<img src={src} alt="" className="h-full w-full object-cover" />
|
||||
)}
|
||||
</span>
|
||||
<span className="mt-1 truncate w-full text-center text-xs text-muted-foreground">
|
||||
{asset.filename}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** String field for image/asset URL: preview + input + asset picker button. */
|
||||
function ImageUrlField({
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
fieldError,
|
||||
required,
|
||||
readonly,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
label: React.ReactNode;
|
||||
fieldError: unknown;
|
||||
required: boolean;
|
||||
readonly: boolean;
|
||||
}) {
|
||||
const t = useTranslations("ContentForm");
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const previewUrl = assetPreviewUrl(value);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{label}
|
||||
{previewUrl && (
|
||||
<div className="mt-2 rounded-lg border border-input overflow-hidden bg-muted/20 inline-block max-w-[200px]">
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt=""
|
||||
className="max-h-40 w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="/api/assets/…"
|
||||
readOnly={readonly}
|
||||
className="flex-1 min-w-[200px] font-mono text-sm"
|
||||
/>
|
||||
{!readonly && (
|
||||
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button type="button" variant="outline" size="sm">
|
||||
<Icon icon="mdi:image-multiple" className="mr-1 size-4" />
|
||||
{t("pickFromAssets")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-[85vh] max-w-2xl overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("pickAsset")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<AssetPickerContent
|
||||
onSelect={(url) => {
|
||||
onChange(url);
|
||||
setPickerOpen(false);
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
{fieldError ? (
|
||||
<p className="mt-1 text-sm text-red-600">
|
||||
{String((fieldError as { message?: string })?.message)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Builds form initial values from API response: references → slug strings, objects recursively. */
|
||||
function buildDefaultValues(
|
||||
schema: SchemaDefinition,
|
||||
@@ -296,6 +444,9 @@ export function ContentForm({
|
||||
if (!isEdit && !initialValues?._slug && defaultValues._slug === undefined) {
|
||||
defaultValues._slug = slugPrefixForCollection(collection);
|
||||
}
|
||||
if (!isEdit && defaultValues._status === undefined) {
|
||||
defaultValues._status = "draft";
|
||||
}
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
@@ -452,6 +603,39 @@ export function ContentForm({
|
||||
slugValue={watch("_slug") as string | undefined}
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<Label className="mb-1 block font-medium">{t("status")}</Label>
|
||||
<Controller
|
||||
name="_status"
|
||||
control={control!}
|
||||
render={({ field }) => {
|
||||
const value = (field.value as string) ?? "draft";
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2" role="group" aria-label={t("status")}>
|
||||
<Button
|
||||
type="button"
|
||||
variant={value === "draft" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => field.onChange("draft")}
|
||||
className="min-w-[100px]"
|
||||
>
|
||||
{t("statusDraft")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => field.onChange("published")}
|
||||
className={`min-w-[100px] ${value === "published" ? "border-success-300 bg-success-50 text-success-700 hover:bg-success-100 hover:text-success-800 hover:border-success-400" : ""}`}
|
||||
>
|
||||
{t("statusPublished")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">{t("statusHint")}</p>
|
||||
</div>
|
||||
{hasSection
|
||||
? formItems.map((item) =>
|
||||
item.kind === "object" ? (
|
||||
@@ -1021,6 +1205,26 @@ function Field({
|
||||
return String(v);
|
||||
};
|
||||
|
||||
if (type === "string" && isImageUrlField(def, name)) {
|
||||
return (
|
||||
<Controller
|
||||
name={name}
|
||||
control={control!}
|
||||
rules={{ required }}
|
||||
render={({ field }) => (
|
||||
<ImageUrlField
|
||||
value={safeStringValue(field.value)}
|
||||
onChange={field.onChange}
|
||||
label={label}
|
||||
fieldError={fieldError}
|
||||
required={required}
|
||||
readonly={readonly}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{label}
|
||||
|
||||
Reference in New Issue
Block a user