diff --git a/apps/web/src/routes/admin/README.md b/apps/web/src/routes/admin/README.md new file mode 100644 index 0000000000..bbd0317d30 --- /dev/null +++ b/apps/web/src/routes/admin/README.md @@ -0,0 +1,83 @@ +# Admin Interface + +The admin interface at `/admin` provides content management capabilities for the Hyprnote website. + +## Authentication + +Access is restricted to whitelisted email addresses. The whitelist is defined in `src/functions/admin.ts`: + +- yujonglee@hyprnote.com +- john@hyprnote.com +- harshika@hyprnote.com + +Users must be authenticated via Supabase to access admin routes. + +## Features + +### Media Library (`/admin/media`) + +Upload, organize, and manage media assets stored in `apps/web/public/images/`. + +- Drag-and-drop file upload +- Folder navigation with breadcrumbs +- Multi-select with batch delete, download, and move +- Context menu for individual file actions (rename, copy as PNG, download, delete) +- Sidebar with search, file type filters, and folder tree navigation + +### Google Docs Import (`/admin/import`) + +Import blog posts from published Google Docs with automatic HTML-to-Markdown conversion. + +1. Publish a Google Doc (File > Share > Publish to web) +2. Paste the published URL +3. Review and edit the generated MDX +4. Fill in metadata (title, author, description, cover image) +5. Select destination folder and save + +### Content Management (`/admin/content`) + +Browse and view MDX content files across all content folders: + +- Articles +- Changelog +- Documentation +- Handbook +- Legal +- Templates + +## API Endpoints + +All API endpoints require admin authentication. + +### Media APIs + +- `GET /api/admin/media/list` - List files in a directory +- `POST /api/admin/media/upload` - Upload files +- `POST /api/admin/media/delete` - Delete files +- `POST /api/admin/media/move` - Move/rename files +- `POST /api/admin/media/create-folder` - Create folders + +### Import APIs + +- `POST /api/admin/import/google-docs` - Parse published Google Doc +- `POST /api/admin/import/save` - Save MDX file to repository + +### Content APIs + +- `GET /api/admin/content/list` - List content files in a folder + +## Environment Variables + +The following environment variables are required: + +- `GITHUB_TOKEN` - GitHub personal access token with repo write access +- Supabase environment variables for authentication + +## Development + +The admin interface uses TanStack Router with file-based routing. Routes are defined in: + +- `src/routes/admin/` - Page components +- `src/routes/api/admin/` - API endpoints + +Admin authentication is handled by the `fetchAdminUser()` function which checks if the current user's email is in the whitelist. diff --git a/apps/web/src/routes/admin/content/index.tsx b/apps/web/src/routes/admin/content/index.tsx new file mode 100644 index 0000000000..495071fb0e --- /dev/null +++ b/apps/web/src/routes/admin/content/index.tsx @@ -0,0 +1,275 @@ +import { createFileRoute, Link } from "@tanstack/react-router"; +import { useCallback, useState } from "react"; + +export const Route = createFileRoute("/admin/content/")({ + component: ContentManagementPage, +}); + +interface ContentItem { + name: string; + path: string; + type: "file" | "dir"; + sha: string; + url: string; +} + +interface ContentFolder { + name: string; + path: string; + items: ContentItem[]; + loading: boolean; + expanded: boolean; +} + +const CONTENT_FOLDERS = [ + { name: "Articles", path: "articles" }, + { name: "Changelog", path: "changelog" }, + { name: "Documentation", path: "docs" }, + { name: "Handbook", path: "handbook" }, + { name: "Legal", path: "legal" }, + { name: "Templates", path: "templates" }, +]; + +function ContentManagementPage() { + const [folders, setFolders] = useState( + CONTENT_FOLDERS.map((f) => ({ + name: f.name, + path: f.path, + items: [], + loading: false, + expanded: false, + })), + ); + const [error, setError] = useState(null); + const [searchQuery, setSearchQuery] = useState(""); + + const fetchFolderContents = useCallback(async (folderPath: string) => { + setFolders((prev) => + prev.map((f) => (f.path === folderPath ? { ...f, loading: true } : f)), + ); + + try { + const response = await fetch( + `/api/admin/content/list?path=${encodeURIComponent(folderPath)}`, + ); + + if (!response.ok) { + const errorText = await response.text(); + try { + const errorData = JSON.parse(errorText); + throw new Error( + errorData.error || `Failed to fetch: ${response.status}`, + ); + } catch { + throw new Error(`Failed to fetch: ${response.status}`); + } + } + + const data = await response.json(); + + setFolders((prev) => + prev.map((f) => + f.path === folderPath + ? { ...f, items: data.items || [], loading: false, expanded: true } + : f, + ), + ); + } catch (err) { + setError((err as Error).message); + setFolders((prev) => + prev.map((f) => (f.path === folderPath ? { ...f, loading: false } : f)), + ); + } + }, []); + + const toggleFolder = (folderPath: string) => { + const folder = folders.find((f) => f.path === folderPath); + if (!folder) return; + + if (folder.expanded) { + setFolders((prev) => + prev.map((f) => + f.path === folderPath ? { ...f, expanded: false } : f, + ), + ); + } else if (folder.items.length === 0) { + fetchFolderContents(folderPath); + } else { + setFolders((prev) => + prev.map((f) => (f.path === folderPath ? { ...f, expanded: true } : f)), + ); + } + }; + + const filteredFolders = folders.map((folder) => ({ + ...folder, + items: folder.items.filter( + (item) => + item.name.toLowerCase().includes(searchQuery.toLowerCase()) || + searchQuery === "", + ), + })); + + const totalItems = folders.reduce( + (acc, folder) => acc + folder.items.length, + 0, + ); + + return ( +
+
+
+

+ Content Management +

+

+ Browse and manage MDX content files +

+
+ + Import from Google Docs + +
+ +
+ setSearchQuery(e.target.value)} + placeholder="Search content files..." + className="w-full px-4 py-2 border border-neutral-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+ + {error && ( +
+ {error} + +
+ )} + +
+ {filteredFolders.map((folder, index) => ( +
0 ? "border-t border-neutral-200" : ""} + > + + + {folder.expanded && folder.items.length > 0 && ( +
+ {folder.items + .filter((item) => item.type === "file") + .map((item) => ( +
+
+ + + + + {item.name} + +
+ +
+ ))} +
+ )} + + {folder.expanded && + folder.items.length === 0 && + !folder.loading && ( +
+ No files found +
+ )} +
+ ))} +
+ +
+ {totalItems > 0 + ? `${totalItems} content files loaded` + : "Click a folder to load its contents"} +
+
+ ); +} diff --git a/apps/web/src/routes/admin/import/index.tsx b/apps/web/src/routes/admin/import/index.tsx new file mode 100644 index 0000000000..a6dcb6c9a0 --- /dev/null +++ b/apps/web/src/routes/admin/import/index.tsx @@ -0,0 +1,395 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { useState } from "react"; + +export const Route = createFileRoute("/admin/import/")({ + component: ImportPage, +}); + +interface ImportResult { + success: boolean; + mdx?: string; + frontmatter?: { + meta_title: string; + display_title: string; + meta_description: string; + author: string; + coverImage: string; + featured: boolean; + published: boolean; + date: string; + }; + error?: string; +} + +interface SaveResult { + success: boolean; + path?: string; + url?: string; + error?: string; +} + +const CONTENT_FOLDERS = [ + { value: "articles", label: "Articles (Blog)" }, + { value: "changelog", label: "Changelog" }, + { value: "docs", label: "Documentation" }, + { value: "handbook", label: "Handbook" }, + { value: "legal", label: "Legal" }, + { value: "templates", label: "Templates" }, +]; + +function ImportPage() { + const [url, setUrl] = useState(""); + const [title, setTitle] = useState(""); + const [author, setAuthor] = useState(""); + const [description, setDescription] = useState(""); + const [coverImage, setCoverImage] = useState(""); + const [slug, setSlug] = useState(""); + const [folder, setFolder] = useState("articles"); + + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [result, setResult] = useState(null); + const [editedMdx, setEditedMdx] = useState(""); + const [saving, setSaving] = useState(false); + const [saveResult, setSaveResult] = useState(null); + + const handleImport = async () => { + if (!url) { + setError("Please enter a Google Docs URL"); + return; + } + + setLoading(true); + setError(null); + setResult(null); + setSaveResult(null); + + try { + const response = await fetch("/api/admin/import/google-docs", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + url, + title: title || undefined, + author: author || undefined, + description: description || undefined, + coverImage: coverImage || undefined, + slug: slug || undefined, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + try { + const errorData = JSON.parse(errorText); + setError( + errorData.error || `Import failed with status ${response.status}`, + ); + } catch { + setError(`Import failed: ${response.status} ${response.statusText}`); + } + return; + } + + const data: ImportResult = await response.json(); + + if (!data.success) { + setError(data.error || "Import failed"); + return; + } + + setResult(data); + setEditedMdx(data.mdx || ""); + + if (data.frontmatter) { + if (!title) setTitle(data.frontmatter.meta_title); + if (!author) setAuthor(data.frontmatter.author); + if (!description) setDescription(data.frontmatter.meta_description); + } + } catch (err) { + setError((err as Error).message); + } finally { + setLoading(false); + } + }; + + const handleSave = async () => { + if (!editedMdx || !slug) { + setError("Please provide content and a filename slug"); + return; + } + + setSaving(true); + setError(null); + setSaveResult(null); + + try { + const filename = slug.endsWith(".mdx") ? slug : `${slug}.mdx`; + + const response = await fetch("/api/admin/import/save", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + content: editedMdx, + filename, + folder, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + try { + const errorData = JSON.parse(errorText); + setError( + errorData.error || `Save failed with status ${response.status}`, + ); + } catch { + setError(`Save failed: ${response.status} ${response.statusText}`); + } + return; + } + + const data: SaveResult = await response.json(); + + if (!data.success) { + setError(data.error || "Save failed"); + return; + } + + setSaveResult(data); + } catch (err) { + setError((err as Error).message); + } finally { + setSaving(false); + } + }; + + const generateSlugFromTitle = () => { + if (title) { + const generatedSlug = title + .toLowerCase() + .replace(/[^a-z0-9\s-]/g, "") + .replace(/\s+/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); + setSlug(generatedSlug); + } + }; + + return ( +
+

+ Import from Google Docs +

+ +
+

+ Step 1: Enter Google Docs URL +

+

+ The document must be published to the web. Go to File > Share > + Publish to web in Google Docs. +

+ +
+
+ + setUrl(e.target.value)} + placeholder="https://docs.google.com/document/d/..." + className="w-full px-3 py-2 border border-neutral-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+ +
+
+ + setTitle(e.target.value)} + placeholder="Article title" + className="w-full px-3 py-2 border border-neutral-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+
+ + setAuthor(e.target.value)} + placeholder="Author name" + className="w-full px-3 py-2 border border-neutral-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+
+ +
+ +