Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
a62511c
feat(web): remove Decap CMS and add admin authentication foundation
devin-ai-integration[bot] Jan 10, 2026
d24c9da
fix(web): rename _admin to admin to fix route conflict
devin-ai-integration[bot] Jan 10, 2026
2e8a73a
feat(web): add media API endpoints for admin
devin-ai-integration[bot] Jan 10, 2026
1322613
feat(web): implement media library UI with grid, upload, and navigation
devin-ai-integration[bot] Jan 10, 2026
bdaeb1e
feat(web): add selection and batch actions to media library
devin-ai-integration[bot] Jan 10, 2026
5f9bd01
feat(web): add context menu and item actions to media library
devin-ai-integration[bot] Jan 10, 2026
1941c6c
feat(web): add sidebar with search, filters, and folder tree to media…
devin-ai-integration[bot] Jan 10, 2026
ea6d2b9
feat(web): add Google Docs import API endpoints
devin-ai-integration[bot] Jan 10, 2026
0261202
feat(web): add Google Docs import UI page
devin-ai-integration[bot] Jan 10, 2026
f6edabf
fix(web): use correct TanStack Router API pattern for import endpoints
devin-ai-integration[bot] Jan 10, 2026
996c450
fix(web): add HTTP response status check before parsing JSON
devin-ai-integration[bot] Jan 10, 2026
414a27e
fix(web): improve file existence check to handle non-404 errors
devin-ai-integration[bot] Jan 10, 2026
d00043f
fix(web): add response status check before creating blob in download …
devin-ai-integration[bot] Jan 10, 2026
3b229ff
feat(web): add content management UI for browsing MDX files
devin-ai-integration[bot] Jan 10, 2026
bd473b5
docs(web): add README for admin interface
devin-ai-integration[bot] Jan 10, 2026
296fcc1
Update apps/web/src/routes/admin/media/index.tsx
ComputelessComputer Jan 11, 2026
efeef81
Update apps/web/src/routes/admin/media/index.tsx
ComputelessComputer Jan 11, 2026
725a74b
Merge branch 'main' into devin/1768032794-media-selection-batch
ComputelessComputer Jan 11, 2026
9e5bcca
feat(routes): add admin media API routes to routeTree
ComputelessComputer Jan 11, 2026
dfbbadc
fix(web): improve batch download to continue on errors and delay URL …
devin-ai-integration[bot] Jan 11, 2026
58c9ff3
Merge branch 'devin/1768032794-media-selection-batch' into devin/1768…
devin-ai-integration[bot] Jan 11, 2026
580fe5e
Merge branch 'devin/1768032944-media-context-menu' into devin/1768033…
devin-ai-integration[bot] Jan 11, 2026
1980413
Merge branch 'devin/1768033096-media-sidebar-search' into devin/17680…
devin-ai-integration[bot] Jan 11, 2026
6fd3192
Merge branch 'devin/1768033298-google-docs-import' into devin/1768033…
devin-ai-integration[bot] Jan 11, 2026
3543658
Merge branch 'devin/1768033442-google-docs-import-ui' into devin/1768…
devin-ai-integration[bot] Jan 11, 2026
4c2cb8b
Merge branch 'devin/1768035431-blog-management-ui' into devin/1768035…
devin-ai-integration[bot] Jan 11, 2026
d96bc17
merge: resolve conflicts with main in media/index.tsx
devin-ai-integration[bot] Jan 11, 2026
a8f0679
merge: resolve conflicts with main in media/index.tsx
devin-ai-integration[bot] Jan 11, 2026
b0a52a2
fix(web): remove unused useEffect import in content/index.tsx
devin-ai-integration[bot] Jan 11, 2026
ec36923
Merge branch 'devin/1768035431-blog-management-ui' into devin/1768035…
devin-ai-integration[bot] Jan 11, 2026
662fd95
merge: resolve conflicts with main in media/index.tsx
devin-ai-integration[bot] Jan 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions apps/web/src/routes/admin/README.md
Original file line number Diff line number Diff line change
@@ -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.
275 changes: 275 additions & 0 deletions apps/web/src/routes/admin/content/index.tsx
Original file line number Diff line number Diff line change
@@ -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<ContentFolder[]>(
CONTENT_FOLDERS.map((f) => ({
name: f.name,
path: f.path,
items: [],
loading: false,
expanded: false,
})),
);
const [error, setError] = useState<string | null>(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 (
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-semibold text-neutral-900">
Content Management
</h1>
<p className="text-sm text-neutral-600 mt-1">
Browse and manage MDX content files
</p>
</div>
<Link
to="/admin/import"
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700"
>
Import from Google Docs
</Link>
</div>

<div className="mb-6">
<input
type="text"
value={searchQuery}
onChange={(e) => 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"
/>
</div>

{error && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700">
{error}
<button
onClick={() => setError(null)}
className="ml-2 text-red-500 hover:text-red-700"
>
Dismiss
</button>
</div>
)}

<div className="bg-white rounded-lg border border-neutral-200">
{filteredFolders.map((folder, index) => (
<div
key={folder.path}
className={index > 0 ? "border-t border-neutral-200" : ""}
>
<button
onClick={() => toggleFolder(folder.path)}
className="w-full px-4 py-3 flex items-center justify-between hover:bg-neutral-50 transition-colors"
>
<div className="flex items-center gap-3">
<span className="text-neutral-400">
{folder.expanded ? (
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
) : (
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
)}
</span>
<span className="text-lg font-medium text-neutral-900">
{folder.name}
</span>
{folder.items.length > 0 && (
<span className="text-sm text-neutral-500">
({folder.items.length} files)
</span>
)}
</div>
{folder.loading && (
<span className="text-sm text-neutral-500">Loading...</span>
)}
</button>

{folder.expanded && folder.items.length > 0 && (
<div className="border-t border-neutral-100 bg-neutral-50">
{folder.items
.filter((item) => item.type === "file")
.map((item) => (
<div
key={item.path}
className="px-4 py-2 pl-12 flex items-center justify-between hover:bg-neutral-100 transition-colors border-b border-neutral-100 last:border-b-0"
>
<div className="flex items-center gap-2">
<svg
className="w-4 h-4 text-neutral-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
<span className="text-sm text-neutral-700">
{item.name}
</span>
</div>
<div className="flex items-center gap-2">
<a
href={`https://github.com/fastrepl/hyprnote/blob/main/apps/web/content/${item.path}`}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-blue-600 hover:text-blue-800"
>
View on GitHub
</a>
</div>
</div>
))}
</div>
)}

{folder.expanded &&
folder.items.length === 0 &&
!folder.loading && (
<div className="px-4 py-3 pl-12 text-sm text-neutral-500 bg-neutral-50 border-t border-neutral-100">
No files found
</div>
)}
</div>
))}
</div>

<div className="mt-4 text-sm text-neutral-500">
{totalItems > 0
? `${totalItems} content files loaded`
: "Click a folder to load its contents"}
</div>
</div>
);
}
Loading
Loading