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 */}
+
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
+
+ {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) => (
- |
- {header.isPlaceholder
- ? null
- : flexRender(
- header.column.columnDef.header,
- header.getContext()
- )}
- {header.column.getCanResize() && (
-
- )}
- |
- ))}
-
- ))}
-
-
- {table.getRowModel().rows.map((row) => (
-
- {row.getVisibleCells().map((cell) => (
- |
- {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
-
-
-
-
-
+ setIsAddModalOpen(false)}
+ onSubmit={handleAddDocument}
+ fields={activeCollection?.model || []}
+ isSubmitting={isSubmitting}
+ />
)}