New and Updated: ConvexFS Media Library with Bunny CDN integration ,OpenCode AI development tool integration, AI image generation download and copy options

This commit is contained in:
Wayne Sutton
2026-01-10 15:53:27 -08:00
parent d5d8de0058
commit 95cc8a4677
43 changed files with 5941 additions and 526 deletions

View File

@@ -0,0 +1,553 @@
import { useState, useRef, useCallback, useEffect } from "react";
import { useAction, usePaginatedQuery, useQuery } from "convex/react";
import { api } from "../../convex/_generated/api";
import {
X,
Upload,
CloudArrowUp,
Warning,
Image as ImageIcon,
Images,
ArrowsOut,
Check,
} from "@phosphor-icons/react";
// Derive the .site URL from Convex URL for uploads
const getSiteUrl = () => {
const convexUrl = import.meta.env.VITE_CONVEX_URL ?? "";
return convexUrl.replace(/\.cloud$/, ".site");
};
// Size presets for image insertion
const SIZE_PRESETS = [
{ id: "original", label: "Original", width: null, height: null },
{ id: "large", label: "Large", width: 1200, height: null },
{ id: "medium", label: "Medium", width: 800, height: null },
{ id: "small", label: "Small", width: 400, height: null },
{ id: "thumbnail", label: "Thumbnail", width: 200, height: null },
{ id: "custom", label: "Custom", width: null, height: null },
] as const;
type SizePreset = typeof SIZE_PRESETS[number]["id"];
interface ImageUploadModalProps {
isOpen: boolean;
onClose: () => void;
onInsert: (markdown: string) => void;
}
interface ImageInfo {
url: string;
width: number;
height: number;
filename: string;
}
export function ImageUploadModal({ isOpen, onClose, onInsert }: ImageUploadModalProps) {
const [activeTab, setActiveTab] = useState<"upload" | "library">("upload");
const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [preview, setPreview] = useState<string | null>(null);
const [altText, setAltText] = useState("");
const [selectedImage, setSelectedImage] = useState<ImageInfo | null>(null);
const [sizePreset, setSizePreset] = useState<SizePreset>("original");
const [customWidth, setCustomWidth] = useState<number | null>(null);
const [customHeight, setCustomHeight] = useState<number | null>(null);
const [dragOver, setDragOver] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const commitFile = useAction(api.files.commitFile);
const configStatus = useQuery(api.files.isConfigured);
const isBunnyConfigured = configStatus?.configured ?? false;
const { results: mediaFiles, status: mediaStatus, loadMore } = usePaginatedQuery(
api.files.listFiles,
{ prefix: "/uploads/" },
{ initialNumItems: 12 }
);
const siteUrl = getSiteUrl();
const cdnHostname = import.meta.env.VITE_BUNNY_CDN_HOSTNAME;
// Reset state when modal closes
const handleClose = () => {
setPreview(null);
setAltText("");
setSelectedImage(null);
setError(null);
setSizePreset("original");
setCustomWidth(null);
setCustomHeight(null);
setActiveTab("upload");
onClose();
};
// Get CDN URL for a file
const getCdnUrl = (path: string, blobId: string) => {
if (cdnHostname) {
return `https://${cdnHostname}${path}`;
}
return `${siteUrl}/fs/blobs/${blobId}`;
};
// Get image dimensions from URL
const getImageDimensionsFromUrl = (url: string): Promise<{ width: number; height: number }> => {
return new Promise((resolve) => {
const img = new window.Image();
img.onload = () => {
resolve({ width: img.naturalWidth, height: img.naturalHeight });
};
img.onerror = () => {
resolve({ width: 0, height: 0 });
};
img.src = url;
});
};
// Get image dimensions from file
const getImageDimensions = (file: File): Promise<{ width: number; height: number }> => {
return new Promise((resolve) => {
const img = new window.Image();
img.onload = () => {
resolve({ width: img.naturalWidth, height: img.naturalHeight });
URL.revokeObjectURL(img.src);
};
img.onerror = () => {
resolve({ width: 0, height: 0 });
URL.revokeObjectURL(img.src);
};
img.src = URL.createObjectURL(file);
});
};
// Calculate display dimensions based on preset
const getDisplayDimensions = () => {
if (!selectedImage) return { width: 0, height: 0 };
const { width: origWidth, height: origHeight } = selectedImage;
const aspectRatio = origWidth / origHeight;
if (sizePreset === "original") {
return { width: origWidth, height: origHeight };
}
if (sizePreset === "custom") {
if (customWidth && customHeight) {
return { width: customWidth, height: customHeight };
}
if (customWidth) {
return { width: customWidth, height: Math.round(customWidth / aspectRatio) };
}
if (customHeight) {
return { width: Math.round(customHeight * aspectRatio), height: customHeight };
}
return { width: origWidth, height: origHeight };
}
const preset = SIZE_PRESETS.find((p) => p.id === sizePreset);
if (preset?.width) {
const newWidth = Math.min(preset.width, origWidth);
return { width: newWidth, height: Math.round(newWidth / aspectRatio) };
}
return { width: origWidth, height: origHeight };
};
// Handle file upload
const handleUpload = useCallback(async (file: File) => {
setError(null);
setUploading(true);
setUploadProgress("Uploading...");
try {
// Validate file type
if (!file.type.startsWith("image/")) {
throw new Error("File must be an image");
}
// Validate file size (10MB max)
if (file.size > 10 * 1024 * 1024) {
throw new Error("File exceeds 10MB limit");
}
// Show preview
const previewUrl = URL.createObjectURL(file);
setPreview(previewUrl);
setAltText(file.name.replace(/\.[^/.]+$/, "").replace(/[-_]/g, " "));
// Get image dimensions
const dimensions = await getImageDimensions(file);
// Upload blob to ConvexFS endpoint
const res = await fetch(`${siteUrl}/fs/upload`, {
method: "POST",
headers: { "Content-Type": file.type },
body: file,
});
if (!res.ok) {
const errorText = await res.text();
throw new Error(errorText || `Upload failed: ${res.status}`);
}
const { blobId } = await res.json();
// Commit file to storage path
const result = await commitFile({
blobId,
filename: file.name,
contentType: file.type,
size: file.size,
width: dimensions.width,
height: dimensions.height,
});
// Get CDN URL
const url = getCdnUrl(result.path, blobId);
setSelectedImage({
url,
width: dimensions.width,
height: dimensions.height,
filename: file.name,
});
setUploadProgress(null);
} catch (err) {
setError((err as Error).message);
setPreview(null);
} finally {
setUploading(false);
setUploadProgress(null);
}
}, [commitFile, siteUrl, cdnHostname]);
// Handle file input change
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
handleUpload(file);
}
};
// Handle drag and drop
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
setDragOver(true);
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
const file = e.dataTransfer.files[0];
if (file) {
handleUpload(file);
}
};
// Handle selecting from media library
const handleSelectFromLibrary = async (file: { path: string; blobId: string; size: number }) => {
const url = getCdnUrl(file.path, file.blobId);
const filename = file.path.split("/").pop() || "image";
// Get dimensions from URL
const dimensions = await getImageDimensionsFromUrl(url);
setSelectedImage({
url,
width: dimensions.width,
height: dimensions.height,
filename,
});
setPreview(url);
setAltText(filename.replace(/\.[^/.]+$/, "").replace(/[-_]/g, " "));
};
// Generate markdown with size
const generateMarkdown = () => {
if (!selectedImage) return "";
const dims = getDisplayDimensions();
const alt = altText || "image";
// For original size, just use standard markdown
if (sizePreset === "original") {
return `![${alt}](${selectedImage.url})`;
}
// For other sizes, use HTML img tag with explicit dimensions
return `<img src="${selectedImage.url}" alt="${alt}" width="${dims.width}" height="${dims.height}" />`;
};
// Insert markdown
const handleInsert = () => {
if (selectedImage) {
const markdown = generateMarkdown();
onInsert(markdown);
handleClose();
}
};
// Update custom dimensions when preset changes
useEffect(() => {
if (sizePreset !== "custom" && selectedImage) {
const dims = getDisplayDimensions();
setCustomWidth(dims.width);
setCustomHeight(dims.height);
}
}, [sizePreset, selectedImage]);
if (!isOpen) return null;
const displayDims = getDisplayDimensions();
return (
<div className="image-upload-modal-backdrop" onClick={handleClose}>
<div
className="image-upload-modal image-upload-modal-large"
onClick={(e) => e.stopPropagation()}
>
<div className="image-upload-modal-header">
<h3>
<ImageIcon size={20} />
Insert Image
</h3>
<button className="image-upload-modal-close" onClick={handleClose}>
<X size={20} />
</button>
</div>
{/* Tabs */}
<div className="image-upload-tabs">
<button
className={`image-upload-tab ${activeTab === "upload" ? "active" : ""}`}
onClick={() => setActiveTab("upload")}
>
<Upload size={16} />
Upload New
</button>
<button
className={`image-upload-tab ${activeTab === "library" ? "active" : ""}`}
onClick={() => setActiveTab("library")}
disabled={!isBunnyConfigured}
>
<Images size={16} />
Media Library
</button>
</div>
<div className="image-upload-modal-content">
{/* Error message */}
{error && (
<div className="image-upload-error">
<Warning size={16} />
<span>{error}</span>
</div>
)}
{activeTab === "upload" && !selectedImage && (
<div
className={`image-upload-dropzone ${dragOver ? "drag-over" : ""}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
>
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/gif,image/webp"
onChange={handleFileChange}
style={{ display: "none" }}
/>
{uploading ? (
<>
<CloudArrowUp size={48} className="spinning" />
<p>{uploadProgress}</p>
</>
) : (
<>
<Upload size={48} />
<p>
<strong>Click to upload</strong> or drag and drop
</p>
<span>PNG, JPG, GIF, WebP up to 10MB</span>
</>
)}
</div>
)}
{activeTab === "library" && !selectedImage && (
<div className="image-upload-library">
{!isBunnyConfigured ? (
<div className="image-upload-library-empty">
<Warning size={32} />
<p>Media library not configured</p>
</div>
) : mediaFiles.length === 0 ? (
<div className="image-upload-library-empty">
<Images size={32} />
<p>No images in library</p>
<button onClick={() => setActiveTab("upload")}>Upload an image</button>
</div>
) : (
<>
<div className="image-upload-library-grid">
{mediaFiles.map((file) => (
<div
key={file.path}
className="image-upload-library-item"
onClick={() => handleSelectFromLibrary(file)}
>
<img
src={getCdnUrl(file.path, file.blobId)}
alt={file.path.split("/").pop()}
loading="lazy"
/>
</div>
))}
</div>
{mediaStatus === "CanLoadMore" && (
<button
className="image-upload-library-loadmore"
onClick={() => loadMore(12)}
>
Load more
</button>
)}
</>
)}
</div>
)}
{/* Preview and settings when image is selected */}
{selectedImage && (
<div className="image-upload-selected">
<div className="image-upload-preview-container">
<div className="image-upload-preview">
<img src={preview || selectedImage.url} alt="Preview" />
{uploading && (
<div className="image-upload-preview-loading">
<CloudArrowUp size={32} className="spinning" />
<span>{uploadProgress}</span>
</div>
)}
</div>
<div className="image-upload-dimensions">
<ArrowsOut size={14} />
<span>
{selectedImage.width} x {selectedImage.height}px
{sizePreset !== "original" && (
<> {displayDims.width} x {displayDims.height}px</>
)}
</span>
</div>
</div>
<div className="image-upload-settings">
{/* Alt text input */}
<div className="image-upload-field">
<label htmlFor="alt-text">Alt text</label>
<input
id="alt-text"
type="text"
value={altText}
onChange={(e) => setAltText(e.target.value)}
placeholder="Describe the image..."
/>
</div>
{/* Size presets */}
<div className="image-upload-field">
<label>Size</label>
<div className="image-upload-size-presets">
{SIZE_PRESETS.map((preset) => (
<button
key={preset.id}
className={`image-upload-size-btn ${sizePreset === preset.id ? "active" : ""}`}
onClick={() => setSizePreset(preset.id)}
>
{sizePreset === preset.id && <Check size={12} />}
{preset.label}
{preset.width && <span className="size-hint">{preset.width}px</span>}
</button>
))}
</div>
</div>
{/* Custom dimensions */}
{sizePreset === "custom" && (
<div className="image-upload-custom-size">
<div className="image-upload-field-inline">
<label>Width</label>
<input
type="number"
value={customWidth || ""}
onChange={(e) => {
const val = parseInt(e.target.value) || null;
setCustomWidth(val);
if (val && selectedImage) {
const ratio = selectedImage.width / selectedImage.height;
setCustomHeight(Math.round(val / ratio));
}
}}
placeholder="Auto"
/>
<span>px</span>
</div>
<div className="image-upload-field-inline">
<label>Height</label>
<input
type="number"
value={customHeight || ""}
onChange={(e) => {
const val = parseInt(e.target.value) || null;
setCustomHeight(val);
if (val && selectedImage) {
const ratio = selectedImage.width / selectedImage.height;
setCustomWidth(Math.round(val * ratio));
}
}}
placeholder="Auto"
/>
<span>px</span>
</div>
</div>
)}
{/* Change image button */}
<button
className="image-upload-change"
onClick={() => {
setSelectedImage(null);
setPreview(null);
}}
>
Choose different image
</button>
</div>
</div>
)}
</div>
<div className="image-upload-modal-footer">
<button className="image-upload-cancel" onClick={handleClose}>
Cancel
</button>
<button
className="image-upload-insert"
onClick={handleInsert}
disabled={!selectedImage || uploading}
>
{uploading ? "Uploading..." : "Insert"}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,509 @@
import { useState, useRef, useCallback } from "react";
import { useAction, usePaginatedQuery, useMutation, useQuery } from "convex/react";
import { api } from "../../convex/_generated/api";
import {
Image as ImageIcon,
Upload,
Trash,
CopySimple,
Check,
Link as LinkIcon,
Code,
X,
Warning,
CloudArrowUp,
CheckSquare,
Square,
SelectionAll,
} from "@phosphor-icons/react";
// Derive the .site URL from Convex URL for uploads
const getSiteUrl = () => {
const convexUrl = import.meta.env.VITE_CONVEX_URL ?? "";
return convexUrl.replace(/\.cloud$/, ".site");
};
// File metadata type from ConvexFS
interface FileInfo {
path: string;
blobId: string;
contentType: string;
size: number;
}
// Copy format options
type CopyFormat = "markdown" | "html" | "url";
export function MediaLibrary() {
const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [dragOver, setDragOver] = useState(false);
const [copiedPath, setCopiedPath] = useState<string | null>(null);
const [copiedFormat, setCopiedFormat] = useState<CopyFormat | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
const [selectMode, setSelectMode] = useState(false);
const [bulkDeleteConfirm, setBulkDeleteConfirm] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Check if Bunny CDN is configured (server-side check)
const configStatus = useQuery(api.files.isConfigured);
const isBunnyConfigured = configStatus?.configured ?? false;
// Convex hooks
const commitFile = useAction(api.files.commitFile);
const deleteFile = useMutation(api.files.deleteFile);
const deleteFiles = useMutation(api.files.deleteFiles);
const { results, status, loadMore } = usePaginatedQuery(
api.files.listFiles,
{ prefix: "/uploads/" },
{ initialNumItems: 20 }
);
const siteUrl = getSiteUrl();
const cdnHostname = import.meta.env.VITE_BUNNY_CDN_HOSTNAME;
// Get CDN URL for a file
const getCdnUrl = (file: FileInfo) => {
// Use the Bunny CDN hostname if configured
if (cdnHostname) {
return `https://${cdnHostname}${file.path}`;
}
// Fallback to ConvexFS blob URL
return `${siteUrl}/fs/blobs/${file.blobId}`;
};
// Handle file upload
const handleUpload = useCallback(async (files: FileList | null) => {
if (!files || files.length === 0) return;
setError(null);
setUploading(true);
for (let i = 0; i < files.length; i++) {
const file = files[i];
setUploadProgress(`Uploading ${file.name} (${i + 1}/${files.length})...`);
try {
// Validate file type
if (!file.type.startsWith("image/")) {
throw new Error(`${file.name} is not an image`);
}
// Validate file size (10MB max)
if (file.size > 10 * 1024 * 1024) {
throw new Error(`${file.name} exceeds 10MB limit`);
}
// Get image dimensions
const dimensions = await getImageDimensions(file);
// Upload blob to ConvexFS endpoint
const res = await fetch(`${siteUrl}/fs/upload`, {
method: "POST",
headers: { "Content-Type": file.type },
body: file,
});
if (!res.ok) {
const errorText = await res.text();
throw new Error(errorText || `Upload failed: ${res.status}`);
}
const { blobId } = await res.json();
// Commit file to storage path
await commitFile({
blobId,
filename: file.name,
contentType: file.type,
size: file.size,
width: dimensions.width,
height: dimensions.height,
});
} catch (err) {
setError((err as Error).message);
break;
}
}
setUploading(false);
setUploadProgress(null);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}, [commitFile, siteUrl]);
// Get image dimensions
const getImageDimensions = (file: File): Promise<{ width: number; height: number }> => {
return new Promise((resolve) => {
const img = new window.Image();
img.onload = () => {
resolve({ width: img.naturalWidth, height: img.naturalHeight });
URL.revokeObjectURL(img.src);
};
img.onerror = () => {
resolve({ width: 0, height: 0 });
URL.revokeObjectURL(img.src);
};
img.src = URL.createObjectURL(file);
});
};
// Handle file input change
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
handleUpload(e.target.files);
};
// Handle drag and drop
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
setDragOver(true);
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
handleUpload(e.dataTransfer.files);
};
// Copy to clipboard
const handleCopy = async (file: FileInfo, format: CopyFormat) => {
const url = getCdnUrl(file);
const filename = file.path.split("/").pop() || "image";
let text = "";
switch (format) {
case "markdown":
text = `![${filename}](${url})`;
break;
case "html":
text = `<img src="${url}" alt="${filename}" />`;
break;
case "url":
text = url;
break;
}
try {
await navigator.clipboard.writeText(text);
setCopiedPath(file.path);
setCopiedFormat(format);
setTimeout(() => {
setCopiedPath(null);
setCopiedFormat(null);
}, 2000);
} catch {
setError("Failed to copy to clipboard");
}
};
// Delete file
const handleDelete = async (path: string) => {
try {
await deleteFile({ path });
setDeleteConfirm(null);
} catch (err) {
setError((err as Error).message);
}
};
// Toggle file selection
const toggleFileSelection = (path: string) => {
setSelectedFiles((prev) => {
const next = new Set(prev);
if (next.has(path)) {
next.delete(path);
} else {
next.add(path);
}
return next;
});
};
// Select all files
const selectAllFiles = () => {
setSelectedFiles(new Set(results.map((f) => f.path)));
};
// Clear selection
const clearSelection = () => {
setSelectedFiles(new Set());
setSelectMode(false);
};
// Bulk delete files
const handleBulkDelete = async () => {
try {
await deleteFiles({ paths: Array.from(selectedFiles) });
setSelectedFiles(new Set());
setSelectMode(false);
setBulkDeleteConfirm(false);
} catch (err) {
setError((err as Error).message);
}
};
// Format file size
const formatSize = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
};
return (
<div className="media-library">
<div className="media-library-header">
<ImageIcon size={32} weight="light" />
<h2>Media Library</h2>
<p>Upload and manage images for your content</p>
</div>
{/* Configuration Status */}
{!isBunnyConfigured && (
<div className="media-config-warning">
<Warning size={20} />
<div>
<strong>Bunny CDN not configured</strong>
<p>
Set BUNNY_API_KEY, BUNNY_STORAGE_ZONE, and BUNNY_CDN_HOSTNAME
environment variables in Convex Dashboard.
See <a href="/docs-media-setup">setup guide</a>.
</p>
</div>
</div>
)}
{/* Selection toolbar */}
{results.length > 0 && (
<div className="media-toolbar">
<button
className={`media-toolbar-btn ${selectMode ? "active" : ""}`}
onClick={() => {
if (selectMode) {
clearSelection();
} else {
setSelectMode(true);
}
}}
>
<SelectionAll size={16} />
<span>{selectMode ? "Cancel" : "Select"}</span>
</button>
{selectMode && (
<>
<button className="media-toolbar-btn" onClick={selectAllFiles}>
<CheckSquare size={16} />
<span>Select All</span>
</button>
{selectedFiles.size > 0 && (
<button
className="media-toolbar-btn danger"
onClick={() => setBulkDeleteConfirm(true)}
>
<Trash size={16} />
<span>Delete ({selectedFiles.size})</span>
</button>
)}
</>
)}
</div>
)}
{/* Bulk delete confirmation */}
{bulkDeleteConfirm && (
<div className="media-bulk-delete-confirm">
<Warning size={20} />
<p>Delete {selectedFiles.size} selected images?</p>
<div className="media-bulk-delete-actions">
<button className="cancel" onClick={() => setBulkDeleteConfirm(false)}>
Cancel
</button>
<button className="confirm" onClick={handleBulkDelete}>
Delete All
</button>
</div>
</div>
)}
{/* Error message */}
{error && (
<div className="media-error">
<Warning size={16} />
<span>{error}</span>
<button onClick={() => setError(null)}>
<X size={14} />
</button>
</div>
)}
{/* Upload zone */}
<div
className={`media-upload-zone ${dragOver ? "drag-over" : ""} ${uploading ? "uploading" : ""}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => !uploading && fileInputRef.current?.click()}
>
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/gif,image/webp"
multiple
onChange={handleFileChange}
style={{ display: "none" }}
/>
{uploading ? (
<>
<CloudArrowUp size={48} className="upload-icon spinning" />
<p>{uploadProgress}</p>
</>
) : (
<>
<Upload size={48} className="upload-icon" />
<p>
<strong>Click to upload</strong> or drag and drop
</p>
<span>PNG, JPG, GIF, WebP up to 10MB</span>
</>
)}
</div>
{/* File grid */}
<div className="media-grid">
{results.map((file) => (
<div
key={file.path}
className={`media-item ${selectMode ? "select-mode" : ""} ${selectedFiles.has(file.path) ? "selected" : ""}`}
onClick={selectMode ? () => toggleFileSelection(file.path) : undefined}
>
{selectMode && (
<div className="media-item-checkbox">
{selectedFiles.has(file.path) ? (
<CheckSquare size={20} weight="fill" />
) : (
<Square size={20} />
)}
</div>
)}
<div className="media-item-preview">
<img
src={getCdnUrl(file)}
alt={file.path.split("/").pop()}
loading="lazy"
/>
</div>
<div className="media-item-info">
<span className="media-item-name" title={file.path}>
{file.path.split("/").pop()}
</span>
<span className="media-item-size">{formatSize(file.size)}</span>
</div>
<div className="media-item-actions">
<button
className={`media-copy-btn ${copiedPath === file.path && copiedFormat === "markdown" ? "copied" : ""}`}
onClick={() => handleCopy(file, "markdown")}
title="Copy as Markdown"
>
{copiedPath === file.path && copiedFormat === "markdown" ? (
<Check size={14} />
) : (
<CopySimple size={14} />
)}
<span>MD</span>
</button>
<button
className={`media-copy-btn ${copiedPath === file.path && copiedFormat === "html" ? "copied" : ""}`}
onClick={() => handleCopy(file, "html")}
title="Copy as HTML"
>
{copiedPath === file.path && copiedFormat === "html" ? (
<Check size={14} />
) : (
<Code size={14} />
)}
<span>HTML</span>
</button>
<button
className={`media-copy-btn ${copiedPath === file.path && copiedFormat === "url" ? "copied" : ""}`}
onClick={() => handleCopy(file, "url")}
title="Copy URL"
>
{copiedPath === file.path && copiedFormat === "url" ? (
<Check size={14} />
) : (
<LinkIcon size={14} />
)}
<span>URL</span>
</button>
<button
className="media-delete-btn"
onClick={() => setDeleteConfirm(file.path)}
title="Delete"
>
<Trash size={14} />
</button>
</div>
{/* Delete confirmation */}
{deleteConfirm === file.path && (
<div className="media-delete-confirm">
<p>Delete this image?</p>
<div className="media-delete-confirm-actions">
<button
className="cancel"
onClick={() => setDeleteConfirm(null)}
>
Cancel
</button>
<button
className="confirm"
onClick={() => handleDelete(file.path)}
>
Delete
</button>
</div>
</div>
)}
</div>
))}
</div>
{/* Load more */}
{status === "CanLoadMore" && (
<button className="media-load-more" onClick={() => loadMore(20)}>
Load more
</button>
)}
{status === "LoadingMore" && (
<div className="media-loading">Loading...</div>
)}
{/* Empty state */}
{results.length === 0 && status !== "LoadingFirstPage" && (
<div className="media-empty">
<ImageIcon size={64} weight="light" />
<p>No images uploaded yet</p>
<span>Upload your first image to get started</span>
</div>
)}
{/* Usage info */}
<div className="media-info">
<h3>Usage</h3>
<p>
Click <strong>MD</strong> to copy markdown image syntax,{" "}
<strong>HTML</strong> for img tag, or <strong>URL</strong> for direct link.
Images are served via Bunny CDN for fast global delivery.
</p>
</div>
</div>
);
}

View File

@@ -235,6 +235,14 @@ export interface DashboardConfig {
requireAuth: boolean; // Require WorkOS authentication (only works if WorkOS is configured)
}
// Media library configuration
// Controls image upload and CDN storage via ConvexFS and Bunny.net
export interface MediaConfig {
enabled: boolean; // Global toggle for media library feature
maxFileSize: number; // Max file size in MB (default: 10)
allowedTypes: string[]; // Allowed MIME types
}
// Image lightbox configuration
// Enables click-to-magnify functionality for images in blog posts and pages
export interface ImageLightboxConfig {
@@ -388,6 +396,9 @@ export interface SiteConfig {
// Dashboard configuration (optional)
dashboard?: DashboardConfig;
// Media library configuration (optional)
media?: MediaConfig;
// Image lightbox configuration (optional)
imageLightbox?: ImageLightboxConfig;
@@ -735,6 +746,15 @@ export const siteConfig: SiteConfig = {
requireAuth: true,
},
// Media library configuration
// Upload and manage images via ConvexFS and Bunny.net CDN
// Requires BUNNY_API_KEY, BUNNY_STORAGE_ZONE, BUNNY_CDN_HOSTNAME in Convex dashboard
media: {
enabled: true,
maxFileSize: 10, // Max file size in MB
allowedTypes: ["image/png", "image/jpeg", "image/gif", "image/webp"],
},
// Image lightbox configuration
// Enables click-to-magnify functionality for images in blog posts and pages
// Images open in a full-screen lightbox overlay when clicked

View File

@@ -66,6 +66,8 @@ import {
import siteConfig from "../config/siteConfig";
import AIChatView from "../components/AIChatView";
import VersionHistoryModal from "../components/VersionHistoryModal";
import { MediaLibrary } from "../components/MediaLibrary";
import { ImageUploadModal } from "../components/ImageUploadModal";
import { isWorkOSConfigured } from "../utils/workos";
// Always import auth components - they're only used when WorkOS is configured
import {
@@ -391,7 +393,8 @@ type DashboardSection =
| "config"
| "index-html"
| "stats"
| "sync";
| "sync"
| "media";
// Post/Page type for editing
interface ContentItem {
@@ -1029,6 +1032,10 @@ function DashboardContent() {
}, [editingItem, editingType, generateMarkdown, addToast]);
// Navigation items for left sidebar
// Filter items based on feature configuration
const mediaEnabled = siteConfig.media?.enabled ?? false;
const newsletterEnabled = siteConfig.newsletter?.enabled ?? false;
const navSections = [
{
label: "Content",
@@ -1044,34 +1051,47 @@ function DashboardContent() {
{ id: "write-page" as const, label: "Write Page", icon: File },
{ id: "ai-agent" as const, label: "AI Agent", icon: Robot },
{ id: "import" as const, label: "Import URL", icon: CloudArrowDown },
// Only show Media if media feature is enabled
...(mediaEnabled
? [{ id: "media" as const, label: "Media", icon: Image }]
: []),
],
},
{
label: "Newsletter",
items: [
{ id: "newsletter" as const, label: "Subscribers", icon: Envelope },
{
id: "newsletter-send" as const,
label: "Send Newsletter",
icon: Envelope,
},
{
id: "newsletter-write-email" as const,
label: "Write Email",
icon: PencilSimple,
},
{
id: "newsletter-recent-sends" as const,
label: "Recent Sends",
icon: ClockCounterClockwise,
},
{
id: "newsletter-stats" as const,
label: "Email Stats",
icon: ChartLine,
},
],
},
// Only show Newsletter section if newsletter is enabled
...(newsletterEnabled
? [
{
label: "Newsletter",
items: [
{
id: "newsletter" as const,
label: "Subscribers",
icon: Envelope,
},
{
id: "newsletter-send" as const,
label: "Send Newsletter",
icon: Envelope,
},
{
id: "newsletter-write-email" as const,
label: "Write Email",
icon: PencilSimple,
},
{
id: "newsletter-recent-sends" as const,
label: "Recent Sends",
icon: ClockCounterClockwise,
},
{
id: "newsletter-stats" as const,
label: "Email Stats",
icon: ChartLine,
},
],
},
]
: []),
{
label: "Settings",
items: [
@@ -1246,6 +1266,7 @@ function DashboardContent() {
{activeSection === "index-html" && "Index HTML"}
{activeSection === "stats" && "Analytics"}
{activeSection === "sync" && "Sync Content"}
{activeSection === "media" && "Media"}
</h1>
</div>
@@ -1463,6 +1484,9 @@ function DashboardContent() {
setSyncOutput={setSyncOutput}
/>
)}
{/* Media */}
{activeSection === "media" && <MediaLibrary />}
</div>
</main>
</div>
@@ -2540,6 +2564,9 @@ function WriteSection({
});
// Store previous sidebar state before entering focus mode
const [prevSidebarState, setPrevSidebarState] = useState<boolean | null>(null);
// Image upload modal state
const [showImageUpload, setShowImageUpload] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Toggle focus mode
const toggleFocusMode = useCallback(() => {
@@ -2693,6 +2720,33 @@ function WriteSection({
}
}, [content]);
// Insert image markdown at cursor position
const handleInsertImage = useCallback((markdown: string) => {
if (editorMode === "markdown" && textareaRef.current) {
const textarea = textareaRef.current;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const newContent = content.substring(0, start) + markdown + "\n" + content.substring(end);
setContent(newContent);
// Set cursor position after inserted text
setTimeout(() => {
textarea.focus();
textarea.setSelectionRange(start + markdown.length + 1, start + markdown.length + 1);
}, 0);
} else if (editorMode === "richtext") {
// For rich text mode, convert markdown to HTML and append
const imgMatch = markdown.match(/!\[(.*?)\]\((.*?)\)/);
if (imgMatch) {
const alt = imgMatch[1];
const src = imgMatch[2];
setRichTextHtml(prev => prev + `<p><img src="${src}" alt="${alt}" /></p>`);
}
} else {
// Preview mode - just append to content
setContent(prev => prev + "\n" + markdown);
}
}, [content, editorMode]);
// Copy a single frontmatter field
const handleCopyField = useCallback(
async (fieldName: string, example: string) => {
@@ -2950,6 +3004,16 @@ published: false
)}
<span>{copied ? "Copied" : "Copy All"}</span>
</button>
{siteConfig.media?.enabled && (
<button
onClick={() => setShowImageUpload(true)}
className="dashboard-action-btn"
title="Insert Image"
>
<Image size={16} />
<span>Image</span>
</button>
)}
<button
onClick={handleDownloadMarkdown}
className="dashboard-action-btn primary"
@@ -2990,6 +3054,7 @@ published: false
<div className="dashboard-write-main">
{editorMode === "markdown" && (
<textarea
ref={textareaRef}
value={content}
onChange={(e) => setContent(e.target.value)}
className="dashboard-write-textarea"
@@ -3101,6 +3166,15 @@ published: false
avoid losing work.
</span>
</div>
{/* Image Upload Modal - only when media is enabled */}
{siteConfig.media?.enabled && (
<ImageUploadModal
isOpen={showImageUpload}
onClose={() => setShowImageUpload(false)}
onInsert={handleInsertImage}
/>
)}
</div>
);
}
@@ -3120,6 +3194,7 @@ function AIAgentSection() {
const [imageError, setImageError] = useState<string | null>(null);
const [showImageModelDropdown, setShowImageModelDropdown] = useState(false);
const [showTextModelDropdown, setShowTextModelDropdown] = useState(false);
const [copiedFormat, setCopiedFormat] = useState<"md" | "html" | null>(null);
const generateImage = useAction(api.aiImageGeneration.generateImage);
@@ -3163,6 +3238,48 @@ function AIAgentSection() {
const selectedTextModelName = textModels.find(m => m.id === selectedTextModel)?.name || "Claude Sonnet 4";
const selectedImageModelName = imageModels.find(m => m.id === selectedImageModel)?.name || "Nano Banana";
// Generate markdown code for the image
const getMarkdownCode = (url: string, prompt: string) => `![${prompt}](${url})`;
// Generate HTML code for the image
const getHtmlCode = (url: string, prompt: string) => `<img src="${url}" alt="${prompt}" />`;
// Copy code to clipboard
const handleCopyCode = async (format: "md" | "html") => {
if (!generatedImage) return;
const code = format === "md"
? getMarkdownCode(generatedImage.url, generatedImage.prompt)
: getHtmlCode(generatedImage.url, generatedImage.prompt);
await navigator.clipboard.writeText(code);
setCopiedFormat(format);
setTimeout(() => setCopiedFormat(null), 2000);
};
// Download image to computer
const handleDownloadImage = async () => {
if (!generatedImage) return;
try {
const response = await fetch(generatedImage.url);
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
// Generate filename from prompt (sanitize and truncate)
const filename = generatedImage.prompt
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.slice(0, 50)
.replace(/-+$/, "");
a.download = `${filename || "generated-image"}.png`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (error) {
console.error("Failed to download image:", error);
}
};
return (
<div className="dashboard-ai-section">
{/* Tabs */}
@@ -3277,6 +3394,46 @@ function AIAgentSection() {
<div className="ai-generated-image">
<img src={generatedImage.url} alt={generatedImage.prompt} />
<p className="ai-generated-image-prompt">{generatedImage.prompt}</p>
{/* Image Actions */}
<div className="ai-image-actions">
<button
className="ai-image-action-btn download"
onClick={handleDownloadImage}
title="Download image"
>
<Download size={16} />
<span>Download</span>
</button>
<button
className={`ai-image-action-btn ${copiedFormat === "md" ? "copied" : ""}`}
onClick={() => handleCopyCode("md")}
title="Copy as Markdown"
>
{copiedFormat === "md" ? <Check size={16} /> : <CopySimple size={16} />}
<span>{copiedFormat === "md" ? "Copied" : "MD"}</span>
</button>
<button
className={`ai-image-action-btn ${copiedFormat === "html" ? "copied" : ""}`}
onClick={() => handleCopyCode("html")}
title="Copy as HTML"
>
{copiedFormat === "html" ? <Check size={16} /> : <CopySimple size={16} />}
<span>{copiedFormat === "html" ? "Copied" : "HTML"}</span>
</button>
</div>
{/* Code Preview */}
<div className="ai-image-code-preview">
<div className="ai-image-code-block">
<span className="ai-image-code-label">Markdown:</span>
<code>{getMarkdownCode(generatedImage.url, generatedImage.prompt)}</code>
</div>
<div className="ai-image-code-block">
<span className="ai-image-code-label">HTML:</span>
<code>{getHtmlCode(generatedImage.url, generatedImage.prompt)}</code>
</div>
</div>
</div>
)}
@@ -4478,11 +4635,6 @@ function IndexHtmlSection({
Path to favicon (e.g., /favicon.svg)
</span>
</div>
</div>
{/* Theme and Appearance */}
<div className="dashboard-config-card">
<h3>Theme and Appearance</h3>
<div className="config-field">
<label>Theme Color</label>
<input
@@ -4491,7 +4643,7 @@ function IndexHtmlSection({
onChange={(e) => handleChange("themeColor", e.target.value)}
/>
<span className="config-field-note">
Used in theme-color meta tag for mobile browsers
Mobile browser chrome color (theme-color meta tag)
</span>
</div>
</div>
@@ -4637,6 +4789,9 @@ function ConfigSection({
semanticSearchEnabled: siteConfig.semanticSearch?.enabled || false,
// Ask AI
askAIEnabled: siteConfig.askAI?.enabled || false,
// Media library
mediaEnabled: siteConfig.media?.enabled || false,
mediaMaxFileSize: siteConfig.media?.maxFileSize || 10,
});
const [copied, setCopied] = useState(false);
@@ -4814,6 +4969,15 @@ export const siteConfig: SiteConfig = {
askAI: {
enabled: ${config.askAIEnabled},
},
// Media library configuration
// Upload and manage images via ConvexFS and Bunny.net CDN
// Requires BUNNY_API_KEY, BUNNY_STORAGE_ZONE, BUNNY_CDN_HOSTNAME in Convex dashboard
media: {
enabled: ${config.mediaEnabled},
maxFileSize: ${config.mediaMaxFileSize},
allowedTypes: ["image/png", "image/jpeg", "image/gif", "image/webp"],
},
};
export default siteConfig;
@@ -5677,6 +5841,36 @@ export default siteConfig;
</p>
</div>
{/* Media Library */}
<div className="dashboard-config-card">
<h3>Media Library</h3>
<div className="config-field checkbox">
<label>
<input
type="checkbox"
checked={config.mediaEnabled}
onChange={(e) =>
handleChange("mediaEnabled", e.target.checked)
}
/>
<span>Enable media library</span>
</label>
</div>
<div className="config-field">
<label>Max File Size (MB)</label>
<input
type="number"
value={config.mediaMaxFileSize}
onChange={(e) => handleChange("mediaMaxFileSize", parseInt(e.target.value) || 10)}
min={1}
max={50}
/>
</div>
<p className="config-hint">
Upload and manage images via ConvexFS and Bunny.net CDN. Requires BUNNY_API_KEY, BUNNY_STORAGE_ZONE, and BUNNY_CDN_HOSTNAME in Convex dashboard.
</p>
</div>
{/* Version Control */}
<VersionControlCard addToast={addToast} />

File diff suppressed because it is too large Load Diff