diff --git a/components/dashboard/multi-column-grid.tsx b/components/dashboard/multi-column-grid.tsx
new file mode 100644
index 0000000..6bb8ff1
--- /dev/null
+++ b/components/dashboard/multi-column-grid.tsx
@@ -0,0 +1,294 @@
+"use client";
+
+import {
+ type ColumnId,
+ type ModuleOrder,
+ COLUMN_IDS,
+} from "@/src/types/module-order";
+import {
+ DndContext,
+ DragEndEvent,
+ DragOverEvent,
+ DragOverlay,
+ DragStartEvent,
+ KeyboardSensor,
+ PointerSensor,
+ closestCorners,
+ useSensor,
+ useSensors,
+} from "@dnd-kit/core";
+import { sortableKeyboardCoordinates } from "@dnd-kit/sortable";
+import { ReactNode, useEffect, useRef, useState } from "react";
+import { DroppableColumn } from "./droppable-column";
+
+interface MultiColumnGridProps {
+ moduleOrder: ModuleOrder;
+ visibleColumns: ColumnId[];
+ onReorder: (newOrder: ModuleOrder) => void;
+ renderModule: (moduleId: string) => ReactNode;
+ allModules: string[];
+}
+
+export function MultiColumnGrid({
+ moduleOrder,
+ visibleColumns,
+ onReorder,
+ renderModule,
+ allModules,
+}: MultiColumnGridProps) {
+ const [activeId, setActiveId] = useState
(null);
+
+ const sensors = useSensors(
+ useSensor(PointerSensor, {
+ activationConstraint: {
+ distance: 8,
+ },
+ }),
+ useSensor(KeyboardSensor, {
+ coordinateGetter: sortableKeyboardCoordinates,
+ })
+ );
+
+ // Distribute unordered modules across visible columns
+ const getModulesForColumn = (columnId: ColumnId): string[] => {
+ const orderedInColumn = moduleOrder[columnId] || [];
+
+ // Filter to only include modules that exist in allModules
+ return orderedInColumn.filter((id) => allModules.includes(id));
+ };
+
+ // Add any modules not in any column
+ const ensureAllModulesPlaced = (order: ModuleOrder): ModuleOrder => {
+ const placedModules = new Set(COLUMN_IDS.flatMap((colId) => order[colId]));
+
+ const unplacedModules = allModules.filter((id) => !placedModules.has(id));
+
+ if (unplacedModules.length === 0) {
+ return order;
+ }
+
+ // Check if layout is completely empty (first time at this breakpoint)
+ const isLayoutEmpty = visibleColumns.every(
+ (colId) => order[colId].length === 0
+ );
+
+ const newOrder = { ...order };
+
+ if (isLayoutEmpty && visibleColumns.length > 0) {
+ // Distribute modules evenly across all visible columns
+ unplacedModules.forEach((moduleId, index) => {
+ const columnIndex = index % visibleColumns.length;
+ const columnId = visibleColumns[columnIndex];
+ if (columnId) {
+ newOrder[columnId].push(moduleId);
+ }
+ });
+ } else if (visibleColumns.length > 0) {
+ // Add new modules to the first column (for newly pinned modules)
+ newOrder[visibleColumns[0]] = [
+ ...unplacedModules,
+ ...order[visibleColumns[0]],
+ ];
+ }
+
+ return newOrder;
+ };
+
+ // Auto-save initial layout distribution
+ const hasInitialized = useRef(false);
+ useEffect(() => {
+ // Only run once on mount
+ if (hasInitialized.current) return;
+
+ const placedModules = new Set(
+ COLUMN_IDS.flatMap((colId) => moduleOrder[colId])
+ );
+ const unplacedModules = allModules.filter((id) => !placedModules.has(id));
+
+ // Check if layout is empty and has unplaced modules
+ const isLayoutEmpty = visibleColumns.every(
+ (colId) => moduleOrder[colId].length === 0
+ );
+
+ if (
+ isLayoutEmpty &&
+ unplacedModules.length > 0 &&
+ visibleColumns.length > 0
+ ) {
+ // Distribute and save
+ const distributedOrder = ensureAllModulesPlaced(moduleOrder);
+ onReorder(distributedOrder);
+ hasInitialized.current = true;
+ }
+ }, [allModules, moduleOrder, visibleColumns, onReorder]);
+
+ const handleDragStart = (event: DragStartEvent) => {
+ console.log("🎯 Drag started:", event.active.id);
+ setActiveId(event.active.id as string);
+ };
+
+ const handleDragOver = (event: DragOverEvent) => {
+ const { active, over } = event;
+ console.log("🔄 Drag over:", { activeId: active.id, overId: over?.id });
+
+ if (!over) return;
+
+ const activeId = active.id as string;
+ const overId = over.id as string;
+
+ // Use finalOrder which has all modules placed
+ const currentOrder = ensureAllModulesPlaced(moduleOrder);
+
+ // Find source column
+ let sourceColumn: ColumnId | null = null;
+ for (const colId of COLUMN_IDS) {
+ if (currentOrder[colId].includes(activeId)) {
+ sourceColumn = colId;
+ break;
+ }
+ }
+
+ if (!sourceColumn) return;
+
+ // Determine target column
+ let targetColumn: ColumnId | null = null;
+ if (COLUMN_IDS.includes(overId as ColumnId)) {
+ // Dropped on column itself
+ targetColumn = overId as ColumnId;
+ } else {
+ // Dropped on a module, find its column
+ for (const colId of COLUMN_IDS) {
+ if (currentOrder[colId].includes(overId)) {
+ targetColumn = colId;
+ break;
+ }
+ }
+ }
+
+ if (!targetColumn) return;
+
+ if (sourceColumn === targetColumn) return;
+
+ // Move module to target column
+ const newOrder = { ...currentOrder };
+ newOrder[sourceColumn] = newOrder[sourceColumn].filter(
+ (id) => id !== activeId
+ );
+
+ // Add to target column at the position of overId if it's a module
+ if (COLUMN_IDS.includes(overId as ColumnId)) {
+ // Dropped on empty column
+ newOrder[targetColumn] = [...newOrder[targetColumn], activeId];
+ } else {
+ // Dropped on a module
+ const overIndex = newOrder[targetColumn].indexOf(overId);
+ newOrder[targetColumn].splice(overIndex, 0, activeId);
+ }
+
+ onReorder(newOrder);
+ };
+
+ const handleDragEnd = (event: DragEndEvent) => {
+ const { active, over } = event;
+ console.log("✅ Drag ended:", { activeId: active.id, overId: over?.id });
+
+ setActiveId(null);
+
+ if (!over) return;
+
+ const activeId = active.id as string;
+ const overId = over.id as string;
+
+ // Use finalOrder which has all modules placed
+ const currentOrder = ensureAllModulesPlaced(moduleOrder);
+
+ // Find active column
+ let activeColumn: ColumnId | null = null;
+ for (const colId of COLUMN_IDS) {
+ if (currentOrder[colId].includes(activeId)) {
+ activeColumn = colId;
+ break;
+ }
+ }
+
+ if (!activeColumn) return;
+
+ // If dropped on a column container
+ if (COLUMN_IDS.includes(overId as ColumnId)) {
+ return; // Already handled in dragOver
+ }
+
+ // Reorder within same column
+ const overColumn = COLUMN_IDS.find((colId) =>
+ currentOrder[colId].includes(overId)
+ );
+
+ if (overColumn === activeColumn) {
+ const columnItems = [...currentOrder[activeColumn]];
+ const activeIndex = columnItems.indexOf(activeId);
+ const overIndex = columnItems.indexOf(overId);
+
+ if (activeIndex !== overIndex) {
+ columnItems.splice(activeIndex, 1);
+ columnItems.splice(overIndex, 0, activeId);
+
+ const newOrder = {
+ ...currentOrder,
+ [activeColumn]: columnItems,
+ };
+
+ console.log("💾 Saving new order:", newOrder);
+ onReorder(newOrder);
+ }
+ }
+ };
+
+ const finalOrder = ensureAllModulesPlaced(moduleOrder);
+
+ // Filter modules to only include those that exist in allModules
+ const filteredOrder: ModuleOrder = {
+ col1: finalOrder.col1.filter((id) => allModules.includes(id)),
+ col2: finalOrder.col2.filter((id) => allModules.includes(id)),
+ col3: finalOrder.col3.filter((id) => allModules.includes(id)),
+ col4: finalOrder.col4.filter((id) => allModules.includes(id)),
+ col5: finalOrder.col5.filter((id) => allModules.includes(id)),
+ };
+
+ return (
+
+
+ {visibleColumns.map((columnId) => {
+ return (
+
+ {filteredOrder[columnId].map((moduleId) =>
+ renderModule(moduleId)
+ )}
+
+ );
+ })}
+
+
+ {activeId ? (
+
+ {renderModule(activeId)}
+
+ ) : null}
+
+
+ );
+}
diff --git a/components/dashboard/sortable-module-grid.tsx b/components/dashboard/sortable-module-grid.tsx
new file mode 100644
index 0000000..1de5564
--- /dev/null
+++ b/components/dashboard/sortable-module-grid.tsx
@@ -0,0 +1,83 @@
+"use client";
+
+import {
+ DndContext,
+ closestCenter,
+ KeyboardSensor,
+ PointerSensor,
+ useSensor,
+ useSensors,
+ DragEndEvent,
+ DragStartEvent,
+} from "@dnd-kit/core";
+import {
+ arrayMove,
+ SortableContext,
+ sortableKeyboardCoordinates,
+ rectSortingStrategy,
+} from "@dnd-kit/sortable";
+import { ReactNode, useState } from "react";
+
+interface SortableModuleGridProps {
+ children: ReactNode;
+ moduleIds: string[];
+ onReorder: (newOrder: string[]) => void;
+}
+
+export function SortableModuleGrid({
+ children,
+ moduleIds,
+ onReorder,
+}: SortableModuleGridProps) {
+ const [activeId, setActiveId] = useState(null);
+
+ // Sensors for drag interaction
+ const sensors = useSensors(
+ useSensor(PointerSensor, {
+ activationConstraint: {
+ distance: 8, // 8px movement before drag starts (prevents accidental drags)
+ },
+ }),
+ useSensor(KeyboardSensor, {
+ coordinateGetter: sortableKeyboardCoordinates,
+ })
+ );
+
+ const handleDragStart = (event: DragStartEvent) => {
+ setActiveId(event.active.id as string);
+ };
+
+ const handleDragEnd = (event: DragEndEvent) => {
+ const { active, over } = event;
+
+ if (over && active.id !== over.id) {
+ const oldIndex = moduleIds.indexOf(active.id as string);
+ const newIndex = moduleIds.indexOf(over.id as string);
+
+ const newOrder = arrayMove(moduleIds, oldIndex, newIndex);
+ onReorder(newOrder);
+ }
+
+ setActiveId(null);
+ };
+
+ const handleDragCancel = () => {
+ setActiveId(null);
+ };
+
+ return (
+
+
+
+ {children}
+
+
+
+ );
+}
diff --git a/components/dashboard/sortable-module-item.tsx b/components/dashboard/sortable-module-item.tsx
new file mode 100644
index 0000000..6f39fb7
--- /dev/null
+++ b/components/dashboard/sortable-module-item.tsx
@@ -0,0 +1,50 @@
+"use client";
+
+import { useSortable } from "@dnd-kit/sortable";
+import { CSS } from "@dnd-kit/utilities";
+import { GripHorizontal } from "lucide-react";
+import { ReactNode } from "react";
+
+interface SortableModuleItemProps {
+ id: string;
+ children: ReactNode;
+}
+
+export function SortableModuleItem({ id, children }: SortableModuleItemProps) {
+ const {
+ attributes,
+ listeners,
+ setNodeRef,
+ setActivatorNodeRef,
+ transform,
+ transition,
+ isDragging,
+ } = useSortable({ id });
+
+ const style = {
+ transform: CSS.Transform.toString(transform),
+ transition: transition || 'transform 200ms cubic-bezier(0.18, 0.67, 0.6, 1.22)',
+ };
+
+ return (
+
+ {/* Drag handle */}
+
+ {children}
+
+ );
+}
diff --git a/package.json b/package.json
index 09d0327..501c97d 100644
--- a/package.json
+++ b/package.json
@@ -19,6 +19,9 @@
},
"dependencies": {
"@base-ui/react": "^1.0.0",
+ "@dnd-kit/core": "^6.3.1",
+ "@dnd-kit/sortable": "^10.0.0",
+ "@dnd-kit/utilities": "^3.2.2",
"@prisma/adapter-pg": "^7.2.0",
"@prisma/client": "^7.2.0",
"@radix-ui/react-switch": "^1.2.6",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index db4820c..0c40383 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -11,6 +11,15 @@ importers:
'@base-ui/react':
specifier: ^1.0.0
version: 1.0.0(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ '@dnd-kit/core':
+ specifier: ^6.3.1
+ version: 6.3.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ '@dnd-kit/sortable':
+ specifier: ^10.0.0
+ version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)
+ '@dnd-kit/utilities':
+ specifier: ^3.2.2
+ version: 3.2.2(react@19.2.3)
'@prisma/adapter-pg':
specifier: ^7.2.0
version: 7.2.0
@@ -357,6 +366,28 @@ packages:
'@chevrotain/utils@10.5.0':
resolution: {integrity: sha512-hBzuU5+JjB2cqNZyszkDHZgOSrUUT8V3dhgRl8Q9Gp6dAj/H5+KILGjbhDpc3Iy9qmqlm/akuOI2ut9VUtzJxQ==}
+ '@dnd-kit/accessibility@3.1.1':
+ resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
+ peerDependencies:
+ react: '>=16.8.0'
+
+ '@dnd-kit/core@6.3.1':
+ resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==}
+ peerDependencies:
+ react: '>=16.8.0'
+ react-dom: '>=16.8.0'
+
+ '@dnd-kit/sortable@10.0.0':
+ resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==}
+ peerDependencies:
+ '@dnd-kit/core': ^6.3.0
+ react: '>=16.8.0'
+
+ '@dnd-kit/utilities@3.2.2':
+ resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==}
+ peerDependencies:
+ react: '>=16.8.0'
+
'@dotenvx/dotenvx@1.51.2':
resolution: {integrity: sha512-+693mNflujDZxudSEqSNGpn92QgFhJlBn9q2mDQ9yGWyHuz3hZ8B5g3EXCwdAz4DMJAI+OFCIbfEFZS+YRdrEA==}
hasBin: true
@@ -5792,6 +5823,31 @@ snapshots:
'@chevrotain/utils@10.5.0': {}
+ '@dnd-kit/accessibility@3.1.1(react@19.2.3)':
+ dependencies:
+ react: 19.2.3
+ tslib: 2.8.1
+
+ '@dnd-kit/core@6.3.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
+ dependencies:
+ '@dnd-kit/accessibility': 3.1.1(react@19.2.3)
+ '@dnd-kit/utilities': 3.2.2(react@19.2.3)
+ react: 19.2.3
+ react-dom: 19.2.3(react@19.2.3)
+ tslib: 2.8.1
+
+ '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)':
+ dependencies:
+ '@dnd-kit/core': 6.3.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ '@dnd-kit/utilities': 3.2.2(react@19.2.3)
+ react: 19.2.3
+ tslib: 2.8.1
+
+ '@dnd-kit/utilities@3.2.2(react@19.2.3)':
+ dependencies:
+ react: 19.2.3
+ tslib: 2.8.1
+
'@dotenvx/dotenvx@1.51.2':
dependencies:
commander: 11.1.0
diff --git a/prisma/generated/prisma/internal/class.ts b/prisma/generated/prisma/internal/class.ts
index 69d5b09..f49c6a6 100644
--- a/prisma/generated/prisma/internal/class.ts
+++ b/prisma/generated/prisma/internal/class.ts
@@ -20,7 +20,7 @@ const config: runtime.GetPrismaClientConfig = {
"clientVersion": "7.2.0",
"engineVersion": "0c8ef2ce45c83248ab3df073180d5eda9e8be7a3",
"activeProvider": "postgresql",
- "inlineSchema": "generator client {\n provider = \"prisma-client\"\n output = \"../prisma/generated/prisma\"\n}\n\ndatasource db {\n provider = \"postgresql\"\n}\n\nmodel Workspace {\n id String @id\n userId String @unique\n layout Json?\n pinnedModules String[] @default([])\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n User User @relation(fields: [userId], references: [id], onDelete: Cascade)\n}\n\nmodel User {\n id String @id\n name String\n email String\n emailVerified Boolean @default(false)\n image String?\n removeBgApiKey String?\n deeplApiKey String?\n\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n sessions Session[]\n accounts Account[]\n\n //relations\n workspace Workspace?\n colorPalettes ColorPalette[]\n domainNames DomainName[]\n snippets Snippet[]\n youtubeVideos YouTubeVideo[]\n pomodoroSettings PomodoroSettings?\n stickyNote StickyNote?\n\n @@unique([email])\n @@map(\"user\")\n}\n\nmodel Session {\n id String @id\n expiresAt DateTime\n token String\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n ipAddress String?\n userAgent String?\n userId String\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n @@unique([token])\n @@index([userId])\n @@map(\"session\")\n}\n\nmodel Account {\n id String @id\n accountId String\n providerId String\n userId String\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n accessToken String?\n refreshToken String?\n idToken String?\n accessTokenExpiresAt DateTime?\n refreshTokenExpiresAt DateTime?\n scope String?\n password String?\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n @@index([userId])\n @@map(\"account\")\n}\n\nmodel Verification {\n id String @id\n identifier String\n value String\n expiresAt DateTime\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n @@index([identifier])\n @@map(\"verification\")\n}\n\nmodel ColorPalette {\n id String @id @default(cuid())\n userId String\n name String\n colors Json\n isDefault Boolean @default(false)\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n @@index([userId])\n @@map(\"color_palette\")\n}\n\nmodel DomainName {\n id String @id @default(cuid())\n userId String\n domain String\n registrar String\n registrarUrl String?\n expiresAt DateTime\n autoRenew Boolean @default(false)\n reminderOneMonth Boolean @default(true)\n reminderOneWeek Boolean @default(true)\n lastReminderSent DateTime?\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n @@index([userId])\n @@index([expiresAt])\n @@map(\"domain_name\")\n}\n\nmodel Snippet {\n id String @id @default(cuid())\n userId String\n title String\n description String?\n language String\n code String\n tags String[] @default([])\n isFavorite Boolean @default(false)\n lastUsedAt DateTime?\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n @@index([userId])\n @@index([isFavorite])\n @@index([lastUsedAt])\n @@map(\"snippet\")\n}\n\nmodel YouTubeVideo {\n id String @id @default(cuid())\n userId String\n url String\n title String\n videoId String\n playlistId String?\n isPlaylist Boolean @default(false)\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n @@index([userId])\n @@index([createdAt])\n @@map(\"youtube_video\")\n}\n\nmodel PomodoroSettings {\n id String @id @default(cuid())\n userId String @unique\n\n //lengths in minutes\n workDuration Int @default(50)\n breakDuration Int @default(10)\n cycles Int @default(3)\n\n //predefined sound IDs\n sessionStartSound String @default(\"rythm-bell\")\n breakStartSound String @default(\"completed\")\n breakEndSound String @default(\"triangle\")\n sessionEndSound String @default(\"victory-dance\")\n\n //background customization\n backgroundImage String? @default(\"cyan\") // Base64 ou ID image galerie\n backgroundType String @default(\"gallery\") // \"gallery\" | \"custom\" | \"none\"\n textColor String @default(\"#FFFFFF\")\n\n //options\n autoStartBreaks Boolean @default(false)\n autoStartPomodoros Boolean @default(false)\n soundEnabled Boolean @default(true)\n\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n @@index([userId])\n @@map(\"pomodoro_settings\")\n}\n\nmodel StickyNote {\n id String @id @default(cuid())\n userId String @unique\n content String @db.Text\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n @@index([userId])\n @@map(\"sticky_note\")\n}\n",
+ "inlineSchema": "generator client {\n provider = \"prisma-client\"\n output = \"../prisma/generated/prisma\"\n}\n\ndatasource db {\n provider = \"postgresql\"\n}\n\nmodel Workspace {\n id String @id\n userId String @unique\n moduleLayouts Json?\n pinnedModules String[] @default([])\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n User User @relation(fields: [userId], references: [id], onDelete: Cascade)\n}\n\nmodel User {\n id String @id\n name String\n email String\n emailVerified Boolean @default(false)\n image String?\n removeBgApiKey String?\n deeplApiKey String?\n\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n sessions Session[]\n accounts Account[]\n\n //relations\n workspace Workspace?\n colorPalettes ColorPalette[]\n domainNames DomainName[]\n snippets Snippet[]\n youtubeVideos YouTubeVideo[]\n pomodoroSettings PomodoroSettings?\n stickyNote StickyNote?\n\n @@unique([email])\n @@map(\"user\")\n}\n\nmodel Session {\n id String @id\n expiresAt DateTime\n token String\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n ipAddress String?\n userAgent String?\n userId String\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n @@unique([token])\n @@index([userId])\n @@map(\"session\")\n}\n\nmodel Account {\n id String @id\n accountId String\n providerId String\n userId String\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n accessToken String?\n refreshToken String?\n idToken String?\n accessTokenExpiresAt DateTime?\n refreshTokenExpiresAt DateTime?\n scope String?\n password String?\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n @@index([userId])\n @@map(\"account\")\n}\n\nmodel Verification {\n id String @id\n identifier String\n value String\n expiresAt DateTime\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n @@index([identifier])\n @@map(\"verification\")\n}\n\nmodel ColorPalette {\n id String @id @default(cuid())\n userId String\n name String\n colors Json\n isDefault Boolean @default(false)\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n @@index([userId])\n @@map(\"color_palette\")\n}\n\nmodel DomainName {\n id String @id @default(cuid())\n userId String\n domain String\n registrar String\n registrarUrl String?\n expiresAt DateTime\n autoRenew Boolean @default(false)\n reminderOneMonth Boolean @default(true)\n reminderOneWeek Boolean @default(true)\n lastReminderSent DateTime?\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n @@index([userId])\n @@index([expiresAt])\n @@map(\"domain_name\")\n}\n\nmodel Snippet {\n id String @id @default(cuid())\n userId String\n title String\n description String?\n language String\n code String\n tags String[] @default([])\n isFavorite Boolean @default(false)\n lastUsedAt DateTime?\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n @@index([userId])\n @@index([isFavorite])\n @@index([lastUsedAt])\n @@map(\"snippet\")\n}\n\nmodel YouTubeVideo {\n id String @id @default(cuid())\n userId String\n url String\n title String\n videoId String\n playlistId String?\n isPlaylist Boolean @default(false)\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n @@index([userId])\n @@index([createdAt])\n @@map(\"youtube_video\")\n}\n\nmodel PomodoroSettings {\n id String @id @default(cuid())\n userId String @unique\n\n //lengths in minutes\n workDuration Int @default(50)\n breakDuration Int @default(10)\n cycles Int @default(3)\n\n //predefined sound IDs\n sessionStartSound String @default(\"rythm-bell\")\n breakStartSound String @default(\"completed\")\n breakEndSound String @default(\"triangle\")\n sessionEndSound String @default(\"victory-dance\")\n\n //background customization\n backgroundImage String? @default(\"cyan\") // Base64 ou ID image galerie\n backgroundType String @default(\"gallery\") // \"gallery\" | \"custom\" | \"none\"\n textColor String @default(\"#FFFFFF\")\n\n //options\n autoStartBreaks Boolean @default(false)\n autoStartPomodoros Boolean @default(false)\n soundEnabled Boolean @default(true)\n\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n @@index([userId])\n @@map(\"pomodoro_settings\")\n}\n\nmodel StickyNote {\n id String @id @default(cuid())\n userId String @unique\n content String @db.Text\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n @@index([userId])\n @@map(\"sticky_note\")\n}\n",
"runtimeDataModel": {
"models": {},
"enums": {},
@@ -28,7 +28,7 @@ const config: runtime.GetPrismaClientConfig = {
}
}
-config.runtimeDataModel = JSON.parse("{\"models\":{\"Workspace\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"userId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"layout\",\"kind\":\"scalar\",\"type\":\"Json\"},{\"name\":\"pinnedModules\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"User\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"UserToWorkspace\"}],\"dbName\":null},\"User\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"email\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"emailVerified\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"image\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"removeBgApiKey\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"deeplApiKey\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"sessions\",\"kind\":\"object\",\"type\":\"Session\",\"relationName\":\"SessionToUser\"},{\"name\":\"accounts\",\"kind\":\"object\",\"type\":\"Account\",\"relationName\":\"AccountToUser\"},{\"name\":\"workspace\",\"kind\":\"object\",\"type\":\"Workspace\",\"relationName\":\"UserToWorkspace\"},{\"name\":\"colorPalettes\",\"kind\":\"object\",\"type\":\"ColorPalette\",\"relationName\":\"ColorPaletteToUser\"},{\"name\":\"domainNames\",\"kind\":\"object\",\"type\":\"DomainName\",\"relationName\":\"DomainNameToUser\"},{\"name\":\"snippets\",\"kind\":\"object\",\"type\":\"Snippet\",\"relationName\":\"SnippetToUser\"},{\"name\":\"youtubeVideos\",\"kind\":\"object\",\"type\":\"YouTubeVideo\",\"relationName\":\"UserToYouTubeVideo\"},{\"name\":\"pomodoroSettings\",\"kind\":\"object\",\"type\":\"PomodoroSettings\",\"relationName\":\"PomodoroSettingsToUser\"},{\"name\":\"stickyNote\",\"kind\":\"object\",\"type\":\"StickyNote\",\"relationName\":\"StickyNoteToUser\"}],\"dbName\":\"user\"},\"Session\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"expiresAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"token\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"ipAddress\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"userAgent\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"userId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"user\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"SessionToUser\"}],\"dbName\":\"session\"},\"Account\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"accountId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"providerId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"userId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"user\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"AccountToUser\"},{\"name\":\"accessToken\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"refreshToken\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"idToken\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"accessTokenExpiresAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"refreshTokenExpiresAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"scope\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"password\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":\"account\"},\"Verification\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"identifier\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"value\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"expiresAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":\"verification\"},\"ColorPalette\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"userId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"colors\",\"kind\":\"scalar\",\"type\":\"Json\"},{\"name\":\"isDefault\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"user\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"ColorPaletteToUser\"}],\"dbName\":\"color_palette\"},\"DomainName\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"userId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"domain\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"registrar\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"registrarUrl\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"expiresAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"autoRenew\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"reminderOneMonth\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"reminderOneWeek\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"lastReminderSent\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"user\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"DomainNameToUser\"}],\"dbName\":\"domain_name\"},\"Snippet\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"userId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"title\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"description\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"language\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"code\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"tags\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"isFavorite\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"lastUsedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"user\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"SnippetToUser\"}],\"dbName\":\"snippet\"},\"YouTubeVideo\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"userId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"url\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"title\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"videoId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"playlistId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"isPlaylist\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"user\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"UserToYouTubeVideo\"}],\"dbName\":\"youtube_video\"},\"PomodoroSettings\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"userId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"workDuration\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"breakDuration\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"cycles\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"sessionStartSound\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"breakStartSound\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"breakEndSound\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"sessionEndSound\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"backgroundImage\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"backgroundType\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"textColor\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"autoStartBreaks\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"autoStartPomodoros\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"soundEnabled\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"user\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"PomodoroSettingsToUser\"}],\"dbName\":\"pomodoro_settings\"},\"StickyNote\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"userId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"content\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"user\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"StickyNoteToUser\"}],\"dbName\":\"sticky_note\"}},\"enums\":{},\"types\":{}}")
+config.runtimeDataModel = JSON.parse("{\"models\":{\"Workspace\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"userId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"moduleLayouts\",\"kind\":\"scalar\",\"type\":\"Json\"},{\"name\":\"pinnedModules\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"User\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"UserToWorkspace\"}],\"dbName\":null},\"User\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"email\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"emailVerified\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"image\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"removeBgApiKey\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"deeplApiKey\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"sessions\",\"kind\":\"object\",\"type\":\"Session\",\"relationName\":\"SessionToUser\"},{\"name\":\"accounts\",\"kind\":\"object\",\"type\":\"Account\",\"relationName\":\"AccountToUser\"},{\"name\":\"workspace\",\"kind\":\"object\",\"type\":\"Workspace\",\"relationName\":\"UserToWorkspace\"},{\"name\":\"colorPalettes\",\"kind\":\"object\",\"type\":\"ColorPalette\",\"relationName\":\"ColorPaletteToUser\"},{\"name\":\"domainNames\",\"kind\":\"object\",\"type\":\"DomainName\",\"relationName\":\"DomainNameToUser\"},{\"name\":\"snippets\",\"kind\":\"object\",\"type\":\"Snippet\",\"relationName\":\"SnippetToUser\"},{\"name\":\"youtubeVideos\",\"kind\":\"object\",\"type\":\"YouTubeVideo\",\"relationName\":\"UserToYouTubeVideo\"},{\"name\":\"pomodoroSettings\",\"kind\":\"object\",\"type\":\"PomodoroSettings\",\"relationName\":\"PomodoroSettingsToUser\"},{\"name\":\"stickyNote\",\"kind\":\"object\",\"type\":\"StickyNote\",\"relationName\":\"StickyNoteToUser\"}],\"dbName\":\"user\"},\"Session\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"expiresAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"token\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"ipAddress\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"userAgent\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"userId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"user\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"SessionToUser\"}],\"dbName\":\"session\"},\"Account\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"accountId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"providerId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"userId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"user\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"AccountToUser\"},{\"name\":\"accessToken\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"refreshToken\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"idToken\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"accessTokenExpiresAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"refreshTokenExpiresAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"scope\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"password\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":\"account\"},\"Verification\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"identifier\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"value\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"expiresAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":\"verification\"},\"ColorPalette\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"userId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"colors\",\"kind\":\"scalar\",\"type\":\"Json\"},{\"name\":\"isDefault\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"user\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"ColorPaletteToUser\"}],\"dbName\":\"color_palette\"},\"DomainName\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"userId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"domain\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"registrar\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"registrarUrl\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"expiresAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"autoRenew\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"reminderOneMonth\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"reminderOneWeek\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"lastReminderSent\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"user\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"DomainNameToUser\"}],\"dbName\":\"domain_name\"},\"Snippet\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"userId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"title\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"description\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"language\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"code\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"tags\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"isFavorite\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"lastUsedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"user\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"SnippetToUser\"}],\"dbName\":\"snippet\"},\"YouTubeVideo\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"userId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"url\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"title\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"videoId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"playlistId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"isPlaylist\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"user\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"UserToYouTubeVideo\"}],\"dbName\":\"youtube_video\"},\"PomodoroSettings\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"userId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"workDuration\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"breakDuration\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"cycles\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"sessionStartSound\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"breakStartSound\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"breakEndSound\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"sessionEndSound\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"backgroundImage\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"backgroundType\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"textColor\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"autoStartBreaks\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"autoStartPomodoros\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"soundEnabled\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"user\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"PomodoroSettingsToUser\"}],\"dbName\":\"pomodoro_settings\"},\"StickyNote\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"userId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"content\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"user\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"StickyNoteToUser\"}],\"dbName\":\"sticky_note\"}},\"enums\":{},\"types\":{}}")
async function decodeBase64AsWasm(wasmBase64: string): Promise {
const { Buffer } = await import('node:buffer')
diff --git a/prisma/generated/prisma/internal/prismaNamespace.ts b/prisma/generated/prisma/internal/prismaNamespace.ts
index 8ea8764..0730d3b 100644
--- a/prisma/generated/prisma/internal/prismaNamespace.ts
+++ b/prisma/generated/prisma/internal/prismaNamespace.ts
@@ -1270,7 +1270,7 @@ export type TransactionIsolationLevel = (typeof TransactionIsolationLevel)[keyof
export const WorkspaceScalarFieldEnum = {
id: 'id',
userId: 'userId',
- layout: 'layout',
+ moduleLayouts: 'moduleLayouts',
pinnedModules: 'pinnedModules',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
diff --git a/prisma/generated/prisma/internal/prismaNamespaceBrowser.ts b/prisma/generated/prisma/internal/prismaNamespaceBrowser.ts
index b753eb5..63b4c9f 100644
--- a/prisma/generated/prisma/internal/prismaNamespaceBrowser.ts
+++ b/prisma/generated/prisma/internal/prismaNamespaceBrowser.ts
@@ -83,7 +83,7 @@ export type TransactionIsolationLevel = (typeof TransactionIsolationLevel)[keyof
export const WorkspaceScalarFieldEnum = {
id: 'id',
userId: 'userId',
- layout: 'layout',
+ moduleLayouts: 'moduleLayouts',
pinnedModules: 'pinnedModules',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
diff --git a/prisma/generated/prisma/models/Workspace.ts b/prisma/generated/prisma/models/Workspace.ts
index 6b93c86..92621a1 100644
--- a/prisma/generated/prisma/models/Workspace.ts
+++ b/prisma/generated/prisma/models/Workspace.ts
@@ -41,7 +41,7 @@ export type WorkspaceMaxAggregateOutputType = {
export type WorkspaceCountAggregateOutputType = {
id: number
userId: number
- layout: number
+ moduleLayouts: number
pinnedModules: number
createdAt: number
updatedAt: number
@@ -66,7 +66,7 @@ export type WorkspaceMaxAggregateInputType = {
export type WorkspaceCountAggregateInputType = {
id?: true
userId?: true
- layout?: true
+ moduleLayouts?: true
pinnedModules?: true
createdAt?: true
updatedAt?: true
@@ -148,7 +148,7 @@ export type WorkspaceGroupByArgs | string
userId?: Prisma.StringFilter<"Workspace"> | string
- layout?: Prisma.JsonNullableFilter<"Workspace">
+ moduleLayouts?: Prisma.JsonNullableFilter<"Workspace">
pinnedModules?: Prisma.StringNullableListFilter<"Workspace">
createdAt?: Prisma.DateTimeFilter<"Workspace"> | Date | string
updatedAt?: Prisma.DateTimeFilter<"Workspace"> | Date | string
@@ -188,7 +188,7 @@ export type WorkspaceWhereInput = {
export type WorkspaceOrderByWithRelationInput = {
id?: Prisma.SortOrder
userId?: Prisma.SortOrder
- layout?: Prisma.SortOrderInput | Prisma.SortOrder
+ moduleLayouts?: Prisma.SortOrderInput | Prisma.SortOrder
pinnedModules?: Prisma.SortOrder
createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder
@@ -201,7 +201,7 @@ export type WorkspaceWhereUniqueInput = Prisma.AtLeast<{
AND?: Prisma.WorkspaceWhereInput | Prisma.WorkspaceWhereInput[]
OR?: Prisma.WorkspaceWhereInput[]
NOT?: Prisma.WorkspaceWhereInput | Prisma.WorkspaceWhereInput[]
- layout?: Prisma.JsonNullableFilter<"Workspace">
+ moduleLayouts?: Prisma.JsonNullableFilter<"Workspace">
pinnedModules?: Prisma.StringNullableListFilter<"Workspace">
createdAt?: Prisma.DateTimeFilter<"Workspace"> | Date | string
updatedAt?: Prisma.DateTimeFilter<"Workspace"> | Date | string
@@ -211,7 +211,7 @@ export type WorkspaceWhereUniqueInput = Prisma.AtLeast<{
export type WorkspaceOrderByWithAggregationInput = {
id?: Prisma.SortOrder
userId?: Prisma.SortOrder
- layout?: Prisma.SortOrderInput | Prisma.SortOrder
+ moduleLayouts?: Prisma.SortOrderInput | Prisma.SortOrder
pinnedModules?: Prisma.SortOrder
createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder
@@ -226,7 +226,7 @@ export type WorkspaceScalarWhereWithAggregatesInput = {
NOT?: Prisma.WorkspaceScalarWhereWithAggregatesInput | Prisma.WorkspaceScalarWhereWithAggregatesInput[]
id?: Prisma.StringWithAggregatesFilter<"Workspace"> | string
userId?: Prisma.StringWithAggregatesFilter<"Workspace"> | string
- layout?: Prisma.JsonNullableWithAggregatesFilter<"Workspace">
+ moduleLayouts?: Prisma.JsonNullableWithAggregatesFilter<"Workspace">
pinnedModules?: Prisma.StringNullableListFilter<"Workspace">
createdAt?: Prisma.DateTimeWithAggregatesFilter<"Workspace"> | Date | string
updatedAt?: Prisma.DateTimeWithAggregatesFilter<"Workspace"> | Date | string
@@ -234,7 +234,7 @@ export type WorkspaceScalarWhereWithAggregatesInput = {
export type WorkspaceCreateInput = {
id: string
- layout?: Prisma.NullableJsonNullValueInput | runtime.InputJsonValue
+ moduleLayouts?: Prisma.NullableJsonNullValueInput | runtime.InputJsonValue
pinnedModules?: Prisma.WorkspaceCreatepinnedModulesInput | string[]
createdAt?: Date | string
updatedAt?: Date | string
@@ -244,7 +244,7 @@ export type WorkspaceCreateInput = {
export type WorkspaceUncheckedCreateInput = {
id: string
userId: string
- layout?: Prisma.NullableJsonNullValueInput | runtime.InputJsonValue
+ moduleLayouts?: Prisma.NullableJsonNullValueInput | runtime.InputJsonValue
pinnedModules?: Prisma.WorkspaceCreatepinnedModulesInput | string[]
createdAt?: Date | string
updatedAt?: Date | string
@@ -252,7 +252,7 @@ export type WorkspaceUncheckedCreateInput = {
export type WorkspaceUpdateInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
- layout?: Prisma.NullableJsonNullValueInput | runtime.InputJsonValue
+ moduleLayouts?: Prisma.NullableJsonNullValueInput | runtime.InputJsonValue
pinnedModules?: Prisma.WorkspaceUpdatepinnedModulesInput | string[]
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
@@ -262,7 +262,7 @@ export type WorkspaceUpdateInput = {
export type WorkspaceUncheckedUpdateInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
userId?: Prisma.StringFieldUpdateOperationsInput | string
- layout?: Prisma.NullableJsonNullValueInput | runtime.InputJsonValue
+ moduleLayouts?: Prisma.NullableJsonNullValueInput | runtime.InputJsonValue
pinnedModules?: Prisma.WorkspaceUpdatepinnedModulesInput | string[]
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
@@ -271,7 +271,7 @@ export type WorkspaceUncheckedUpdateInput = {
export type WorkspaceCreateManyInput = {
id: string
userId: string
- layout?: Prisma.NullableJsonNullValueInput | runtime.InputJsonValue
+ moduleLayouts?: Prisma.NullableJsonNullValueInput | runtime.InputJsonValue
pinnedModules?: Prisma.WorkspaceCreatepinnedModulesInput | string[]
createdAt?: Date | string
updatedAt?: Date | string
@@ -279,7 +279,7 @@ export type WorkspaceCreateManyInput = {
export type WorkspaceUpdateManyMutationInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
- layout?: Prisma.NullableJsonNullValueInput | runtime.InputJsonValue
+ moduleLayouts?: Prisma.NullableJsonNullValueInput | runtime.InputJsonValue
pinnedModules?: Prisma.WorkspaceUpdatepinnedModulesInput | string[]
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
@@ -288,7 +288,7 @@ export type WorkspaceUpdateManyMutationInput = {
export type WorkspaceUncheckedUpdateManyInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
userId?: Prisma.StringFieldUpdateOperationsInput | string
- layout?: Prisma.NullableJsonNullValueInput | runtime.InputJsonValue
+ moduleLayouts?: Prisma.NullableJsonNullValueInput | runtime.InputJsonValue
pinnedModules?: Prisma.WorkspaceUpdatepinnedModulesInput | string[]
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
@@ -305,7 +305,7 @@ export type StringNullableListFilter<$PrismaModel = never> = {
export type WorkspaceCountOrderByAggregateInput = {
id?: Prisma.SortOrder
userId?: Prisma.SortOrder
- layout?: Prisma.SortOrder
+ moduleLayouts?: Prisma.SortOrder
pinnedModules?: Prisma.SortOrder
createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder
@@ -381,7 +381,7 @@ export type WorkspaceUncheckedUpdateOneWithoutUserNestedInput = {
export type WorkspaceCreateWithoutUserInput = {
id: string
- layout?: Prisma.NullableJsonNullValueInput | runtime.InputJsonValue
+ moduleLayouts?: Prisma.NullableJsonNullValueInput | runtime.InputJsonValue
pinnedModules?: Prisma.WorkspaceCreatepinnedModulesInput | string[]
createdAt?: Date | string
updatedAt?: Date | string
@@ -389,7 +389,7 @@ export type WorkspaceCreateWithoutUserInput = {
export type WorkspaceUncheckedCreateWithoutUserInput = {
id: string
- layout?: Prisma.NullableJsonNullValueInput | runtime.InputJsonValue
+ moduleLayouts?: Prisma.NullableJsonNullValueInput | runtime.InputJsonValue
pinnedModules?: Prisma.WorkspaceCreatepinnedModulesInput | string[]
createdAt?: Date | string
updatedAt?: Date | string
@@ -413,7 +413,7 @@ export type WorkspaceUpdateToOneWithWhereWithoutUserInput = {
export type WorkspaceUpdateWithoutUserInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
- layout?: Prisma.NullableJsonNullValueInput | runtime.InputJsonValue
+ moduleLayouts?: Prisma.NullableJsonNullValueInput | runtime.InputJsonValue
pinnedModules?: Prisma.WorkspaceUpdatepinnedModulesInput | string[]
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
@@ -421,7 +421,7 @@ export type WorkspaceUpdateWithoutUserInput = {
export type WorkspaceUncheckedUpdateWithoutUserInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
- layout?: Prisma.NullableJsonNullValueInput | runtime.InputJsonValue
+ moduleLayouts?: Prisma.NullableJsonNullValueInput | runtime.InputJsonValue
pinnedModules?: Prisma.WorkspaceUpdatepinnedModulesInput | string[]
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
@@ -432,7 +432,7 @@ export type WorkspaceUncheckedUpdateWithoutUserInput = {
export type WorkspaceSelect = runtime.Types.Extensions.GetSelect<{
id?: boolean
userId?: boolean
- layout?: boolean
+ moduleLayouts?: boolean
pinnedModules?: boolean
createdAt?: boolean
updatedAt?: boolean
@@ -442,7 +442,7 @@ export type WorkspaceSelect = runtime.Types.Extensions.GetSelect<{
id?: boolean
userId?: boolean
- layout?: boolean
+ moduleLayouts?: boolean
pinnedModules?: boolean
createdAt?: boolean
updatedAt?: boolean
@@ -452,7 +452,7 @@ export type WorkspaceSelectCreateManyAndReturn = runtime.Types.Extensions.GetSelect<{
id?: boolean
userId?: boolean
- layout?: boolean
+ moduleLayouts?: boolean
pinnedModules?: boolean
createdAt?: boolean
updatedAt?: boolean
@@ -462,13 +462,13 @@ export type WorkspaceSelectUpdateManyAndReturn = runtime.Types.Extensions.GetOmit<"id" | "userId" | "layout" | "pinnedModules" | "createdAt" | "updatedAt", ExtArgs["result"]["workspace"]>
+export type WorkspaceOmit = runtime.Types.Extensions.GetOmit<"id" | "userId" | "moduleLayouts" | "pinnedModules" | "createdAt" | "updatedAt", ExtArgs["result"]["workspace"]>
export type WorkspaceInclude = {
User?: boolean | Prisma.UserDefaultArgs
}
@@ -487,7 +487,7 @@ export type $WorkspacePayload
readonly userId: Prisma.FieldRef<"Workspace", 'String'>
- readonly layout: Prisma.FieldRef<"Workspace", 'Json'>
+ readonly moduleLayouts: Prisma.FieldRef<"Workspace", 'Json'>
readonly pinnedModules: Prisma.FieldRef<"Workspace", 'String[]'>
readonly createdAt: Prisma.FieldRef<"Workspace", 'DateTime'>
readonly updatedAt: Prisma.FieldRef<"Workspace", 'DateTime'>
diff --git a/prisma/migrations/20251231121111_add_module_order_for_drag_and_drop_feature/migration.sql b/prisma/migrations/20251231121111_add_module_order_for_drag_and_drop_feature/migration.sql
new file mode 100644
index 0000000..d8dd005
--- /dev/null
+++ b/prisma/migrations/20251231121111_add_module_order_for_drag_and_drop_feature/migration.sql
@@ -0,0 +1,9 @@
+/*
+ Warnings:
+
+ - You are about to drop the column `layout` on the `Workspace` table. All the data in the column will be lost.
+
+*/
+-- AlterTable
+ALTER TABLE "Workspace" DROP COLUMN "layout",
+ADD COLUMN "moduleOrder" TEXT[] DEFAULT ARRAY[]::TEXT[];
diff --git a/prisma/migrations/20251231131356_change_module_order_to_json/migration.sql b/prisma/migrations/20251231131356_change_module_order_to_json/migration.sql
new file mode 100644
index 0000000..70f48dd
--- /dev/null
+++ b/prisma/migrations/20251231131356_change_module_order_to_json/migration.sql
@@ -0,0 +1,9 @@
+/*
+ Warnings:
+
+ - The `moduleOrder` column on the `Workspace` table would be dropped and recreated. This will lead to data loss if there is data in the column.
+
+*/
+-- AlterTable
+ALTER TABLE "Workspace" DROP COLUMN "moduleOrder",
+ADD COLUMN "moduleOrder" JSONB;
diff --git a/prisma/migrations/20251231133936_change_to_module_layouts/migration.sql b/prisma/migrations/20251231133936_change_to_module_layouts/migration.sql
new file mode 100644
index 0000000..e507e5c
--- /dev/null
+++ b/prisma/migrations/20251231133936_change_to_module_layouts/migration.sql
@@ -0,0 +1,9 @@
+/*
+ Warnings:
+
+ - You are about to drop the column `moduleOrder` on the `Workspace` table. All the data in the column will be lost.
+
+*/
+-- AlterTable
+ALTER TABLE "Workspace" DROP COLUMN "moduleOrder",
+ADD COLUMN "moduleLayouts" JSONB;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index dbbe81e..6348a23 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -10,7 +10,7 @@ datasource db {
model Workspace {
id String @id
userId String @unique
- layout Json?
+ moduleLayouts Json?
pinnedModules String[] @default([])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
diff --git a/src/contexts/modules-context.tsx b/src/contexts/modules-context.tsx
index bb1ae49..5e9b8dd 100644
--- a/src/contexts/modules-context.tsx
+++ b/src/contexts/modules-context.tsx
@@ -4,12 +4,26 @@ import { AuthRequiredModal } from "@/components/auth/auth-required-modal";
import {
createContext,
ReactNode,
+ useCallback,
useContext,
useEffect,
+ useMemo,
useRef,
useState,
} from "react";
import { useModule } from "../hooks/useModule";
+import { useBreakpoint } from "../hooks/useBreakpoint";
+import {
+ type ModuleOrder,
+ type ColumnId,
+ type ColumnCount,
+ type ModuleLayouts,
+ DEFAULT_MODULE_ORDER,
+ DEFAULT_MODULE_LAYOUTS,
+ getVisibleColumnIds,
+ getLayoutForColumnCount,
+ updateLayoutInLayouts,
+} from "../types/module-order";
interface ModulesContextType {
pinnedModules: string[];
@@ -24,6 +38,10 @@ interface ModulesContextType {
setSidebarCollapsed: (collapsed: boolean) => void;
isAuthenticated: boolean;
onAuthRequired: () => void;
+ moduleOrder: ModuleOrder;
+ handleReorderModules: (newOrder: ModuleOrder) => void;
+ columnCount: number;
+ visibleColumns: ColumnId[];
}
const ModulesContext = createContext(undefined);
@@ -36,8 +54,22 @@ export function ModulesProvider({
session: any;
}) {
const isAuthenticated = !!session;
- const { getPinnedModules, togglePin, isToggling, isLoading } =
- useModule(isAuthenticated);
+
+ // Detect current breakpoint/column count
+ const columnCount = useBreakpoint();
+ const visibleColumns = useMemo(
+ () => getVisibleColumnIds(columnCount),
+ [columnCount]
+ );
+
+ const {
+ getPinnedModules,
+ togglePin,
+ isToggling,
+ isLoading,
+ getModuleOrder,
+ updateModuleOrder,
+ } = useModule(isAuthenticated, columnCount as ColumnCount);
// tempOpenModules for visitors only
const [tempOpenModules, setTempOpenModules] = useState([
@@ -47,6 +79,15 @@ export function ModulesProvider({
"pomodoro-timer",
]);
+ // moduleLayouts for visitors (localStorage) - multi-breakpoint storage
+ const [visitorModuleLayouts, setVisitorModuleLayouts] = useState(
+ () => {
+ if (typeof window === "undefined") return DEFAULT_MODULE_LAYOUTS;
+ const saved = localStorage.getItem("module-layouts");
+ return saved ? JSON.parse(saved) : DEFAULT_MODULE_LAYOUTS;
+ }
+ );
+
//load collapsed sidebar state from localStorage
const [sidebarCollapsed, setSidebarCollapsedState] = useState(() => {
if (typeof window === "undefined") return false;
@@ -103,6 +144,35 @@ export function ModulesProvider({
const isTempOpen = (moduleId: string) => tempOpenModules.includes(moduleId);
+ // Unified module order state - extract layout for current columnCount
+ const moduleOrder = useMemo(() => {
+ if (isAuthenticated) {
+ return getModuleOrder ?? DEFAULT_MODULE_ORDER;
+ } else {
+ return getLayoutForColumnCount(visitorModuleLayouts, columnCount as ColumnCount);
+ }
+ }, [isAuthenticated, getModuleOrder, visitorModuleLayouts, columnCount]);
+
+ // Handle reordering modules
+ const handleReorderModules = useCallback(
+ (newOrder: ModuleOrder) => {
+ if (isAuthenticated) {
+ // Save to database via tRPC mutation with columnCount
+ updateModuleOrder({ moduleOrder: newOrder, columnCount });
+ } else {
+ // Update only the current columnCount's layout for visitors
+ const updatedLayouts = updateLayoutInLayouts(
+ visitorModuleLayouts,
+ columnCount as ColumnCount,
+ newOrder
+ );
+ setVisitorModuleLayouts(updatedLayouts);
+ localStorage.setItem("module-layouts", JSON.stringify(updatedLayouts));
+ }
+ },
+ [isAuthenticated, updateModuleOrder, columnCount, visitorModuleLayouts]
+ );
+
return (
{children}
diff --git a/src/hooks/useBreakpoint.tsx b/src/hooks/useBreakpoint.tsx
new file mode 100644
index 0000000..b413b99
--- /dev/null
+++ b/src/hooks/useBreakpoint.tsx
@@ -0,0 +1,41 @@
+"use client";
+
+import { useEffect, useState } from "react";
+
+/**
+ * Hook to detect the current number of columns based on screen width
+ * Matches Tailwind breakpoints:
+ * - < 768px (mobile): 1 column
+ * - 768px-1023px (md/tablet): 2 columns
+ * - 1024px-1919px (lg/desktop): 3 columns
+ * - 1920px-2399px (3xl/ultra-wide): 4 columns
+ * - >= 2400px (4xl/extreme): 5 columns
+ */
+export function useBreakpoint(): number {
+ const [columnCount, setColumnCount] = useState(3); // Default to 3 columns
+
+ useEffect(() => {
+ const getColumnCount = () => {
+ const width = window.innerWidth;
+
+ if (width < 768) return 1; // mobile
+ if (width < 1024) return 2; // tablet
+ if (width < 1920) return 3; // desktop
+ if (width < 2400) return 4; // ultra-wide
+ return 5; // extreme
+ };
+
+ // Set initial value
+ setColumnCount(getColumnCount());
+
+ // Update on resize
+ const handleResize = () => {
+ setColumnCount(getColumnCount());
+ };
+
+ window.addEventListener("resize", handleResize);
+ return () => window.removeEventListener("resize", handleResize);
+ }, []);
+
+ return columnCount;
+}
diff --git a/src/hooks/useModule.tsx b/src/hooks/useModule.tsx
index 5402f9f..fb4eab1 100644
--- a/src/hooks/useModule.tsx
+++ b/src/hooks/useModule.tsx
@@ -1,7 +1,8 @@
import { toast } from "sonner";
import { api } from "../lib/trpc/client";
+import type { ColumnCount } from "../types/module-order";
-export function useModule(isAuthenticated: boolean) {
+export function useModule(isAuthenticated: boolean, columnCount: ColumnCount) {
const utils = api.useUtils();
//list pinned modules - only query if authenticated
@@ -20,15 +21,60 @@ export function useModule(isAuthenticated: boolean) {
},
});
+ //get module order - only query if authenticated
+ const getModuleOrder = api.module.getModuleOrder.useQuery(
+ { columnCount },
+ {
+ enabled: isAuthenticated,
+ }
+ );
+
+ //update module order
+ const updateModuleOrderMutation = api.module.updateModuleOrder.useMutation({
+ onMutate: async (variables) => {
+ // Cancel outgoing refetches
+ await utils.module.getModuleOrder.cancel({ columnCount: variables.columnCount });
+
+ // Snapshot previous value
+ const previousOrder = utils.module.getModuleOrder.getData({ columnCount: variables.columnCount });
+
+ // Optimistically update
+ utils.module.getModuleOrder.setData(
+ { columnCount: variables.columnCount },
+ variables.moduleOrder
+ );
+
+ return { previousOrder, columnCount: variables.columnCount };
+ },
+ onError: (err, variables, context) => {
+ // Rollback on error
+ if (context?.previousOrder && context?.columnCount) {
+ utils.module.getModuleOrder.setData(
+ { columnCount: context.columnCount },
+ context.previousOrder
+ );
+ }
+ toast.error("Failed to save module order");
+ console.error("Error updating module order: ", err);
+ },
+ onSettled: (data, error, variables) => {
+ utils.module.getModuleOrder.invalidate({ columnCount: variables.columnCount });
+ },
+ });
+
return {
//queries
getPinnedModules: getPinnedModules.data,
+ getModuleOrder: getModuleOrder.data,
//mutations
togglePin: togglePinMutation.mutateAsync,
+ updateModuleOrder: updateModuleOrderMutation.mutateAsync,
//states
isToggling: togglePinMutation.isPending,
isLoading: getPinnedModules.isLoading,
+ isOrderLoading: getModuleOrder.isLoading,
+ isOrderUpdating: updateModuleOrderMutation.isPending,
};
}
diff --git a/src/server/routers/module.ts b/src/server/routers/module.ts
index b5f8b40..3c4de3e 100644
--- a/src/server/routers/module.ts
+++ b/src/server/routers/module.ts
@@ -1,4 +1,12 @@
import { db } from "@/src/lib/db";
+import {
+ DEFAULT_MODULE_LAYOUTS,
+ type ModuleLayouts,
+ type ModuleOrder,
+ type ColumnCount,
+ getLayoutForColumnCount,
+ updateLayoutInLayouts,
+} from "@/src/types/module-order";
import z from "zod";
import { createTRPCRouter, protectedProcedure } from "../trpc";
@@ -20,9 +28,10 @@ export const moduleRouter = createTRPCRouter({
.mutation(async ({ ctx, input }) => {
const userId = ctx.session.user.id;
- // Get or create workspace
+ // Get or create workspace (only select pinnedModules to avoid moduleOrder parsing issues)
let workspace = await db.workspace.findUnique({
where: { userId },
+ select: { pinnedModules: true, id: true },
});
if (!workspace) {
@@ -32,6 +41,7 @@ export const moduleRouter = createTRPCRouter({
userId,
pinnedModules: [],
},
+ select: { pinnedModules: true, id: true },
});
}
@@ -53,4 +63,95 @@ export const moduleRouter = createTRPCRouter({
return updated.pinnedModules;
}),
+
+ // Get all module layouts
+ getModuleLayouts: protectedProcedure.query(async ({ ctx }) => {
+ const workspace = await db.workspace.findUnique({
+ where: { userId: ctx.session.user.id },
+ select: { moduleLayouts: true },
+ });
+
+ if (!workspace?.moduleLayouts) {
+ return DEFAULT_MODULE_LAYOUTS;
+ }
+
+ // Parse JSON and validate structure
+ const layouts = workspace.moduleLayouts as unknown as ModuleLayouts;
+ return layouts;
+ }),
+
+ // Get module order for specific column count
+ getModuleOrder: protectedProcedure
+ .input(z.object({ columnCount: z.number().min(1).max(5) }))
+ .query(async ({ ctx, input }) => {
+ const workspace = await db.workspace.findUnique({
+ where: { userId: ctx.session.user.id },
+ select: { moduleLayouts: true },
+ });
+
+ if (!workspace?.moduleLayouts) {
+ return getLayoutForColumnCount(
+ DEFAULT_MODULE_LAYOUTS,
+ input.columnCount as ColumnCount
+ );
+ }
+
+ const layouts = workspace.moduleLayouts as unknown as ModuleLayouts;
+ return getLayoutForColumnCount(layouts, input.columnCount as ColumnCount);
+ }),
+
+ // Update module order for specific column count
+ updateModuleOrder: protectedProcedure
+ .input(
+ z.object({
+ columnCount: z.number().min(1).max(5),
+ moduleOrder: z.object({
+ col1: z.array(z.string()),
+ col2: z.array(z.string()),
+ col3: z.array(z.string()),
+ col4: z.array(z.string()),
+ col5: z.array(z.string()),
+ }),
+ })
+ )
+ .mutation(async ({ ctx, input }) => {
+ const userId = ctx.session.user.id;
+
+ // Get or create workspace
+ let workspace = await db.workspace.findUnique({
+ where: { userId },
+ select: { moduleLayouts: true, id: true },
+ });
+
+ const currentLayouts = workspace?.moduleLayouts
+ ? (workspace.moduleLayouts as unknown as ModuleLayouts)
+ : DEFAULT_MODULE_LAYOUTS;
+
+ // Update only the layout for this column count
+ const updatedLayouts = updateLayoutInLayouts(
+ currentLayouts,
+ input.columnCount as ColumnCount,
+ input.moduleOrder
+ );
+
+ if (!workspace) {
+ workspace = await db.workspace.create({
+ data: {
+ id: crypto.randomUUID(),
+ userId,
+ pinnedModules: [],
+ moduleLayouts: updatedLayouts as any,
+ },
+ select: { moduleLayouts: true, id: true },
+ });
+ } else {
+ workspace = await db.workspace.update({
+ where: { userId },
+ data: { moduleLayouts: updatedLayouts as any },
+ select: { moduleLayouts: true, id: true },
+ });
+ }
+
+ return workspace.moduleLayouts as unknown as ModuleLayouts;
+ }),
});
diff --git a/src/types/module-order.ts b/src/types/module-order.ts
new file mode 100644
index 0000000..b349578
--- /dev/null
+++ b/src/types/module-order.ts
@@ -0,0 +1,172 @@
+/**
+ * Type definitions for column-based module ordering with multi-breakpoint support
+ */
+
+export interface ModuleOrder {
+ col1: string[];
+ col2: string[];
+ col3: string[];
+ col4: string[];
+ col5: string[];
+}
+
+export const DEFAULT_MODULE_ORDER: ModuleOrder = {
+ col1: [],
+ col2: [],
+ col3: [],
+ col4: [],
+ col5: [],
+};
+
+/**
+ * Multi-breakpoint layouts: one layout per column count (1-5)
+ */
+export interface ModuleLayouts {
+ 1: { col1: string[] };
+ 2: { col1: string[]; col2: string[] };
+ 3: { col1: string[]; col2: string[]; col3: string[] };
+ 4: { col1: string[]; col2: string[]; col3: string[]; col4: string[] };
+ 5: { col1: string[]; col2: string[]; col3: string[]; col4: string[]; col5: string[] };
+}
+
+export const DEFAULT_MODULE_LAYOUTS: ModuleLayouts = {
+ 1: { col1: [] },
+ 2: { col1: [], col2: [] },
+ 3: { col1: [], col2: [], col3: [] },
+ 4: { col1: [], col2: [], col3: [], col4: [] },
+ 5: { col1: [], col2: [], col3: [], col4: [], col5: [] },
+};
+
+export type ColumnCount = 1 | 2 | 3 | 4 | 5;
+
+export type ColumnId = "col1" | "col2" | "col3" | "col4" | "col5";
+
+export const COLUMN_IDS: ColumnId[] = ["col1", "col2", "col3", "col4", "col5"];
+
+/**
+ * Get the number of visible columns based on breakpoint
+ */
+export function getVisibleColumnIds(breakpoint: number): ColumnId[] {
+ return COLUMN_IDS.slice(0, breakpoint) as ColumnId[];
+}
+
+/**
+ * Distribute modules across columns evenly
+ */
+export function distributeModulesAcrossColumns(
+ modules: string[],
+ columnCount: number
+): ModuleOrder {
+ const order: ModuleOrder = { ...DEFAULT_MODULE_ORDER };
+ const visibleColumns = getVisibleColumnIds(columnCount);
+
+ modules.forEach((moduleId, index) => {
+ const columnIndex = index % columnCount;
+ const columnId = visibleColumns[columnIndex];
+ if (columnId) {
+ order[columnId].push(moduleId);
+ }
+ });
+
+ return order;
+}
+
+/**
+ * Flatten column order to get all module IDs in order
+ */
+export function flattenModuleOrder(
+ order: ModuleOrder,
+ columnCount: number
+): string[] {
+ const visibleColumns = getVisibleColumnIds(columnCount);
+ const result: string[] = [];
+
+ // Get max length to iterate through rows
+ const maxLength = Math.max(
+ ...visibleColumns.map((colId) => order[colId].length)
+ );
+
+ // Iterate row by row across columns (masonry order)
+ for (let i = 0; i < maxLength; i++) {
+ visibleColumns.forEach((colId) => {
+ const moduleId = order[colId][i];
+ if (moduleId) {
+ result.push(moduleId);
+ }
+ });
+ }
+
+ return result;
+}
+
+/**
+ * Get layout for specific column count from ModuleLayouts
+ */
+export function getLayoutForColumnCount(
+ layouts: ModuleLayouts,
+ columnCount: ColumnCount
+): ModuleOrder {
+ const layout = layouts[columnCount];
+
+ // Extend layout to full ModuleOrder structure
+ return {
+ col1: layout.col1 || [],
+ col2: (layout as any).col2 || [],
+ col3: (layout as any).col3 || [],
+ col4: (layout as any).col4 || [],
+ col5: (layout as any).col5 || [],
+ };
+}
+
+/**
+ * Initialize a layout with modules distributed across available columns
+ */
+export function initializeLayoutForColumnCount(
+ modules: string[],
+ columnCount: ColumnCount
+): ModuleOrder {
+ const layout: ModuleOrder = { ...DEFAULT_MODULE_ORDER };
+ const visibleColumns = getVisibleColumnIds(columnCount);
+
+ modules.forEach((moduleId, index) => {
+ const columnIndex = index % columnCount;
+ const columnId = visibleColumns[columnIndex];
+ if (columnId) {
+ layout[columnId].push(moduleId);
+ }
+ });
+
+ return layout;
+}
+
+/**
+ * Update specific layout in ModuleLayouts
+ */
+export function updateLayoutInLayouts(
+ layouts: ModuleLayouts,
+ columnCount: ColumnCount,
+ newLayout: ModuleOrder
+): ModuleLayouts {
+ const updated = { ...layouts };
+
+ // Only store the columns relevant to this breakpoint
+ switch (columnCount) {
+ case 1:
+ updated[1] = { col1: newLayout.col1 };
+ break;
+ case 2:
+ updated[2] = { col1: newLayout.col1, col2: newLayout.col2 };
+ break;
+ case 3:
+ updated[3] = { col1: newLayout.col1, col2: newLayout.col2, col3: newLayout.col3 };
+ break;
+ case 4:
+ updated[4] = { col1: newLayout.col1, col2: newLayout.col2, col3: newLayout.col3, col4: newLayout.col4 };
+ break;
+ case 5:
+ updated[5] = newLayout;
+ break;
+ }
+
+ return updated;
+}