diff --git a/backend/app.js b/backend/app.js index 67b008a18..db22ba72f 100644 --- a/backend/app.js +++ b/backend/app.js @@ -1,12 +1,13 @@ +const dotenv = require('dotenv'); +dotenv.config(); + const express = require('express') const mongoose = require('mongoose') const cors = require('cors') -const dotenv = require('dotenv'); const rateLimit = require('express-rate-limit'); const app = express(); app.set('trust proxy', 1); const GC = require('./utils/GC'); -dotenv.config(); // Middleware app.use(cors()); diff --git a/backend/config/redis.js b/backend/config/redis.js index e889cc15b..a4482666e 100644 --- a/backend/config/redis.js +++ b/backend/config/redis.js @@ -3,13 +3,33 @@ const dotenv = require("dotenv"); dotenv.config() if (!process.env.REDIS_URL) { + if (process.env.NODE_ENV !== 'production') { + console.log("DEBUG: ENV KEYS:", Object.keys(process.env)); + } throw new Error("REDIS_URL is not defined in .env"); } -const redis = new Redis(process.env.REDIS_URL); +const redis = new Redis(process.env.REDIS_URL, { + retryStrategy(times) { + const delay = Math.min(times * 50, 2000); + if (times > 3) { + console.warn("⚠️ Redis: Max retries reached. Caching will be disabled."); + return null; // Stop retrying + } + return delay; + }, + maxRetriesPerRequest: null // Allow requests to fail if not connected +}); redis.on('ready', () => { console.log('ioredis client is connected and ready.'); }); +redis.on('error', (err) => { + console.error('❌ Redis Connection Error:', err.message); + console.error(' -> Ensure Redis is running on localhost:6379'); + console.error(' -> Windows: Use WSL or a Memurai/Redis port.'); + // Do not throw; lets the app continue without caching if needed +}); + module.exports = redis; diff --git a/backend/controllers/auth.controller.js b/backend/controllers/auth.controller.js index 05856d717..30a57ad47 100644 --- a/backend/controllers/auth.controller.js +++ b/backend/controllers/auth.controller.js @@ -115,6 +115,7 @@ module.exports.sendOtp = async (req, res) => { try { const { email } = onlyEmailSchema.parse(req.body); const otp = Math.floor(100000 + Math.random() * 900000); + // Secure: OTP logging removed const existingUser = await Developer.findOne({ email }); if (!existingUser) return res.status(400).json({ error: "User not found" }); @@ -150,8 +151,7 @@ module.exports.verifyOtp = async (req, res) => { const existingOtp = await otpSchema.findOne({ userId: existingUser._id }); if (!existingOtp) return res.status(400).json({ error: "You havn't requested an OTP" }); - console.log(existingOtp.otp); - console.log(otp); + // Secure: OTP verification logging removed if (existingOtp.otp !== otp) return res.status(400).json({ error: "Incorrect OTP" }); await existingOtp.deleteOne(); diff --git a/backend/services/redisCaching.js b/backend/services/redisCaching.js index 49624aebb..437f1aa18 100644 --- a/backend/services/redisCaching.js +++ b/backend/services/redisCaching.js @@ -1,6 +1,7 @@ const redis = require("../config/redis"); async function setProjectByApiKeyCache(api, project) { + if (redis.status !== "ready") return; try { const data = JSON.stringify(project); await redis.set( @@ -15,6 +16,7 @@ async function setProjectByApiKeyCache(api, project) { } async function getProjectByApiKeyCache(api) { + if (redis.status !== "ready") return null; try { const data = await redis.get(`project:apikey:${api}`); if (!data) return null; @@ -28,6 +30,7 @@ async function getProjectByApiKeyCache(api) { } async function deleteProjectByApiKeyCache(api) { + if (redis.status !== "ready") return; try { await redis.del(`project:apikey:${api}`); } catch (err) { @@ -37,6 +40,7 @@ async function deleteProjectByApiKeyCache(api) { async function setProjectById(id, project) { + if (redis.status !== "ready") return; try { const data = JSON.stringify(project); await redis.set( @@ -52,6 +56,7 @@ async function setProjectById(id, project) { async function getProjectById(id) { + if (redis.status !== "ready") return null; try { const data = await redis.get(`project:id:${id}`); if (!data) return null; @@ -65,6 +70,7 @@ async function getProjectById(id) { async function deleteProjectById(id) { + if (redis.status !== "ready") return; try { await redis.del(`project:id:${id}`); } catch (err) { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index fe8b94204..ea22b58ee 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,10 @@ "name": "frontend", "version": "0.0.0", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@tailwindcss/vite": "^4.1.18", "@tanstack/react-table": "^8.21.3", "axios": "^1.13.2", @@ -313,6 +317,73 @@ "node": ">=6.9.0" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/modifiers": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/modifiers/-/modifiers-9.0.0.tgz", + "integrity": "sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -3959,6 +4030,12 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 5445f3b6c..9debd4bd2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,6 +10,10 @@ "preview": "vite preview" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@tailwindcss/vite": "^4.1.18", "@tanstack/react-table": "^8.21.3", "axios": "^1.13.2", diff --git a/frontend/src/components/AddRecordDrawer.jsx b/frontend/src/components/AddRecordDrawer.jsx new file mode 100644 index 000000000..17f0fadcf --- /dev/null +++ b/frontend/src/components/AddRecordDrawer.jsx @@ -0,0 +1,348 @@ +import React, { useState, useEffect } from "react"; +import { X, Check, AlertCircle } from "lucide-react"; + +/** + * AddRecordDrawer + * A slide-over drawer component for adding records to a collection. + * Optimized for collections with many columns by using a responsive grid layout. + * + * @param {boolean} isOpen - Whether the drawer is open + * @param {function} onClose - Function to close the drawer + * @param {function} onSubmit - Function to handle form submission (receives form data) + * @param {array} fields - Array of field objects { key, type, required } from the collection model + * @param {boolean} isSubmitting - Loading state for submission + */ +export default function AddRecordDrawer({ + isOpen, + onClose, + onSubmit, + fields = [], + isSubmitting = false, +}) { + const [formData, setFormData] = useState({}); + const [errors, setErrors] = useState({}); + + + + // Handle outside click to close + useEffect(() => { + const handleKeyDown = (e) => { + if (e.key === "Escape" && isOpen) { + onClose(); + } + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [isOpen, onClose]); + + const handleChange = (key, value) => { + let finalValue = value; + + // Basic type conversion + // (Number conversion handled in handleSubmit to avoid input issues) + + setFormData((prev) => ({ + ...prev, + [key]: finalValue, + })); + + // Clear error for this field if it exists + if (errors[key]) { + setErrors((prev) => { + const newErrors = { ...prev }; + delete newErrors[key]; + return newErrors; + }); + } + }; + + const handleSubmit = (e) => { + e.preventDefault(); + + // Client-side validation + const newErrors = {}; + const formattedData = { ...formData }; + + fields.forEach(field => { + const val = formattedData[field.key]; + + // Check required + if (field.required && (val === undefined || val === "" || val === null)) { + newErrors[field.key] = "This field is required"; + } + + // Convert types for submission + if (field.type === "Number" && val !== undefined && val !== "") { + const num = Number(val); + if (isNaN(num)) { + newErrors[field.key] = "Must be a valid number"; + } else { + formattedData[field.key] = num; + } + } + + if (field.type === "Boolean") { + formattedData[field.key] = val === "true" || val === true; + } + + // Convert Date fields to ISO string + if (field.type === "Date" && val) { + formattedData[field.key] = new Date(val).toISOString(); + } + }); + + if (Object.keys(newErrors).length > 0) { + setErrors(newErrors); + return; + } + + onSubmit(formattedData); + }; + + // Determine grid columns based on field count + // If > 8 fields, use 2 columns on wider screens + const isWideForm = fields.length > 8; + + if (!isOpen) return null; + + return ( + <> + {/* Backdrop */} +
+ + {/* Drawer Panel */} +
+ {/* Header */} +
+
+

Add New Record

+

+ Fill in the details for the new document. +

+
+ +
+ + {/* Body */} +
+
+
+ {fields.map((field) => ( +
20)) ? "span 2" : "auto" + }} + > + + + {renderInput(field, formData[field.key], handleChange, errors[field.key])} + + {errors[field.key] && ( +
+ + {errors[field.key]} +
+ )} +
+ ))} +
+
+
+ + {/* Footer */} +
+ + +
+
+ + + + ); +} + +function renderInput(field, value, onChange, error) { + const val = value === undefined || value === null ? "" : value; + + if (field.type === "Boolean") { + return ( + + ); + } + + if (field.type === "Date") { + return ( + onChange(field.key, e.target.value, "Date")} + style={error ? { borderColor: "#ef4444" } : {}} + /> + ); + } + + return ( + onChange(field.key, e.target.value, field.type)} + step={field.type === "Number" ? "any" : undefined} + style={error ? { borderColor: "#ef4444" } : {}} + /> + ); +} diff --git a/frontend/src/components/CollectionTable.jsx b/frontend/src/components/CollectionTable.jsx new file mode 100644 index 000000000..0ba0aa4c3 --- /dev/null +++ b/frontend/src/components/CollectionTable.jsx @@ -0,0 +1,618 @@ +import React, { useMemo, useState, useEffect } from 'react'; +import { + useReactTable, + getCoreRowModel, + flexRender, +} from "@tanstack/react-table"; +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, +} from '@dnd-kit/core'; +import { + arrayMove, + SortableContext, + horizontalListSortingStrategy, + useSortable, +} from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { Trash2, Settings2, Check, GripVertical, Eye } from "lucide-react"; + +/* Resizer Component - kept simple */ +const Resizer = ({ header }) => { + return ( +
e.stopPropagation()} + /> + ); +}; + +/* Draggable Header Component */ +const DraggableColumnHeader = ({ header, children, style: propStyle, className }) => { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ + id: header.id, + // Disable drag for sticky/pinned columns if desired, effectively pinning them + disabled: header.id === 'rowNumber' || header.id === 'actions' || header.id === '_id' + }); + + const style = { + transform: CSS.Translate.toString(transform), + transition, + opacity: isDragging ? 0.8 : 1, + // Ensure dragging item is on top + zIndex: isDragging ? 100 : propStyle.zIndex, + ...propStyle, + cursor: isDragging ? 'grabbing' : (header.column.getCanSort() ? 'grab' : 'default'), + }; + + return ( + + {children} + + ); +}; + +export default function CollectionTable({ data, activeCollection, onDelete, onView }) { + // 1. Column Definitions + const columns = useMemo(() => { + if (!activeCollection?.model) return []; + return [ + { + id: "rowNumber", + header: "#", + cell: (info) => {info.row.index + 1}, + size: 50, + enableResizing: false, + enableHiding: false, + }, + ...activeCollection.model.map((field) => ({ + id: field.key, // Explicit ID matches accessorKey usually + header: () => ( +
+ {/* Drag Handle Indicator (Visual only, whole header is draggable) */} + + {field.key} + {field.type} +
+ ), + accessorKey: field.key, + size: 200, + minSize: 100, + maxSize: 500, + cell: (info) => { + const value = info.getValue(); + if (typeof value === "boolean") { + return ( + + {String(value)} + + ); + } + return ( +
+ {String(value)} +
+ ); + }, + })), + { + id: "_id", + header: "ID", + accessorKey: "_id", + size: 150, + cell: (info) => ( + + {String(info.getValue()).substring(0, 8)}... + + ), + }, + { + id: "actions", + header: "Actions", + size: 80, + enableResizing: false, + enableHiding: false, + cell: (info) => ( +
+ + +
+ ), + }, + ]; + }, [activeCollection, onDelete, onView]); + + // 2. Load Persisted State + const storageKey = `table-settings-${activeCollection?._id}`; + + // Initial State Loaders + const [columnVisibility, setColumnVisibility] = useState({}); + const [columnOrder, setColumnOrder] = useState([]); + + useEffect(() => { + if (!activeCollection) return; + + const saved = localStorage.getItem(storageKey); + if (saved) { + try { + const parsed = JSON.parse(saved); + setColumnVisibility(parsed.columnVisibility || {}); + // Verify saved order matches current columns (reconcile) + const savedOrder = parsed.columnOrder || []; + const currentIds = columns.map(c => c.id); + // Simple reconciliation: use saved if it contains all current, otherwise append new + if (savedOrder.length > 0) { + const missing = currentIds.filter(id => !savedOrder.includes(id)); + setColumnOrder([...savedOrder, ...missing]); + } else { + setColumnOrder(currentIds); + } + } catch (e) { + console.error("Failed to load table settings", e); + setColumnOrder(columns.map(c => c.id)); + } + } else { + setColumnOrder(columns.map(c => c.id)); + } + }, [activeCollection, columns, storageKey]); // Re-run when collection changes (or columns def changes) + + // 3. Persist State Changes + useEffect(() => { + if (!activeCollection || columnOrder.length === 0) return; + const settings = { + columnVisibility, + columnOrder + }; + localStorage.setItem(storageKey, JSON.stringify(settings)); + }, [columnVisibility, columnOrder, activeCollection, storageKey]); + + + // 4. DnD Sensors + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, // Requires 8px movement to start drag (prevents accidental clicks) + }, + }), + useSensor(KeyboardSensor) + ); + + // 5. Table Instance + // eslint-disable-next-line + const table = useReactTable({ + data, + columns, + state: { + columnVisibility, + columnOrder, + }, + onColumnVisibilityChange: setColumnVisibility, + onColumnOrderChange: setColumnOrder, + columnResizeMode: "onChange", + getCoreRowModel: getCoreRowModel(), + }); + + // 6. DnD Handler + const handleDragEnd = (event) => { + const { active, over } = event; + if (active && over && active.id !== over.id) { + setColumnOrder((order) => { + const oldIndex = order.indexOf(active.id); + const newIndex = order.indexOf(over.id); + return arrayMove(order, oldIndex, newIndex); + }); + } + }; + + const [showColumnMenu, setShowColumnMenu] = useState(false); + + // Filter out pinned columns from SortableContext if we want to lock them + // actually, allowing them to be in the list is fine, DraggableColumnHeader 'disabled' prop handles the interaction. + // However, if we move 'rowNumber' it might look weird. + // Let's just pass the whole order. + + // 7. Scroll Sync Slider + const tableContainerRef = React.useRef(null); + const [scrollState, setScrollState] = useState({ + scrollLeft: 0, + scrollWidth: 0, + clientWidth: 0 + }); + + const updateScrollState = React.useCallback(() => { + if (tableContainerRef.current) { + const { scrollLeft, scrollWidth, clientWidth } = tableContainerRef.current; + setScrollState({ scrollLeft, scrollWidth, clientWidth }); + } + }, []); + + // Listen to scroll events on the table container + useEffect(() => { + const el = tableContainerRef.current; + if (!el) return; + + el.addEventListener('scroll', updateScrollState, { passive: true }); + // Initial check + updateScrollState(); + + // ResizeObserver to handle window/container resizing + const observer = new ResizeObserver(updateScrollState); + observer.observe(el); + + return () => { + el.removeEventListener('scroll', updateScrollState); + observer.disconnect(); + }; + }, [updateScrollState]); + + const handleSliderChange = (e) => { + const value = Number(e.target.value); + if (tableContainerRef.current) { + tableContainerRef.current.scrollLeft = value; + // Update state manually locally to keep slider smooth, + // though the scroll listener will also fire. + // setScrollState(prev => ({ ...prev, scrollLeft: value })); + } + }; + + const showSlider = scrollState.scrollWidth > scrollState.clientWidth; + + return ( +
+ {/* Toolbar Area */} +
+
+ + + {showColumnMenu && ( + <> +
setShowColumnMenu(false)} + /> +
+
+ TOGGLE COLUMNS +
+ {table.getAllLeafColumns().map(column => { + if (!column.getCanHide()) return null; + return ( +
+
+ {column.getIsVisible() && } +
+ + {column.columnDef.header && typeof column.columnDef.header === 'string' + ? column.columnDef.header + : column.id === '_id' ? 'ID' : column.id} + +
+ ) + })} +
+ + )} +
+
+ +
+ + + + {table.getHeaderGroups().map((headerGroup) => ( + + + {headerGroup.headers.map((header) => { + /* Handle Sticky Columns */ + const isStickyLeft = header.id === 'rowNumber'; + const isStickyRight = header.id === 'actions'; + // _id is semi-sticky or just pinned + + const style = { + width: header.getSize(), + position: (isStickyLeft || isStickyRight) ? "sticky" : "relative", + left: isStickyLeft ? 0 : 'auto', + right: isStickyRight ? 0 : 'auto', + zIndex: (isStickyLeft || isStickyRight) ? 20 : 10, + background: 'rgba(10, 10, 10, 0.85)', + backdropFilter: 'blur(8px)', + boxShadow: isStickyRight ? '-5px 0 15px rgba(0,0,0,0.3)' : 'none' + }; + + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + {header.column.getCanResize() && ( + + )} + + ); + })} + + + ))} + + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => { + /* Handle Sticky Cells */ + const columnId = cell.column.id; + const isStickyLeft = columnId === 'rowNumber'; + const isStickyRight = columnId === 'actions'; + + const style = { + width: cell.column.getSize(), + position: (isStickyLeft || isStickyRight) ? "sticky" : "relative", + left: isStickyLeft ? 0 : 'auto', + right: isStickyRight ? 0 : 'auto', + zIndex: (isStickyLeft || isStickyRight) ? 5 : 1, + boxShadow: isStickyRight ? '-5px 0 15px rgba(0,0,0,0.2)' : 'none' + }; + + return ( + + ); + })} + + ))} + +
+
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+
+ {/* Overlay for smoother drag visualization (optional, but good) */} + {/* ... */} +
+
+ + {/* Slider Control */} + {showSlider && ( +
+ +
+ )} + + +
+ ); +} + + diff --git a/frontend/src/components/DatabaseSidebar.jsx b/frontend/src/components/DatabaseSidebar.jsx new file mode 100644 index 000000000..dfac4e39a --- /dev/null +++ b/frontend/src/components/DatabaseSidebar.jsx @@ -0,0 +1,184 @@ +import { + Plus, + X, + Database as DbIcon, + ChevronRight, +} from "lucide-react"; + +export default function DatabaseSidebar({ + isSidebarOpen, + setIsSidebarOpen, + collections, + activeCollection, + setActiveCollection, + project, + navigate, + projectId +}) { + return ( + + ); +} diff --git a/frontend/src/components/RecordList.jsx b/frontend/src/components/RecordList.jsx new file mode 100644 index 000000000..5e69e9ce0 --- /dev/null +++ b/frontend/src/components/RecordList.jsx @@ -0,0 +1,174 @@ +import React from "react"; +import { List, MoreHorizontal, Calendar, ArrowRight } from "lucide-react"; + +export default function RecordList({ data, activeCollection, onView }) { + // Helper to get important fields (skip _id and system fields) + const getPreviewFields = (record) => { + if (!activeCollection?.model) return []; + // Take first 3 fields from model + return activeCollection.model.slice(0, 3).map(field => ({ + key: field.key, + value: record[field.key], + type: field.type + })); + }; + + return ( +
+
+ {data.map((record, index) => { + const previewFields = getPreviewFields(record); + + return ( +
onView(record)} + > +
+
+ #{index + 1} + {record._id.substring(0, 8)}... +
+ +
+ {previewFields.map((field) => ( +
+ {field.key} + + {String(field.value ?? '')} + +
+ ))} +
+
+ +
+ +
+
+ ); + })} +
+ + +
+ ); +} diff --git a/frontend/src/components/RowDetailDrawer.jsx b/frontend/src/components/RowDetailDrawer.jsx new file mode 100644 index 000000000..ad79f04be --- /dev/null +++ b/frontend/src/components/RowDetailDrawer.jsx @@ -0,0 +1,164 @@ +import React from "react"; +import { X, Calendar, Type, Hash, ToggleLeft, FileText, Link as LinkIcon, AlertCircle } from "lucide-react"; + +export default function RowDetailDrawer({ isOpen, onClose, record, fields }) { + if (!isOpen || !record) return null; + + const getIconForType = (type) => { + switch (type) { + case "String": return ; + case "Number": return ; + case "Boolean": return ; + case "Date": return ; + case "Object": return ; + case "Array": return ; + default: return ; + } + }; + + const renderValue = (value) => { + if (value === null || value === undefined) return Empty; + + if (typeof value === "boolean") { + return ( + + {String(value)} + + ); + } + + if (typeof value === "object") { + return
{JSON.stringify(value, null, 2)}
; + } + + return String(value); + }; + + return ( + <> +
+
+
+
+
+ +
+
+

Record Details

+

ID: {record._id}

+
+
+ +
+ +
+
+ {fields.map((field) => ( +
+
+
+ {getIconForType(field.type)} + {field.key} +
+ {field.type} +
+
+ {renderValue(record[field.key])} +
+
+ ))} +
+ +
+

SYSTEM METADATA

+
+
_id
+
{record._id}
+
+ {record.createdAt && ( +
+
createdAt
+
{new Date(record.createdAt).toLocaleString()}
+
+ )} + {record.updatedAt && ( +
+
updatedAt
+
{new Date(record.updatedAt).toLocaleString()}
+
+ )} +
+
+
+ + + + ); +} diff --git a/frontend/src/pages/Database.jsx b/frontend/src/pages/Database.jsx index 5e20ddd93..1d91c175f 100644 --- a/frontend/src/pages/Database.jsx +++ b/frontend/src/pages/Database.jsx @@ -1,29 +1,26 @@ -import { useState, useEffect, useCallback, useMemo } from "react"; +import { useState, useEffect, useCallback } from "react"; import { useParams, useSearchParams, useNavigate } from "react-router-dom"; import axios from "axios"; import { useAuth } from "../context/AuthContext"; import toast from "react-hot-toast"; import ConfirmationModal from "./ConfirmationModal"; +import AddRecordDrawer from "../components/AddRecordDrawer"; +import CollectionTable from "../components/CollectionTable"; +import DatabaseSidebar from "../components/DatabaseSidebar"; +import RowDetailDrawer from "../components/RowDetailDrawer"; +import RecordList from "../components/RecordList"; import { Database as DbIcon, Plus, - Trash2, RefreshCw, Code, Table as TableIcon, - Search, + List as ListIcon, Menu, - X, - ChevronRight, - MoreVertical, FileText, } from "lucide-react"; + import { API_URL } from "../config"; -import { - useReactTable, - getCoreRowModel, - flexRender, -} from "@tanstack/react-table"; export default function Database() { const { projectId } = useParams(); @@ -36,14 +33,16 @@ export default function Database() { const [activeCollection, setActiveCollection] = useState(null); const [data, setData] = useState([]); const [loadingData, setLoadingData] = useState(false); - const [viewMode, setViewMode] = useState("table"); + const [viewMode, setViewMode] = useState("list"); const [isAddModalOpen, setIsAddModalOpen] = useState(false); - const [newData, setNewData] = useState({}); + const [isSubmitting, setIsSubmitting] = useState(false); const [isSidebarOpen, setIsSidebarOpen] = useState(false); //used showModal to open the Confirmation model const [showModal, setShowModal] = useState(false); //keeping track of the selected record in the collection const [selectedId, setSelectedId] = useState(null); + const [selectedRecord, setSelectedRecord] = useState(null); // For detail drawer + const fetchShowModal = (id) => { setShowModal(true); setSelectedId(id); @@ -97,6 +96,7 @@ export default function Database() { useEffect(() => { if (!activeCollection) return; setSearchParams({ collection: activeCollection.name }); + setSelectedRecord(null); // Close any open record detail drawer fetchData(); if (window.innerWidth <= 768) setIsSidebarOpen(false); }, [activeCollection, fetchData, setSearchParams]); @@ -119,20 +119,12 @@ export default function Database() { } }; - const handleAddDocument = async (e) => { - e.preventDefault(); + const handleAddDocument = async (submittedData) => { + setIsSubmitting(true); try { - const formattedData = { ...newData }; - (activeCollection.model || []).forEach((field) => { - if (field.type === "Number") - formattedData[field.key] = Number(formattedData[field.key]); - if (field.type === "Boolean") - formattedData[field.key] = formattedData[field.key] === "true"; - }); - await axios.post( `${API_URL}/api/projects/${projectId}/collections/${activeCollection.name}/data`, - formattedData, + submittedData, { headers: { Authorization: `Bearer ${token}` }, } @@ -140,267 +132,33 @@ export default function Database() { toast.success("Document added successfully"); setIsAddModalOpen(false); - setNewData({}); fetchData(); } catch (err) { console.error(err); toast.error(err.response?.data?.error || "Failed to add data"); + } finally { + setIsSubmitting(false); } }; - const renderInput = (field) => { - const val = newData[field.key] || ""; - if (field.type === "Boolean") { - return ( -
- -
- ); - } - return ( - - setNewData({ ...newData, [field.key]: e.target.value }) - } - required={field.required} - /> - ); - }; - // --- SUB-COMPONENTS --- // - const Sidebar = () => ( - - ); - /* Column Resizer Component */ - const Resizer = ({ header }) => { - return ( -
- ); - }; - const TableView = () => { - const columns = useMemo(() => { - if (!activeCollection?.model) return []; - return [ - { - id: "rowNumber", - header: "#", - cell: (info) => {info.row.index + 1}, - size: 50, - enableResizing: false, - }, - ...activeCollection.model.map((field) => ({ - header: () => ( -
- {field.key} - {field.type} -
- ), - accessorKey: field.key, - size: 200, // Default width - minSize: 100, - maxSize: 500, - cell: (info) => { - const value = info.getValue(); - if (typeof value === "boolean") { - return ( - - {String(value)} - - ); - } - return ( -
- {String(value)} -
- ); - }, - })), - { - id: "_id", - header: "ID", - accessorKey: "_id", - size: 150, - cell: (info) => ( - - {String(info.getValue()).substring(0, 8)}... - - ), - }, - { - id: "actions", - header: "Actions", - size: 80, - enableResizing: false, - cell: (info) => ( - - ), - }, - ]; - }, [activeCollection]); - - const table = useReactTable({ - data, - columns, - columnResizeMode: "onChange", - getCoreRowModel: getCoreRowModel(), - }); - - return ( -
- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - - ))} - - ))} - - - {table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - ))} - - ))} - -
- {header.isPlaceholder - ? null - : flexRender( - header.column.columnDef.header, - header.getContext() - )} - {header.column.getCanResize() && ( - - )} -
- {flexRender(cell.column.columnDef.cell, cell.getContext())} -
- {showModal && ( - { - handleDelete(selectedId); - setShowModal(false); - }} - onCancel={() => setShowModal(false)} - /> - )} -
- ); - }; + const TableView = () => ( +
+ +
+ ); const JsonView = () => (
setIsSidebarOpen(false)} /> - + + + setSelectedRecord(null)} + record={selectedRecord} + fields={activeCollection?.model || []} + /> + + {showModal && ( + { + handleDelete(selectedId); + setShowModal(false); + }} + onCancel={() => setShowModal(false)} + /> + )}
{activeCollection ? ( @@ -456,11 +243,19 @@ export default function Database() { {data.length} Records
+ @@ -511,6 +306,12 @@ export default function Database() { Add Document
+ ) : viewMode === "list" ? ( + ) : viewMode === "table" ? ( ) : ( @@ -537,45 +338,16 @@ export default function Database() { )}
- {/* Add Document Modal */} + {/* Add Record Drawer */} {isAddModalOpen && ( -
-
-
-

Add New Document

- -
-
- {(activeCollection.model || []).map((field) => ( -
- - {renderInput(field)} -
- ))} -
- - -
-
-
-
+ setIsAddModalOpen(false)} + onSubmit={handleAddDocument} + fields={activeCollection?.model || []} + isSubmitting={isSubmitting} + /> )}