RustyCMS: file-based headless CMS — API, Admin UI (content, types, assets), Docker/Caddy, image transform; only demo type and demo content in version control
Made-with: Cursor
This commit is contained in:
@@ -3,8 +3,22 @@
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { fetchContentList, fetchCollections } from "@/lib/api";
|
||||
import type { FieldDefinition } from "@/lib/api";
|
||||
import { getOptionLabel } from "@/lib/referenceOptionLabel";
|
||||
import {
|
||||
SearchableSelect,
|
||||
type SearchableSelectOption,
|
||||
} from "./SearchableSelect";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
type Props = {
|
||||
name: string;
|
||||
@@ -37,19 +51,35 @@ export function ReferenceArrayField({
|
||||
locale,
|
||||
label,
|
||||
}: Props) {
|
||||
const t = useTranslations("ReferenceArrayField");
|
||||
const schemaCollections = getCollections(def);
|
||||
const singleCollection =
|
||||
schemaCollections.length === 1 ? schemaCollections[0] : null;
|
||||
const multipleCollections =
|
||||
schemaCollections.length > 1 ? schemaCollections : [];
|
||||
const valueList: string[] = Array.isArray(value)
|
||||
? value
|
||||
: typeof value === "string"
|
||||
? value
|
||||
.split(/[\n,]+/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
|
||||
/** Normalise to string[]: API may return resolved refs as objects with _slug. */
|
||||
function toSlugList(raw: unknown): string[] {
|
||||
if (Array.isArray(raw)) {
|
||||
return raw
|
||||
.map((v) =>
|
||||
typeof v === "object" && v !== null && "_slug" in v
|
||||
? String((v as { _slug?: string })._slug ?? "")
|
||||
: typeof v === "string"
|
||||
? v.trim()
|
||||
: "",
|
||||
)
|
||||
.filter(Boolean);
|
||||
}
|
||||
if (typeof raw === "string") {
|
||||
return raw
|
||||
.split(/[\n,]+/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
const valueList: string[] = toSlugList(value);
|
||||
|
||||
const [pickedCollection, setPickedCollection] = useState<string>(
|
||||
singleCollection ?? multipleCollections[0] ?? "",
|
||||
@@ -130,6 +160,24 @@ export function ReferenceArrayField({
|
||||
add(slug);
|
||||
};
|
||||
|
||||
/** Options for the "add existing" SearchableSelect: full label, exclude already selected. */
|
||||
const addSelectOptions: SearchableSelectOption[] =
|
||||
multipleCollections.length > 1 && multiQueries.data
|
||||
? multiQueries.data.flatMap(({ collection: coll, items: list }) =>
|
||||
(list as Record<string, unknown>[])
|
||||
.filter((i) => !valueList.includes(String(i._slug ?? "")))
|
||||
.map((i) => ({
|
||||
value: `${coll}:${i._slug ?? ""}`,
|
||||
label: getOptionLabel(i),
|
||||
}))
|
||||
)
|
||||
: ((singleQuery.data?.items ?? []) as Record<string, unknown>[])
|
||||
.filter((i) => !valueList.includes(String(i._slug ?? "")))
|
||||
.map((i) => ({
|
||||
value: String(i._slug ?? ""),
|
||||
label: getOptionLabel(i),
|
||||
}));
|
||||
|
||||
const remove = (index: number) => {
|
||||
onChange(valueList.filter((_, i) => i !== index));
|
||||
};
|
||||
@@ -153,33 +201,47 @@ export function ReferenceArrayField({
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-700">{label}</span>
|
||||
<div className="mb-1">
|
||||
{label}
|
||||
{schemaCollections.length > 0 ? (
|
||||
<span className="text-xs text-gray-500">
|
||||
<p className="mt-0.5 text-xs text-gray-500">
|
||||
{schemaCollections.length === 1
|
||||
? `Typ: ${schemaCollections[0]}`
|
||||
: `Typen: ${schemaCollections.join(", ")}`}
|
||||
</span>
|
||||
? t("typeLabel", { collection: schemaCollections[0] })
|
||||
: t("typesLabel", { collections: schemaCollections.join(", ") })}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Selected entries */}
|
||||
<ul className="mb-2 space-y-1.5">
|
||||
{valueList.map((slug, index) => (
|
||||
{valueList.map((slug, index) => {
|
||||
const collForSlug =
|
||||
options.find((o) => o.slug === slug)?.collection ??
|
||||
effectiveCollection ??
|
||||
null;
|
||||
return (
|
||||
<li
|
||||
key={`${slug}-${index}`}
|
||||
className="flex items-center gap-2 rounded border border-gray-200 bg-gray-50 px-3 py-2 text-sm"
|
||||
>
|
||||
<span className="flex-1 font-mono text-gray-900">{slug}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{collForSlug && (
|
||||
<Link
|
||||
href={`/content/${collForSlug}/${encodeURIComponent(slug)}${locale ? `?_locale=${locale}` : ""}`}
|
||||
className="rounded p-1 text-accent-700 hover:bg-accent-50 hover:text-accent-900"
|
||||
title="Open entry"
|
||||
>
|
||||
→
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => move(index, -1)}
|
||||
disabled={index === 0}
|
||||
className="rounded p-1 text-gray-500 hover:bg-gray-200 hover:text-gray-700 disabled:opacity-40"
|
||||
title="Move up"
|
||||
aria-label="Move up"
|
||||
title={t("moveUp")}
|
||||
aria-label={t("moveUp")}
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
@@ -200,8 +262,8 @@ export function ReferenceArrayField({
|
||||
onClick={() => move(index, 1)}
|
||||
disabled={index === valueList.length - 1}
|
||||
className="rounded p-1 text-gray-500 hover:bg-gray-200 hover:text-gray-700 disabled:opacity-40"
|
||||
title="Move down"
|
||||
aria-label="Move down"
|
||||
title={t("moveDown")}
|
||||
aria-label={t("moveDown")}
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
@@ -221,8 +283,8 @@ export function ReferenceArrayField({
|
||||
type="button"
|
||||
onClick={() => remove(index)}
|
||||
className="rounded p-1 text-red-600 hover:bg-red-50 hover:text-red-700"
|
||||
title="Remove"
|
||||
aria-label="Remove"
|
||||
title={t("remove")}
|
||||
aria-label={t("remove")}
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
@@ -240,7 +302,8 @@ export function ReferenceArrayField({
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
{/* Without schema collection: choose component type first */}
|
||||
@@ -249,93 +312,75 @@ export function ReferenceArrayField({
|
||||
availableCollections.length > 0 ? (
|
||||
<div className="mb-2">
|
||||
<label className="mb-1 block text-xs font-medium text-gray-500">
|
||||
Component type
|
||||
{t("componentType")}
|
||||
</label>
|
||||
<select
|
||||
value={pickedCollection}
|
||||
onChange={(e) => setPickedCollection(e.target.value)}
|
||||
className="block w-full rounded border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900"
|
||||
>
|
||||
<option value="">— Select type —</option>
|
||||
{availableCollections.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{c}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Select value={pickedCollection} onValueChange={setPickedCollection}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder={t("selectType")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableCollections.map((c) => (
|
||||
<SelectItem key={c} value={c}>{c}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Add: select from existing + create new component */}
|
||||
{effectiveCollection || multipleCollections.length > 1 ? (
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-start">
|
||||
<select
|
||||
value=""
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
if (v) addFromOption(v);
|
||||
e.target.value = "";
|
||||
}}
|
||||
className="block flex-1 rounded border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900"
|
||||
disabled={isLoading}
|
||||
>
|
||||
<option value="">— Select from existing —</option>
|
||||
{options.map((item) => {
|
||||
const key =
|
||||
multipleCollections.length > 1
|
||||
? `${item.collection}:${item.slug}`
|
||||
: item.slug;
|
||||
const alreadySelected = valueList.includes(item.slug);
|
||||
const label =
|
||||
multipleCollections.length > 1
|
||||
? `${item.slug} (${item.collection})`
|
||||
: item.slug;
|
||||
return (
|
||||
<option key={key} value={key} disabled={alreadySelected}>
|
||||
{alreadySelected ? `${label} (already selected)` : label}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
<div className="min-w-0 flex-1">
|
||||
<SearchableSelect
|
||||
value=""
|
||||
onChange={(v) => v && addFromOption(v)}
|
||||
options={addSelectOptions}
|
||||
placeholder={t("selectFromExisting")}
|
||||
clearable={false}
|
||||
filterPlaceholder={t("filterPlaceholder")}
|
||||
emptyLabel={t("emptyLabel")}
|
||||
disabled={isLoading}
|
||||
ariaLabel={t("selectExistingAriaLabel")}
|
||||
/>
|
||||
</div>
|
||||
{collectionsForNew.length > 0 ? (
|
||||
<span className="flex shrink-0 flex-col gap-0.5">
|
||||
{collectionsForNew.length === 1 ? (
|
||||
<Link
|
||||
href={
|
||||
collectionsForNew[0]
|
||||
? `/content/${collectionsForNew[0]}/new${locale ? `?_locale=${locale}` : ""}`
|
||||
: "#"
|
||||
}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center justify-center gap-1 rounded border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
<span aria-hidden>+</span> New {collectionsForNew[0]} component
|
||||
</Link>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link
|
||||
href={
|
||||
collectionsForNew[0]
|
||||
? `/content/${collectionsForNew[0]}/new${locale ? `?_locale=${locale}` : ""}`
|
||||
: "#"
|
||||
}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t("newComponent", { collection: collectionsForNew[0] })}
|
||||
</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<select
|
||||
className="rounded border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900"
|
||||
<Select
|
||||
value=""
|
||||
onChange={(e) => {
|
||||
const c = e.target.value;
|
||||
if (c)
|
||||
window.open(
|
||||
`/content/${c}/new${locale ? `?_locale=${locale}` : ""}`,
|
||||
"_blank",
|
||||
);
|
||||
e.target.value = "";
|
||||
onValueChange={(c) => {
|
||||
if (c) window.open(
|
||||
`/content/${c}/new${locale ? `?_locale=${locale}` : ""}`,
|
||||
"_blank",
|
||||
);
|
||||
}}
|
||||
>
|
||||
<option value="">+ Create new component…</option>
|
||||
{collectionsForNew.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{c}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder={t("createNewComponent")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{collectionsForNew.map((c) => (
|
||||
<SelectItem key={c} value={c}>{c}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
<span className="text-xs text-gray-500">
|
||||
Open in new tab; then reload this page.
|
||||
{t("openInNewTab")}
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
@@ -344,10 +389,10 @@ export function ReferenceArrayField({
|
||||
|
||||
{schemaCollections.length === 0 && availableCollections.length === 0 ? (
|
||||
<p className="text-xs text-amber-600">
|
||||
No reference collection in schema. Set{" "}
|
||||
<code className="rounded bg-gray-100 px-1">items.collection</code> or{" "}
|
||||
<code className="rounded bg-gray-100 px-1">items.collections</code>{" "}
|
||||
in the type, or start the API and reload the page.
|
||||
{t("noCollection", {
|
||||
collectionCode: "items.collection",
|
||||
collectionsCode: "items.collections",
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user