From a62511ce4b46528b2236a5c5efb7ba7b21d557bd Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 10 Jan 2026 07:40:23 +0000 Subject: [PATCH 1/5] feat(web): remove Decap CMS and add admin authentication foundation - Remove Decap CMS files (config.yml, index.html, media-library.js, etc.) - Remove netlify-identity-redirect.js and its reference in __root.tsx - Move mdx-format-core.js to scripts/ folder (still used by format-mdx.js) - Add admin authentication with email whitelist - Add admin layout route with auth guard - Add admin dashboard and media library placeholder pages - Update handbook documentation to reflect new admin system Co-Authored-By: john@hyprnote.com --- .../content/handbook/how-we-work/4.cms.mdx | 15 +- apps/web/public/admin/config.yml | 133 --- apps/web/public/admin/index.html | 34 - apps/web/public/admin/media-library.js | 939 ------------------ apps/web/public/admin/media.html | 594 ----------- apps/web/public/admin/preview-styles.css | 391 -------- apps/web/public/admin/preview-templates.js | 74 -- apps/web/public/admin/registration.js | 30 - apps/web/public/netlify-identity-redirect.js | 13 - apps/web/scripts/format-mdx.js | 2 +- .../admin => scripts}/mdx-format-core.js | 1 - apps/web/src/functions/admin.ts | 32 + apps/web/src/routes/__root.tsx | 4 - apps/web/src/routes/_admin/index.tsx | 55 + apps/web/src/routes/_admin/media/index.tsx | 23 + apps/web/src/routes/_admin/route.tsx | 77 ++ apps/web/src/routes/api/media-upload.ts | 2 +- 17 files changed, 196 insertions(+), 2223 deletions(-) delete mode 100644 apps/web/public/admin/config.yml delete mode 100644 apps/web/public/admin/index.html delete mode 100644 apps/web/public/admin/media-library.js delete mode 100644 apps/web/public/admin/media.html delete mode 100644 apps/web/public/admin/preview-styles.css delete mode 100644 apps/web/public/admin/preview-templates.js delete mode 100644 apps/web/public/admin/registration.js delete mode 100644 apps/web/public/netlify-identity-redirect.js rename apps/web/{public/admin => scripts}/mdx-format-core.js (99%) create mode 100644 apps/web/src/functions/admin.ts create mode 100644 apps/web/src/routes/_admin/index.tsx create mode 100644 apps/web/src/routes/_admin/media/index.tsx create mode 100644 apps/web/src/routes/_admin/route.tsx diff --git a/apps/web/content/handbook/how-we-work/4.cms.mdx b/apps/web/content/handbook/how-we-work/4.cms.mdx index 61f8e124d7..375b18d14d 100644 --- a/apps/web/content/handbook/how-we-work/4.cms.mdx +++ b/apps/web/content/handbook/how-we-work/4.cms.mdx @@ -17,14 +17,13 @@ Content lives as MDX files in Git — not locked in a proprietary database. We eat our own cooking. If users' notes should be files they own, our company content should be too. -## Decap +## Admin Interface -![](/images/handbook/decap-as-cms.png) +A custom admin interface at `https://hyprnote.com/admin` for content management. -[Decap CMS](https://github.com/decaporg/decap-cms) for non-technical editors. +**Features:** +* Media library for managing images and assets +* Google Docs import for blog posts +* Blog post management with publish/unpublish controls -**Developer** — Config in `apps/web/public/admin`, accessible at `https://hyprnote.com/admin` (not in dev mode). [GitHub backend](https://decapcms.org/docs/github-backend) with [Netlify Git Gateway](https://docs.netlify.com/manage/security/secure-access-to-sites/git-gateway). Invite users at [Netlify Identity](https://app.netlify.com/projects/hyprnote/configuration/identity#users). - -**Non-Developer** — Rename images before uploading. - -![](/images/handbook/decap-workflow.png) +**Access:** Sign in with your authorized email account at `/admin/login`. diff --git a/apps/web/public/admin/config.yml b/apps/web/public/admin/config.yml deleted file mode 100644 index 19a229f0b4..0000000000 --- a/apps/web/public/admin/config.yml +++ /dev/null @@ -1,133 +0,0 @@ -backend: - name: git-gateway - branch: main - -publish_mode: editorial_workflow - -media_folder: apps/web/public/images -public_folder: /images - -media_library: - name: github-images - -collections: - - name: articles - label: Blog - folder: apps/web/content/articles - media_folder: /apps/web/public/images/blog - public_folder: /images/blog - create: true - slug: "{{slug}}" - extension: mdx - format: mdx-custom - fields: - - label: Meta Title - name: meta_title - widget: string - hint: Title for SEO/browser tab (50-60 chars ideal) - pattern: ["^.{1,70}$", "Keep under 70 characters for SEO"] - - - label: Display Title - name: display_title - widget: string - required: false - hint: Optional display title (defaults to meta_title if not set) - - - label: Meta Description - name: meta_description - widget: text - hint: Description for SEO (150-160 chars ideal) - pattern: ["^.{50,200}$", "Aim for 150-160 characters"] - - - label: Author - name: author - widget: select - options: - - Harshika - - John Jeong - - Yujong Lee - - - label: Created Date - name: created - widget: datetime - format: "YYYY-MM-DD" - date_format: "YYYY-MM-DD" - time_format: false - default: "{{now}}" - picker_utc: true - - - label: Updated Date - name: updated - widget: datetime - format: "YYYY-MM-DD" - date_format: "YYYY-MM-DD" - time_format: false - required: false - default: "{{now}}" - picker_utc: true - - - label: Cover Image - name: coverImage - widget: image - required: false - choose_url: false - hint: Upload a cover image for this article - - - label: Featured - name: featured - widget: boolean - default: false - - - label: Published - name: published - widget: boolean - default: false - hint: Set to true to publish the article - - - label: Category - name: category - widget: select - options: - - Case Study - - Hyprnote Weekly - - Productivity Hack - - Engineering - - - label: Body - name: body - widget: markdown - hint: Use standard markdown. For images, use ![alt text](/api/images/blog/...) syntax. - - - name: handbook - label: Handbook - folder: apps/web/content/handbook - media_folder: /apps/web/public/images/handbook - public_folder: /images/handbook - create: true - slug: "{{slug}}" - extension: mdx - format: mdx-custom - nested: - depth: 10 - summary: "{{title}}" - subfolders: false - fields: - - label: Title - name: title - widget: string - - label: Section - name: section - widget: select - options: - - About - - How We Work - - Who We Want - - Go To Market - - Communication - - Beliefs - - label: Summary - name: summary - widget: text - - label: Body - name: body - widget: markdown diff --git a/apps/web/public/admin/index.html b/apps/web/public/admin/index.html deleted file mode 100644 index 5662b2448b..0000000000 --- a/apps/web/public/admin/index.html +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - Content Manager | Hyprnote - - - - - - - - - - - - - diff --git a/apps/web/public/admin/media-library.js b/apps/web/public/admin/media-library.js deleted file mode 100644 index a07590ed3a..0000000000 --- a/apps/web/public/admin/media-library.js +++ /dev/null @@ -1,939 +0,0 @@ -const createGitHubMediaLibrary = () => { - let modal = null; - let handleInsert = null; - let allowMultiple = false; - let selectedItems = new Map(); - let currentPath = ""; - let viewMode = "grid"; - let allItems = []; - let uploadingFiles = []; - let isUploading = false; - let isEditorMode = false; - let navigationStack = []; - let navigationIndex = -1; - - const GITHUB_REPO = "fastrepl/hyprnote"; - const GITHUB_BRANCH = "main"; - const IMAGES_PATH = "apps/web/public/images"; - - let cachedData = {}; - let cacheTimestamp = {}; - const CACHE_DURATION = 5 * 60 * 1000; - - async function fetchFolder(path = IMAGES_PATH) { - const cacheKey = path; - if (cachedData[cacheKey] && cacheTimestamp[cacheKey] && Date.now() - cacheTimestamp[cacheKey] < CACHE_DURATION) { - return cachedData[cacheKey]; - } - - const url = `https://api.github.com/repos/${GITHUB_REPO}/contents/${path}?ref=${GITHUB_BRANCH}`; - const response = await fetch(url); - if (!response.ok) { - throw new Error(`GitHub API error: ${response.status}`); - } - const data = await response.json(); - cachedData[cacheKey] = data; - cacheTimestamp[cacheKey] = Date.now(); - return data; - } - - async function uploadViaAPI(file, folder) { - const base64Content = await new Promise((resolve) => { - const reader = new FileReader(); - reader.onload = () => resolve(reader.result.split(",")[1]); - reader.readAsDataURL(file); - }); - - const response = await fetch("/api/media-upload", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - filename: file.name, - content: base64Content, - folder: folder || IMAGES_PATH, - }), - }); - - const result = await response.json(); - if (!response.ok) { - throw new Error(result.error || `Upload failed: ${response.status}`); - } - - delete cachedData[folder || IMAGES_PATH]; - return result; - } - - async function createFolderViaAPI(folderName, parentFolder) { - const response = await fetch("/api/media-create-folder", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - folderName: folderName, - parentFolder: parentFolder || IMAGES_PATH, - }), - }); - - const result = await response.json(); - if (!response.ok) { - throw new Error(result.error || `Folder creation failed: ${response.status}`); - } - - delete cachedData[parentFolder || IMAGES_PATH]; - return result; - } - - async function deleteViaAPI(paths) { - const response = await fetch("/api/media-delete", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - paths: Array.isArray(paths) ? paths : [paths], - }), - }); - - const result = await response.json(); - if (!response.ok) { - throw new Error(result.error || `Delete failed: ${response.status}`); - } - - cachedData = {}; - return result; - } - - function isImageFile(filename) { - const ext = filename.toLowerCase().split(".").pop(); - return ["jpg", "jpeg", "png", "gif", "svg", "webp", "avif"].includes(ext); - } - - function getPublicPath(fullPath) { - return fullPath.replace("apps/web/public", ""); - } - - function createModal() { - const overlay = document.createElement("div"); - overlay.id = "gml-overlay"; - overlay.innerHTML = ` - - -
-
- Media Library -
- - -
- - -
- - -
-
- - - -
-
- - -
-
- images -
-
- -
-
Loading...
-
- -
-
-
- -
-
-
- `; - - return overlay; - } - - function navigateToPath(path, addToHistory = true) { - if (addToHistory) { - if (navigationIndex < navigationStack.length - 1) { - navigationStack = navigationStack.slice(0, navigationIndex + 1); - } - navigationStack.push(path); - navigationIndex = navigationStack.length - 1; - } - currentPath = path; - loadFolder(); - } - - function navigateBack() { - if (navigationIndex > 0) { - navigationIndex--; - currentPath = navigationStack[navigationIndex]; - loadFolder(); - } - } - - function navigateForward() { - if (navigationIndex < navigationStack.length - 1) { - navigationIndex++; - currentPath = navigationStack[navigationIndex]; - loadFolder(); - } - } - - function renderBreadcrumb() { - const breadcrumbPath = modal.querySelector(".gml-breadcrumb-path"); - const backBtn = modal.querySelector(".gml-nav-back"); - const forwardBtn = modal.querySelector(".gml-nav-forward"); - const parts = currentPath ? currentPath.split("/") : []; - - let html = `images`; - let path = ""; - - for (let i = 0; i < parts.length; i++) { - path += (path ? "/" : "") + parts[i]; - html += `/`; - if (i === parts.length - 1) { - html += `${parts[i]}`; - } else { - html += `${parts[i]}`; - } - } - - breadcrumbPath.innerHTML = html; - - breadcrumbPath.querySelectorAll(".gml-breadcrumb-item").forEach((item) => { - item.addEventListener("click", () => { - navigateToPath(item.dataset.path); - }); - }); - - backBtn.disabled = navigationIndex <= 0; - forwardBtn.disabled = navigationIndex >= navigationStack.length - 1; - } - - function renderItems(items, searchQuery = "") { - const content = modal.querySelector(".gml-content"); - - let filtered = items; - if (searchQuery) { - const query = searchQuery.toLowerCase(); - filtered = items.filter((item) => item.name.toLowerCase().includes(query)); - } - - const folders = filtered.filter((item) => item.type === "dir"); - const files = filtered.filter((item) => item.type === "file" && isImageFile(item.name)); - const sorted = [...folders, ...files]; - - if (sorted.length === 0) { - content.innerHTML = '
No files found
'; - return; - } - - if (viewMode === "grid") { - content.innerHTML = `
${sorted - .map((item) => { - const isFolder = item.type === "dir"; - const publicPath = isFolder ? "" : getPublicPath(item.path); - const isSelected = selectedItems.has(item.path); - return ` -
-
- ${isFolder ? "📁" : ``} -
-
${item.name}
-
- `; - }) - .join("")}
`; - } else { - content.innerHTML = `
${sorted - .map((item) => { - const isFolder = item.type === "dir"; - const publicPath = isFolder ? "" : getPublicPath(item.path); - const isSelected = selectedItems.has(item.path); - return ` -
-
- ${isFolder ? "📁" : ``} -
-
-
${item.name}
- ${!isFolder ? `
${publicPath}
` : ""} -
-
- `; - }) - .join("")}
`; - } - - content.querySelectorAll(".gml-grid-item, .gml-list-item").forEach((item) => { - item.addEventListener("click", (e) => { - e.stopPropagation(); - const type = item.dataset.type; - const path = item.dataset.path; - const publicPath = item.dataset.public || path; - const downloadUrl = item.dataset.downloadUrl; - - if (type === "dir" && !(e.metaKey || e.ctrlKey || allowMultiple)) { - const newPath = path.replace(IMAGES_PATH + "/", "").replace(IMAGES_PATH, ""); - navigateToPath(newPath); - } else { - const itemData = { - path: path, - type: type, - publicPath: publicPath, - downloadUrl: downloadUrl, - }; - - if (e.metaKey || e.ctrlKey || allowMultiple) { - if (selectedItems.has(path)) { - selectedItems.delete(path); - item.classList.remove("selected"); - } else { - selectedItems.set(path, itemData); - item.classList.add("selected"); - } - } else { - selectedItems.clear(); - content.querySelectorAll(".selected").forEach((el) => el.classList.remove("selected")); - selectedItems.set(path, itemData); - item.classList.add("selected"); - } - updateToolbar(); - } - }); - - item.addEventListener("dblclick", () => { - const type = item.dataset.type; - const publicPath = item.dataset.public; - const downloadUrl = item.dataset.downloadUrl; - if (type === "file" && publicPath && handleInsert) { - const asset = { path: publicPath, url: downloadUrl || publicPath }; - console.log("Double-click inserting:", asset); - handleInsert(asset); - hide(); - } - }); - }); - - content.addEventListener("click", (e) => { - if ( - e.target === content || - e.target.classList.contains("gml-grid") || - e.target.classList.contains("gml-list") - ) { - selectedItems.clear(); - content.querySelectorAll(".selected").forEach((el) => el.classList.remove("selected")); - updateToolbar(); - } - }); - } - - function updateToolbar() { - const toolbarLeft = modal.querySelector(".gml-toolbar-left"); - const toolbarRight = modal.querySelector(".gml-toolbar-right"); - const headerNewFolderBtn = modal.querySelector(".gml-header .gml-new-folder-btn"); - const headerUploadBtn = modal.querySelector(".gml-header .gml-upload-btn"); - - if (isUploading) { - headerNewFolderBtn.style.display = "none"; - headerUploadBtn.style.display = "none"; - toolbarLeft.textContent = `${uploadingFiles.length} file${uploadingFiles.length > 1 ? "s" : ""}`; - toolbarRight.innerHTML = ``; - } else if (isEditorMode && selectedItems.size > 0) { - headerNewFolderBtn.style.display = "none"; - headerUploadBtn.style.display = "none"; - toolbarLeft.textContent = `${selectedItems.size} selected`; - toolbarRight.innerHTML = ` - - - `; - - toolbarRight.querySelector(".gml-insert-btn").addEventListener("click", () => { - if (selectedItems.size > 0) { - const assets = Array.from(selectedItems.values()) - .filter((item) => item.type === "file") - .map((item) => ({ - path: item.publicPath, - url: item.downloadUrl || item.publicPath, - })); - console.log("Insert button clicked, assets:", assets); - console.log("handleInsert function:", handleInsert); - if (handleInsert && assets.length > 0) { - handleInsert(assets.length === 1 ? assets[0] : assets); - } - hide(); - } - }); - - toolbarRight.querySelector(".gml-cancel-btn").addEventListener("click", hide); - } else if (isEditorMode && selectedItems.size === 0) { - headerNewFolderBtn.style.display = "none"; - headerUploadBtn.style.display = "none"; - toolbarLeft.textContent = ""; - toolbarRight.innerHTML = ` - - `; - - toolbarRight.querySelector(".gml-cancel-btn").addEventListener("click", hide); - } else if (!isEditorMode && selectedItems.size > 0) { - headerNewFolderBtn.style.display = "none"; - headerUploadBtn.style.display = "none"; - toolbarLeft.textContent = `${selectedItems.size} selected`; - toolbarRight.innerHTML = ` - - - `; - - toolbarRight.querySelector(".gml-unselect-btn").addEventListener("click", () => { - selectedItems.clear(); - modal.querySelectorAll(".selected").forEach((el) => el.classList.remove("selected")); - updateToolbar(); - }); - - toolbarRight.querySelector(".gml-delete-btn").addEventListener("click", handleDelete); - } else { - headerNewFolderBtn.style.display = "inline-block"; - headerUploadBtn.style.display = "inline-block"; - toolbarLeft.textContent = ""; - toolbarRight.innerHTML = ""; - } - } - - async function handleUpload(files) { - isUploading = true; - uploadingFiles = files; - updateToolbar(); - - const folder = currentPath ? `${IMAGES_PATH}/${currentPath}` : IMAGES_PATH; - - for (const file of files) { - try { - await uploadViaAPI(file, folder); - } catch (error) { - console.error("Upload failed:", error); - alert(`Failed to upload ${file.name}: ${error.message}`); - } - } - - isUploading = false; - uploadingFiles = []; - await loadFolder(); - updateToolbar(); - } - - async function handleNewFolder() { - const folderName = prompt("Enter folder name:"); - if (!folderName || !folderName.trim()) { - return; - } - - const sanitizedName = folderName.trim().replace(/[^a-zA-Z0-9-_]/g, "-"); - if (sanitizedName !== folderName.trim()) { - alert("Folder name was sanitized to: " + sanitizedName); - } - - const parentFolder = currentPath ? `${IMAGES_PATH}/${currentPath}` : IMAGES_PATH; - - try { - await createFolderViaAPI(sanitizedName, parentFolder); - await loadFolder(); - } catch (error) { - console.error("Folder creation failed:", error); - alert(`Failed to create folder: ${error.message}`); - } - } - - async function handleDelete() { - if (selectedItems.size === 0) { - return; - } - - const itemCount = selectedItems.size; - const itemWord = itemCount === 1 ? "item" : "items"; - const confirmed = confirm(`Are you sure you want to delete ${itemCount} ${itemWord}? This cannot be undone.`); - - if (!confirmed) { - return; - } - - try { - const pathsToDelete = Array.from(selectedItems); - await deleteViaAPI(pathsToDelete); - selectedItems.clear(); - await loadFolder(); - updateToolbar(); - } catch (error) { - console.error("Delete failed:", error); - alert(`Failed to delete items: ${error.message}`); - } - } - - async function loadFolder() { - const content = modal.querySelector(".gml-content"); - content.innerHTML = '
Loading...
'; - - renderBreadcrumb(); - - try { - const path = currentPath ? `${IMAGES_PATH}/${currentPath}` : IMAGES_PATH; - allItems = await fetchFolder(path); - renderItems(allItems); - updateToolbar(); - } catch (error) { - console.error("Failed to load folder:", error); - content.innerHTML = `
Failed to load: ${error.message}
`; - } - } - - async function show(config = {}) { - allowMultiple = config.allowMultiple || false; - isEditorMode = !!handleInsert; - selectedItems.clear(); - currentPath = ""; - viewMode = "grid"; - navigationStack = [""]; - navigationIndex = 0; - - modal = createModal(); - document.body.appendChild(modal); - - const closeBtn = modal.querySelector(".gml-close"); - const searchInput = modal.querySelector(".gml-search-input"); - const viewListBtn = modal.querySelector(".gml-view-list"); - const viewGridBtn = modal.querySelector(".gml-view-grid"); - const content = modal.querySelector(".gml-content"); - const backBtn = modal.querySelector(".gml-nav-back"); - const forwardBtn = modal.querySelector(".gml-nav-forward"); - const headerNewFolderBtn = modal.querySelector(".gml-header .gml-new-folder-btn"); - const headerUploadBtn = modal.querySelector(".gml-header .gml-upload-btn"); - const headerUploadInput = headerUploadBtn.querySelector('input[type="file"]'); - - closeBtn.addEventListener("click", hide); - modal.addEventListener("click", (e) => { - if (e.target === modal) hide(); - }); - - backBtn.addEventListener("click", navigateBack); - forwardBtn.addEventListener("click", navigateForward); - - headerNewFolderBtn.addEventListener("click", handleNewFolder); - headerUploadInput.addEventListener("change", async (e) => { - const files = Array.from(e.target.files); - if (files.length > 0) { - await handleUpload(files); - } - e.target.value = ""; - }); - - viewListBtn.addEventListener("click", () => { - viewMode = "list"; - viewListBtn.classList.add("active"); - viewGridBtn.classList.remove("active"); - renderItems(allItems, searchInput.value); - }); - - viewGridBtn.addEventListener("click", () => { - viewMode = "grid"; - viewGridBtn.classList.add("active"); - viewListBtn.classList.remove("active"); - renderItems(allItems, searchInput.value); - }); - - searchInput.addEventListener("input", () => { - renderItems(allItems, searchInput.value); - }); - - content.addEventListener("dragover", (e) => { - e.preventDefault(); - content.classList.add("dragover"); - }); - content.addEventListener("dragleave", () => { - content.classList.remove("dragover"); - }); - content.addEventListener("drop", async (e) => { - e.preventDefault(); - content.classList.remove("dragover"); - const files = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith("image/")); - if (files.length > 0) { - await handleUpload(files); - } - }); - - await loadFolder(); - } - - function hide() { - if (modal && modal.parentNode) { - modal.parentNode.removeChild(modal); - modal = null; - } - selectedItems.clear(); - } - - return { - name: "github-images", - init: ({ handleInsert: insertFn }) => { - handleInsert = insertFn; - return { - show, - hide, - enableStandalone: () => true, - }; - }, - }; -}; - -window.GitHubMediaLibrary = createGitHubMediaLibrary(); diff --git a/apps/web/public/admin/media.html b/apps/web/public/admin/media.html deleted file mode 100644 index 1d7c84ffd9..0000000000 --- a/apps/web/public/admin/media.html +++ /dev/null @@ -1,594 +0,0 @@ - - - - - - - Media Library | Hyprnote - - - -
-

Media Library

- -
- -
-
- - -
- -
-
- - - -
-
-
Loading images...
-
-
-
- -
-
- - -
-
-
- -
📁
-
Drop images here or click to browse
-
Supports JPG, PNG, GIF, SVG, WebP, AVIF
-
-
-
-
-
- - - - diff --git a/apps/web/public/admin/preview-styles.css b/apps/web/public/admin/preview-styles.css deleted file mode 100644 index 26b23d4f8e..0000000000 --- a/apps/web/public/admin/preview-styles.css +++ /dev/null @@ -1,391 +0,0 @@ -@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Lora:wght@400;500;600;700&display=swap"); - -:root { - --font-sans: "Inter", system-ui, -apple-system, sans-serif; - --font-serif: "Lora", Georgia, serif; -} - -html, -body { - margin: 0; - padding: 0; - font-family: var(--font-sans); - color: #1c1917; - background: #fff; - -webkit-font-smoothing: antialiased; -} - -.blog-preview { - max-width: 800px; - margin: 0 auto; - padding: 48px 16px; - background: #fff; -} - -.blog-preview-header { - text-align: center; - margin-bottom: 32px; -} - -.blog-preview-back { - display: inline-flex; - align-items: center; - gap: 8px; - font-size: 14px; - color: #525252; - margin-bottom: 32px; -} - -.blog-preview-title { - font-family: var(--font-serif); - font-size: 2.5rem; - font-weight: 400; - color: #57534e; - margin: 0 0 24px 0; - line-height: 1.2; -} - -.blog-preview-author { - display: flex; - align-items: center; - justify-content: center; - gap: 12px; - margin-bottom: 8px; -} - -.blog-preview-author-avatar { - width: 32px; - height: 32px; - border-radius: 50%; - object-fit: cover; -} - -.blog-preview-author-name { - font-size: 16px; - color: #525252; -} - -.blog-preview-date { - font-size: 12px; - font-family: monospace; - color: #737373; -} - -.blog-preview-content { - font-family: var(--font-sans); - font-size: 16px; - line-height: 1.75; - color: #44403c; -} - -.blog-preview-content h1 { - font-family: var(--font-serif); - font-size: 1.875rem; - font-weight: 600; - color: #57534e; - margin-top: 48px; - margin-bottom: 24px; -} - -.blog-preview-content h2 { - font-family: var(--font-serif); - font-size: 1.5rem; - font-weight: 600; - color: #57534e; - margin-top: 40px; - margin-bottom: 20px; -} - -.blog-preview-content h3 { - font-family: var(--font-serif); - font-size: 1.25rem; - font-weight: 600; - color: #57534e; - margin-top: 32px; - margin-bottom: 16px; -} - -.blog-preview-content h4 { - font-family: var(--font-serif); - font-size: 1.125rem; - font-weight: 600; - color: #57534e; - margin-top: 24px; - margin-bottom: 12px; -} - -.blog-preview-content p { - margin: 0 0 16px 0; -} - -.blog-preview-content a { - color: #57534e; - text-decoration: underline; - text-decoration-style: dotted; -} - -.blog-preview-content a:hover { - color: #292524; -} - -.blog-preview-content code:not(pre code) { - background: #fafaf9; - border: 1px solid #e5e5e5; - border-radius: 4px; - padding: 2px 6px; - font-size: 14px; - font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; - color: #44403c; - overflow-wrap: break-word; - word-break: break-all; -} - -.blog-preview-content pre { - background: #fafaf9; - border: 1px solid #e5e5e5; - border-radius: 4px; - padding: 16px; - overflow-x: auto; - margin: 24px 0; -} - -.blog-preview-content pre code { - background: transparent; - border: none; - padding: 0; - font-size: 14px; - font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; -} - -.blog-preview-content img { - max-width: 100%; - height: auto; - border-radius: 4px; - border: 1px solid #e5e5e5; - margin: 32px 0; -} - -.blog-preview-content blockquote { - border-left: 4px solid #d6d3d1; - margin: 24px 0; - padding-left: 16px; - color: #57534e; - font-style: italic; -} - -.blog-preview-content ul, -.blog-preview-content ol { - margin: 16px 0; - padding-left: 24px; -} - -.blog-preview-content li { - margin: 8px 0; -} - -.blog-preview-content hr { - border: none; - border-top: 1px solid #e5e5e5; - margin: 32px 0; -} - -.blog-preview-content table { - width: 100%; - border-collapse: collapse; - margin: 24px 0; -} - -.blog-preview-content th, -.blog-preview-content td { - border: 1px solid #e5e5e5; - padding: 12px; - text-align: left; -} - -.blog-preview-content th { - background: #fafaf9; - font-weight: 600; -} - -.cta-card-preview { - padding: 24px; - border: 2px dashed #a8a29e; - text-align: center; - border-radius: 8px; - background: #fafaf9; - margin: 24px 0; - color: #57534e; - font-weight: 500; -} - -/* Handbook Preview Styles */ -.handbook-preview { - max-width: 800px; - margin: 0 auto; - padding: 48px 16px; - background: #fff; -} - -.handbook-preview-header { - margin-bottom: 32px; -} - -.handbook-preview-section { - display: inline-flex; - align-items: center; - gap: 8px; - font-size: 14px; - color: #737373; - margin-bottom: 16px; -} - -.handbook-preview-title { - font-family: var(--font-serif); - font-size: 2.25rem; - font-weight: 400; - color: #57534e; - margin: 0 0 16px 0; - line-height: 1.2; -} - -.handbook-preview-summary { - font-size: 18px; - line-height: 1.75; - color: #525252; - margin: 0; -} - -.handbook-preview-content { - font-family: var(--font-sans); - font-size: 16px; - line-height: 1.75; - color: #44403c; -} - -.handbook-preview-content h1 { - font-family: var(--font-serif); - font-size: 1.875rem; - font-weight: 600; - color: #57534e; - margin-top: 48px; - margin-bottom: 24px; -} - -.handbook-preview-content h2 { - font-family: var(--font-serif); - font-size: 1.5rem; - font-weight: 600; - color: #57534e; - margin-top: 40px; - margin-bottom: 20px; -} - -.handbook-preview-content h3 { - font-family: var(--font-serif); - font-size: 1.25rem; - font-weight: 600; - color: #57534e; - margin-top: 32px; - margin-bottom: 16px; -} - -.handbook-preview-content h4 { - font-family: var(--font-serif); - font-size: 1.125rem; - font-weight: 600; - color: #57534e; - margin-top: 24px; - margin-bottom: 12px; -} - -.handbook-preview-content p { - margin: 0 0 16px 0; -} - -.handbook-preview-content a { - color: #57534e; - text-decoration: underline; - text-decoration-style: dotted; -} - -.handbook-preview-content a:hover { - color: #292524; -} - -.handbook-preview-content code:not(pre code) { - background: #fafaf9; - border: 1px solid #e5e5e5; - border-radius: 4px; - padding: 2px 6px; - font-size: 14px; - font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; - color: #44403c; - overflow-wrap: break-word; - word-break: break-all; -} - -.handbook-preview-content pre { - background: #fafaf9; - border: 1px solid #e5e5e5; - border-radius: 4px; - padding: 16px; - overflow-x: auto; - margin: 24px 0; -} - -.handbook-preview-content pre code { - background: transparent; - border: none; - padding: 0; - font-size: 14px; - font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; -} - -.handbook-preview-content img { - max-width: 100%; - height: auto; - border-radius: 4px; - margin: 32px 0; -} - -.handbook-preview-content blockquote { - border-left: 4px solid #d6d3d1; - margin: 24px 0; - padding-left: 16px; - color: #57534e; - font-style: italic; -} - -.handbook-preview-content ul, -.handbook-preview-content ol { - margin: 16px 0; - padding-left: 24px; -} - -.handbook-preview-content li { - margin: 8px 0; -} - -.handbook-preview-content hr { - border: none; - border-top: 1px solid #e5e5e5; - margin: 32px 0; -} - -.handbook-preview-content table { - width: 100%; - border-collapse: collapse; - margin: 24px 0; -} - -.handbook-preview-content th, -.handbook-preview-content td { - border: 1px solid #e5e5e5; - padding: 12px; - text-align: left; -} - -.handbook-preview-content th { - background: #fafaf9; - font-weight: 600; -} diff --git a/apps/web/public/admin/preview-templates.js b/apps/web/public/admin/preview-templates.js deleted file mode 100644 index 0129c8c3dc..0000000000 --- a/apps/web/public/admin/preview-templates.js +++ /dev/null @@ -1,74 +0,0 @@ -const AUTHOR_AVATARS = { - "John Jeong": "/api/images/team/john.png", - Harshika: "/api/images/team/harshika.jpeg", - "Yujong Lee": "/api/images/team/yujong.png", -}; - -function formatDate(dateStr) { - if (!dateStr) return ""; - const date = new Date(dateStr); - return date.toLocaleDateString("en-US", { - year: "numeric", - month: "long", - day: "numeric", - }); -} - -const ArticlePreview = createClass({ - render: function () { - const entry = this.props.entry; - const title = - entry.getIn(["data", "display_title"]) || - entry.getIn(["data", "meta_title"]) || - "Untitled"; - const author = entry.getIn(["data", "author"]); - const created = entry.getIn(["data", "created"]); - const avatarUrl = author ? AUTHOR_AVATARS[author] : null; - - return h( - "div", - { className: "blog-preview" }, - h( - "header", - { className: "blog-preview-header" }, - h("div", { className: "blog-preview-back" }, h("span", {}, "\u2190"), h("span", {}, "Back to Blog")), - h("h1", { className: "blog-preview-title" }, title), - author && - h( - "div", - { className: "blog-preview-author" }, - avatarUrl && h("img", { src: avatarUrl, alt: author, className: "blog-preview-author-avatar" }), - h("span", { className: "blog-preview-author-name" }, author) - ), - created && h("time", { className: "blog-preview-date" }, formatDate(created)) - ), - h("article", { className: "blog-preview-content" }, this.props.widgetFor("body")) - ); - }, -}); - -CMS.registerPreviewTemplate("articles", ArticlePreview); - -const HandbookPreview = createClass({ - render: function () { - const entry = this.props.entry; - const title = entry.getIn(["data", "title"]) || "Untitled"; - const section = entry.getIn(["data", "section"]); - const summary = entry.getIn(["data", "summary"]); - - return h( - "div", - { className: "handbook-preview" }, - h( - "header", - { className: "handbook-preview-header" }, - section && h("div", { className: "handbook-preview-section" }, section), - h("h1", { className: "handbook-preview-title" }, title), - summary && h("p", { className: "handbook-preview-summary" }, summary) - ), - h("article", { className: "handbook-preview-content" }, this.props.widgetFor("body")) - ); - }, -}); - -CMS.registerPreviewTemplate("handbook", HandbookPreview); diff --git a/apps/web/public/admin/registration.js b/apps/web/public/admin/registration.js deleted file mode 100644 index ab1f76c215..0000000000 --- a/apps/web/public/admin/registration.js +++ /dev/null @@ -1,30 +0,0 @@ -import { ARTICLE_FIELD_ORDER, createMdxFormatter } from "./mdx-format-core.js"; - -const { parse, stringify } = createMdxFormatter(window.jsyaml); - -CMS.registerCustomFormat("mdx-custom", "mdx", { - fromFile: (content) => { - const { frontmatter, body } = parse(content); - return { ...frontmatter, body }; - }, - toFile: (data) => { - const { body, ...frontmatter } = data; - return stringify(frontmatter, body || "", ARTICLE_FIELD_ORDER); - }, -}); - -CMS.registerEditorComponent({ - id: "cta-card", - label: "CTA Card", - fields: [], - pattern: /^$/, - fromBlock: function () { - return {}; - }, - toBlock: function () { - return ``; - }, - toPreview: function () { - return `
[CTA Card]
`; - }, -}); diff --git a/apps/web/public/netlify-identity-redirect.js b/apps/web/public/netlify-identity-redirect.js deleted file mode 100644 index 03ee718792..0000000000 --- a/apps/web/public/netlify-identity-redirect.js +++ /dev/null @@ -1,13 +0,0 @@ -(function () { - var hash = window.location.hash; - if ( - hash.indexOf("#invite_token=") === 0 || - hash.indexOf("#confirmation_token=") === 0 || - hash.indexOf("#recovery_token=") === 0 || - hash.indexOf("#access_token=") === 0 - ) { - if (window.location.pathname !== "/admin/") { - window.location.href = "/admin/" + hash; - } - } -})(); diff --git a/apps/web/scripts/format-mdx.js b/apps/web/scripts/format-mdx.js index da7ad07071..c3923eff9a 100644 --- a/apps/web/scripts/format-mdx.js +++ b/apps/web/scripts/format-mdx.js @@ -1,6 +1,6 @@ import yaml from "js-yaml"; -import { createMdxFormatter } from "../public/admin/mdx-format-core.js"; +import { createMdxFormatter } from "./mdx-format-core.js"; const { format } = createMdxFormatter(yaml); diff --git a/apps/web/public/admin/mdx-format-core.js b/apps/web/scripts/mdx-format-core.js similarity index 99% rename from apps/web/public/admin/mdx-format-core.js rename to apps/web/scripts/mdx-format-core.js index d8bbea705c..0fbfc05aec 100644 --- a/apps/web/public/admin/mdx-format-core.js +++ b/apps/web/scripts/mdx-format-core.js @@ -89,4 +89,3 @@ export function createMdxFormatter(yaml) { return { parse, stringify, format }; } - diff --git a/apps/web/src/functions/admin.ts b/apps/web/src/functions/admin.ts new file mode 100644 index 0000000000..46882fe240 --- /dev/null +++ b/apps/web/src/functions/admin.ts @@ -0,0 +1,32 @@ +import { createServerFn } from "@tanstack/react-start"; + +import { getSupabaseServerClient } from "@/functions/supabase"; + +const ADMIN_EMAILS = [ + "yujonglee@hyprnote.com", + "john@hyprnote.com", + "harshika@hyprnote.com", +]; + +export const isAdminEmail = (email: string): boolean => { + return ADMIN_EMAILS.includes(email.toLowerCase()); +}; + +export const fetchAdminUser = createServerFn({ method: "GET" }).handler( + async () => { + const supabase = getSupabaseServerClient(); + const { data, error: _error } = await supabase.auth.getUser(); + + if (!data.user?.email) { + return null; + } + + const email = data.user.email; + const isAdmin = isAdminEmail(email); + + return { + email, + isAdmin, + }; + }, +); diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 014ffd57da..30e297d5ba 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -56,10 +56,6 @@ export const Route = createRootRouteWithContext()({ links: [{ rel: "stylesheet", href: appCss }], }), scripts: () => [ - { - id: "netlify-identity-redirect", - src: "/netlify-identity-redirect.js", - }, { id: "ze-snippet", src: "https://static.zdassets.com/ekr/snippet.js?key=15949e47-ed5a-4e52-846e-200dd0b8f4b9", diff --git a/apps/web/src/routes/_admin/index.tsx b/apps/web/src/routes/_admin/index.tsx new file mode 100644 index 0000000000..eb083666bb --- /dev/null +++ b/apps/web/src/routes/_admin/index.tsx @@ -0,0 +1,55 @@ +import { createFileRoute } from "@tanstack/react-router"; + +export const Route = createFileRoute("/_admin/")({ + component: AdminDashboard, +}); + +function AdminDashboard() { + return ( +
+

+ Dashboard +

+ +
+ +

+ Media Library +

+

+ Upload, organize, and manage media assets for blog posts and + content. +

+
+ +
+

+ Import from Google Docs +

+

+ Import blog posts from Google Docs with automatic markdown + conversion. +

+ + Coming soon + +
+ +
+

+ Blog Posts +

+

+ View and manage all published and draft blog posts. +

+ + Coming soon + +
+
+
+ ); +} diff --git a/apps/web/src/routes/_admin/media/index.tsx b/apps/web/src/routes/_admin/media/index.tsx new file mode 100644 index 0000000000..73ac77df11 --- /dev/null +++ b/apps/web/src/routes/_admin/media/index.tsx @@ -0,0 +1,23 @@ +import { createFileRoute } from "@tanstack/react-router"; + +export const Route = createFileRoute("/_admin/media/")({ + component: MediaLibrary, +}); + +function MediaLibrary() { + return ( +
+
+

+ Media Library +

+
+ +
+

+ Media library functionality coming in the next PR. +

+
+
+ ); +} diff --git a/apps/web/src/routes/_admin/route.tsx b/apps/web/src/routes/_admin/route.tsx new file mode 100644 index 0000000000..769b952f21 --- /dev/null +++ b/apps/web/src/routes/_admin/route.tsx @@ -0,0 +1,77 @@ +import { createFileRoute, Outlet, redirect } from "@tanstack/react-router"; + +import { fetchAdminUser } from "@/functions/admin"; + +export const Route = createFileRoute("/_admin")({ + beforeLoad: async () => { + const user = await fetchAdminUser(); + + if (!user) { + throw redirect({ + to: "/auth", + search: { + flow: "web", + redirect: "/admin", + }, + }); + } + + if (!user.isAdmin) { + throw redirect({ + to: "/", + }); + } + + return { user }; + }, + component: AdminLayout, +}); + +function AdminLayout() { + const { user } = Route.useRouteContext(); + + return ( +
+
+
+ +
+
+
+ +
+
+ ); +} diff --git a/apps/web/src/routes/api/media-upload.ts b/apps/web/src/routes/api/media-upload.ts index ea09a11021..d8fc7f96fa 100644 --- a/apps/web/src/routes/api/media-upload.ts +++ b/apps/web/src/routes/api/media-upload.ts @@ -97,7 +97,7 @@ export const Route = createFileRoute("/api/media-upload")({ Accept: "application/vnd.github.v3+json", }, body: JSON.stringify({ - message: `Upload ${sanitizedFilename} via Decap CMS`, + message: `Upload ${sanitizedFilename} via Admin`, content, branch: GITHUB_BRANCH, }), From d24c9dad3039e08057bb12ac3e40e430f360ef52 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 10 Jan 2026 07:44:12 +0000 Subject: [PATCH 2/5] fix(web): rename _admin to admin to fix route conflict The _admin prefix creates a pathless layout route which conflicts with _view's index.tsx since both resolve to '/'. Renaming to 'admin' creates routes at /admin/* which don't conflict. Co-Authored-By: john@hyprnote.com --- apps/web/src/routes/{_admin => admin}/index.tsx | 2 +- apps/web/src/routes/{_admin => admin}/media/index.tsx | 2 +- apps/web/src/routes/{_admin => admin}/route.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) rename apps/web/src/routes/{_admin => admin}/index.tsx (97%) rename apps/web/src/routes/{_admin => admin}/media/index.tsx (91%) rename apps/web/src/routes/{_admin => admin}/route.tsx (97%) diff --git a/apps/web/src/routes/_admin/index.tsx b/apps/web/src/routes/admin/index.tsx similarity index 97% rename from apps/web/src/routes/_admin/index.tsx rename to apps/web/src/routes/admin/index.tsx index eb083666bb..913e8c818c 100644 --- a/apps/web/src/routes/_admin/index.tsx +++ b/apps/web/src/routes/admin/index.tsx @@ -1,6 +1,6 @@ import { createFileRoute } from "@tanstack/react-router"; -export const Route = createFileRoute("/_admin/")({ +export const Route = createFileRoute("/admin/")({ component: AdminDashboard, }); diff --git a/apps/web/src/routes/_admin/media/index.tsx b/apps/web/src/routes/admin/media/index.tsx similarity index 91% rename from apps/web/src/routes/_admin/media/index.tsx rename to apps/web/src/routes/admin/media/index.tsx index 73ac77df11..1f3ceff4a7 100644 --- a/apps/web/src/routes/_admin/media/index.tsx +++ b/apps/web/src/routes/admin/media/index.tsx @@ -1,6 +1,6 @@ import { createFileRoute } from "@tanstack/react-router"; -export const Route = createFileRoute("/_admin/media/")({ +export const Route = createFileRoute("/admin/media/")({ component: MediaLibrary, }); diff --git a/apps/web/src/routes/_admin/route.tsx b/apps/web/src/routes/admin/route.tsx similarity index 97% rename from apps/web/src/routes/_admin/route.tsx rename to apps/web/src/routes/admin/route.tsx index 769b952f21..d135c43579 100644 --- a/apps/web/src/routes/_admin/route.tsx +++ b/apps/web/src/routes/admin/route.tsx @@ -2,7 +2,7 @@ import { createFileRoute, Outlet, redirect } from "@tanstack/react-router"; import { fetchAdminUser } from "@/functions/admin"; -export const Route = createFileRoute("/_admin")({ +export const Route = createFileRoute("/admin")({ beforeLoad: async () => { const user = await fetchAdminUser(); From 2e8a73a8a49736eb7940759a3c21836c60eacd88 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 10 Jan 2026 07:56:32 +0000 Subject: [PATCH 3/5] feat(web): add media API endpoints for admin - Add github-media.ts with utilities for listing, uploading, deleting, moving files and creating folders via GitHub API - Add /api/admin/media/list endpoint for listing media files - Add /api/admin/media/upload endpoint for uploading files - Add /api/admin/media/delete endpoint for batch deleting files - Add /api/admin/media/move endpoint for moving/renaming files - Add /api/admin/media/create-folder endpoint for creating folders - All endpoints protected with admin authentication check Co-Authored-By: john@hyprnote.com --- apps/web/src/functions/github-media.ts | 413 ++++++++++++++++++ .../routes/api/admin/media/create-folder.ts | 62 +++ apps/web/src/routes/api/admin/media/delete.ts | 56 +++ apps/web/src/routes/api/admin/media/list.ts | 37 ++ apps/web/src/routes/api/admin/media/move.ts | 64 +++ apps/web/src/routes/api/admin/media/upload.ts | 65 +++ 6 files changed, 697 insertions(+) create mode 100644 apps/web/src/functions/github-media.ts create mode 100644 apps/web/src/routes/api/admin/media/create-folder.ts create mode 100644 apps/web/src/routes/api/admin/media/delete.ts create mode 100644 apps/web/src/routes/api/admin/media/list.ts create mode 100644 apps/web/src/routes/api/admin/media/move.ts create mode 100644 apps/web/src/routes/api/admin/media/upload.ts diff --git a/apps/web/src/functions/github-media.ts b/apps/web/src/functions/github-media.ts new file mode 100644 index 0000000000..006092c873 --- /dev/null +++ b/apps/web/src/functions/github-media.ts @@ -0,0 +1,413 @@ +import { env } from "@/env"; + +const GITHUB_REPO = "fastrepl/hyprnote"; +const GITHUB_BRANCH = "main"; +const IMAGES_PATH = "apps/web/public/images"; + +export interface GitHubFile { + name: string; + path: string; + sha: string; + size: number; + type: "file" | "dir"; + download_url: string | null; +} + +export interface MediaItem { + name: string; + path: string; + publicPath: string; + sha: string; + size: number; + type: "file" | "dir"; + downloadUrl: string | null; +} + +function getGitHubToken(): string | undefined { + return env.YUJONGLEE_GITHUB_TOKEN_REPO; +} + +function getPublicPath(fullPath: string): string { + return fullPath.replace("apps/web/public", ""); +} + +function getFullPath(relativePath: string): string { + if (relativePath.startsWith("/")) { + relativePath = relativePath.slice(1); + } + if (relativePath.startsWith("images")) { + return `apps/web/public/${relativePath}`; + } + return `${IMAGES_PATH}/${relativePath}`; +} + +export async function listMediaFiles( + path: string = "", +): Promise<{ items: MediaItem[]; error?: string }> { + const githubToken = getGitHubToken(); + if (!githubToken) { + return { items: [], error: "GitHub token not configured" }; + } + + const fullPath = path ? getFullPath(path) : IMAGES_PATH; + + try { + const response = await fetch( + `https://api.github.com/repos/${GITHUB_REPO}/contents/${fullPath}?ref=${GITHUB_BRANCH}`, + { + headers: { + Authorization: `token ${githubToken}`, + Accept: "application/vnd.github.v3+json", + }, + }, + ); + + if (!response.ok) { + if (response.status === 404) { + return { items: [], error: "Folder not found" }; + } + const error = await response.json(); + return { + items: [], + error: error.message || `GitHub API error: ${response.status}`, + }; + } + + const data = (await response.json()) as GitHubFile[]; + + if (!Array.isArray(data)) { + return { items: [], error: "Invalid response from GitHub API" }; + } + + const items: MediaItem[] = data.map((file) => ({ + name: file.name, + path: file.path, + publicPath: getPublicPath(file.path), + sha: file.sha, + size: file.size, + type: file.type, + downloadUrl: file.download_url, + })); + + const folders = items.filter((item) => item.type === "dir"); + const files = items.filter((item) => item.type === "file"); + folders.sort((a, b) => a.name.localeCompare(b.name)); + files.sort((a, b) => a.name.localeCompare(b.name)); + + return { items: [...folders, ...files] }; + } catch (error) { + return { + items: [], + error: `Failed to list files: ${(error as Error).message}`, + }; + } +} + +export async function uploadMediaFile( + filename: string, + content: string, + folder: string = "", +): Promise<{ + success: boolean; + path?: string; + publicPath?: string; + error?: string; +}> { + const githubToken = getGitHubToken(); + if (!githubToken) { + return { success: false, error: "GitHub token not configured" }; + } + + const timestamp = Date.now(); + const sanitizedFilename = `${timestamp}-${filename + .replace(/[^a-zA-Z0-9.-]/g, "-") + .toLowerCase()}`; + + const allowedExtensions = [ + "jpg", + "jpeg", + "png", + "gif", + "svg", + "webp", + "avif", + ]; + const ext = sanitizedFilename.toLowerCase().split(".").pop(); + + if (!ext || !allowedExtensions.includes(ext)) { + return { + success: false, + error: "Invalid file type. Only images are allowed.", + }; + } + + const fullFolder = folder ? getFullPath(folder) : IMAGES_PATH; + const path = `${fullFolder}/${sanitizedFilename}`; + + try { + const response = await fetch( + `https://api.github.com/repos/${GITHUB_REPO}/contents/${path}`, + { + method: "PUT", + headers: { + Authorization: `token ${githubToken}`, + "Content-Type": "application/json", + Accept: "application/vnd.github.v3+json", + }, + body: JSON.stringify({ + message: `Upload ${sanitizedFilename} via Admin`, + content, + branch: GITHUB_BRANCH, + }), + }, + ); + + if (!response.ok) { + const error = await response.json(); + return { + success: false, + error: error.message || `GitHub API error: ${response.status}`, + }; + } + + return { + success: true, + path, + publicPath: getPublicPath(path), + }; + } catch (error) { + return { + success: false, + error: `Upload failed: ${(error as Error).message}`, + }; + } +} + +export async function deleteMediaFiles( + paths: string[], +): Promise<{ success: boolean; deleted: string[]; errors: string[] }> { + const githubToken = getGitHubToken(); + if (!githubToken) { + return { + success: false, + deleted: [], + errors: ["GitHub token not configured"], + }; + } + + const deleted: string[] = []; + const errors: string[] = []; + + for (const path of paths) { + const fullPath = path.startsWith("apps/web/") ? path : getFullPath(path); + + try { + const getResponse = await fetch( + `https://api.github.com/repos/${GITHUB_REPO}/contents/${fullPath}?ref=${GITHUB_BRANCH}`, + { + headers: { + Authorization: `token ${githubToken}`, + Accept: "application/vnd.github.v3+json", + }, + }, + ); + + if (!getResponse.ok) { + errors.push( + `Failed to get file info for ${path}: ${getResponse.status}`, + ); + continue; + } + + const fileData = await getResponse.json(); + const sha = fileData.sha; + + const deleteResponse = await fetch( + `https://api.github.com/repos/${GITHUB_REPO}/contents/${fullPath}`, + { + method: "DELETE", + headers: { + Authorization: `token ${githubToken}`, + "Content-Type": "application/json", + Accept: "application/vnd.github.v3+json", + }, + body: JSON.stringify({ + message: `Delete ${path} via Admin`, + sha, + branch: GITHUB_BRANCH, + }), + }, + ); + + if (!deleteResponse.ok) { + const error = await deleteResponse.json(); + errors.push( + `Failed to delete ${path}: ${error.message || deleteResponse.status}`, + ); + continue; + } + + deleted.push(path); + } catch (error) { + errors.push(`Failed to delete ${path}: ${(error as Error).message}`); + } + } + + return { + success: errors.length === 0, + deleted, + errors, + }; +} + +export async function createMediaFolder( + folderName: string, + parentFolder: string = "", +): Promise<{ success: boolean; path?: string; error?: string }> { + const githubToken = getGitHubToken(); + if (!githubToken) { + return { success: false, error: "GitHub token not configured" }; + } + + const sanitizedFolderName = folderName + .replace(/[^a-zA-Z0-9-_]/g, "-") + .toLowerCase(); + const fullParent = parentFolder ? getFullPath(parentFolder) : IMAGES_PATH; + const path = `${fullParent}/${sanitizedFolderName}/.gitkeep`; + + try { + const response = await fetch( + `https://api.github.com/repos/${GITHUB_REPO}/contents/${path}`, + { + method: "PUT", + headers: { + Authorization: `token ${githubToken}`, + "Content-Type": "application/json", + Accept: "application/vnd.github.v3+json", + }, + body: JSON.stringify({ + message: `Create folder ${sanitizedFolderName} via Admin`, + content: "", + branch: GITHUB_BRANCH, + }), + }, + ); + + if (!response.ok) { + const error = await response.json(); + return { + success: false, + error: error.message || `GitHub API error: ${response.status}`, + }; + } + + return { + success: true, + path: `${fullParent}/${sanitizedFolderName}`, + }; + } catch (error) { + return { + success: false, + error: `Failed to create folder: ${(error as Error).message}`, + }; + } +} + +export async function moveMediaFile( + fromPath: string, + toPath: string, +): Promise<{ success: boolean; newPath?: string; error?: string }> { + const githubToken = getGitHubToken(); + if (!githubToken) { + return { success: false, error: "GitHub token not configured" }; + } + + const fullFromPath = fromPath.startsWith("apps/web/") + ? fromPath + : getFullPath(fromPath); + const fullToPath = toPath.startsWith("apps/web/") + ? toPath + : getFullPath(toPath); + + try { + const getResponse = await fetch( + `https://api.github.com/repos/${GITHUB_REPO}/contents/${fullFromPath}?ref=${GITHUB_BRANCH}`, + { + headers: { + Authorization: `token ${githubToken}`, + Accept: "application/vnd.github.v3+json", + }, + }, + ); + + if (!getResponse.ok) { + return { + success: false, + error: `Source file not found: ${getResponse.status}`, + }; + } + + const fileData = await getResponse.json(); + const content = fileData.content; + const sha = fileData.sha; + + const createResponse = await fetch( + `https://api.github.com/repos/${GITHUB_REPO}/contents/${fullToPath}`, + { + method: "PUT", + headers: { + Authorization: `token ${githubToken}`, + "Content-Type": "application/json", + Accept: "application/vnd.github.v3+json", + }, + body: JSON.stringify({ + message: `Move ${fromPath} to ${toPath} via Admin`, + content, + branch: GITHUB_BRANCH, + }), + }, + ); + + if (!createResponse.ok) { + const error = await createResponse.json(); + return { + success: false, + error: `Failed to create new file: ${error.message || createResponse.status}`, + }; + } + + const deleteResponse = await fetch( + `https://api.github.com/repos/${GITHUB_REPO}/contents/${fullFromPath}`, + { + method: "DELETE", + headers: { + Authorization: `token ${githubToken}`, + "Content-Type": "application/json", + Accept: "application/vnd.github.v3+json", + }, + body: JSON.stringify({ + message: `Move ${fromPath} to ${toPath} via Admin (delete original)`, + sha, + branch: GITHUB_BRANCH, + }), + }, + ); + + if (!deleteResponse.ok) { + return { + success: false, + error: "File copied but failed to delete original", + }; + } + + return { + success: true, + newPath: fullToPath, + }; + } catch (error) { + return { + success: false, + error: `Move failed: ${(error as Error).message}`, + }; + } +} diff --git a/apps/web/src/routes/api/admin/media/create-folder.ts b/apps/web/src/routes/api/admin/media/create-folder.ts new file mode 100644 index 0000000000..485917780a --- /dev/null +++ b/apps/web/src/routes/api/admin/media/create-folder.ts @@ -0,0 +1,62 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { fetchAdminUser } from "@/functions/admin"; +import { createMediaFolder } from "@/functions/github-media"; + +export const Route = createFileRoute("/api/admin/media/create-folder")({ + server: { + handlers: { + POST: async ({ request }) => { + const user = await fetchAdminUser(); + if (!user?.isAdmin) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }); + } + + let body: { name: string; parentFolder?: string }; + try { + body = await request.json(); + } catch { + return new Response(JSON.stringify({ error: "Invalid JSON body" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + + const { name, parentFolder } = body; + + if (!name) { + return new Response( + JSON.stringify({ error: "Missing required field: name" }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + }, + ); + } + + const result = await createMediaFolder(name, parentFolder || ""); + + if (!result.success) { + return new Response(JSON.stringify({ error: result.error }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + } + + return new Response( + JSON.stringify({ + success: true, + path: result.path, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ); + }, + }, + }, +}); diff --git a/apps/web/src/routes/api/admin/media/delete.ts b/apps/web/src/routes/api/admin/media/delete.ts new file mode 100644 index 0000000000..a9cfbafc73 --- /dev/null +++ b/apps/web/src/routes/api/admin/media/delete.ts @@ -0,0 +1,56 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { fetchAdminUser } from "@/functions/admin"; +import { deleteMediaFiles } from "@/functions/github-media"; + +export const Route = createFileRoute("/api/admin/media/delete")({ + server: { + handlers: { + POST: async ({ request }) => { + const user = await fetchAdminUser(); + if (!user?.isAdmin) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }); + } + + let body: { paths: string[] }; + try { + body = await request.json(); + } catch { + return new Response(JSON.stringify({ error: "Invalid JSON body" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + + const { paths } = body; + + if (!paths || !Array.isArray(paths) || paths.length === 0) { + return new Response( + JSON.stringify({ error: "Missing required field: paths (array)" }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + }, + ); + } + + const result = await deleteMediaFiles(paths); + + return new Response( + JSON.stringify({ + success: result.success, + deleted: result.deleted, + errors: result.errors, + }), + { + status: result.success ? 200 : 207, + headers: { "Content-Type": "application/json" }, + }, + ); + }, + }, + }, +}); diff --git a/apps/web/src/routes/api/admin/media/list.ts b/apps/web/src/routes/api/admin/media/list.ts new file mode 100644 index 0000000000..57bbb81667 --- /dev/null +++ b/apps/web/src/routes/api/admin/media/list.ts @@ -0,0 +1,37 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { fetchAdminUser } from "@/functions/admin"; +import { listMediaFiles } from "@/functions/github-media"; + +export const Route = createFileRoute("/api/admin/media/list")({ + server: { + handlers: { + GET: async ({ request }) => { + const user = await fetchAdminUser(); + if (!user?.isAdmin) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }); + } + + const url = new URL(request.url); + const path = url.searchParams.get("path") || ""; + + const result = await listMediaFiles(path); + + if (result.error) { + return new Response(JSON.stringify({ error: result.error }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + } + + return new Response(JSON.stringify({ items: result.items }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }, + }, + }, +}); diff --git a/apps/web/src/routes/api/admin/media/move.ts b/apps/web/src/routes/api/admin/media/move.ts new file mode 100644 index 0000000000..84def1e0f8 --- /dev/null +++ b/apps/web/src/routes/api/admin/media/move.ts @@ -0,0 +1,64 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { fetchAdminUser } from "@/functions/admin"; +import { moveMediaFile } from "@/functions/github-media"; + +export const Route = createFileRoute("/api/admin/media/move")({ + server: { + handlers: { + POST: async ({ request }) => { + const user = await fetchAdminUser(); + if (!user?.isAdmin) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }); + } + + let body: { fromPath: string; toPath: string }; + try { + body = await request.json(); + } catch { + return new Response(JSON.stringify({ error: "Invalid JSON body" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + + const { fromPath, toPath } = body; + + if (!fromPath || !toPath) { + return new Response( + JSON.stringify({ + error: "Missing required fields: fromPath, toPath", + }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + }, + ); + } + + const result = await moveMediaFile(fromPath, toPath); + + if (!result.success) { + return new Response(JSON.stringify({ error: result.error }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + } + + return new Response( + JSON.stringify({ + success: true, + newPath: result.newPath, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ); + }, + }, + }, +}); diff --git a/apps/web/src/routes/api/admin/media/upload.ts b/apps/web/src/routes/api/admin/media/upload.ts new file mode 100644 index 0000000000..017bec48f2 --- /dev/null +++ b/apps/web/src/routes/api/admin/media/upload.ts @@ -0,0 +1,65 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { fetchAdminUser } from "@/functions/admin"; +import { uploadMediaFile } from "@/functions/github-media"; + +export const Route = createFileRoute("/api/admin/media/upload")({ + server: { + handlers: { + POST: async ({ request }) => { + const user = await fetchAdminUser(); + if (!user?.isAdmin) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }); + } + + let body: { filename: string; content: string; folder?: string }; + try { + body = await request.json(); + } catch { + return new Response(JSON.stringify({ error: "Invalid JSON body" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + + const { filename, content, folder } = body; + + if (!filename || !content) { + return new Response( + JSON.stringify({ + error: "Missing required fields: filename, content", + }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + }, + ); + } + + const result = await uploadMediaFile(filename, content, folder || ""); + + if (!result.success) { + return new Response(JSON.stringify({ error: result.error }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + } + + return new Response( + JSON.stringify({ + success: true, + path: result.path, + publicPath: result.publicPath, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ); + }, + }, + }, +}); From bb7d38c0ec0f7ded99949590b7f85cd409da7773 Mon Sep 17 00:00:00 2001 From: John Jeong <63365510+ComputelessComputer@users.noreply.github.com> Date: Sun, 11 Jan 2026 23:43:29 +0900 Subject: [PATCH 4/5] Update apps/web/src/functions/github-media.ts Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com> --- apps/web/src/functions/github-media.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/web/src/functions/github-media.ts b/apps/web/src/functions/github-media.ts index 006092c873..8544ae0099 100644 --- a/apps/web/src/functions/github-media.ts +++ b/apps/web/src/functions/github-media.ts @@ -31,16 +31,21 @@ function getPublicPath(fullPath: string): string { return fullPath.replace("apps/web/public", ""); } + function getFullPath(relativePath: string): string { if (relativePath.startsWith("/")) { relativePath = relativePath.slice(1); } + // Sanitize path traversal by removing any directory traversal sequences + relativePath = relativePath.replace(/\.\.\//g, ""); + if (relativePath.startsWith("images")) { return `apps/web/public/${relativePath}`; } return `${IMAGES_PATH}/${relativePath}`; } + export async function listMediaFiles( path: string = "", ): Promise<{ items: MediaItem[]; error?: string }> { From f449ceec3b094d4307602af667975564e8a15a17 Mon Sep 17 00:00:00 2001 From: John Jeong <63365510+ComputelessComputer@users.noreply.github.com> Date: Mon, 12 Jan 2026 00:21:23 +0900 Subject: [PATCH 5/5] Clean up whitespace in github-media.ts Removed unnecessary blank lines in github-media.ts --- apps/web/src/functions/github-media.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/web/src/functions/github-media.ts b/apps/web/src/functions/github-media.ts index 8544ae0099..58222fd177 100644 --- a/apps/web/src/functions/github-media.ts +++ b/apps/web/src/functions/github-media.ts @@ -31,21 +31,19 @@ function getPublicPath(fullPath: string): string { return fullPath.replace("apps/web/public", ""); } - function getFullPath(relativePath: string): string { if (relativePath.startsWith("/")) { relativePath = relativePath.slice(1); } // Sanitize path traversal by removing any directory traversal sequences relativePath = relativePath.replace(/\.\.\//g, ""); - + if (relativePath.startsWith("images")) { return `apps/web/public/${relativePath}`; } return `${IMAGES_PATH}/${relativePath}`; } - export async function listMediaFiles( path: string = "", ): Promise<{ items: MediaItem[]; error?: string }> {