mirror of
https://github.com/waynesutton/markdown-site.git
synced 2026-01-12 04:09:14 +00:00
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:
553
src/components/ImageUploadModal.tsx
Normal file
553
src/components/ImageUploadModal.tsx
Normal 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 ``;
|
||||
}
|
||||
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
509
src/components/MediaLibrary.tsx
Normal file
509
src/components/MediaLibrary.tsx
Normal 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 = ``;
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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) => ``;
|
||||
|
||||
// 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
Reference in New Issue
Block a user