initial commit after project creation

This commit is contained in:
Gerhard Scheikl
2026-04-01 09:38:50 +02:00
commit b02af637d4
292 changed files with 61408 additions and 0 deletions

View File

@@ -0,0 +1,533 @@
"use client";
import { CustomDropdown } from "@/src/components/ui/custom-dropdown";
import { detectLocalMode } from "@/src/utils/detectLocalMode";
import { parseFieldValue } from "@/src/utils/parseFieldValue";
import React from "react";
import { wrapFieldsWithMeta } from "tinacms";
// ========================================
// API FUNCTIONS
// ========================================
/**
* Loads schemas from the API
*/
const loadSchemas = async () => {
const response = await fetch("/api/list-api-schemas");
const result = await response.json();
if (!response.ok) {
throw new Error(result.error || "Failed to load schemas");
}
return result.schemas || [];
};
/**
* Loads tags for a specific schema
*/
const loadTagsForSchema = async (schemaFilename: string) => {
const response = await fetch(
`/api/get-tag-api-schema?filename=${encodeURIComponent(schemaFilename)}`
);
const result = await response.json();
if (!response.ok) {
throw new Error(result.error || "Failed to load schema");
}
const apiSchema = result.apiSchema;
if (apiSchema?.paths) {
const tagSet = new Set<string>();
// Extract tags
for (const path in apiSchema.paths) {
for (const method in apiSchema.paths[path]) {
const op = apiSchema.paths[path][method];
if (op.tags) {
for (const tag of op.tags) {
tagSet.add(tag);
}
}
}
}
return {
tags: Array.from(tagSet),
apiSchema: apiSchema,
};
}
return { tags: [], apiSchema: null };
};
/**
* Validates if an API path follows the simple pattern like /something/api/{id}
* and doesn't contain special characters
*/
const isValidApiPath = (input: string): boolean => {
// Extract just the path if input contains an HTTP method
const pathMatch = input.match(
/^\s*(GET|POST|PUT|DELETE|PATCH|OPTIONS|HEAD)?\s*(\/[^\s]*)/i
);
if (!pathMatch) return false;
const path = pathMatch[2]; // e.g., "/api/projects/{id}/TogglePreferredNoiseLevelMeasurement"
// Reject special characters not allowed in paths
const specialCharRegex = /[^a-zA-Z0-9\/\{\}\-_]/;
if (specialCharRegex.test(path)) {
return false;
}
// Allow multiple segments including path params anywhere
const validPathRegex =
/^\/([a-zA-Z0-9\-_]+|\{[a-zA-Z0-9\-_]+\})(\/([a-zA-Z0-9\-_]+|\{[a-zA-Z0-9\-_]+\}))*$/;
return validPathRegex.test(path);
};
/**
* Loads endpoints for a specific tag from API schema
*/
const loadEndpointsForTag = (
apiSchema: any,
tag: string,
setIsValidPath: (isValid: boolean) => void,
hasTags = true
) => {
const endpointsList: {
id: string;
label: string;
method: string;
path: string;
summary: string;
description: string;
}[] = [];
for (const path in apiSchema.paths) {
if (!isValidApiPath(path)) {
setIsValidPath(false);
}
for (const method in apiSchema.paths[path]) {
const op = apiSchema.paths[path][method];
const endpoint = {
id: `${method.toUpperCase()}:${path}`,
label: `${method.toUpperCase()} ${path} - ${op.summary || ""}`,
method: method.toUpperCase(),
path,
summary: op.summary || "",
description: op.description || "",
};
// If we don't have tags, we show all endpoints
if (!hasTags) {
endpointsList.push(endpoint);
} else if (hasTags && op.tags?.includes(tag)) {
endpointsList.push(endpoint);
}
}
}
return endpointsList;
};
// ========================================
// MAIN COMPONENT
// ========================================
export const ApiReferencesSelector = wrapFieldsWithMeta((props: any) => {
const { input, meta } = props;
const [schemas, setSchemas] = React.useState<any[]>([]);
const [tags, setTags] = React.useState<string[]>([]);
const [endpoints, setEndpoints] = React.useState<
{
id: string;
label: string;
method: string;
path: string;
summary: string;
description: string;
}[]
>([]);
const [loadingSchemas, setLoadingSchemas] = React.useState(true);
const [loadingTags, setLoadingTags] = React.useState(false);
const [loadingEndpoints, setLoadingEndpoints] = React.useState(false);
const [selectedSchema, setSelectedSchema] = React.useState("");
const [selectedTag, setSelectedTag] = React.useState("");
const [hasTag, setHasTag] = React.useState<boolean | null>(null);
const [generatingFiles, setGeneratingFiles] = React.useState(false);
const [lastSavedValue, setLastSavedValue] = React.useState<string>("");
const [initialLoad, setInitialLoad] = React.useState(true);
const [isValidPath, setIsValidPath] = React.useState<boolean | null>(null);
const isLocalMode = detectLocalMode();
const parsedValue = parseFieldValue(input.value);
const selectedEndpoints = Array.isArray(parsedValue.endpoints)
? parsedValue.endpoints
: [];
// Load schemas from filesystem API
React.useEffect(() => {
const loadInitialData = async () => {
setLoadingSchemas(true);
try {
const schemasList = await loadSchemas();
setSchemas(schemasList);
setLoadingSchemas(false);
// Set local state from parsed values
const currentSchema = parsedValue.schema || "";
const currentTag = parsedValue.tag || "";
setSelectedSchema(currentSchema);
setSelectedTag(currentTag);
// If we have existing data, load tags and endpoints only once
if (currentSchema && hasTag === null) {
await loadTagsAndEndpoints(currentSchema, currentTag);
}
// Mark initial load as complete and set the last saved value
setInitialLoad(false);
setLastSavedValue(input.value || "");
} catch (error) {
setSchemas([]);
setLoadingSchemas(false);
setInitialLoad(false);
setLastSavedValue(input.value || "");
}
};
loadInitialData();
}, [parsedValue.schema, parsedValue.tag, input.value, hasTag]);
const loadTagsAndEndpoints = async (
schemaFilename: string,
currentTag?: string
) => {
setLoadingTags(true);
setIsValidPath(null);
try {
const { tags: tagsList, apiSchema } =
await loadTagsForSchema(schemaFilename);
setTags(tagsList);
setHasTag(tagsList.length > 0);
setLoadingTags(false);
// If we also have a selected tag, load endpoints
if (apiSchema) {
setLoadingEndpoints(true);
const tag = currentTag ?? "";
const hasTag = !!currentTag;
const endpointsList = loadEndpointsForTag(
apiSchema,
tag,
setIsValidPath,
hasTag
);
setEndpoints(endpointsList);
setLoadingEndpoints(false);
}
} catch (error) {
setTags([]);
setLoadingTags(false);
}
};
// Handle schema change
const handleSchemaChange = async (schema: string) => {
// Only update if schema actually changed to reduce form disruption
if (schema === selectedSchema) return;
setSelectedSchema(schema);
setSelectedTag("");
setIsValidPath(null);
setTags([]);
setEndpoints([]);
// Update form state with a slight delay to avoid dropdown disruption
setTimeout(() => {
input.onChange(JSON.stringify({ schema, tag: "", endpoints: [] }));
}, 0);
if (!schema) {
setLoadingTags(false);
return;
}
await loadTagsAndEndpoints(schema);
};
// Handle tag change
const handleTagChange = async (tag: string) => {
// Only update if tag actually changed
if (tag === selectedTag) return;
setSelectedTag(tag);
setEndpoints([]);
// Update form state with a slight delay to avoid dropdown disruption
setTimeout(() => {
input.onChange(
JSON.stringify({ schema: selectedSchema, tag, endpoints: [] })
);
}, 0);
if (!tag || !selectedSchema) {
setLoadingEndpoints(false);
return;
}
try {
const { apiSchema } = await loadTagsForSchema(selectedSchema);
if (apiSchema) {
setLoadingEndpoints(true);
const endpointsList = loadEndpointsForTag(
apiSchema,
tag,
setIsValidPath
);
setEndpoints(endpointsList);
setLoadingEndpoints(false);
}
} catch (error) {
setEndpoints([]);
setLoadingEndpoints(false);
}
};
const handleEndpointCheckbox = (id: string) => {
let updated: typeof endpoints;
const clickedEndpoint = endpoints.find((ep) => ep.id === id);
if (!clickedEndpoint) return;
if (selectedEndpoints.find((ep) => ep.id === id)) {
updated = selectedEndpoints.filter((ep) => ep.id !== id);
} else {
updated = [...selectedEndpoints, clickedEndpoint];
}
input.onChange(
JSON.stringify({
schema: selectedSchema,
tag: selectedTag,
endpoints: updated,
})
);
};
const handleSelectAll = () => {
input.onChange(
JSON.stringify({
schema: selectedSchema,
tag: selectedTag,
endpoints: endpoints,
})
);
};
return (
<div className="bg-white rounded-xl shadow-md p-7 my-5 max-w-xl w-full border border-gray-200 font-sans">
<div className="mb-6">
<label className="font-bold text-slate-800 text-base mb-2 block">
API Schema
</label>
<CustomDropdown
options={schemas.map((schema) => ({
value: schema.filename,
label: schema.displayName,
}))}
value={selectedSchema}
onChange={handleSchemaChange}
disabled={loadingSchemas}
placeholder={
loadingSchemas
? "Loading schemas..."
: schemas.length === 0
? "No schemas available"
: "Select a schema"
}
className="w-full px-4 py-2 rounded-lg border border-slate-300 text-base bg-slate-50 mb-2 focus:outline-none focus:ring-2 focus:ring-blue-400"
/>
{!loadingSchemas && schemas.length === 0 && (
<div className="text-red-600 text-sm mt-1">
No API schemas found. This might be due to:
<br /> Missing TinaCMS client generation on staging
<br /> Missing schema files deployment
<br /> Environment configuration issues
<br />
<br />
Please ensure schema files are uploaded to the "API Schema"
collection.
</div>
)}
</div>
{selectedSchema && (
<div className="mb-6">
{hasTag || hasTag === null ? (
<>
<label className="font-bold text-slate-800 text-base mb-2 block">
Group/Tag
</label>
<CustomDropdown
options={tags.map((tag) => ({
value: tag,
label: tag,
}))}
value={selectedTag}
onChange={handleTagChange}
disabled={loadingTags}
placeholder={loadingTags ? "Loading tags..." : "Select a tag"}
className="w-full px-4 py-2 rounded-lg border border-slate-300 text-base bg-slate-50 mb-2 focus:outline-none focus:ring-2 focus:ring-blue-400"
/>
</>
) : (
<div className="text-red-600 text-sm mt-1 border border-red-300 p-2 rounded-md">
No tags found for this schema.
</div>
)}
</div>
)}
{(selectedTag || hasTag === false) && (
<div>
<div className="flex items-center mb-3">
<label className="font-bold text-slate-800 text-base mr-4">
Endpoints
</label>
<button
type="button"
onClick={handleSelectAll}
disabled={loadingEndpoints || isValidPath === false}
className={`ml-auto px-4 py-1.5 rounded-md bg-blue-600 text-white font-semibold text-sm shadow hover:bg-blue-700 transition-colors border border-blue-700 disabled:opacity-50 ${
isValidPath === false ? "cursor-not-allowed" : ""
}`}
>
Select All
</button>
</div>
{!loadingEndpoints && isValidPath === false && (
<div className="text-red-600 text-sm mb-4 p-2 bg-red-50 border border-red-200 rounded-md text-wrap">
Unsupported Schema format detected. Please check the README for
supported endpoint configurations{" "}
<a
href="https://github.com/tinacms/tina-docs/tree/main?tab=readme-ov-file#api-documentation"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
Read more
</a>
.
</div>
)}
{loadingEndpoints ? (
<div className="text-slate-400 text-sm mb-4">
Loading endpoints...
</div>
) : (
<div
className={`grid grid-cols-1 sm:grid-cols-2 gap-2 max-h-64 overflow-y-auto overflow-x-auto border border-gray-200 rounded-lg bg-slate-50 p-4 mb-4 ${
isValidPath === false ? "opacity-50 cursor-not-allowed " : ""
}`}
>
{endpoints.map((ep) => (
<label
key={ep.id}
className={`flex items-center rounded-md px-2 py-2 cursor-pointer transition-colors border ${
selectedEndpoints.some((selected) => selected.id === ep.id)
? "bg-indigo-50 border-indigo-400 shadow"
: "bg-white border-gray-200"
} hover:bg-indigo-100 ${
isValidPath === false ? "cursor-not-allowed" : ""
}`}
>
<input
type="checkbox"
checked={selectedEndpoints.some(
(selected) => selected.id === ep.id
)}
onChange={() => handleEndpointCheckbox(ep.id)}
className={`accent-indigo-600 mr-3 ${
isValidPath === false
? "cursor-not-allowed"
: "cursor-pointer"
}`}
disabled={isValidPath === false}
/>
<span
className={`text-slate-700 text-sm font-medium truncate ${
isValidPath === false ? "cursor-not-allowed" : ""
}`}
style={{ maxWidth: "14rem", display: "inline-block" }}
title={ep.label}
>
{ep.label}
</span>
</label>
))}
{endpoints.length === 0 && (
<div className="text-slate-400 text-sm col-span-2">
No endpoints found for this tag.
</div>
)}
</div>
)}
{/* Form Save Generation Status */}
{selectedEndpoints.length > 0 && (
<div className="mb-4 p-3 bg-green-50 rounded-lg border border-green-200">
{generatingFiles ? (
<div className="flex items-center text-green-700">
<span className="inline-block mr-2 animate-spin"></span>
<span className="text-sm font-medium">
{isLocalMode
? "Generating MDX files locally..."
: "Creating files via TinaCMS..."}
</span>
</div>
) : (
<div className="text-green-700">
<div className="flex items-center mb-1">
<span className="inline-block mr-2">💾</span>
<span className="text-sm font-medium">
Ready for Save & Generate
</span>
</div>
<div className="text-xs text-green-600">
{selectedEndpoints.length} endpoint
{selectedEndpoints.length !== 1 ? "s" : ""} selected
<br />
Files will be generated using{" "}
<span className="underline">TinaCMS GraphQL</span> when you
hit save.
</div>
</div>
)}
</div>
)}
{selectedEndpoints.length > 0 && (
<p className="text-xs text-black bg-yellow-100 p-2 rounded-md mb-4 break-all overflow-x-auto whitespace-pre">
Following are the endpoint(s) that will have their mdx files
generated.
</p>
)}
<div className="mt-2 p-3 bg-gray-100 rounded text-xs text-gray-700 font-mono break-all overflow-x-auto whitespace-pre">
{JSON.stringify(
{
schema: selectedSchema,
tag: selectedTag,
endpoints: selectedEndpoints,
},
null,
2
)}
</div>
</div>
)}
</div>
);
});

View File

@@ -0,0 +1,294 @@
"use client";
import {
ChevronDownIcon,
ChevronRightIcon,
DocumentIcon,
FolderIcon,
TrashIcon,
} from "@heroicons/react/24/outline";
import { useState } from "react";
import React from "react";
import type { DragEvent, KeyboardEvent } from "react";
// Types
export interface FileItem {
id: string;
name: string;
type: "file" | "folder";
parentId: string | null;
}
export interface TreeNode extends FileItem {
children: TreeNode[];
level: number;
}
export interface DragState {
draggedId: string | null;
dragOverId: string | null;
dropPosition: "before" | "after" | "inside" | null;
}
export interface EditState {
editingId: string | null;
setEditingId: (id: string | null) => void;
}
export interface DragActions {
dragState: DragState;
setDragState: (state: DragState) => void;
}
export interface TreeState {
expandedFolders: Set<string>;
toggleFolder: (id: string) => void;
}
export interface FileTreeItemProps {
node: TreeNode;
editState: EditState;
dragActions: DragActions;
treeState: TreeState;
onUpdate: (id: string, updates: Partial<FileItem>) => void;
onDelete: (id: string) => void;
onMove: (
draggedId: string,
targetId: string | null,
position: "before" | "after" | "inside"
) => void;
}
// Constants for drag and drop
export const DRAG_STATE_RESET: DragState = {
draggedId: null,
dragOverId: null,
dropPosition: null,
};
const DROP_ZONES = {
BEFORE_THRESHOLD: 0.25,
AFTER_THRESHOLD: 0.75,
FILE_MIDDLE_THRESHOLD: 0.5,
} as const;
const SPACING = {
LEVEL_INDENT: 20,
BASE_PADDING: 8,
} as const;
export const FileTreeItem = ({
node,
editState,
dragActions,
treeState,
onUpdate,
onDelete,
onMove,
}: FileTreeItemProps) => {
const { editingId, setEditingId } = editState;
const { dragState, setDragState } = dragActions;
const { expandedFolders, toggleFolder } = treeState;
const [editName, setEditName] = useState(node.name);
const isExpanded = expandedFolders.has(node.id);
const hasChildren = node.children.length > 0;
const isEditing = editingId === node.id;
const isDragging = dragState.draggedId === node.id;
const isDragOver = dragState.dragOverId === node.id;
const handleSave = () => {
if (editName.trim()) {
onUpdate(node.id, { name: editName.trim() });
setEditingId(null);
}
};
const handleCancel = () => {
setEditName(node.name);
setEditingId(null);
};
const handleKeyPress = (e: KeyboardEvent) => {
if (e.key === "Enter") handleSave();
else if (e.key === "Escape") handleCancel();
};
const handleDragStart = (e: DragEvent) => {
e.dataTransfer.setData("text/plain", node.id);
e.dataTransfer.effectAllowed = "move";
setDragState({ ...DRAG_STATE_RESET, draggedId: node.id });
};
const handleDragEnd = () => setDragState(DRAG_STATE_RESET);
const getDropPosition = (
e: DragEvent,
rect: DOMRect
): "before" | "after" | "inside" => {
const relativeY = (e.clientY - rect.top) / rect.height;
if (node.type === "folder") {
if (relativeY < DROP_ZONES.BEFORE_THRESHOLD) return "before";
if (relativeY > DROP_ZONES.AFTER_THRESHOLD) return "after";
return "inside";
}
return relativeY < DROP_ZONES.FILE_MIDDLE_THRESHOLD ? "before" : "after";
};
const handleDragOver = (e: DragEvent) => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
if (!dragState.draggedId) return;
setDragState({
...dragState,
dragOverId: node.id,
dropPosition: getDropPosition(e, e.currentTarget.getBoundingClientRect()),
});
};
const handleDragLeave = (e: DragEvent) => {
// Only clear if we're leaving this element entirely
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
setDragState({
...dragState,
dragOverId: null,
dropPosition: null,
});
}
};
const handleDrop = (e: DragEvent) => {
e.preventDefault();
if (!dragState.draggedId || !dragState.dropPosition) return;
onMove(dragState.draggedId, node.id, dragState.dropPosition);
setDragState(DRAG_STATE_RESET);
};
const getDropIndicatorClass = () => {
if (!isDragOver || !dragState.dropPosition) return "";
const indicators = {
before: "border-t-2 border-blue-500",
after: "border-b-2 border-blue-500",
inside: "bg-blue-50 border border-blue-300",
};
return indicators[dragState.dropPosition] || "";
};
const leftPadding = node.level * SPACING.LEVEL_INDENT + SPACING.BASE_PADDING;
return (
<div>
<div
className={[
"flex items-center gap-2 py-1 px-2 hover:bg-gray-50 rounded text-sm group relative w-fit min-w-full",
!isEditing && "cursor-grab active:cursor-grabbing",
isDragging && "opacity-50",
getDropIndicatorClass(),
]
.filter(Boolean)
.join(" ")}
style={{
paddingLeft: `${leftPadding}px`,
}}
draggable={!isEditing}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{/* Expand/Collapse Icon */}
{node.type === "folder" && hasChildren && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
toggleFolder(node.id);
}}
className="flex-shrink-0 hover:bg-gray-200 rounded p-0.5"
>
{isExpanded ? (
<ChevronDownIcon className="h-3 w-3 text-gray-600" />
) : (
<ChevronRightIcon className="h-3 w-3 text-gray-600" />
)}
</button>
)}
{(node.type === "file" || !hasChildren) && (
<div className="w-4 flex-shrink-0" />
)}
{/* File/Folder Icon */}
<div className="flex-shrink-0">
{node.type === "folder" ? (
<FolderIcon className="h-4 w-4 text-blue-500" />
) : (
<DocumentIcon className="h-4 w-4 text-gray-500" />
)}
</div>
{/* Name */}
<div className="flex-1 min-w-0">
{isEditing ? (
<input
type="text"
value={editName}
onChange={(e) => setEditName(e.target.value)}
onBlur={handleSave}
onKeyDown={handleKeyPress}
className="w-full px-1 py-0.5 text-sm border border-blue-300 rounded focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
) : (
<span
className={`cursor-pointer ${node.type === "folder" ? "font-medium" : ""}`}
onClick={(e) => {
e.stopPropagation();
setEditingId(node.id);
}}
>
{node.name}
</span>
)}
</div>
{/* Actions */}
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onDelete(node.id);
}}
className="p-1 hover:bg-red-100 rounded"
title="Delete"
>
<TrashIcon className="h-3 w-3 text-red-600" />
</button>
</div>
</div>
{/* Children */}
{node.type === "folder" &&
isExpanded &&
node.children.map((child) => (
<FileTreeItem
// @ts-expect-error - key is not a prop of FileTreeItemProps, false positive
key={child.id}
node={child}
editState={{ editingId, setEditingId }}
dragActions={{ dragState, setDragState }}
treeState={{ expandedFolders, toggleFolder }}
onUpdate={onUpdate}
onDelete={onDelete}
onMove={onMove}
/>
))}
</div>
);
};

View File

@@ -0,0 +1,214 @@
"use client";
import { DocumentIcon, FolderIcon } from "@heroicons/react/24/outline";
import { useState } from "react";
import React from "react";
import { wrapFieldsWithMeta } from "tinacms";
import {
DRAG_STATE_RESET,
type DragState,
type FileItem,
FileTreeItem,
type TreeNode,
} from "./file-structure.item";
// Convert flat array to tree structure
const buildTree = (items: FileItem[]): TreeNode[] => {
const itemMap = new Map<string, TreeNode>();
const rootItems: TreeNode[] = [];
for (const item of items) {
itemMap.set(item.id, {
...item,
children: [],
level: 0,
});
}
for (const item of items) {
const node = itemMap.get(item.id);
if (!node) continue;
if (item.parentId === null) {
rootItems.push(node);
} else {
const parent = itemMap.get(item.parentId);
if (parent) {
parent.children.push(node);
node.level = parent.level + 1;
}
}
}
return rootItems;
};
export const FileStructureField = wrapFieldsWithMeta(({ input }) => {
const items: FileItem[] = input.value || [];
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(
new Set()
);
const [editingId, setEditingId] = useState<string | null>(null);
const [dragState, setDragState] = useState<DragState>(DRAG_STATE_RESET);
const toggleFolder = (id: string) => {
setExpandedFolders((prev) =>
prev.has(id)
? new Set([...prev].filter((item) => item !== id))
: new Set(prev).add(id)
);
};
const isValidDrop = (draggedId: string, targetId: string): boolean => {
if (draggedId === targetId) return false;
const draggedItem = items.find((item) => item.id === draggedId);
if (draggedItem?.type !== "folder") return true;
// Check if target is a descendant of dragged folder
const isDescendant = (parentId: string, checkId: string): boolean =>
items.some(
(item) =>
item.parentId === parentId &&
(item.id === checkId || isDescendant(item.id, checkId))
);
return !isDescendant(draggedId, targetId);
};
const moveItem = (
draggedId: string,
targetId: string | null,
position: "before" | "after" | "inside"
) => {
if (!isValidDrop(draggedId, targetId || "")) return;
const draggedItem = items.find((item) => item.id === draggedId);
const targetItem = targetId
? items.find((item) => item.id === targetId)
: null;
if (!draggedItem || !targetItem) return;
const filteredItems = items.filter((item) => item.id !== draggedId);
const targetIndex = filteredItems.findIndex((item) => item.id === targetId);
if (targetIndex === -1) return;
let insertIndex: number;
let newParentId: string | null;
if (position === "inside" && targetItem.type === "folder" && targetId) {
newParentId = targetId;
setExpandedFolders((prev) => new Set(prev).add(targetId));
// Insert after last child or after target folder
const lastChild = filteredItems.findLastIndex(
(item) => item.parentId === targetId
);
insertIndex = lastChild !== -1 ? lastChild + 1 : targetIndex + 1;
} else {
newParentId = targetItem.parentId;
insertIndex = position === "before" ? targetIndex : targetIndex + 1;
}
filteredItems.splice(insertIndex, 0, {
...draggedItem,
parentId: newParentId,
});
input.onChange(filteredItems);
};
const addItem = (parentId: string | null, type: "file" | "folder") => {
const newItem: FileItem = {
id: Math.random().toString(36).substr(2, 9),
name: type === "folder" ? "New Folder" : "new-file.txt",
type,
parentId,
};
input.onChange([...items, newItem]);
setEditingId(newItem.id);
if (parentId) {
setExpandedFolders((prev) => new Set(prev).add(parentId));
}
};
const updateItem = (id: string, updates: Partial<FileItem>) => {
input.onChange(
items.map((item) => (item.id === id ? { ...item, ...updates } : item))
);
};
const deleteItem = (id: string) => {
const getChildIds = (parentId: string): string[] =>
items
.filter((item) => item.parentId === parentId)
.flatMap((child) => [child.id, ...getChildIds(child.id)]);
const idsToDelete = new Set([id, ...getChildIds(id)]);
input.onChange(items.filter((item) => !idsToDelete.has(item.id)));
};
const treeNodes = buildTree(items);
return (
<div className="w-full">
<div className="border border-gray-200 rounded-lg overflow-hidden bg-white">
{/* Header */}
<div className="px-4 py-3 border-b border-gray-200 bg-slate-50 flex items-center justify-between">
<div className="flex gap-2">
<button
type="button"
onClick={() => addItem(null, "folder")}
className="flex items-center gap-1 px-2 py-1 text-xs bg-blue-500 text-white rounded hover:bg-blue-600"
>
<FolderIcon className="h-3 w-3" />
Add Folder
</button>
<button
type="button"
onClick={() => addItem(null, "file")}
className="flex items-center gap-1 px-2 py-1 text-xs bg-green-500 text-white rounded hover:bg-green-600"
>
<DocumentIcon className="h-3 w-3" />
Add File
</button>
</div>
</div>
{/* File Tree */}
<div className="p-4 min-h-[200px] font-mono text-sm">
{treeNodes.length === 0 ? (
<div className="text-center text-gray-500 py-8 max-w-full break-words text-wrap">
<div className="mb-2">No files or folders yet</div>
<div className="text-xs">
Click "Add Folder" or "Add File" to get started
</div>
</div>
) : (
treeNodes.map((node) => (
<div
key={`file-tree-item-${node.id}`}
className="w-full overflow-x-scroll"
>
<FileTreeItem
node={node}
editState={{ editingId, setEditingId }}
dragActions={{ dragState, setDragState }}
treeState={{ expandedFolders, toggleFolder }}
onUpdate={updateItem}
onDelete={deleteItem}
onMove={moveItem}
/>
</div>
))
)}
</div>
</div>
{/* Current items count */}
<div className="mt-2 text-xs text-gray-500">
{items.length} item{items.length !== 1 ? "s" : ""} total
</div>
</div>
);
});

View File

@@ -0,0 +1,469 @@
import * as React from "react";
import * as dropzone from "react-dropzone";
// import type { FieldProps } from "tinacms/dist/forms";
// Try default import from tinacms
// import FieldProps from "tinacms";
// Fallback: restore the original TinaFieldProps interface if FieldProps cannot be imported
interface TinaFieldProps {
input: {
value: string;
onChange: (value: string) => void;
};
field: {
name: string;
label?: string;
description?: string;
};
}
const { useDropzone } = dropzone;
// Define Swagger-related types
interface SwaggerEndpoint {
path: string;
method: string;
summary: string;
description: string;
operationId: string;
tags: string[];
parameters: any[];
responses: Record<string, any>;
consumes: string[];
produces: string[];
security: any[];
}
interface SwaggerInfo {
title?: string;
version?: string;
description?: string;
[key: string]: any;
}
interface SwaggerParseResult {
endpoints: SwaggerEndpoint[];
info: SwaggerInfo | null;
definitions?: Record<string, any>;
error: string | null;
}
// Utility function to parse Swagger JSON and extract endpoints
export const parseSwaggerJson = (jsonString: string): SwaggerParseResult => {
try {
// Parse the JSON string into an object
const swagger = JSON.parse(jsonString);
// Check if it's a valid Swagger/OpenAPI document
if (!swagger.paths) {
return {
endpoints: [],
info: null,
error: "Invalid Swagger JSON: Missing paths object",
};
}
// Extract basic API information
const info = swagger.info || {};
// Extract endpoints from the paths object
const endpoints: SwaggerEndpoint[] = [];
for (const path of Object.keys(swagger.paths)) {
const pathItem = swagger.paths[path];
// Process each HTTP method in the path (GET, POST, PUT, DELETE, etc.)
for (const method of Object.keys(pathItem)) {
const operation = pathItem[method];
// Create an endpoint object with relevant information
const endpoint: SwaggerEndpoint = {
path,
method: method.toUpperCase(),
summary: operation.summary || "",
description: operation.description || "",
operationId: operation.operationId || "",
tags: operation.tags || [],
parameters: operation.parameters || [],
responses: operation.responses || {},
consumes: operation.consumes || [],
produces: operation.produces || [],
security: operation.security || [],
};
endpoints.push(endpoint);
}
}
return {
endpoints,
info,
definitions: swagger.definitions || {},
error: null,
};
} catch (error) {
return { endpoints: [], info: null, error: error.message };
}
};
// SVG icons as React components
const FileIcon = (props: React.SVGProps<SVGSVGElement>) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="feather feather-file"
{...props}
>
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z" />
<polyline points="13 2 13 9 20 9" />
</svg>
);
// JSON file icon
const JsonFileIcon = (props: React.SVGProps<SVGSVGElement>) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="feather feather-file-text"
{...props}
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<polyline points="10 9 9 9 8 9" />
</svg>
);
const FilePlusIcon = (props: React.SVGProps<SVGSVGElement>) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="feather feather-file-plus"
{...props}
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="12" y1="18" x2="12" y2="12" />
<line x1="9" y1="15" x2="15" y2="15" />
</svg>
);
const TrashIcon = (props: React.SVGProps<SVGSVGElement>) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="feather feather-trash-2"
{...props}
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
<line x1="10" y1="11" x2="10" y2="17" />
<line x1="14" y1="11" x2="14" y2="17" />
</svg>
);
// File Preview component
const FilePreview = ({ src }: { src: string }) => {
const fileName = src.split("/").pop() || src;
return (
<div className="max-w-full w-full flex-1 flex justify-start items-center gap-3 p-3 bg-gray-50 rounded shadow-sm">
<div className="w-12 h-12 bg-white shadow border border-gray-100 rounded flex justify-center flex-none">
<FileIcon className="w-3/5 h-full text-gray-500" />
</div>
<div className="flex-1 overflow-hidden">
<span className="text-base font-medium block truncate">{fileName}</span>
<span className="text-sm text-gray-500 block">
{src.startsWith("http") ? "Uploaded file" : src}
</span>
</div>
</div>
);
};
// JSON file preview
const JsonFilePreview = ({
fileName,
jsonPreview,
}: {
fileName: string;
jsonPreview?: string;
}) => {
return (
<div className="max-w-full w-full flex-1 flex justify-start items-center gap-3 p-3 bg-gray-50 rounded shadow-sm">
<div className="w-12 h-12 bg-white shadow border border-gray-100 rounded flex justify-center flex-none">
<JsonFileIcon className="w-3/5 h-full text-blue-500" />
</div>
<div className="flex-1 overflow-hidden">
<span className="text-base font-medium block truncate">{fileName}</span>
{jsonPreview && (
<span className="text-sm text-gray-500 block truncate">
{jsonPreview}
</span>
)}
</div>
</div>
);
};
// Loading indicator
const LoadingIndicator = () => (
<div className="p-6 w-full flex flex-col justify-center items-center">
<div className="flex space-x-1">
<div className="w-2 h-2 bg-blue-400 rounded-full animate-bounce" />
<div
className="w-2 h-2 bg-blue-400 rounded-full animate-bounce"
style={{ animationDelay: "0.2s" }}
/>
<div
className="w-2 h-2 bg-blue-400 rounded-full animate-bounce"
style={{ animationDelay: "0.4s" }}
/>
</div>
</div>
);
// Delete button
const DeleteFileButton = ({
onClick,
}: {
onClick: (_event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
}) => {
return (
<button
type="button"
className="flex-none bg-white bg-opacity-80 hover:bg-opacity-100 shadow-sm p-1 rounded-full border border-gray-200"
onClick={onClick}
>
<TrashIcon className="w-5 h-5 text-red-500" />
</button>
);
};
// JSON File Upload component
export const JsonFileUploadComponent = ({ input, field }: TinaFieldProps) => {
const [loading, setLoading] = React.useState(false);
const [fileName, setFileName] = React.useState("");
const [jsonPreview, setJsonPreview] = React.useState("");
const [swaggerData, setSwaggerData] =
React.useState<SwaggerParseResult | null>(null);
React.useEffect(() => {
// Parse existing data if available
if (input.value && typeof input.value === "string" && input.value.trim()) {
try {
const data = parseSwaggerJson(input.value);
setSwaggerData(data);
// Show number of endpoints in the preview
if (data.endpoints?.length > 0) {
setJsonPreview(
`Contains ${data.endpoints.length} endpoints. API: ${
data.info?.title || "Unknown"
}`
);
}
} catch (error) {
// Handle error silently
}
}
}, [input.value]);
const handleDrop = async (acceptedFiles: File[]) => {
if (acceptedFiles.length === 0) return;
setLoading(true);
try {
const file = acceptedFiles[0];
// Only accept JSON files
if (file.type === "application/json" || file.name.endsWith(".json")) {
const reader = new FileReader();
reader.onload = () => {
try {
const jsonContent = reader.result as string;
// Parse the Swagger JSON to validate and extract info
const swaggerData = parseSwaggerJson(jsonContent);
setSwaggerData(swaggerData);
if (swaggerData.error) {
alert(`Error parsing Swagger JSON: ${swaggerData.error}`);
setLoading(false);
return;
}
// Store the complete JSON content in the input
input.onChange(jsonContent);
setFileName(file.name);
// Create a meaningful preview
let preview = "";
if (swaggerData.info?.title) {
preview = `${swaggerData.info.title} v${
swaggerData.info.version || "unknown"
} - `;
}
preview += `${swaggerData.endpoints.length} endpoints`;
setJsonPreview(preview);
// Log for debugging
setLoading(false);
} catch (parseError) {
try {
const parseError = new Error("Error handling JSON");
alert(
"Error parsing JSON file. Please ensure it is a valid Swagger/OpenAPI JSON."
);
setLoading(false);
} catch (error) {
setLoading(false);
}
}
};
reader.onerror = () => {
setLoading(false);
};
reader.readAsText(file); // Read as text for JSON files
} else {
alert("Please upload a JSON file only");
setLoading(false);
}
} catch (error) {
setLoading(false);
}
};
const handleClear = () => {
input.onChange("");
setFileName("");
setJsonPreview("");
setSwaggerData(null);
};
const handleBrowseClick = () => {
// Trigger file browser
document.getElementById(`file-upload-${field.name}`)?.click();
};
const { getRootProps, getInputProps, isDragActive } = useDropzone({
accept: {
"application/json": [],
"text/json": [],
},
onDrop: handleDrop,
noClick: true, // We'll handle clicks ourselves
});
return (
<div className="w-full max-w-full">
{field.label && (
<h4 className="mb-1 font-medium text-gray-600">{field.label}</h4>
)}
{field.description && (
<p className="mb-2 text-sm text-gray-500">{field.description}</p>
)}
<div
className={`border-2 ${
isDragActive ? "border-blue-400 bg-blue-50" : "border-gray-200"
} border-dashed rounded-md transition-colors duration-200 ease-in-out`}
{...getRootProps()}
>
<input {...getInputProps()} id={`file-upload-${field.name}`} />
{input.value ? (
loading ? (
<LoadingIndicator />
) : (
<div className="relative w-full max-w-full">
<div className="p-1">
<div
className="w-full focus-within:shadow-outline focus-within:border-blue-500 rounded outline-none overflow-visible cursor-pointer border-none hover:bg-gray-100 transition ease-out duration-100"
onClick={handleBrowseClick}
>
<JsonFilePreview
fileName={fileName}
jsonPreview={jsonPreview}
/>
</div>
{/* Show summary of parsed Swagger data */}
{swaggerData && swaggerData.endpoints.length > 0 && (
<div className="mt-2 p-3 text-sm text-gray-700 bg-gray-50 rounded">
<p className="font-semibold">
API: {swaggerData.info?.title || "Unknown"}
</p>
<p>Version: {swaggerData.info?.version || "Unknown"}</p>
<p>{swaggerData.endpoints.length} endpoints found</p>
</div>
)}
</div>
<div className="absolute top-2 right-2">
<DeleteFileButton
onClick={(e) => {
e.stopPropagation();
handleClear();
}}
/>
</div>
</div>
)
) : (
<button
type="button"
className="outline-none relative hover:bg-gray-50 w-full"
onClick={handleBrowseClick}
>
{loading ? (
<LoadingIndicator />
) : (
<div
className={`text-center py-6 px-4 ${
isDragActive ? "text-blue-500" : "text-gray-500"
}`}
>
<JsonFileIcon className="mx-auto w-10 h-10 mb-2 text-blue-500" />
<p className="text-base font-medium">
{isDragActive
? "Drop the JSON file here"
: "Drag and drop a Swagger/OpenAPI JSON file here"}
</p>
<p className="text-sm mt-1">
or{" "}
<span className="text-blue-500 cursor-pointer">browse</span>{" "}
to upload
</p>
</div>
)}
</button>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,166 @@
"use client";
import Editor from "@monaco-editor/react";
import debounce from "lodash/debounce";
import React, { useEffect, useState, useRef, useCallback } from "react";
import { wrapFieldsWithMeta } from "tinacms";
const MINIMUM_HEIGHT = 75;
if (typeof window !== "undefined") {
import("@monaco-editor/react")
.then(({ loader }) => {
loader.config({
paths: {
vs: "https://cdn.jsdelivr.net/npm/monaco-editor@0.31.1/min/vs",
},
});
})
.catch((e) => {
// Failed to load Monaco editor
});
}
const MonacoCodeEditor = wrapFieldsWithMeta(({ input }) => {
const [value, setValue] = useState(input.value || "");
const [editorHeight, setEditorHeight] = useState(MINIMUM_HEIGHT);
const [isLoaded, setIsLoaded] = useState(false);
const editorRef = useRef<any>(null);
const lastSavedValue = useRef(input.value || "");
const updateTinaValue = useCallback(
debounce((newValue: string) => {
lastSavedValue.current = newValue;
input.onChange(newValue);
}, 100),
[]
);
useEffect(() => {
if (input.value !== lastSavedValue.current && input.value !== value) {
setValue(input.value || "");
lastSavedValue.current = input.value || "";
}
}, [input.value, value]);
const handleEditorDidMount = useCallback(
(editor: any, monaco: any) => {
editorRef.current = editor;
if (monaco?.languages?.typescript) {
monaco.languages.typescript.javascriptDefaults.setEagerModelSync(true);
monaco.languages.typescript.javascriptDefaults.setDiagnosticsOptions({
noSemanticValidation: true,
noSyntaxValidation: true,
});
}
editor.onDidContentSizeChange(() => {
const contentHeight = Math.max(
editor.getContentHeight(),
MINIMUM_HEIGHT
);
setEditorHeight(contentHeight);
editor.layout();
});
editor.onDidChangeModelContent(() => {
const currentValue = editor.getValue();
if (currentValue !== lastSavedValue.current) {
setValue(currentValue);
updateTinaValue(currentValue);
}
});
// Mark as loaded and focus after a brief delay for smooth transition
setTimeout(() => {
setIsLoaded(true);
setTimeout(() => {
try {
editorRef.current?.focus();
} catch (e) {
// Error focusing editor silently ignored
}
}, 200);
}, 100);
},
[updateTinaValue]
);
const handleBeforeMount = useCallback(() => {}, []);
const editorOptions = {
scrollBeyondLastLine: false,
tabSize: 2,
disableLayerHinting: true,
accessibilitySupport: "off" as const,
codeLens: false,
wordWrap: "on" as const,
minimap: {
enabled: false,
},
fontSize: 14,
lineHeight: 2,
formatOnPaste: true,
lineNumbers: "on" as const,
formatOnType: true,
fixedOverflowWidgets: true,
folding: false,
renderLineHighlight: "none" as const,
scrollbar: {
verticalScrollbarSize: 1,
horizontalScrollbarSize: 1,
alwaysConsumeMouseWheel: false,
},
automaticLayout: true,
};
return (
<div className="relative mb-2 mt-0.5 rounded-lg shadow-md border-gray-200 border overflow-hidden">
<style>
{`.monaco-editor .editor-widget {
display: none !important;
visibility: hidden !important;
padding: 0 1rem !important;
}
.editor-container {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.editor-loading {
background: linear-gradient(90deg, #1e1e1e 0%, #2d2d2d 50%, #1e1e1e 100%);
background-size: 200% 100%;
animation: shimmer 2s infinite;
}
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}`}
</style>
<div
className={`editor-container ${!isLoaded ? "editor-loading" : ""}`}
style={{
height: `${editorHeight}px`,
opacity: isLoaded ? 1 : 0.7,
transform: isLoaded ? "scale(1)" : "scale(0.98)",
position: "relative",
}}
>
{!isLoaded && (
<div className="absolute inset-0 flex items-center justify-center text-gray-400 text-sm z-10 rounded-lg overflow-hidden">
Loading editor...
</div>
)}
<Editor
height="100%"
language="javascript"
theme="vs-dark"
value={value}
options={editorOptions}
onMount={handleEditorDidMount}
beforeMount={handleBeforeMount}
/>
</div>
</div>
);
});
export default MonacoCodeEditor;

View File

@@ -0,0 +1,33 @@
import React from "react";
import { FaArrowRight } from "react-icons/fa";
export const RedirectItem = ({ source, destination, permanent }) => {
const displaySource = displayPath(source);
const displayDestination = displayPath(destination);
return (
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-3">
<span className="text-orange-600 font-semibold">{displaySource}</span>
<FaArrowRight className="text-slate-600 w-4 h-4" />
<span className="text-green-600 font-semibold">
{displayDestination}
</span>
</div>
<div
className={`rounded-full mr-4 px-2 py-0.5 text-xs opacity-70 ${
permanent ? "bg-blue-100" : "border-2 bg-gray-100"
}`}
title={permanent ? "Permanent Redirect" : "Temporary Redirect"}
>
{permanent ? "permanent" : "temporary"}
</div>
</div>
);
};
const displayPath = (path) => {
if (!path) return "";
if (path.replace("/", "").length === 0) return "home";
return path.replace("/", "");
};

View File

@@ -0,0 +1,24 @@
import React from "react";
import { wrapFieldsWithMeta } from "tinacms";
export const TextInputWithCount = (max: number, isTextArea = false) =>
wrapFieldsWithMeta(({ input }) => (
<div className="flex flex-col gap-2">
{isTextArea ? (
<textarea
className="focus:shadow-outline block min-h-40 w-full resize-y rounded-md border border-gray-200 px-3 py-2 text-base text-gray-600 shadow-inner focus:border-blue-500 focus:text-gray-900"
{...input}
/>
) : (
<input
className="focus:shadow-outline block w-full rounded-md border border-gray-200 bg-white px-3 py-2 text-base text-gray-600 shadow-inner transition-all duration-150 ease-out placeholder:text-gray-300 focus:border-blue-500 focus:text-gray-900 focus:outline-none"
{...input}
/>
)}
<p
className={input.value.length > max ? "text-red-500" : "text-gray-500"}
>
{input.value.length}/{max}
</p>
</div>
));

View File

@@ -0,0 +1,225 @@
import React, { type FC } from "react";
import { wrapFieldsWithMeta } from "tinacms";
// Theme definitions with their color palettes
const themes = [
{
value: "default",
label: "Default",
description: "Clean monochrome design",
colors: {
primary: "#0f172a",
secondary: "#64748b",
accent: "#e2e8f0",
},
},
{
value: "tina",
label: "Tina",
description: "TinaCMS-inspired orange & blue",
colors: {
primary: "#EC4815",
secondary: "#0084FF",
accent: "#93E9BE",
},
},
{
value: "blossom",
label: "Blossom",
description: "Elegant pink & rose tones",
colors: {
primary: "#e54666",
secondary: "#dc3b5d",
accent: "#ffcdcf",
},
},
{
value: "lake",
label: "Lake",
description: "Professional blue palette",
colors: {
primary: "#0090FF",
secondary: "#3b82f6",
accent: "#bfdbfe",
},
},
{
value: "pine",
label: "Pine",
description: "Natural green tones",
colors: {
primary: "#30A46C",
secondary: "#5bb98c",
accent: "#c4e8d1",
},
},
{
value: "indigo",
label: "Indigo",
description: "Modern purple & violet",
colors: {
primary: "#6E56CF",
secondary: "#b197fc",
accent: "#e1d9ff",
},
},
];
interface ThemeOptionProps {
theme: {
value: string;
label: string;
description: string;
colors: {
primary: string;
secondary: string;
accent: string;
};
};
isSelected: boolean;
onClick: () => void;
}
const ThemeOption: FC<ThemeOptionProps> = ({ theme, isSelected, onClick }) => {
return (
<div
className={`
relative p-4 rounded-lg border cursor-pointer transition-all duration-200 bg-white max-w-sm hover:shadow-md
${
isSelected
? "border-blue-500 shadow-md"
: "border-gray-200 hover:border-gray-300 shadow-sm"
}
`}
onClick={onClick}
>
{/* Color palette preview */}
<div className="flex space-x-2 mb-3">
<div
className="w-6 h-6 rounded-full border border-gray-200 shadow-sm"
style={{ backgroundColor: theme.colors.primary }}
title={`Primary: ${theme.colors.primary}`}
/>
<div
className="w-6 h-6 rounded-full border border-gray-200 shadow-sm"
style={{ backgroundColor: theme.colors.secondary }}
title={`Secondary: ${theme.colors.secondary}`}
/>
<div
className="w-6 h-6 rounded-full border border-gray-200 shadow-sm"
style={{ backgroundColor: theme.colors.accent }}
title={`Accent: ${theme.colors.accent}`}
/>
</div>
{/* Theme info */}
<div>
<div className="font-semibold text-gray-800 mb-1">{theme.label}</div>
<div className="text-sm text-gray-600">{theme.description}</div>
</div>
{/* Selection indicator */}
{isSelected && (
<div className="absolute top-2 right-2">
<div className="w-5 h-5 bg-blue-500 rounded-full flex items-center justify-center">
<svg
className="w-3 h-3 text-white"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
</div>
</div>
)}
</div>
);
};
export const ThemeSelector = wrapFieldsWithMeta(({ input, field }) => {
const currentValue = input.value || "default";
const handleThemeChange = (themeValue: string) => {
input.onChange(themeValue);
};
return (
<div className="w-full">
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4">
{themes.map((theme, index) => (
<ThemeOption
key={`theme-${theme.value}-${index}`}
theme={theme}
isSelected={currentValue === theme.value}
onClick={() => handleThemeChange(theme.value)}
/>
))}
</div>
{/* Current selection display */}
<div className="mt-4 p-3 bg-gray-50 rounded-md">
<div className="text-sm text-gray-600">
Current theme:{" "}
<span className="font-semibold text-gray-800">
{themes.find((t) => t.value === currentValue)?.label ||
currentValue}
</span>
</div>
</div>
{/* Instructions for custom themes */}
<div className="mb-6 p-4 bg-blue-50 border border-blue-200 rounded-lg text-wrap">
<div className="flex items-start space-x-3">
<div className="flex-shrink-0">
<svg
className="w-5 h-5 text-blue-600"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clipRule="evenodd"
/>
</svg>
</div>
<div>
<h3 className="text-sm font-semibold text-blue-800 mb-1">
Want to create a custom theme?
</h3>
<p className="text-sm text-blue-700 mb-2">
You can create your own custom themes by modifying the CSS
variables in the global stylesheet. See the{" "}
<a
href="https://github.com/tinacms/tina-docs#custom-theming"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 underline font-medium"
>
Custom Theming section in the README
</a>{" "}
for detailed instructions on how to create new themes.
</p>
<div className="text-xs text-blue-600">
<strong>Files to modify:</strong>{" "}
<code className="bg-blue-100 px-1 rounded">
src/styles/global.css
</code>
,
<code className="bg-blue-100 px-1 rounded">
tina/customFields/theme-selector.tsx
</code>
, and
<code className="bg-blue-100 px-1 rounded">
src/components/ui/theme-selector.tsx
</code>
</div>
</div>
</div>
</div>
</div>
);
});