e.stopPropagation()}
- disabled={isDragged}
+ disabled={isDragging}
>
diff --git a/src/components/folders/constants.ts b/src/components/folders/constants.ts
index 47dc562..61477d3 100644
--- a/src/components/folders/constants.ts
+++ b/src/components/folders/constants.ts
@@ -20,7 +20,7 @@ export const FOLDER_COLORS = [
export const SPECIAL_VIEWS = [
{
id: 'all' as ViewMode,
- label: 'All Notes',
+ label: 'All Files',
icon: FileText,
},
{
diff --git a/src/components/folders/index.tsx b/src/components/folders/index.tsx
index 449e014..49b1bb4 100644
--- a/src/components/folders/index.tsx
+++ b/src/components/folders/index.tsx
@@ -1,6 +1,18 @@
import { useState, useCallback, useMemo } from 'react';
import { UserButton, useUser } from '@clerk/clerk-react';
-import { Plus, Search } from 'lucide-react';
+import {
+ DndContext,
+ DragOverlay,
+ closestCenter,
+ PointerSensor,
+ useSensor,
+ useSensors,
+} from '@dnd-kit/core';
+import {
+ SortableContext,
+ verticalListSortingStrategy,
+} from '@dnd-kit/sortable';
+import { Plus, Search, GripVertical } from 'lucide-react';
import CreateFolderModal from '@/components/folders/modals/CreateFolderModal';
import FolderDeleteModal from '@/components/folders/modals/FolderDeleteModal';
import EditFolderModal from '@/components/folders/modals/EditFolderModal';
@@ -101,16 +113,27 @@ export default function FolderPanel({
[folderTree, expandedFolders]
);
+ const sensors = useSensors(
+ useSensor(PointerSensor, {
+ activationConstraint: {
+ distance: 8,
+ },
+ })
+ );
+
const {
- dragState,
+ activeFolder,
handleDragStart,
handleDragOver,
- handleDragEnter,
- handleDragLeave,
- handleDrop,
handleDragEnd,
+ handleDragCancel,
} = useDragAndDrop(displayFolders, folders, onReorderFolders);
+ const folderIds = useMemo(
+ () => displayFolders.map((f) => f.id),
+ [displayFolders]
+ );
+
const handleFolderSelect = useCallback(
(folder: FolderType) => {
onFolderSelect(folder);
@@ -187,25 +210,6 @@ export default function FolderPanel({
window.location.reload();
}, []);
- const dragHandlers = useMemo(
- () => ({
- onDragStart: handleDragStart,
- onDragOver: handleDragOver,
- onDragEnter: handleDragEnter,
- onDragLeave: handleDragLeave,
- onDrop: handleDrop,
- onDragEnd: handleDragEnd,
- }),
- [
- handleDragStart,
- handleDragOver,
- handleDragEnter,
- handleDragLeave,
- handleDrop,
- handleDragEnd,
- ]
- );
-
if (!isOpen) {
return (
@@ -214,7 +218,7 @@ export default function FolderPanel({
return (
<>
-
+
Folders
@@ -269,28 +273,55 @@ export default function FolderPanel({
-
- {displayFolders.map((folder, index) => (
-
- ))}
-
+
+
+
+ {displayFolders.map((folder) => (
+
+ ))}
+
+
+
+ {activeFolder ? (
+
+
+
+
+
+
{activeFolder.name}
+
+ ) : null}
+
+
diff --git a/src/components/layout/MainLayout.tsx b/src/components/layout/MainLayout.tsx
index da8eb6b..a081059 100644
--- a/src/components/layout/MainLayout.tsx
+++ b/src/components/layout/MainLayout.tsx
@@ -34,7 +34,6 @@ export default function MainLayout() {
const [activeTabId, setActiveTabId] = useState
(null);
// Sync responsive panel states with local states for desktop
- /* eslint-disable react-hooks/set-state-in-effect -- Sync external state to local state */
useEffect(() => {
if (!isMobile) {
if (folderSidebarOpen !== responsiveFolderPanel.isOpen) {
@@ -45,7 +44,6 @@ export default function MainLayout() {
}
}
}, [isMobile, responsiveFolderPanel.isOpen, responsiveNotesPanel.isOpen, folderSidebarOpen, filesPanelOpen]);
- /* eslint-enable react-hooks/set-state-in-effect */
const {
notes,
@@ -55,6 +53,7 @@ export default function MainLayout() {
selectedFolder,
currentView,
searchQuery,
+ loading,
notesCount,
starredCount,
archivedCount,
@@ -91,8 +90,21 @@ export default function MainLayout() {
} = useNotes();
const handleCreateNote = useCallback(
- (templateContent?: { title: string; content: string }) => {
- void createNote(undefined, templateContent);
+ async (templateContent?: { title: string; content: string }) => {
+ const newNote = await createNote(undefined, templateContent);
+ if (newNote) {
+ // Open the new note in a tab
+ const newTab: Tab = {
+ id: `tab-${newNote.id}-${Date.now()}`,
+ noteId: newNote.id,
+ title: newNote.title || 'Untitled',
+ type: newNote.type || 'note',
+ isDirty: false,
+ isPublished: newNote.isPublished,
+ };
+ setOpenTabs(prev => [...prev, newTab]);
+ setActiveTabId(newTab.id);
+ }
if (!filesPanelOpen) setFilesPanelOpen(true);
},
[createNote, filesPanelOpen]
@@ -103,12 +115,26 @@ export default function MainLayout() {
// If templateCode is provided, use it; otherwise create blank diagram
const content = templateCode || '';
- await createNote(undefined, {
+ const newNote = await createNote(undefined, {
title: 'Untitled Diagram',
content,
type: 'diagram'
});
+ if (newNote) {
+ // Open the new diagram in a tab
+ const newTab: Tab = {
+ id: `tab-${newNote.id}-${Date.now()}`,
+ noteId: newNote.id,
+ title: newNote.title || 'Untitled Diagram',
+ type: 'diagram',
+ isDirty: false,
+ isPublished: newNote.isPublished,
+ };
+ setOpenTabs(prev => [...prev, newTab]);
+ setActiveTabId(newTab.id);
+ }
+
if (!filesPanelOpen) setFilesPanelOpen(true);
} catch (error) {
console.error('Failed to create diagram:', error);
@@ -124,18 +150,76 @@ export default function MainLayout() {
// Store both language and code in content as JSON
const content = JSON.stringify({ language, code });
- await createNote(undefined, {
+ const newNote = await createNote(undefined, {
title: 'Untitled Code',
content,
type: 'code'
});
+ if (newNote) {
+ // Open the new code note in a tab
+ const newTab: Tab = {
+ id: `tab-${newNote.id}-${Date.now()}`,
+ noteId: newNote.id,
+ title: newNote.title || 'Untitled Code',
+ type: 'code',
+ isDirty: false,
+ isPublished: newNote.isPublished,
+ };
+ setOpenTabs(prev => [...prev, newTab]);
+ setActiveTabId(newTab.id);
+ }
+
if (!filesPanelOpen) setFilesPanelOpen(true);
} catch (error) {
console.error('Failed to create code note:', error);
}
}, [createNote, filesPanelOpen]);
+ const handleCreateSheets = useCallback(async () => {
+ try {
+ // Create blank spreadsheet with default workbook data
+ const defaultWorkbook = {
+ id: 'workbook-1',
+ name: 'Untitled Spreadsheet',
+ sheets: {
+ 'sheet-1': {
+ id: 'sheet-1',
+ name: 'Sheet1',
+ rowCount: 100,
+ columnCount: 26,
+ cellData: {},
+ },
+ },
+ sheetOrder: ['sheet-1'],
+ };
+
+ const newNote = await createNote(undefined, {
+ title: 'Untitled Spreadsheet',
+ content: JSON.stringify(defaultWorkbook),
+ type: 'sheets'
+ });
+
+ if (newNote) {
+ // Open the new spreadsheet in a tab
+ const newTab: Tab = {
+ id: `tab-${newNote.id}-${Date.now()}`,
+ noteId: newNote.id,
+ title: newNote.title || 'Untitled Spreadsheet',
+ type: 'sheets',
+ isDirty: false,
+ isPublished: newNote.isPublished,
+ };
+ setOpenTabs(prev => [...prev, newTab]);
+ setActiveTabId(newTab.id);
+ }
+
+ if (!filesPanelOpen) setFilesPanelOpen(true);
+ } catch (error) {
+ console.error('Failed to create spreadsheet:', error);
+ }
+ }, [createNote, filesPanelOpen]);
+
const handleEmptyTrash = useCallback(async () => {
try {
// Clear selected note if it's in trash, regardless of current view
@@ -286,7 +370,6 @@ export default function MainLayout() {
}, [selectedNote]);
// Update tab properties when note changes
- /* eslint-disable react-hooks/set-state-in-effect -- Sync note properties to tab state */
useEffect(() => {
if (selectedNote?.id) {
setOpenTabs(tabs =>
@@ -298,7 +381,6 @@ export default function MainLayout() {
);
}
}, [selectedNote?.id, selectedNote?.title, selectedNote?.type, selectedNote?.isPublished]);
- /* eslint-enable react-hooks/set-state-in-effect */
const handlePasswordChange = useCallback(() => {
setSelectedNote(null);
@@ -399,10 +481,12 @@ export default function MainLayout() {
onCreateNote: handleCreateNote,
onCreateDiagram: handleCreateDiagram,
onCreateCode: handleCreateCode,
+ onCreateSheets: handleCreateSheets,
onToggleFolderPanel: handleToggleFolderPanel,
onEmptyTrash: handleEmptyTrash,
onRefresh: refetch,
creatingNote,
+ loading,
};
const editorProps: EditorProps = {
diff --git a/src/components/layout/TabBar.tsx b/src/components/layout/TabBar.tsx
index ccc3515..6c46833 100644
--- a/src/components/layout/TabBar.tsx
+++ b/src/components/layout/TabBar.tsx
@@ -1,4 +1,6 @@
-import { X, Network, Code2, ChevronDown, Globe } from 'lucide-react';
+import { X, ChevronDown, Globe, SquareCode } from 'lucide-react';
+import Icon from '@mdi/react';
+import { mdiTextBoxOutline, mdiFileTableBoxOutline, mdiVectorSquare } from '@mdi/js';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
@@ -13,7 +15,7 @@ export interface Tab {
id: string;
noteId: string;
title: string;
- type: 'note' | 'diagram' | 'code';
+ type: 'note' | 'diagram' | 'code' | 'sheets';
isDirty: boolean;
isPublished?: boolean;
}
@@ -27,14 +29,18 @@ interface TabBarProps {
}
const TabIcon = ({ type, isPublished }: { type: Tab['type']; isPublished?: boolean }) => {
+ const iconSize = "14px";
const typeIcon = (() => {
switch (type) {
case 'code':
- return ;
+ return ;
case 'diagram':
- return ;
+ return ;
+ case 'sheets':
+ return ;
+ case 'note':
default:
- return null;
+ return ;
}
})();
@@ -42,13 +48,13 @@ const TabIcon = ({ type, isPublished }: { type: Tab['type']; isPublished?: boole
return (
{typeIcon}
-
+
);
}
if (isPublished) {
- return ;
+ return ;
}
return typeIcon;
diff --git a/src/components/notes/NotesPanel/NoteCard.tsx b/src/components/notes/NotesPanel/NoteCard.tsx
index 74612ec..2e710a9 100644
--- a/src/components/notes/NotesPanel/NoteCard.tsx
+++ b/src/components/notes/NotesPanel/NoteCard.tsx
@@ -1,5 +1,7 @@
import { memo, useMemo } from 'react';
-import { Star, Paperclip, Codesandbox, Network, Code2, Globe } from 'lucide-react';
+import { Star, Paperclip, Codesandbox, Globe, SquareCode } from 'lucide-react';
+import Icon from '@mdi/react';
+import { mdiTextBoxOutline, mdiFileTableBoxOutline, mdiVectorSquare } from '@mdi/js';
import type { Note, Folder as FolderType } from '@/types/note.ts';
interface NoteCardProps {
@@ -105,9 +107,25 @@ function NoteCard({
return null;
}
+ // Get the file type icon - all icons use 20px for consistency
+ const FileTypeIcon = () => {
+ const iconSize = "20px";
+ switch (note.type) {
+ case 'diagram':
+ return ;
+ case 'code':
+ return ;
+ case 'sheets':
+ return ;
+ case 'note':
+ default:
+ return ;
+ }
+ };
+
return (
onSelect(note)}
@@ -116,146 +134,138 @@ function NoteCard({
)}
-
-
-
- {note.title || 'Untitled'}
-
-
- {note.isNew && (
-
- NEW
-
- )}
- {note.type === 'diagram' && (
-
-
-
- )}
- {note.type === 'code' && (
-
-
-
- )}
- {note.isPublished && (
-
-
-
- )}
- {hasExecutableCode && (
-
-
-
- )}
- {((note.attachmentCount && note.attachmentCount > 0) ||
- (note.attachments && note.attachments.length > 0)) && (
-
-
-
- {note.attachmentCount ?? note.attachments?.length ?? 0}
-
-
- )}
-
{
- e.stopPropagation();
- onToggleStar(note.id);
- }}
- className="hover:bg-muted shrink-0 rounded"
- >
-
-
-
+
+ {/* File type icon on the left */}
+
+
-
- {previewText}
-
-
-
- {/* Date and Folder */}
-
-
+
+
- {formattedDate}
-
+ {note.title || 'Untitled'}
+
+
+ {note.isNew && (
+
+ NEW
+
+ )}
+ {note.isPublished && (
+
+
+
+ )}
+ {hasExecutableCode && (
+
+
+
+ )}
+ {((note.attachmentCount && note.attachmentCount > 0) ||
+ (note.attachments && note.attachments.length > 0)) && (
+
+
+
+ {note.attachmentCount ?? note.attachments?.length ?? 0}
+
+
+ )}
+
{
+ e.stopPropagation();
+ onToggleStar(note.id);
+ }}
+ className="hover:bg-muted shrink-0 rounded"
+ >
+
+
+
+
+
+
+ {previewText}
+
- {folder && (
-
+ {/* Date and Folder */}
+
- )}
-
+ {formattedDate}
+
- {/* Tags */}
- {note.tags && note.tags.length > 0 && (
-
- {note.tags.slice(0, 1).map((tag) => (
-
- {tag}
-
- ))}
- {note.tags.length > 1 && (
-
- +{note.tags.length - 1}
-
+
+
{folder.name}
+
)}
- )}
+
+ {/* Tags */}
+ {note.tags && note.tags.length > 0 && (
+
+ {note.tags.slice(0, 1).map((tag) => (
+
+ {tag}
+
+ ))}
+ {note.tags.length > 1 && (
+
+ +{note.tags.length - 1}
+
+ )}
+
+ )}
+
diff --git a/src/components/notes/NotesPanel/NotesList.tsx b/src/components/notes/NotesPanel/NotesList.tsx
index 3c937bc..0e89669 100644
--- a/src/components/notes/NotesPanel/NotesList.tsx
+++ b/src/components/notes/NotesPanel/NotesList.tsx
@@ -3,10 +3,26 @@ import { useState } from 'react';
import { Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button.tsx';
+import { Skeleton } from '@/components/ui/skeleton.tsx';
import type { Note, Folder } from '@/types/note.ts';
import NoteCard from './NoteCard.tsx';
+function NoteCardSkeleton() {
+ return (
+
+ );
+}
+
interface NotesListProps {
notes: Note[];
selectedNote: Note | null;
@@ -16,6 +32,7 @@ interface NotesListProps {
isTrashView?: boolean;
emptyMessage?: string;
folders?: Folder[];
+ loading?: boolean;
}
export default function NotesList({
@@ -27,6 +44,7 @@ export default function NotesList({
isTrashView = false,
emptyMessage,
folders,
+ loading = false,
}: NotesListProps) {
const [isEmptyingTrash, setIsEmptyingTrash] = useState(false);
@@ -44,6 +62,17 @@ export default function NotesList({
}
};
+ // Show skeletons while loading
+ if (loading) {
+ return (
+
+ {[...Array(5)].map((_, i) => (
+
+ ))}
+
+ );
+ }
+
return (
<>
@@ -87,10 +116,10 @@ export default function NotesList({
/>
))}
- {notes.length === 0 && emptyMessage && (
-
-
-
{emptyMessage}
+ {notes.length === 0 && isTrashView && (
+
)}
diff --git a/src/components/notes/NotesPanel/index.tsx b/src/components/notes/NotesPanel/index.tsx
index 2d2d187..d0c2343 100644
--- a/src/components/notes/NotesPanel/index.tsx
+++ b/src/components/notes/NotesPanel/index.tsx
@@ -6,10 +6,11 @@ import {
Filter,
FilterX,
ChevronDown,
- Network,
- Code2,
RefreshCw,
+ SquareCode,
} from 'lucide-react';
+import Icon from '@mdi/react';
+import { mdiTextBoxOutline, mdiFileTableBoxOutline, mdiVectorSquare } from '@mdi/js';
import NotesList from '@/components/notes/NotesPanel/NotesList.tsx';
import { Button } from '@/components/ui/button.tsx';
import { ButtonGroup } from '@/components/ui/button-group';
@@ -39,6 +40,7 @@ interface FilterConfig {
showAttachmentsOnly: boolean;
showCodeOnly: boolean;
showDiagramsOnly: boolean;
+ showSheetsOnly: boolean;
showHiddenOnly: boolean;
showPublicOnly: boolean;
showStarredOnly: boolean;
@@ -57,10 +59,12 @@ interface FilesPanelProps {
onCreateNote: (templateContent?: { title: string; content: string; type?: string }) => void;
onCreateDiagram?: (templateCode?: string) => void;
onCreateCode?: (templateData?: { language: string; code: string }) => void;
+ onCreateSheets?: () => void;
onToggleFolderPanel: () => void;
onEmptyTrash?: () => Promise
;
onRefresh?: () => Promise;
creatingNote?: boolean;
+ loading?: boolean;
isMobile?: boolean;
onClose?: () => void;
}
@@ -78,10 +82,12 @@ export default function FilesPanel({
onCreateNote,
onCreateDiagram,
onCreateCode,
+ onCreateSheets,
onToggleFolderPanel,
onEmptyTrash,
onRefresh,
creatingNote = false,
+ loading = false,
isMobile = false,
onClose,
}: FilesPanelProps) {
@@ -94,6 +100,7 @@ export default function FilesPanel({
showAttachmentsOnly: false,
showCodeOnly: false,
showDiagramsOnly: false,
+ showSheetsOnly: false,
showHiddenOnly: false,
showPublicOnly: false,
showStarredOnly: false,
@@ -143,11 +150,12 @@ export default function FilesPanel({
const excludeByAttachments = config.showAttachmentsOnly && !hasAttachments;
const excludeByCode = config.showCodeOnly && note.type !== 'code';
const excludeByDiagrams = config.showDiagramsOnly && note.type !== 'diagram';
+ const excludeBySheets = config.showSheetsOnly && note.type !== 'sheets';
const excludeByHidden = config.showHiddenOnly && !note.hidden;
const excludeByPublic = config.showPublicOnly && !note.isPublished;
const excludeByStarred = config.showStarredOnly && !note.starred;
- return !(excludeByAttachments || excludeByCode || excludeByDiagrams || excludeByHidden || excludeByPublic || excludeByStarred);
+ return !(excludeByAttachments || excludeByCode || excludeByDiagrams || excludeBySheets || excludeByHidden || excludeByPublic || excludeByStarred);
});
};
@@ -172,6 +180,7 @@ export default function FilesPanel({
if (filterConfig.showAttachmentsOnly) activeFilters.push('Attachments');
if (filterConfig.showCodeOnly) activeFilters.push('Code');
if (filterConfig.showDiagramsOnly) activeFilters.push('Diagrams');
+ if (filterConfig.showSheetsOnly) activeFilters.push('Sheets');
if (filterConfig.showHiddenOnly) activeFilters.push('Hidden');
if (filterConfig.showPublicOnly) activeFilters.push('Public');
if (filterConfig.showStarredOnly) activeFilters.push('Starred');
@@ -184,6 +193,7 @@ export default function FilesPanel({
filterConfig.showAttachmentsOnly ||
filterConfig.showCodeOnly ||
filterConfig.showDiagramsOnly ||
+ filterConfig.showSheetsOnly ||
filterConfig.showHiddenOnly ||
filterConfig.showPublicOnly ||
filterConfig.showStarredOnly;
@@ -195,15 +205,15 @@ export default function FilesPanel({
switch (currentView) {
case 'starred':
- return 'Starred Notes';
+ return 'Starred Files';
case 'archived':
- return 'Archived Notes';
+ return 'Archived Files';
case 'trash':
return 'Trash';
case 'public':
- return 'Public Notes';
+ return 'Public Files';
default:
- return 'All Notes';
+ return 'All Files';
}
};
@@ -283,7 +293,7 @@ export default function FilesPanel({
/>
)}
- {sortedNotes.length} note{sortedNotes.length !== 1 ? 's' : ''}
+ {sortedNotes.length} file{sortedNotes.length !== 1 ? 's' : ''}
{sortedNotes.length !== notes.length &&
` (${notes.length} total)`}
@@ -359,6 +369,17 @@ export default function FilesPanel({
>
{filterConfig.showDiagramsOnly ? '✓' : '○'} Diagrams
+
+ setFilterConfig((prev) => ({
+ ...prev,
+ showSheetsOnly: !prev.showSheetsOnly,
+ }))
+ }
+ className={`${filterConfig.showSheetsOnly ? 'bg-accent' : ''} mb-1`}
+ >
+ {filterConfig.showSheetsOnly ? '✓' : '○'} Sheets
+
setFilterConfig((prev) => ({
@@ -400,6 +421,7 @@ export default function FilesPanel({
showAttachmentsOnly: false,
showCodeOnly: false,
showDiagramsOnly: false,
+ showSheetsOnly: false,
showHiddenOnly: false,
showPublicOnly: false,
showStarredOnly: false,
@@ -473,31 +495,26 @@ export default function FilesPanel({
)}
-
+
{/* Blank Note */}
onCreateNote()}
- className="flex flex-col items-start gap-1 py-2"
+ className="flex items-center gap-2"
>
- Blank Note
-
- Start with an empty note
-
+
+ Note
+
+
{/* Blank Diagram */}
{onCreateDiagram && (
onCreateDiagram()}
- className="flex flex-col items-start gap-1 py-2"
+ className="flex items-center gap-2"
>
-
- Blank Diagram
-
-
-
- Start with an empty diagram
-
+
+ Diagram
)}
@@ -505,15 +522,23 @@ export default function FilesPanel({
{onCreateCode && (
onCreateCode()}
- className="flex flex-col items-start gap-1 py-2"
+ className="flex items-center gap-2"
+ >
+
+ Code
+
+ )}
+
+
+
+ {/* Blank Spreadsheet */}
+ {onCreateSheets && (
+ onCreateSheets()}
+ className="flex items-center gap-2"
>
-
- Blank Code
-
-
-
- Start with an executable code note
-
+
+ Spreadsheet
)}
@@ -709,6 +734,7 @@ export default function FilesPanel({
onEmptyTrash={onEmptyTrash}
emptyMessage={getEmptyMessage()}
folders={folders}
+ loading={loading}
/>
diff --git a/src/components/password/ChangeMasterPasswordDialog.tsx b/src/components/password/ChangeMasterPasswordDialog.tsx
index a66a0c9..8b404ce 100644
--- a/src/components/password/ChangeMasterPasswordDialog.tsx
+++ b/src/components/password/ChangeMasterPasswordDialog.tsx
@@ -26,7 +26,6 @@ export function ChangeMasterPasswordDialog({
const newPasswordRef = useRef(null);
// Reset form state when dialog opens
- /* eslint-disable react-hooks/set-state-in-effect -- Legitimate form reset on dialog open */
useEffect(() => {
if (open) {
setNewPassword('');
@@ -40,7 +39,6 @@ export function ChangeMasterPasswordDialog({
}, 100);
}
}, [open]);
- /* eslint-enable react-hooks/set-state-in-effect */
const handleChange = async () => {
if (!user?.id || isChanging) return;
diff --git a/src/components/sheets/PublicSheetsViewer.tsx b/src/components/sheets/PublicSheetsViewer.tsx
new file mode 100644
index 0000000..377b4e1
--- /dev/null
+++ b/src/components/sheets/PublicSheetsViewer.tsx
@@ -0,0 +1,237 @@
+import { useEffect, useRef, useState, useId } from 'react';
+import { createUniver, LocaleType, mergeLocales } from '@univerjs/presets';
+import { UniverSheetsCorePreset } from '@univerjs/preset-sheets-core';
+import UniverPresetSheetsCoreEnUS from '@univerjs/preset-sheets-core/locales/en-US';
+import { WorkbookEditablePermission } from '@univerjs/sheets';
+
+import '@univerjs/preset-sheets-core/lib/index.css';
+
+interface PublicSheetsViewerProps {
+ content: string;
+ darkMode?: boolean;
+}
+
+type LoadingState = 'loading' | 'ready' | 'error';
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+type WorkbookData = any;
+
+const DEFAULT_WORKBOOK_DATA: WorkbookData = {
+ id: 'workbook-1',
+ name: 'Untitled Spreadsheet',
+ sheetOrder: ['sheet-1'],
+ sheets: {
+ 'sheet-1': {
+ id: 'sheet-1',
+ name: 'Sheet1',
+ rowCount: 100,
+ columnCount: 26,
+ },
+ },
+};
+
+function parseWorkbookData(data: string): WorkbookData {
+ if (!data) return DEFAULT_WORKBOOK_DATA;
+ try {
+ const parsed = JSON.parse(data);
+ return parsed as WorkbookData;
+ } catch {
+ return DEFAULT_WORKBOOK_DATA;
+ }
+}
+
+// Generate a content hash for cache key
+function generateContentHash(content: string): string {
+ // Use a simple but more reliable hash than just length + slice
+ let hash = 0;
+ for (let i = 0; i < content.length; i++) {
+ const char = content.charCodeAt(i);
+ hash = ((hash << 5) - hash) + char;
+ hash = hash & hash; // Convert to 32-bit integer
+ }
+ return `${content.length}-${hash}`;
+}
+
+// Store instances in a Map keyed by unique instance ID to support multiple viewers
+// This survives React strict mode while allowing multiple instances
+interface ViewerInstance {
+ univer: ReturnType;
+ contentHash: string;
+}
+
+const viewerInstances = new Map();
+
+export function PublicSheetsViewer({ content, darkMode = false }: PublicSheetsViewerProps) {
+ const containerRef = useRef(null);
+ const [state, setState] = useState('loading');
+ const [error, setError] = useState(null);
+
+ // Generate a stable unique ID for this component instance
+ const instanceId = useId();
+
+ // Toggle dark mode when it changes (without reinitializing)
+ useEffect(() => {
+ const instance = viewerInstances.get(instanceId);
+ if (instance) {
+ instance.univer.univerAPI.toggleDarkMode(darkMode);
+ }
+ }, [darkMode, instanceId]);
+
+ // Initialize Univer
+ useEffect(() => {
+ const container = containerRef.current;
+ if (!container) return;
+
+ const contentHash = generateContentHash(content);
+ const existingInstance = viewerInstances.get(instanceId);
+
+ // Already initialized with same content - just make sure it's ready
+ if (existingInstance && existingInstance.contentHash === contentHash) {
+ setState('ready');
+ return;
+ }
+
+ // Content changed or new instance - need to reinitialize
+ if (existingInstance) {
+ try {
+ existingInstance.univer.univer.dispose();
+ } catch {
+ // Ignore disposal errors
+ }
+ viewerInstances.delete(instanceId);
+ }
+
+ setState('loading');
+ setError(null);
+
+ // Clear container
+ container.innerHTML = '';
+
+ try {
+ const workbookData = parseWorkbookData(content);
+
+ // Create Univer instance
+ const univerInstance = createUniver({
+ locale: LocaleType.EN_US,
+ locales: {
+ [LocaleType.EN_US]: mergeLocales(UniverPresetSheetsCoreEnUS),
+ },
+ darkMode,
+ presets: [
+ UniverSheetsCorePreset({
+ container,
+ }),
+ ],
+ });
+
+ const { univerAPI } = univerInstance;
+
+ // Create workbook with initial data
+ const workbook = univerAPI.createWorkbook(workbookData);
+
+ // Set workbook to read-only mode
+ if (workbook) {
+ const unitId = workbook.getId();
+ const permission = univerAPI.getPermission();
+ if (permission && unitId) {
+ permission.setWorkbookPermissionPoint(unitId, WorkbookEditablePermission, false);
+ }
+ }
+
+ viewerInstances.set(instanceId, {
+ univer: univerInstance,
+ contentHash,
+ });
+ setState('ready');
+ } catch (err) {
+ console.error('PublicSheetsViewer: Failed to initialize:', err);
+ setError(err instanceof Error ? err.message : 'Failed to load spreadsheet');
+ setState('error');
+ }
+
+ // Cleanup function - handles both strict mode and actual unmount
+ return () => {
+ // Don't dispose immediately in dev mode (React Strict Mode)
+ // Use requestAnimationFrame to defer and check if container is actually gone
+ };
+ }, [content, darkMode, instanceId]);
+
+ // Cleanup on actual page unload
+ useEffect(() => {
+ const container = containerRef.current;
+ const currentInstanceId = instanceId;
+
+ return () => {
+ // This runs on actual unmount (not strict mode double-render)
+ // We use requestAnimationFrame to let strict mode remount happen first
+ requestAnimationFrame(() => {
+ if (!document.body.contains(container)) {
+ const instance = viewerInstances.get(currentInstanceId);
+ if (instance) {
+ try {
+ instance.univer.univer.dispose();
+ } catch {
+ // Ignore disposal errors
+ }
+ viewerInstances.delete(currentInstanceId);
+ }
+ }
+ });
+ };
+ }, [instanceId]);
+
+ if (state === 'error') {
+ return (
+
+
+
Failed to load spreadsheet
+ {error &&
{error}
}
+
+
+ );
+ }
+
+ return (
+ <>
+ {/* Hide Univer permission dialogs and zoom slider */}
+
+
+ {state === 'loading' && (
+
+ )}
+
+
+
+ >
+ );
+}
+
+export default PublicSheetsViewer;
diff --git a/src/components/sheets/SheetsEditor.tsx b/src/components/sheets/SheetsEditor.tsx
new file mode 100644
index 0000000..968c72d
--- /dev/null
+++ b/src/components/sheets/SheetsEditor.tsx
@@ -0,0 +1,572 @@
+import { useEffect, useRef, useState } from 'react';
+import { createUniver, LocaleType, mergeLocales } from '@univerjs/presets';
+import { UniverSheetsCorePreset } from '@univerjs/preset-sheets-core';
+import UniverPresetSheetsCoreEnUS from '@univerjs/preset-sheets-core/locales/en-US';
+import { useTheme } from '@/components/ui/theme-provider';
+import { Button } from '@/components/ui/button';
+import { ButtonGroup } from '@/components/ui/button-group';
+import {
+ PanelRightClose,
+ PanelRightOpen,
+ MoreHorizontal,
+ Star,
+ Eye,
+ EyeOff,
+ RefreshCw,
+ FolderInput,
+ Archive,
+ Trash2,
+ Globe,
+} from 'lucide-react';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu';
+import MoveNoteModal from '@/components/editor/modals/MoveNoteModal';
+import PublishNoteModal from '@/components/editor/modals/PublishNoteModal';
+import type { Folder as FolderType, Note } from '@/types/note';
+
+import '@univerjs/preset-sheets-core/lib/index.css';
+
+interface SheetsEditorProps {
+ noteId: string;
+ initialData: string;
+ initialTitle?: string;
+ createdAt?: Date;
+ updatedAt?: Date;
+ starred?: boolean;
+ hidden?: boolean;
+ isPublished?: boolean;
+ publicSlug?: string | null;
+ folders?: FolderType[];
+ folderId?: string | null;
+ onSave: (data: string, title: string) => void | Promise;
+ onToggleStar?: (noteId: string) => void;
+ starringStar?: boolean;
+ onHideNote?: (noteId: string) => void;
+ onUnhideNote?: (noteId: string) => void;
+ hidingNote?: boolean;
+ onRefreshNote?: (noteId: string) => void;
+ onArchiveNote?: (noteId: string) => void;
+ onDeleteNote?: (noteId: string) => void;
+ onMoveNote?: (noteId: string, updates: { folderId: string | null }) => void;
+ onPublishNote?: (noteId: string, authorName?: string) => Promise;
+ onUnpublishNote?: (noteId: string) => Promise;
+ onToggleNotesPanel?: () => void;
+ isNotesPanelOpen?: boolean;
+ onDirtyChange?: (isDirty: boolean) => void;
+ isReadOnly?: boolean;
+}
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+type WorkbookData = any;
+
+const DEFAULT_WORKBOOK_DATA: WorkbookData = {
+ id: 'workbook-1',
+ name: 'Untitled Spreadsheet',
+ sheetOrder: ['sheet-1'],
+ sheets: {
+ 'sheet-1': {
+ id: 'sheet-1',
+ name: 'Sheet1',
+ rowCount: 100,
+ columnCount: 26,
+ },
+ },
+};
+
+function parseWorkbookData(data: string): WorkbookData {
+ if (!data) return DEFAULT_WORKBOOK_DATA;
+ try {
+ const parsed = JSON.parse(data);
+ return parsed as WorkbookData;
+ } catch {
+ return DEFAULT_WORKBOOK_DATA;
+ }
+}
+
+function formatDate(date: Date): string {
+ const now = new Date();
+ const noteDate = new Date(date);
+ const diffMs = now.getTime() - noteDate.getTime();
+ const diffMins = Math.floor(diffMs / 60000);
+ const diffHours = Math.floor(diffMs / 3600000);
+ const diffDays = Math.floor(diffMs / 86400000);
+
+ if (diffMins < 1) return 'just now';
+ if (diffMins < 60) return `${diffMins}m ago`;
+ if (diffHours < 24) return `${diffHours}h ago`;
+ if (diffDays < 7) return `${diffDays}d ago`;
+
+ return noteDate.toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ year: noteDate.getFullYear() !== now.getFullYear() ? 'numeric' : undefined,
+ });
+}
+
+// Store instances globally to survive React strict mode
+const univerInstances = new Map>();
+
+export function SheetsEditor({
+ noteId,
+ initialData,
+ initialTitle = 'Untitled Spreadsheet',
+ createdAt,
+ updatedAt,
+ starred = false,
+ hidden = false,
+ isPublished = false,
+ publicSlug,
+ folders,
+ folderId,
+ onSave,
+ onToggleStar,
+ starringStar = false,
+ onHideNote,
+ onUnhideNote,
+ hidingNote = false,
+ onRefreshNote,
+ onArchiveNote,
+ onDeleteNote,
+ onMoveNote,
+ onPublishNote,
+ onUnpublishNote,
+ onToggleNotesPanel,
+ isNotesPanelOpen,
+ onDirtyChange,
+ isReadOnly = false,
+}: SheetsEditorProps) {
+ const { theme } = useTheme();
+ const isDarkMode = theme === 'dark' || (theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
+ const containerRef = useRef(null);
+ const saveTimeoutRef = useRef | null>(null);
+ const lastSavedDataRef = useRef(initialData);
+ const lastSavedTitleRef = useRef(initialTitle);
+ const onSaveRef = useRef(onSave);
+ const [title, setTitle] = useState(initialTitle);
+ const [isMoveModalOpen, setIsMoveModalOpen] = useState(false);
+ const [isPublishModalOpen, setIsPublishModalOpen] = useState(false);
+ const [isRefreshing, setIsRefreshing] = useState(false);
+ const [saveStatus, setSaveStatus] = useState<'saved' | 'saving' | 'unsaved'>('saved');
+ const disposableRef = useRef<{ dispose: () => void } | null>(null);
+
+ // Keep refs updated
+ onSaveRef.current = onSave;
+
+ // Update title when initialTitle changes (e.g., switching notes)
+ useEffect(() => {
+ setTitle(initialTitle);
+ lastSavedTitleRef.current = initialTitle;
+ }, [noteId, initialTitle]);
+
+ // Track dirty state
+ useEffect(() => {
+ const isDirty = title !== lastSavedTitleRef.current;
+ onDirtyChange?.(isDirty);
+ }, [title, onDirtyChange]);
+
+ // Toggle dark mode when theme changes
+ useEffect(() => {
+ const instance = univerInstances.get(noteId);
+ if (instance) {
+ instance.univerAPI.toggleDarkMode(isDarkMode);
+ }
+ }, [isDarkMode, noteId]);
+
+ // Handle title changes with autosave
+ const handleTitleChange = (e: React.ChangeEvent) => {
+ const newTitle = e.target.value;
+ setTitle(newTitle);
+ setSaveStatus('unsaved');
+
+ // Debounced save for title changes
+ if (saveTimeoutRef.current) {
+ clearTimeout(saveTimeoutRef.current);
+ }
+ saveTimeoutRef.current = setTimeout(async () => {
+ const instance = univerInstances.get(noteId);
+ if (instance) {
+ try {
+ const workbook = instance.univerAPI.getActiveWorkbook();
+ if (workbook) {
+ const snapshot = workbook.save();
+ const dataString = JSON.stringify(snapshot);
+ setSaveStatus('saving');
+ await onSaveRef.current(dataString, newTitle);
+ lastSavedDataRef.current = dataString;
+ lastSavedTitleRef.current = newTitle;
+ setSaveStatus('saved');
+ }
+ } catch (error) {
+ console.error('Failed to save:', error);
+ setSaveStatus('unsaved');
+ }
+ }
+ }, 1000);
+ };
+
+ const handleMoveNote = (newFolderId: string | null) => {
+ if (onMoveNote) {
+ onMoveNote(noteId, { folderId: newFolderId });
+ }
+ setIsMoveModalOpen(false);
+ };
+
+ useEffect(() => {
+ const container = containerRef.current;
+ if (!container) return;
+
+ // Check if we already have an instance for this noteId
+ let univerInstance = univerInstances.get(noteId);
+
+ if (!univerInstance) {
+ // Clear container
+ container.innerHTML = '';
+
+ const workbookData = parseWorkbookData(initialData);
+
+ // Create Univer instance using presets with dark mode support
+ univerInstance = createUniver({
+ locale: LocaleType.EN_US,
+ locales: {
+ [LocaleType.EN_US]: mergeLocales(UniverPresetSheetsCoreEnUS),
+ },
+ darkMode: isDarkMode,
+ presets: [
+ UniverSheetsCorePreset({
+ container,
+ }),
+ ],
+ });
+
+ const { univerAPI } = univerInstance;
+
+ // Create workbook with initial data
+ univerAPI.createWorkbook(workbookData);
+
+ // Store instance
+ univerInstances.set(noteId, univerInstance);
+
+ // Save function
+ const doSave = async () => {
+ const instance = univerInstances.get(noteId);
+ if (!instance) return;
+ try {
+ const workbook = instance.univerAPI.getActiveWorkbook();
+ if (!workbook) return;
+
+ const snapshot = workbook.save();
+ const dataString = JSON.stringify(snapshot);
+
+ if (dataString !== lastSavedDataRef.current) {
+ setSaveStatus('saving');
+ await onSaveRef.current(dataString, title);
+ lastSavedDataRef.current = dataString;
+ setSaveStatus('saved');
+ } else {
+ setSaveStatus('saved');
+ }
+ } catch (error) {
+ console.error('Failed to save spreadsheet:', error);
+ setSaveStatus('unsaved');
+ }
+ };
+
+ // Debounced save
+ const debouncedSave = () => {
+ setSaveStatus('unsaved');
+ if (saveTimeoutRef.current) {
+ clearTimeout(saveTimeoutRef.current);
+ }
+ saveTimeoutRef.current = setTimeout(doSave, 1000);
+ };
+
+ // Set up change listener for auto-save
+ if (!isReadOnly) {
+ disposableRef.current = univerAPI.onCommandExecuted(() => {
+ debouncedSave();
+ });
+ }
+ }
+
+ // Cleanup function - only runs on actual unmount (when key changes or component removed)
+ return () => {
+ // Use requestAnimationFrame to defer cleanup
+ requestAnimationFrame(() => {
+ // Check if container is actually gone from DOM
+ if (!document.body.contains(container)) {
+ const instance = univerInstances.get(noteId);
+ if (instance) {
+ if (disposableRef.current) {
+ disposableRef.current.dispose();
+ disposableRef.current = null;
+ }
+ if (saveTimeoutRef.current) {
+ clearTimeout(saveTimeoutRef.current);
+ }
+ // Save before disposing
+ try {
+ const workbook = instance.univerAPI.getActiveWorkbook();
+ if (workbook) {
+ const snapshot = workbook.save();
+ const dataString = JSON.stringify(snapshot);
+ if (dataString !== lastSavedDataRef.current) {
+ onSaveRef.current(dataString, title);
+ }
+ }
+ } catch {
+ // Ignore save errors on cleanup
+ }
+ instance.univer.dispose();
+ univerInstances.delete(noteId);
+ }
+ }
+ });
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [noteId]);
+
+ return (
+
+ {/* Header */}
+
+
+
+ {onToggleNotesPanel && (
+
+ {isNotesPanelOpen ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+
+
+
+
+ {onToggleStar && (
+ onToggleStar(noteId)}
+ className={starred ? 'text-yellow-500' : 'text-muted-foreground'}
+ title={starred ? 'Remove from starred' : 'Add to starred'}
+ disabled={starringStar}
+ >
+ {starringStar ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+ {onHideNote && onUnhideNote && (
+ (hidden ? onUnhideNote(noteId) : onHideNote(noteId))}
+ className={hidden ? 'text-primary' : 'text-muted-foreground'}
+ title={hidden ? 'Unhide note' : 'Hide note'}
+ disabled={hidingNote}
+ >
+ {hidingNote ? (
+
+ ) : hidden ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+ {onRefreshNote && (
+ {
+ setIsRefreshing(true);
+ try {
+ await onRefreshNote(noteId);
+ } finally {
+ setIsRefreshing(false);
+ }
+ }}
+ className="text-muted-foreground"
+ title="Refresh note from server"
+ disabled={isRefreshing}
+ >
+
+
+ )}
+
+
+
+
+
+
+
+
+ {onToggleStar && (
+ onToggleStar(noteId)}>
+
+ {starred ? 'Unstar' : 'Star'}
+
+ )}
+ {onMoveNote && (
+ setIsMoveModalOpen(true)}>
+
+ Move
+
+ )}
+ {onPublishNote && onUnpublishNote && (
+ setIsPublishModalOpen(true)}>
+
+ {isPublished ? 'Manage' : 'Publish'}
+
+ )}
+ {onArchiveNote && (
+ onArchiveNote(noteId)}>
+
+ Archive
+
+ )}
+
+ {onDeleteNote && (
+ onDeleteNote(noteId)}>
+
+ Trash
+
+ )}
+
+
+
+
+
+
+
+
+ {createdAt && (
+ <>
+
Created {formatDate(createdAt)}
+
•
+ >
+ )}
+ {updatedAt && (
+
Modified {formatDate(updatedAt)}
+ )}
+
•
+
+ {saveStatus === 'saving' && (
+ <>
+
+
Saving
+ >
+ )}
+ {saveStatus === 'saved' && (
+ <>
+
+
Saved
+ >
+ )}
+ {saveStatus === 'unsaved' && (
+ <>
+
+
Unsaved
+ >
+ )}
+
+
+
+
+
+ {/* Spreadsheet */}
+
+
+ {/* Move Note Modal */}
+
setIsMoveModalOpen(false)}
+ onMove={handleMoveNote}
+ folders={folders || []}
+ currentFolderId={folderId ?? null}
+ noteTitle={title}
+ />
+
+ {/* Publish Note Modal */}
+ {onPublishNote && onUnpublishNote && (
+ setIsPublishModalOpen(false)}
+ note={{
+ id: noteId,
+ title,
+ content: initialData,
+ type: 'sheets',
+ isPublished,
+ publicSlug,
+ } as Note}
+ onPublish={onPublishNote}
+ onUnpublish={onUnpublishNote}
+ />
+ )}
+
+ );
+}
+
+export default SheetsEditor;
diff --git a/src/components/sheets/SheetsErrorBoundary.tsx b/src/components/sheets/SheetsErrorBoundary.tsx
new file mode 100644
index 0000000..c7aead3
--- /dev/null
+++ b/src/components/sheets/SheetsErrorBoundary.tsx
@@ -0,0 +1,79 @@
+import { Component, type ReactNode } from 'react';
+import { AlertCircle, RefreshCw } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+
+interface Props {
+ children: ReactNode;
+ fallbackTitle?: string;
+ onRetry?: () => void;
+}
+
+interface State {
+ hasError: boolean;
+ error: Error | null;
+}
+
+/**
+ * Error boundary specifically for Univer spreadsheet components.
+ * Catches errors during Univer initialization or rendering and displays
+ * a friendly error message with retry option.
+ */
+export class SheetsErrorBoundary extends Component {
+ constructor(props: Props) {
+ super(props);
+ this.state = { hasError: false, error: null };
+ }
+
+ static getDerivedStateFromError(error: Error): State {
+ return { hasError: true, error };
+ }
+
+ componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
+ console.error('SheetsErrorBoundary caught an error:', error, errorInfo);
+ }
+
+ handleRetry = () => {
+ this.setState({ hasError: false, error: null });
+ this.props.onRetry?.();
+ };
+
+ render() {
+ if (this.state.hasError) {
+ return (
+
+
+
+
+
+ {this.props.fallbackTitle || 'Failed to load spreadsheet'}
+
+
+ Something went wrong while loading the spreadsheet editor.
+ {this.state.error?.message && (
+
+ Error: {this.state.error.message}
+
+ )}
+
+
+
+
+ Try Again
+
+
+
+ );
+ }
+
+ return this.props.children;
+ }
+}
+
+export default SheetsErrorBoundary;
diff --git a/src/components/ui/skeleton.tsx b/src/components/ui/skeleton.tsx
new file mode 100644
index 0000000..a626d9b
--- /dev/null
+++ b/src/components/ui/skeleton.tsx
@@ -0,0 +1,15 @@
+import { cn } from '@/lib/utils';
+
+function Skeleton({
+ className,
+ ...props
+}: React.HTMLAttributes) {
+ return (
+
+ );
+}
+
+export { Skeleton };
diff --git a/src/constants/index.ts b/src/constants/index.ts
index 74a3088..38ad1dc 100644
--- a/src/constants/index.ts
+++ b/src/constants/index.ts
@@ -43,8 +43,8 @@ export const SEO_CONFIG = {
],
},
signedIn: {
- title: 'Secure Encrypted Notes',
- description: 'Secure encrypted notes with zero-knowledge privacy and integrated code execution for developers. Available on iOS, Android, and Web.',
+ title: 'Workspace',
+ description: 'Secure encrypted workspace with documents, spreadsheets, diagrams, and code execution. Zero-knowledge privacy. Available on iOS, Android, and Web.',
keywords: [
'dashboard',
'encrypted notes',
diff --git a/src/hooks/useDragAndDrop.ts b/src/hooks/useDragAndDrop.ts
index 6332add..eb7a164 100644
--- a/src/hooks/useDragAndDrop.ts
+++ b/src/hooks/useDragAndDrop.ts
@@ -1,18 +1,12 @@
-import { useCallback, useState } from 'react';
-
+import { useState } from 'react';
+import type { DragStartEvent, DragEndEvent, DragOverEvent } from '@dnd-kit/core';
import type { Folder } from '@/types/note.ts';
import { canMoveToFolder } from '@/utils/folderTree';
import { secureLogger } from '@/lib/utils/secureLogger';
interface DragState {
- isDragging: boolean;
- draggedIndex: number | null;
- dragOverIndex: number | null;
-}
-
-interface DragData {
- index: number;
- folderId: string;
+ activeId: string | null;
+ overId: string | null;
}
export function useDragAndDrop(
@@ -21,139 +15,104 @@ export function useDragAndDrop(
onReorderFolders: (folderId: string, newIndex: number) => Promise
) {
const [dragState, setDragState] = useState({
- isDragging: false,
- draggedIndex: null,
- dragOverIndex: null,
+ activeId: null,
+ overId: null,
});
- const handleDragStart = useCallback(
- (e: React.DragEvent, index: number) => {
- const folder = displayFolders[index];
- e.dataTransfer.effectAllowed = 'move';
- e.dataTransfer.setData(
- 'text/plain',
- JSON.stringify({ index, folderId: folder.id } as DragData)
- );
+ const handleDragStart = (event: DragStartEvent) => {
+ setDragState({
+ activeId: event.active.id as string,
+ overId: null,
+ });
+ };
- setDragState({
- isDragging: true,
- draggedIndex: index,
- dragOverIndex: null,
- });
-
- const target = e.currentTarget as HTMLElement;
- target.style.opacity = '0.5';
- },
- [displayFolders]
- );
-
- const handleDragOver = useCallback(
- (e: React.DragEvent, index: number) => {
- e.preventDefault();
- e.dataTransfer.dropEffect = 'move';
-
- if (dragState.draggedIndex !== index) {
- setDragState((prev) => ({
- ...prev,
- dragOverIndex: index,
- }));
- }
- },
- [dragState.draggedIndex]
- );
+ const handleDragOver = (event: DragOverEvent) => {
+ const { over } = event;
+ setDragState((prev) => ({
+ ...prev,
+ overId: over?.id as string | null,
+ }));
+ };
+
+ const handleDragEnd = async (event: DragEndEvent) => {
+ const { active, over } = event;
- const handleDragEnter = useCallback((e: React.DragEvent) => {
- e.preventDefault();
- }, []);
+ setDragState({
+ activeId: null,
+ overId: null,
+ });
- const handleDragLeave = useCallback((e: React.DragEvent) => {
- if (!e.currentTarget.contains(e.relatedTarget as Node)) {
- setDragState((prev) => ({ ...prev, dragOverIndex: null }));
+ if (!over || active.id === over.id) {
+ return;
}
- }, []);
-
- const handleDrop = useCallback(
- async (e: React.DragEvent, dropIndex: number) => {
- e.preventDefault();
-
- try {
- const dragData = JSON.parse(
- e.dataTransfer.getData('text/plain')
- ) as DragData;
- const draggedIndex = dragData.index;
- const draggedFolderId = dragData.folderId;
-
- if (draggedIndex === dropIndex) {
- return;
- }
-
- const draggedFolder = displayFolders[draggedIndex];
- const targetFolder = displayFolders[dropIndex];
-
- // Check if the folder can be moved to the target location
- if (
- targetFolder.parentId &&
- !canMoveToFolder(draggedFolder.id, targetFolder.parentId, folders)
- ) {
- secureLogger.warn('Cannot move folder to its own descendant');
- return;
- }
-
- if (draggedFolder.parentId !== targetFolder.parentId) {
- return;
- }
-
- const allSiblingsInOrder = folders
- .filter((f) => f.parentId === draggedFolder.parentId)
- .sort((a, b) => {
- const aIndex = folders.findIndex((f) => f.id === a.id);
- const bIndex = folders.findIndex((f) => f.id === b.id);
- return aIndex - bIndex;
- });
-
- const draggedSiblingIndex = allSiblingsInOrder.findIndex(
- (f) => f.id === draggedFolderId
- );
- const targetSiblingIndex = allSiblingsInOrder.findIndex(
- (f) => f.id === targetFolder.id
- );
-
- if (draggedSiblingIndex === -1 || targetSiblingIndex === -1) {
- secureLogger.error('Could not find folder indices in siblings array');
- return;
- }
-
- await onReorderFolders(draggedFolderId, targetSiblingIndex);
- } catch (error) {
- secureLogger.error('Failed to reorder folders:', error);
- } finally {
- setDragState({
- isDragging: false,
- draggedIndex: null,
- dragOverIndex: null,
+
+ try {
+ const draggedFolderId = active.id as string;
+ const targetFolderId = over.id as string;
+
+ const draggedIndex = displayFolders.findIndex((f) => f.id === draggedFolderId);
+ const targetIndex = displayFolders.findIndex((f) => f.id === targetFolderId);
+
+ if (draggedIndex === -1 || targetIndex === -1) {
+ return;
+ }
+
+ const draggedFolder = displayFolders[draggedIndex];
+ const targetFolder = displayFolders[targetIndex];
+
+ // Check if the folder can be moved to the target location
+ if (
+ targetFolder.parentId &&
+ !canMoveToFolder(draggedFolder.id, targetFolder.parentId, folders)
+ ) {
+ secureLogger.warn('Cannot move folder to its own descendant');
+ return;
+ }
+
+ if (draggedFolder.parentId !== targetFolder.parentId) {
+ return;
+ }
+
+ const allSiblingsInOrder = folders
+ .filter((f) => f.parentId === draggedFolder.parentId)
+ .sort((a, b) => {
+ const aIndex = folders.findIndex((f) => f.id === a.id);
+ const bIndex = folders.findIndex((f) => f.id === b.id);
+ return aIndex - bIndex;
});
+
+ const targetSiblingIndex = allSiblingsInOrder.findIndex(
+ (f) => f.id === targetFolder.id
+ );
+
+ if (targetSiblingIndex === -1) {
+ secureLogger.error('Could not find folder index in siblings array');
+ return;
}
- },
- [displayFolders, folders, onReorderFolders]
- );
- const handleDragEnd = useCallback((e: React.DragEvent) => {
- const target = e.currentTarget as HTMLElement;
- target.style.opacity = '1';
+ await onReorderFolders(draggedFolderId, targetSiblingIndex);
+ } catch (error) {
+ secureLogger.error('Failed to reorder folders:', error);
+ }
+ };
+
+ const handleDragCancel = () => {
setDragState({
- isDragging: false,
- draggedIndex: null,
- dragOverIndex: null,
+ activeId: null,
+ overId: null,
});
- }, []);
+ };
+
+ const activeFolder = dragState.activeId
+ ? displayFolders.find((f) => f.id === dragState.activeId)
+ : null;
return {
dragState,
+ activeFolder,
handleDragStart,
handleDragOver,
- handleDragEnter,
- handleDragLeave,
- handleDrop,
handleDragEnd,
+ handleDragCancel,
};
}
diff --git a/src/hooks/useMasterPassword.ts b/src/hooks/useMasterPassword.ts
index 798ed94..e0dcd3f 100644
--- a/src/hooks/useMasterPassword.ts
+++ b/src/hooks/useMasterPassword.ts
@@ -7,7 +7,6 @@ export function useMasterPassword() {
const [needsUnlock, setNeedsUnlock] = useState(false);
const [isChecking, setIsChecking] = useState(true);
- /* eslint-disable react-hooks/set-state-in-effect -- Check encryption status on user change */
useEffect(() => {
if (!user) {
setIsChecking(false);
@@ -27,7 +26,6 @@ export function useMasterPassword() {
checkMasterPassword();
}, [user]);
- /* eslint-enable react-hooks/set-state-in-effect */
const handleUnlockSuccess = () => {
setNeedsUnlock(false);
diff --git a/src/hooks/useNotes.ts b/src/hooks/useNotes.ts
index c196d03..ea5aef9 100644
--- a/src/hooks/useNotes.ts
+++ b/src/hooks/useNotes.ts
@@ -502,7 +502,9 @@ export function useNotes() {
// Auto-sync public note if published and content/title changed
if (updates.title !== undefined || updates.content !== undefined) {
- const note = notes.find(n => n.id === noteId);
+ // Use selectedNote if it matches (most common case - editing the selected note)
+ // Fall back to finding in notes array for edge cases
+ const note = selectedNote?.id === noteId ? selectedNote : notes.find(n => n.id === noteId);
if (note?.isPublished && note?.publicSlug) {
// Sync in background without blocking the save
void api.updatePublicNote(note.publicSlug, {
@@ -514,7 +516,7 @@ export function useNotes() {
}
}
},
- [restNotesOperations, notes]
+ [restNotesOperations, notes, selectedNote]
);
const deleteNote = async (noteId: string) => {
@@ -567,9 +569,35 @@ export function useNotes() {
};
const reorderFolders = async (folderId: string, newIndex: number) => {
+ // Optimistic update - reorder locally first
+ const previousFolders = [...folders];
+
+ setFolders((currentFolders) => {
+ const folderToMove = currentFolders.find((f) => f.id === folderId);
+ if (!folderToMove) return currentFolders;
+
+ // Get siblings (folders with same parent)
+ const parentId = folderToMove.parentId;
+ const siblings = currentFolders.filter((f) => f.parentId === parentId);
+ const nonSiblings = currentFolders.filter((f) => f.parentId !== parentId);
+
+ // Remove the folder from its current position
+ const siblingsWithoutFolder = siblings.filter((f) => f.id !== folderId);
+
+ // Insert at new position
+ const reorderedSiblings = [
+ ...siblingsWithoutFolder.slice(0, newIndex),
+ folderToMove,
+ ...siblingsWithoutFolder.slice(newIndex),
+ ];
+
+ return [...nonSiblings, ...reorderedSiblings];
+ });
+
try {
await api.reorderFolder(folderId, newIndex);
+ // Fetch fresh data in background to ensure consistency
const newFolders = await fetchAllFolders();
setFolders(newFolders);
@@ -586,6 +614,8 @@ export function useNotes() {
// }
// }
} catch (error) {
+ // Rollback on error
+ setFolders(previousFolders);
secureLogger.error('Folder reordering failed', error);
setError('Failed to reorder folders');
}
diff --git a/src/hooks/useNotesOperations.ts b/src/hooks/useNotesOperations.ts
index faf08ab..6345d79 100644
--- a/src/hooks/useNotesOperations.ts
+++ b/src/hooks/useNotesOperations.ts
@@ -49,7 +49,7 @@ export function useNotesOperations({
const createNote = useCallback(
async (
folderId?: string,
- templateContent?: { title: string; content: string; type?: 'note' | 'diagram' | 'code' }
+ templateContent?: { title: string; content: string; type?: 'note' | 'diagram' | 'code' | 'sheets' }
) => {
let showSpinner = false;
const spinnerTimeout = setTimeout(() => {
@@ -200,6 +200,10 @@ export function useNotesOperations({
});
const updatedNote = convertApiNote(apiNote);
+
+ // Check if API actually returned isPublished (before conversion defaults it to false)
+ const apiHasPublishedField = apiNote.isPublished !== undefined;
+
setNotes((prev) =>
prev.map((note) => {
if (note.id !== noteId) return note;
@@ -214,6 +218,9 @@ export function useNotesOperations({
attachments: note.attachments,
folder: newFolder,
isNew: updates.title !== undefined ? false : note.isNew,
+ // Preserve isPublished if not returned by API
+ isPublished: apiHasPublishedField ? updatedNote.isPublished : note.isPublished,
+ publicSlug: apiNote.publicSlug !== undefined ? updatedNote.publicSlug : note.publicSlug,
};
})
);
@@ -229,6 +236,9 @@ export function useNotesOperations({
attachments: selectedNote.attachments,
folder: newFolder,
isNew: updates.title !== undefined ? false : selectedNote.isNew,
+ // Preserve isPublished if not returned by API
+ isPublished: apiHasPublishedField ? updatedNote.isPublished : selectedNote.isPublished,
+ publicSlug: apiNote.publicSlug !== undefined ? updatedNote.publicSlug : selectedNote.publicSlug,
});
}
@@ -310,6 +320,8 @@ export function useNotesOperations({
selectedNote?.id,
selectedNote?.attachments,
selectedNote?.isNew,
+ selectedNote?.isPublished,
+ selectedNote?.publicSlug,
folders,
loadData,
convertApiNote,
@@ -352,6 +364,9 @@ export function useNotesOperations({
const apiNote = await api.toggleStarNote(noteId);
const updatedNote = convertApiNote(apiNote);
+ // Check if API actually returned isPublished (before conversion defaults it to false)
+ const apiHasPublishedField = apiNote.isPublished !== undefined;
+
setNotes((prev) =>
prev.map((note) =>
note.id === noteId
@@ -359,6 +374,9 @@ export function useNotesOperations({
...updatedNote,
attachments: note.attachments,
folder: note.folder,
+ // Preserve isPublished if not returned by API
+ isPublished: apiHasPublishedField ? updatedNote.isPublished : note.isPublished,
+ publicSlug: apiNote.publicSlug !== undefined ? updatedNote.publicSlug : note.publicSlug,
}
: note
)
@@ -369,6 +387,9 @@ export function useNotesOperations({
...updatedNote,
attachments: selectedNote.attachments,
folder: selectedNote.folder,
+ // Preserve isPublished if not returned by API
+ isPublished: apiHasPublishedField ? updatedNote.isPublished : selectedNote.isPublished,
+ publicSlug: apiNote.publicSlug !== undefined ? updatedNote.publicSlug : selectedNote.publicSlug,
});
}
@@ -417,8 +438,20 @@ export function useNotesOperations({
const apiNote = await api.restoreNote(noteId);
const restoredNote = convertApiNote(apiNote);
+ // Check if API actually returned isPublished (before conversion defaults it to false)
+ const apiHasPublishedField = apiNote.isPublished !== undefined;
+
setNotes((prev) =>
- prev.map((note) => (note.id === noteId ? restoredNote : note))
+ prev.map((note) =>
+ note.id === noteId
+ ? {
+ ...restoredNote,
+ // Preserve isPublished if not returned by API
+ isPublished: apiHasPublishedField ? restoredNote.isPublished : note.isPublished,
+ publicSlug: apiNote.publicSlug !== undefined ? restoredNote.publicSlug : note.publicSlug,
+ }
+ : note
+ )
);
// BACKLOG: WebSocket update moved to upcoming release
@@ -448,6 +481,9 @@ export function useNotesOperations({
const apiNote = await api.hideNote(noteId);
const hiddenNote = convertApiNote(apiNote);
+ // Check if API actually returned isPublished (before conversion defaults it to false)
+ const apiHasPublishedField = apiNote.isPublished !== undefined;
+
setNotes((prev) =>
prev.map((note) =>
note.id === noteId
@@ -455,6 +491,9 @@ export function useNotesOperations({
...hiddenNote,
attachments: note.attachments,
folder: note.folder,
+ // Preserve isPublished if not returned by API
+ isPublished: apiHasPublishedField ? hiddenNote.isPublished : note.isPublished,
+ publicSlug: apiNote.publicSlug !== undefined ? hiddenNote.publicSlug : note.publicSlug,
}
: note
)
@@ -465,6 +504,9 @@ export function useNotesOperations({
...hiddenNote,
attachments: selectedNote.attachments,
folder: selectedNote.folder,
+ // Preserve isPublished if not returned by API
+ isPublished: apiHasPublishedField ? hiddenNote.isPublished : selectedNote.isPublished,
+ publicSlug: apiNote.publicSlug !== undefined ? hiddenNote.publicSlug : selectedNote.publicSlug,
});
}
@@ -503,6 +545,9 @@ export function useNotesOperations({
const apiNote = await api.unhideNote(noteId);
const unhiddenNote = convertApiNote(apiNote);
+ // Check if API actually returned isPublished (before conversion defaults it to false)
+ const apiHasPublishedField = apiNote.isPublished !== undefined;
+
setNotes((prev) =>
prev.map((note) =>
note.id === noteId
@@ -510,6 +555,9 @@ export function useNotesOperations({
...unhiddenNote,
attachments: note.attachments,
folder: note.folder,
+ // Preserve isPublished if not returned by API
+ isPublished: apiHasPublishedField ? unhiddenNote.isPublished : note.isPublished,
+ publicSlug: apiNote.publicSlug !== undefined ? unhiddenNote.publicSlug : note.publicSlug,
}
: note
)
@@ -520,6 +568,9 @@ export function useNotesOperations({
...unhiddenNote,
attachments: selectedNote.attachments,
folder: selectedNote.folder,
+ // Preserve isPublished if not returned by API
+ isPublished: apiHasPublishedField ? unhiddenNote.isPublished : selectedNote.isPublished,
+ publicSlug: apiNote.publicSlug !== undefined ? unhiddenNote.publicSlug : selectedNote.publicSlug,
});
}
diff --git a/src/index.css b/src/index.css
index a0978d0..c987d7f 100644
--- a/src/index.css
+++ b/src/index.css
@@ -163,3 +163,64 @@
background: #777;
}
}
+
+/* TipTap Table Styles */
+.ProseMirror table {
+ border-collapse: collapse;
+ table-layout: fixed;
+ width: 100%;
+ margin: 1rem 0;
+ overflow: hidden;
+}
+
+.ProseMirror td,
+.ProseMirror th {
+ min-width: 1em;
+ border: 1px solid var(--border);
+ padding: 0.5rem 0.75rem;
+ vertical-align: top;
+ box-sizing: border-box;
+ position: relative;
+}
+
+.ProseMirror th {
+ font-weight: 600;
+ text-align: left;
+ background-color: var(--muted);
+}
+
+.ProseMirror .selectedCell:after {
+ z-index: 2;
+ position: absolute;
+ content: "";
+ left: 0;
+ right: 0;
+ top: 0;
+ bottom: 0;
+ background: oklch(0.488 0.243 264.376 / 20%);
+ pointer-events: none;
+}
+
+.ProseMirror .column-resize-handle {
+ position: absolute;
+ right: -2px;
+ top: 0;
+ bottom: -2px;
+ width: 4px;
+ background-color: oklch(0.488 0.243 264.376);
+ pointer-events: none;
+}
+
+.ProseMirror.resize-cursor {
+ cursor: col-resize;
+}
+
+/* Table grip for selection */
+.ProseMirror .tableWrapper {
+ padding: 1rem 0;
+ overflow-x: auto;
+}
+
+.ProseMirror table p {
+ margin: 0;
+}
diff --git a/src/lib/api/api.ts b/src/lib/api/api.ts
index cf272ff..313eb51 100644
--- a/src/lib/api/api.ts
+++ b/src/lib/api/api.ts
@@ -38,7 +38,7 @@ export interface ApiNote {
createdAt: string;
updatedAt: string;
attachmentCount?: number;
- type?: 'note' | 'diagram';
+ type?: 'note' | 'diagram' | 'code' | 'sheets';
// Public note fields (from JOIN with public_notes table)
isPublished?: boolean;
publicSlug?: string | null;
@@ -82,7 +82,7 @@ export interface ApiPublicNote {
userId: string;
title: string;
content: string; // Plaintext HTML (not encrypted)
- type?: 'note' | 'diagram' | 'code';
+ type?: 'note' | 'diagram' | 'code' | 'sheets';
authorName?: string;
publishedAt: string;
updatedAt: string;
@@ -93,7 +93,7 @@ export interface ApiPublicNoteResponse {
slug: string;
title: string;
content: string;
- type?: 'note' | 'diagram' | 'code';
+ type?: 'note' | 'diagram' | 'code' | 'sheets';
authorName?: string;
publishedAt: string;
updatedAt: string;
@@ -389,7 +389,7 @@ class ClerkEncryptedApiService {
folderId?: string | null;
starred?: boolean;
tags?: string[];
- type?: 'note' | 'diagram' | 'code';
+ type?: 'note' | 'diagram' | 'code' | 'sheets';
}): Promise {
const title = noteData.title ?? 'Untitled';
const content = noteData.content ?? '';
@@ -682,7 +682,7 @@ class ClerkEncryptedApiService {
noteId: string;
title: string;
content: string;
- type?: 'note' | 'diagram' | 'code';
+ type?: 'note' | 'diagram' | 'code' | 'sheets';
authorName?: string;
}): Promise {
return this.request('/public-notes', {
diff --git a/src/pages/PublicNotePage.tsx b/src/pages/PublicNotePage.tsx
index f08ead2..51065bb 100644
--- a/src/pages/PublicNotePage.tsx
+++ b/src/pages/PublicNotePage.tsx
@@ -1,7 +1,11 @@
-import { useEffect, useState, useRef } from 'react';
+import { useEffect, useState, useRef, lazy, Suspense } from 'react';
import { Calendar, User, AlertCircle, Sun, Moon, ArrowUp } from 'lucide-react';
import type { ApiPublicNoteResponse } from '@/lib/api/api';
import DOMPurify from 'dompurify';
+
+// Lazy load the sheets viewer to avoid loading Univer for non-sheet notes
+const PublicSheetsViewer = lazy(() => import('@/components/sheets/PublicSheetsViewer'));
+import { SheetsErrorBoundary } from '@/components/sheets/SheetsErrorBoundary';
import hljs from 'highlight.js/lib/core';
import javascript from 'highlight.js/lib/languages/javascript';
import typescript from 'highlight.js/lib/languages/typescript';
@@ -41,6 +45,18 @@ type Theme = 'light' | 'dark';
// Note: Public notes API endpoint doesn't require authentication
const VITE_API_URL = import.meta.env.VITE_API_URL as string;
+// Helper to detect if content is a sheets workbook
+function isWorkbookContent(content: string): boolean {
+ if (!content) return false;
+ try {
+ const parsed = JSON.parse(content);
+ // Check for typical workbook structure
+ return parsed && (parsed.sheets || parsed.sheetOrder || parsed.id?.startsWith?.('workbook'));
+ } catch {
+ return false;
+ }
+}
+
// TipTap content styles for proper rendering
const tiptapContentStyles = `
.tiptap-content {
@@ -574,6 +590,65 @@ const tiptapContentStyles = `
.dark .tiptap-content .toc-empty-message {
color: #9ca3af;
}
+
+/* Table Styles */
+.tiptap-content table {
+ width: 100%;
+ max-width: 100%;
+ border-collapse: collapse;
+ margin: 16px 0;
+ display: block;
+ overflow-x: auto;
+ -webkit-overflow-scrolling: touch;
+}
+
+.tiptap-content table tbody,
+.tiptap-content table thead {
+ display: table;
+ width: 100%;
+ table-layout: fixed;
+}
+
+.tiptap-content table th,
+.tiptap-content table td {
+ border: 1px solid #d1d5db;
+ padding: 8px 12px;
+ text-align: left;
+ vertical-align: top;
+ min-width: 80px;
+ word-wrap: break-word;
+ overflow-wrap: break-word;
+}
+
+.dark .tiptap-content table th,
+.dark .tiptap-content table td {
+ border-color: #4b5563;
+}
+
+.tiptap-content table th {
+ background-color: #f3f4f6;
+ font-weight: 600;
+}
+
+.dark .tiptap-content table th {
+ background-color: #374151;
+}
+
+.tiptap-content table tr:nth-child(even) td {
+ background-color: #f9fafb;
+}
+
+.dark .tiptap-content table tr:nth-child(even) td {
+ background-color: #1f2937;
+}
+
+.tiptap-content table tr:hover td {
+ background-color: #f3f4f6;
+}
+
+.dark .tiptap-content table tr:hover td {
+ background-color: #374151;
+}
`;
async function fetchPublicNote(slug: string): Promise {
@@ -920,7 +995,12 @@ export default function PublicNotePage() {
};
const ThemeIcon = () => {
- return theme === 'light' ? : ;
+ return (
+ <>
+
+
+ >
+ );
};
const formatDate = (dateString: string) => {
@@ -939,7 +1019,7 @@ export default function PublicNotePage() {
if (loading) {
return (
-
+
Loading note...
@@ -950,7 +1030,7 @@ export default function PublicNotePage() {
if (error || !note) {
return (
-
+
@@ -966,17 +1046,83 @@ export default function PublicNotePage() {
);
}
+ // Determine if this is a sheet
+ const isSheet = note.type === 'sheets' || isWorkbookContent(note.content);
+
+ // For sheets, use a full-screen layout with header
+ if (isSheet) {
+ return (
+
+ {/* Header with title and meta */}
+
+
+
+
+ {note.title || 'Untitled Sheet'}
+
+
+ {note.authorName && (
+ <>
+
+
+ {note.authorName}
+
+
•
+ >
+ )}
+
+
+ Published {formatDate(note.publishedAt)}
+
+ {note.updatedAt !== note.publishedAt && (
+
(Updated {formatDate(note.updatedAt)})
+ )}
+
•
+
Read-only
+
•
+
+ Shared via Typelets
+
+
+
+
+
+
+
+
+
+ {/* Sheet content - takes remaining height */}
+
+ }>
+
+
+
+
+
+ );
+ }
+
return (
-
+
{/* Header */}
-
-
+
+
Typelets Shared Note
@@ -986,15 +1132,15 @@ export default function PublicNotePage() {
{/* Content */}
-
-
+
+
{/* Title */}
{note.title || 'Untitled Note'}
{/* Meta */}
-
+
{note.authorName && (
@@ -1024,13 +1170,6 @@ export default function PublicNotePage() {
{/* TipTap content styles */}
- {/* Footer */}
-
-
{/* Scroll to top FAB - hidden on mobile */}
{showScrollTop && (
void;
onCreateDiagram?: (templateCode?: string) => void;
onCreateCode?: (templateData?: { language: string; code: string }) => void;
+ onCreateSheets?: () => void;
onToggleFolderPanel: () => void;
onEmptyTrash: () => Promise;
onRefresh?: () => Promise;
creatingNote?: boolean;
+ loading?: boolean;
isMobile?: boolean;
onClose?: () => void;
}
diff --git a/src/types/note.ts b/src/types/note.ts
index f7d8cc7..bf93cc7 100644
--- a/src/types/note.ts
+++ b/src/types/note.ts
@@ -12,7 +12,7 @@ export interface Note {
id: string;
title: string;
content: string;
- type?: 'note' | 'diagram' | 'code'; // Type of note: regular note, diagram, or code
+ type?: 'note' | 'diagram' | 'code' | 'sheets'; // Type of note: regular note, diagram, code, or sheets
createdAt: Date;
updatedAt: Date;
starred: boolean;
@@ -41,7 +41,7 @@ export interface PublicNote {
userId: string;
title: string;
content: string; // Plaintext HTML (not encrypted)
- type?: 'note' | 'diagram' | 'code';
+ type?: 'note' | 'diagram' | 'code' | 'sheets';
authorName?: string;
publishedAt: Date;
updatedAt: Date;
@@ -52,7 +52,7 @@ export interface PublicNoteResponse {
slug: string;
title: string;
content: string;
- type?: 'note' | 'diagram' | 'code';
+ type?: 'note' | 'diagram' | 'code' | 'sheets';
authorName?: string;
publishedAt: Date;
updatedAt: Date;