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

1
tina/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
__generated__

View File

@@ -0,0 +1,20 @@
import { wrapFieldsWithMeta } from "tinacms";
import { JsonFileUploadComponent } from "../customFields/file-upload";
export const API_Schema_Collection = {
name: "apiSchema",
label: "API Schema",
path: "content/apiSchema",
format: "json",
fields: [
{
name: "apiSchema",
label: "API Schema",
type: "string",
ui: { component: wrapFieldsWithMeta(JsonFileUploadComponent) },
},
],
};
export default API_Schema_Collection;

105
tina/collections/docs.tsx Normal file
View File

@@ -0,0 +1,105 @@
import AccordionTemplate, {
AccordionBlockTemplate,
} from "@/tina/templates/markdown-embeds/accordion.template";
import { ApiReferenceTemplate } from "@/tina/templates/markdown-embeds/api-reference.template";
import CalloutTemplate from "@/tina/templates/markdown-embeds/callout.template";
import CardGridTemplate from "@/tina/templates/markdown-embeds/card-grid.template";
import CodeTabsTemplate from "@/tina/templates/markdown-embeds/code-tabs.template";
import { FileStructureTemplate } from "@/tina/templates/markdown-embeds/file-structure.template";
import RecipeTemplate from "@/tina/templates/markdown-embeds/recipe.template";
import ScrollShowcaseTemplate from "@/tina/templates/markdown-embeds/scroll-showcase.template";
import { TypeDefinitionTemplate } from "@/tina/templates/markdown-embeds/type-definition.template";
import YoutubeTemplate from "@/tina/templates/markdown-embeds/youtube.template";
import type { Template } from "tinacms";
import SeoInformation from "./seo-information";
export const docsCollection = {
name: "docs",
label: "Docs",
path: "content/docs",
format: "mdx",
ui: {
beforeSubmit: async ({ values }) => {
return {
...values,
last_edited: new Date().toISOString(),
auto_generated: false,
};
},
router: ({ document }) => {
if (document._sys.filename === "index") {
return "/";
}
const slug = document._sys.breadcrumbs.join("/");
return `/docs/${slug}`;
},
filename: {
slugify: (values) => {
return (
values?.title
?.toLowerCase()
.replace(/[^a-z0-9\s-]/g, "") // Remove special characters except spaces and dashes
.replace(/\s+/g, "-") // Replace spaces with dashes
.replace(/-+/g, "-") // Replace multiple dashes with single dash
.replace(/^-|-$/g, "") || // Remove leading/trailing dashes
""
);
},
},
},
fields: [
SeoInformation,
{
name: "title",
label: "Title",
type: "string",
isTitle: true,
required: true,
},
{
type: "string",
name: "last_edited",
label: "Last Edited",
ui: {
component: "hidden",
},
},
{
type: "boolean",
name: "auto_generated",
label: "Auto Generated",
description: "Indicates if this document was automatically generated",
ui: {
component: "hidden",
},
},
{
type: "boolean",
name: "tocIsHidden",
label: "Hide Table of Contents",
description:
"Hide the Table of Contents on this page and expand the content window.",
},
{
type: "rich-text",
name: "body",
label: "Body",
isBody: true,
templates: [
ScrollShowcaseTemplate as Template,
CardGridTemplate as Template,
RecipeTemplate as Template,
AccordionTemplate as Template,
AccordionBlockTemplate as Template,
ApiReferenceTemplate as Template,
YoutubeTemplate as Template,
CodeTabsTemplate as Template,
CalloutTemplate as Template,
TypeDefinitionTemplate as Template,
FileStructureTemplate as unknown as Template,
],
},
],
};
export default docsCollection;

View File

@@ -0,0 +1,206 @@
import { getBearerAuthHeader } from "@/src/utils/tina/get-bearer-auth-header";
import { ApiReferencesSelector } from "../customFields/api-reference-selector";
import { itemTemplate } from "../templates/navbar-ui.template";
import submenuTemplate from "../templates/submenu.template";
const docsNavigationBarFields = [
{
name: "title",
label: "Title Label",
type: "string",
},
{
name: "supermenuGroup",
label: "Supermenu Group",
type: "object",
list: true,
ui: {
itemProps: (item) => ({
label: `🗂️ ${item?.title ?? "Unnamed Menu Group"}`,
}),
},
fields: [
{ name: "title", label: "Name", type: "string" },
{
name: "items",
label: "Page or Submenu",
type: "object",
list: true,
templates: [submenuTemplate, itemTemplate],
},
],
},
];
const documentSubMenuTemplate = {
name: "documentSubMenu",
label: "Document Submenu",
fields: [
{ name: "title", label: "Name", type: "string" },
{
name: "items",
label: "Items",
type: "object",
list: true,
templates: [itemTemplate],
},
],
};
const groupOfApiReferencesTemplate = {
name: "groupOfApiReferences",
label: "Group of API References",
fields: [
{
type: "string",
name: "apiGroup",
label: "API Group",
ui: {
component: ApiReferencesSelector,
},
},
],
};
const apiNavigationBarFields = [
{
name: "title",
label: "title",
type: "string",
},
{
name: "supermenuGroup",
label: "Supermenu Group",
type: "object",
list: true,
templates: [documentSubMenuTemplate, groupOfApiReferencesTemplate],
},
];
const docsTabTemplate = {
name: "docsTab",
label: "Docs Tab",
fields: docsNavigationBarFields,
};
const apiTabTemplate = {
name: "apiTab",
label: "API Tab",
fields: apiNavigationBarFields,
};
export const docsNavigationBarCollection = {
name: "navigationBar",
label: "Navigation Bar",
path: "content/navigation-bar",
format: "json",
ui: {
allowedActions: {
create: false,
delete: false,
},
beforeSubmit: async ({ values }: { values: Record<string, any> }) => {
try {
// Generate .mdx files for API endpoints when navigation is saved
const response = await fetch("/api/process-api-docs", {
method: "POST",
headers: getBearerAuthHeader(),
body: JSON.stringify({
data: values,
}),
});
if (response.ok) {
const result = await response.json();
} else {
const error = await response.json();
// Log error but don't block the save operation
}
// Always return the values, don't block the save operation if file generation fails
return {
...values,
};
} catch (error) {
// Don't block the save operation if file generation fails
return {
...values,
};
}
},
},
fields: [
{
name: "lightModeLogo",
label: "Light Mode Logo",
type: "image",
},
{
name: "darkModeLogo",
label: "Dark Mode Logo",
type: "image",
description: "If your light mode logo fits dark-mode, leave this blank.",
},
{
name: "tabs",
label: "Tabs",
type: "object",
list: true,
ui: {
itemProps: (item) => ({
label: `🗂️ ${item?.title ?? "Unnamed Tab"}`,
}),
},
templates: [docsTabTemplate, apiTabTemplate],
},
{
name: "ctaButtons",
label: "CTA Buttons",
type: "object",
fields: [
{
name: "button1",
label: "Button 1",
type: "object",
fields: [
{ label: "Label", name: "label", type: "string" },
{ label: "Link", name: "link", type: "string" },
{
label: "variant",
name: "variant",
type: "string",
options: [
"primary-background",
"secondary-background",
"primary-outline",
"secondary-outline",
],
},
],
},
{
name: "button2",
label: "Button 2",
type: "object",
fields: [
{ label: "Label", name: "label", type: "string" },
{ label: "Link", name: "link", type: "string" },
{
label: "variant",
name: "variant",
type: "string",
options: [
"primary-background",
"secondary-background",
"primary-outline",
"secondary-outline",
],
},
],
},
],
},
],
};
export default docsNavigationBarCollection;

View File

@@ -0,0 +1,48 @@
import { TextInputWithCount } from "../customFields/text-input-with-count";
export const SeoInformation = {
type: "object",
label: "SEO Values",
name: "seo",
fields: [
{
type: "string",
label: "Meta - Title",
description: "Recommended limit of 70 characters",
name: "title",
ui: {
validate: (value) => {
if (value && value.length > 70) {
return "Title should be 70 characters or less";
}
},
component: TextInputWithCount(70),
},
},
{
type: "string",
label: "Meta - Description",
description: "Recommended limit of 150 characters",
name: "description",
component: "textarea",
ui: {
component: TextInputWithCount(150, true),
},
},
{
type: "string",
label: "Canonical URL",
name: "canonicalUrl",
description: "Default URL if no URL is provided",
},
{
type: "image",
label: "Open Graph Image",
name: "ogImage",
uploadDir: () => "og",
description: "Default image if no image is provided",
},
],
};
export default SeoInformation;

View File

@@ -0,0 +1,179 @@
import { BROWSER_TAB_THEME_KEY } from "@/src/components/ui/theme-selector";
import React from "react";
import { RedirectItem } from "../customFields/redirect-item";
import { ThemeSelector } from "../customFields/theme-selector";
export const Settings = {
name: "settings",
label: "Settings",
path: "content/settings",
format: "json",
ui: {
global: true,
allowedActions: {
create: false,
delete: false,
},
defaultItem: {
autoCapitalizeNavigation: true,
},
beforeSubmit: async ({ values }: { values: Record<string, any> }) => {
sessionStorage.setItem(BROWSER_TAB_THEME_KEY, values.selectedTheme);
},
},
fields: [
{
name: "selectedTheme",
label: "Selected Theme",
description: "Choose your website's visual theme with color previews",
type: "string",
ui: {
component: ThemeSelector,
},
},
{
name: "redirects",
label: "Redirects",
type: "object",
list: true,
ui: {
itemProps: (item) => {
return {
label:
item.source && item.destination ? (
<RedirectItem
source={item.source}
destination={item.destination}
permanent={item.permanent}
/>
) : (
"Add Redirect"
),
};
},
},
fields: [
{
name: "source",
label: "Source",
type: "string",
ui: {
validate: (value) => {
if (!value?.startsWith("/")) {
return "Source path must start with /";
}
},
},
},
{
name: "destination",
label: "Destination",
type: "string",
ui: {
validate: (value) => {
if (!value?.startsWith("/")) {
return "Destination path must start with /";
}
},
},
},
{
name: "permanent",
label: "Permanent",
type: "boolean",
},
],
},
{
name: "title",
label: "Title",
type: "string",
},
{
name: "description",
label: "Description",
type: "string",
},
{
name: "seoDefaultTitle",
label: "SEO Default Title",
type: "string",
},
{
name: "publisher",
label: "Publisher",
type: "string",
},
{
name: "applicationName",
label: "Application Name",
type: "string",
},
{
name: "siteUrl",
label: "Site URL",
type: "string",
},
{
name: "roadmapUrl",
label: "Roadmap URL",
type: "string",
},
{
name: "licenseUrl",
label: "License URL",
type: "string",
},
{
name: "keywords",
label: "Keywords",
type: "string",
},
{
name: "docsHomepage",
label: "Docs Homepage",
type: "string",
},
{
name: "autoApiTitles",
label: "Auto-Capitalize Titles",
description:
"Auto-capitalize titles in the navigation bar and generated API pages",
type: "boolean",
defaultValue: true,
},
{
name: "defaultOGImage",
label: "Default OG Image",
type: "image",
uploadDir: () => "og",
},
{
name: "social",
label: "Social",
type: "object",
fields: [
{
name: "twitterHandle",
label: "Twitter Handle",
type: "string",
},
{
name: "twitter",
label: "Twitter",
type: "string",
},
{
name: "github",
label: "GitHub",
type: "string",
},
{
name: "forum",
label: "Forum",
type: "string",
},
],
},
],
};

View File

@@ -0,0 +1,166 @@
import { CustomColorToggle } from "@/components/ui/custom-color-toggle";
export const GlobalSiteConfiguration = {
name: "globalSiteConfiguration",
label: "Global Site Configuration",
ui: {
global: true,
allowedActions: {
create: false,
delete: false,
},
},
path: "content/site-config",
format: "json",
fields: [
{
name: "docsConfig",
label: "Docs Config",
type: "object",
fields: [
{
name: "documentationSiteTitle",
label: "Documentation Site Title",
type: "string",
},
],
},
{
name: "colorScheme",
label: "Color Scheme",
type: "object",
fields: [
{
name: "siteColors",
label: "Site Colors",
type: "object",
defaultItem: () => {
return {
primaryStart: "#f97316",
primaryEnd: "#f97316",
primaryVia: "#f97316",
};
},
fields: [
{
name: "primaryStart",
label: "Primary Color | Gradient Start",
type: "string",
description:
"This is the start of the primary color gradient ⚠️ If you want a solid color leave the end and via empty ⚠️",
ui: {
component: "color",
colorFormat: "hex",
widget: "sketch",
},
},
{
name: "primaryEnd",
label: "Primary Color | Gradient End",
type: "string",
ui: {
component: "color",
colorFormat: "hex",
widget: "sketch",
},
},
{
name: "primaryVia",
label: "Primary Color | Gradient Via",
type: "string",
ui: {
component: "color",
colorFormat: "hex",
widget: "sketch",
},
},
{
name: "secondaryStart",
label: "Secondary Color | Gradient Start",
type: "string",
description:
"This is the start of the secondary color gradient ⚠️ If you want a solid color leave the end and via empty ⚠️",
ui: {
component: "color",
colorFormat: "hex",
widget: "sketch",
},
},
{
name: "secondaryEnd",
label: "Secondary Color | Gradient End",
type: "string",
ui: {
component: "color",
colorFormat: "hex",
widget: "sketch",
},
},
{
name: "secondaryVia",
label: "Secondary Color | Gradient Via",
type: "string",
ui: {
component: "color",
colorFormat: "hex",
widget: "sketch",
},
},
{
name: "rightHandSideActiveColor",
label: "Right Hand Side ToC Active Color",
type: "string",
ui: {
component: "color",
colorFormat: "hex",
},
},
{
name: "rightHandSideInactiveColor",
label: "Right Hand Side ToC Inactive Color",
type: "string",
ui: {
component: "color",
colorFormat: "hex",
},
},
],
},
{
name: "customColorToggle",
label: "Custom Color Toggle",
type: "object",
fields: [
{
name: "disableColor",
label: "Tick to use Default Background Color",
type: "boolean",
},
{
name: "colorValue",
label: "Color Value",
type: "string",
},
],
ui: {
component: CustomColorToggle,
},
},
{
name: "leftSidebarBackground",
label: "Left Sidebar Background",
type: "string",
description: "This is the background color of the left sidebar",
ui: {
component: "color",
colorFormat: "hex",
widget: "sketch",
},
},
],
},
],
};
export default GlobalSiteConfiguration;

33
tina/config.ts Normal file
View File

@@ -0,0 +1,33 @@
import { defineConfig } from "tinacms";
import { schema } from "./schema";
export const config = defineConfig({
telemetry: 'disabled',
schema,
clientId: process.env.NEXT_PUBLIC_TINA_CLIENT_ID,
branch:
process.env.NEXT_PUBLIC_TINA_BRANCH || // custom branch env override
process.env.NEXT_PUBLIC_VERCEL_GIT_COMMIT_REF || // Vercel branch env
process.env.HEAD, // Netlify branch env
token: process.env.TINA_TOKEN,
media: {
// If you wanted cloudinary do this
// loadCustomStore: async () => {
// const pack = await import("next-tinacms-cloudinary");
// return pack.TinaCloudCloudinaryMediaStore;
// },
// this is the config for the tina cloud media store
tina: {
publicFolder: "public",
mediaRoot: "",
},
accept: ["image/*", "video/*", "application/json", ".json"],
},
build: {
publicFolder: "public", // The public asset folder for your framework
outputFolder: "admin", // within the public folder
basePath: process.env.TINA_BASE_PATH || "",
},
});
export default config;

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>
);
});

234
tina/queries/post.gql Normal file
View File

@@ -0,0 +1,234 @@
fragment MinimisedNavigationBarParts on NavigationBar {
__typename
lightModeLogo
darkModeLogo
tabs {
__typename
... on NavigationBarTabsDocsTab {
title
supermenuGroup {
__typename
title
items {
__typename
... on NavigationBarTabsDocsTabSupermenuGroupItemsItems {
title
items {
__typename
... on NavigationBarTabsDocsTabSupermenuGroupItemsItemsItemsItems {
title
items {
__typename
... on NavigationBarTabsDocsTabSupermenuGroupItemsItemsItemsItemsItemsItems {
title
items {
__typename
... on NavigationBarTabsDocsTabSupermenuGroupItemsItemsItemsItemsItemsItemsItemsItem {
slug {
... on Docs {
__typename
seo {
__typename
title
description
canonicalUrl
ogImage
}
title
last_edited
auto_generated
tocIsHidden
}
... on Document {
_sys {
filename
basename
hasReferences
breadcrumbs
path
relativePath
extension
}
id
}
}
}
}
}
... on NavigationBarTabsDocsTabSupermenuGroupItemsItemsItemsItemsItemsItem {
slug {
... on Docs {
__typename
seo {
__typename
title
description
canonicalUrl
ogImage
}
title
last_edited
auto_generated
tocIsHidden
}
... on Document {
_sys {
filename
basename
hasReferences
breadcrumbs
path
relativePath
extension
}
id
}
}
}
}
}
... on NavigationBarTabsDocsTabSupermenuGroupItemsItemsItemsItem {
slug {
... on Docs {
__typename
seo {
__typename
title
description
canonicalUrl
ogImage
}
title
last_edited
auto_generated
tocIsHidden
}
... on Document {
_sys {
filename
basename
hasReferences
breadcrumbs
path
relativePath
extension
}
id
}
}
}
}
}
... on NavigationBarTabsDocsTabSupermenuGroupItemsItem {
slug {
... on Docs {
__typename
seo {
__typename
title
description
canonicalUrl
ogImage
}
title
last_edited
auto_generated
tocIsHidden
}
... on Document {
_sys {
filename
basename
hasReferences
breadcrumbs
path
relativePath
extension
}
id
}
}
}
}
}
}
... on NavigationBarTabsApiTab {
title
supermenuGroup {
__typename
... on NavigationBarTabsApiTabSupermenuGroupDocumentSubMenu {
title
items {
__typename
... on NavigationBarTabsApiTabSupermenuGroupDocumentSubMenuItemsItem {
slug {
... on Docs {
__typename
seo {
__typename
title
description
canonicalUrl
ogImage
}
title
last_edited
auto_generated
tocIsHidden
}
... on Document {
_sys {
filename
basename
hasReferences
breadcrumbs
path
relativePath
extension
}
id
}
}
}
}
}
... on NavigationBarTabsApiTabSupermenuGroupGroupOfApiReferences {
apiGroup
}
}
}
}
ctaButtons {
__typename
button1 {
__typename
label
link
variant
}
button2 {
__typename
label
link
variant
}
}
}
query minimisedNavigationBarFetch($relativePath: String!) {
navigationBar(relativePath: $relativePath) {
... on Document {
_sys {
filename
basename
hasReferences
breadcrumbs
path
relativePath
extension
}
id
}
...MinimisedNavigationBarParts
}
}

15
tina/schema.tsx Normal file
View File

@@ -0,0 +1,15 @@
import { type Collection, defineSchema } from "tinacms";
import API_Schema_Collection from "./collections/API-schema";
import docsCollection from "./collections/docs";
import docsNavigationBarCollection from "./collections/navigation-bar";
import { Settings } from "./collections/settings";
export const schema = defineSchema({
collections: [
docsCollection as Collection,
docsNavigationBarCollection as Collection,
//TODO: Investigate why casting as unknown works
API_Schema_Collection as unknown as Collection,
Settings as unknown as Collection,
],
});

View File

@@ -0,0 +1,101 @@
const AccordionItemFields = [
{
name: "heading",
label: "Heading",
type: "string",
description:
"The heading text that will be displayed in the collapsed state",
},
{
name: "docText",
label: "Body Text",
isBody: true,
type: "rich-text",
},
{
name: "image",
label: "image",
type: "image",
},
];
export const AccordionTemplate = {
name: "accordion",
label: "Accordion",
ui: {
defaultItem: {
heading: "Click to expand",
//TODO: Need to configure this to be a rich text field
docText: {
type: "root",
children: [
{
type: "p",
children: [
{
type: "text",
text: "Default Text. Edit me!",
},
],
},
],
},
image: "/img/rico-replacement.jpg",
fullWidth: false,
},
},
fields: [
...AccordionItemFields,
{
name: "fullWidth",
label: "Full Width",
type: "boolean",
},
],
};
export default AccordionTemplate;
export const AccordionBlockTemplate = {
name: "accordionBlock",
label: "Accordion Block",
fields: [
{
name: "fullWidth",
label: "Full Width",
type: "boolean",
},
{
name: "accordionItems",
label: "Accordion Items",
type: "object",
list: true,
fields: AccordionItemFields,
ui: {
itemProps: (item) => {
return {
label: item.heading ?? "Accordion Item",
};
},
defaultItem: {
heading: "Click to expand",
docText: {
type: "root",
children: [
{
type: "p",
children: [
{
type: "text",
text: "Default Text. Edit me!",
},
],
},
],
},
image: "/img/rico-replacement.jpg",
},
},
},
],
};

View File

@@ -0,0 +1,343 @@
"use client";
import { CustomDropdown } from "@/src/components/ui/custom-dropdown";
import type { DropdownOption } from "@/src/components/ui/custom-dropdown";
import React, { useState, useEffect } from "react";
// Define schema type to match the actual structure from the API
interface SchemaFile {
id: string;
relativePath: string;
apiSchema?: string | null;
_sys: {
filename: string;
};
}
// Interface for parsed Swagger/OpenAPI details
interface SchemaDetails {
title?: string;
version?: string;
endpointCount: number;
endpoints: Endpoint[];
}
// Interface for endpoint details
interface Endpoint {
path: string;
method: string;
summary: string;
operationId?: string;
}
// Parse Swagger/OpenAPI JSON to extract details
const parseSwaggerJson = (jsonContent: string): SchemaDetails => {
try {
const parsed = JSON.parse(jsonContent);
// Extract endpoints
const endpoints: Endpoint[] = [];
if (parsed.paths) {
for (const path of Object.keys(parsed.paths)) {
const pathObj = parsed.paths[path];
for (const method of Object.keys(pathObj)) {
const operation = pathObj[method];
endpoints.push({
path,
method: method.toUpperCase(),
summary: operation.summary || `${method.toUpperCase()} ${path}`,
operationId: operation.operationId,
});
}
}
}
return {
title: parsed.info?.title || "Unknown API",
version: parsed.info?.version || "Unknown Version",
endpointCount: endpoints.length,
endpoints,
};
} catch (error) {
return {
title: "Error Parsing Schema",
version: "Unknown",
endpointCount: 0,
endpoints: [],
};
}
};
const getSchemas = async () => {
try {
const { schemas } = await fetchSchemas();
if (schemas) {
// Convert API response into our simpler SchemaFile interface
const schemaFiles: SchemaFile[] = schemas.map((schema) => ({
id: schema.id,
relativePath: schema.filename,
apiSchema: schema.apiSchema,
_sys: {
filename: schema.displayName,
},
}));
return schemaFiles;
}
return [];
} catch (error) {
return [];
}
};
const fetchSchemas = async () => {
const response = await fetch("/api/list-api-schemas");
return await response.json();
};
const getSchemaDetails = async (schemaPath: string, schemas: SchemaFile[]) => {
try {
// Find the selected schema
const selectedSchema = schemas.find((s) => s.relativePath === schemaPath);
if (selectedSchema?.apiSchema) {
const details = parseSwaggerJson(selectedSchema.apiSchema);
return details;
}
// If the schema content isn't in the current data, fetch it
const { schemas: data } = await fetchSchemas();
if (data?.apiSchema) {
const details = parseSwaggerJson(data.apiSchema);
return details;
}
} catch (error) {
return null;
}
};
// Custom field for selecting an API schema file
const SchemaSelector = (props: any) => {
const { input, field } = props;
const [schemas, setSchemas] = useState<SchemaFile[]>([]);
const [loading, setLoading] = useState(true);
const [schemaDetails, setSchemaDetails] = useState<SchemaDetails | null>(
null
);
const [loadingDetails, setLoadingDetails] = useState(false);
const [selectedEndpoint, setSelectedEndpoint] = useState<string>("");
// Fetch schema details when a schema is selected
useEffect(() => {
if (!input.value) {
setSchemaDetails(null);
return;
}
const parts = input.value.split("|");
if (parts.length > 1) {
setSelectedEndpoint(parts[1]);
} else {
setSelectedEndpoint("");
}
const fetchSchemaDetails = async () => {
setLoadingDetails(true);
const details = await getSchemaDetails(
input.value.split("|")[0],
schemas
);
setSchemaDetails(details);
setLoadingDetails(false);
};
fetchSchemaDetails();
}, [input.value, schemas]);
// Fetch available schema files when component mounts
useEffect(() => {
const fetchSchemas = async () => {
setLoading(true);
const schemas = await getSchemas();
setSchemas(schemas);
setLoading(false);
};
fetchSchemas();
}, []);
const handleSchemaChange = async (schemaPath: string) => {
// Reset endpoint selection when schema changes
if (!schemaDetails) {
setLoadingDetails(true);
const details = await getSchemaDetails(
input.value.split("|")[0],
schemas
);
setSchemaDetails(details);
setLoadingDetails(false);
}
setSelectedEndpoint("");
input.onChange(schemaPath);
};
const handleEndpointChange = (endpoint: string) => {
setSelectedEndpoint(endpoint);
// Extract just the schema path
const schemaPath = input.value.split("|")[0];
// Combine schema path and endpoint
input.onChange(endpoint ? `${schemaPath}|${endpoint}` : schemaPath);
};
// Helper function to create a unique endpoint identifier
const createEndpointId = (endpoint: Endpoint) => {
return `${endpoint.method}:${endpoint.path}`;
};
return (
<div className="w-full max-w-full overflow-x-hidden">
<label className="block font-medium text-gray-700 mb-1">
{field.label || "Select API Schema"}
</label>
{loading ? (
<div className="py-2 px-3 bg-gray-100 rounded text-gray-500">
Loading schemas...
</div>
) : schemas.length === 0 ? (
<div className="max-w-full w-full py-2 px-3 bg-red-50 text-red-500 rounded whitespace-normal">
No API schema files found. Please upload one in the Content Manager.
</div>
) : (
<div className="max-w-full w-full overflow-x-hidden">
{/* Schema selector dropdown */}
<CustomDropdown
value={input.value?.split("|")[0]}
onChange={handleSchemaChange}
options={[
{ value: "", label: "Select a schema" },
...schemas.map<DropdownOption>((schema) => ({
value: schema.relativePath,
label: schema._sys.filename,
})),
]}
placeholder="Select a schema"
/>
{input.value && (
<div className="mt-3 p-3 bg-blue-50 text-blue-600 rounded text-sm w-full max-w-full overflow-x-hidden">
<div className="font-medium mb-1 truncate break-words whitespace-normal max-w-full">
Selected schema:{" "}
<span className="truncate break-words whitespace-normal max-w-full">
{
schemas.find(
(s) => s.relativePath === input.value.split("|")[0]
)?._sys.filename
}
</span>
</div>
{loadingDetails ? (
<div className="text-blue-500">Loading schema details...</div>
) : schemaDetails ? (
<>
<div className="grid grid-cols-3 gap-2 mt-2 w-full max-w-full">
<div className="bg-blue-100 p-2 rounded w-full max-w-full break-words whitespace-normal">
<div className="text-xs text-blue-500 truncate break-words whitespace-normal max-w-full">
API Name
</div>
<div className="font-medium truncate break-words whitespace-normal max-w-full">
{schemaDetails.title}
</div>
</div>
<div className="bg-blue-100 p-2 rounded w-full max-w-full break-words whitespace-normal">
<div className="text-xs text-blue-500 truncate break-words whitespace-normal max-w-full">
Version
</div>
<div className="font-medium truncate break-words whitespace-normal max-w-full">
{schemaDetails.version}
</div>
</div>
<div className="bg-blue-100 p-2 rounded w-full max-w-full break-words whitespace-normal">
<div className="text-xs text-blue-500 truncate break-words whitespace-normal max-w-full">
Endpoints
</div>
<div className="font-medium truncate break-words whitespace-normal max-w-full">
{schemaDetails.endpointCount}
</div>
</div>
</div>
{/* Endpoint selector */}
{schemaDetails.endpoints.length > 0 && (
<div className="mt-4 w-full max-w-full overflow-x-hidden">
<label className="block text-blue-700 font-medium mb-1">
Select Endpoint (Optional)
</label>
{/* Endpoint selector dropdown */}
<CustomDropdown
value={selectedEndpoint}
onChange={handleEndpointChange}
options={[
{ value: "", label: "All Endpoints" },
...schemaDetails.endpoints
.sort((a, b) => a.path.localeCompare(b.path))
.map<DropdownOption>((endpoint) => ({
value: createEndpointId(endpoint),
label: `${endpoint.method} ${endpoint.path} ${
endpoint.summary ? `- ${endpoint.summary}` : ""
}`,
})),
]}
placeholder="All Endpoints"
contentClassName="bg-white border border-blue-300"
/>
</div>
)}
</>
) : (
<div className="text-blue-500">
Unable to load schema details
</div>
)}
</div>
)}
</div>
)}
{field.description && (
<p className="mt-1 text-sm text-gray-500 break-words whitespace-normal max-w-full">
{field.description}
</p>
)}
<div className="mt-4 p-3 bg-gray-50 text-gray-600 rounded-md text-sm break-words whitespace-normal max-w-full">
<p>
<strong>Note:</strong> To add more schema files, go to the Content
Manager and add files to the API Schema collection.
</p>
</div>
</div>
);
};
export const ApiReferenceTemplate = {
name: "apiReference",
label: "API Reference",
ui: {
defaultItem: {
schemaFile: "test-doc.json",
},
},
fields: [
{
type: "string",
name: "schemaFile",
label: "API Schema",
description:
"Select a Swagger/OpenAPI schema file to display in this component. Optionally select a specific endpoint to display.",
ui: {
component: SchemaSelector,
},
},
],
};

View File

@@ -0,0 +1,27 @@
export const CalloutTemplate = {
name: "Callout",
label: "Callout",
ui: {
defaultItem: {
body: "This is a callout",
variant: "warning",
},
},
fields: [
{
name: "body",
label: "Body",
type: "rich-text",
isBody: true,
},
{
name: "variant",
label: "Variant",
type: "string",
options: ["warning", "info", "success", "error", "idea", "lock", "api"],
defaultValue: "warning",
},
],
};
export default CalloutTemplate;

View File

@@ -0,0 +1,66 @@
export const CardGridTemplate = {
name: "cardGrid",
label: "Card Grid",
ui: {
defaultItem: {
cards: [
{
title: "Card Title",
description: "Card Description",
link: "https://www.google.com",
linkText: "Search now",
},
],
},
},
fields: [
{
name: "cards",
label: "Cards",
type: "object",
list: true,
ui: {
defaultItem: () => {
return {
title: "Card Title",
description: "Card Description",
link: "https://www.google.com",
linkText: "Search now",
};
},
itemProps: (item) => {
return {
label: item.title || "Untitled",
};
},
},
fields: [
{
name: "title",
label: "Title",
type: "string",
},
{
name: "description",
label: "Description",
type: "string",
ui: {
component: "textarea",
},
},
{
name: "link",
label: "Link",
type: "string",
},
{
name: "linkText",
label: "Button Text",
type: "string",
},
],
},
],
};
export default CardGridTemplate;

View File

@@ -0,0 +1,156 @@
import MonacoCodeEditor from "@/tina/customFields/monaco-code-editor";
export const CodeTabsTemplate = {
name: "codeTabs",
label: "Code Tabs",
ui: {
defaultItem: {
tabs: [
{
name: "Query",
content: "const CONTENT_MANAGEMENT = 'Optimized';",
},
{
name: "Response",
content: "const LLAMAS = '100';",
},
],
initialSelectedIndex: 0,
},
},
fields: [
{
type: "object",
name: "tabs",
label: "Tabs",
list: true,
ui: {
itemProps: (item) => ({
label: `🗂️ ${item?.name ?? "Tab"}`,
}),
defaultItem: {
name: "Tab",
content: "const CONTENT_MANAGEMENT = 'Optimized';",
language: "text",
},
},
fields: [
{
type: "string",
name: "name",
label: "Name",
},
{
type: "string",
name: "language",
label: "Code Highlighting Language",
options: [
{
value: "text",
label: "Plain Text",
},
{
value: "javascript",
label: "JavaScript",
},
{
value: "typescript",
label: "TypeScript",
},
{
value: "python",
label: "Python",
},
{
value: "json",
label: "JSON",
},
{
value: "html",
label: "HTML",
},
{
value: "css",
label: "CSS",
},
{
value: "jsx",
label: "JSX",
},
{
value: "tsx",
label: "TSX",
},
{
value: "markdown",
label: "Markdown",
},
{
value: "shell",
label: "Shell",
},
{
value: "sql",
label: "SQL",
},
{
value: "graphql",
label: "GraphQL",
},
{
value: "java",
label: "Java",
},
{
value: "php",
label: "PHP",
},
{
value: "cpp",
label: "C++",
},
{
value: "yaml",
label: "YAML",
},
{
value: "xml",
label: "XML",
},
{
value: "scss",
label: "SCSS",
},
{
value: "vue",
label: "Vue",
},
{
value: "svelte",
label: "Svelte",
},
],
},
{
type: "string",
name: "content",
label: "Content",
ui: {
component: MonacoCodeEditor,
format: (val?: string) => val?.replaceAll("<22>", " "),
parse: (val?: string) => val?.replaceAll(" ", "<22>"),
},
},
],
},
{
type: "number",
name: "initialSelectedIndex",
label: "Initial Selected Index",
description:
"The index of the tab to select by default, starting from 0.",
},
],
};
export default CodeTabsTemplate;

View File

@@ -0,0 +1,45 @@
import { FileStructureField } from "@/tina/customFields/file-structure";
export const FileStructureTemplate = {
name: "fileStructure",
label: "File Structure",
fields: [
{
type: "object",
name: "fileStructure",
label: "File Structure",
ui: {
component: FileStructureField,
},
list: true,
fields: [
{
type: "string",
name: "id",
label: "ID",
},
{
type: "string",
name: "name",
label: "Name",
},
{
type: "string",
name: "type",
label: "Type",
},
{
type: "string",
name: "parentId",
label: "Parent ID",
},
],
},
{
type: "string",
name: "caption",
label: "Caption",
description: "Optional caption that appears under the component",
},
],
};

View File

@@ -0,0 +1,67 @@
import MonacoCodeEditor from "@/tina/customFields/monaco-code-editor";
export const RecipeTemplate = {
name: "recipe",
label: "Code Accordion (Recipe)",
fields: [
{
name: "title",
label: "Heading Title",
type: "string",
},
{
name: "description",
label: "Description",
type: "string",
},
{
type: "string",
name: "code",
label: "Code",
ui: {
component: MonacoCodeEditor,
format: (val?: string) => val?.replaceAll("<22>", " "),
parse: (val?: string) => val?.replaceAll(" ", "<22>"),
},
},
{
name: "instruction",
label: "Instruction",
type: "object",
list: true,
ui: {
itemProps: (item) => {
return { label: item?.header };
},
},
fields: [
{
name: "header",
label: "Header",
type: "string",
},
{
name: "itemDescription",
label: "Item Description",
type: "string",
},
{
name: "codeLineStart",
label: "Code Line Start",
type: "number",
description:
"Enter negative values to highlight from 0 to your end number",
},
{
name: "codeLineEnd",
label: "Code Line End",
type: "number",
description:
"Highlighting will not work if end number is greater than start number",
},
],
},
],
};
export default RecipeTemplate;

View File

@@ -0,0 +1,62 @@
export const ScrollShowcaseTemplate = {
label: "Scroll Showcase",
name: "scrollShowcase",
fields: [
{
type: "object",
label: "Showcase Items",
name: "showcaseItems",
list: true,
ui: {
defaultItem: {
title: "Title",
image: "/img/rico-replacement.jpg",
content: {
type: "root",
children: [
{
type: "p",
children: [
{
type: "text",
text: "Default Text. Edit me!",
},
],
},
],
},
useAsSubsection: false,
},
itemProps: (item) => {
return {
label: item.title,
};
},
},
fields: [
{
type: "image",
label: "Image",
name: "image",
},
{
type: "string",
label: "Title",
name: "title",
},
{
type: "boolean",
label: "Use as Subsection",
name: "useAsSubsection",
},
{
type: "rich-text",
label: "Content",
name: "content",
},
],
},
],
};
export default ScrollShowcaseTemplate;

View File

@@ -0,0 +1,53 @@
export const TypeDefinitionTemplate = {
name: "typeDefinition",
label: "Type Definition",
fields: [
{
type: "object",
name: "property",
label: "Property",
list: true,
ui: {
itemProps: (item) => {
return {
label: item.name,
};
},
},
fields: [
{
type: "string",
name: "name",
label: "Name",
},
{
type: "rich-text",
name: "description",
label: "Description",
},
{
type: "string",
name: "type",
label: "Type",
},
{
type: "string",
name: "typeUrl",
label: "Type URL",
description:
"Turns the type into a link for further context. Useful for deeply nested types.",
},
{
type: "boolean",
name: "required",
label: "Required",
},
{
type: "boolean",
name: "experimental",
label: "Experimental",
},
],
},
],
};

View File

@@ -0,0 +1,34 @@
export const YoutubeTemplate = {
name: "youtube",
label: "Youtube Video",
ui: {
defaultItem: {
embedSrc: "https://www.youtube.com/embed/CsCQS7HIBv0?si=os9ona92O2VMOl-V",
caption: "Seth goes over the basics of using TinaCMS",
minutes: "2",
},
},
fields: [
{
type: "string",
name: "embedSrc",
label: "Embed URL",
description:
"⚠︎ Only YouTube embed URLs work - they look like this https://www.youtube.com/embed/Yoh2c5RUTiY",
},
{
type: "string",
name: "caption",
label: "Caption",
description: "The caption of the video",
},
{
type: "string",
name: "minutes",
label: "Minutes",
description: "The duration of the video in minutes",
},
],
};
export default YoutubeTemplate;

View File

@@ -0,0 +1,35 @@
import type { Template } from "tinacms";
import { titleCase } from "title-case";
export const itemTemplate: Template = {
label: "Item",
name: "item",
ui: {
itemProps: (item) => {
return {
label: `🔗 ${titleCase(
item?.slug?.split("/").at(-1).split(".").at(0).replaceAll("-", " ") ??
"Unnamed Menu Item"
)}`,
};
},
},
fields: [
{
name: "slug",
label: "Page",
type: "reference",
collections: ["docs"],
},
],
};
export const submenusLabel: Pick<Template, "label" | "name" | "ui"> = {
label: "Submenu",
name: "items",
ui: {
itemProps: (item) => ({
label: `🗂️ ${item?.title ?? "Unnamed Menu Group"}`,
}),
},
};

View File

@@ -0,0 +1,37 @@
import type { Template } from "tinacms";
import { itemTemplate } from "./navbar-ui.template";
const createMenuTemplate = (
templates: Template[],
level: number
): Template => ({
label: "Submenu",
name: "items",
ui: {
itemProps: (item) => {
return { label: `🗂️ ${level} | ${item?.title ?? "Unnamed Menu Group"}` };
},
},
fields: [
{ name: "title", label: "Name", type: "string" },
{
name: "items",
label: "Submenu Items",
type: "object",
list: true,
templates,
},
],
});
const thirdLevelSubmenu: Template = createMenuTemplate([itemTemplate], 3);
const secondLevelSubmenu: Template = createMenuTemplate(
[thirdLevelSubmenu, itemTemplate],
2
);
export const submenuTemplate: Template = createMenuTemplate(
[secondLevelSubmenu, itemTemplate],
1
);
export default submenuTemplate;

1
tina/tina-lock.json Normal file

File diff suppressed because one or more lines are too long