From ade52dd5c0c25d3cc64e04c284eb59317acf1824 Mon Sep 17 00:00:00 2001 From: putuwahyu29 Date: Thu, 30 Jan 2025 13:49:08 +0700 Subject: [PATCH 01/95] Adding the Resend Platform Email Configuration --- .env.local.example | 1 + README.md | 1 + lib/actions/auth.ts | 2 +- lib/actions/collaborator.ts | 2 +- 4 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.env.local.example b/.env.local.example index f70c69a9..d0f8b036 100644 --- a/.env.local.example +++ b/.env.local.example @@ -7,6 +7,7 @@ xxx GITHUB_APP_WEBHOOK_SECRET="another-random-string-of-characters" GITHUB_APP_CLIENT_ID="your-github-app-client-id" GITHUB_APP_CLIENT_SECRET="your-github-app-client-secret" +RESEND_DOMAIN_EMAIL ="your-domain-email" RESEND_API_KEY="your-resend-api-key" SQLITE_URL="file:./local.db" # SQLITE_AUTH_TOKEN="" diff --git a/README.md b/README.md index c6b3d3f6..948ecf14 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,7 @@ Variable | Comments `GITHUB_APP_WEBHOOK_SECRET` | The secret you picked for your webhook. This is used to ensure the request is coming from GitHub. `GITHUB_APP_CLIENT_ID` | GitHub App Client ID from your GitHub App details page. `GITHUB_APP_CLIENT_SECRET` | GitHub App Client Secret you generate on theGitHub App details page. +`RESEND_DOMAIN_EMAIL` | The domain email you use for your Resend account. `RESEND_API_KEY` | You'll get that when you create a (free) [Resend](https://resend.com) account to handle emails. `SQLITE_URL` | `file:./local.db` for development, `libsql://pages-cms-username.turso.io` for example if you use [Turso](https://turso.tech) (you should, Turso is great). `SQLITE_AUTH_TOKEN` | Leave blank for development, otherwise use the token provided by [Turso](https://turso.tech) (if that's what you use). diff --git a/lib/actions/auth.ts b/lib/actions/auth.ts index b316678f..761633f3 100644 --- a/lib/actions/auth.ts +++ b/lib/actions/auth.ts @@ -45,7 +45,7 @@ const handleEmailSignIn = async (prevState: any, formData: FormData) => { const resend = new Resend(process.env.RESEND_API_KEY); const { data, error } = await resend.emails.send({ - from: "Pages CMS ", + from: `Pages CMS <${process.env.RESEND_DOMAIN_EMAIL}>`, to: [email], subject: "Sign in link for Pages CMS", react: LoginEmailTemplate({ diff --git a/lib/actions/collaborator.ts b/lib/actions/collaborator.ts index ac60a566..80f89f16 100644 --- a/lib/actions/collaborator.ts +++ b/lib/actions/collaborator.ts @@ -62,7 +62,7 @@ const handleAddCollaborator = async (prevState: any, formData: FormData) => { Promise.resolve().then(async () => { const { data, error } = await resend.emails.send({ - from: "Pages CMS ", + from: `Pages CMS <${process.env.RESEND_DOMAIN_EMAIL}>`, to: [email], subject: `Join "${owner}/${repo}" on Pages CMS`, react: InviteEmailTemplate({ From 3d89b66627c8a29dd5fd3cdd787bf286a038a1c1 Mon Sep 17 00:00:00 2001 From: putuwahyu29 Date: Thu, 30 Jan 2025 14:32:27 +0700 Subject: [PATCH 02/95] Update Readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 948ecf14..db505ab4 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ Variable | Comments `GITHUB_APP_WEBHOOK_SECRET` | The secret you picked for your webhook. This is used to ensure the request is coming from GitHub. `GITHUB_APP_CLIENT_ID` | GitHub App Client ID from your GitHub App details page. `GITHUB_APP_CLIENT_SECRET` | GitHub App Client Secret you generate on theGitHub App details page. -`RESEND_DOMAIN_EMAIL` | The domain email you use for your Resend account. +`RESEND_DOMAIN_EMAIL` | The domain email you use for your Resend account (e.g. `no-reply@mail.resend.com` ) `RESEND_API_KEY` | You'll get that when you create a (free) [Resend](https://resend.com) account to handle emails. `SQLITE_URL` | `file:./local.db` for development, `libsql://pages-cms-username.turso.io` for example if you use [Turso](https://turso.tech) (you should, Turso is great). `SQLITE_AUTH_TOKEN` | Leave blank for development, otherwise use the token provided by [Turso](https://turso.tech) (if that's what you use). From 2b019209021bb1a6f8338840c38237b48878420b Mon Sep 17 00:00:00 2001 From: Ronan Berder Date: Mon, 10 Feb 2025 16:33:11 +0800 Subject: [PATCH 03/95] Dealing with null value in collection views --- fields/core/boolean/view-component.tsx | 2 ++ fields/core/date/view-component.tsx | 2 ++ fields/core/rich-text/view-component.tsx | 2 ++ 3 files changed, 6 insertions(+) diff --git a/fields/core/boolean/view-component.tsx b/fields/core/boolean/view-component.tsx index f191513e..c96f0c4d 100644 --- a/fields/core/boolean/view-component.tsx +++ b/fields/core/boolean/view-component.tsx @@ -1,6 +1,8 @@ "use client"; const ViewComponent = ({ value }: { value: boolean}) => { + if (value == null) return null; + const firstValue = Array.isArray(value) ? value[0] : value; if (firstValue == null) return null; const extraValuesCount = Array.isArray(value) ? value.length - 1 : 0; diff --git a/fields/core/date/view-component.tsx b/fields/core/date/view-component.tsx index 01e34ed8..b321c0ea 100644 --- a/fields/core/date/view-component.tsx +++ b/fields/core/date/view-component.tsx @@ -12,6 +12,8 @@ const ViewComponent = ({ value: string | string[], field: Field }) => { + if (!value) return null; + const firstValue = Array.isArray(value) ? value[0] : value; if (firstValue == null) return null; const extraValuesCount = Array.isArray(value) ? value.length - 1 : 0; diff --git a/fields/core/rich-text/view-component.tsx b/fields/core/rich-text/view-component.tsx index a6acfc06..d2306cfb 100644 --- a/fields/core/rich-text/view-component.tsx +++ b/fields/core/rich-text/view-component.tsx @@ -9,6 +9,8 @@ const ViewComponent = ({ value: string, field: Field }) => { + if (!value) return null; + const sanitizeHtml = (text: string) => { return text .replace(/<[^>]*>/g, ' ') From 75a799f43f8bb3f14511c05e87480f84b0fad612 Mon Sep 17 00:00:00 2001 From: Ronan Berder Date: Mon, 10 Feb 2025 20:52:15 +0800 Subject: [PATCH 04/95] Making sure branches are encoded in URLs and API calls (#145) --- app/(main)/[owner]/[repo]/[branch]/page.tsx | 8 ++++---- components/collection/collection-view.tsx | 12 ++++++------ components/empty-create.tsx | 4 ++-- components/entry/entry-editor.tsx | 14 +++++++------- components/file-options.tsx | 6 +++--- components/folder-create.tsx | 2 +- components/media/media-upload.tsx | 2 +- components/media/media-view.tsx | 4 ++-- components/repo/repo-branches.tsx | 2 +- components/repo/repo-nav.tsx | 8 ++++---- 10 files changed, 31 insertions(+), 31 deletions(-) diff --git a/app/(main)/[owner]/[repo]/[branch]/page.tsx b/app/(main)/[owner]/[repo]/[branch]/page.tsx index 9325ab1e..7b71685b 100644 --- a/app/(main)/[owner]/[repo]/[branch]/page.tsx +++ b/app/(main)/[owner]/[repo]/[branch]/page.tsx @@ -12,11 +12,11 @@ export default function Page() { useEffect(() => { if (config?.object.content?.[0]) { - router.replace(`/${config.owner}/${config.repo}/${config.branch}/${config.object.content[0].type}/${config.object.content[0].name}`); + router.replace(`/${config.owner}/${config.repo}/${encodeURIComponent(config.branch)}/${config.object.content[0].type}/${config.object.content[0].name}`); } else if (config?.object.media) { - router.replace(`/${config.owner}/${config.repo}/${config.branch}/media`); - } else if (config?.object.settings !== false) { - router.replace(`/${config?.owner}/${config?.repo}/${config?.branch}/settings`); + router.replace(`/${config.owner}/${config.repo}/${encodeURIComponent(config.branch)}/media`); + } else if (config && config?.object.settings !== false) { + router.replace(`/${config?.owner}/${config?.repo}/${encodeURIComponent(config.branch)}/settings`); } else { setError(true); } diff --git a/components/collection/collection-view.tsx b/components/collection/collection-view.tsx index 36fadf78..edb9e25f 100644 --- a/components/collection/collection-view.tsx +++ b/components/collection/collection-view.tsx @@ -153,7 +153,7 @@ export function CollectionView({ return ( {CellView} @@ -176,7 +176,7 @@ export function CollectionView({
Edit @@ -227,7 +227,7 @@ export function CollectionView({ setIsLoading(true); setError(null); try { - const response = await fetch(`/api/${config.owner}/${config.repo}/${config.branch}/collections/${encodeURIComponent(name)}?path=${encodeURIComponent(path || schema.path)}`); + const response = await fetch(`/api/${config.owner}/${config.repo}/${encodeURIComponent(config.branch)}/collections/${encodeURIComponent(name)}?path=${encodeURIComponent(path || schema.path)}`); if (!response.ok) throw new Error(`Failed to fetch collection: ${response.status} ${response.statusText}`); const data: any = await response.json(); @@ -325,7 +325,7 @@ export function CollectionView({ description={error} className="absolute inset-0" cta="Go to settings" - href={`/${config.owner}/${config.repo}/${config.branch}/settings`} + href={`/${config.owner}/${config.repo}/${encodeURIComponent(config.branch)}/settings`} /> } } @@ -351,13 +351,13 @@ export function CollectionView({ Add an entry diff --git a/components/empty-create.tsx b/components/empty-create.tsx index 3e9b5c0d..24c190a1 100644 --- a/components/empty-create.tsx +++ b/components/empty-create.tsx @@ -26,7 +26,7 @@ const EmptyCreate = ({ let path = ""; let content: string | Record = ""; let toCreate = ""; - let redirectTo = `/${config.owner}/${config.repo}/${config.branch}`; + let redirectTo = `/${config.owner}/${config.repo}/${encodeURIComponent(config.branch)}`; if (type === "settings") { path = ".pages.yml"; @@ -65,7 +65,7 @@ const EmptyCreate = ({ try { const createPromise = new Promise(async (resolve, reject) => { try { - const response = await fetch(`/api/${config.owner}/${config.repo}/${config.branch}/files/${encodeURIComponent(normalizePath(path))}`, { + const response = await fetch(`/api/${config.owner}/${config.repo}/${encodeURIComponent(config.branch)}/files/${encodeURIComponent(normalizePath(path))}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ diff --git a/components/entry/entry-editor.tsx b/components/entry/entry-editor.tsx index a34cd7a6..c44de120 100644 --- a/components/entry/entry-editor.tsx +++ b/components/entry/entry-editor.tsx @@ -107,7 +107,7 @@ export function EntryEditor({ setError(null); try { - const response = await fetch(`/api/${config.owner}/${config.repo}/${config.branch}/entries/${encodeURIComponent(path)}?name=${encodeURIComponent(name)}`); + const response = await fetch(`/api/${config.owner}/${config.repo}/${encodeURIComponent(config.branch)}/entries/${encodeURIComponent(path)}?name=${encodeURIComponent(name)}`); if (!response.ok) throw new Error(`Failed to fetch entry: ${response.status} ${response.statusText}`); const data: any = await response.json(); @@ -137,7 +137,7 @@ export function EntryEditor({ const fetchHistory = async () => { if (path) { try { - const response = await fetch(`/api/${config.owner}/${config.repo}/${config.branch}/entries/${encodeURIComponent(path)}/history?name=${encodeURIComponent(name)}`); + const response = await fetch(`/api/${config.owner}/${config.repo}/${encodeURIComponent(config.branch)}/entries/${encodeURIComponent(path)}/history?name=${encodeURIComponent(name)}`); if (!response.ok) throw new Error(`Failed to fetch entry's history: ${response.status} ${response.statusText}`); const data: any = await response.json(); @@ -159,7 +159,7 @@ export function EntryEditor({ try { const savePath = path ?? `${parent ?? schema.path}/${generateFilename(schema.filename, schema, contentObject)}`; - const response = await fetch(`/api/${config.owner}/${config.repo}/${config.branch}/files/${encodeURIComponent(savePath)}`, { + const response = await fetch(`/api/${config.owner}/${config.repo}/${encodeURIComponent(config.branch)}/files/${encodeURIComponent(savePath)}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -178,7 +178,7 @@ export function EntryEditor({ if (data.data.sha !== sha) setSha(data.data.sha); - if (!path && schema.type === "collection") router.push(`/${config.owner}/${config.repo}/${config.branch}/collection/${encodeURIComponent(name)}/edit/${encodeURIComponent(data.data.path)}`); + if (!path && schema.type === "collection") router.push(`/${config.owner}/${config.repo}/${encodeURIComponent(config.branch)}/collection/${encodeURIComponent(name)}/edit/${encodeURIComponent(data.data.path)}`); resolve(data); } catch (error) { @@ -205,7 +205,7 @@ export function EntryEditor({ const handleDelete = (path: string) => { // TODO: disable save button or freeze form while deleting? if (schema.type === "collection") { - router.push(`/${config.owner}/${config.repo}/${config.branch}/collection/${encodeURIComponent(name)}`); + router.push(`/${config.owner}/${config.repo}/${encodeURIComponent(config.branch)}/collection/${encodeURIComponent(name)}`); } else { setRefetchTrigger(refetchTrigger + 1); } @@ -213,7 +213,7 @@ export function EntryEditor({ const handleRename = (oldPath: string, newPath: string) => { setPath(newPath); - router.replace(`/${config.owner}/${config.repo}/${config.branch}/collection/${encodeURIComponent(name)}/edit/${encodeURIComponent(newPath)}`); + router.replace(`/${config.owner}/${config.repo}/${encodeURIComponent(config.branch)}/collection/${encodeURIComponent(name)}/edit/${encodeURIComponent(newPath)}`); }; const loadingSkeleton = useMemo(() => ( @@ -337,7 +337,7 @@ export function EntryEditor({ description={error} className="absolute inset-0" cta="Go to settings" - href={`/${config.owner}/${config.repo}/${config.branch}/settings`} + href={`/${config.owner}/${config.repo}/${encodeURIComponent(config.branch)}/settings`} /> ); } diff --git a/components/file-options.tsx b/components/file-options.tsx index c175b563..67961ddd 100644 --- a/components/file-options.tsx +++ b/components/file-options.tsx @@ -76,7 +76,7 @@ export function FileOptions({ }); if (name) params.set("name", name); - const response = await fetch(`/api/${config.owner}/${config.repo}/${config.branch}/files/${encodeURIComponent(normalizedPath)}?${params.toString()}`, { + const response = await fetch(`/api/${config.owner}/${config.repo}/${encodeURIComponent(config.branch)}/files/${encodeURIComponent(normalizedPath)}?${params.toString()}`, { method: "DELETE", }); @@ -112,7 +112,7 @@ export function FileOptions({ const renamePromise = new Promise(async (resolve, reject) => { try { - const response = await fetch(`/api/${config.owner}/${config.repo}/${config.branch}/files/${encodeURIComponent(normalizedPath)}/rename`, { + const response = await fetch(`/api/${config.owner}/${config.repo}/${encodeURIComponent(config.branch)}/files/${encodeURIComponent(normalizedPath)}/rename`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -155,7 +155,7 @@ export function FileOptions({ - + See on GitHub diff --git a/components/folder-create.tsx b/components/folder-create.tsx index 09aaf10d..47e658ca 100644 --- a/components/folder-create.tsx +++ b/components/folder-create.tsx @@ -44,7 +44,7 @@ const FolderCreate = ({ const createPromise = new Promise(async (resolve, reject) => { try { - const response = await fetch(`/api/${config.owner}/${config.repo}/${config.branch}/files/${encodeURIComponent(fullNewPath + "/.gitkeep")}`, { + const response = await fetch(`/api/${config.owner}/${config.repo}/${encodeURIComponent(config.branch)}/files/${encodeURIComponent(fullNewPath + "/.gitkeep")}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ diff --git a/components/media/media-upload.tsx b/components/media/media-upload.tsx index 1461bf9a..d2829d88 100644 --- a/components/media/media-upload.tsx +++ b/components/media/media-upload.tsx @@ -48,7 +48,7 @@ const MediaUpload = ({ const uploadPromise = new Promise(async (resolve, reject) => { try { - const response = await fetch(`/api/${config.owner}/${config.repo}/${config.branch}/files/${encodeURIComponent(fullPath)}`, { + const response = await fetch(`/api/${config.owner}/${config.repo}/${encodeURIComponent(config.branch)}/files/${encodeURIComponent(fullPath)}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ diff --git a/components/media/media-view.tsx b/components/media/media-view.tsx index c5b030da..a72f05f2 100644 --- a/components/media/media-view.tsx +++ b/components/media/media-view.tsx @@ -71,7 +71,7 @@ const MediaView = ({ setError(null); try { - const response = await fetch(`/api/${config.owner}/${config.repo}/${config.branch}/media/${encodeURIComponent(path)}`); + const response = await fetch(`/api/${config.owner}/${config.repo}/${encodeURIComponent(config.branch)}/media/${encodeURIComponent(path)}`); if (!response.ok) throw new Error(`Failed to fetch media: ${response.status} ${response.statusText}`); const data: any = await response.json(); @@ -201,7 +201,7 @@ const MediaView = ({ description="You have no media defined in your settings." className="absolute inset-0" cta="Go to settings" - href={`/${config.owner}/${config.repo}/${config.branch}/settings`} + href={`/${config.owner}/${config.repo}/${encodeURIComponent(config.branch)}/settings`} /> ); } diff --git a/components/repo/repo-branches.tsx b/components/repo/repo-branches.tsx index 6358c56e..584922d6 100644 --- a/components/repo/repo-branches.tsx +++ b/components/repo/repo-branches.tsx @@ -39,7 +39,7 @@ export function RepoBranches() { try { const newBranch = search; - const response = await fetch(`/api/${config.owner}/${config.repo}/${config.branch}/branches`, { + const response = await fetch(`/api/${config.owner}/${config.repo}/${encodeURIComponent(config.branch)}/branches`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ diff --git a/components/repo/repo-nav.tsx b/components/repo/repo-nav.tsx index 14d0f89c..6486ed84 100644 --- a/components/repo/repo-nav.tsx +++ b/components/repo/repo-nav.tsx @@ -52,7 +52,7 @@ const RepoNav = ({ ? : , - href: `/${config.owner}/${config.repo}/${config.branch}/${item.type}/${encodeURIComponent(item.name)}`, + href: `/${config.owner}/${config.repo}/${encodeURIComponent(config.branch)}/${item.type}/${encodeURIComponent(item.name)}`, label: item.label || item.name, })) || []; @@ -60,7 +60,7 @@ const RepoNav = ({ ? { key: "media", icon: , - href: `/${config.owner}/${config.repo}/${config.branch}/media`, + href: `/${config.owner}/${config.repo}/${encodeURIComponent(config.branch)}/media`, label: "Media" } : null; @@ -69,7 +69,7 @@ const RepoNav = ({ ? { key: "settings", icon: , - href: `/${config.owner}/${config.repo}/${config.branch}/settings`, + href: `/${config.owner}/${config.repo}/${encodeURIComponent(config.branch)}/settings`, label: "Settings" } : null; @@ -78,7 +78,7 @@ const RepoNav = ({ ? { key: "collaborators", icon: , - href: `/${config.owner}/${config.repo}/${config.branch}/collaborators`, + href: `/${config.owner}/${config.repo}/${encodeURIComponent(config.branch)}/collaborators`, label: "Collaborators" } : null; From 3b735b020190aa7b7e665c9fff4887b7b68d3132 Mon Sep 17 00:00:00 2001 From: Ronan Berder Date: Mon, 10 Feb 2025 21:08:37 +0800 Subject: [PATCH 05/95] Fixing bug with githubImage service --- lib/githubImage.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/githubImage.ts b/lib/githubImage.ts index 03b1e9e5..e7d8c578 100644 --- a/lib/githubImage.ts +++ b/lib/githubImage.ts @@ -18,7 +18,7 @@ const getRelativeUrl = ( let relativePath = path; if (path.startsWith("https://raw.githubusercontent.com/")) { - const pattern = new RegExp(`^https://raw\\.githubusercontent\\.com/${owner}/${repo}/${branch}/`, "i"); + const pattern = new RegExp(`^https://raw\\.githubusercontent\\.com/${owner}/${repo}/${encodeURIComponent(branch)}/`, "i"); relativePath = path.replace(pattern, ""); relativePath = relativePath.split("?")[0]; } @@ -41,13 +41,13 @@ const getRawUrl = async ( if (!filename) return null; const parentPath = getParentPath(decodedPath); - const parentFullPath = `${owner}/${repo}/${branch}/${parentPath}`; + const parentFullPath = `${owner}/${repo}/${encodeURIComponent(branch)}/${parentPath}`; if (!cache[parentFullPath]?.files?.[filename] || (Date.now() - (cache[parentFullPath]?.time || 0) > ttl)) { delete cache[parentFullPath]; if (!requests[parentFullPath]) { - requests[parentFullPath] = fetch(`/api/${owner}/${repo}/${branch}/media/${encodeURIComponent(parentPath)}`) + requests[parentFullPath] = fetch(`/api/${owner}/${repo}/${encodeURIComponent(branch)}/media/${encodeURIComponent(parentPath)}`) .then(response => { if (!response.ok) throw new Error(`Failed to fetch media: ${response.status} ${response.statusText}`); @@ -74,7 +74,7 @@ const getRawUrl = async ( return cache[parentFullPath]?.files?.[filename]; } else { - return `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${encodeURI(decodedPath)}`; + return `https://raw.githubusercontent.com/${owner}/${repo}/${encodeURIComponent(branch)}/${encodeURI(decodedPath)}`; } }; @@ -91,7 +91,7 @@ const rawToRelativeUrls = ( const quote = match[1] ? "\"" : "'"; if (src.startsWith("https://raw.githubusercontent.com/")) { - let relativePath = src.replace(new RegExp(`https://raw\\.githubusercontent\\.com/${owner}/${repo}/${branch}/`, "gi"), ""); + let relativePath = src.replace(new RegExp(`https://raw\\.githubusercontent\\.com/${owner}/${repo}/${encodeURIComponent(branch)}/`, "gi"), ""); relativePath = relativePath.split("?")[0]; if (!encode) relativePath = decodeURIComponent(relativePath); From 814e7603f97bd240d287ec3101b8c50dc34b284c Mon Sep 17 00:00:00 2001 From: Ronan Berder Date: Mon, 10 Feb 2025 21:45:04 +0800 Subject: [PATCH 06/95] Fixing more branch URLs --- app/(main)/[owner]/[repo]/[branch]/page.tsx | 2 +- components/entry/entry-editor.tsx | 2 +- components/entry/entry-history.tsx | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/(main)/[owner]/[repo]/[branch]/page.tsx b/app/(main)/[owner]/[repo]/[branch]/page.tsx index 7b71685b..0a531668 100644 --- a/app/(main)/[owner]/[repo]/[branch]/page.tsx +++ b/app/(main)/[owner]/[repo]/[branch]/page.tsx @@ -28,7 +28,7 @@ export default function Page() { description={<>This branch and/or repository has no configuration and settings are disabled. Edit on GitHub if you think this is a mistake.} className="absolute inset-0" cta="Edit configuration on GitHub" - href={`https://github.com/${config?.owner}/${config?.repo}/edit/${config?.branch}/.pages.yml`} + href={`https://github.com/${config?.owner}/${config?.repo}/edit/${encodeURIComponent(config?.branch)}/.pages.yml`} /> : null; } \ No newline at end of file diff --git a/components/entry/entry-editor.tsx b/components/entry/entry-editor.tsx index c44de120..a4a1132d 100644 --- a/components/entry/entry-editor.tsx +++ b/components/entry/entry-editor.tsx @@ -95,7 +95,7 @@ export function EntryEditor({ const navigateBack = useMemo(() => { const parentPath = path ? getParentPath(path) : undefined; return schema && schema.type === "collection" - ? `/${config?.owner}/${config?.repo}/${config?.branch}/collection/${schema.name}${parentPath && parentPath !== schema.path ? `?path=${encodeURIComponent(parentPath)}` : ""}` + ? `/${config?.owner}/${config?.repo}/${encodeURIComponent(config?.branch)}/collection/${schema.name}${parentPath && parentPath !== schema.path ? `?path=${encodeURIComponent(parentPath)}` : ""}` : ""}, [schema, config?.owner, config?.repo, config?.branch, path] ); diff --git a/components/entry/entry-history.tsx b/components/entry/entry-history.tsx index 88860140..0f76cfc3 100644 --- a/components/entry/entry-history.tsx +++ b/components/entry/entry-history.tsx @@ -50,9 +50,9 @@ export function EntryHistoryBlock({
))} - {history.length > 3 && ( + {config && history.length > 3 && ( @@ -99,12 +99,12 @@ export function EntryHistoryDropdown({ ))} - {history.length > 3 && ( + {config && history.length > 3 && ( <> From 56c5a83fdd281d481d33498d2b94455742178985 Mon Sep 17 00:00:00 2001 From: Ronan Berder Date: Mon, 10 Feb 2025 21:51:29 +0800 Subject: [PATCH 07/95] Fixing more branch URLs --- app/(main)/[owner]/[repo]/[branch]/page.tsx | 6 +++--- components/entry/entry-editor.tsx | 4 ++-- components/entry/entry-history.tsx | 8 ++++---- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/app/(main)/[owner]/[repo]/[branch]/page.tsx b/app/(main)/[owner]/[repo]/[branch]/page.tsx index 0a531668..7f8bc3ec 100644 --- a/app/(main)/[owner]/[repo]/[branch]/page.tsx +++ b/app/(main)/[owner]/[repo]/[branch]/page.tsx @@ -15,8 +15,8 @@ export default function Page() { router.replace(`/${config.owner}/${config.repo}/${encodeURIComponent(config.branch)}/${config.object.content[0].type}/${config.object.content[0].name}`); } else if (config?.object.media) { router.replace(`/${config.owner}/${config.repo}/${encodeURIComponent(config.branch)}/media`); - } else if (config && config?.object.settings !== false) { - router.replace(`/${config?.owner}/${config?.repo}/${encodeURIComponent(config.branch)}/settings`); + } else if (config?.object.settings !== false) { + router.replace(`/${config?.owner}/${config?.repo}/${encodeURIComponent(config!.branch)}/settings`); } else { setError(true); } @@ -28,7 +28,7 @@ export default function Page() { description={<>This branch and/or repository has no configuration and settings are disabled. Edit on GitHub if you think this is a mistake.} className="absolute inset-0" cta="Edit configuration on GitHub" - href={`https://github.com/${config?.owner}/${config?.repo}/edit/${encodeURIComponent(config?.branch)}/.pages.yml`} + href={`https://github.com/${config?.owner}/${config?.repo}/edit/${encodeURIComponent(config!.branch)}/.pages.yml`} /> : null; } \ No newline at end of file diff --git a/components/entry/entry-editor.tsx b/components/entry/entry-editor.tsx index a4a1132d..c9016700 100644 --- a/components/entry/entry-editor.tsx +++ b/components/entry/entry-editor.tsx @@ -95,9 +95,9 @@ export function EntryEditor({ const navigateBack = useMemo(() => { const parentPath = path ? getParentPath(path) : undefined; return schema && schema.type === "collection" - ? `/${config?.owner}/${config?.repo}/${encodeURIComponent(config?.branch)}/collection/${schema.name}${parentPath && parentPath !== schema.path ? `?path=${encodeURIComponent(parentPath)}` : ""}` + ? `/${config.owner}/${config.repo}/${encodeURIComponent(config.branch)}/collection/${schema.name}${parentPath && parentPath !== schema.path ? `?path=${encodeURIComponent(parentPath)}` : ""}` : ""}, - [schema, config?.owner, config?.repo, config?.branch, path] + [schema, config.owner, config.repo, config.branch, path] ); useEffect(() => { diff --git a/components/entry/entry-history.tsx b/components/entry/entry-history.tsx index 0f76cfc3..041cc24c 100644 --- a/components/entry/entry-history.tsx +++ b/components/entry/entry-history.tsx @@ -50,9 +50,9 @@ export function EntryHistoryBlock({ ))} - {config && history.length > 3 && ( + {history.length > 3 && ( @@ -99,12 +99,12 @@ export function EntryHistoryDropdown({ ))} - {config && history.length > 3 && ( + {history.length > 3 && ( <> From ee4e3242e9b4afa9d961ef227957d4308336ab0c Mon Sep 17 00:00:00 2001 From: Ronan Berder Date: Wed, 12 Feb 2025 09:35:43 +0800 Subject: [PATCH 08/95] Staring work on reference and autocomplete --- fields/core/image/edit-component.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fields/core/image/edit-component.tsx b/fields/core/image/edit-component.tsx index 5573bfbd..4da17d2f 100644 --- a/fields/core/image/edit-component.tsx +++ b/fields/core/image/edit-component.tsx @@ -1,6 +1,6 @@ "use client"; -import { forwardRef, useCallback, useMemo, useRef, useState } from "react"; +import { forwardRef, useCallback, useMemo, useState } from "react"; import { getParentPath } from "@/lib/utils/file"; import { MediaDialog } from "@/components/media/media-dialog"; import { Thumbnail } from "@/components/thumbnail"; From ab6646a503c3630ade44e1fdcaed82bbdb06f233 Mon Sep 17 00:00:00 2001 From: Ryan Gittings Date: Fri, 21 Feb 2025 16:18:58 +0000 Subject: [PATCH 09/95] Fix collection view --- components/collection/collection-view.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/collection/collection-view.tsx b/components/collection/collection-view.tsx index edb9e25f..0e9b73a4 100644 --- a/components/collection/collection-view.tsx +++ b/components/collection/collection-view.tsx @@ -66,7 +66,7 @@ export function CollectionView({ }); } else { pathAndFieldArray = schema.fields - .filter((field: any) => field?.type !== 'object') + .filter((field: any) => field?.type !== 'object' && !field.hidden) .map((field: any) => ({ path: field.name, field: field })); } } else { From accc740285f3b426b3536b883076f41a3f783edf Mon Sep 17 00:00:00 2001 From: Ryan Gittings Date: Fri, 21 Feb 2025 16:49:51 +0000 Subject: [PATCH 10/95] Improve repo permissions --- app/api/repos/[owner]/route.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/api/repos/[owner]/route.ts b/app/api/repos/[owner]/route.ts index e546af3a..34317c36 100644 --- a/app/api/repos/[owner]/route.ts +++ b/app/api/repos/[owner]/route.ts @@ -49,8 +49,8 @@ export async function GET( }); repos = response.data.items; } - - repos = repos.map(repo => ({ + + repos = repos.filter(repo => repo.permissions.push).map(repo => ({ owner: repo.owner.login, repo: repo.name, private: repo.private, From 77a607fdc8eb508aa5b500f06d139b75e321ec6d Mon Sep 17 00:00:00 2001 From: Andrew Monty Date: Sat, 22 Feb 2025 12:14:00 -0500 Subject: [PATCH 11/95] added custom field to auto-generate a uuid --- fields/custom/uuid/edit-component.tsx | 10 ++++++++++ fields/custom/uuid/index.tsx | 15 +++++++++++++++ lib/configSchema.ts | 2 +- lib/schema.ts | 5 ++++- types/field.ts | 2 +- 5 files changed, 31 insertions(+), 3 deletions(-) create mode 100644 fields/custom/uuid/edit-component.tsx create mode 100644 fields/custom/uuid/index.tsx diff --git a/fields/custom/uuid/edit-component.tsx b/fields/custom/uuid/edit-component.tsx new file mode 100644 index 00000000..61c22053 --- /dev/null +++ b/fields/custom/uuid/edit-component.tsx @@ -0,0 +1,10 @@ +"use client"; + +import { forwardRef } from "react"; +import { Input } from "@/components/ui/input"; + +const EditComponent = forwardRef((props: any, ref: React.Ref) => { + return ; +}); + +export { EditComponent }; diff --git a/fields/custom/uuid/index.tsx b/fields/custom/uuid/index.tsx new file mode 100644 index 00000000..4f8e6e29 --- /dev/null +++ b/fields/custom/uuid/index.tsx @@ -0,0 +1,15 @@ +import { z } from "zod"; +import { Field } from "@/types/field"; +import { EditComponent } from "./edit-component"; + +const defaultValue = (): string => { + return crypto.randomUUID(); +}; + +const schema = (_: Field) => { + let zodSchema = z.coerce.string(); + + return zodSchema; +}; + +export { EditComponent, defaultValue, schema }; diff --git a/lib/configSchema.ts b/lib/configSchema.ts index 9b30c3da..88973ebf 100644 --- a/lib/configSchema.ts +++ b/lib/configSchema.ts @@ -49,7 +49,7 @@ const FieldObjectSchema: z.ZodType = z.lazy(() => z.object({ description: z.string().optional().nullable(), type: z.enum([ "boolean", "code", "date", "image", "number", "object", "rich-text", - "select", "string", "text" + "select", "string", "text", "uuid" ], { message: "'type' is required and must be set to a valid field type (see documentation)." }), diff --git a/lib/schema.ts b/lib/schema.ts index 45ad7284..34a168ad 100644 --- a/lib/schema.ts +++ b/lib/schema.ts @@ -69,7 +69,10 @@ const getDefaultValue = (field: Record) => { } else if (field.type === "object") { return initializeState(field.fields, {}); } else { - return defaultValues?.[field.type] || ""; + const defaultValue = defaultValues?.[field.type]; + return defaultValue instanceof Function + ? defaultValue() + : defaultValue || ""; } }; diff --git a/types/field.ts b/types/field.ts index c7f4ba7d..b5736685 100644 --- a/types/field.ts +++ b/types/field.ts @@ -2,7 +2,7 @@ export type Field = { name: string; label?: string | false; description?: string | null; - type: "boolean" | "code" | "date" | "image" | "number" | "object" | "rich-text" | "select" | "string" | "text" | string; + type: "boolean" | "code" | "date" | "image" | "number" | "object" | "rich-text" | "select" | "string" | "text" | "uuid" | string; default?: any; list?: boolean | { min?: number; max?: number }; hidden?: boolean | null; From fe53b24b40687b94632f11a8c7f90700f9317bd5 Mon Sep 17 00:00:00 2001 From: Andrew Monty Date: Sat, 22 Feb 2025 12:42:49 -0500 Subject: [PATCH 12/95] move field to core --- fields/{custom => core}/uuid/edit-component.tsx | 0 fields/{custom => core}/uuid/index.tsx | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename fields/{custom => core}/uuid/edit-component.tsx (100%) rename fields/{custom => core}/uuid/index.tsx (100%) diff --git a/fields/custom/uuid/edit-component.tsx b/fields/core/uuid/edit-component.tsx similarity index 100% rename from fields/custom/uuid/edit-component.tsx rename to fields/core/uuid/edit-component.tsx diff --git a/fields/custom/uuid/index.tsx b/fields/core/uuid/index.tsx similarity index 100% rename from fields/custom/uuid/index.tsx rename to fields/core/uuid/index.tsx From b0d5a02e1d17c9282f9b09246b64e3d330e121a5 Mon Sep 17 00:00:00 2001 From: Ryan Gittings Date: Tue, 4 Mar 2025 10:56:47 +0000 Subject: [PATCH 13/95] Fixes --- app/api/[owner]/[repo]/[branch]/collections/[name]/route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/api/[owner]/[repo]/[branch]/collections/[name]/route.ts b/app/api/[owner]/[repo]/[branch]/collections/[name]/route.ts index 8985b28b..2b8abfe0 100644 --- a/app/api/[owner]/[repo]/[branch]/collections/[name]/route.ts +++ b/app/api/[owner]/[repo]/[branch]/collections/[name]/route.ts @@ -139,7 +139,7 @@ const parseContents = ( object: contentObject, type: "file", }; - } else if (item.type === "tree") { + } else if (item.type === "tree" && !excludedFiles.includes(item.name)) { return { name: item.name, path: item.path, From 45996efb7172ec9d547fbe57af35601ff17954b6 Mon Sep 17 00:00:00 2001 From: Ryan Gittings Date: Tue, 4 Mar 2025 11:53:07 +0000 Subject: [PATCH 14/95] Fix list defaults --- components/entry/entry-form.tsx | 61 +++++++++++++----------- fields/core/rich-text/edit-component.tsx | 34 ++++++------- 2 files changed, 50 insertions(+), 45 deletions(-) diff --git a/components/entry/entry-form.tsx b/components/entry/entry-form.tsx index 38c8dc00..0ded3e2d 100644 --- a/components/entry/entry-form.tsx +++ b/components/entry/entry-form.tsx @@ -36,7 +36,7 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { - DndContext, + DndContext, closestCenter, KeyboardSensor, PointerSensor, @@ -81,9 +81,9 @@ const SortableItem = ({ }; return ( -
+
{children}
@@ -114,8 +114,13 @@ const ListField = ({ const hasAppended = useRef(false); useEffect(() => { - if (arrayFields.length === 0 && !hasAppended.current) { - append(getDefaultValue(field)); + const defaultValue = getDefaultValue(field); + if (typeof defaultValue === "object" && Object.values(defaultValue).filter(n => n).length === 0) { + return; + } + + if (arrayFields.length === 0 && !hasAppended.current && defaultValue) { + append(defaultValue); hasAppended.current = true; } }, [arrayFields, append, field]); @@ -140,7 +145,7 @@ const ListField = ({ setValue(fieldName, updatedValues); } }; - + return ( = field.list.max ? null : + type="button" + variant="outline" + size="sm" + onClick={() => { + append(field.type === "object" + ? initializeState(field.fields, {}, true) + : getDefaultValue(field) + ); + }} + className="gap-x-2" + > + + Add an entry + }
- + )} /> @@ -230,7 +235,7 @@ const renderSingleField = ( {field.description && {field.description}} - + )} /> @@ -279,7 +284,7 @@ const EntryForm = ({ const renderFields = (fields: Field[], parentName?: string) => { return fields.map((field) => { if (field.hidden) return null; - + const fieldName = parentName ? `${parentName}.${field.name}` : field.name; if (field.type === "object" && field.list && !supportsList[field.type]) { @@ -324,17 +329,17 @@ const EntryForm = ({ className={cn(buttonVariants({ variant: "outline", size: "icon-xs" }), "mr-4 shrink-0")} href={navigateBack} > - + } - +

{title}

{renderFields(fields)}
- +
@@ -344,11 +349,11 @@ const EntryForm = ({ {options ? options : null}
- {path && history && } + {path && history && }
- {path && history && } + {path && history && } - + editor.chain().focus().setParagraph().run()} className="gap-x-1.5"> Text @@ -232,7 +232,7 @@ const EditComponent = forwardRef((props: any, ref) => { onClick={() => linkUrl ? editor.chain().focus().extendMarkRange('link').setLink({ href: linkUrl }).run() : editor.chain().focus().extendMarkRange('link').unsetLink() - .run() + .run() } >Link
- {(editor.isActive("paragraph") || editor.isActive("heading")) && + {(editor.isActive("paragraph") || editor.isActive("heading")) && - + editor.chain().focus().setTextAlign("left").run()} className="gap-x-1.5"> Align left @@ -335,7 +335,7 @@ const EditComponent = forwardRef((props: any, ref) => { > - {editor.isActive("table") && + {editor.isActive("table") && - + editor.chain().focus().addColumnAfter().run()}>Add a column editor.chain().focus().addRowAfter().run()}>Add a row @@ -364,7 +364,7 @@ const EditComponent = forwardRef((props: any, ref) => { } - + ) From 2cbdde8e16f2689a68aa51b3b3eab4d3ba862441 Mon Sep 17 00:00:00 2001 From: Ryan Gittings Date: Wed, 5 Mar 2025 16:15:57 +0000 Subject: [PATCH 15/95] Fix --- fields/core/rich-text/index.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/fields/core/rich-text/index.tsx b/fields/core/rich-text/index.tsx index d34af82f..f1d44d2f 100644 --- a/fields/core/rich-text/index.tsx +++ b/fields/core/rich-text/index.tsx @@ -15,15 +15,15 @@ const read = (value: any, field: Field, config: Record) => { const prefixInput = field.options?.input ?? config.object.media?.input; const prefixOutput = field.options?.output ?? config.object.media?.output; - + return htmlSwapPrefix(html, prefixOutput, prefixInput, true); }; const write = (value: any, field: Field, config: Record) => { - let content = value; - + let content = value || ''; + content = rawToRelativeUrls(config.owner, config.repo, config.branch, content); - + const prefixInput = field.options?.input ?? config.object.media?.input; const prefixOutput = field.options?.output ?? config.object.media?.output; @@ -36,7 +36,7 @@ const write = (value: any, field: Field, config: Record) => { }); turndownService.use([tables, strikethrough]); turndownService.addRule("retain-html", { - filter: (node: any, options: any) => ( + filter: (node: any, options: any) => ( ( node.nodeName === "IMG" && (node.getAttribute("width") || node.getAttribute("height")) ) || @@ -50,10 +50,10 @@ const write = (value: any, field: Field, config: Record) => { // We need to strip and tags otherwise turndown won't convert tables content = content.replace(/.*?<\/colgroup>/g, ''); - content = turndownService.turndown(content); + content = turndownService.turndown(content); } return content; }; -export { EditComponent, ViewComponent, read, write}; \ No newline at end of file +export { EditComponent, ViewComponent, read, write }; \ No newline at end of file From 3dac3a856b380d0d65b2a3ee4067caf15db61843 Mon Sep 17 00:00:00 2001 From: Ronan Berder Date: Sat, 8 Mar 2025 10:14:33 +0800 Subject: [PATCH 16/95] New select field (WIP) --- components/entry/entry-form.tsx | 4 +- fields/core/autocomplete/edit-component.css | 76 ++++ fields/core/autocomplete/edit-component.tsx | 201 +++++++++ fields/core/autocomplete/index.tsx | 26 ++ lib/schema.ts | 31 +- package-lock.json | 472 +++++++++++++++++++- package.json | 1 + types/field.ts | 2 +- 8 files changed, 782 insertions(+), 31 deletions(-) create mode 100644 fields/core/autocomplete/edit-component.css create mode 100644 fields/core/autocomplete/edit-component.tsx create mode 100644 fields/core/autocomplete/index.tsx diff --git a/components/entry/entry-form.tsx b/components/entry/entry-form.tsx index 38c8dc00..9460c85e 100644 --- a/components/entry/entry-form.tsx +++ b/components/entry/entry-form.tsx @@ -187,7 +187,7 @@ const ListField = ({ size="sm" onClick={() => { append(field.type === "object" - ? initializeState(field.fields, {}, true) + ? initializeState(field.fields, {}) : getDefaultValue(field) ); }} @@ -263,7 +263,7 @@ const EntryForm = ({ }, [fields]); const defaultValues = useMemo(() => { - return initializeState(fields, sanitizeObject(contentObject), true); + return initializeState(fields, sanitizeObject(contentObject)); }, [fields, contentObject]); const form = useForm({ diff --git a/fields/core/autocomplete/edit-component.css b/fields/core/autocomplete/edit-component.css new file mode 100644 index 00000000..f74dc577 --- /dev/null +++ b/fields/core/autocomplete/edit-component.css @@ -0,0 +1,76 @@ +.react-select__control { + @apply !min-h-10 !rounded-md !border-input !bg-background !ring-offset-background !transition-none !cursor-pointer !box-border !px-3 !py-2; +} + +.react-select__control--is-focused { + @apply !outline-none !ring-2 !ring-ring !ring-offset-2; +} + +.react-select__placeholder { + @apply !text-muted-foreground !mx-0; +} + +.react-select__value-container { + @apply !p-0 !m-0 !leading-none !text-foreground; +} + +.react-select__value-container--is-multi { + @apply flex gap-1.5 !-my-[1px]; +} + +.react-select__single-value { + @apply !mx-0 !text-foreground; +} + +.react-select__input-container { + @apply !m-0 !p-0 !text-foreground; +} + +.react-select__indicator-separator { + @apply !hidden; +} + +.react-select__clear-indicator, +.react-select__dropdown-indicator { + @apply !text-foreground opacity-50 !p-0 !transition-opacity; +} + +.react-select__clear-indicator { + @apply hover:!opacity-100 mr-1; +} + +.react-select__multi-value { + @apply !rounded-full !border !bg-accent !m-0 !inline-flex !items-center !p-0 !font-medium !text-inherit; +} + +.react-select__multi-value__label { + @apply !py-1 !pl-2 !pr-0 !text-inherit; +} + +.react-select__multi-value__remove { + @apply !py-1 !px-1 !text-foreground !opacity-50 hover:!opacity-100 !bg-transparent !transition-opacity; +} + +.react-select__menu { + @apply !rounded-md !border !border-input !shadow-md !bg-popover !text-popover-foreground !max-h-96 !min-w-[8rem]; +} + +.react-select__menu-list { + @apply !p-1; +} + +.react-select__option { + @apply !rounded-sm !py-1.5 !pl-8 !pr-2 !text-sm hover:!bg-accent; +} + +.react-select__menu-list--is-multi .react-select__option { + @apply !pl-2; +} + +.react-select__option--is-focused { + @apply !bg-accent; +} + +.react-select__option--is-selected { + @apply !bg-transparent !text-foreground bg-no-repeat bg-[url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9IiMwYTBhMGEiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBjbGFzcz0ibHVjaWRlIGx1Y2lkZS1jaGVjayI+PHBhdGggZD0iTTIwIDYgOSAxN2wtNS01Ii8+PC9zdmc+")] dark:bg-[url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9IiNmZmZmZmYiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBjbGFzcz0ibHVjaWRlIGx1Y2lkZS1jaGVjayI+PHBhdGggZD0iTTIwIDYgOSAxN2wtNS01Ii8+PC9zdmc+")] bg-[left_0.5rem_center] bg-[length:16px_16px]; +} \ No newline at end of file diff --git a/fields/core/autocomplete/edit-component.tsx b/fields/core/autocomplete/edit-component.tsx new file mode 100644 index 00000000..0a59996a --- /dev/null +++ b/fields/core/autocomplete/edit-component.tsx @@ -0,0 +1,201 @@ +"use client"; + +import { forwardRef, useMemo, useState, useCallback, useEffect } from "react"; +import "./edit-component.css"; +import Select, { components } from "react-select" +import CreatableSelect from 'react-select/creatable'; +import AsyncSelect from 'react-select/async'; +import AsyncCreatableSelect from 'react-select/async-creatable'; +import { ChevronDown, X } from "lucide-react"; + +const DropdownIndicator = (props: any) => { + return ( + + + + ); +}; + +const ClearIndicator = (props: any) => { + return ( + + + + ); +}; + +const MultiValueRemove = (props: any) => { + return ( + + + + ); +}; + +const EditComponent = forwardRef((props: any, ref: any) => { + const { value, field, onChange } = props; + const [isMounted, setIsMounted] = useState(false); + const [inputValue, setInputValue] = useState(''); + + useEffect(() => { + setIsMounted(true); + }, []); + + // Only create static options if not using fetch + const options = useMemo(() => + !field.options?.fetch && field.options?.values + ? field.options.values.map((option: any) => { + if (typeof option === "object") { + return { value: option.value, label: option.label }; + } + return { value: option, label: option }; + }) + : [], + [field.options?.values, field.options?.fetch] + ); + + // Handle fetching options + const loadOptions = useCallback( + async (inputValue: string) => { + if (!field.options?.fetch?.url) return []; + + try { + // Process URL - either replace template or add query parameter + let urlString = field.options.fetch.url; + + // Check if URL has a template pattern like {input} + if (urlString.includes('{input}')) { + // Replace the template with the encoded input value + urlString = urlString.replace('{input}', encodeURIComponent(inputValue)); + } else { + // Add query parameter to URL + const url = new URL(urlString); + if (inputValue) { + url.searchParams.append(field.options.fetch.queryParam || 'q', inputValue); + } + urlString = url.toString(); + } + + const response = await fetch(urlString, { + method: field.options.fetch.method || 'GET', + headers: field.options.fetch.headers || {}, + }); + + if (!response.ok) throw new Error('Failed to fetch options'); + + const data = await response.json(); + + // Extract options from response using the provided path + let results = data; + if (field.options.fetch.resultsPath) { + const path = field.options.fetch.resultsPath.split('.'); + for (const segment of path) { + results = results[segment]; + } + } + + // Map results to option format + return results.map((item: any) => { + const valuePath = field.options.fetch.valuePath || 'id'; + const labelPath = field.options.fetch.labelPath || 'name'; + + const getValue = (obj: any, path: string) => { + const parts = path.split('.'); + return parts.reduce((o, key) => (o && o[key] !== undefined) ? o[key] : null, obj); + }; + + return { + value: getValue(item, valuePath), + label: getValue(item, labelPath), + }; + }); + } catch (error) { + console.error('Error loading options:', error); + return []; + } + }, + [field.options?.fetch] + ); + + // Internal state to manage the react-select format + const [selectedOptions, setSelectedOptions] = useState(() => { + if (field.list) { + if (!value) return []; + + // Handle nested array structure + const valueToUse = Array.isArray(value[0]) ? value[0] : value; + + // Map each value to an option object + return valueToUse.map((val: any) => { + const option = options.find((opt: any) => opt.value === val); + return option || { value: val, label: val }; + }); + } else { + if (!value) return null; + const option = options.find((opt: any) => opt.value === value); + return option || { value, label: value }; + } + }); + + const handleChange = useCallback((newValue: any) => { + setSelectedOptions(newValue); + if (field.list) { + const values = newValue ? newValue.map((item: any) => item.value) : []; + onChange(values); + } else { + onChange(newValue ? newValue.value : null); + } + }, [onChange, field.list]); + + const handleInputChange = useCallback((newValue: string) => { + setInputValue(newValue); + }, []); + + if (!isMounted) return null; + + // Determine which Select component to use based on options + let SelectComponent; + if (field.options?.fetch) { + SelectComponent = field.options?.creatable ? AsyncCreatableSelect : AsyncSelect; + } else { + SelectComponent = field.options?.creatable ? CreatableSelect : Select; + } + + // Common props for all select variants + const selectProps = { + ref, + isMulti: field.list, + classNamePrefix: "react-select", + placeholder: field.options?.placeholder || "Select...", + components: { + DropdownIndicator, + ClearIndicator, + MultiValueRemove + }, + value: selectedOptions, + onChange: handleChange, + onInputChange: handleInputChange, + inputValue: inputValue, + }; + + // Add specific props based on component type + if (field.options?.fetch) { + return ( + + ); + } else { + return ( + + ); + } +}); + +export { EditComponent }; \ No newline at end of file diff --git a/fields/core/autocomplete/index.tsx b/fields/core/autocomplete/index.tsx new file mode 100644 index 00000000..0cdd277b --- /dev/null +++ b/fields/core/autocomplete/index.tsx @@ -0,0 +1,26 @@ +import { z } from "zod"; +import { Field } from "@/types/field"; +import { EditComponent } from "./edit-component"; + +const schema = (field: Field) => { + let zodSchema; + + if (!field.options?.creatable && field.options?.values && Array.isArray(field.options.values)) { + const normalizedValues = field.options.values.map((item) => { + return typeof item === "object" + ? item.value + : item; + }); + zodSchema = z.enum(normalizedValues as [string, ...string[]]); + } else { + zodSchema = z.string(); + } + + if (!field.required) zodSchema = zodSchema.optional(); + + return zodSchema; +}; + +const supportsList = true; + +export { schema, EditComponent, supportsList }; \ No newline at end of file diff --git a/lib/schema.ts b/lib/schema.ts index 45ad7284..50902352 100644 --- a/lib/schema.ts +++ b/lib/schema.ts @@ -21,13 +21,17 @@ const deepMap = ( // TOOD: do we want to check for undefined or null? if (field.list) { - result[field.name] = Array.isArray(value) - ? value.map(item => - field.type === "object" - ? traverse(item, field.fields || []) - : apply(item, field) - ) - : []; + if (value === undefined) { + result[field.name] = apply(value, field); + } else { + result[field.name] = Array.isArray(value) + ? value.map(item => + field.type === "object" + ? traverse(item, field.fields || []) + : apply(item, field) + ) + : []; + } } else if (field.type === "object") { result[field.name] = value !== undefined ? traverse(value, field.fields || []) @@ -46,18 +50,19 @@ const deepMap = ( // Create an initial state for an entry based on the schema fields and content const initializeState = ( fields: Field[] | undefined, - contentObject: Record = {}, - addDefaultEntryToLists: boolean = true, - nestArrays: boolean = false + contentObject: Record = {} ): Record => { if (!fields) return {}; - + return deepMap(contentObject, fields, (value, field) => { let appliedValue = value; if (value === undefined) { - appliedValue = field.list && addDefaultEntryToLists ? [getDefaultValue(field)] : getDefaultValue(field); + appliedValue = field.list + ? (typeof field.list === "object" && field.list.default) + ? field.list.default + : [getDefaultValue(field)] + : getDefaultValue(field); } - if (field.list && nestArrays) appliedValue = { value: appliedValue} return appliedValue; }); }; diff --git a/package-lock.json b/package-lock.json index 05132ec2..fcc553e6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -72,6 +72,7 @@ "react-day-picker": "^8.10.1", "react-dom": "^18.3.1", "react-hook-form": "^7.51.5", + "react-select": "^5.10.0", "resend": "^3.5.0", "slugify": "^1.6.6", "sonner": "^1.4.41", @@ -108,6 +109,82 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@babel/code-frame": { + "version": "7.26.2", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", + "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.25.9", + "js-tokens": "^4.0.0", + "picocolors": "^1.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/generator": { + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.9.tgz", + "integrity": "sha512-kEWdzjOAUMW4hAyrzJ0ZaTOu9OmpyDIQicIh0zg0EEcEkYXZb2TjtBhnHi2ViX7PKwZqF4xwqfAm299/QMP3lg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.26.9", + "@babel/types": "^7.26.9", + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.25.9.tgz", + "integrity": "sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", + "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", + "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.9.tgz", + "integrity": "sha512-81NWa1njQblgZbQHxWHpxxCzNsa3ZwvFqpUg7P+NNUU6f3UU2jBEg4OlF/J6rl8+PQGh1q6/zWScd001YwcA5A==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.26.9" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@babel/runtime": { "version": "7.25.0", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.25.0.tgz", @@ -119,6 +196,60 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/template": { + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.26.9.tgz", + "integrity": "sha512-qyRplbeIpNZhmzOysF/wFMuP9sctmh2cFzRAZOn1YapxBsE1i9bJIY586R/WBLfLcmcBlM8ROBiQURnnNy+zfA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "@babel/parser": "^7.26.9", + "@babel/types": "^7.26.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.9.tgz", + "integrity": "sha512-ZYW7L+pL8ahU5fXmNbPF+iZFHCv5scFak7MZ9bwaRPLUhHh7QQEMjZUg0HevihoqCM5iSYHN61EyCoZvqC+bxg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "@babel/generator": "^7.26.9", + "@babel/parser": "^7.26.9", + "@babel/template": "^7.26.9", + "@babel/types": "^7.26.9", + "debug": "^4.3.1", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse/node_modules/globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/types": { + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.9.tgz", + "integrity": "sha512-Y3IR1cRnOxOCDvMmNiym7XpXQ93iGDDPHx+Zj+NM+rg0fBaShfQLkg+hKPaZCEvg5N/LeCo4+Rj/i3FuJsIQaw==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@codemirror/autocomplete": { "version": "6.18.0", "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.18.0.tgz", @@ -568,6 +699,129 @@ "tslib": "^2.4.0" } }, + "node_modules/@emotion/babel-plugin": { + "version": "11.13.5", + "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", + "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.16.7", + "@babel/runtime": "^7.18.3", + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/serialize": "^1.3.3", + "babel-plugin-macros": "^3.1.0", + "convert-source-map": "^1.5.0", + "escape-string-regexp": "^4.0.0", + "find-root": "^1.1.0", + "source-map": "^0.5.7", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/babel-plugin/node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@emotion/cache": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", + "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", + "license": "MIT" + }, + "node_modules/@emotion/memoize": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", + "license": "MIT" + }, + "node_modules/@emotion/react": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", + "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "hoist-non-react-statics": "^3.3.1" + }, + "peerDependencies": { + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/serialize": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", + "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", + "license": "MIT", + "dependencies": { + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/unitless": "^0.10.0", + "@emotion/utils": "^1.4.2", + "csstype": "^3.0.2" + } + }, + "node_modules/@emotion/sheet": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", + "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==", + "license": "MIT" + }, + "node_modules/@emotion/unitless": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", + "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==", + "license": "MIT" + }, + "node_modules/@emotion/use-insertion-effect-with-fallbacks": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", + "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@emotion/utils": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", + "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==", + "license": "MIT" + }, + "node_modules/@emotion/weak-memoize": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", + "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", + "license": "MIT" + }, "node_modules/@esbuild-kit/core-utils": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/@esbuild-kit/core-utils/-/core-utils-3.3.2.tgz", @@ -4572,17 +4826,21 @@ "undici-types": "~5.26.4" } }, + "node_modules/@types/parse-json": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", + "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", + "license": "MIT" + }, "node_modules/@types/prop-types": { "version": "15.7.12", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz", - "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==", - "devOptional": true + "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==" }, "node_modules/@types/react": { "version": "18.3.3", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.3.tgz", "integrity": "sha512-hti/R0pS0q1/xx+TsI73XIqk26eBsISZ2R0wUijXIngRK9R/e7Xw/cXVxQK7R5JjW+SV4zGcn5hXjudkN/pLIw==", - "devOptional": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -4597,6 +4855,15 @@ "@types/react": "*" } }, + "node_modules/@types/react-transition-group": { + "version": "4.4.12", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", + "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, "node_modules/@types/turndown": { "version": "5.0.5", "resolved": "https://registry.npmjs.org/@types/turndown/-/turndown-5.0.5.tgz", @@ -5149,6 +5416,21 @@ "deep-equal": "^2.0.5" } }, + "node_modules/babel-plugin-macros": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", + "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5", + "cosmiconfig": "^7.0.0", + "resolve": "^1.19.0" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -5236,7 +5518,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, "engines": { "node": ">=6" } @@ -5763,6 +6044,37 @@ "proto-list": "~1.2.1" } }, + "node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "license": "MIT" + }, + "node_modules/cosmiconfig": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "license": "MIT", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/cosmiconfig/node_modules/yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, "node_modules/crelt": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", @@ -5795,8 +6107,7 @@ "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "devOptional": true + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" }, "node_modules/damerau-levenshtein": { "version": "1.0.8", @@ -5876,7 +6187,6 @@ "version": "4.4.0", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -6017,6 +6327,16 @@ "node": ">=6.0.0" } }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -6283,6 +6603,15 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, "node_modules/es-abstract": { "version": "1.23.3", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.3.tgz", @@ -7031,6 +7360,12 @@ "node": ">=8" } }, + "node_modules/find-root": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", + "license": "MIT" + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -7426,6 +7761,15 @@ "node": ">= 0.4" } }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, "node_modules/html-to-text": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz", @@ -7472,7 +7816,6 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", - "dev": true, "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" @@ -7577,6 +7920,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "license": "MIT" + }, "node_modules/is-async-function": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.0.0.tgz", @@ -8029,12 +8378,30 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", "dev": true }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT" + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -8305,6 +8672,12 @@ "memfs": "3.5.3" } }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "license": "MIT" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -8358,7 +8731,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/mz": { @@ -11316,7 +11688,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, "dependencies": { "callsites": "^3.0.0" }, @@ -11324,6 +11695,24 @@ "node": ">=6" } }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/parseley": { "version": "0.12.1", "resolved": "https://registry.npmjs.org/parseley/-/parseley-0.12.1.tgz", @@ -11386,7 +11775,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "dev": true, "engines": { "node": ">=8" } @@ -11617,7 +12005,6 @@ "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", @@ -11895,8 +12282,7 @@ "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, "node_modules/react-promise-suspense": { "version": "0.3.4", @@ -11956,6 +12342,27 @@ } } }, + "node_modules/react-select": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/react-select/-/react-select-5.10.0.tgz", + "integrity": "sha512-k96gw+i6N3ExgDwPIg0lUPmexl1ygPe6u5BdQFNBhkpbwroIgCNXdubtIzHfThYXYYTubwOBafoMnn7ruEP1xA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.0", + "@emotion/cache": "^11.4.0", + "@emotion/react": "^11.8.1", + "@floating-ui/dom": "^1.0.1", + "@types/react-transition-group": "^4.4.0", + "memoize-one": "^6.0.0", + "prop-types": "^15.6.0", + "react-transition-group": "^4.3.0", + "use-isomorphic-layout-effect": "^1.2.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/react-style-singleton": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.1.tgz", @@ -11978,6 +12385,22 @@ } } }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -12077,7 +12500,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, "engines": { "node": ">=4" } @@ -12610,6 +13032,12 @@ } } }, + "node_modules/stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", + "license": "MIT" + }, "node_modules/sucrase": { "version": "3.35.0", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", @@ -12993,6 +13421,20 @@ "react": ">=16.8.0" } }, + "node_modules/use-isomorphic-layout-effect": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.0.tgz", + "integrity": "sha512-q6ayo8DWoPZT0VdG4u3D3uxcgONP3Mevx2i2b0434cwWBoL+aelL1DzkXI6w3PhTZzUeR2kaVlZn70iCiseP6w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/use-sidecar": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.2.tgz", diff --git a/package.json b/package.json index 35612a14..36520e10 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,7 @@ "react-day-picker": "^8.10.1", "react-dom": "^18.3.1", "react-hook-form": "^7.51.5", + "react-select": "^5.10.0", "resend": "^3.5.0", "slugify": "^1.6.6", "sonner": "^1.4.41", diff --git a/types/field.ts b/types/field.ts index c7f4ba7d..3aa85c66 100644 --- a/types/field.ts +++ b/types/field.ts @@ -4,7 +4,7 @@ export type Field = { description?: string | null; type: "boolean" | "code" | "date" | "image" | "number" | "object" | "rich-text" | "select" | "string" | "text" | string; default?: any; - list?: boolean | { min?: number; max?: number }; + list?: boolean | { min?: number; max?: number; default?: any }; hidden?: boolean | null; required?: boolean | null; pattern?: string | { regex: string; message?: string }; From a6ab6470bf2289c02c7bb99c3bf6b6d7b5fd3124 Mon Sep 17 00:00:00 2001 From: Ronan Berder Date: Sat, 8 Mar 2025 12:41:26 +0800 Subject: [PATCH 17/95] Added options and cleaned up PR --- fields/core/uuid/edit-component.tsx | 47 +++++++++++++++++++++++++++-- fields/core/uuid/index.tsx | 4 +-- package-lock.json | 14 +++++++++ package.json | 1 + 4 files changed, 62 insertions(+), 4 deletions(-) diff --git a/fields/core/uuid/edit-component.tsx b/fields/core/uuid/edit-component.tsx index 61c22053..1446c247 100644 --- a/fields/core/uuid/edit-component.tsx +++ b/fields/core/uuid/edit-component.tsx @@ -2,9 +2,52 @@ import { forwardRef } from "react"; import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { v4 as uuidv4 } from 'uuid'; +import { RefreshCcw } from "lucide-react"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; const EditComponent = forwardRef((props: any, ref: React.Ref) => { - return ; + const { value, field, onChange } = props; + + const generateNewUUID = () => { + onChange(uuidv4()); + }; + + return ( +
+ + {field?.options?.generate !== false && ( + + + + + + +

Generate new UUID

+
+
+
+ )} +
+ ); }); -export { EditComponent }; +export { EditComponent }; \ No newline at end of file diff --git a/fields/core/uuid/index.tsx b/fields/core/uuid/index.tsx index 4f8e6e29..fd512fc0 100644 --- a/fields/core/uuid/index.tsx +++ b/fields/core/uuid/index.tsx @@ -7,9 +7,9 @@ const defaultValue = (): string => { }; const schema = (_: Field) => { - let zodSchema = z.coerce.string(); + let zodSchema = z.coerce.string().uuid(); return zodSchema; }; -export { EditComponent, defaultValue, schema }; +export { EditComponent, defaultValue, schema }; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 05132ec2..bf234fc3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -79,6 +79,7 @@ "tailwindcss-animate": "^1.0.7", "turndown": "^7.2.0", "use-debounce": "^10.0.0", + "uuid": "^11.1.0", "yaml": "^2.4.2", "zod": "^3.23.8" }, @@ -13027,6 +13028,19 @@ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" }, + "node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "node_modules/w3c-keyname": { "version": "2.2.8", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", diff --git a/package.json b/package.json index 35612a14..5b82a144 100644 --- a/package.json +++ b/package.json @@ -86,6 +86,7 @@ "tailwindcss-animate": "^1.0.7", "turndown": "^7.2.0", "use-debounce": "^10.0.0", + "uuid": "^11.1.0", "yaml": "^2.4.2", "zod": "^3.23.8" }, From a6c2b7caac31cb69d91d8be8974ca157992d861d Mon Sep 17 00:00:00 2001 From: Ryan Gittings Date: Mon, 10 Mar 2025 10:26:40 +0000 Subject: [PATCH 18/95] Fixes --- components/entry/entry-form.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/components/entry/entry-form.tsx b/components/entry/entry-form.tsx index 0ded3e2d..bfe71a12 100644 --- a/components/entry/entry-form.tsx +++ b/components/entry/entry-form.tsx @@ -115,7 +115,8 @@ const ListField = ({ useEffect(() => { const defaultValue = getDefaultValue(field); - if (typeof defaultValue === "object" && Object.values(defaultValue).filter(n => n).length === 0) { + + if (field.list?.min == undefined) { return; } From 72406859ffa259fd1a65907f0983632c3562deba Mon Sep 17 00:00:00 2001 From: Ryan Gittings Date: Mon, 10 Mar 2025 10:31:34 +0000 Subject: [PATCH 19/95] Fixes --- components/entry/entry-form.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/entry/entry-form.tsx b/components/entry/entry-form.tsx index bfe71a12..d1f95805 100644 --- a/components/entry/entry-form.tsx +++ b/components/entry/entry-form.tsx @@ -116,7 +116,7 @@ const ListField = ({ useEffect(() => { const defaultValue = getDefaultValue(field); - if (field.list?.min == undefined) { + if ((field.list && typeof field.list === 'object' && field.list.min === undefined) || field.list === true) { return; } From 58c61f58344f6532f6783413200471a9f6a8fe60 Mon Sep 17 00:00:00 2001 From: Ryan Gittings Date: Mon, 10 Mar 2025 11:03:36 +0000 Subject: [PATCH 20/95] Fixes --- components/entry/entry-form.tsx | 4 ++-- fields/core/rich-text/edit-component.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/components/entry/entry-form.tsx b/components/entry/entry-form.tsx index d1f95805..0186612d 100644 --- a/components/entry/entry-form.tsx +++ b/components/entry/entry-form.tsx @@ -114,12 +114,12 @@ const ListField = ({ const hasAppended = useRef(false); useEffect(() => { - const defaultValue = getDefaultValue(field); - if ((field.list && typeof field.list === 'object' && field.list.min === undefined) || field.list === true) { return; } + const defaultValue = getDefaultValue(field); + if (arrayFields.length === 0 && !hasAppended.current && defaultValue) { append(defaultValue); hasAppended.current = true; diff --git a/fields/core/rich-text/edit-component.tsx b/fields/core/rich-text/edit-component.tsx index 1d6a27d1..4363567d 100644 --- a/fields/core/rich-text/edit-component.tsx +++ b/fields/core/rich-text/edit-component.tsx @@ -76,7 +76,7 @@ const EditComponent = forwardRef((props: any, ref) => { : undefined; const editor = useEditor({ - immediatelyRender: false, + immediatelyRender: true, extensions: [ StarterKit.configure({ dropcursor: { width: 2 } From 600e4073c8c9d309151018d679b58dc1e6f895be Mon Sep 17 00:00:00 2001 From: Ronan Berder Date: Tue, 11 Mar 2025 17:44:54 +0800 Subject: [PATCH 21/95] Working fetch on new select field --- fields/core/autocomplete/edit-component.tsx | 273 ++++++++------------ fields/core/autocomplete/index.tsx | 8 +- 2 files changed, 109 insertions(+), 172 deletions(-) diff --git a/fields/core/autocomplete/edit-component.tsx b/fields/core/autocomplete/edit-component.tsx index 0a59996a..dc4882ac 100644 --- a/fields/core/autocomplete/edit-component.tsx +++ b/fields/core/autocomplete/edit-component.tsx @@ -2,200 +2,139 @@ import { forwardRef, useMemo, useState, useCallback, useEffect } from "react"; import "./edit-component.css"; -import Select, { components } from "react-select" -import CreatableSelect from 'react-select/creatable'; -import AsyncSelect from 'react-select/async'; -import AsyncCreatableSelect from 'react-select/async-creatable'; +import Select, { components } from "react-select"; +import CreatableSelect from "react-select/creatable"; +import AsyncSelect from "react-select/async"; +import AsyncCreatableSelect from "react-select/async-creatable"; import { ChevronDown, X } from "lucide-react"; - -const DropdownIndicator = (props: any) => { - return ( - - - - ); -}; - -const ClearIndicator = (props: any) => { - return ( - - - - ); -}; - -const MultiValueRemove = (props: any) => { - return ( - - - - ); +import { safeAccess } from "@/lib/schema"; + +const DropdownIndicator = (props: any) => ( + + + +); + +const ClearIndicator = (props: any) => ( + + + +); + +const MultiValueRemove = (props: any) => ( + + + +); + +type FetchConfig = { + url: string; + method?: string; + query?: string; + headers?: Record; + results?: string; + value?: string; + label?: string; + minlength?: number; }; const EditComponent = forwardRef((props: any, ref: any) => { const { value, field, onChange } = props; const [isMounted, setIsMounted] = useState(false); - const [inputValue, setInputValue] = useState(''); - - useEffect(() => { - setIsMounted(true); - }, []); - - // Only create static options if not using fetch - const options = useMemo(() => - !field.options?.fetch && field.options?.values - ? field.options.values.map((option: any) => { - if (typeof option === "object") { - return { value: option.value, label: option.label }; - } - return { value: option, label: option }; - }) - : [], + + useEffect(() => setIsMounted(true), []); + + const staticOptions = useMemo( + () => + !field.options?.fetch && field.options?.values + ? field.options.values.map((opt: any) => + typeof opt === "object" + ? { value: opt.value, label: opt.label } + : { value: opt, label: opt } + ) + : [], [field.options?.values, field.options?.fetch] ); - // Handle fetching options const loadOptions = useCallback( - async (inputValue: string) => { - if (!field.options?.fetch?.url) return []; - + async (input: string) => { + const fetchConfig = field.options?.fetch as FetchConfig; + const minLength = fetchConfig?.minlength || 0; + if (!fetchConfig?.url || input.length < minLength) { + return []; + } + try { - // Process URL - either replace template or add query parameter - let urlString = field.options.fetch.url; - - // Check if URL has a template pattern like {input} - if (urlString.includes('{input}')) { - // Replace the template with the encoded input value - urlString = urlString.replace('{input}', encodeURIComponent(inputValue)); - } else { - // Add query parameter to URL - const url = new URL(urlString); - if (inputValue) { - url.searchParams.append(field.options.fetch.queryParam || 'q', inputValue); - } - urlString = url.toString(); - } - - const response = await fetch(urlString, { - method: field.options.fetch.method || 'GET', - headers: field.options.fetch.headers || {}, + const url = new URL(fetchConfig.url); + if (fetchConfig.query) url.searchParams.append(fetchConfig.query, input); + const response = await fetch(url, { + method: fetchConfig.method || "GET", + headers: fetchConfig.headers || {}, }); - - if (!response.ok) throw new Error('Failed to fetch options'); - + if (!response.ok) throw new Error("Fetch failed"); const data = await response.json(); - - // Extract options from response using the provided path - let results = data; - if (field.options.fetch.resultsPath) { - const path = field.options.fetch.resultsPath.split('.'); - for (const segment of path) { - results = results[segment]; - } - } - - // Map results to option format - return results.map((item: any) => { - const valuePath = field.options.fetch.valuePath || 'id'; - const labelPath = field.options.fetch.labelPath || 'name'; - - const getValue = (obj: any, path: string) => { - const parts = path.split('.'); - return parts.reduce((o, key) => (o && o[key] !== undefined) ? o[key] : null, obj); - }; - - return { - value: getValue(item, valuePath), - label: getValue(item, labelPath), - }; - }); + const results = fetchConfig.results ? safeAccess(data, fetchConfig.results) : data; + if (!Array.isArray(results)) return []; + return results.map((item: any) => ({ + value: fetchConfig.value ? safeAccess(item, fetchConfig.value) : item.id, + label: fetchConfig.label ? safeAccess(item, fetchConfig.label) : item.name, + })); } catch (error) { - console.error('Error loading options:', error); + console.error("Error loading options:", error); return []; } }, [field.options?.fetch] ); - // Internal state to manage the react-select format const [selectedOptions, setSelectedOptions] = useState(() => { - if (field.list) { - if (!value) return []; - - // Handle nested array structure - const valueToUse = Array.isArray(value[0]) ? value[0] : value; - - // Map each value to an option object - return valueToUse.map((val: any) => { - const option = options.find((opt: any) => opt.value === val); - return option || { value: val, label: val }; - }); - } else { - if (!value) return null; - const option = options.find((opt: any) => opt.value === value); - return option || { value, label: value }; + if (field.options?.multiple) { + const values = Array.isArray(value) ? value : []; + return values.map((val: any) => staticOptions.find((opt: any) => opt.value === val) || { value: val, label: val }); } + if (!value) return null; + return staticOptions.find((opt: any) => opt.value === value) || { value, label: value }; }); - const handleChange = useCallback((newValue: any) => { - setSelectedOptions(newValue); - if (field.list) { - const values = newValue ? newValue.map((item: any) => item.value) : []; - onChange(values); - } else { - onChange(newValue ? newValue.value : null); - } - }, [onChange, field.list]); - - const handleInputChange = useCallback((newValue: string) => { - setInputValue(newValue); - }, []); + const handleChange = useCallback( + (newValue: any) => { + setSelectedOptions(newValue); + const output = field.options?.multiple + ? newValue ? newValue.map((item: any) => item.value) : [] + : newValue ? newValue.value : null; + onChange(output); + }, + [onChange, field.options?.multiple] + ); if (!isMounted) return null; - // Determine which Select component to use based on options - let SelectComponent; - if (field.options?.fetch) { - SelectComponent = field.options?.creatable ? AsyncCreatableSelect : AsyncSelect; - } else { - SelectComponent = field.options?.creatable ? CreatableSelect : Select; - } - - // Common props for all select variants - const selectProps = { - ref, - isMulti: field.list, - classNamePrefix: "react-select", - placeholder: field.options?.placeholder || "Select...", - components: { - DropdownIndicator, - ClearIndicator, - MultiValueRemove - }, - value: selectedOptions, - onChange: handleChange, - onInputChange: handleInputChange, - inputValue: inputValue, - }; - - // Add specific props based on component type - if (field.options?.fetch) { - return ( - - ); - } else { - return ( - - ); - } + const SelectComponent = field.options?.fetch + ? field.options?.creatable + ? AsyncCreatableSelect + : AsyncSelect + : field.options?.creatable + ? CreatableSelect + : Select; + + const fetchConfig = field.options?.fetch as FetchConfig; + return ( + + ); }); export { EditComponent }; \ No newline at end of file diff --git a/fields/core/autocomplete/index.tsx b/fields/core/autocomplete/index.tsx index 0cdd277b..b600626b 100644 --- a/fields/core/autocomplete/index.tsx +++ b/fields/core/autocomplete/index.tsx @@ -5,7 +5,7 @@ import { EditComponent } from "./edit-component"; const schema = (field: Field) => { let zodSchema; - if (!field.options?.creatable && field.options?.values && Array.isArray(field.options.values)) { + if (!field.options?.creatable && !field.options?.fetch && field.options?.values && Array.isArray(field.options.values)) { const normalizedValues = field.options.values.map((item) => { return typeof item === "object" ? item.value @@ -13,7 +13,7 @@ const schema = (field: Field) => { }); zodSchema = z.enum(normalizedValues as [string, ...string[]]); } else { - zodSchema = z.string(); + zodSchema = z.coerce.string(); } if (!field.required) zodSchema = zodSchema.optional(); @@ -21,6 +21,4 @@ const schema = (field: Field) => { return zodSchema; }; -const supportsList = true; - -export { schema, EditComponent, supportsList }; \ No newline at end of file +export { schema, EditComponent }; \ No newline at end of file From 292180ec0d07674bb4c0a1034dd331e1124e6057 Mon Sep 17 00:00:00 2001 From: Ronan Berder Date: Tue, 11 Mar 2025 18:11:14 +0800 Subject: [PATCH 22/95] Adding templates for value and label --- fields/core/autocomplete/edit-component.tsx | 19 +++++++++++++++---- fields/core/autocomplete/index.tsx | 2 ++ 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/fields/core/autocomplete/edit-component.tsx b/fields/core/autocomplete/edit-component.tsx index dc4882ac..13551cbd 100644 --- a/fields/core/autocomplete/edit-component.tsx +++ b/fields/core/autocomplete/edit-component.tsx @@ -9,6 +9,13 @@ import AsyncCreatableSelect from "react-select/async-creatable"; import { ChevronDown, X } from "lucide-react"; import { safeAccess } from "@/lib/schema"; +const processTemplate = (template: string, item: any): string => { + return template.replace(/\{([^}]+)\}/g, (_, path) => { + const value = safeAccess(item, path); + return value !== undefined ? String(value) : ''; + }); +}; + const DropdownIndicator = (props: any) => ( @@ -76,8 +83,12 @@ const EditComponent = forwardRef((props: any, ref: any) => { const results = fetchConfig.results ? safeAccess(data, fetchConfig.results) : data; if (!Array.isArray(results)) return []; return results.map((item: any) => ({ - value: fetchConfig.value ? safeAccess(item, fetchConfig.value) : item.id, - label: fetchConfig.label ? safeAccess(item, fetchConfig.label) : item.name, + value: fetchConfig.value ? + (fetchConfig.value.includes('{') ? processTemplate(fetchConfig.value, item) : safeAccess(item, fetchConfig.value)) + : item.id, + label: fetchConfig.label ? + (fetchConfig.label.includes('{') ? processTemplate(fetchConfig.label, item) : safeAccess(item, fetchConfig.label)) + : item.name, })); } catch (error) { console.error("Error loading options:", error); @@ -90,10 +101,10 @@ const EditComponent = forwardRef((props: any, ref: any) => { const [selectedOptions, setSelectedOptions] = useState(() => { if (field.options?.multiple) { const values = Array.isArray(value) ? value : []; - return values.map((val: any) => staticOptions.find((opt: any) => opt.value === val) || { value: val, label: val }); + return values.map((val: any) => ({ value: val, label: val })); } if (!value) return null; - return staticOptions.find((opt: any) => opt.value === value) || { value, label: value }; + return { value: value, label: value }; }); const handleChange = useCallback( diff --git a/fields/core/autocomplete/index.tsx b/fields/core/autocomplete/index.tsx index b600626b..3280098c 100644 --- a/fields/core/autocomplete/index.tsx +++ b/fields/core/autocomplete/index.tsx @@ -16,6 +16,8 @@ const schema = (field: Field) => { zodSchema = z.coerce.string(); } + if (field.options?.multiple) zodSchema = z.array(zodSchema); + if (!field.required) zodSchema = zodSchema.optional(); return zodSchema; From 970960f960c77563844e06589e8d3594e60f9101 Mon Sep 17 00:00:00 2001 From: Ronan Berder Date: Tue, 11 Mar 2025 22:39:18 +0800 Subject: [PATCH 23/95] Polished animations, fixed validation of multiple fields --- components/ui/form.tsx | 46 +++++++++++++----- fields/core/autocomplete/edit-component.css | 13 ++++++ fields/core/autocomplete/edit-component.tsx | 52 +++++++++++++++++++-- fields/core/autocomplete/index.tsx | 2 +- 4 files changed, 95 insertions(+), 18 deletions(-) diff --git a/components/ui/form.tsx b/components/ui/form.tsx index fbe37485..3e10fd8e 100644 --- a/components/ui/form.tsx +++ b/components/ui/form.tsx @@ -146,25 +146,47 @@ const FormMessage = React.forwardRef< >(({ className, children, ...props }, ref) => { const { error, formMessageId } = useFormField() - const body = error - ? error.root - ? String(error?.root?.message) - : String(error?.message) - : children - - if (!body) { - return null - } + // const body = error + // ? error.root + // ? String(error?.root?.message) + // : String(error?.message) + // : children + + // if (!body) { + // return null + // } + + const messages: any[] = [] + + const errors = Array.isArray(error) + ? error + : [error]; + + errors.forEach((err) => { + const body = err + ? err.root + ? String(err?.root?.message) + : String(err?.message) + : children + + if (!body) { + return null + } else { + messages.push(body) + } + }); return ( -

- {body} -

+ {messages.map((message, index) => ( +

{message}

+ ))} + ) }) FormMessage.displayName = "FormMessage" diff --git a/fields/core/autocomplete/edit-component.css b/fields/core/autocomplete/edit-component.css index f74dc577..2261f005 100644 --- a/fields/core/autocomplete/edit-component.css +++ b/fields/core/autocomplete/edit-component.css @@ -53,6 +53,19 @@ .react-select__menu { @apply !rounded-md !border !border-input !shadow-md !bg-popover !text-popover-foreground !max-h-96 !min-w-[8rem]; + animation: selectMenuIn 150ms cubic-bezier(0.4, 0, 0.2, 1); + transform-origin: top; +} + +@keyframes selectMenuIn { + 0% { + opacity: 0; + transform: translateY(-0.5rem) scale(0.95); + } + 100% { + opacity: 1; + transform: translateY(0) scale(1); + } } .react-select__menu-list { diff --git a/fields/core/autocomplete/edit-component.tsx b/fields/core/autocomplete/edit-component.tsx index 13551cbd..2b34e1dd 100644 --- a/fields/core/autocomplete/edit-component.tsx +++ b/fields/core/autocomplete/edit-component.tsx @@ -9,6 +9,30 @@ import AsyncCreatableSelect from "react-select/async-creatable"; import { ChevronDown, X } from "lucide-react"; import { safeAccess } from "@/lib/schema"; +const Option = ({ children, ...props }: any) => { + const { data } = props; + return ( + +
+ {data.image && } + {children} +
+
+ ); +}; + +const SingleValue = ({ children, ...props }: any) => { + const { data } = props; + return ( + +
+ {data.image && } + {children} +
+
+ ); +}; + const processTemplate = (template: string, item: any): string => { return template.replace(/\{([^}]+)\}/g, (_, path) => { const value = safeAccess(item, path); @@ -43,6 +67,7 @@ type FetchConfig = { value?: string; label?: string; minlength?: number; + image?: string; }; const EditComponent = forwardRef((props: any, ref: any) => { @@ -89,6 +114,9 @@ const EditComponent = forwardRef((props: any, ref: any) => { label: fetchConfig.label ? (fetchConfig.label.includes('{') ? processTemplate(fetchConfig.label, item) : safeAccess(item, fetchConfig.label)) : item.name, + image: fetchConfig.image ? + (fetchConfig.image.includes('{') ? processTemplate(fetchConfig.image, item) : safeAccess(item, fetchConfig.image)) + : undefined, })); } catch (error) { console.error("Error loading options:", error); @@ -109,13 +137,21 @@ const EditComponent = forwardRef((props: any, ref: any) => { const handleChange = useCallback( (newValue: any) => { - setSelectedOptions(newValue); + // When a new value is selected, ensure we display the value, not the label + const selectedValue = newValue + ? field.options?.multiple + ? newValue.map((item: any) => ({ value: item.value, label: item.value })) + : { value: newValue.value, label: newValue.value } + : field.options?.multiple ? [] : null; + + setSelectedOptions(selectedValue); + const output = field.options?.multiple ? newValue ? newValue.map((item: any) => item.value) : [] : newValue ? newValue.value : null; onChange(output); }, - [onChange, field.options?.multiple] + [onChange, field.options?.multiple, field] ); if (!isMounted) return null; @@ -125,8 +161,8 @@ const EditComponent = forwardRef((props: any, ref: any) => { ? AsyncCreatableSelect : AsyncSelect : field.options?.creatable - ? CreatableSelect - : Select; + ? CreatableSelect + : Select; const fetchConfig = field.options?.fetch as FetchConfig; return ( @@ -135,7 +171,13 @@ const EditComponent = forwardRef((props: any, ref: any) => { isMulti={field.options?.multiple} classNamePrefix="react-select" placeholder={field.options?.placeholder || "Select..."} - components={{ DropdownIndicator, ClearIndicator, MultiValueRemove }} + components={{ + DropdownIndicator, + ClearIndicator, + MultiValueRemove, + Option, + SingleValue, + }} value={selectedOptions} onChange={handleChange} {...(fetchConfig diff --git a/fields/core/autocomplete/index.tsx b/fields/core/autocomplete/index.tsx index 3280098c..763d0958 100644 --- a/fields/core/autocomplete/index.tsx +++ b/fields/core/autocomplete/index.tsx @@ -16,7 +16,7 @@ const schema = (field: Field) => { zodSchema = z.coerce.string(); } - if (field.options?.multiple) zodSchema = z.array(zodSchema); + if (field.options?.multiple) zodSchema = z.array(zodSchema, { message: `Error` }); if (!field.required) zodSchema = zodSchema.optional(); From 4ac54a7b277c9bd06b6adde424a22e33d7481e11 Mon Sep 17 00:00:00 2001 From: Ronan Berder Date: Fri, 14 Mar 2025 09:35:23 +0800 Subject: [PATCH 24/95] Working proof of concept for caching and Reference field --- .../[branch]/collections/[name]/route.ts | 77 +-- app/api/webhook/github/route.ts | 41 ++ components/collection/collection-view.tsx | 2 +- components/entry/entry-form.tsx | 2 +- components/ui/form.tsx | 34 +- db/migrations/0001_brief_mesmero.sql | 13 + db/migrations/meta/0001_snapshot.json | 582 ++++++++++++++++++ db/migrations/meta/_journal.json | 7 + db/schema.ts | 17 +- fields/core/autocomplete/edit-component.tsx | 193 ------ fields/core/old.select/edit-component.tsx | 40 ++ .../{autocomplete => old.select}/index.tsx | 10 +- fields/core/reference/edit-component.tsx | 39 ++ fields/core/reference/index.tsx | 3 + .../edit-component.css | 0 fields/core/select/edit-component.tsx | 229 ++++++- fields/core/select/index.tsx | 21 +- lib/githubCache.ts | 214 +++++++ lib/schema.ts | 11 +- 19 files changed, 1239 insertions(+), 296 deletions(-) create mode 100644 db/migrations/0001_brief_mesmero.sql create mode 100644 db/migrations/meta/0001_snapshot.json delete mode 100644 fields/core/autocomplete/edit-component.tsx create mode 100644 fields/core/old.select/edit-component.tsx rename fields/core/{autocomplete => old.select}/index.tsx (63%) create mode 100644 fields/core/reference/edit-component.tsx create mode 100644 fields/core/reference/index.tsx rename fields/core/{autocomplete => select}/edit-component.css (100%) create mode 100644 lib/githubCache.ts diff --git a/app/api/[owner]/[repo]/[branch]/collections/[name]/route.ts b/app/api/[owner]/[repo]/[branch]/collections/[name]/route.ts index 8985b28b..e4831bbb 100644 --- a/app/api/[owner]/[repo]/[branch]/collections/[name]/route.ts +++ b/app/api/[owner]/[repo]/[branch]/collections/[name]/route.ts @@ -1,14 +1,14 @@ export const maxDuration = 30; import { type NextRequest } from "next/server"; -import { createOctokitInstance } from "@/lib/utils/octokit" import { readFns } from "@/fields/registry"; import { parse } from "@/lib/serialization"; -import { deepMap, getDateFromFilename, getSchemaByName } from "@/lib/schema"; +import { deepMap, getDateFromFilename, getSchemaByName, safeAccess } from "@/lib/schema"; import { getConfig } from "@/lib/utils/config"; import { normalizePath } from "@/lib/utils/file"; import { getAuth } from "@/lib/auth"; import { getToken } from "@/lib/token"; +import { getCachedCollection } from "@/lib/githubCache"; export async function GET( request: NextRequest, @@ -29,39 +29,15 @@ export async function GET( const searchParams = request.nextUrl.searchParams; const path = searchParams.get("path") || ""; + const type = searchParams.get("type"); + const query = searchParams.get("query") || ""; + const fields = searchParams.get("fields")?.split(",") || ["name"]; + const normalizedPath = normalizePath(path); if (!normalizedPath.startsWith(schema.path)) throw new Error(`Invalid path "${path}" for collection "${params.name}".`); - - const octokit = createOctokitInstance(token); - const query = ` - query ($owner: String!, $repo: String!, $expression: String!) { - repository(owner: $owner, name: $repo) { - object(expression: $expression) { - ... on Tree { - entries { - name - path - type - object { - ... on Blob { - text - oid - } - } - } - } - } - } - } - `; - const expression = `${params.branch}:${normalizedPath}`; - const response: any = await octokit.graphql(query, { owner: params.owner, repo: params.repo, expression }); - // TODO: handle 401 / Bad credentials error - - const entries = response.repository?.object?.entries; - - if (entries === undefined) throw new Error("Not found"); + let entries = await getCachedCollection(params.owner, params.repo, params.branch, normalizedPath, token); + let data: { contents: Record[], errors: string[] @@ -69,8 +45,35 @@ export async function GET( contents: [], errors: [] }; + + if (entries) { + data = parseContents(entries, schema, config); - if (entries) data = parseContents(entries, schema, config); + console.log("data", JSON.stringify(data, null, 2)); + + // If this is a search request, filter the contents + if (type === "search" && query) { + const searchQuery = query.toLowerCase(); + data.contents = data.contents.filter(item => { + return fields.some(field => { + // Handle extra fields (name and path) + if (field === 'name' || field === 'path') { + const value = item[field]; + return value && String(value).toLowerCase().includes(searchQuery); + } + + // Handle content fields (e.g. fields.title) + if (field.startsWith('fields.')) { + const fieldPath = field.replace('fields.', ''); + const value = safeAccess(item.fields, fieldPath); + return value && String(value).toLowerCase().includes(searchQuery); + } + + return false; + }); + }); + } + } return Response.json({ status: "success", @@ -107,7 +110,7 @@ const parseContents = ( if (serializedTypes.includes(schema.format) && schema.fields) { // If we are dealing with a serialized format and we have fields defined try { - contentObject = parse(item.object.text, { format: schema.format, delimiters: schema.delimiters }); + contentObject = parse(item.content, { format: schema.format, delimiters: schema.delimiters }); contentObject = deepMap(contentObject, schema.fields, (value, field) => readFns[field.type] ? readFns[field.type](value, field, config) : value); } catch (error: any) { // TODO: send this to the client? @@ -132,11 +135,11 @@ const parseContents = ( } // TODO: handle proper returns return { - sha: item.object.oid, + sha: item.sha, name: item.name, path: item.path, - content: item.object.text, - object: contentObject, + content: item.content, + fields: contentObject, type: "file", }; } else if (item.type === "tree") { diff --git a/app/api/webhook/github/route.ts b/app/api/webhook/github/route.ts index 5a81d8ae..f14485c1 100644 --- a/app/api/webhook/github/route.ts +++ b/app/api/webhook/github/route.ts @@ -3,6 +3,9 @@ import crypto from "crypto"; import { db } from "@/db"; import { collaboratorTable, githubInstallationTokenTable } from "@/db/schema"; import { eq, inArray } from "drizzle-orm"; +import { normalizePath } from "@/lib/utils/file"; +import { updateCache } from "@/lib/githubCache"; +import { getInstallationToken } from "@/lib/token"; export async function POST(request: Request) { try { @@ -62,6 +65,44 @@ export async function POST(request: Request) { ); } break; + case "push": + const owner = data.repository.owner.login.toLowerCase(); + const repo = data.repository.name.toLowerCase(); + const branch = data.ref.replace('refs/heads/', ''); + + const removedFiles = data.commits.flatMap((commit: any) => + (commit.removed || []).map((path: string) => ({ + path: normalizePath(path) + })) + ); + + const modifiedFiles = data.commits.flatMap((commit: any) => + (commit.modified || []).map((path: string) => ({ + path: normalizePath(path), + sha: commit.id + })) + ); + + const addedFiles = data.commits.flatMap((commit: any) => + (commit.added || []).map((path: string) => ({ + path: normalizePath(path), + sha: commit.id + })) + ); + + // TODO: verify this is secure + const installationToken = await getInstallationToken(owner, repo); + + await updateCache( + owner, + repo, + branch, + removedFiles, + modifiedFiles, + addedFiles, + installationToken + ); + break; } } catch (error: any) { // TODO: this may need to be logged for remediation since the DB must be accurate diff --git a/components/collection/collection-view.tsx b/components/collection/collection-view.tsx index edb9e25f..19e872ee 100644 --- a/components/collection/collection-view.tsx +++ b/components/collection/collection-view.tsx @@ -138,7 +138,7 @@ export function CollectionView({ return { accessorKey: path, - accessorFn: (originalRow: any) => safeAccess(originalRow.object, path), + accessorFn: (originalRow: any) => safeAccess(originalRow.fields, path), header: field?.label ?? field.name, meta: { className: field.name === primaryField ? "truncate w-full min-w-[12rem] max-w-[1px]" : "" }, cell: ({ cell, row }: { cell: any, row: any }) => { diff --git a/components/entry/entry-form.tsx b/components/entry/entry-form.tsx index 9460c85e..ddd6b209 100644 --- a/components/entry/entry-form.tsx +++ b/components/entry/entry-form.tsx @@ -141,6 +141,7 @@ const ListField = ({ } }; + // We don't render in ListField, because it's already rendered in the individual fields return ( } - )} /> diff --git a/components/ui/form.tsx b/components/ui/form.tsx index 3e10fd8e..f25c4f47 100644 --- a/components/ui/form.tsx +++ b/components/ui/form.tsx @@ -145,16 +145,6 @@ const FormMessage = React.forwardRef< React.HTMLAttributes >(({ className, children, ...props }, ref) => { const { error, formMessageId } = useFormField() - - // const body = error - // ? error.root - // ? String(error?.root?.message) - // : String(error?.message) - // : children - - // if (!body) { - // return null - // } const messages: any[] = [] @@ -177,16 +167,20 @@ const FormMessage = React.forwardRef< }); return ( -
- {messages.map((message, index) => ( -

{message}

- ))} -
+ messages.length > 0 + ? ( +
+ {messages.map((message, index) => ( +

{message}

+ ))} +
+ ) + : null ) }) FormMessage.displayName = "FormMessage" diff --git a/db/migrations/0001_brief_mesmero.sql b/db/migrations/0001_brief_mesmero.sql new file mode 100644 index 00000000..d9ca79f3 --- /dev/null +++ b/db/migrations/0001_brief_mesmero.sql @@ -0,0 +1,13 @@ +CREATE TABLE `cached_entries` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `owner` text NOT NULL, + `repo` text NOT NULL, + `branch` text NOT NULL, + `parent_path` text NOT NULL, + `name` text NOT NULL, + `path` text NOT NULL, + `type` text NOT NULL, + `content` text, + `sha` text, + `last_updated` integer NOT NULL +); diff --git a/db/migrations/meta/0001_snapshot.json b/db/migrations/meta/0001_snapshot.json new file mode 100644 index 00000000..bea7effd --- /dev/null +++ b/db/migrations/meta/0001_snapshot.json @@ -0,0 +1,582 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "86214e27-a7c3-4087-8050-6fc55560872a", + "prevId": "9bebad23-d902-47ff-b6e6-8878fb0c5b3e", + "tables": { + "cached_entries": { + "name": "cached_entries", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo": { + "name": "repo", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_path": { + "name": "parent_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sha": { + "name": "sha", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_updated": { + "name": "last_updated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "collaborator": { + "name": "collaborator", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "installation_id": { + "name": "installation_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_id": { + "name": "repo_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo": { + "name": "repo", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "collaborator_user_id_user_id_fk": { + "name": "collaborator_user_id_user_id_fk", + "tableFrom": "collaborator", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "collaborator_invited_by_user_id_fk": { + "name": "collaborator_invited_by_user_id_fk", + "tableFrom": "collaborator", + "tableTo": "user", + "columnsFrom": [ + "invited_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "config": { + "name": "config", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo": { + "name": "repo", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sha": { + "name": "sha", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "object": { + "name": "object", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "email_login_token": { + "name": "email_login_token", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "email_login_token_token_hash_unique": { + "name": "email_login_token_token_hash_unique", + "columns": [ + "token_hash" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "github_installation_token": { + "name": "github_installation_token", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "ciphertext": { + "name": "ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "iv": { + "name": "iv", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "installation_id": { + "name": "installation_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "github_user_token": { + "name": "github_user_token", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "ciphertext": { + "name": "ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "iv": { + "name": "iv", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "github_user_token_user_id_user_id_fk": { + "name": "github_user_token_user_id_user_id_fk", + "tableFrom": "github_user_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "history": { + "name": "history", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo": { + "name": "repo", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_visited": { + "name": "last_visited", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "history_user_id_user_id_fk": { + "name": "history_user_id_user_id_fk", + "tableFrom": "history", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "github_email": { + "name": "github_email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "github_name": { + "name": "github_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "github_id": { + "name": "github_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "github_username": { + "name": "github_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "user_github_id_unique": { + "name": "user_github_id_unique", + "columns": [ + "github_id" + ], + "isUnique": true + }, + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/db/migrations/meta/_journal.json b/db/migrations/meta/_journal.json index e63938b2..d0d5c7b5 100644 --- a/db/migrations/meta/_journal.json +++ b/db/migrations/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1733392958887, "tag": "0000_lame_dormammu", "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1741784806486, + "tag": "0001_brief_mesmero", + "breakpoints": true } ] } \ No newline at end of file diff --git a/db/schema.ts b/db/schema.ts index fd793f8b..6b8ed374 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -74,6 +74,20 @@ const configTable = sqliteTable("config", { object: text("object").notNull() }); +const cachedEntriesTable = sqliteTable("cached_entries", { + id: integer("id").primaryKey({ autoIncrement: true }), + owner: text("owner").notNull(), + repo: text("repo").notNull(), + branch: text("branch").notNull(), + parentPath: text("parent_path").notNull(), + name: text("name").notNull(), + path: text("path").notNull(), + type: text("type").notNull(), + content: text("content"), + sha: text("sha"), + lastUpdated: integer("last_updated").notNull() +}); + export { userTable, sessionTable, @@ -82,5 +96,6 @@ export { githubInstallationTokenTable, emailLoginTokenTable, collaboratorTable, - configTable + configTable, + cachedEntriesTable }; \ No newline at end of file diff --git a/fields/core/autocomplete/edit-component.tsx b/fields/core/autocomplete/edit-component.tsx deleted file mode 100644 index 2b34e1dd..00000000 --- a/fields/core/autocomplete/edit-component.tsx +++ /dev/null @@ -1,193 +0,0 @@ -"use client"; - -import { forwardRef, useMemo, useState, useCallback, useEffect } from "react"; -import "./edit-component.css"; -import Select, { components } from "react-select"; -import CreatableSelect from "react-select/creatable"; -import AsyncSelect from "react-select/async"; -import AsyncCreatableSelect from "react-select/async-creatable"; -import { ChevronDown, X } from "lucide-react"; -import { safeAccess } from "@/lib/schema"; - -const Option = ({ children, ...props }: any) => { - const { data } = props; - return ( - -
- {data.image && } - {children} -
-
- ); -}; - -const SingleValue = ({ children, ...props }: any) => { - const { data } = props; - return ( - -
- {data.image && } - {children} -
-
- ); -}; - -const processTemplate = (template: string, item: any): string => { - return template.replace(/\{([^}]+)\}/g, (_, path) => { - const value = safeAccess(item, path); - return value !== undefined ? String(value) : ''; - }); -}; - -const DropdownIndicator = (props: any) => ( - - - -); - -const ClearIndicator = (props: any) => ( - - - -); - -const MultiValueRemove = (props: any) => ( - - - -); - -type FetchConfig = { - url: string; - method?: string; - query?: string; - headers?: Record; - results?: string; - value?: string; - label?: string; - minlength?: number; - image?: string; -}; - -const EditComponent = forwardRef((props: any, ref: any) => { - const { value, field, onChange } = props; - const [isMounted, setIsMounted] = useState(false); - - useEffect(() => setIsMounted(true), []); - - const staticOptions = useMemo( - () => - !field.options?.fetch && field.options?.values - ? field.options.values.map((opt: any) => - typeof opt === "object" - ? { value: opt.value, label: opt.label } - : { value: opt, label: opt } - ) - : [], - [field.options?.values, field.options?.fetch] - ); - - const loadOptions = useCallback( - async (input: string) => { - const fetchConfig = field.options?.fetch as FetchConfig; - const minLength = fetchConfig?.minlength || 0; - if (!fetchConfig?.url || input.length < minLength) { - return []; - } - - try { - const url = new URL(fetchConfig.url); - if (fetchConfig.query) url.searchParams.append(fetchConfig.query, input); - const response = await fetch(url, { - method: fetchConfig.method || "GET", - headers: fetchConfig.headers || {}, - }); - if (!response.ok) throw new Error("Fetch failed"); - const data = await response.json(); - const results = fetchConfig.results ? safeAccess(data, fetchConfig.results) : data; - if (!Array.isArray(results)) return []; - return results.map((item: any) => ({ - value: fetchConfig.value ? - (fetchConfig.value.includes('{') ? processTemplate(fetchConfig.value, item) : safeAccess(item, fetchConfig.value)) - : item.id, - label: fetchConfig.label ? - (fetchConfig.label.includes('{') ? processTemplate(fetchConfig.label, item) : safeAccess(item, fetchConfig.label)) - : item.name, - image: fetchConfig.image ? - (fetchConfig.image.includes('{') ? processTemplate(fetchConfig.image, item) : safeAccess(item, fetchConfig.image)) - : undefined, - })); - } catch (error) { - console.error("Error loading options:", error); - return []; - } - }, - [field.options?.fetch] - ); - - const [selectedOptions, setSelectedOptions] = useState(() => { - if (field.options?.multiple) { - const values = Array.isArray(value) ? value : []; - return values.map((val: any) => ({ value: val, label: val })); - } - if (!value) return null; - return { value: value, label: value }; - }); - - const handleChange = useCallback( - (newValue: any) => { - // When a new value is selected, ensure we display the value, not the label - const selectedValue = newValue - ? field.options?.multiple - ? newValue.map((item: any) => ({ value: item.value, label: item.value })) - : { value: newValue.value, label: newValue.value } - : field.options?.multiple ? [] : null; - - setSelectedOptions(selectedValue); - - const output = field.options?.multiple - ? newValue ? newValue.map((item: any) => item.value) : [] - : newValue ? newValue.value : null; - onChange(output); - }, - [onChange, field.options?.multiple, field] - ); - - if (!isMounted) return null; - - const SelectComponent = field.options?.fetch - ? field.options?.creatable - ? AsyncCreatableSelect - : AsyncSelect - : field.options?.creatable - ? CreatableSelect - : Select; - - const fetchConfig = field.options?.fetch as FetchConfig; - return ( - - ); -}); - -export { EditComponent }; \ No newline at end of file diff --git a/fields/core/old.select/edit-component.tsx b/fields/core/old.select/edit-component.tsx new file mode 100644 index 00000000..1efe345a --- /dev/null +++ b/fields/core/old.select/edit-component.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { forwardRef } from "react"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" + +const EditComponent = forwardRef((props: any, ref: React.Ref) => { + const { value, field, onChange } = props; + + return ( + + ) +}); + +export { EditComponent }; \ No newline at end of file diff --git a/fields/core/autocomplete/index.tsx b/fields/core/old.select/index.tsx similarity index 63% rename from fields/core/autocomplete/index.tsx rename to fields/core/old.select/index.tsx index 763d0958..cd3eed58 100644 --- a/fields/core/autocomplete/index.tsx +++ b/fields/core/old.select/index.tsx @@ -4,8 +4,8 @@ import { EditComponent } from "./edit-component"; const schema = (field: Field) => { let zodSchema; - - if (!field.options?.creatable && !field.options?.fetch && field.options?.values && Array.isArray(field.options.values)) { + + if (field.options?.values && Array.isArray(field.options.values)) { const normalizedValues = field.options.values.map((item) => { return typeof item === "object" ? item.value @@ -13,14 +13,12 @@ const schema = (field: Field) => { }); zodSchema = z.enum(normalizedValues as [string, ...string[]]); } else { - zodSchema = z.coerce.string(); + zodSchema = z.string(); } - if (field.options?.multiple) zodSchema = z.array(zodSchema, { message: `Error` }); - if (!field.required) zodSchema = zodSchema.optional(); return zodSchema; }; -export { schema, EditComponent }; \ No newline at end of file +export { schema, EditComponent}; \ No newline at end of file diff --git a/fields/core/reference/edit-component.tsx b/fields/core/reference/edit-component.tsx new file mode 100644 index 00000000..47315841 --- /dev/null +++ b/fields/core/reference/edit-component.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { EditComponent as SelectEditComponent } from "../select"; +import { forwardRef, useMemo } from "react"; +import { getSchemaByName } from "@/lib/schema"; +import { useConfig } from "@/contexts/config-context"; + +// const EditComponent = forwardRef((props: any, ref: React.Ref) => { +// return ; +// }); + +const EditComponent = forwardRef((props: any, ref: React.Ref) => { + const { value, field, onChange } = props; + + const { config } = useConfig(); + if (!config) return null; + + const collection = getSchemaByName(config.object, field.options.collection); + if (!collection) return null; + + const fetchConfig = useMemo(() => ({ + url: `/api/${config.owner}/${config.repo}/${encodeURIComponent(config.branch)}/collections/${field.options.collection}`, + params: { + path: collection.path, + type: "search", + query: "{input}", + fields: field.options.search || "name" + }, + minlength: 2, + results: "data.contents", + value: field.options.value || "{path}", + label: field.options.label || "{name}", + headers: {}, + }), [config.owner, config.repo, config.branch, field.options]); + + return ; +}); + +export { EditComponent }; \ No newline at end of file diff --git a/fields/core/reference/index.tsx b/fields/core/reference/index.tsx new file mode 100644 index 00000000..3dd612f7 --- /dev/null +++ b/fields/core/reference/index.tsx @@ -0,0 +1,3 @@ +import { EditComponent } from "./edit-component"; + +export { EditComponent }; \ No newline at end of file diff --git a/fields/core/autocomplete/edit-component.css b/fields/core/select/edit-component.css similarity index 100% rename from fields/core/autocomplete/edit-component.css rename to fields/core/select/edit-component.css diff --git a/fields/core/select/edit-component.tsx b/fields/core/select/edit-component.tsx index 1efe345a..64966846 100644 --- a/fields/core/select/edit-component.tsx +++ b/fields/core/select/edit-component.tsx @@ -1,40 +1,207 @@ "use client"; -import { forwardRef } from "react"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select" - -const EditComponent = forwardRef((props: any, ref: React.Ref) => { +import { forwardRef, useMemo, useState, useCallback, useEffect } from "react"; +import "./edit-component.css"; +import Select, { components } from "react-select"; +import CreatableSelect from "react-select/creatable"; +import AsyncSelect from "react-select/async"; +import AsyncCreatableSelect from "react-select/async-creatable"; +import { ChevronDown, X } from "lucide-react"; +import { safeAccess, interpolate } from "@/lib/schema"; + +const Option = ({ children, ...props }: any) => { + const { data } = props; + return ( + +
+ {data.image && } + {children} +
+
+ ); +}; + +const SingleValue = ({ children, ...props }: any) => { + const { data } = props; + return ( + +
+ {data.image && } + {children} +
+
+ ); +}; + +const DropdownIndicator = (props: any) => ( + + + +); + +const ClearIndicator = (props: any) => ( + + + +); + +const MultiValueRemove = (props: any) => ( + + + +); + +type ParamValue = string | { value: 'input' } | { template: string }; + +type FetchConfig = { + url: string; + method?: string; + params?: Record; + headers?: Record; + results?: string; + value?: string; + label?: string; + minlength?: number; + image?: string; +}; + +const EditComponent = forwardRef((props: any, ref: any) => { const { value, field, onChange } = props; + console.log('select field', field); + const [isMounted, setIsMounted] = useState(false); + + useEffect(() => setIsMounted(true), []); + + const staticOptions = useMemo( + () => + !field.options?.fetch && field.options?.values + ? field.options.values.map((opt: any) => + typeof opt === "object" + ? { value: opt.value, label: opt.label } + : { value: opt, label: opt } + ) + : [], + [field.options?.values, field.options?.fetch] + ); + + const loadOptions = useCallback( + async (input: string) => { + const fetchConfig = field.options?.fetch as FetchConfig; + const minLength = fetchConfig?.minlength || 0; + if (!fetchConfig?.url || input.length < minLength) { + return []; + } + + try { + const searchParams = new URLSearchParams(); + + // Handle params + if (fetchConfig.params) { + Object.entries(fetchConfig.params).forEach(([key, paramValue]) => { + if (Array.isArray(paramValue)) { + paramValue.forEach(value => { + const interpolatedValue = interpolate(value, { input }); + searchParams.append(key, interpolatedValue); + }); + } else { + const value = interpolate(paramValue, { input }); + searchParams.append(key, value); + } + }); + } + + const queryString = searchParams.toString(); + const url = `${fetchConfig.url}${queryString ? `?${queryString}` : ''}`; + + const response = await fetch(url, { + method: fetchConfig.method || "GET", + headers: fetchConfig.headers || {}, + }); + if (!response.ok) throw new Error("Fetch failed"); + const data = await response.json(); + const results = fetchConfig.results ? safeAccess(data, fetchConfig.results) : data; + if (!Array.isArray(results)) return []; + return results.map((item: any) => ({ + value: fetchConfig.value ? + interpolate(fetchConfig.value, item) + : item.id, + label: fetchConfig.label ? + interpolate(fetchConfig.label, item) + : item.name, + image: fetchConfig.image ? + interpolate(fetchConfig.image, item) + : undefined, + })); + } catch (error) { + console.error("Error loading options:", error); + return []; + } + }, + [field.options?.fetch] + ); + + const [selectedOptions, setSelectedOptions] = useState(() => { + if (field.options?.multiple) { + const values = Array.isArray(value) ? value : []; + return values.map((val: any) => ({ value: val, label: val })); + } + if (!value) return null; + return { value: value, label: value }; + }); + + const handleChange = useCallback( + (newValue: any) => { + // When a new value is selected, ensure we display the value, not the label + const selectedValue = newValue + ? field.options?.multiple + ? newValue.map((item: any) => ({ value: item.value, label: item.value })) + : { value: newValue.value, label: newValue.value } + : field.options?.multiple ? [] : null; + + setSelectedOptions(selectedValue); + + const output = field.options?.multiple + ? newValue ? newValue.map((item: any) => item.value) : [] + : newValue ? newValue.value : null; + onChange(output); + }, + [onChange, field.options?.multiple, field] + ); + + if (!isMounted) return null; + + const SelectComponent = field.options?.fetch + ? field.options?.creatable + ? AsyncCreatableSelect + : AsyncSelect + : field.options?.creatable + ? CreatableSelect + : Select; + const fetchConfig = field.options?.fetch as FetchConfig; return ( - - ) + : { options: staticOptions })} + /> + ); }); export { EditComponent }; \ No newline at end of file diff --git a/fields/core/select/index.tsx b/fields/core/select/index.tsx index cd3eed58..2290dcc5 100644 --- a/fields/core/select/index.tsx +++ b/fields/core/select/index.tsx @@ -4,21 +4,32 @@ import { EditComponent } from "./edit-component"; const schema = (field: Field) => { let zodSchema; - - if (field.options?.values && Array.isArray(field.options.values)) { + + if (!field.options?.creatable && !field.options?.fetch && field.options?.values && Array.isArray(field.options.values)) { const normalizedValues = field.options.values.map((item) => { return typeof item === "object" ? item.value : item; }); - zodSchema = z.enum(normalizedValues as [string, ...string[]]); + zodSchema = z.enum(normalizedValues as [string, ...string[]]).optional(); } else { - zodSchema = z.string(); + zodSchema = z.coerce.string(); } + if (field.options?.multiple) { + zodSchema = z.preprocess( + (val) => { + if (val === "" || val === null) return undefined; + return val; + }, + z.array(zodSchema) + ); + } + + // TODO: optional array if (!field.required) zodSchema = zodSchema.optional(); return zodSchema; }; -export { schema, EditComponent}; \ No newline at end of file +export { schema, EditComponent }; \ No newline at end of file diff --git a/lib/githubCache.ts b/lib/githubCache.ts new file mode 100644 index 00000000..e3197e73 --- /dev/null +++ b/lib/githubCache.ts @@ -0,0 +1,214 @@ +import { db } from "@/db"; +import { eq, and, inArray } from "drizzle-orm"; +import { cachedEntriesTable } from "@/db/schema"; +import { createOctokitInstance } from "@/lib/utils/octokit"; +import path from "path"; + +type FileChange = { + path: string; + sha: string; +}; + +const updateCache = async ( + owner: string, + repo: string, + branch: string, + removedFiles: Array<{ path: string }>, + modifiedFiles: Array, + addedFiles: Array, + token: string +) => { + // Get all unique parent paths for all operations + const parentPaths = Array.from(new Set([ + ...removedFiles.map(f => path.dirname(f.path)), + ...modifiedFiles.map(f => path.dirname(f.path)), + ...addedFiles.map(f => path.dirname(f.path)) + ])); + + const entries = await db.query.cachedEntriesTable.findMany({ + where: and( + eq(cachedEntriesTable.owner, owner), + eq(cachedEntriesTable.repo, repo), + eq(cachedEntriesTable.branch, branch), + inArray(cachedEntriesTable.parentPath, parentPaths) + ) + }); + + const cachedParentPaths = parentPaths.length > 0 ? + Array.from(new Set(entries.map(e => e.parentPath))) : []; + + // Only process files in cached directories + const filesToRemove = removedFiles.filter(file => + cachedParentPaths.includes(path.dirname(file.path)) + ); + + const filesToFetch = [ + ...modifiedFiles.filter(file => cachedParentPaths.includes(path.dirname(file.path))), + ...addedFiles.filter(file => cachedParentPaths.includes(path.dirname(file.path))) + ]; + + // Delete removed files (only if in cached directories) + if (filesToRemove.length > 0) { + await db.delete(cachedEntriesTable).where( + and( + eq(cachedEntriesTable.owner, owner), + eq(cachedEntriesTable.repo, repo), + eq(cachedEntriesTable.branch, branch), + inArray(cachedEntriesTable.path, filesToRemove.map(f => f.path)) + ) + ); + } + + if (filesToFetch.length === 0) return; + + // Fetch content for all files in a single GraphQL query + const octokit = createOctokitInstance(token); + + const query = ` + query($owner: String!, $repo: String!, ${filesToFetch.map((_, i) => `$exp${i}: String!`).join(', ')}) { + repository(owner: $owner, name: $repo) { + ${filesToFetch.map((_, i) => ` + file${i}: object(expression: $exp${i}) { + ... on Blob { + text + oid + } + } + `).join('\n')} + } + } + `; + + const variables = { + owner, + repo, + ...Object.fromEntries( + filesToFetch.map((file, i) => [`exp${i}`, `${branch}:${file.path}`]) + ) + }; + + const response: any = await octokit.graphql(query, variables); + + // Process the results + const updates = filesToFetch.map((file, index) => { + const fileData = response.repository[`file${index}`]; + return { + path: file.path, + parentPath: path.dirname(file.path), + content: fileData.text, + sha: fileData.oid, + lastUpdated: Date.now() + }; + }); + + // Batch update the cache + for (const update of updates) { + const isModified = modifiedFiles.some(f => f.path === update.path); + + if (isModified) { + // Update existing entry + await db.update(cachedEntriesTable) + .set({ + content: update.content, + sha: update.sha, + lastUpdated: update.lastUpdated + }) + .where( + and( + eq(cachedEntriesTable.owner, owner), + eq(cachedEntriesTable.repo, repo), + eq(cachedEntriesTable.branch, branch), + eq(cachedEntriesTable.path, update.path) + ) + ); + } else { + // Insert new entry + await db.insert(cachedEntriesTable) + .values({ + owner, + repo, + branch, + path: update.path, + parentPath: update.parentPath, + name: path.basename(update.path), + type: 'blob', + content: update.content, + sha: update.sha, + lastUpdated: update.lastUpdated + }); + } + } +} + +const getCachedCollection = async ( + owner: string, + repo: string, + branch: string, + path: string, + token: string +) => { + let entries = await db.query.cachedEntriesTable.findMany({ + where: and( + eq(cachedEntriesTable.owner, owner), + eq(cachedEntriesTable.repo, repo), + eq(cachedEntriesTable.branch, branch), + eq(cachedEntriesTable.parentPath, path) + ) + }); + + if (entries.length === 0) { + // No cache hit, fetch from GitHub + const octokit = createOctokitInstance(token); + const query = ` + query ($owner: String!, $repo: String!, $expression: String!) { + repository(owner: $owner, name: $repo) { + object(expression: $expression) { + ... on Tree { + entries { + name + path + type + object { + ... on Blob { + text + oid + } + } + } + } + } + } + } + `; + const expression = `${branch}:${path}`; + const response: any = await octokit.graphql(query, { + owner: owner, + repo: repo, + expression + }); + // TODO: handle 401 / Bad credentials error + + let githubEntries = response.repository?.object?.entries || []; + + if (githubEntries.length > 0) { + entries = await db.insert(cachedEntriesTable) + .values(githubEntries.map((entry: any) => ({ + owner: owner, + repo: repo, + branch: branch, + parentPath: path, + name: entry.name, + path: entry.path, + type: entry.type, + content: entry.type === "blob" ? entry.object.text : null, + sha: entry.type === "blob" ? entry.object.oid : null, + lastUpdated: Date.now() + }))) + .returning(); + } + } + + return entries; +} + +export { updateCache, getCachedCollection }; \ No newline at end of file diff --git a/lib/schema.ts b/lib/schema.ts index 1cee4caa..c5bd0478 100644 --- a/lib/schema.ts +++ b/lib/schema.ts @@ -240,6 +240,14 @@ function safeAccess(obj: Record, path: string) { }, obj); } +// Interpolate a string with a data object (e.g. {field.name} -> data.field.name) +function interpolate(input: string, data: Record): string { + return input.replace(/(? { + const value = safeAccess(data, token); + return value !== undefined ? String(value) : ''; + }).replace(/\\([{}])/g, '$1'); +} + // Get a field by its path function getFieldByPath(schema: Field[], path: string): Field | undefined { const [first, ...rest] = path.split('.'); @@ -315,5 +323,6 @@ export { nestFieldArrays, unnestFieldArrays, generateZodSchema, - safeAccess + safeAccess, + interpolate }; \ No newline at end of file From da1676bb1e39635e7ee0cca40e18ceb176697863 Mon Sep 17 00:00:00 2001 From: Ronan Berder Date: Fri, 14 Mar 2025 11:10:10 +0800 Subject: [PATCH 25/95] Adding comments on API endpoints --- app/api/[owner]/[repo]/[branch]/branches/route.ts | 9 ++++++++- .../[repo]/[branch]/collections/[name]/route.ts | 9 +++++++++ .../[repo]/[branch]/entries/[path]/history/route.ts | 7 +++++++ .../[owner]/[repo]/[branch]/entries/[path]/route.ts | 9 +++++++++ .../[repo]/[branch]/files/[path]/rename/route.ts | 7 +++++++ .../[owner]/[repo]/[branch]/files/[path]/route.ts | 8 ++++++++ fields/core/reference/edit-component.tsx | 7 ++++--- fields/core/select/edit-component.tsx | 10 +++++----- lib/schema.ts | 13 ++++++++++--- 9 files changed, 67 insertions(+), 12 deletions(-) diff --git a/app/api/[owner]/[repo]/[branch]/branches/route.ts b/app/api/[owner]/[repo]/[branch]/branches/route.ts index 58ccebfa..fe9e2a46 100644 --- a/app/api/[owner]/[repo]/[branch]/branches/route.ts +++ b/app/api/[owner]/[repo]/[branch]/branches/route.ts @@ -2,6 +2,13 @@ import { createOctokitInstance } from "@/lib/utils/octokit"; import { getAuth } from "@/lib/auth"; import { getToken } from "@/lib/token"; +/** + * Creates a new branch in a GitHub repository. + * POST /api/[owner]/[repo]/[branch]/branches + * + * Requires authentication. + */ + export async function POST( request: Request, { params }: { params: { owner: string, repo: string, branch: string } } @@ -18,7 +25,7 @@ export async function POST( const octokit = createOctokitInstance(token); - // Get the SHA of the branch we"re creating the new branch from + // Get the SHA of the branch we're creating the new branch from const { data: refData } = await octokit.rest.git.getRef({ owner: params.owner, repo: params.repo, diff --git a/app/api/[owner]/[repo]/[branch]/collections/[name]/route.ts b/app/api/[owner]/[repo]/[branch]/collections/[name]/route.ts index e4831bbb..d2dbc7a1 100644 --- a/app/api/[owner]/[repo]/[branch]/collections/[name]/route.ts +++ b/app/api/[owner]/[repo]/[branch]/collections/[name]/route.ts @@ -10,6 +10,15 @@ import { getAuth } from "@/lib/auth"; import { getToken } from "@/lib/token"; import { getCachedCollection } from "@/lib/githubCache"; +/** + * Fetches and parses collection contents from GitHub repositories + * (for collection views and searches) + * GET /api/[owner]/[repo]/[branch]/collections/[name] + * + * Requires authentication. If type is set to "search", we filter the contents + * based on the query and fields parameters. + */ + export async function GET( request: NextRequest, { params }: { params: { owner: string, repo: string, branch: string, name: string } } diff --git a/app/api/[owner]/[repo]/[branch]/entries/[path]/history/route.ts b/app/api/[owner]/[repo]/[branch]/entries/[path]/history/route.ts index 70c35107..4f760e03 100644 --- a/app/api/[owner]/[repo]/[branch]/entries/[path]/history/route.ts +++ b/app/api/[owner]/[repo]/[branch]/entries/[path]/history/route.ts @@ -6,6 +6,13 @@ import { getFileExtension, normalizePath } from "@/lib/utils/file"; import { getAuth } from "@/lib/auth"; import { getToken } from "@/lib/token"; +/** + * Fetches the history of a file from GitHub repositories. + * GET /api/[owner]/[repo]/[branch]/entries/[path]/history + * + * Requires authentication. + */ + export async function GET( request: NextRequest, { params }: { params: { owner: string, repo: string, branch: string, path: string } } diff --git a/app/api/[owner]/[repo]/[branch]/entries/[path]/route.ts b/app/api/[owner]/[repo]/[branch]/entries/[path]/route.ts index 7bf08795..16998fe6 100644 --- a/app/api/[owner]/[repo]/[branch]/entries/[path]/route.ts +++ b/app/api/[owner]/[repo]/[branch]/entries/[path]/route.ts @@ -8,6 +8,15 @@ import { getFileExtension, normalizePath } from "@/lib/utils/file"; import { getAuth } from "@/lib/auth"; import { getToken } from "@/lib/token"; +/** + * Fetches and parses individual file contents from GitHub repositories + * (usually for editing). + * GET /api/[owner]/[repo]/[branch]/entries/[path]?name=[schemaName] + * + * Requires authentication. If no schema name is provided, we return the raw + * contents. + */ + export async function GET( request: NextRequest, { params }: { params: { owner: string, repo: string, branch: string, path: string } } diff --git a/app/api/[owner]/[repo]/[branch]/files/[path]/rename/route.ts b/app/api/[owner]/[repo]/[branch]/files/[path]/rename/route.ts index 13fab5d3..d15ff7d9 100644 --- a/app/api/[owner]/[repo]/[branch]/files/[path]/rename/route.ts +++ b/app/api/[owner]/[repo]/[branch]/files/[path]/rename/route.ts @@ -5,6 +5,13 @@ import { getFileExtension, normalizePath } from "@/lib/utils/file"; import { getAuth } from "@/lib/auth"; import { getToken } from "@/lib/token"; +/** + * Renames a file in a GitHub repository. + * POST /api/[owner]/[repo]/[branch]/files/[path]/rename + * + * Requires authentication. + */ + export async function POST( request: Request, { params }: { params: { owner: string, repo: string, branch: string, path: string } } diff --git a/app/api/[owner]/[repo]/[branch]/files/[path]/route.ts b/app/api/[owner]/[repo]/[branch]/files/[path]/route.ts index a241c3df..e3d17129 100644 --- a/app/api/[owner]/[repo]/[branch]/files/[path]/route.ts +++ b/app/api/[owner]/[repo]/[branch]/files/[path]/route.ts @@ -9,6 +9,14 @@ import { getFileExtension, getFileName, normalizePath, serializedTypes } from "@ import { getAuth } from "@/lib/auth"; import { getToken } from "@/lib/token"; +/** + * Create, update and delete individual files in a GitHub repository. + * POST /api/[owner]/[repo]/[branch]/files/[path] + * DELETE /api/[owner]/[repo]/[branch]/files/[path] + * + * Requires authentication. + */ + export async function POST( request: Request, { params }: { params: { owner: string, repo: string, branch: string, path: string } } diff --git a/fields/core/reference/edit-component.tsx b/fields/core/reference/edit-component.tsx index 47315841..579fd4a2 100644 --- a/fields/core/reference/edit-component.tsx +++ b/fields/core/reference/edit-component.tsx @@ -24,12 +24,13 @@ const EditComponent = forwardRef((props: any, ref: React.Ref) path: collection.path, type: "search", query: "{input}", - fields: field.options.search || "name" + fields: field.options?.search || "name" }, minlength: 2, results: "data.contents", - value: field.options.value || "{path}", - label: field.options.label || "{name}", + value: field.options?.value || "{path}", + label: field.options?.label || "{name}", + image: field.options?.image, headers: {}, }), [config.owner, config.repo, config.branch, field.options]); diff --git a/fields/core/select/edit-component.tsx b/fields/core/select/edit-component.tsx index 64966846..6e54b6d4 100644 --- a/fields/core/select/edit-component.tsx +++ b/fields/core/select/edit-component.tsx @@ -100,11 +100,11 @@ const EditComponent = forwardRef((props: any, ref: any) => { Object.entries(fetchConfig.params).forEach(([key, paramValue]) => { if (Array.isArray(paramValue)) { paramValue.forEach(value => { - const interpolatedValue = interpolate(value, { input }); + const interpolatedValue = interpolate(value, { input }, "fields"); searchParams.append(key, interpolatedValue); }); } else { - const value = interpolate(paramValue, { input }); + const value = interpolate(paramValue, { input }, "fields"); searchParams.append(key, value); } }); @@ -123,13 +123,13 @@ const EditComponent = forwardRef((props: any, ref: any) => { if (!Array.isArray(results)) return []; return results.map((item: any) => ({ value: fetchConfig.value ? - interpolate(fetchConfig.value, item) + interpolate(fetchConfig.value, item, "fields") : item.id, label: fetchConfig.label ? - interpolate(fetchConfig.label, item) + interpolate(fetchConfig.label, item, "fields") : item.name, image: fetchConfig.image ? - interpolate(fetchConfig.image, item) + interpolate(fetchConfig.image, item, "fields") : undefined, })); } catch (error) { diff --git a/lib/schema.ts b/lib/schema.ts index c5bd0478..b5cd212f 100644 --- a/lib/schema.ts +++ b/lib/schema.ts @@ -240,10 +240,17 @@ function safeAccess(obj: Record, path: string) { }, obj); } -// Interpolate a string with a data object (e.g. {field.name} -> data.field.name) -function interpolate(input: string, data: Record): string { +// Interpolate a string with a data object, with optional prefix fallback (e.g. "fields") +function interpolate(input: string, data: Record, prefixFallback?: string): string { return input.replace(/(? { - const value = safeAccess(data, token); + // First try direct access + let value = safeAccess(data, token); + + // If value is undefined and we have a prefix fallback, try with prefix + if (value === undefined && prefixFallback) { + value = safeAccess(data, `${prefixFallback}.${token}`); + } + return value !== undefined ? String(value) : ''; }).replace(/\\([{}])/g, '$1'); } From 7d011ef9df9520cda1085b2c602c90e86b80cde3 Mon Sep 17 00:00:00 2001 From: Ronan Berder Date: Fri, 14 Mar 2025 12:06:32 +0800 Subject: [PATCH 26/95] Updating cache on individual file operations --- .../[repo]/[branch]/files/[path]/route.ts | 27 +++++++ app/api/auth/email/[token]/route.ts | 7 ++ app/api/auth/github/route.ts | 7 ++ app/api/collaborators/[...slug]/route.ts | 7 ++ app/api/repos/[owner]/route.ts | 7 ++ app/api/tracker/route.ts | 8 +++ app/api/webhook/github/route.ts | 11 ++- lib/githubCache.ts | 70 ++++++++++++++++++- 8 files changed, 142 insertions(+), 2 deletions(-) diff --git a/app/api/[owner]/[repo]/[branch]/files/[path]/route.ts b/app/api/[owner]/[repo]/[branch]/files/[path]/route.ts index e3d17129..4f437496 100644 --- a/app/api/[owner]/[repo]/[branch]/files/[path]/route.ts +++ b/app/api/[owner]/[repo]/[branch]/files/[path]/route.ts @@ -8,6 +8,7 @@ import { getConfig, updateConfig } from "@/lib/utils/config"; import { getFileExtension, getFileName, normalizePath, serializedTypes } from "@/lib/utils/file"; import { getAuth } from "@/lib/auth"; import { getToken } from "@/lib/token"; +import { updateFileCache } from "@/lib/githubCache"; /** * Create, update and delete individual files in a GitHub repository. @@ -151,6 +152,21 @@ export async function POST( await updateConfig(newConfig); } + // Update cache for content and settings files, but not media + if (response?.data.content && response?.data.commit && data.type !== "media") { + await updateFileCache( + params.owner, + params.repo, + params.branch, + { + type: data.sha ? 'modify' : 'add', + path: response.data.content.path!, + sha: response.data.content.sha!, + content: Buffer.from(contentBase64, 'base64').toString('utf-8') + } + ); + } + return Response.json({ status: "success", message: savedPath !== normalizedPath @@ -289,6 +305,17 @@ export async function DELETE( message: `Delete ${params.path} (via Pages CMS)`, }); + // Update cache after successful deletion + await updateFileCache( + params.owner, + params.repo, + params.branch, + { + type: 'delete', + path: params.path + } + ); + return Response.json({ status: "success", message: `File "${normalizedPath}" deleted successfully.`, diff --git a/app/api/auth/email/[token]/route.ts b/app/api/auth/email/[token]/route.ts index 5d9b7207..79b2413f 100644 --- a/app/api/auth/email/[token]/route.ts +++ b/app/api/auth/email/[token]/route.ts @@ -9,6 +9,13 @@ import { eq } from "drizzle-orm"; import { db } from "@/db"; import { emailLoginTokenTable, userTable } from "@/db/schema"; +/** + * Handles email login authentication (for collaborators). + * GET /api/auth/email/[token] + * + * Requires email login token. + */ + export async function GET( request: Request, { params }: { params: { token: string } } diff --git a/app/api/auth/github/route.ts b/app/api/auth/github/route.ts index 2499c9b6..a823157c 100644 --- a/app/api/auth/github/route.ts +++ b/app/api/auth/github/route.ts @@ -8,6 +8,13 @@ import { db } from "@/db"; import { userTable, githubUserTokenTable } from "@/db/schema"; import { eq } from "drizzle-orm"; +/** + * Handles GitHub OAuth authentication. + * GET /api/auth/github + * + * Requires GitHub OAuth code and state. + */ + export async function GET(request: Request): Promise { const { session } = await getAuth(); if (session) return redirect("/"); diff --git a/app/api/collaborators/[...slug]/route.ts b/app/api/collaborators/[...slug]/route.ts index 9442129b..fb15c17a 100644 --- a/app/api/collaborators/[...slug]/route.ts +++ b/app/api/collaborators/[...slug]/route.ts @@ -6,6 +6,13 @@ import { db } from "@/db"; import { collaboratorTable } from "@/db/schema"; import { getInstallations, getInstallationRepos } from "@/lib/githubApp"; +/** + * Fetches collaborators for a repository. + * GET /api/collaborators/[owner]/[repo] + * + * Requires authentication. Only accessible to GitHub users (not collaborators). + */ + export async function GET( request: NextRequest, { params }: { params: { slug: string[] } } diff --git a/app/api/repos/[owner]/route.ts b/app/api/repos/[owner]/route.ts index e546af3a..73b554d3 100644 --- a/app/api/repos/[owner]/route.ts +++ b/app/api/repos/[owner]/route.ts @@ -9,6 +9,13 @@ import { collaboratorTable } from "@/db/schema"; export const dynamic = "force-dynamic"; +/** + * Fetches repositories for a user. + * GET /api/repos/[owner] + * + * Requires authentication. + */ + export async function GET( request: NextRequest, { params }: { params: { owner: string } } diff --git a/app/api/tracker/route.ts b/app/api/tracker/route.ts index ea60182f..0a0b31b0 100644 --- a/app/api/tracker/route.ts +++ b/app/api/tracker/route.ts @@ -4,6 +4,14 @@ import { db } from "@/db"; import { historyTable } from "@/db/schema"; import { z } from "zod"; +/** + * Fetches and updates user's history of visits to repositories. + * GET /api/tracker + * POST /api/tracker + * + * Requires authentication. + */ + export async function GET(request: Request) { try { const { user, session } = await getAuth(); diff --git a/app/api/webhook/github/route.ts b/app/api/webhook/github/route.ts index f14485c1..096a814f 100644 --- a/app/api/webhook/github/route.ts +++ b/app/api/webhook/github/route.ts @@ -7,6 +7,16 @@ import { normalizePath } from "@/lib/utils/file"; import { updateCache } from "@/lib/githubCache"; import { getInstallationToken } from "@/lib/token"; +/** + * Handles GitHub webhooks: + * - Maintains tables related to GitHub installations (e.g. collaborators, + * installation tokens) + * - Maintains GitHub cache + * POST /api/webhook/github + * + * Requires GitHub App webhook secret and signature. + */ + export async function POST(request: Request) { try { const signature = request.headers.get("X-Hub-Signature-256"); @@ -90,7 +100,6 @@ export async function POST(request: Request) { })) ); - // TODO: verify this is secure const installationToken = await getInstallationToken(owner, repo); await updateCache( diff --git a/lib/githubCache.ts b/lib/githubCache.ts index e3197e73..f32d2df5 100644 --- a/lib/githubCache.ts +++ b/lib/githubCache.ts @@ -9,6 +9,13 @@ type FileChange = { sha: string; }; +type FileOperation = { + type: 'add' | 'modify' | 'delete'; + path: string; + sha?: string; + content?: string; +}; + const updateCache = async ( owner: string, repo: string, @@ -211,4 +218,65 @@ const getCachedCollection = async ( return entries; } -export { updateCache, getCachedCollection }; \ No newline at end of file +const updateFileCache = async ( + owner: string, + repo: string, + branch: string, + operation: FileOperation +) => { + const parentPath = path.dirname(operation.path); + + switch (operation.type) { + case 'delete': + await db.delete(cachedEntriesTable).where( + and( + eq(cachedEntriesTable.owner, owner), + eq(cachedEntriesTable.repo, repo), + eq(cachedEntriesTable.branch, branch), + eq(cachedEntriesTable.path, operation.path) + ) + ); + break; + + case 'add': + case 'modify': + if (operation.content === undefined || !operation.sha) { + throw new Error('Content and SHA are required for add/modify operations'); + } + + const entry = { + owner, + repo, + branch, + path: operation.path, + parentPath, + name: path.basename(operation.path), + type: 'blob', + content: operation.content, + sha: operation.sha, + lastUpdated: Date.now() + }; + + if (operation.type === 'modify') { + await db.update(cachedEntriesTable) + .set({ + content: entry.content, + sha: entry.sha, + lastUpdated: entry.lastUpdated + }) + .where( + and( + eq(cachedEntriesTable.owner, owner), + eq(cachedEntriesTable.repo, repo), + eq(cachedEntriesTable.branch, branch), + eq(cachedEntriesTable.path, operation.path) + ) + ); + } else { + await db.insert(cachedEntriesTable).values(entry); + } + break; + } +}; + +export { updateCache, getCachedCollection, updateFileCache }; \ No newline at end of file From e65f8a319bcf681230b859fa03104f29d320d88f Mon Sep 17 00:00:00 2001 From: Ronan Berder Date: Fri, 14 Mar 2025 15:40:28 +0800 Subject: [PATCH 27/95] Cleaning up and testing migration --- app/api/[owner]/[repo]/[branch]/collections/[name]/route.ts | 2 -- fields/core/select/edit-component.tsx | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/app/api/[owner]/[repo]/[branch]/collections/[name]/route.ts b/app/api/[owner]/[repo]/[branch]/collections/[name]/route.ts index d2dbc7a1..7e311598 100644 --- a/app/api/[owner]/[repo]/[branch]/collections/[name]/route.ts +++ b/app/api/[owner]/[repo]/[branch]/collections/[name]/route.ts @@ -57,8 +57,6 @@ export async function GET( if (entries) { data = parseContents(entries, schema, config); - - console.log("data", JSON.stringify(data, null, 2)); // If this is a search request, filter the contents if (type === "search" && query) { diff --git a/fields/core/select/edit-component.tsx b/fields/core/select/edit-component.tsx index 6e54b6d4..1279fb6f 100644 --- a/fields/core/select/edit-component.tsx +++ b/fields/core/select/edit-component.tsx @@ -67,7 +67,7 @@ type FetchConfig = { const EditComponent = forwardRef((props: any, ref: any) => { const { value, field, onChange } = props; - console.log('select field', field); + const [isMounted, setIsMounted] = useState(false); useEffect(() => setIsMounted(true), []); From a27934efdf2914afbe2a424c619dd4a4cd666d4a Mon Sep 17 00:00:00 2001 From: Ronan Berder Date: Fri, 14 Mar 2025 16:26:36 +0800 Subject: [PATCH 28/95] Fixed validation for reference --- fields/core/reference/index.tsx | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/fields/core/reference/index.tsx b/fields/core/reference/index.tsx index 3dd612f7..762c0f1c 100644 --- a/fields/core/reference/index.tsx +++ b/fields/core/reference/index.tsx @@ -1,3 +1,15 @@ +import { z } from "zod"; +import { Field } from "@/types/field"; import { EditComponent } from "./edit-component"; -export { EditComponent }; \ No newline at end of file +const schema = (field: Field) => { + let zodSchema: z.ZodTypeAny = z.coerce.string(); + + if (field.options?.multiple) zodSchema = z.array(zodSchema); + + if (!field.required) zodSchema = zodSchema.optional(); + + return zodSchema; +}; + +export { schema, EditComponent }; \ No newline at end of file From 877078afd3f79e4557ff1fa8535ddddeddee799e Mon Sep 17 00:00:00 2001 From: Ronan Berder Date: Fri, 14 Mar 2025 17:28:15 +0800 Subject: [PATCH 29/95] Adding custm icons --- components/icon.tsx | 30 ++++++++++++++++++++++++++++++ components/repo/repo-nav.tsx | 13 +++++++++---- package-lock.json | 11 ++++++----- package.json | 2 +- 4 files changed, 46 insertions(+), 10 deletions(-) create mode 100644 components/icon.tsx diff --git a/components/icon.tsx b/components/icon.tsx new file mode 100644 index 00000000..83b10199 --- /dev/null +++ b/components/icon.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { DynamicIcon } from "lucide-react/dynamic"; +import type { LucideProps } from "lucide-react"; + +interface IconProps extends Omit { + name?: string; + fallback: React.ReactNode; +} + +export function Icon({ name, fallback, ...props }: IconProps) { + const [error, setError] = useState(!name); + + useEffect(() => { + setError(!name); + }, [name]); + + if (error) { + return <>{fallback}; + } + + return ( + setError(true)} + /> + ); +} \ No newline at end of file diff --git a/components/repo/repo-nav.tsx b/components/repo/repo-nav.tsx index 6486ed84..e5619542 100644 --- a/components/repo/repo-nav.tsx +++ b/components/repo/repo-nav.tsx @@ -7,6 +7,7 @@ import { useConfig } from "@/contexts/config-context"; import { useUser } from "@/contexts/user-context"; import { cn } from "@/lib/utils"; import { FileStack, FileText, Image as ImageIcon, Settings, Users } from "lucide-react"; +import { Icon } from "@/components/icon"; const RepoNavItem = ({ children, @@ -48,10 +49,14 @@ const RepoNav = ({ const configObject: any = config.object; const contentItems = configObject.content?.map((item: any) => ({ key: item.name, - icon: item.type === "collection" - ? - : - , + icon: + : + } + />, href: `/${config.owner}/${config.repo}/${encodeURIComponent(config.branch)}/${item.type}/${encodeURIComponent(item.name)}`, label: item.label || item.name, })) || []; diff --git a/package-lock.json b/package-lock.json index 026c3141..b414c3f5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -63,7 +63,7 @@ "install": "^0.13.0", "joplin-turndown-plugin-gfm": "^1.0.12", "lucia": "^3.2.0", - "lucide-react": "^0.378.0", + "lucide-react": "^0.479.0", "marked": "^12.0.2", "next": "^14.2.20", "next-themes": "^0.3.0", @@ -8591,11 +8591,12 @@ } }, "node_modules/lucide-react": { - "version": "0.378.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.378.0.tgz", - "integrity": "sha512-u6EPU8juLUk9ytRcyapkWI18epAv3RU+6+TC23ivjR0e+glWKBobFeSgRwOIJihzktILQuy6E0E80P2jVTDR5g==", + "version": "0.479.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.479.0.tgz", + "integrity": "sha512-aBhNnveRhorBOK7uA4gDjgaf+YlHMdMhQ/3cupk6exM10hWlEU+2QtWYOfhXhjAsmdb6LeKR+NZnow4UxRRiTQ==", + "license": "ISC", "peerDependencies": { - "react": "^16.5.1 || ^17.0.0 || ^18.0.0" + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "node_modules/markdown-it": { diff --git a/package.json b/package.json index 56984248..a9cdbcc6 100644 --- a/package.json +++ b/package.json @@ -70,7 +70,7 @@ "install": "^0.13.0", "joplin-turndown-plugin-gfm": "^1.0.12", "lucia": "^3.2.0", - "lucide-react": "^0.378.0", + "lucide-react": "^0.479.0", "marked": "^12.0.2", "next": "^14.2.20", "next-themes": "^0.3.0", From 9e118cb98629f196af1378e63f57b49737a0065c Mon Sep 17 00:00:00 2001 From: Ronan Berder Date: Fri, 14 Mar 2025 17:31:59 +0800 Subject: [PATCH 30/95] Adding contributing guidelines --- CONTRIBUTING.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..5c4fe106 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,8 @@ +# Contributing to Pages CMS + +- Submit pull requests (PRs) against the `development` branch, not `main`. +- Keep changes focused: one feature or fix per PR. +- Test locally before submitting. +- Follow existing code style. + +Thanks for helping! \ No newline at end of file From f50d5e8cb050ae2b694ea068f0c474e8413128a4 Mon Sep 17 00:00:00 2001 From: Ronan Berder Date: Fri, 14 Mar 2025 17:42:39 +0800 Subject: [PATCH 31/95] Fixing #177 --- fields/core/image/edit-component.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/fields/core/image/edit-component.tsx b/fields/core/image/edit-component.tsx index 4da17d2f..2b57be66 100644 --- a/fields/core/image/edit-component.tsx +++ b/fields/core/image/edit-component.tsx @@ -156,6 +156,7 @@ const EditComponent = forwardRef((props: any, ref: React.Ref) selected={images.map((image: any) => image.path)} onSubmit={handleSubmit} maxSelected={maxImages} + initialPath={field.options?.path} > + + + Remove + + + + + + + ); +}; + +const EditComponent = forwardRef((props: any, ref: React.Ref) => { + const { value, field, onChange } = props; + const { config } = useConfig(); + + const [files, setFiles] = useState(() => + value + ? Array.isArray(value) + ? value + : [value] + : [] + ); + + const isMultiple = field.options?.multiple !== undefined && field.options?.multiple !== false; + const uploadPath = field.options?.path || config?.object.media?.input; + + useEffect(() => { + if (isMultiple) { + onChange(files); + } else { + onChange(files[0] || undefined); + } + }, [files, isMultiple, onChange]); + + const handleUpload = useCallback((fileData: any) => { + if (!config) return; + + const path = fileData.path; + + if (isMultiple) { + setFiles(prev => [...prev, path]); + } else { + setFiles([path]); + } + }, [isMultiple, config]); + + const handleRemove = useCallback((pathToRemove: string) => { + console.log("handleRemove", pathToRemove); + setFiles(prev => prev.filter(path => path !== pathToRemove)); + }, []); + + const getFileIcon = (filePath: string) => { + const ext = getFileExtension(filePath); + for (const [category, extensions] of Object.entries(extensionCategories)) { + if (extensions.includes(ext)) { + switch (category) { + case 'image': + return ; + case 'document': + return ; + case 'video': + return ; + case 'audio': + return ; + case 'compressed': + return ; + case 'code': + return ; + case 'font': + return ; + case 'spreadsheet': + return ; + default: + return ; + } + } + } + return ; + }; + + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ); + + const handleDragEnd = (event: any) => { + const { active, over } = event; + + if (active.id !== over.id) { + setFiles((items) => { + const oldIndex = items.indexOf(active.id); + const newIndex = items.indexOf(over.id); + return arrayMove(items, oldIndex, newIndex); + }); + } + }; + + return ( +
+
+ + + {files.map((file) => ( + + ))} + + +
+ + {(!files.length || isMultiple) && ( + + + + )} +
+ ); +}); + +EditComponent.displayName = "EditComponent"; + +export { EditComponent }; \ No newline at end of file diff --git a/fields/core/file/index.tsx b/fields/core/file/index.tsx new file mode 100644 index 00000000..581a0d2c --- /dev/null +++ b/fields/core/file/index.tsx @@ -0,0 +1,48 @@ +import { z } from "zod"; +import { ViewComponent } from "./view-component"; +import { EditComponent } from "./edit-component"; +import { Field } from "@/types/field"; +import { swapPrefix } from "@/lib/githubImage"; + +const read = (value: any, field: Field, config: Record): string | string[] | null => { + if (!value) return null; + if (Array.isArray(value) && !value.length) return null; + + const prefixInput = field.options?.input ?? config.object.media?.input; + const prefixOutput = field.options?.output ?? config.object.media?.output; + + if (Array.isArray(value)) { + return value.map(v => read(v, field, config)) as string[]; + } + + return swapPrefix(value, prefixOutput, prefixInput, true); +}; + +const write = (value: any, field: Field, config: Record): string | string[] | null => { + if (!value) return null; + if (Array.isArray(value) && !value.length) return null; + + const prefixInput = field.options?.input ?? config.object.media?.input; + const prefixOutput = field.options?.output ?? config.object.media?.output; + + if (Array.isArray(value)) { + return value.map(v => write(v, field, config)) as string[]; + } + + return swapPrefix(value, prefixInput, prefixOutput); +}; + +const schema = (field: Field) => { + let zodSchema: z.ZodTypeAny = z.coerce.string(); + + if (field.options?.multiple) zodSchema = z.array(zodSchema); + + if (!field.required) zodSchema = zodSchema.optional(); + + return zodSchema; +}; + + +const supportsList = true; + +export { schema, ViewComponent, EditComponent, read, write, supportsList }; \ No newline at end of file diff --git a/fields/core/file/view-component.tsx b/fields/core/file/view-component.tsx new file mode 100644 index 00000000..0ac55249 --- /dev/null +++ b/fields/core/file/view-component.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { useMemo } from "react"; +import { Thumbnail } from "@/components/thumbnail"; +import { Field } from "@/types/field"; + +const ViewComponent = ({ + value, + field +}: { + value: string; + field: Field; +}) => { + const extraValuesCount = value && Array.isArray(value) ? value.length - 1 : 0; + + const path = useMemo(() => { + return !value + ? null + : Array.isArray(value) + ? value[0] + : value; + }, [value]); + + return ( + + + {extraValuesCount > 0 && ( + + +{extraValuesCount} + + )} + + ); +} + +export { ViewComponent }; \ No newline at end of file From bb4a7b1539569f108e5117c60c1fcfa981e556d5 Mon Sep 17 00:00:00 2001 From: Ronan Berder Date: Sat, 15 Mar 2025 20:14:22 +0800 Subject: [PATCH 35/95] Removing unused RHF helpers --- lib/schema.ts | 36 ------------------------------------ 1 file changed, 36 deletions(-) diff --git a/lib/schema.ts b/lib/schema.ts index 279c393f..7049197e 100644 --- a/lib/schema.ts +++ b/lib/schema.ts @@ -82,42 +82,6 @@ const getDefaultValue = (field: Record) => { } }; -// Used to work around RHF's inability to handle flat field arrays -// See https://react-hook-form.com/docs/usefieldarray#rules -const nestFieldArrays = (values: any, fields: Field[]): any => { - const result: any = {}; - - fields.forEach(field => { - if (field.list) { - result[field.name] = values[field.name]?.map((item: any) => field.type === 'object' ? { value: nestFieldArrays(item, field.fields || []) } : { value: item }) || []; - } else if (field.type === "object" && field.fields) { - result[field.name] = nestFieldArrays(values[field.name] || {}, field.fields); - } else { - result[field.name] = values[field.name]; - } - }); - - return result; -}; - -// Used to work around RHF's inability to handle flat field arrays -// See https://react-hook-form.com/docs/usefieldarray#rules -const unnestFieldArrays = (values: any, fields: Field[]): any => { - const result: any = {}; - - fields.forEach(field => { - if (field.list) { - result[field.name] = values[field.name]?.map((item: { value: any }) => field.type === 'object' ? unnestFieldArrays(item.value, field.fields || []) : item.value) || []; - } else if (field.type === "object" && field.fields) { - result[field.name] = unnestFieldArrays(values[field.name] || {}, field.fields); - } else { - result[field.name] = values[field.name]; - } - }); - - return result; -}; - // Generate a Zod schema for validation // nestArrays allows us to nest arrays to work around RHF's inability to handle flat field arrays // See https://react-hook-form.com/docs/usefieldarray#rules From f9f92d0cb5f66b2f0102fa491089196022800a19 Mon Sep 17 00:00:00 2001 From: Ronan Berder Date: Sat, 15 Mar 2025 20:14:54 +0800 Subject: [PATCH 36/95] Removing unused RHF helpers --- lib/schema.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/schema.ts b/lib/schema.ts index 7049197e..84f5f135 100644 --- a/lib/schema.ts +++ b/lib/schema.ts @@ -292,8 +292,6 @@ export { getPrimaryField, generateFilename, getDateFromFilename, - nestFieldArrays, - unnestFieldArrays, generateZodSchema, safeAccess, interpolate From 350b242d537fc8eedd264817972f32897394fc8a Mon Sep 17 00:00:00 2001 From: Ronan Berder Date: Sun, 16 Mar 2025 19:30:11 +0800 Subject: [PATCH 37/95] Adding support for multiple media --- .../[owner]/[repo]/[branch]/media/page.tsx | 1 - .../[repo]/[branch]/media/[path]/route.ts | 18 ++- components/entry/entry-form.tsx | 4 - components/media/media-dialog.tsx | 2 +- components/media/media-view.tsx | 33 ++-- components/repo/repo-nav.tsx | 34 ++--- fields/core/file/edit-component.tsx | 142 ++++++++++++------ fields/core/uuid/edit-component.tsx | 3 +- lib/config.ts | 50 +++--- lib/configSchema.ts | 71 +++++---- lib/schema.ts | 2 +- 11 files changed, 226 insertions(+), 134 deletions(-) diff --git a/app/(main)/[owner]/[repo]/[branch]/media/page.tsx b/app/(main)/[owner]/[repo]/[branch]/media/page.tsx index 917f5f08..135c81e5 100644 --- a/app/(main)/[owner]/[repo]/[branch]/media/page.tsx +++ b/app/(main)/[owner]/[repo]/[branch]/media/page.tsx @@ -3,7 +3,6 @@ import { useSearchParams } from "next/navigation"; import { useConfig } from "@/contexts/config-context"; import { MediaView} from "@/components/media/media-view"; -import { Message } from "@/components/message"; export default function Page() { const searchParams = useSearchParams(); diff --git a/app/api/[owner]/[repo]/[branch]/media/[path]/route.ts b/app/api/[owner]/[repo]/[branch]/media/[path]/route.ts index 881e3e9a..6493b262 100644 --- a/app/api/[owner]/[repo]/[branch]/media/[path]/route.ts +++ b/app/api/[owner]/[repo]/[branch]/media/[path]/route.ts @@ -28,10 +28,20 @@ export async function GET( const config = await getConfig(params.owner, params.repo, params.branch); if (!config) throw new Error(`Configuration not found for ${params.owner}/${params.repo}/${params.branch}.`); - if (!config.object.media) throw new Error(`No media configuration found for ${params.owner}/${params.repo}/${params.branch}.`); + const { searchParams } = new URL(request.url); + const mediaConfigName = searchParams.get('name'); + + const mediaConfig = mediaConfigName + ? config.object.media.find((item: any) => item.name === mediaConfigName) + : config.object.media[0]; + + if (!mediaConfig) { + if (mediaConfigName) throw new Error(`No media configuration named "${mediaConfigName}" found for ${params.owner}/${params.repo}/${params.branch}.`); + throw new Error(`No media configuration found for ${params.owner}/${params.repo}/${params.branch}.`); + } const normalizedPath = normalizePath(params.path); - if (!normalizedPath.startsWith(config.object.media.input)) throw new Error(`Invalid path "${params.path}" for media.`); + if (!normalizedPath.startsWith(mediaConfig.input)) throw new Error(`Invalid path "${params.path}" for media.`); const octokit = createOctokitInstance(token); const response = await octokit.rest.repos.getContent({ @@ -47,11 +57,11 @@ export async function GET( let results = response.data; - if (config.object.media.extensions && config.object.media.extensions.length > 0) { + if (mediaConfig.extensions && mediaConfig.extensions.length > 0) { results = response.data.filter((item) => { if (item.type === "dir") return true; const extension = getFileExtension(item.name); - return config.object.media.extensions.includes(extension); + return mediaConfig.extensions.includes(extension); }); } diff --git a/components/entry/entry-form.tsx b/components/entry/entry-form.tsx index 10346598..c1f6a9aa 100644 --- a/components/entry/entry-form.tsx +++ b/components/entry/entry-form.tsx @@ -320,9 +320,6 @@ const EntryForm = ({ }; return ( - <> - {/* WE display the state of the form's values */} -
{JSON.stringify(form.watch(), null, 2)}
@@ -367,7 +364,6 @@ const EntryForm = ({
- ); }; diff --git a/components/media/media-dialog.tsx b/components/media/media-dialog.tsx index d698e5b2..c793388e 100644 --- a/components/media/media-dialog.tsx +++ b/components/media/media-dialog.tsx @@ -27,8 +27,8 @@ const MediaDialog = forwardRef(({ initialPath, children }: { - selected: string[], onSubmit: (images: string[]) => void, + selected?: string[], maxSelected?: number, initialPath?: string, children?: React.ReactNode diff --git a/components/media/media-view.tsx b/components/media/media-view.tsx index a72f05f2..a43e6c7d 100644 --- a/components/media/media-view.tsx +++ b/components/media/media-view.tsx @@ -32,11 +32,13 @@ import { } from "lucide-react"; const MediaView = ({ + name, initialPath, initialSelected, maxSelected, onSelect, }: { + name?: string, initialPath?: string, initialSelected?: string[], maxSelected?: number, @@ -45,6 +47,11 @@ const MediaView = ({ const { config } = useConfig(); if (!config) throw new Error(`Configuration not found.`); + const mediaConfig = useMemo(() => { + if (!name) return config.object.media[0]; + return config.object.media.find((item: any) => item.name === name); + }, [name, config.object.media]); + const searchParams = useSearchParams(); const pathname = usePathname(); const router = useRouter(); @@ -54,12 +61,12 @@ const MediaView = ({ const [error, setError] = useState(null); const [selected, setSelected] = useState(initialSelected || []); const [path, setPath] = useState(() => { - if (!config.object.media) return ""; - if (!initialPath) return config.object.media.input; + if (!mediaConfig) return ""; + if (!initialPath) return mediaConfig.input; const normalizedInitialPath = normalizePath(initialPath); - if (normalizedInitialPath.startsWith(config.object.media.input)) return normalizedInitialPath; - console.warn(`"${initialPath}" is not within media root "${config.object.media.input}". Defaulting to media root.`); - return config.object.media.input; + if (normalizedInitialPath.startsWith(mediaConfig.input)) return normalizedInitialPath; + console.warn(`"${initialPath}" is not within media root "${mediaConfig.input}". Defaulting to media root.`); + return mediaConfig.input; }); const [data, setData] = useState[] | undefined>(undefined); const [isLoading, setIsLoading] = useState(true); @@ -135,13 +142,13 @@ const MediaView = ({ setPath(newPath); if (!onSelect) { const params = new URLSearchParams(Array.from(searchParams.entries())); - params.set("path", newPath || config?.object.media.input); + params.set("path", newPath || mediaConfig.input); router.push(`${pathname}?${params.toString()}`); } } const handleNavigateParent = () => { - if (!path || path === config.object.media.input) return; + if (!path || path === mediaConfig.input) return; handleNavigate(getParentPath(path)); } @@ -194,7 +201,7 @@ const MediaView = ({ ), []); - if (!config.object.media?.input) { + if (!mediaConfig.input) { return ( Create folder @@ -225,7 +232,7 @@ const MediaView = ({ description={error} className="absolute inset-0" > - + ); } @@ -235,8 +242,8 @@ const MediaView = ({
- -
diff --git a/components/repo/repo-nav.tsx b/components/repo/repo-nav.tsx index 6486ed84..8cd9801c 100644 --- a/components/repo/repo-nav.tsx +++ b/components/repo/repo-nav.tsx @@ -6,7 +6,7 @@ import { usePathname } from "next/navigation"; import { useConfig } from "@/contexts/config-context"; import { useUser } from "@/contexts/user-context"; import { cn } from "@/lib/utils"; -import { FileStack, FileText, Image as ImageIcon, Settings, Users } from "lucide-react"; +import { FileStack, FileText, FolderOpen, Settings, Users } from "lucide-react"; const RepoNavItem = ({ children, @@ -56,31 +56,31 @@ const RepoNav = ({ label: item.label || item.name, })) || []; - const mediaItem = configObject.media?.input && configObject.media?.output + const mediaItem = configObject.media ? { - key: "media", - icon: , - href: `/${config.owner}/${config.repo}/${encodeURIComponent(config.branch)}/media`, - label: "Media" - } + key: "media", + icon: , + href: `/${config.owner}/${config.repo}/${encodeURIComponent(config.branch)}/media`, + label: "Media" + } : null; const settingsItem = configObject.settings !== false ? { - key: "settings", - icon: , - href: `/${config.owner}/${config.repo}/${encodeURIComponent(config.branch)}/settings`, - label: "Settings" - } + key: "settings", + icon: , + href: `/${config.owner}/${config.repo}/${encodeURIComponent(config.branch)}/settings`, + label: "Settings" + } : null; const collaboratorsItem = configObject && Object.keys(configObject).length !== 0 && user?.githubId ? { - key: "collaborators", - icon: , - href: `/${config.owner}/${config.repo}/${encodeURIComponent(config.branch)}/collaborators`, - label: "Collaborators" - } + key: "collaborators", + icon: , + href: `/${config.owner}/${config.repo}/${encodeURIComponent(config.branch)}/collaborators`, + label: "Collaborators" + } : null; return [ diff --git a/fields/core/file/edit-component.tsx b/fields/core/file/edit-component.tsx index 7cd4aada..4fa46150 100644 --- a/fields/core/file/edit-component.tsx +++ b/fields/core/file/edit-component.tsx @@ -3,9 +3,10 @@ import { forwardRef, useCallback, useState, useEffect } from "react"; import { Button } from "@/components/ui/button"; import { MediaUpload } from "@/components/media/media-upload"; -import { Pencil, Trash2, Upload, File, FileText, FileVideo, FileImage, FileAudio, FileArchive, FileCode, FileType, FileSpreadsheet, GripVertical } from "lucide-react"; +import { MediaDialog } from "@/components/media/media-dialog"; +import { Trash2, Upload, File, FileText, FileVideo, FileImage, FileAudio, FileArchive, FileCode, FileType, FileSpreadsheet, GripVertical, Folder, FolderOpen } from "lucide-react"; import { useConfig } from "@/contexts/config-context"; -import { getFileExtension, getFileName, extensionCategories } from "@/lib/utils/file"; +import { getFileExtension, getFileName, extensionCategories, getParentPath } from "@/lib/utils/file"; import { Tooltip, TooltipContent, @@ -16,8 +17,12 @@ import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, us import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy } from '@dnd-kit/sortable'; import { useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; +import { v4 as uuidv4 } from 'uuid'; -const SortableItem = ({ file, onRemove, getFileIcon }: { +const generateId = () => uuidv4().slice(0, 8); + +const SortableItem = ({ id, file, onRemove, getFileIcon }: { + id: string; file: string; onRemove: (file: string) => void; getFileIcon: (file: string) => React.ReactNode; @@ -29,7 +34,7 @@ const SortableItem = ({ file, onRemove, getFileIcon }: { transform, transition, isDragging - } = useSortable({ id: file }); + } = useSortable({ id: id }); const style = { transform: CSS.Transform.toString(transform), @@ -41,14 +46,17 @@ const SortableItem = ({ file, onRemove, getFileIcon }: { return (
-
-
- +
+
+
- {getFileIcon(file)} -
-
{getFileName(file)}
-
{file}
+
+ {getFileIcon(file)} + {getFileName(file)} + {file}
@@ -80,40 +88,44 @@ const EditComponent = forwardRef((props: any, ref: React.Ref) const { value, field, onChange } = props; const { config } = useConfig(); - const [files, setFiles] = useState(() => + const [files, setFiles] = useState>(() => value ? Array.isArray(value) - ? value - : [value] + ? value.map(path => ({ id: generateId(), path })) + : [{ id: generateId(), path: value }] : [] ); const isMultiple = field.options?.multiple !== undefined && field.options?.multiple !== false; - const uploadPath = field.options?.path || config?.object.media?.input; + const rootPath = field.options?.path || field.options?.input || config?.object.media?.input; + const remainingSlots = field.options?.multiple + ? field.options.multiple.max + ? field.options.multiple.max - files.length + : Infinity + : 1 - files.length; useEffect(() => { if (isMultiple) { - onChange(files); + onChange(files.map(f => f.path)); } else { - onChange(files[0] || undefined); + onChange(files[0]?.path || undefined); } }, [files, isMultiple, onChange]); const handleUpload = useCallback((fileData: any) => { if (!config) return; - const path = fileData.path; + const newFile = { id: generateId(), path: fileData.path }; if (isMultiple) { - setFiles(prev => [...prev, path]); + setFiles(prev => [...prev, newFile]); } else { - setFiles([path]); + setFiles([newFile]); } }, [isMultiple, config]); - const handleRemove = useCallback((pathToRemove: string) => { - console.log("handleRemove", pathToRemove); - setFiles(prev => prev.filter(path => path !== pathToRemove)); + const handleRemove = useCallback((fileId: string) => { + setFiles(prev => prev.filter(file => file.id !== fileId)); }, []); const getFileIcon = (filePath: string) => { @@ -122,27 +134,27 @@ const EditComponent = forwardRef((props: any, ref: React.Ref) if (extensions.includes(ext)) { switch (category) { case 'image': - return ; + return ; case 'document': - return ; + return ; case 'video': - return ; + return ; case 'audio': - return ; + return ; case 'compressed': - return ; + return ; case 'code': - return ; + return ; case 'font': - return ; + return ; case 'spreadsheet': - return ; + return ; default: - return ; + return ; } } } - return ; + return ; }; const sensors = useSensors( @@ -157,13 +169,30 @@ const EditComponent = forwardRef((props: any, ref: React.Ref) if (active.id !== over.id) { setFiles((items) => { - const oldIndex = items.indexOf(active.id); - const newIndex = items.indexOf(over.id); + const oldIndex = items.findIndex(item => item.id === active.id); + const newIndex = items.findIndex(item => item.id === over.id); return arrayMove(items, oldIndex, newIndex); }); } }; + const handleSelected = useCallback((newPaths: string[]) => { + if (newPaths.length === 0) { + setFiles([]); + } else { + const newFiles = newPaths.map(path => ({ + id: generateId(), + path + })); + + if (isMultiple) { + setFiles(prev => [...prev, ...newFiles]); + } else { + setFiles([newFiles[0]]); + } + } + }, [isMultiple]); + return (
@@ -173,14 +202,15 @@ const EditComponent = forwardRef((props: any, ref: React.Ref) onDragEnd={handleDragEnd} > f.id)} strategy={verticalListSortingStrategy} > {files.map((file) => ( handleRemove(file.id)} getFileIcon={getFileIcon} /> ))} @@ -188,13 +218,33 @@ const EditComponent = forwardRef((props: any, ref: React.Ref)
- {(!files.length || isMultiple) && ( - - - + {remainingSlots > 0 && ( +
+ + + + + + + + + + + + Select from media + + + +
)}
); diff --git a/fields/core/uuid/edit-component.tsx b/fields/core/uuid/edit-component.tsx index 1446c247..e2bea7f2 100644 --- a/fields/core/uuid/edit-component.tsx +++ b/fields/core/uuid/edit-component.tsx @@ -33,7 +33,8 @@ const EditComponent = forwardRef((props: any, ref: React.Ref) + } diff --git a/components/media/media-upload.tsx b/components/media/media-upload.tsx index d2829d88..6143991f 100644 --- a/components/media/media-upload.tsx +++ b/components/media/media-upload.tsx @@ -4,21 +4,28 @@ import { useRef, isValidElement, cloneElement } from "react"; import { useConfig } from "@/contexts/config-context"; import { joinPathSegments } from "@/lib/utils/file"; import { toast } from "sonner"; +import { getSchemaByName } from "@/lib/schema"; const MediaUpload = ({ children, path, onUpload, + name }: { children: React.ReactElement<{ onClick: () => void }>; path?: string; onUpload?: (path: string) => void; + name?: string; }) => { const fileInputRef = useRef(null); const { config } = useConfig(); if (!config) throw new Error(`Configuration not found.`); + const configMedia = name + ? getSchemaByName(config.object, name, "media") + : config.object.media[0]; + const handleTriggerClick = () => { if (fileInputRef.current) { (fileInputRef.current as HTMLInputElement).click(); @@ -53,6 +60,7 @@ const MediaUpload = ({ headers: { "Content-Type": "application/json" }, body: JSON.stringify({ type: "media", + name: configMedia.name, content, }), }); @@ -88,7 +96,7 @@ const MediaUpload = ({ ? cloneElement(children, { onClick: handleTriggerClick }) : null; - const mediaExtensions = config?.object.media?.extensions; + const mediaExtensions = configMedia.extensions; const accept = mediaExtensions && mediaExtensions.length > 0 ? mediaExtensions.map((extension: string) => `.${extension}`).join(",") : undefined; diff --git a/components/media/media-view.tsx b/components/media/media-view.tsx index a43e6c7d..5bd47c43 100644 --- a/components/media/media-view.tsx +++ b/components/media/media-view.tsx @@ -78,7 +78,7 @@ const MediaView = ({ setError(null); try { - const response = await fetch(`/api/${config.owner}/${config.repo}/${encodeURIComponent(config.branch)}/media/${encodeURIComponent(path)}`); + const response = await fetch(`/api/${config.owner}/${config.repo}/${encodeURIComponent(config.branch)}/media/${encodeURIComponent(path)}${name && `?name=${encodeURIComponent(name)}`}`); if (!response.ok) throw new Error(`Failed to fetch media: ${response.status} ${response.statusText}`); const data: any = await response.json(); @@ -222,7 +222,7 @@ const MediaView = ({ description={`The media folder "${mediaConfig.input}" has not been created yet.`} className="absolute inset-0" > - Create folder + Create folder ); } else { @@ -247,12 +247,12 @@ const MediaView = ({
- + - +
- + diff --git a/components/repo/repo-nav.tsx b/components/repo/repo-nav.tsx index 8cd9801c..2e37257e 100644 --- a/components/repo/repo-nav.tsx +++ b/components/repo/repo-nav.tsx @@ -56,14 +56,12 @@ const RepoNav = ({ label: item.label || item.name, })) || []; - const mediaItem = configObject.media - ? { - key: "media", - icon: , - href: `/${config.owner}/${config.repo}/${encodeURIComponent(config.branch)}/media`, - label: "Media" - } - : null; + const mediaItems = configObject.media?.map((item: any) => ({ + key: item.name || "media", + icon: , + href: `/${config.owner}/${config.repo}/${encodeURIComponent(config.branch)}/media/${item.name}`, + label: item.label || item.name || "Media" + })) || []; const settingsItem = configObject.settings !== false ? { @@ -85,7 +83,7 @@ const RepoNav = ({ return [ ...contentItems, - mediaItem, + ...mediaItems, settingsItem, collaboratorsItem ].filter(Boolean); diff --git a/fields/core/file/edit-component.tsx b/fields/core/file/edit-component.tsx index 4fa46150..b443819a 100644 --- a/fields/core/file/edit-component.tsx +++ b/fields/core/file/edit-component.tsx @@ -18,6 +18,7 @@ import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSo import { useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { v4 as uuidv4 } from 'uuid'; +import { getSchemaByName } from "@/lib/schema"; const generateId = () => uuidv4().slice(0, 8); @@ -97,7 +98,10 @@ const EditComponent = forwardRef((props: any, ref: React.Ref) ); const isMultiple = field.options?.multiple !== undefined && field.options?.multiple !== false; - const rootPath = field.options?.path || field.options?.input || config?.object.media?.input; + const mediaConfig = field.options?.media + ? getSchemaByName(config?.object, field.options?.media, "media") + : config?.object.media[0]; + const rootPath = field.options?.path || mediaConfig.input; const remainingSlots = field.options?.multiple ? field.options.multiple.max ? field.options.multiple.max - files.length @@ -220,15 +224,16 @@ const EditComponent = forwardRef((props: any, ref: React.Ref) {remainingSlots > 0 && (
- + - + { // Ensure media.input is a relative path (and add name and label) const relativePath = configObjectCopy.media.replace(/^\/|\/$/g, ""); configObjectCopy.media = [{ - name: "media", + name: "default", label: "Media", input: relativePath, output: `/${relativePath}`, @@ -49,14 +49,14 @@ const normalizeConfig = (configObject: any) => { } else if (typeof configObjectCopy.media === "object" && !Array.isArray(configObjectCopy.media)) { // Ensure it's an array of media configurations (and add name and label) configObjectCopy.media = [{ - name: "media", + name: "default", label: "Media", ...configObjectCopy.media }]; } // We normalize each media configuration - configObjectCopy.media = configObjectCopy.media.map((mediaConfig: any) => { + configObjectCopy.media = configObjectCopy.media.map((mediaConfig: any) => { if (mediaConfig.input != null && typeof mediaConfig.input === "string") { // Make sure input is relative mediaConfig.input = mediaConfig.input.replace(/^\/|\/$/g, ""); diff --git a/lib/configSchema.ts b/lib/configSchema.ts index bfe6ccb8..6e2c1227 100644 --- a/lib/configSchema.ts +++ b/lib/configSchema.ts @@ -6,7 +6,7 @@ import { z } from "zod"; -// Media configuration object schema +// Media configuration object schema (for single object) const MediaConfigObject = z.object({ input: z.string({ message: "'input' is required." @@ -31,17 +31,16 @@ const MediaConfigObject = z.object({ }), { message: "'categories' must be an array of strings." }).optional(), + name: z.string().optional(), + label: z.string().optional(), }).strict(); -// Named media configuration schema +// Named media configuration schema (for array entries) const NamedMediaConfig = MediaConfigObject.extend({ name: z.string({ required_error: "'name' is required for media configurations in array format.", invalid_type_error: "'name' must be a string.", }), - label: z.string({ - message: "'label' must be a string." - }).optional(), }); // Media schema diff --git a/lib/githubCache.ts b/lib/githubCache.ts index e477e476..80fd9547 100644 --- a/lib/githubCache.ts +++ b/lib/githubCache.ts @@ -235,6 +235,7 @@ const updateFileCache = async ( switch (operation.type) { case 'delete': + // We always remove entries from the cache when the file is deleted await db.delete(cachedEntriesTable).where( and( eq(cachedEntriesTable.owner, owner), @@ -265,6 +266,7 @@ const updateFileCache = async ( }; if (operation.type === 'modify') { + // We always update entries in the cache if they are already present await db.update(cachedEntriesTable) .set({ content: entry.content, @@ -280,7 +282,22 @@ const updateFileCache = async ( ) ); } else { - await db.insert(cachedEntriesTable).values(entry); + // We only cache collections (and maybe media folders later on). When a + // file is added, we only add it to the cache if there are already existing + // entries with the same parent path (meaning getCachedCollection (or + // getCachedMedia once we have media) has already been called on this path). + const sibling = await db.query.cachedEntriesTable.findFirst({ + where: and( + eq(cachedEntriesTable.owner, owner), + eq(cachedEntriesTable.repo, repo), + eq(cachedEntriesTable.branch, branch), + eq(cachedEntriesTable.parentPath, parentPath) + ) + }); + + if (sibling) { + await db.insert(cachedEntriesTable).values(entry); + } } break; } diff --git a/lib/schema.ts b/lib/schema.ts index ea277ce3..fa9d56a1 100644 --- a/lib/schema.ts +++ b/lib/schema.ts @@ -184,11 +184,13 @@ const getSchemaByPath = (config: Record, path: string) => { return schema ? JSON.parse(JSON.stringify(schema)) : null; }; -// Retrieve the matching schema for a type -const getSchemaByName = (config: Record | null | undefined, name: string) => { +// Retrieve the matching schema for a media or content entry +const getSchemaByName = (config: Record | null | undefined, name: string, type: string = "content") => { if (!config || !config.content || !name) return null; - const schema = config.content.find((item: Record) => item.name === name); + const schema = (type === "media") + ? config.media.find((item: Record) => item.name === name) + : config.content.find((item: Record) => item.name === name); // We deep clone the object to avoid mutating config if schema is modified. return schema ? JSON.parse(JSON.stringify(schema)) : null; From 978826053862cdea60f9c7d94f916b4e97fb02e3 Mon Sep 17 00:00:00 2001 From: Ronan Berder Date: Mon, 17 Mar 2025 11:51:02 +0800 Subject: [PATCH 39/95] Adding back media page --- .../[repo]/[branch]/media/[name]/page.tsx | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 app/(main)/[owner]/[repo]/[branch]/media/[name]/page.tsx diff --git a/app/(main)/[owner]/[repo]/[branch]/media/[name]/page.tsx b/app/(main)/[owner]/[repo]/[branch]/media/[name]/page.tsx new file mode 100644 index 00000000..b1e606c4 --- /dev/null +++ b/app/(main)/[owner]/[repo]/[branch]/media/[name]/page.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { useSearchParams } from "next/navigation"; +import { useConfig } from "@/contexts/config-context"; +import { MediaView} from "@/components/media/media-view"; + +export default function Page({ + params +}: { + params: { + name: string; + } +}) { + const searchParams = useSearchParams(); + const path = searchParams.get("path") || ""; + + const { config } = useConfig(); + if (!config) throw new Error(`Configuration not found.`); + + return ( +
+
+

Media

+
+
+ +
+
+ ); +} \ No newline at end of file From 9af99eed31bc6e5dfc6889d82c0e88002e145433 Mon Sep 17 00:00:00 2001 From: Ronan Berder Date: Mon, 17 Mar 2025 22:04:56 +0800 Subject: [PATCH 40/95] Refined cache strategy --- .../[branch]/files/[path]/rename/route.ts | 18 + .../[repo]/[branch]/files/[path]/route.ts | 15 +- .../[repo]/[branch]/media/[path]/route.ts | 98 ---- app/api/webhook/github/route.ts | 12 +- components/media/media-view.tsx | 6 +- components/thumbnail.tsx | 6 +- db/schema.ts | 5 + fields/core/file/view-component.tsx | 18 +- fields/core/image/edit-component.tsx | 13 +- fields/core/image/view-component.tsx | 6 +- fields/core/rich-text/edit-component.tsx | 9 +- lib/githubCache.ts | 456 ++++++++++++++---- lib/githubImage.ts | 11 +- 13 files changed, 451 insertions(+), 222 deletions(-) delete mode 100644 app/api/[owner]/[repo]/[branch]/media/[path]/route.ts diff --git a/app/api/[owner]/[repo]/[branch]/files/[path]/rename/route.ts b/app/api/[owner]/[repo]/[branch]/files/[path]/rename/route.ts index 5230ffc0..8ef6dfcd 100644 --- a/app/api/[owner]/[repo]/[branch]/files/[path]/rename/route.ts +++ b/app/api/[owner]/[repo]/[branch]/files/[path]/rename/route.ts @@ -4,6 +4,7 @@ import { getConfig } from "@/lib/utils/config"; import { getFileExtension, normalizePath } from "@/lib/utils/file"; import { getAuth } from "@/lib/auth"; import { getToken } from "@/lib/token"; +import { updateFileCache } from "@/lib/githubCache"; /** * Renames a file in a GitHub repository. @@ -78,6 +79,23 @@ export async function POST( const response = await githubRenameFile(token, params.owner, params.repo, params.branch, normalizedPath, normalizedNewPath); + // Update the cache with the rename operation + await updateFileCache( + data.type === 'content' ? 'collection' : 'media', + params.owner, + params.repo, + params.branch, + { + type: 'rename', + path: normalizedPath, + newPath: normalizedNewPath, + commit: { + sha: response.sha, + timestamp: Date.now() + } + } + ); + // TODO: remove success message in backend return Response.json({ status: "success", diff --git a/app/api/[owner]/[repo]/[branch]/files/[path]/route.ts b/app/api/[owner]/[repo]/[branch]/files/[path]/route.ts index 4a4ccde3..cf08f47c 100644 --- a/app/api/[owner]/[repo]/[branch]/files/[path]/route.ts +++ b/app/api/[owner]/[repo]/[branch]/files/[path]/route.ts @@ -159,8 +159,8 @@ export async function POST( if (response?.data.content && response?.data.commit && data.type !== "media") { // If the file is successfully saved, update the cache - // TODO: add media caching (requires split between listing and displaying media) await updateFileCache( + data.type, params.owner, params.repo, params.branch, @@ -168,7 +168,13 @@ export async function POST( type: data.sha ? 'modify' : 'add', path: response.data.content.path!, sha: response.data.content.sha!, - content: Buffer.from(contentBase64, 'base64').toString('utf-8') + content: Buffer.from(contentBase64, 'base64').toString('utf-8'), + size: response.data.content.size, + downloadUrl: response.data.content.download_url, + commit: { + sha: response.data.commit.sha!, + timestamp: new Date(response.data.commit.committer?.date ?? new Date().toISOString()).getTime() + } } ); } @@ -211,7 +217,7 @@ const githubSaveFile = async ( const octokit = createOctokitInstance(token); try { - // First attempt - try with original path + // First attempt: try with original path const response = await octokit.rest.repos.createOrUpdateFileContents({ owner, repo, @@ -268,7 +274,7 @@ const githubSaveFile = async ( } } catch (error: any) { if (i === 3 || error.status !== 422) throw error; - // Continue to next attempt if 422 + // Continue to next attempt if 422 (file already exists) } } } @@ -342,6 +348,7 @@ export async function DELETE( // Update cache after successful deletion await updateFileCache( + 'collection', params.owner, params.repo, params.branch, diff --git a/app/api/[owner]/[repo]/[branch]/media/[path]/route.ts b/app/api/[owner]/[repo]/[branch]/media/[path]/route.ts deleted file mode 100644 index 9e05b7e6..00000000 --- a/app/api/[owner]/[repo]/[branch]/media/[path]/route.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { createOctokitInstance } from "@/lib/utils/octokit"; -import { getConfig } from "@/lib/utils/config"; -import { getFileExtension, normalizePath } from "@/lib/utils/file"; -import { getAuth } from "@/lib/auth"; -import { getToken } from "@/lib/token"; - -// Add docs - -/** - * Get the list of media files in a directory. - * - * GET /api/[owner]/[repo]/[branch]/media/[path] - * - * Requires authentication. - */ - -export async function GET( - request: Request, - { params }: { params: { owner: string, repo: string, branch: string, path: string } } -) { - try { - const { user, session } = await getAuth(); - if (!session) return new Response(null, { status: 401 }); - - const token = await getToken(user, params.owner, params.repo); - if (!token) throw new Error("Token not found"); - - const config = await getConfig(params.owner, params.repo, params.branch); - if (!config) throw new Error(`Configuration not found for ${params.owner}/${params.repo}/${params.branch}.`); - - const { searchParams } = new URL(request.url); - const mediaConfigName = searchParams.get('name'); - - const mediaConfig = mediaConfigName - ? config.object.media.find((item: any) => item.name === mediaConfigName) - : config.object.media[0]; - - if (!mediaConfig) { - if (mediaConfigName) throw new Error(`No media configuration named "${mediaConfigName}" found for ${params.owner}/${params.repo}/${params.branch}.`); - throw new Error(`No media configuration found for ${params.owner}/${params.repo}/${params.branch}.`); - } - - const normalizedPath = normalizePath(params.path); - if (!normalizedPath.startsWith(mediaConfig.input)) throw new Error(`Invalid path "${params.path}" for media.`); - - const octokit = createOctokitInstance(token); - const response = await octokit.rest.repos.getContent({ - owner: params.owner, - repo: params.repo, - path: normalizedPath, - ref: params.branch, - }); - - if (!Array.isArray(response.data)) { - throw new Error("Expected a directory but found a file."); - } - - let results = response.data; - - if (mediaConfig.extensions && mediaConfig.extensions.length > 0) { - results = results.filter((item) => { - if (item.type === "dir") return true; - const extension = getFileExtension(item.name); - return mediaConfig.extensions.includes(extension); - }); - } - - results.sort((a: any, b: any) => { - if (a.type === b.type) { - return a.name.localeCompare(b.name); - } else { - return a.type === "dir" ? -1 : 1; - } - }); - - return Response.json({ - status: "success", - data: results.map((item: any) => { - return { - type: item.type, - sha: item.sha, - name: item.name, - path: item.path, - extension: item.type === "dir" ? undefined : getFileExtension(item.name), - size: item.size, - url: item.download_url - }; - }), - }); - } catch (error: any) { - console.error(error); - // TODO: better handling of GitHub errors - return Response.json({ - status: "error", - message: error.status === 404 ? "Not found" : error.message, - }); - } -} \ No newline at end of file diff --git a/app/api/webhook/github/route.ts b/app/api/webhook/github/route.ts index bc8e7294..00ec489c 100644 --- a/app/api/webhook/github/route.ts +++ b/app/api/webhook/github/route.ts @@ -4,7 +4,7 @@ import { db } from "@/db"; import { collaboratorTable, githubInstallationTokenTable } from "@/db/schema"; import { eq, inArray } from "drizzle-orm"; import { normalizePath } from "@/lib/utils/file"; -import { updateCache } from "@/lib/githubCache"; +import { updateMultipleFilesCache } from "@/lib/githubCache"; import { getInstallationToken } from "@/lib/token"; /** @@ -103,14 +103,20 @@ export async function POST(request: Request) { const installationToken = await getInstallationToken(owner, repo); - await updateCache( + const commit = { + sha: data.head_commit.id, + timestamp: new Date(data.head_commit.timestamp).getTime() + }; + + await updateMultipleFilesCache( owner, repo, branch, removedFiles, modifiedFiles, addedFiles, - installationToken + installationToken, + commit ); break; } diff --git a/components/media/media-view.tsx b/components/media/media-view.tsx index 5bd47c43..c42bf6d7 100644 --- a/components/media/media-view.tsx +++ b/components/media/media-view.tsx @@ -38,7 +38,7 @@ const MediaView = ({ maxSelected, onSelect, }: { - name?: string, + name: string, initialPath?: string, initialSelected?: string[], maxSelected?: number, @@ -78,7 +78,7 @@ const MediaView = ({ setError(null); try { - const response = await fetch(`/api/${config.owner}/${config.repo}/${encodeURIComponent(config.branch)}/media/${encodeURIComponent(path)}${name && `?name=${encodeURIComponent(name)}`}`); + const response = await fetch(`/api/${config.owner}/${config.repo}/${encodeURIComponent(config.branch)}/media/${encodeURIComponent(name)}/${encodeURIComponent(path)}`); if (!response.ok) throw new Error(`Failed to fetch media: ${response.status} ${response.statusText}`); const data: any = await response.json(); @@ -292,7 +292,7 @@ const MediaView = ({ }
{extensionCategories.image.includes(item.extension) - ? + ? :
diff --git a/components/thumbnail.tsx b/components/thumbnail.tsx index a88e64fd..5f8562e2 100644 --- a/components/thumbnail.tsx +++ b/components/thumbnail.tsx @@ -9,9 +9,11 @@ import { Loader } from "@/components/loader"; import { Ban, ImageOff } from "lucide-react"; export function Thumbnail({ + name, path, className }: { + name: string, path: string | null; className?: string; }) { @@ -29,7 +31,7 @@ export function Thumbnail({ setError(null); setRawUrl(null); try { - const url = await getRawUrl(owner, repo, branch, path, isPrivate); + const url = await getRawUrl(owner, repo, branch, name, path, isPrivate); setRawUrl(url); } catch (error: any) { setError(error.message); @@ -40,8 +42,6 @@ export function Thumbnail({ fetchRawUrl(); }, [path, owner, repo, branch, isPrivate]); - // if (!path) return null; - return (
{ const extraValuesCount = value && Array.isArray(value) ? value.length - 1 : 0; - const path = useMemo(() => { + const filename = useMemo(() => { return !value ? null : Array.isArray(value) - ? value[0] - : value; + ? getFileName(value[0]) + : getFileName(value); }, [value]); + if (!filename) return null; + return ( - + + + + {filename} + + {extraValuesCount > 0 && ( +{extraValuesCount} diff --git a/fields/core/image/edit-component.tsx b/fields/core/image/edit-component.tsx index 2b57be66..da1b8f6d 100644 --- a/fields/core/image/edit-component.tsx +++ b/fields/core/image/edit-component.tsx @@ -22,6 +22,7 @@ import { } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { Pencil, Trash2, ImagePlus } from "lucide-react"; +import { useConfig } from "@/contexts/config-context"; // TODO: disable sortable for single image // TODO: make component resilient to illegal parentPath @@ -29,10 +30,12 @@ import { Pencil, Trash2, ImagePlus } from "lucide-react"; const SortableItem = ({ id, path, + media, children }: { id: string, path: string, + media: string, children: React.ReactNode }) => { const { @@ -50,11 +53,13 @@ const SortableItem = ({ zIndex: isDragging ? 1000 : "auto" }; + // const name = field.options?; + return (
- +
{children}
@@ -71,6 +76,10 @@ const EditComponent = forwardRef((props: any, ref: React.Ref) : [{ id: "image-0", path: value }] : [] ); + + const { config } = useConfig(); + + const mediaName = field.options?.media || config?.object.media[0].name; const maxImages = useMemo(() => { if (field.list && typeof field.list.max === 'number') { @@ -133,7 +142,7 @@ const EditComponent = forwardRef((props: any, ref: React.Ref)
{images.map((item, index) => - +
-
- {isLoading - ? loadingSkeleton - : filteredData && filteredData.length > 0 - ?
    - {filteredData.map((item, index) => -
  • - {item.type === "dir" - ? - :
  • - )} -
- :

- - This folder is empty. -

- } -
+ + } + + + )} + + :

+ + This folder is empty. +

+ } +
+ + + ) }; diff --git a/drizzle.config.ts b/drizzle.config.ts index 2c32b6f5..946c5d2e 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -6,8 +6,8 @@ export default defineConfig({ out: "./db/migrations", dialect: "sqlite", driver: (process.env.SQLITE_AUTH_TOKEN && process.env.SQLITE_AUTH_TOKEN !== "") - ? "turso" - : undefined, + ? ("turso" as const) + : (undefined as any), dbCredentials: { url: process.env.SQLITE_URL!, authToken: process.env.SQLITE_AUTH_TOKEN, diff --git a/fields/core/file/edit-component.tsx b/fields/core/file/edit-component.tsx index ce262770..22a03801 100644 --- a/fields/core/file/edit-component.tsx +++ b/fields/core/file/edit-component.tsx @@ -24,6 +24,61 @@ import { buttonVariants } from "@/components/ui/button"; const generateId = () => uuidv4().slice(0, 8); +const FileTeaser = ({ file, config, onRemove, getFileIcon }: { + file: string; + config: any; + onRemove: (file: string) => void; + getFileIcon: (file: string) => React.ReactNode; +}) => { + return ( + <> +
+ {getFileIcon(file)} + {getFileName(file)} + {file} +
+ +
+ + + + + + + + + See on GitHub + + + + + + + + + + + Remove + + + +
+ + ) +}; + const SortableItem = ({ id, file, config, onRemove, getFileIcon }: { id: string; file: string; @@ -57,49 +112,7 @@ const SortableItem = ({ id, file, config, onRemove, getFileIcon }: { > -
- {getFileIcon(file)} - {getFileName(file)} - {file} -
- -
- - - - - - - - - See on GitHub - - - - - - - - - - - Remove - - - -
+ ); @@ -108,7 +121,7 @@ const SortableItem = ({ id, file, config, onRemove, getFileIcon }: { const EditComponent = forwardRef((props: any, ref: React.Ref) => { const { value, field, onChange } = props; const { config } = useConfig(); - + const [files, setFiles] = useState>(() => value ? Array.isArray(value) @@ -159,7 +172,7 @@ const EditComponent = forwardRef((props: any, ref: React.Ref) }, [field.options?.extensions, field.options?.categories, mediaConfig]); const isMultiple = useMemo(() => - field.options?.multiple !== undefined && field.options?.multiple !== false, + field.options?.multiple === true, [field.options?.multiple] ); @@ -276,61 +289,73 @@ const EditComponent = forwardRef((props: any, ref: React.Ref) } return ( -
-
- - f.id)} - strategy={verticalListSortingStrategy} - > - {files.map((file) => ( - handleRemove(file.id)} - getFileIcon={getFileIcon} - /> - ))} - - -
- {remainingSlots > 0 && ( -
- - - - - - - - - - - - Select from media - - - + + +
+ {files.length > 0 && ( + isMultiple ? ( +
+ + f.id)} + strategy={verticalListSortingStrategy} + > + {files.map((file) => ( + handleRemove(file.id)} + getFileIcon={getFileIcon} + /> + ))} + + +
+ ) : ( +
+ handleRemove(files[0].id)} getFileIcon={getFileIcon} /> +
+ ) + )} + {remainingSlots > 0 && ( +
+ + + + + + + + + + + + Select from media + + + +
+ )}
- )} -
+ + ); }); diff --git a/fields/core/image/edit-component.tsx b/fields/core/image/edit-component.tsx index da1b8f6d..04b280ba 100644 --- a/fields/core/image/edit-component.tsx +++ b/fields/core/image/edit-component.tsx @@ -1,120 +1,212 @@ "use client"; -import { forwardRef, useCallback, useMemo, useState } from "react"; -import { getParentPath } from "@/lib/utils/file"; -import { MediaDialog } from "@/components/media/media-dialog"; -import { Thumbnail } from "@/components/thumbnail"; +import { forwardRef, useCallback, useState, useEffect, useMemo } from "react"; import { Button } from "@/components/ui/button"; -import { - DndContext, - closestCenter, - KeyboardSensor, - PointerSensor, - useSensor, - useSensors, -} from "@dnd-kit/core"; -import { - arrayMove, - SortableContext, - sortableKeyboardCoordinates, - useSortable, - rectSortingStrategy -} from "@dnd-kit/sortable"; -import { CSS } from "@dnd-kit/utilities"; -import { Pencil, Trash2, ImagePlus } from "lucide-react"; +import { MediaUpload } from "@/components/media/media-upload"; +import { MediaDialog } from "@/components/media/media-dialog"; +import { Trash2, Upload, FolderOpen, ArrowUpRight } from "lucide-react"; import { useConfig } from "@/contexts/config-context"; +import { extensionCategories, normalizePath } from "@/lib/utils/file"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'; +import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy, rectSortingStrategy } from '@dnd-kit/sortable'; +import { useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { v4 as uuidv4 } from 'uuid'; +import { getSchemaByName } from "@/lib/schema"; +import { cn } from "@/lib/utils"; +import { buttonVariants } from "@/components/ui/button"; +import { Thumbnail } from "@/components/thumbnail"; -// TODO: disable sortable for single image -// TODO: make component resilient to illegal parentPath - -const SortableItem = ({ - id, - path, - media, - children -}: { - id: string, - path: string, - media: string, - children: React.ReactNode +const generateId = () => uuidv4().slice(0, 8); + +const ImageTeaser = ({ file, config, media, onRemove }: { + file: string; + config: any; + media: string; + onRemove: (file: string) => void; +}) => { + return ( + <> +
+ + + + + + + + + See on GitHub + + + + + + + + + + + Remove + + + +
+ + ) +}; + +const SortableItem = ({ id, file, config, media, onRemove }: { + id: string; + file: string; + config: any; + media: string; + onRemove: (file: string) => void; }) => { const { attributes, - isDragging, listeners, setNodeRef, transform, transition, - } = useSortable({ id }); + isDragging + } = useSortable({ id: id }); const style = { transform: CSS.Transform.toString(transform), transition, - zIndex: isDragging ? 1000 : "auto" + opacity: isDragging ? 0.5 : 1, + zIndex: isDragging ? 1 : 0, + position: 'relative' as const }; - // const name = field.options?; - return (
-
-
- -
- {children} +
+
+
); }; const EditComponent = forwardRef((props: any, ref: React.Ref) => { const { value, field, onChange } = props; - const [images, setImages] = useState<{ id: string, path: string }[]>(() => + const { config } = useConfig(); + + const [files, setFiles] = useState>(() => value ? Array.isArray(value) - ? value.map((path, index) => ({ id: `image-${index}`, path })) - : [{ id: "image-0", path: value }] + ? value.map(path => ({ id: generateId(), path })) + : [{ id: generateId(), path: value }] : [] ); - const { config } = useConfig(); + const mediaConfig = useMemo(() => { + if (!config?.object?.media?.length) { + return undefined; + } + return field.options?.media + ? getSchemaByName(config.object, field.options?.media, "media") + : config.object.media[0]; + }, [field.options?.media, config?.object]); - const mediaName = field.options?.media || config?.object.media[0].name; - - const maxImages = useMemo(() => { - if (field.list && typeof field.list.max === 'number') { - return field.list.max; + const rootPath = useMemo(() => { + if (!field.options?.path) { + return mediaConfig?.input; } - return field.list ? undefined : 1; - }, [field]); - const handleRemove = useCallback((index: number) => { - let newImages = [...images]; - newImages.splice(index, 1); - - if (newImages.length === 0) { - setImages([]); - onChange(field.list ? [] : ""); - } else { - setImages(newImages); - field.list - ? onChange(newImages.map((item: any) => item.path)) - : onChange(newImages[0].path); + const normalizedPath = normalizePath(field.options.path); + const normalizedMediaPath = normalizePath(mediaConfig?.input); + + if (!normalizedPath.startsWith(normalizedMediaPath)) { + console.warn(`"${field.options.path}" is not within media root "${mediaConfig?.input}". Defaulting to media root.`); + return mediaConfig?.input; } - }, [field, images, onChange]); - - const handleSubmit = useCallback((newImages: string[]) => { - if (newImages.length === 0) { - setImages([]); - onChange(field.list ? [] : ""); - } else { - const newImagesObjects = newImages.map((path: string, index: number) => ({ id: `image-${index}`, path })); - setImages(newImagesObjects); - field.list - ? onChange(newImagesObjects.map((item: any) => item.path)) - : onChange(newImagesObjects[0].path); + + return normalizedPath; + }, [field.options?.path, mediaConfig?.input]); + + const allowedExtensions = useMemo(() => { + if (!mediaConfig) return []; + + // Start with image extensions + let extensions = extensionCategories['image']; + + // Apply field-level extensions/categories if specified + const fieldExtensions = field.options?.extensions + ? field.options.extensions + : field.options?.categories + ? field.options.categories.flatMap((category: string) => extensionCategories[category]) + : []; + + if (fieldExtensions.length) { + extensions = extensions.filter(ext => fieldExtensions.includes(ext)); + } + + // Finally, filter by media config extensions if they exist + if (mediaConfig.extensions) { + extensions = extensions.filter(ext => mediaConfig.extensions.includes(ext)); + } + + return extensions; + }, [field.options?.extensions, field.options?.categories, mediaConfig]); + + const isMultiple = useMemo(() => + field.options?.multiple === true, + [field.options?.multiple] + ); + + const remainingSlots = useMemo(() => + field.options?.multiple + ? field.options.multiple.max + ? field.options.multiple.max - files.length + : Infinity + : 1 - files.length, + [field.options?.multiple, files.length] + ); + + useEffect(() => { + if (isMultiple) { + onChange(files.map(f => f.path)); + } else { + onChange(files[0]?.path || undefined); + } + }, [files, isMultiple, onChange]); + + const handleUpload = useCallback((fileData: any) => { + if (!config) return; + + const newFile = { id: generateId(), path: fileData.path }; + + if (isMultiple) { + setFiles(prev => [...prev, newFile]); + } else { + setFiles([newFile]); } - }, [field, onChange]); + }, [isMultiple, config]); + + const handleRemove = useCallback((fileId: string) => { + setFiles(prev => prev.filter(file => file.id !== fileId)); + }, []); const sensors = useSensors( useSensor(PointerSensor), @@ -125,60 +217,118 @@ const EditComponent = forwardRef((props: any, ref: React.Ref) const handleDragEnd = (event: any) => { const { active, over } = event; + if (active.id !== over.id) { - let newImages = images; - const oldIndex = newImages.findIndex(item => item.id === active.id); - const newIndex = newImages.findIndex(item => item.id === over.id); - newImages = arrayMove(newImages, oldIndex, newIndex); - setImages(newImages); - onChange(newImages.map((item: any) => item.path)); + setFiles((items) => { + const oldIndex = items.findIndex(item => item.id === active.id); + const newIndex = items.findIndex(item => item.id === over.id); + return arrayMove(items, oldIndex, newIndex); + }); } }; + const handleSelected = useCallback((newPaths: string[]) => { + if (newPaths.length === 0) { + setFiles([]); + } else { + const newFiles = newPaths.map(path => ({ + id: generateId(), + path + })); + + if (isMultiple) { + setFiles(prev => [...prev, ...newFiles]); + } else { + setFiles([newFiles[0]]); + } + } + }, [isMultiple]); + + if (!mediaConfig) { + return ( +
+ No media configuration found. {' '} + + Check your settings + . +
+ ); + } + return ( - - - {/*
*/} -
- - {images.map((item, index) => - -
- - image.path)} - onSubmit={handleSubmit} - maxSelected={maxImages} - initialPath={getParentPath(item.path)} + + +
+ {files.length > 0 && ( + isMultiple ? ( +
+ - - -
-
+ f.id)} + strategy={rectSortingStrategy} + > + {files.map((file) => ( + handleRemove(file.id)} + /> + ))} + + +
+ ) : ( +
+ handleRemove(files[0].id)} /> +
+ ) + )} + {remainingSlots > 0 && ( +
+ + + + + + + + + + + + Select from media + + + +
)} - {(!maxImages || images.length < maxImages) && - image.path)} - onSubmit={handleSubmit} - maxSelected={maxImages} - initialPath={field.options?.path} - > - - - }
-
-
+ + ); }); +EditComponent.displayName = "EditComponent"; + export { EditComponent }; \ No newline at end of file diff --git a/fields/core/image/index.tsx b/fields/core/image/index.tsx index 8943c841..7fd28106 100644 --- a/fields/core/image/index.tsx +++ b/fields/core/image/index.tsx @@ -1,28 +1,46 @@ +import { z } from "zod"; import { ViewComponent } from "./view-component"; import { EditComponent } from "./edit-component"; import { Field } from "@/types/field"; import { swapPrefix } from "@/lib/githubImage"; -const read = (value: any, field: Field, config: Record) => { +const read = (value: any, field: Field, config: Record): string | string[] | null => { if (!value) return null; + if (Array.isArray(value) && !value.length) return null; const prefixInput = field.options?.input ?? config.object.media?.input; const prefixOutput = field.options?.output ?? config.object.media?.output; + if (Array.isArray(value)) { + return value.map(v => read(v, field, config)) as string[]; + } + return swapPrefix(value, prefixOutput, prefixInput, true); }; -const write = (value: any, field: Field, config: Record) => { +const write = (value: any, field: Field, config: Record): string | string[] | null => { if (!value) return null; + if (Array.isArray(value) && !value.length) return null; const prefixInput = field.options?.input ?? config.object.media?.input; const prefixOutput = field.options?.output ?? config.object.media?.output; + if (Array.isArray(value)) { + return value.map(v => write(v, field, config)) as string[]; + } + return swapPrefix(value, prefixInput, prefixOutput); }; -// TODO: add image validation +// TODO: add validation for media path and file extension +const schema = (field: Field) => { + let zodSchema: z.ZodTypeAny = z.coerce.string(); -const supportsList = true; + if (field.options?.multiple) zodSchema = z.array(zodSchema); + + if (!field.required) zodSchema = zodSchema.optional(); + + return zodSchema; +}; -export { ViewComponent, EditComponent, read, write, supportsList }; \ No newline at end of file +export { schema, ViewComponent, EditComponent, read, write }; \ No newline at end of file diff --git a/lib/configSchema.ts b/lib/configSchema.ts index 6e2c1227..971e25e7 100644 --- a/lib/configSchema.ts +++ b/lib/configSchema.ts @@ -70,7 +70,7 @@ const FieldObjectSchema: z.ZodType = z.lazy(() => z.object({ ]).optional(), description: z.string().optional().nullable(), type: z.enum([ - "boolean", "code", "date", "file", "image", "number", "object", "rich-text", + "boolean", "code", "date", "file", "image", "number", "object", "reference", "rich-text", "select", "string", "text", "uuid" ], { message: "'type' is required and must be set to a valid field type (see documentation)." From a94f1f33141ff2b77f2d61d5206b377edd0a16bf Mon Sep 17 00:00:00 2001 From: Ronan Berder Date: Wed, 19 Mar 2025 10:10:02 +0800 Subject: [PATCH 53/95] Adding file extensions validation on drag and drop --- components/media/media-upload.tsx | 49 ++++++++++++++++++++++++++--- fields/core/file/edit-component.tsx | 18 +++++------ 2 files changed, 54 insertions(+), 13 deletions(-) diff --git a/components/media/media-upload.tsx b/components/media/media-upload.tsx index ea39632a..15f3f3db 100644 --- a/components/media/media-upload.tsx +++ b/components/media/media-upload.tsx @@ -134,7 +134,26 @@ function MediaUploadTrigger({ children }: MediaUploadTriggerProps) { const handleFileInput = useCallback((event: React.ChangeEvent) => { const files = event.target.files; - if (files && files.length > 0) { + if (!files || files.length === 0) return; + + const acceptedExtensions = context.accept?.split(',').map(ext => ext.trim().toLowerCase()); + if (acceptedExtensions?.length) { + const validFiles = Array.from(files).filter(file => { + const ext = `.${file.name.split('.').pop()?.toLowerCase()}`; + return acceptedExtensions.includes(ext); + }); + + if (validFiles.length === 0) { + toast.error(`Invalid file type. Allowed: ${context.accept}`); + return; + } + + if (validFiles.length !== files.length) { + toast.error(`Some files were skipped. Allowed: ${context.accept}`); + } + + context.handleFiles(validFiles as unknown as FileList); + } else { context.handleFiles(files); } }, [context]); @@ -174,8 +193,28 @@ function MediaUploadDropZone({ children, className }: MediaUploadDropZoneProps) e.preventDefault(); setIsDragging(false); - if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { - context.handleFiles(e.dataTransfer.files); + const files = e.dataTransfer.files; + if (!files || files.length === 0) return; + + const acceptedExtensions = context.accept?.split(',').map(ext => ext.trim().toLowerCase()); + if (acceptedExtensions?.length) { + const validFiles = Array.from(files).filter(file => { + const ext = `.${file.name.split('.').pop()?.toLowerCase()}`; + return acceptedExtensions.includes(ext); + }); + + if (validFiles.length === 0) { + toast.error(`Invalid file type. Allowed: ${context.accept}`); + return; + } + + if (validFiles.length !== files.length) { + toast.error(`Some files were skipped. Allowed: ${context.accept}`); + } + + context.handleFiles(validFiles as unknown as FileList); + } else { + context.handleFiles(files); } }, [context]); @@ -189,7 +228,9 @@ function MediaUploadDropZone({ children, className }: MediaUploadDropZoneProps) {children} {isDragging && (
-

Drop files here to upload

+

+ Drop files here to upload +

)}
diff --git a/fields/core/file/edit-component.tsx b/fields/core/file/edit-component.tsx index 22a03801..ea2eafb7 100644 --- a/fields/core/file/edit-component.tsx +++ b/fields/core/file/edit-component.tsx @@ -215,23 +215,23 @@ const EditComponent = forwardRef((props: any, ref: React.Ref) if (extensions.includes(ext)) { switch (category) { case 'image': - return ; + return ; case 'document': - return ; + return ; case 'video': - return ; + return ; case 'audio': - return ; + return ; case 'compressed': - return ; + return ; case 'code': - return ; + return ; case 'font': - return ; + return ; case 'spreadsheet': - return ; + return ; default: - return ; + return ; } } } From 38352bf3fe9a125075af2918fa55dea03e5d7ce4 Mon Sep 17 00:00:00 2001 From: Ronan Berder Date: Wed, 19 Mar 2025 11:32:12 +0800 Subject: [PATCH 54/95] Updating read/write functions to account for new media config --- .../[branch]/media/[name]/[path]/route.ts | 2 +- components/thumbnail.tsx | 2 + fields/core/file/index.tsx | 23 +++-- fields/core/image/index.tsx | 23 +++-- fields/core/rich-text/edit-component.tsx | 91 +++++++++++++++++-- fields/core/rich-text/index.tsx | 23 +++-- 6 files changed, 136 insertions(+), 28 deletions(-) diff --git a/app/api/[owner]/[repo]/[branch]/media/[name]/[path]/route.ts b/app/api/[owner]/[repo]/[branch]/media/[name]/[path]/route.ts index 1de79ddf..3f6692bd 100644 --- a/app/api/[owner]/[repo]/[branch]/media/[name]/[path]/route.ts +++ b/app/api/[owner]/[repo]/[branch]/media/[name]/[path]/route.ts @@ -37,7 +37,7 @@ export async function GET( } const normalizedPath = normalizePath(params.path); - if (!normalizedPath.startsWith(mediaConfig.input)) throw new Error(`Invalid path "${params.path}" for media.`); + if (!normalizedPath.startsWith(mediaConfig.input)) throw new Error(`Invalid path "${params.path}" for media "${params.name}".`); const { searchParams } = new URL(request.url); const nocache = searchParams.get('nocache'); diff --git a/components/thumbnail.tsx b/components/thumbnail.tsx index 5f8562e2..a9fa4a17 100644 --- a/components/thumbnail.tsx +++ b/components/thumbnail.tsx @@ -34,6 +34,8 @@ export function Thumbnail({ const url = await getRawUrl(owner, repo, branch, name, path, isPrivate); setRawUrl(url); } catch (error: any) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + console.warn(errorMessage); setError(error.message); } } diff --git a/fields/core/file/index.tsx b/fields/core/file/index.tsx index 7fd28106..8f712ba6 100644 --- a/fields/core/file/index.tsx +++ b/fields/core/file/index.tsx @@ -3,33 +3,44 @@ import { ViewComponent } from "./view-component"; import { EditComponent } from "./edit-component"; import { Field } from "@/types/field"; import { swapPrefix } from "@/lib/githubImage"; +import { getSchemaByName } from "@/lib/schema"; const read = (value: any, field: Field, config: Record): string | string[] | null => { if (!value) return null; if (Array.isArray(value) && !value.length) return null; - const prefixInput = field.options?.input ?? config.object.media?.input; - const prefixOutput = field.options?.output ?? config.object.media?.output; + const mediaConfig = field.options?.media === false + ? undefined + : field.options?.media && typeof field.options.media === 'string' + ? getSchemaByName(config.object, field.options.media, "media") + : config.object.media[0]; + + if (!mediaConfig) return value; if (Array.isArray(value)) { return value.map(v => read(v, field, config)) as string[]; } - return swapPrefix(value, prefixOutput, prefixInput, true); + return swapPrefix(value, mediaConfig.output, mediaConfig.input, true); }; const write = (value: any, field: Field, config: Record): string | string[] | null => { if (!value) return null; if (Array.isArray(value) && !value.length) return null; - const prefixInput = field.options?.input ?? config.object.media?.input; - const prefixOutput = field.options?.output ?? config.object.media?.output; + const mediaConfig = field.options?.media === false + ? undefined + : field.options?.media && typeof field.options.media === 'string' + ? getSchemaByName(config.object, field.options.media, "media") + : config.object.media[0]; + + if (!mediaConfig) return value; if (Array.isArray(value)) { return value.map(v => write(v, field, config)) as string[]; } - return swapPrefix(value, prefixInput, prefixOutput); + return swapPrefix(value, mediaConfig.input, mediaConfig.output); }; // TODO: add validation for media path and file extension diff --git a/fields/core/image/index.tsx b/fields/core/image/index.tsx index 7fd28106..8f712ba6 100644 --- a/fields/core/image/index.tsx +++ b/fields/core/image/index.tsx @@ -3,33 +3,44 @@ import { ViewComponent } from "./view-component"; import { EditComponent } from "./edit-component"; import { Field } from "@/types/field"; import { swapPrefix } from "@/lib/githubImage"; +import { getSchemaByName } from "@/lib/schema"; const read = (value: any, field: Field, config: Record): string | string[] | null => { if (!value) return null; if (Array.isArray(value) && !value.length) return null; - const prefixInput = field.options?.input ?? config.object.media?.input; - const prefixOutput = field.options?.output ?? config.object.media?.output; + const mediaConfig = field.options?.media === false + ? undefined + : field.options?.media && typeof field.options.media === 'string' + ? getSchemaByName(config.object, field.options.media, "media") + : config.object.media[0]; + + if (!mediaConfig) return value; if (Array.isArray(value)) { return value.map(v => read(v, field, config)) as string[]; } - return swapPrefix(value, prefixOutput, prefixInput, true); + return swapPrefix(value, mediaConfig.output, mediaConfig.input, true); }; const write = (value: any, field: Field, config: Record): string | string[] | null => { if (!value) return null; if (Array.isArray(value) && !value.length) return null; - const prefixInput = field.options?.input ?? config.object.media?.input; - const prefixOutput = field.options?.output ?? config.object.media?.output; + const mediaConfig = field.options?.media === false + ? undefined + : field.options?.media && typeof field.options.media === 'string' + ? getSchemaByName(config.object, field.options.media, "media") + : config.object.media[0]; + + if (!mediaConfig) return value; if (Array.isArray(value)) { return value.map(v => write(v, field, config)) as string[]; } - return swapPrefix(value, prefixInput, prefixOutput); + return swapPrefix(value, mediaConfig.input, mediaConfig.output); }; // TODO: add validation for media path and file extension diff --git a/fields/core/rich-text/edit-component.tsx b/fields/core/rich-text/edit-component.tsx index c6ddcd49..8ad3bf53 100644 --- a/fields/core/rich-text/edit-component.tsx +++ b/fields/core/rich-text/edit-component.tsx @@ -1,6 +1,6 @@ "use client"; -import { forwardRef, useCallback, useRef, useState } from "react"; +import { forwardRef, useCallback, useRef, useState, useMemo } from "react"; import { BubbleMenu, EditorContent, useEditor } from "@tiptap/react"; import StarterKit from "@tiptap/starter-kit"; import Image from "@tiptap/extension-image"; @@ -58,13 +58,50 @@ import { Trash2, Underline as UnderlineIcon } from "lucide-react"; +import { toast } from "sonner"; +import { getSchemaByName } from "@/lib/schema"; +import { extensionCategories, normalizePath } from "@/lib/utils/file"; const EditComponent = forwardRef((props: any, ref) => { const { config } = useConfig(); const { isPrivate } = useRepo(); const { value, field, onChange } = props; - const mediaName = field.options?.media || config?.object.media[0].name; + + const mediaConfig = useMemo(() => { + if (!config?.object?.media?.length) { + return undefined; + } + return field.options?.media !== false + ? field.options?.media + ? getSchemaByName(config.object, field.options.media, "media") + : config.object.media[0] + : undefined; + }, [field.options?.media, config?.object]); + + const allowedExtensions = useMemo(() => { + if (!mediaConfig) return []; + + let extensions = extensionCategories['image']; + + const fieldExtensions = field.options?.extensions + ? field.options.extensions + : field.options?.categories + ? field.options.categories.flatMap((category: string) => extensionCategories[category]) + : []; + + if (fieldExtensions.length) { + extensions = extensions.filter(ext => fieldExtensions.includes(ext)); + } + + if (mediaConfig.extensions) { + extensions = extensions.filter(ext => mediaConfig.extensions.includes(ext)); + } + + return extensions; + }, [field.options?.extensions, field.options?.categories, mediaConfig]); + + const mediaName = mediaConfig?.name || config?.object.media[0].name; if (!mediaName) throw new Error("No media defined."); const mediaDialogRef = useRef(null); @@ -74,10 +111,26 @@ const EditComponent = forwardRef((props: any, ref) => { const [linkUrl, setLinkUrl] = useState(""); - const openMediaDialog = config?.object.media?.input + const openMediaDialog = mediaConfig?.input ? () => { if (mediaDialogRef.current) mediaDialogRef.current.open() } : undefined; + const rootPath = useMemo(() => { + if (!field.options?.path) { + return mediaConfig?.input; + } + + const normalizedPath = normalizePath(field.options.path); + const normalizedMediaPath = normalizePath(mediaConfig?.input); + + if (!normalizedPath.startsWith(normalizedMediaPath)) { + console.warn(`"${field.options.path}" is not within media root "${mediaConfig?.input}". Defaulting to media root.`); + return mediaConfig?.input; + } + + return normalizedPath; + }, [field.options?.path, mediaConfig?.input]); + const editor = useEditor({ immediatelyRender: true, extensions: [ @@ -119,8 +172,15 @@ const EditComponent = forwardRef((props: any, ref) => { onUpdate: ({ editor }) => onChange(editor.getHTML()), onCreate: async ({ editor }) => { if (config && value) { - const initialContent = await relativeToRawUrls(config.owner, config.repo, config.branch, mediaName, value, isPrivate); - editor.commands.setContent(initialContent || "

"); + try { + const initialContent = await relativeToRawUrls(config.owner, config.repo, config.branch, mediaName, value, isPrivate); + editor.commands.setContent(initialContent || "

"); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + console.warn(errorMessage); + toast.error(`${errorMessage} Check if the image exists or if media configuration is correct.`); + editor.commands.setContent(value); + } } setContentReady(true); } @@ -129,12 +189,18 @@ const EditComponent = forwardRef((props: any, ref) => { const handleMediaDialogSubmit = useCallback(async (images: string[]) => { if (config && editor) { const content = await Promise.all(images.map(async (image) => { - const url = await getRawUrl(config.owner, config.repo, config.branch, mediaName, image, isPrivate); - return `

`; + try { + const url = await getRawUrl(config.owner, config.repo, config.branch, mediaName, image, isPrivate); + return `

`; + } catch { + toast.error(`Failed to load image: ${image}`); + // Return a placeholder with error styling + return `

${image}

`; + } })); editor.chain().focus().insertContent(content.join('\n')).run(); } - }, [config, editor, isPrivate]); + }, [config, editor, isPrivate, mediaName]); const getBlockIcon = (editor: any) => { if (editor.isActive("heading", { level: 1 })) return ; @@ -367,7 +433,14 @@ const EditComponent = forwardRef((props: any, ref) => {
} - + ) diff --git a/fields/core/rich-text/index.tsx b/fields/core/rich-text/index.tsx index f1d44d2f..123d14e2 100644 --- a/fields/core/rich-text/index.tsx +++ b/fields/core/rich-text/index.tsx @@ -5,6 +5,7 @@ import { ViewComponent } from "./view-component"; import { marked } from "marked"; import TurndownService from "turndown"; import { tables, strikethrough } from "joplin-turndown-plugin-gfm"; +import { getSchemaByName } from "@/lib/schema"; const read = (value: any, field: Field, config: Record) => { let html = field.options?.format === "html" @@ -13,10 +14,15 @@ const read = (value: any, field: Field, config: Record) => { ? marked(value) : value; - const prefixInput = field.options?.input ?? config.object.media?.input; - const prefixOutput = field.options?.output ?? config.object.media?.output; + const mediaConfig = field.options?.media === false + ? undefined + : field.options?.media && typeof field.options.media === 'string' + ? getSchemaByName(config.object, field.options.media, "media") + : config.object.media[0]; - return htmlSwapPrefix(html, prefixOutput, prefixInput, true); + if (!mediaConfig) return html; + + return htmlSwapPrefix(html, mediaConfig.output, mediaConfig.input, true); }; const write = (value: any, field: Field, config: Record) => { @@ -24,10 +30,15 @@ const write = (value: any, field: Field, config: Record) => { content = rawToRelativeUrls(config.owner, config.repo, config.branch, content); - const prefixInput = field.options?.input ?? config.object.media?.input; - const prefixOutput = field.options?.output ?? config.object.media?.output; + const mediaConfig = field.options?.media === false + ? undefined + : field.options?.media && typeof field.options.media === 'string' + ? getSchemaByName(config.object, field.options.media, "media") + : config.object.media[0]; - content = htmlSwapPrefix(content, prefixInput, prefixOutput); + if (mediaConfig) { + content = htmlSwapPrefix(content, mediaConfig.input, mediaConfig.output); + } if (field.options?.format !== "html") { const turndownService = new TurndownService({ From bc8b8e65e78ebf4774174e65cb27a9871c64caf7 Mon Sep 17 00:00:00 2001 From: Ronan Berder Date: Wed, 19 Mar 2025 17:00:20 +0800 Subject: [PATCH 55/95] Fixing scroll bug on media dialog --- components/media/media-view.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/components/media/media-view.tsx b/components/media/media-view.tsx index 16a46d83..8594675f 100644 --- a/components/media/media-view.tsx +++ b/components/media/media-view.tsx @@ -287,9 +287,9 @@ const MediaView = ({ - +
-
+
{isLoading ? loadingSkeleton : filteredData && filteredData.length > 0 From d2e05fef8d289e78296cbec67997900512792c08 Mon Sep 17 00:00:00 2001 From: Ronan Berder Date: Wed, 19 Mar 2025 17:06:18 +0800 Subject: [PATCH 56/95] Cleaning up --- .../[repo]/[branch]/media/[name]/page.tsx | 2 +- components/media/media-view.tsx | 124 +++++++++--------- 2 files changed, 62 insertions(+), 64 deletions(-) diff --git a/app/(main)/[owner]/[repo]/[branch]/media/[name]/page.tsx b/app/(main)/[owner]/[repo]/[branch]/media/[name]/page.tsx index b1e606c4..a83240de 100644 --- a/app/(main)/[owner]/[repo]/[branch]/media/[name]/page.tsx +++ b/app/(main)/[owner]/[repo]/[branch]/media/[name]/page.tsx @@ -23,7 +23,7 @@ export default function Page({

Media

- +
); diff --git a/components/media/media-view.tsx b/components/media/media-view.tsx index 8594675f..47d4ce13 100644 --- a/components/media/media-view.tsx +++ b/components/media/media-view.tsx @@ -288,74 +288,72 @@ const MediaView = ({ -
-
- {isLoading - ? loadingSkeleton - : filteredData && filteredData.length > 0 - ?
    - {filteredData.map((item, index) => -
  • - {item.type === "dir" - ? + :
  • - )} -
- :

- - This folder is empty. -

- } -
+ } +
+ + } + + + )} + + :

+ + This folder is empty. +

+ }
From a17d44e03b1a8e6520ad87cb5d63f86d11a7989a Mon Sep 17 00:00:00 2001 From: Ronan Berder Date: Wed, 19 Mar 2025 18:49:57 +0800 Subject: [PATCH 57/95] Cleaning up debug code --- fields/core/image/edit-component.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fields/core/image/edit-component.tsx b/fields/core/image/edit-component.tsx index 04b280ba..d442c384 100644 --- a/fields/core/image/edit-component.tsx +++ b/fields/core/image/edit-component.tsx @@ -288,7 +288,7 @@ const EditComponent = forwardRef((props: any, ref: React.Ref)
) : ( -
+
handleRemove(files[0].id)} />
) From 77c4f0d475a1a398f3dc56091082df4585c3aecb Mon Sep 17 00:00:00 2001 From: Ronan Berder Date: Wed, 19 Mar 2025 18:58:57 +0800 Subject: [PATCH 58/95] Quick fix for single images --- fields/core/image/edit-component.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/fields/core/image/edit-component.tsx b/fields/core/image/edit-component.tsx index d442c384..a469042f 100644 --- a/fields/core/image/edit-component.tsx +++ b/fields/core/image/edit-component.tsx @@ -288,7 +288,8 @@ const EditComponent = forwardRef((props: any, ref: React.Ref)
) : ( -
+
+ handleRemove(files[0].id)} />
) From 84251652c2265317ff8c7f0c657b2e73d5f94787 Mon Sep 17 00:00:00 2001 From: Ronan Berder Date: Wed, 19 Mar 2025 19:11:19 +0800 Subject: [PATCH 59/95] Fixing options --- fields/core/image/edit-component.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fields/core/image/edit-component.tsx b/fields/core/image/edit-component.tsx index a469042f..8456ae7f 100644 --- a/fields/core/image/edit-component.tsx +++ b/fields/core/image/edit-component.tsx @@ -33,7 +33,7 @@ const ImageTeaser = ({ file, config, media, onRemove }: { }) => { return ( <> -
+
From ff5e2a6ba6798d103fe2daef296884af3d484db0 Mon Sep 17 00:00:00 2001 From: Ronan Berder Date: Wed, 19 Mar 2025 22:42:08 +0800 Subject: [PATCH 60/95] Fixing folders not showing up on collections --- app/api/[owner]/[repo]/[branch]/collections/[name]/route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/api/[owner]/[repo]/[branch]/collections/[name]/route.ts b/app/api/[owner]/[repo]/[branch]/collections/[name]/route.ts index 10f32767..9bd338e3 100644 --- a/app/api/[owner]/[repo]/[branch]/collections/[name]/route.ts +++ b/app/api/[owner]/[repo]/[branch]/collections/[name]/route.ts @@ -151,7 +151,7 @@ const parseContents = ( fields: contentObject, type: "file", }; - } else if (item.type === "tree" && !excludedFiles.includes(item.name)) { + } else if (item.type === "dir" && !excludedFiles.includes(item.name)) { return { name: item.name, path: item.path, From a7ee57c8b80d696dcea48b557cacc35fd73ffc36 Mon Sep 17 00:00:00 2001 From: Ronan Berder Date: Wed, 19 Mar 2025 22:46:42 +0800 Subject: [PATCH 61/95] Adding restriction on subfolders --- app/api/[owner]/[repo]/[branch]/collections/[name]/route.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/api/[owner]/[repo]/[branch]/collections/[name]/route.ts b/app/api/[owner]/[repo]/[branch]/collections/[name]/route.ts index 9bd338e3..a8ed01f1 100644 --- a/app/api/[owner]/[repo]/[branch]/collections/[name]/route.ts +++ b/app/api/[owner]/[repo]/[branch]/collections/[name]/route.ts @@ -46,6 +46,10 @@ export async function GET( const normalizedPath = normalizePath(path); if (!normalizedPath.startsWith(schema.path)) throw new Error(`Invalid path "${path}" for collection "${params.name}".`); + if (schema.subfolders === false) { + if (normalizedPath !== schema.path) throw new Error(`Invalid path "${path}" for collection "${params.name}".`); + } + let entries = await getCachedCollection(params.owner, params.repo, params.branch, normalizedPath, token); let data: { @@ -151,7 +155,7 @@ const parseContents = ( fields: contentObject, type: "file", }; - } else if (item.type === "dir" && !excludedFiles.includes(item.name)) { + } else if (item.type === "dir" && !excludedFiles.includes(item.name) && schema.subfolders !== false) { return { name: item.name, path: item.path, From 9c136126aca2f523767bc4d3a43168d86508ca6b Mon Sep 17 00:00:00 2001 From: Ronan Berder Date: Thu, 20 Mar 2025 08:56:25 +0800 Subject: [PATCH 62/95] Moving migrations to post build step --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 9fa870bd..e6bafba6 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "private": true, "scripts": { "dev": "next dev", - "build": "next build && npm run db:migrate", + "build": "next build", + "postbuild": "npm run db:migrate", "start": "next start", "lint": "next lint", "db:generate": "npx drizzle-kit generate", From d8cdd0dafb998d255f5fb8f0b8af50944a1fab28 Mon Sep 17 00:00:00 2001 From: Ronan Berder Date: Thu, 20 Mar 2025 09:20:02 +0800 Subject: [PATCH 63/95] Debugging caching in development --- lib/githubCache.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/githubCache.ts b/lib/githubCache.ts index 9f6e608b..56802741 100644 --- a/lib/githubCache.ts +++ b/lib/githubCache.ts @@ -236,6 +236,8 @@ const getCachedCollection = async ( ) ); } + + console.log('Octokit call in getCachedCollection'); // Fetch from GitHub to create the collection cache const octokit = createOctokitInstance(token); From abed723bac85544a052a6d6a969080b972df34a9 Mon Sep 17 00:00:00 2001 From: Ronan Berder Date: Thu, 20 Mar 2025 09:38:31 +0800 Subject: [PATCH 64/95] Removing debugging code --- lib/githubCache.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/githubCache.ts b/lib/githubCache.ts index 56802741..9f6e608b 100644 --- a/lib/githubCache.ts +++ b/lib/githubCache.ts @@ -236,8 +236,6 @@ const getCachedCollection = async ( ) ); } - - console.log('Octokit call in getCachedCollection'); // Fetch from GitHub to create the collection cache const octokit = createOctokitInstance(token); From 1d87730be3596d05e5a507566a108eb2224de72b Mon Sep 17 00:00:00 2001 From: Ronan Berder Date: Thu, 20 Mar 2025 13:44:38 +0800 Subject: [PATCH 65/95] Minor bug fixes --- components/collection/collection-table.tsx | 2 +- components/collection/collection-view.tsx | 29 ++++++++++++++-------- fields/core/reference/edit-component.tsx | 2 +- fields/core/rich-text/edit-component.tsx | 2 +- fields/core/select/edit-component.tsx | 13 +++++++++- 5 files changed, 34 insertions(+), 14 deletions(-) diff --git a/components/collection/collection-table.tsx b/components/collection/collection-table.tsx index 8cb19001..16886ed9 100644 --- a/components/collection/collection-table.tsx +++ b/components/collection/collection-table.tsx @@ -74,7 +74,7 @@ export function CollectionTable({ item.path === "date")) + || !schema.view?.fields + ) + ) { + pathAndFieldArray.push({ + path: "date", + field: { + label: "Date", + name: "date", + type: "date" + } + }); } return pathAndFieldArray; diff --git a/fields/core/reference/edit-component.tsx b/fields/core/reference/edit-component.tsx index 579fd4a2..a23d9fa4 100644 --- a/fields/core/reference/edit-component.tsx +++ b/fields/core/reference/edit-component.tsx @@ -26,7 +26,7 @@ const EditComponent = forwardRef((props: any, ref: React.Ref) query: "{input}", fields: field.options?.search || "name" }, - minlength: 2, + minlength: 0, results: "data.contents", value: field.options?.value || "{path}", label: field.options?.label || "{name}", diff --git a/fields/core/rich-text/edit-component.tsx b/fields/core/rich-text/edit-component.tsx index 8ad3bf53..e9095f14 100644 --- a/fields/core/rich-text/edit-component.tsx +++ b/fields/core/rich-text/edit-component.tsx @@ -132,7 +132,7 @@ const EditComponent = forwardRef((props: any, ref) => { }, [field.options?.path, mediaConfig?.input]); const editor = useEditor({ - immediatelyRender: true, + immediatelyRender: false, extensions: [ StarterKit.configure({ dropcursor: { width: 2 } diff --git a/fields/core/select/edit-component.tsx b/fields/core/select/edit-component.tsx index 1279fb6f..9c453733 100644 --- a/fields/core/select/edit-component.tsx +++ b/fields/core/select/edit-component.tsx @@ -179,10 +179,20 @@ const EditComponent = forwardRef((props: any, ref: any) => { : Select; const fetchConfig = field.options?.fetch as FetchConfig; + + // Determine if we should load options immediately based on minlength + const shouldLoadInitially = fetchConfig?.minlength === undefined || fetchConfig?.minlength === 0; + + // Use field.options.default if defined, otherwise use our automatic behavior + const defaultOptions = field.options?.default !== undefined + ? field.options.default + : shouldLoadInitially; + return ( { {...(fetchConfig ? { loadOptions, - cacheOptions: false, + cacheOptions: field.options?.cache ?? true, + defaultOptions: defaultOptions } : { options: staticOptions })} /> From 0f2156a6290896527192081dec8ecd7cef5f742b Mon Sep 17 00:00:00 2001 From: Ronan Berder Date: Thu, 20 Mar 2025 17:47:43 +0800 Subject: [PATCH 66/95] Cleaning up reference search --- .../[repo]/[branch]/collections/[name]/route.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/app/api/[owner]/[repo]/[branch]/collections/[name]/route.ts b/app/api/[owner]/[repo]/[branch]/collections/[name]/route.ts index a8ed01f1..21b3dc27 100644 --- a/app/api/[owner]/[repo]/[branch]/collections/[name]/route.ts +++ b/app/api/[owner]/[repo]/[branch]/collections/[name]/route.ts @@ -66,15 +66,26 @@ export async function GET( // If this is a search request, filter the contents if (type === "search" && query) { const searchQuery = query.toLowerCase(); + const searchFields = Array.isArray(fields) ? fields : fields ? [fields] : []; + data.contents = data.contents.filter(item => { - return fields.some(field => { - // Handle extra fields (name and path) + if (searchFields.length === 0) { + if ( + (item.name && item.name.toLowerCase().includes(searchQuery)) || + (item.path && item.path.toLowerCase().includes(searchQuery)) + ) { + return true; + } + + return item.content && item.content.toLowerCase().includes(searchQuery); + } + + return searchFields.some(field => { if (field === 'name' || field === 'path') { const value = item[field]; return value && String(value).toLowerCase().includes(searchQuery); } - // Handle content fields (e.g. fields.title) if (field.startsWith('fields.')) { const fieldPath = field.replace('fields.', ''); const value = safeAccess(item.fields, fieldPath); From 4fac2876762de68ec37b3a8b01c406c900ccd6d4 Mon Sep 17 00:00:00 2001 From: Ronan Berder Date: Thu, 20 Mar 2025 18:58:38 +0800 Subject: [PATCH 67/95] Fixing options on text field (#202) --- fields/core/text/edit-component.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/fields/core/text/edit-component.tsx b/fields/core/text/edit-component.tsx index d1bbc036..8f75d95c 100644 --- a/fields/core/text/edit-component.tsx +++ b/fields/core/text/edit-component.tsx @@ -4,9 +4,11 @@ import { forwardRef, useEffect, useImperativeHandle, useRef } from "react"; import { Textarea } from "@/components/ui/textarea"; const EditComponent = forwardRef((props: any, ref) => { + const { value, field, onChange } = props; const internalRef = useRef(null); const adjustHeight = (el: HTMLTextAreaElement | null) => { + if (field.options?.autoresize === false) return; if (!el) return; el.style.height = "auto"; const totalBorderWidth = 2; @@ -23,7 +25,15 @@ const EditComponent = forwardRef((props: any, ref) => { adjustHeight(event.target); }; - return