From 92e6b7b5246ade47154ea2364c23d06de931e709 Mon Sep 17 00:00:00 2001 From: juri1212 Date: Mon, 13 Jul 2026 22:37:03 +0200 Subject: [PATCH] Add content file picker to load existing files - Restrict CMS roots to `content/` - Let editors browse and load existing content files before saving - Prevent saving paths outside `content/` --- cms.example.toml | 2 +- cms.toml | 2 +- frontend/index.html | 10 +++++-- frontend/src/main.js | 61 +++++++++++++++++++++++++++++++++++++++++- frontend/src/style.css | 4 +-- 5 files changed, 72 insertions(+), 7 deletions(-) diff --git a/cms.example.toml b/cms.example.toml index 8ada941..9da57da 100644 --- a/cms.example.toml +++ b/cms.example.toml @@ -13,7 +13,7 @@ commit_author_name = "Git CMS" commit_author_email = "git-cms@example.com" [content] -roots = ["content/**", "pages/**", "data/**/*.json", "data/**/*.yaml", "data/**/*.yml", "README.md"] +roots = ["content/**"] max_file_bytes = 1048576 editable_extensions = ["md", "mdx", "json", "yaml", "yml", "txt"] diff --git a/cms.toml b/cms.toml index 8ada941..9da57da 100644 --- a/cms.toml +++ b/cms.toml @@ -13,7 +13,7 @@ commit_author_name = "Git CMS" commit_author_email = "git-cms@example.com" [content] -roots = ["content/**", "pages/**", "data/**/*.json", "data/**/*.yaml", "data/**/*.yml", "README.md"] +roots = ["content/**"] max_file_bytes = 1048576 editable_extensions = ["md", "mdx", "json", "yaml", "yml", "txt"] diff --git a/frontend/index.html b/frontend/index.html index d06f9c0..a048f00 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -24,9 +24,15 @@

Content editor

+ + + Choose an existing file from content/ to load its current content. + - - Use an allowed path, for example content/welcome.md, pages/about.md, or README.md. + + The file path is set by the selector and must remain inside content/. diff --git a/frontend/src/main.js b/frontend/src/main.js index 276fc77..93b3984 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -6,10 +6,12 @@ const signedOut = document.querySelector("#signed-out"); const editor = document.querySelector("#editor"); const user = document.querySelector("#user"); const form = document.querySelector("#save-form"); +const filePicker = document.querySelector("#file-picker"); const filename = document.querySelector("#filename"); const content = document.querySelector("#content"); const save = document.querySelector("#save"); const status = document.querySelector("#status"); +let fileLoadRequest = 0; function setStatus(message, isError = false) { status.textContent = message; @@ -56,8 +58,62 @@ async function showEditor() { user.textContent = `Signed in as ${me.login} · ${me.repository}`; signedOut.hidden = true; editor.hidden = false; + await loadAvailableFiles(); } +async function listFiles(path = "") { + const query = path ? `?path=${encodeURIComponent(path)}` : ""; + const result = await api(`/api/files${query}`); + const entries = await Promise.all( + result.entries.map(async (entry) => { + if (entry.kind === "directory") return listFiles(entry.path); + return [entry]; + }), + ); + return entries.flat(); +} + +async function loadAvailableFiles() { + filePicker.disabled = true; + filePicker.innerHTML = ''; + try { + const files = await listFiles("content"); + files.sort((a, b) => a.path.localeCompare(b.path)); + filePicker.innerHTML = ''; + for (const file of files) { + const option = document.createElement("option"); + option.value = file.path; + option.textContent = file.path; + filePicker.append(option); + } + filePicker.disabled = files.length === 0; + if (files.length === 0) setStatus("No editable files are available."); + } catch (error) { + filePicker.innerHTML = ''; + setStatus(`Could not load available files: ${error.message}`, true); + } +} + +filePicker.addEventListener("change", async () => { + const path = filePicker.value; + if (!path) return; + + const request = ++fileLoadRequest; + filePicker.disabled = true; + setStatus(`Loading ${path}…`); + try { + const file = await api(`/api/files/${apiPath(path)}`); + if (request !== fileLoadRequest) return; + filename.value = file.path; + content.value = file.content; + setStatus(`Loaded ${file.path}.`); + } catch (error) { + if (request === fileLoadRequest) setStatus(`Could not load ${path}: ${error.message}`, true); + } finally { + if (request === fileLoadRequest) filePicker.disabled = false; + } +}); + async function getSession(path, initialContent) { const existing = sessionStorage.getItem(sessionKey); if (existing) return { session: JSON.parse(existing), created: false }; @@ -80,7 +136,10 @@ async function getSession(path, initialContent) { form.addEventListener("submit", async (event) => { event.preventDefault(); const path = filename.value.trim(); - if (!path) return; + if (!path.startsWith("content/")) { + setStatus("Select a file from content/ before saving.", true); + return; + } save.disabled = true; setStatus("Saving…"); diff --git a/frontend/src/style.css b/frontend/src/style.css index 8408da6..8969959 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -16,9 +16,9 @@ form { display: grid; gap: 9px; } label { font-weight: 650; } .hint { margin-top: -4px; color: #667085; font-size: .82rem; } code { padding: 1px 4px; background: #f2f4f7; border-radius: 4px; } -input, textarea { width: 100%; padding: 11px 12px; border: 1px solid #cfd4dc; border-radius: 8px; font: inherit; } +input, select, textarea { width: 100%; padding: 11px 12px; border: 1px solid #cfd4dc; border-radius: 8px; font: inherit; } textarea { resize: vertical; line-height: 1.5; } -input:focus, textarea:focus { outline: 3px solid #dcd7ff; border-color: #6956d6; } +input:focus, select:focus, textarea:focus { outline: 3px solid #dcd7ff; border-color: #6956d6; } .button { display: inline-block; width: fit-content; margin-top: 10px; padding: 10px 15px; background: #5b47cb; color: white; border: 0; border-radius: 8px; font: inherit; font-weight: 650; text-decoration: none; cursor: pointer; } .button:hover { background: #4937ae; } .button:disabled { opacity: .65; cursor: wait; }