瀏覽代碼

feat: add CSV file support to media gallery and improve file filtering

Adam Jafarov 3 周之前
父節點
當前提交
c8e9d310f1
共有 5 個文件被更改,包括 86 次插入42 次删除
  1. 18 4
      backend/server.js
  2. 1 1
      public/posts
  3. 0 11
      src/components/CsvGraph.jsx
  4. 32 21
      src/components/MediaGalleryModal.jsx
  5. 35 5
      src/components/PostEditor.jsx

+ 18 - 4
backend/server.js

@@ -364,14 +364,27 @@ app.get("/api/media", requireAuth, async (req, res) => {
 
         const files = await fs.readdir(targetDir);
         const mediaFiles = [];
+        const { type } = req.query; // 'image' or 'csv'
 
         for (const file of files) {
             const stats = await fs.stat(path.join(targetDir, file));
-            if (stats.isFile() && /\.(jpg|jpeg|png|gif|webp|svg)$/i.test(file)) {
-                const slugPart = slug ? slug.replace(/[^a-z0-9-]/gi, "") : "uploads";
-                // If slug was passed, url is /media-files/SLUG/images/FILE
-                // If no slug (using uploads), url is /media-files/uploads/FILE
+            if (!stats.isFile()) continue;
+
+            const isImage = /\.(jpg|jpeg|png|gif|webp|svg)$/i.test(file);
+            const isCsv = /\.csv$/i.test(file);
+
+            // Filter logic
+            let shouldInclude = false;
+            if (type === "image") {
+                shouldInclude = isImage;
+            } else if (type === "csv") {
+                shouldInclude = isCsv;
+            } else {
+                shouldInclude = isImage || isCsv;
+            }
 
+            if (shouldInclude) {
+                const slugPart = slug ? slug.replace(/[^a-z0-9-]/gi, "") : "uploads";
                 const url = slug
                     ? `/media-files/${slugPart}/images/${file}`
                     : `/media-files/uploads/${file}`;
@@ -381,6 +394,7 @@ app.get("/api/media", requireAuth, async (req, res) => {
                     url: url,
                     size: stats.size,
                     date: stats.mtime,
+                    type: isImage ? "image" : (isCsv ? "csv" : "file")
                 });
             }
         }

+ 1 - 1
public/posts

@@ -1 +1 @@
-Subproject commit 39ec8fb8d003a0abab3adfd84769590cb20c3dd7
+Subproject commit 9a9aefed4d7fd36293ebb0d40a10b584758e7188

+ 0 - 11
src/components/CsvGraph.jsx

@@ -350,17 +350,6 @@ const CsvGraph = ({ rawData }) => {
                     </LineChart>
                 </PerformanceCard>
             )}
-
-            {/* Shared Brush for scrolling */}
-            {data.length > 0 && (
-                <div className="h-10 w-full mt-2 bg-gray-50/50 dark:bg-gray-800/20 rounded-lg p-1">
-                    <ResponsiveContainer width="100%" height="100%">
-                        <LineChart data={data}>
-                            <Brush height={16} stroke="#3b82f6" fill="transparent" tickFormatter={() => ''} />
-                        </LineChart>
-                    </ResponsiveContainer>
-                </div>
-            )}
         </div>
     );
 };

+ 32 - 21
src/components/MediaGalleryModal.jsx

@@ -1,8 +1,8 @@
 import React, { useState, useEffect } from "react";
 import { API_BASE } from "../config";
 
-function MediaGalleryModal({ isOpen, onClose, onSelect, slug }) {
-    const [images, setImages] = useState([]);
+function MediaGalleryModal({ isOpen, onClose, onSelect, slug, typeFilter = "image" }) {
+    const [media, setMedia] = useState([]);
     const [loading, setLoading] = useState(false);
     const [uploading, setUploading] = useState(false);
     const [error, setError] = useState(null);
@@ -11,22 +11,22 @@ function MediaGalleryModal({ isOpen, onClose, onSelect, slug }) {
         if (isOpen) {
             fetchMedia();
         }
-    }, [isOpen, slug]);
+    }, [isOpen, slug, typeFilter]);
 
     const fetchMedia = async () => {
         try {
             setLoading(true);
             const url = slug
-                ? `${API_BASE}/media?slug=${encodeURIComponent(slug)}`
-                : `${API_BASE}/media`;
+                ? `${API_BASE}/media?slug=${encodeURIComponent(slug)}&type=${typeFilter}`
+                : `${API_BASE}/media?type=${typeFilter}`;
 
             const response = await fetch(url, { credentials: "include" });
             if (!response.ok) throw new Error("Failed to load media");
             const data = await response.json();
-            setImages(data);
+            setMedia(data);
         } catch (err) {
             console.error("Fetch media error:", err);
-            setError("Could not load images.");
+            setError(`Could not load ${typeFilter === 'image' ? 'images' : 'files'}.`);
         } finally {
             setLoading(false);
         }
@@ -63,7 +63,7 @@ function MediaGalleryModal({ isOpen, onClose, onSelect, slug }) {
 
     const handleDelete = async (path, e) => {
         e.stopPropagation();
-        if (!window.confirm("Delete this image?")) return;
+        if (!window.confirm("Delete this file?")) return;
 
         try {
             const response = await fetch(`${API_BASE}/media`, {
@@ -82,19 +82,23 @@ function MediaGalleryModal({ isOpen, onClose, onSelect, slug }) {
 
     if (!isOpen) return null;
 
+    const modalTitle = typeFilter === "csv" ? "CSV Media Gallery" : "Image Media Gallery";
+    const uploadLabel = typeFilter === "csv" ? "Upload CSV" : "Upload Image";
+    const acceptAttr = typeFilter === "csv" ? ".csv" : "image/*";
+
     return (
         <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
             <div className="theme-surface rounded-xl shadow-2xl w-full max-w-4xl max-h-[80vh] flex flex-col overflow-hidden theme-border border">
                 {/* Header */}
                 <div className="px-6 py-4 border-b theme-border flex justify-between items-center bg-gray-50/50">
-                    <h3 className="text-lg font-bold theme-text">Media Gallery</h3>
+                    <h3 className="text-lg font-bold theme-text">{modalTitle}</h3>
                     <div className="flex items-center space-x-4">
                         <label className="cursor-pointer btn-theme-primary text-white px-4 py-2 rounded-lg transition-colors text-sm font-medium">
-                            {uploading ? "Uploading..." : "Upload Image"}
+                            {uploading ? "Uploading..." : uploadLabel}
                             <input
                                 type="file"
                                 className="hidden"
-                                accept="image/*"
+                                accept={acceptAttr}
                                 onChange={handleUpload}
                                 disabled={uploading}
                             />
@@ -120,24 +124,31 @@ function MediaGalleryModal({ isOpen, onClose, onSelect, slug }) {
                         <div className="flex justify-center py-12">
                             <div className="animate-spin h-8 w-8 border-4 border-blue-500 rounded-full border-t-transparent"></div>
                         </div>
-                    ) : images.length === 0 ? (
+                    ) : media.length === 0 ? (
                         <div className="text-center py-12 theme-text-secondary">
-                            No images found in this folder.
+                            No {typeFilter === 'csv' ? 'CSV files' : 'images'} found in this folder.
                         </div>
                     ) : (
                         <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
-                            {images.map((img) => (
+                            {media.map((img) => (
                                 <div
                                     key={img.name}
                                     className="group relative theme-surface rounded-lg shadow-sm border theme-border overflow-hidden hover:shadow-md transition-shadow cursor-pointer aspect-square"
                                     onClick={() => onSelect(`${API_BASE}${img.url}`, img.name)}
                                 >
-                                    <img
-                                        src={`${API_BASE}${img.url}`}
-                                        alt={img.name}
-                                        className="w-full h-full object-cover"
-                                        loading="lazy"
-                                    />
+                                    {img.type === 'csv' || img.name.endsWith('.csv') ? (
+                                        <div className="w-full h-full flex flex-col items-center justify-center bg-green-50 dark:bg-green-900/10 text-green-600 dark:text-green-400">
+                                            <svg className="w-12 h-12" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 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-[10px] font-bold mt-2 font-mono">.CSV</span>
+                                        </div>
+                                    ) : (
+                                        <img
+                                            src={`${API_BASE}${img.url}`}
+                                            alt={img.name}
+                                            className="w-full h-full object-cover"
+                                            loading="lazy"
+                                        />
+                                    )}
                                     <div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors" />
 
                                     {/* Delete Button */}
@@ -160,7 +171,7 @@ function MediaGalleryModal({ isOpen, onClose, onSelect, slug }) {
                 </div>
 
                 <div className="p-4 bg-gray-50/50 border-t theme-border text-sm theme-text-secondary text-center">
-                    Click an image to insert it into the editor.
+                    Click to insert into the editor.
                 </div>
             </div>
         </div>

+ 35 - 5
src/components/PostEditor.jsx

@@ -16,6 +16,7 @@ function PostEditor() {
     const isEditing = !!slug;
     const [activeTab, setActiveTab] = useState("write"); // "write" | "preview"
     const [galleryOpen, setGalleryOpen] = useState(false);
+    const [csvGalleryOpen, setCsvGalleryOpen] = useState(false);
 
     const [formData, setFormData] = useState({
         title: "",
@@ -179,16 +180,21 @@ function PostEditor() {
         }
     };
 
-    // Gallery selection handler
+    // Gallery selection handlers
     const handleImageSelect = (url, name) => {
-        // We append to end since we lose cursor position when modal opens/closes
-        // Ideally we would insert at cursor, but standard 'insertTextAtCursor' used state append.
-        // Users can cut/paste.
         const markdown = `![${name}](${url})`;
         insertTextAtCursor(markdown);
         setGalleryOpen(false);
     };
 
+    const handleCsvSelect = (url, name) => {
+        // url is /api/media-files/...
+        // We want the relative path part after /media-files
+        const markdown = `\n\`\`\`csv-graph\nurl: ${url}\n\`\`\`\n`;
+        insertTextAtCursor(markdown);
+        setCsvGalleryOpen(false);
+    };
+
     // Define Custom Toolbar Commands
     const customCommands = useMemo(() => {
         const mediaCommand = {
@@ -239,7 +245,22 @@ function PostEditor() {
             },
         };
 
-        return [mediaCommand, compareCommand, reelCommand, resizeCommand];
+        const csvCommand = {
+            name: "csv-graph",
+            keyCommand: "csv-graph",
+            buttonProps: { "aria-label": "Add Scene CSV Graph", title: "Add Scene CSV Graph" },
+            icon: (
+                <div className="flex items-center space-x-1">
+                    <svg className="w-3 h-3 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 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-[10px] font-bold">CSV</span>
+                </div>
+            ),
+            execute: (state, api) => {
+                setCsvGalleryOpen(true);
+            },
+        };
+
+        return [mediaCommand, compareCommand, reelCommand, resizeCommand, csvCommand];
     }, []);
 
     const handleSubmit = async (e) => {
@@ -509,6 +530,15 @@ function PostEditor() {
                 onClose={() => setGalleryOpen(false)}
                 onSelect={handleImageSelect}
                 slug={getSlugForUpload()}
+                typeFilter="image"
+            />
+
+            <MediaGalleryModal
+                isOpen={csvGalleryOpen}
+                onClose={() => setCsvGalleryOpen(false)}
+                onSelect={handleCsvSelect}
+                slug={getSlugForUpload()}
+                typeFilter="csv"
             />
 
             {/* Glassmorphic Top Bar */}