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 && ( - + +
+ {renderFolderTree(folderTree)}
- )} +
- - {error && ( -
- {error} - -
- )} + {showCreateFolder && ( +
+
+ setNewFolderName(e.target.value)} + placeholder="Folder name" + className="flex-1 px-3 py-2 border border-neutral-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + onKeyDown={(e) => e.key === "Enter" && handleCreateFolder()} + /> + + +
+
+ )} -
- {loading ? ( -
-
Loading...
-
- ) : items.length === 0 ? ( -
-

No files in this folder

-

- Drag and drop files here or click Upload -

-
- ) : ( -
- {items.map((item) => ( -
- item.type === "dir" - ? navigateToFolder( - currentPath ? `${currentPath}/${item.name}` : item.name, - ) - : toggleSelection(item.path) - } - onContextMenu={(e) => handleContextMenu(e, item)} + {error && ( +
+ {error} +
+ Clear filters + + ) : ( -
- {item.downloadUrl && ( - {item.name} + <> +

No files in this folder

+

+ Drag and drop files here or click Upload +

+ + )} +
+ ) : ( +
+ {filteredItems.map((item) => ( +
+ item.type === "dir" + ? navigateToFolder( + currentPath + ? `${currentPath}/${item.name}` + : item.name, + ) + : toggleSelection(item.path) + } + onContextMenu={(e) => handleContextMenu(e, item)} + > + {item.type === "dir" ? ( +
+ + + +
+ ) : ( +
+ {item.downloadUrl && ( + {item.name} + )} +
+ )} +
+

+ {item.name} +

+ {item.type === "file" && ( +

+ {formatFileSize(item.size)} +

+ )} +
+ {item.type === "file" && ( + )}
- )} -
-

+ )} +

+ + {showMoveDialog && ( +
+
+

+ Move {selectedItems.size} item(s) +

+

+ Select destination folder: +

+ +
+ +
- {item.type === "file" && ( +
+
+ )} + + {contextMenu && ( +
+ {contextMenu.item.type === "file" && ( + <> + - )} -
- ))} -
- )} -
- - {showMoveDialog && ( -
-
-

- Move {selectedItems.size} item(s) -

-

- Select destination folder: -

- -
- - -
-
-
- )} - - {contextMenu && ( -
- {contextMenu.item.type === "file" && ( - <> + +
+ + )} +
-
- +
)} - - -
- -
- )} - - {showRenameDialog && renameItem && ( -
-
-

- Rename "{renameItem.name}" -

-
- -
- setNewName(e.target.value)} - className="flex-1 px-3 py-2 border border-neutral-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" - onKeyDown={(e) => e.key === "Enter" && handleRename()} - autoFocus - /> - {renameItem.name.includes(".") && ( - - .{renameItem.name.split(".").pop()} - - )} + + {showRenameDialog && renameItem && ( +
+
+

+ Rename "{renameItem.name}" +

+
+ +
+ setNewName(e.target.value)} + className="flex-1 px-3 py-2 border border-neutral-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + onKeyDown={(e) => e.key === "Enter" && handleRename()} + autoFocus + /> + {renameItem.name.includes(".") && ( + + .{renameItem.name.split(".").pop()} + + )} +
+
+
+ + +
-
- - -
-
+ )}
- )} +
); } diff --git a/apps/web/src/routes/api/admin/import/google-docs.ts b/apps/web/src/routes/api/admin/import/google-docs.ts new file mode 100644 index 0000000000..f2642a9079 --- /dev/null +++ b/apps/web/src/routes/api/admin/import/google-docs.ts @@ -0,0 +1,271 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { fetchAdminUser } from "@/functions/admin"; + +interface ImportRequest { + url: string; + title?: string; + author?: string; + description?: string; + coverImage?: string; + slug?: string; +} + +interface ImportResponse { + success: boolean; + mdx?: string; + frontmatter?: Record; + error?: string; +} + +function extractGoogleDocsId(url: string): string | null { + const patterns = [ + /docs\.google\.com\/document\/d\/([a-zA-Z0-9_-]+)/, + /docs\.google\.com\/document\/u\/\d+\/d\/([a-zA-Z0-9_-]+)/, + /drive\.google\.com\/file\/d\/([a-zA-Z0-9_-]+)/, + ]; + + for (const pattern of patterns) { + const match = url.match(pattern); + if (match) { + return match[1]; + } + } + return null; +} + +function htmlToMarkdown(html: string): string { + let markdown = html; + + markdown = markdown.replace(/]*>[\s\S]*?<\/style>/gi, ""); + markdown = markdown.replace(/]*>[\s\S]*?<\/script>/gi, ""); + + markdown = markdown.replace(/]*>([\s\S]*?)<\/h1>/gi, "\n# $1\n"); + markdown = markdown.replace(/]*>([\s\S]*?)<\/h2>/gi, "\n## $1\n"); + markdown = markdown.replace(/]*>([\s\S]*?)<\/h3>/gi, "\n### $1\n"); + markdown = markdown.replace(/]*>([\s\S]*?)<\/h4>/gi, "\n#### $1\n"); + markdown = markdown.replace(/]*>([\s\S]*?)<\/h5>/gi, "\n##### $1\n"); + markdown = markdown.replace(/]*>([\s\S]*?)<\/h6>/gi, "\n###### $1\n"); + + markdown = markdown.replace(/]*>([\s\S]*?)<\/strong>/gi, "**$1**"); + markdown = markdown.replace(/]*>([\s\S]*?)<\/b>/gi, "**$1**"); + markdown = markdown.replace(/]*>([\s\S]*?)<\/em>/gi, "*$1*"); + markdown = markdown.replace(/]*>([\s\S]*?)<\/i>/gi, "*$1*"); + markdown = markdown.replace(/]*>([\s\S]*?)<\/u>/gi, "_$1_"); + markdown = markdown.replace(/]*>([\s\S]*?)<\/s>/gi, "~~$1~~"); + markdown = markdown.replace(/]*>([\s\S]*?)<\/strike>/gi, "~~$1~~"); + markdown = markdown.replace(/]*>([\s\S]*?)<\/del>/gi, "~~$1~~"); + + markdown = markdown.replace(/]*>([\s\S]*?)<\/code>/gi, "`$1`"); + markdown = markdown.replace( + /]*>([\s\S]*?)<\/pre>/gi, + "\n```\n$1\n```\n", + ); + + markdown = markdown.replace( + /]*href=["']([^"']*)["'][^>]*>([\s\S]*?)<\/a>/gi, + "[$2]($1)", + ); + + markdown = markdown.replace( + /]*src=["']([^"']*)["'][^>]*alt=["']([^"']*)["'][^>]*\/?>/gi, + "![$2]($1)", + ); + markdown = markdown.replace( + /]*alt=["']([^"']*)["'][^>]*src=["']([^"']*)["'][^>]*\/?>/gi, + "![$1]($2)", + ); + markdown = markdown.replace( + /]*src=["']([^"']*)["'][^>]*\/?>/gi, + "![]($1)", + ); + + markdown = markdown.replace(/]*>/gi, "\n"); + markdown = markdown.replace(/<\/ul>/gi, "\n"); + markdown = markdown.replace(/]*>/gi, "\n"); + markdown = markdown.replace(/<\/ol>/gi, "\n"); + markdown = markdown.replace(/]*>([\s\S]*?)<\/li>/gi, "- $1\n"); + + markdown = markdown.replace( + /]*>([\s\S]*?)<\/blockquote>/gi, + (_, content) => { + return content + .split("\n") + .map((line: string) => `> ${line}`) + .join("\n"); + }, + ); + + markdown = markdown.replace(/]*\/?>/gi, "\n---\n"); + + markdown = markdown.replace(/]*\/?>/gi, "\n"); + markdown = markdown.replace(/]*>([\s\S]*?)<\/p>/gi, "\n$1\n"); + markdown = markdown.replace(/]*>([\s\S]*?)<\/div>/gi, "\n$1\n"); + + markdown = markdown.replace(/]*>([\s\S]*?)<\/span>/gi, "$1"); + + markdown = markdown.replace(/<[^>]+>/g, ""); + + markdown = markdown.replace(/ /g, " "); + markdown = markdown.replace(/&/g, "&"); + markdown = markdown.replace(/</g, "<"); + markdown = markdown.replace(/>/g, ">"); + markdown = markdown.replace(/"/g, '"'); + markdown = markdown.replace(/'/g, "'"); + markdown = markdown.replace(/’/g, "'"); + markdown = markdown.replace(/‘/g, "'"); + markdown = markdown.replace(/”/g, '"'); + markdown = markdown.replace(/“/g, '"'); + markdown = markdown.replace(/—/g, "—"); + markdown = markdown.replace(/–/g, "–"); + markdown = markdown.replace(/…/g, "..."); + + markdown = markdown.replace(/\n{3,}/g, "\n\n"); + markdown = markdown.trim(); + + return markdown; +} + +function extractTitle(html: string): string | null { + const titleMatch = html.match(/]*>([\s\S]*?)<\/title>/i); + if (titleMatch) { + let title = titleMatch[1].trim(); + title = title.replace(/ - Google Docs$/, ""); + return title; + } + return null; +} + +function generateSlug(title: string): string { + return title + .toLowerCase() + .replace(/[^a-z0-9\s-]/g, "") + .replace(/\s+/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); +} + +function generateMdx( + content: string, + options: { + title: string; + author: string; + description: string; + coverImage: string; + }, +): string { + const today = new Date().toISOString().split("T")[0]; + + const frontmatter = `--- +meta_title: "${options.title}" +display_title: "${options.title}" +meta_description: "${options.description}" +author: "${options.author}" +coverImage: "${options.coverImage}" +featured: false +published: false +date: "${today}" +---`; + + return `${frontmatter}\n\n${content}`; +} + +export const Route = createFileRoute("/api/admin/import/google-docs")({ + 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" }, + }); + } + + try { + const body: ImportRequest = await request.json(); + const { url, title, author, description, coverImage, slug } = body; + + if (!url) { + return new Response( + JSON.stringify({ success: false, error: "URL is required" }), + { status: 400, headers: { "Content-Type": "application/json" } }, + ); + } + + const docId = extractGoogleDocsId(url); + if (!docId) { + return new Response( + JSON.stringify({ + success: false, + error: "Invalid Google Docs URL", + }), + { status: 400, headers: { "Content-Type": "application/json" } }, + ); + } + + const publishedUrl = `https://docs.google.com/document/d/${docId}/pub`; + const response = await fetch(publishedUrl); + + if (!response.ok) { + return new Response( + JSON.stringify({ + success: false, + error: + "Failed to fetch document. Make sure it is published to the web.", + }), + { status: 400, headers: { "Content-Type": "application/json" } }, + ); + } + + const html = await response.text(); + const extractedTitle = extractTitle(html) || "Untitled"; + const finalTitle = title || extractedTitle; + const finalSlug = slug || generateSlug(finalTitle); + + const bodyMatch = html.match(/]*>([\s\S]*?)<\/body>/i); + const bodyContent = bodyMatch ? bodyMatch[1] : html; + + const markdown = htmlToMarkdown(bodyContent); + + const mdx = generateMdx(markdown, { + title: finalTitle, + author: author || "Unknown", + description: description || "", + coverImage: coverImage || `/api/images/blog/${finalSlug}/cover.png`, + }); + + const frontmatter = { + meta_title: finalTitle, + display_title: finalTitle, + meta_description: description || "", + author: author || "Unknown", + coverImage: coverImage || `/api/images/blog/${finalSlug}/cover.png`, + featured: false, + published: false, + date: new Date().toISOString().split("T")[0], + }; + + const result: ImportResponse = { + success: true, + mdx, + frontmatter, + }; + + return new Response(JSON.stringify(result), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } catch (err) { + return new Response( + JSON.stringify({ + success: false, + error: (err as Error).message, + }), + { status: 500, headers: { "Content-Type": "application/json" } }, + ); + } + }, + }, + }, +}); diff --git a/apps/web/src/routes/api/admin/import/save.ts b/apps/web/src/routes/api/admin/import/save.ts new file mode 100644 index 0000000000..c5018eb491 --- /dev/null +++ b/apps/web/src/routes/api/admin/import/save.ts @@ -0,0 +1,166 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { fetchAdminUser } from "@/functions/admin"; + +interface SaveRequest { + content: string; + filename: string; + folder: string; +} + +export const Route = createFileRoute("/api/admin/import/save")({ + 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" }, + }); + } + + try { + const body: SaveRequest = await request.json(); + const { content, filename, folder } = body; + + if (!content || !filename || !folder) { + return new Response( + JSON.stringify({ + success: false, + error: "Content, filename, and folder are required", + }), + { status: 400, headers: { "Content-Type": "application/json" } }, + ); + } + + const validFolders = [ + "articles", + "changelog", + "docs", + "handbook", + "legal", + "templates", + ]; + if (!validFolders.includes(folder)) { + return new Response( + JSON.stringify({ + success: false, + error: `Invalid folder. Must be one of: ${validFolders.join(", ")}`, + }), + { status: 400, headers: { "Content-Type": "application/json" } }, + ); + } + + const safeFilename = filename + .replace(/[^a-zA-Z0-9-_.]/g, "-") + .replace(/-+/g, "-") + .toLowerCase(); + + if (!safeFilename.endsWith(".mdx")) { + return new Response( + JSON.stringify({ + success: false, + error: "Filename must end with .mdx", + }), + { status: 400, headers: { "Content-Type": "application/json" } }, + ); + } + + const owner = "fastrepl"; + const repo = "hyprnote"; + const path = `apps/web/content/${folder}/${safeFilename}`; + const branch = "main"; + + const token = process.env.GITHUB_TOKEN; + if (!token) { + return new Response( + JSON.stringify({ + success: false, + error: "GitHub token not configured", + }), + { status: 500, headers: { "Content-Type": "application/json" } }, + ); + } + + const checkResponse = await fetch( + `https://api.github.com/repos/${owner}/${repo}/contents/${path}?ref=${branch}`, + { + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github.v3+json", + }, + }, + ); + + if (checkResponse.status === 200) { + return new Response( + JSON.stringify({ + success: false, + error: `File already exists: ${path}`, + }), + { status: 409, headers: { "Content-Type": "application/json" } }, + ); + } else if (checkResponse.status !== 404) { + return new Response( + JSON.stringify({ + success: false, + error: `Failed to check file existence: ${checkResponse.statusText}`, + }), + { status: 500, headers: { "Content-Type": "application/json" } }, + ); + } + + const contentBase64 = Buffer.from(content).toString("base64"); + + const createResponse = await fetch( + `https://api.github.com/repos/${owner}/${repo}/contents/${path}`, + { + method: "PUT", + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github.v3+json", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + message: `Add ${folder}/${safeFilename} via admin import`, + content: contentBase64, + branch, + }), + }, + ); + + if (!createResponse.ok) { + const errorData = await createResponse.json(); + return new Response( + JSON.stringify({ + success: false, + error: errorData.message || "Failed to create file", + }), + { status: 500, headers: { "Content-Type": "application/json" } }, + ); + } + + const result = await createResponse.json(); + + return new Response( + JSON.stringify({ + success: true, + path, + url: result.content?.html_url, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } catch (err) { + return new Response( + JSON.stringify({ + success: false, + error: (err as Error).message, + }), + { status: 500, headers: { "Content-Type": "application/json" } }, + ); + } + }, + }, + }, +});