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"
+ />
+
+
+
+
+
+
+
+
+
+
+
+ setCoverImage(e.target.value)}
+ placeholder="/api/images/blog/slug/cover.png"
+ className="w-full px-3 py-2 border border-neutral-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
+ />
+
+
+
+
+ setSlug(e.target.value)}
+ placeholder="my-article-slug"
+ className="flex-1 px-3 py-2 border border-neutral-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
+ />
+
+
+
+
+
+
+
+
+
+ {error && (
+
+ {error}
+
+
+ )}
+
+ {result && result.success && (
+
+
+ Step 2: Review & Edit MDX
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ content/{folder}/
+
+ setSlug(e.target.value)}
+ placeholder="filename"
+ className="flex-1 px-3 py-2 border border-neutral-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
+ />
+ .mdx
+
+
+
+
+
+
+ )}
+
+ {saveResult && saveResult.success && (
+
+
File saved successfully!
+
+ Path:{" "}
+ {saveResult.path}
+
+ {saveResult.url && (
+
+
+ View on GitHub
+
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/apps/web/src/routes/admin/index.tsx b/apps/web/src/routes/admin/index.tsx
index 913e8c818c..5763f9fd77 100644
--- a/apps/web/src/routes/admin/index.tsx
+++ b/apps/web/src/routes/admin/index.tsx
@@ -25,7 +25,10 @@ function AdminDashboard() {
-
+
-
+
);
diff --git a/apps/web/src/routes/admin/media/index.tsx b/apps/web/src/routes/admin/media/index.tsx
index e7ebe4eb44..4ac2a31ea2 100644
--- a/apps/web/src/routes/admin/media/index.tsx
+++ b/apps/web/src/routes/admin/media/index.tsx
@@ -17,6 +17,15 @@ interface FolderOption {
depth: number;
}
+interface FolderTreeItem {
+ path: string;
+ name: string;
+ children: FolderTreeItem[];
+ expanded: boolean;
+}
+
+type FileTypeFilter = "all" | "images" | "videos" | "documents";
+
export const Route = createFileRoute("/admin/media/")({
component: MediaLibrary,
});
@@ -45,6 +54,10 @@ function MediaLibrary() {
const [renameItem, setRenameItem] = useState(null);
const [newName, setNewName] = useState("");
const [renaming, setRenaming] = useState(false);
+ const [searchQuery, setSearchQuery] = useState("");
+ const [fileTypeFilter, setFileTypeFilter] = useState("all");
+ const [folderTree, setFolderTree] = useState([]);
+ const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const fileInputRef = useRef(null);
const fetchItems = useCallback(async (path: string) => {
@@ -465,6 +478,104 @@ function MediaLibrary() {
setShowMoveDialog(true);
};
+ const fetchFolderTree = useCallback(async () => {
+ const buildTree = async (
+ path: string,
+ depth: number,
+ ): Promise => {
+ if (depth > 3) return [];
+ try {
+ const response = await fetch(
+ `/api/admin/media/list?path=${encodeURIComponent(path)}`,
+ );
+ const data = await response.json();
+ if (!response.ok) return [];
+
+ const folders = data.items.filter(
+ (item: MediaItem) => item.type === "dir",
+ );
+ const result: FolderTreeItem[] = [];
+
+ for (const folder of folders) {
+ const folderPath = path ? `${path}/${folder.name}` : folder.name;
+ const children = await buildTree(folderPath, depth + 1);
+ result.push({
+ path: folderPath,
+ name: folder.name,
+ children,
+ expanded: false,
+ });
+ }
+ return result;
+ } catch {
+ return [];
+ }
+ };
+
+ const tree = await buildTree("", 0);
+ setFolderTree(tree);
+ }, []);
+
+ useEffect(() => {
+ fetchFolderTree();
+ }, [fetchFolderTree]);
+
+ const toggleFolderExpanded = (path: string) => {
+ const updateTree = (items: FolderTreeItem[]): FolderTreeItem[] => {
+ return items.map((item) => {
+ if (item.path === path) {
+ return { ...item, expanded: !item.expanded };
+ }
+ if (item.children.length > 0) {
+ return { ...item, children: updateTree(item.children) };
+ }
+ return item;
+ });
+ };
+ setFolderTree(updateTree(folderTree));
+ };
+
+ const getFileExtension = (filename: string): string => {
+ const parts = filename.split(".");
+ return parts.length > 1 ? parts.pop()?.toLowerCase() || "" : "";
+ };
+
+ const matchesFileTypeFilter = (item: MediaItem): boolean => {
+ if (item.type === "dir") return true;
+ if (fileTypeFilter === "all") return true;
+
+ const ext = getFileExtension(item.name);
+ const imageExts = ["jpg", "jpeg", "png", "gif", "webp", "svg", "ico"];
+ const videoExts = ["mp4", "webm", "mov", "avi", "mkv"];
+ const docExts = ["pdf", "doc", "docx", "txt", "md", "mdx"];
+
+ switch (fileTypeFilter) {
+ case "images":
+ return imageExts.includes(ext);
+ case "videos":
+ return videoExts.includes(ext);
+ case "documents":
+ return docExts.includes(ext);
+ default:
+ return true;
+ }
+ };
+
+ const filteredItems = items.filter((item) => {
+ const matchesSearch =
+ searchQuery === "" ||
+ item.name.toLowerCase().includes(searchQuery.toLowerCase());
+ const matchesType = matchesFileTypeFilter(item);
+ return matchesSearch && matchesType;
+ });
+
+ const clearFilters = () => {
+ setSearchQuery("");
+ setFileTypeFilter("all");
+ };
+
+ const hasActiveFilters = searchQuery !== "" || fileTypeFilter !== "all";
+
useEffect(() => {
const handleClickOutside = () => closeContextMenu();
if (contextMenu) {
@@ -473,233 +584,526 @@ function MediaLibrary() {
}
}, [contextMenu]);
- return (
-
-
-
- Media Library
-
-
-
+ const renderFolderTree = (
+ items: FolderTreeItem[],
+ depth: number = 0,
+ ): React.ReactNode => {
+ return items.map((item) => (
+
+
+ {item.children.length > 0 && (
+
+ )}
+ {item.children.length === 0 &&
}
-
e.target.files && handleUpload(e.target.files)}
- />
+ {item.expanded &&
+ item.children.length > 0 &&
+ renderFolderTree(item.children, depth + 1)}
+ ));
+ };
- {selectedItems.size > 0 && (
-
-
-
- {selectedItems.size} item(s) selected
-
-
+ return (
+
+
+
+
+
+
+
setSearchQuery(e.target.value)}
+ placeholder="Search files..."
+ className="w-full pl-9 pr-3 py-2 text-sm border border-neutral-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
+ />
+
-
-
-
+
+
+
+ File Type
+
+
+ {(
+ [
+ { value: "all", label: "All Files" },
+ { value: "images", label: "Images" },
+ { value: "videos", label: "Videos" },
+ { value: "documents", label: "Documents" },
+ ] as const
+ ).map((option) => (
+
+ ))}
+
+
+
+ {hasActiveFilters && (
-
- )}
+
-