diff --git a/apps/web/public/admin/config.yml b/apps/web/public/admin/config.yml index b6203afe69..19a229f0b4 100644 --- a/apps/web/public/admin/config.yml +++ b/apps/web/public/admin/config.yml @@ -7,6 +7,9 @@ publish_mode: editorial_workflow media_folder: apps/web/public/images public_folder: /images +media_library: + name: github-images + collections: - name: articles label: Blog @@ -22,7 +25,7 @@ collections: name: meta_title widget: string hint: Title for SEO/browser tab (50-60 chars ideal) - pattern: ['^.{1,70}$', "Keep under 70 characters for SEO"] + pattern: ["^.{1,70}$", "Keep under 70 characters for SEO"] - label: Display Title name: display_title @@ -34,7 +37,7 @@ collections: name: meta_description widget: text hint: Description for SEO (150-160 chars ideal) - pattern: ['^.{50,200}$', "Aim for 150-160 characters"] + pattern: ["^.{50,200}$", "Aim for 150-160 characters"] - label: Author name: author diff --git a/apps/web/public/admin/index.html b/apps/web/public/admin/index.html index 29015a8c85..5662b2448b 100644 --- a/apps/web/public/admin/index.html +++ b/apps/web/public/admin/index.html @@ -10,6 +10,10 @@ + + + + diff --git a/apps/web/public/admin/registration.js b/apps/web/public/admin/registration.js index 90a32d585a..ab1f76c215 100644 --- a/apps/web/public/admin/registration.js +++ b/apps/web/public/admin/registration.js @@ -13,25 +13,6 @@ CMS.registerCustomFormat("mdx-custom", "mdx", { }, }); -CMS.registerEditorComponent({ - id: "mdx-image", - label: "Image", - fields: [ - { name: "src", label: "Source", widget: "string" }, - { name: "alt", label: "Alt Text", widget: "string" }, - ], - pattern: /^$/, - fromBlock: function (match) { - return { src: match[1], alt: match[2] }; - }, - toBlock: function (data) { - return `${data.alt}`; - }, - toPreview: function (data) { - return `${data.alt}`; - }, -}); - CMS.registerEditorComponent({ id: "cta-card", label: "CTA Card", @@ -47,4 +28,3 @@ CMS.registerEditorComponent({ return `
[CTA Card]
`; }, }); - diff --git a/apps/web/src/env.ts b/apps/web/src/env.ts index 9917c9b4bf..ef77ebf5f3 100644 --- a/apps/web/src/env.ts +++ b/apps/web/src/env.ts @@ -21,6 +21,7 @@ export const env = createEnv({ DEEPGRAM_API_KEY: z.string().min(1), GITHUB_TOKEN: z.string().optional(), + YUJONGLEE_GITHUB_TOKEN_REPO: z.string().optional(), }, clientPrefix: "VITE_", diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 2a8fd5529d..9175199c32 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -23,6 +23,7 @@ import { Route as ViewIndexRouteImport } from './routes/_view/index' import { Route as WebhookNangoRouteImport } from './routes/webhook/nango' import { Route as ApiTemplatesRouteImport } from './routes/api/templates' import { Route as ApiShortcutsRouteImport } from './routes/api/shortcuts' +import { Route as ApiMediaUploadRouteImport } from './routes/api/media-upload' import { Route as ApiK6ReportsRouteImport } from './routes/api/k6-reports' import { Route as ViewSecurityRouteImport } from './routes/_view/security' import { Route as ViewPrivacyRouteImport } from './routes/_view/privacy' @@ -172,6 +173,11 @@ const ApiShortcutsRoute = ApiShortcutsRouteImport.update({ path: '/api/shortcuts', getParentRoute: () => rootRouteImport, } as any) +const ApiMediaUploadRoute = ApiMediaUploadRouteImport.update({ + id: '/api/media-upload', + path: '/api/media-upload', + getParentRoute: () => rootRouteImport, +} as any) const ApiK6ReportsRoute = ApiK6ReportsRouteImport.update({ id: '/api/k6-reports', path: '/api/k6-reports', @@ -602,6 +608,7 @@ export interface FileRoutesByFullPath { '/privacy': typeof ViewPrivacyRoute '/security': typeof ViewSecurityRoute '/api/k6-reports': typeof ApiK6ReportsRoute + '/api/media-upload': typeof ApiMediaUploadRoute '/api/shortcuts': typeof ApiShortcutsRoute '/api/templates': typeof ApiTemplatesRoute '/webhook/nango': typeof WebhookNangoRoute @@ -693,6 +700,7 @@ export interface FileRoutesByTo { '/privacy': typeof ViewPrivacyRoute '/security': typeof ViewSecurityRoute '/api/k6-reports': typeof ApiK6ReportsRoute + '/api/media-upload': typeof ApiMediaUploadRoute '/api/shortcuts': typeof ApiShortcutsRoute '/api/templates': typeof ApiTemplatesRoute '/webhook/nango': typeof WebhookNangoRoute @@ -789,6 +797,7 @@ export interface FileRoutesById { '/_view/privacy': typeof ViewPrivacyRoute '/_view/security': typeof ViewSecurityRoute '/api/k6-reports': typeof ApiK6ReportsRoute + '/api/media-upload': typeof ApiMediaUploadRoute '/api/shortcuts': typeof ApiShortcutsRoute '/api/templates': typeof ApiTemplatesRoute '/webhook/nango': typeof WebhookNangoRoute @@ -885,6 +894,7 @@ export interface FileRouteTypes { | '/privacy' | '/security' | '/api/k6-reports' + | '/api/media-upload' | '/api/shortcuts' | '/api/templates' | '/webhook/nango' @@ -976,6 +986,7 @@ export interface FileRouteTypes { | '/privacy' | '/security' | '/api/k6-reports' + | '/api/media-upload' | '/api/shortcuts' | '/api/templates' | '/webhook/nango' @@ -1071,6 +1082,7 @@ export interface FileRouteTypes { | '/_view/privacy' | '/_view/security' | '/api/k6-reports' + | '/api/media-upload' | '/api/shortcuts' | '/api/templates' | '/webhook/nango' @@ -1154,6 +1166,7 @@ export interface RootRouteChildren { XRoute: typeof XRoute YoutubeRoute: typeof YoutubeRoute ApiK6ReportsRoute: typeof ApiK6ReportsRoute + ApiMediaUploadRoute: typeof ApiMediaUploadRoute ApiShortcutsRoute: typeof ApiShortcutsRoute ApiTemplatesRoute: typeof ApiTemplatesRoute WebhookNangoRoute: typeof WebhookNangoRoute @@ -1261,6 +1274,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiShortcutsRouteImport parentRoute: typeof rootRouteImport } + '/api/media-upload': { + id: '/api/media-upload' + path: '/api/media-upload' + fullPath: '/api/media-upload' + preLoaderRoute: typeof ApiMediaUploadRouteImport + parentRoute: typeof rootRouteImport + } '/api/k6-reports': { id: '/api/k6-reports' path: '/api/k6-reports' @@ -2025,6 +2045,7 @@ const rootRouteChildren: RootRouteChildren = { XRoute: XRoute, YoutubeRoute: YoutubeRoute, ApiK6ReportsRoute: ApiK6ReportsRoute, + ApiMediaUploadRoute: ApiMediaUploadRoute, ApiShortcutsRoute: ApiShortcutsRoute, ApiTemplatesRoute: ApiTemplatesRoute, WebhookNangoRoute: WebhookNangoRoute, diff --git a/apps/web/src/routes/api/media-upload.ts b/apps/web/src/routes/api/media-upload.ts new file mode 100644 index 0000000000..ea09a11021 --- /dev/null +++ b/apps/web/src/routes/api/media-upload.ts @@ -0,0 +1,149 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { env } from "@/env"; + +const GITHUB_REPO = "fastrepl/hyprnote"; +const GITHUB_BRANCH = "main"; +const ALLOWED_FOLDERS = [ + "apps/web/public/images", + "apps/web/public/images/blog", + "apps/web/public/images/handbook", +]; + +export const Route = createFileRoute("/api/media-upload")({ + server: { + handlers: { + POST: async ({ request }) => { + const githubToken = env.YUJONGLEE_GITHUB_TOKEN_REPO; + if (!githubToken) { + return new Response( + JSON.stringify({ error: "GitHub token not configured" }), + { + status: 500, + 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 || !folder) { + return new Response( + JSON.stringify({ + error: "Missing required fields: filename, content, folder", + }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + }, + ); + } + + if (!ALLOWED_FOLDERS.includes(folder)) { + return new Response(JSON.stringify({ error: "Invalid folder" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + + 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 new Response( + JSON.stringify({ + error: "Invalid file type. Only images are allowed.", + }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + }, + ); + } + + const path = `${folder}/${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 Decap CMS`, + content, + branch: GITHUB_BRANCH, + }), + }, + ); + + if (!response.ok) { + const error = await response.json(); + return new Response( + JSON.stringify({ + error: error.message || `GitHub API error: ${response.status}`, + }), + { + status: response.status, + headers: { "Content-Type": "application/json" }, + }, + ); + } + + const result = await response.json(); + const publicPath = path.replace("apps/web/public", ""); + + return new Response( + JSON.stringify({ + success: true, + path: publicPath, + url: result.content.download_url, + name: sanitizedFilename, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ); + } catch (error) { + return new Response( + JSON.stringify({ + error: `Upload failed: ${(error as Error).message}`, + }), + { + status: 500, + headers: { "Content-Type": "application/json" }, + }, + ); + } + }, + }, + }, +});