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

-
- ← Back to CMS -
-
- -
-
- - -
- -
-
- - - -
-
-
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..87dc1a3c37 --- /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", + "marketing@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/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 9175199c32..806e51e4c2 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -18,7 +18,9 @@ import { Route as DiscordRouteImport } from './routes/discord' import { Route as ContactRouteImport } from './routes/contact' import { Route as BountiesRouteImport } from './routes/bounties' import { Route as AuthRouteImport } from './routes/auth' +import { Route as AdminRouteRouteImport } from './routes/admin/route' import { Route as ViewRouteRouteImport } from './routes/_view/route' +import { Route as AdminIndexRouteImport } from './routes/admin/index' import { Route as ViewIndexRouteImport } from './routes/_view/index' import { Route as WebhookNangoRouteImport } from './routes/webhook/nango' import { Route as ApiTemplatesRouteImport } from './routes/api/templates' @@ -38,6 +40,7 @@ import { Route as ViewAboutRouteImport } from './routes/_view/about' import { Route as ViewDocsRouteRouteImport } from './routes/_view/docs/route' import { Route as ViewCompanyHandbookRouteRouteImport } from './routes/_view/company-handbook/route' import { Route as ViewAppRouteRouteImport } from './routes/_view/app/route' +import { Route as AdminMediaIndexRouteImport } from './routes/admin/media/index' import { Route as ViewTemplatesIndexRouteImport } from './routes/_view/templates/index' import { Route as ViewShortcutsIndexRouteImport } from './routes/_view/shortcuts/index' import { Route as ViewRoadmapIndexRouteImport } from './routes/_view/roadmap/index' @@ -149,10 +152,20 @@ const AuthRoute = AuthRouteImport.update({ path: '/auth', getParentRoute: () => rootRouteImport, } as any) +const AdminRouteRoute = AdminRouteRouteImport.update({ + id: '/admin', + path: '/admin', + getParentRoute: () => rootRouteImport, +} as any) const ViewRouteRoute = ViewRouteRouteImport.update({ id: '/_view', getParentRoute: () => rootRouteImport, } as any) +const AdminIndexRoute = AdminIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => AdminRouteRoute, +} as any) const ViewIndexRoute = ViewIndexRouteImport.update({ id: '/', path: '/', @@ -249,6 +262,11 @@ const ViewAppRouteRoute = ViewAppRouteRouteImport.update({ path: '/app', getParentRoute: () => ViewRouteRoute, } as any) +const AdminMediaIndexRoute = AdminMediaIndexRouteImport.update({ + id: '/media/', + path: '/media/', + getParentRoute: () => AdminRouteRoute, +} as any) const ViewTemplatesIndexRoute = ViewTemplatesIndexRouteImport.update({ id: '/templates/', path: '/templates/', @@ -585,6 +603,7 @@ const ViewGalleryTypeSlugRoute = ViewGalleryTypeSlugRouteImport.update({ } as any) export interface FileRoutesByFullPath { + '/admin': typeof AdminRouteRouteWithChildren '/auth': typeof AuthRoute '/bounties': typeof BountiesRoute '/contact': typeof ContactRoute @@ -613,6 +632,7 @@ export interface FileRoutesByFullPath { '/api/templates': typeof ApiTemplatesRoute '/webhook/nango': typeof WebhookNangoRoute '/': typeof ViewIndexRoute + '/admin/': typeof AdminIndexRoute '/app/account': typeof ViewAppAccountRoute '/app/checkout': typeof ViewAppCheckoutRoute '/app/file-transcription': typeof ViewAppFileTranscriptionRoute @@ -676,6 +696,7 @@ export interface FileRoutesByFullPath { '/roadmap': typeof ViewRoadmapIndexRoute '/shortcuts': typeof ViewShortcutsIndexRoute '/templates': typeof ViewTemplatesIndexRoute + '/admin/media': typeof AdminMediaIndexRoute '/gallery/$type/$slug': typeof ViewGalleryTypeSlugRoute '/integrations/$category/$slug': typeof ViewIntegrationsCategorySlugRoute } @@ -705,6 +726,7 @@ export interface FileRoutesByTo { '/api/templates': typeof ApiTemplatesRoute '/webhook/nango': typeof WebhookNangoRoute '/': typeof ViewIndexRoute + '/admin': typeof AdminIndexRoute '/app/account': typeof ViewAppAccountRoute '/app/checkout': typeof ViewAppCheckoutRoute '/app/file-transcription': typeof ViewAppFileTranscriptionRoute @@ -768,12 +790,14 @@ export interface FileRoutesByTo { '/roadmap': typeof ViewRoadmapIndexRoute '/shortcuts': typeof ViewShortcutsIndexRoute '/templates': typeof ViewTemplatesIndexRoute + '/admin/media': typeof AdminMediaIndexRoute '/gallery/$type/$slug': typeof ViewGalleryTypeSlugRoute '/integrations/$category/$slug': typeof ViewIntegrationsCategorySlugRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/_view': typeof ViewRouteRouteWithChildren + '/admin': typeof AdminRouteRouteWithChildren '/auth': typeof AuthRoute '/bounties': typeof BountiesRoute '/contact': typeof ContactRoute @@ -802,6 +826,7 @@ export interface FileRoutesById { '/api/templates': typeof ApiTemplatesRoute '/webhook/nango': typeof WebhookNangoRoute '/_view/': typeof ViewIndexRoute + '/admin/': typeof AdminIndexRoute '/_view/app/account': typeof ViewAppAccountRoute '/_view/app/checkout': typeof ViewAppCheckoutRoute '/_view/app/file-transcription': typeof ViewAppFileTranscriptionRoute @@ -865,12 +890,14 @@ export interface FileRoutesById { '/_view/roadmap/': typeof ViewRoadmapIndexRoute '/_view/shortcuts/': typeof ViewShortcutsIndexRoute '/_view/templates/': typeof ViewTemplatesIndexRoute + '/admin/media/': typeof AdminMediaIndexRoute '/_view/gallery/$type/$slug': typeof ViewGalleryTypeSlugRoute '/_view/integrations/$category/$slug': typeof ViewIntegrationsCategorySlugRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: + | '/admin' | '/auth' | '/bounties' | '/contact' @@ -899,6 +926,7 @@ export interface FileRouteTypes { | '/api/templates' | '/webhook/nango' | '/' + | '/admin/' | '/app/account' | '/app/checkout' | '/app/file-transcription' @@ -962,6 +990,7 @@ export interface FileRouteTypes { | '/roadmap' | '/shortcuts' | '/templates' + | '/admin/media' | '/gallery/$type/$slug' | '/integrations/$category/$slug' fileRoutesByTo: FileRoutesByTo @@ -991,6 +1020,7 @@ export interface FileRouteTypes { | '/api/templates' | '/webhook/nango' | '/' + | '/admin' | '/app/account' | '/app/checkout' | '/app/file-transcription' @@ -1054,11 +1084,13 @@ export interface FileRouteTypes { | '/roadmap' | '/shortcuts' | '/templates' + | '/admin/media' | '/gallery/$type/$slug' | '/integrations/$category/$slug' id: | '__root__' | '/_view' + | '/admin' | '/auth' | '/bounties' | '/contact' @@ -1087,6 +1119,7 @@ export interface FileRouteTypes { | '/api/templates' | '/webhook/nango' | '/_view/' + | '/admin/' | '/_view/app/account' | '/_view/app/checkout' | '/_view/app/file-transcription' @@ -1150,12 +1183,14 @@ export interface FileRouteTypes { | '/_view/roadmap/' | '/_view/shortcuts/' | '/_view/templates/' + | '/admin/media/' | '/_view/gallery/$type/$slug' | '/_view/integrations/$category/$slug' fileRoutesById: FileRoutesById } export interface RootRouteChildren { ViewRouteRoute: typeof ViewRouteRouteWithChildren + AdminRouteRoute: typeof AdminRouteRouteWithChildren AuthRoute: typeof AuthRoute BountiesRoute: typeof BountiesRoute ContactRoute: typeof ContactRoute @@ -1239,6 +1274,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthRouteImport parentRoute: typeof rootRouteImport } + '/admin': { + id: '/admin' + path: '/admin' + fullPath: '/admin' + preLoaderRoute: typeof AdminRouteRouteImport + parentRoute: typeof rootRouteImport + } '/_view': { id: '/_view' path: '' @@ -1246,6 +1288,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ViewRouteRouteImport parentRoute: typeof rootRouteImport } + '/admin/': { + id: '/admin/' + path: '/' + fullPath: '/admin/' + preLoaderRoute: typeof AdminIndexRouteImport + parentRoute: typeof AdminRouteRoute + } '/_view/': { id: '/_view/' path: '/' @@ -1379,6 +1428,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ViewAppRouteRouteImport parentRoute: typeof ViewRouteRoute } + '/admin/media/': { + id: '/admin/media/' + path: '/media' + fullPath: '/admin/media' + preLoaderRoute: typeof AdminMediaIndexRouteImport + parentRoute: typeof AdminRouteRoute + } '/_view/templates/': { id: '/_view/templates/' path: '/templates' @@ -2033,8 +2089,23 @@ const ViewRouteRouteWithChildren = ViewRouteRoute._addFileChildren( ViewRouteRouteChildren, ) +interface AdminRouteRouteChildren { + AdminIndexRoute: typeof AdminIndexRoute + AdminMediaIndexRoute: typeof AdminMediaIndexRoute +} + +const AdminRouteRouteChildren: AdminRouteRouteChildren = { + AdminIndexRoute: AdminIndexRoute, + AdminMediaIndexRoute: AdminMediaIndexRoute, +} + +const AdminRouteRouteWithChildren = AdminRouteRoute._addFileChildren( + AdminRouteRouteChildren, +) + const rootRouteChildren: RootRouteChildren = { ViewRouteRoute: ViewRouteRouteWithChildren, + AdminRouteRoute: AdminRouteRouteWithChildren, AuthRoute: AuthRoute, BountiesRoute: BountiesRoute, ContactRoute: ContactRoute, 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..913e8c818c --- /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..1f3ceff4a7 --- /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..d135c43579 --- /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, }),