initial commit after project creation
This commit is contained in:
1
tina/.gitignore
vendored
Normal file
1
tina/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
__generated__
|
||||
20
tina/collections/API-schema.tsx
Normal file
20
tina/collections/API-schema.tsx
Normal 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
105
tina/collections/docs.tsx
Normal 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;
|
||||
206
tina/collections/navigation-bar.tsx
Normal file
206
tina/collections/navigation-bar.tsx
Normal 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;
|
||||
48
tina/collections/seo-information.tsx
Normal file
48
tina/collections/seo-information.tsx
Normal 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;
|
||||
179
tina/collections/settings.tsx
Normal file
179
tina/collections/settings.tsx
Normal 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",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
166
tina/collections/site-config.tsx
Normal file
166
tina/collections/site-config.tsx
Normal 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
33
tina/config.ts
Normal 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;
|
||||
533
tina/customFields/api-reference-selector.tsx
Normal file
533
tina/customFields/api-reference-selector.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
294
tina/customFields/file-structure.item.tsx
Normal file
294
tina/customFields/file-structure.item.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
214
tina/customFields/file-structure.tsx
Normal file
214
tina/customFields/file-structure.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
469
tina/customFields/file-upload.tsx
Normal file
469
tina/customFields/file-upload.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
166
tina/customFields/monaco-code-editor.tsx
Normal file
166
tina/customFields/monaco-code-editor.tsx
Normal 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;
|
||||
33
tina/customFields/redirect-item.tsx
Normal file
33
tina/customFields/redirect-item.tsx
Normal 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("/", "");
|
||||
};
|
||||
24
tina/customFields/text-input-with-count.tsx
Normal file
24
tina/customFields/text-input-with-count.tsx
Normal 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>
|
||||
));
|
||||
225
tina/customFields/theme-selector.tsx
Normal file
225
tina/customFields/theme-selector.tsx
Normal 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
234
tina/queries/post.gql
Normal 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
15
tina/schema.tsx
Normal 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,
|
||||
],
|
||||
});
|
||||
101
tina/templates/markdown-embeds/accordion.template.tsx
Normal file
101
tina/templates/markdown-embeds/accordion.template.tsx
Normal 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",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
343
tina/templates/markdown-embeds/api-reference.template.tsx
Normal file
343
tina/templates/markdown-embeds/api-reference.template.tsx
Normal 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,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
27
tina/templates/markdown-embeds/callout.template.tsx
Normal file
27
tina/templates/markdown-embeds/callout.template.tsx
Normal 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;
|
||||
66
tina/templates/markdown-embeds/card-grid.template.tsx
Normal file
66
tina/templates/markdown-embeds/card-grid.template.tsx
Normal 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;
|
||||
156
tina/templates/markdown-embeds/code-tabs.template.tsx
Normal file
156
tina/templates/markdown-embeds/code-tabs.template.tsx
Normal 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;
|
||||
45
tina/templates/markdown-embeds/file-structure.template.tsx
Normal file
45
tina/templates/markdown-embeds/file-structure.template.tsx
Normal 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",
|
||||
},
|
||||
],
|
||||
};
|
||||
67
tina/templates/markdown-embeds/recipe.template.tsx
Normal file
67
tina/templates/markdown-embeds/recipe.template.tsx
Normal 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;
|
||||
62
tina/templates/markdown-embeds/scroll-showcase.template.tsx
Normal file
62
tina/templates/markdown-embeds/scroll-showcase.template.tsx
Normal 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;
|
||||
53
tina/templates/markdown-embeds/type-definition.template.tsx
Normal file
53
tina/templates/markdown-embeds/type-definition.template.tsx
Normal 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",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
34
tina/templates/markdown-embeds/youtube.template.tsx
Normal file
34
tina/templates/markdown-embeds/youtube.template.tsx
Normal 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;
|
||||
35
tina/templates/navbar-ui.template.tsx
Normal file
35
tina/templates/navbar-ui.template.tsx
Normal 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"}`,
|
||||
}),
|
||||
},
|
||||
};
|
||||
37
tina/templates/submenu.template.tsx
Normal file
37
tina/templates/submenu.template.tsx
Normal 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
1
tina/tina-lock.json
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user