From 97d0b62d094fffa6707b7911558859de1ea0b857 Mon Sep 17 00:00:00 2001 From: Rui Costa Date: Sat, 22 Nov 2025 17:35:36 -0500 Subject: [PATCH 1/3] feat: add public notes sharing with security hardening Add ability to publish notes as public, shareable pages while maintaining security for the E2E encrypted notes application. Features: - Publish/unpublish notes via modal with optional author attribution - Public page at /p/:slug with full TipTap rendering support - Globe icon indicator on tabs and note cards for published notes - "Public" view in sidebar to filter published notes - Auto-sync: edits to notes automatically update public version - Theme toggle (light/dark) on public page, defaults to system - Collapsible table of contents support - SEO meta tags (Open Graph, Twitter Cards) Security: - DOMPurify sanitization on both frontend and backend (defense in depth) - Strip internal note IDs from public content - Clear warning that publishing bypasses E2E encryption - Unguessable slugs (nanoid, 839 quadrillion combinations) - Hard delete on unpublish (no soft delete) - Rate limiting on public endpoints - No auth tokens or sensitive data exposed --- README.md | 13 + package.json | 2 + pnpm-lock.yaml | 58 +- src/App.tsx | 9 +- src/components/editor/index.tsx | 23 + .../editor/modals/PublishNoteModal.tsx | 287 +++++ src/components/folders/QuickAccessSection.tsx | 5 +- src/components/folders/constants.ts | 7 +- src/components/folders/index.tsx | 3 + src/components/layout/MainLayout.tsx | 21 +- src/components/layout/TabBar.tsx | 40 +- src/components/notes/NotesPanel/NoteCard.tsx | 10 +- src/components/notes/NotesPanel/index.tsx | 86 +- src/hooks/useNotes.ts | 166 ++- src/hooks/useNotesFiltering.ts | 4 + src/lib/api/api.ts | 85 ++ src/pages/PublicNotePage.tsx | 1011 +++++++++++++++++ src/types/layout.ts | 3 + src/types/note.ts | 20 +- 19 files changed, 1792 insertions(+), 61 deletions(-) create mode 100644 src/components/editor/modals/PublishNoteModal.tsx create mode 100644 src/pages/PublicNotePage.tsx diff --git a/README.md b/README.md index e359c57..8439f2a 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,19 @@ Available on iOS and Android with the same powerful features and encryption. - 🌐 **Cross-platform** - Responsive web app that works seamlessly on desktop, tablet, and mobile - 🖨️ **Print support** - Clean printing with proper formatting +### 🌐 Public Notes (Sharing) +- 🔗 **Shareable links** - Publish any note with a unique, unguessable URL +- 👤 **Optional author attribution** - Add your name or publish anonymously +- 🎨 **Full formatting preserved** - Rich text, code blocks, images, and diagrams render beautifully +- 📑 **Table of contents** - Collapsible TOC for easy navigation +- 🌙 **Theme toggle** - Readers can switch between light and dark mode +- 📱 **Mobile-friendly** - Responsive design for all screen sizes +- 🔄 **Auto-sync** - Changes to your note automatically update the public version +- ❌ **Instant unpublish** - Remove public access at any time (hard delete) +- 🛡️ **Security hardened** - DOMPurify sanitization, rate limiting, no internal IDs exposed + +> ⚠️ **Important:** Publishing a note bypasses end-to-end encryption. An unencrypted copy is stored on our servers and anyone with the link can view it. Use this feature only for content you intend to share publicly. + ### ⚡ Executable Code Blocks ![Execute Code Demo](https://github.com/typelets/typelets-app/blob/main/assets/execute-code-demo.gif) diff --git a/package.json b/package.json index 61b29fa..e8a1e70 100644 --- a/package.json +++ b/package.json @@ -88,9 +88,11 @@ "@tiptap/react": "^3.4.4", "@tiptap/starter-kit": "^3.4.4", "@tiptap/suggestion": "^3.4.4", + "@types/dompurify": "^3.2.0", "@unhead/react": "^2.0.17", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "dompurify": "^3.3.0", "highlight.js": "^11.11.1", "lowlight": "^3.3.0", "lucide-react": "^0.544.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 08a4b6c..e29bbba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -95,6 +95,9 @@ importers: '@tiptap/suggestion': specifier: ^3.4.4 version: 3.4.4(@tiptap/core@3.4.4(@tiptap/pm@3.4.4))(@tiptap/pm@3.4.4) + '@types/dompurify': + specifier: ^3.2.0 + version: 3.2.0 '@unhead/react': specifier: ^2.0.17 version: 2.0.17(react@19.1.1) @@ -104,6 +107,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + dompurify: + specifier: ^3.3.0 + version: 3.3.0 highlight.js: specifier: ^11.11.1 version: 11.11.1 @@ -3075,6 +3081,10 @@ packages: '@types/d3@7.4.3': resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==} + '@types/dompurify@3.2.0': + resolution: {integrity: sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==} + deprecated: This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed. + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -11966,6 +11976,10 @@ snapshots: '@types/d3-transition': 3.0.9 '@types/d3-zoom': 3.0.8 + '@types/dompurify@3.2.0': + dependencies: + dompurify: 3.3.0 + '@types/estree@1.0.8': {} '@types/geojson@7946.0.16': {} @@ -13604,7 +13618,7 @@ snapshots: eslint: 9.36.0(jiti@2.6.1) eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.36.0(jiti@2.6.1)) eslint-plugin-expo: 1.0.0(eslint@9.36.0(jiti@2.6.1))(typescript@5.9.2) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.44.0(eslint@9.36.0(jiti@2.6.1))(typescript@5.9.2))(eslint-import-resolver-typescript@4.4.4)(eslint@9.36.0(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.44.0(eslint@9.36.0(jiti@2.6.1))(typescript@5.9.2))(eslint-import-resolver-typescript@4.4.4(eslint-plugin-import@2.32.0)(eslint@9.36.0(jiti@2.6.1)))(eslint@9.36.0(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.36.0(jiti@2.6.1)) eslint-plugin-react-hooks: 5.2.0(eslint@9.36.0(jiti@2.6.1)) globals: 16.4.0 @@ -13644,7 +13658,7 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.44.0(eslint@9.36.0(jiti@2.6.1))(typescript@5.9.2))(eslint-import-resolver-typescript@4.4.4)(eslint@9.36.0(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.44.0(eslint@9.36.0(jiti@2.6.1))(typescript@5.9.2))(eslint-import-resolver-typescript@4.4.4(eslint-plugin-import@2.32.0)(eslint@9.36.0(jiti@2.6.1)))(eslint@9.36.0(jiti@2.6.1)) transitivePeerDependencies: - supports-color @@ -13663,6 +13677,17 @@ snapshots: transitivePeerDependencies: - supports-color + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.44.0(eslint@9.36.0(jiti@2.6.1))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.36.0(jiti@2.6.1)): + dependencies: + debug: 3.2.7 + optionalDependencies: + '@typescript-eslint/parser': 8.44.0(eslint@9.36.0(jiti@2.6.1))(typescript@5.9.2) + eslint: 9.36.0(jiti@2.6.1) + eslint-import-resolver-node: 0.3.9 + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.36.0(jiti@2.6.1)) + transitivePeerDependencies: + - supports-color + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.44.0(eslint@9.36.0(jiti@2.6.1))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.36.0(jiti@2.6.1)): dependencies: debug: 3.2.7 @@ -13683,6 +13708,35 @@ snapshots: - supports-color - typescript + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.44.0(eslint@9.36.0(jiti@2.6.1))(typescript@5.9.2))(eslint-import-resolver-typescript@4.4.4(eslint-plugin-import@2.32.0)(eslint@9.36.0(jiti@2.6.1)))(eslint@9.36.0(jiti@2.6.1)): + dependencies: + '@rtsao/scc': 1.1.0 + array-includes: 3.1.9 + array.prototype.findlastindex: 1.2.6 + array.prototype.flat: 1.3.3 + array.prototype.flatmap: 1.3.3 + debug: 3.2.7 + doctrine: 2.1.0 + eslint: 9.36.0(jiti@2.6.1) + eslint-import-resolver-node: 0.3.9 + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.44.0(eslint@9.36.0(jiti@2.6.1))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.36.0(jiti@2.6.1)) + hasown: 2.0.2 + is-core-module: 2.16.1 + is-glob: 4.0.3 + minimatch: 3.1.2 + object.fromentries: 2.0.8 + object.groupby: 1.0.3 + object.values: 1.2.1 + semver: 6.3.1 + string.prototype.trimend: 1.0.9 + tsconfig-paths: 3.15.0 + optionalDependencies: + '@typescript-eslint/parser': 8.44.0(eslint@9.36.0(jiti@2.6.1))(typescript@5.9.2) + transitivePeerDependencies: + - eslint-import-resolver-typescript + - eslint-import-resolver-webpack + - supports-color + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.44.0(eslint@9.36.0(jiti@2.6.1))(typescript@5.9.2))(eslint-import-resolver-typescript@4.4.4)(eslint@9.36.0(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 diff --git a/src/App.tsx b/src/App.tsx index d1d3d7c..fef0c0e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -19,6 +19,7 @@ import { codeExecutionService } from '@/services/codeExecutionService'; import { clearUserEncryptionData } from '@/lib/encryption'; import { MonacoThemeProvider } from '@/contexts/MonacoThemeContext'; import MainApp from '@/pages/MainApp'; +import PublicNotePage from '@/pages/PublicNotePage'; function AppContent() { const { getToken, isSignedIn } = useAuth(); @@ -46,6 +47,7 @@ function AppContent() { const isSignInPage = window.location.pathname === '/sign-in'; const isSignUpPage = window.location.pathname === '/sign-up'; + const isPublicNotePage = window.location.pathname.startsWith('/p/'); // Check if user wants to force web version const urlParams = new URLSearchParams(window.location.search); @@ -58,10 +60,15 @@ function AppContent() { localStorage.setItem('forceWebVersion', 'true'); } - if (isMobileDevice && !isSignedIn && !forceWeb) { + if (isMobileDevice && !isSignedIn && !forceWeb && !isPublicNotePage) { return ; } + // Public note pages don't require authentication + if (isPublicNotePage) { + return ; + } + if (isSignInPage) { return (
diff --git a/src/components/editor/index.tsx b/src/components/editor/index.tsx index 5b29009..58ee1a2 100644 --- a/src/components/editor/index.tsx +++ b/src/components/editor/index.tsx @@ -12,6 +12,7 @@ import { PanelRightClose, PanelRightOpen, RefreshCw, + Globe, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { ButtonGroup } from '@/components/ui/button-group'; @@ -39,6 +40,7 @@ import { editorStyles } from './config/editor-styles'; import { EmptyState } from '@/components/editor/Editor/EmptyState'; import { Toolbar } from '@/components/editor/Editor/Toolbar'; import MoveNoteModal from '@/components/editor/modals/MoveNoteModal'; +import PublishNoteModal from '@/components/editor/modals/PublishNoteModal'; import FileUpload from '@/components/editor/FileUpload'; import { StatusBar } from '@/components/editor/Editor/StatusBar'; import { useEditorState } from '@/components/editor/hooks/useEditorState'; @@ -101,6 +103,8 @@ interface NoteEditorProps { onUnhideNote: (noteId: string) => void; onRefreshNote?: (noteId: string) => void; onSelectNote?: (note: Note) => void; // Navigate to a linked note + onPublishNote?: (noteId: string, authorName?: string) => Promise; + onUnpublishNote?: (noteId: string) => Promise; userId?: string; isNotesPanelOpen?: boolean; onToggleNotesPanel?: () => void; @@ -128,6 +132,8 @@ export default function Index({ onUnhideNote, onRefreshNote, onSelectNote, + onPublishNote, + onUnpublishNote, userId = 'current-user', isNotesPanelOpen, onToggleNotesPanel, @@ -140,6 +146,7 @@ export default function Index({ onWsDisconnect, }: NoteEditorProps) { const [isMoveModalOpen, setIsMoveModalOpen] = useState(false); + const [isPublishModalOpen, setIsPublishModalOpen] = useState(false); const [showAttachments, setShowAttachments] = useState(false); const [isUploading, setIsUploading] = useState(false); const [uploadProgress, setUploadProgress] = useState(0); @@ -1084,6 +1091,12 @@ export default function Index({ Print + {onPublishNote && onUnpublishNote && ( + setIsPublishModalOpen(true)}> + + {note.isPublished ? 'Manage' : 'Publish'} + + )} onArchiveNote(note.id)}> Archive @@ -1204,6 +1217,16 @@ export default function Index({ noteTitle={note.title} /> )} + + {note && onPublishNote && onUnpublishNote && ( + setIsPublishModalOpen(false)} + note={note} + onPublish={onPublishNote} + onUnpublish={onUnpublishNote} + /> + )}
); diff --git a/src/components/editor/modals/PublishNoteModal.tsx b/src/components/editor/modals/PublishNoteModal.tsx new file mode 100644 index 0000000..164e169 --- /dev/null +++ b/src/components/editor/modals/PublishNoteModal.tsx @@ -0,0 +1,287 @@ +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import { Globe, Copy, Check, X, ExternalLink, Loader2 } from 'lucide-react'; +import * as Dialog from '@radix-ui/react-dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import type { Note } from '@/types/note'; + +interface PublishNoteModalProps { + isOpen: boolean; + onClose: () => void; + note: Note | null; + onPublish: (noteId: string, authorName?: string) => Promise; + onUnpublish: (noteId: string) => Promise; +} + +export default function PublishNoteModal({ + isOpen, + onClose, + note, + onPublish, + onUnpublish, +}: PublishNoteModalProps) { + const [authorName, setAuthorName] = useState(''); + const [isPublishing, setIsPublishing] = useState(false); + const [isUnpublishing, setIsUnpublishing] = useState(false); + const [copied, setCopied] = useState(false); + const [error, setError] = useState(null); + const inputRef = useRef(null); + + const isPublished = note?.isPublished; + const publicUrl = note?.publicSlug + ? `${window.location.origin}/p/${note.publicSlug}` + : null; + + useEffect(() => { + if (isOpen) { + setAuthorName(''); + setError(null); + setCopied(false); + // Focus author name input after modal opens (only for unpublished notes) + if (!isPublished) { + setTimeout(() => { + inputRef.current?.focus(); + }, 100); + } + } + }, [isOpen, isPublished]); + + const handlePublish = useCallback(async () => { + if (!note || isPublishing) return; + + setIsPublishing(true); + setError(null); + + try { + const result = await onPublish(note.id, authorName || undefined); + if (!result) { + setError('Failed to publish note. Please try again.'); + } + } catch (err) { + setError('Failed to publish note. Please try again.'); + console.error('Publish error:', err); + } finally { + setIsPublishing(false); + } + }, [note, authorName, isPublishing, onPublish]); + + const handleUnpublish = useCallback(async () => { + if (!note || isUnpublishing) return; + + setIsUnpublishing(true); + setError(null); + + try { + const success = await onUnpublish(note.id); + if (success) { + onClose(); + } else { + setError('Failed to unpublish note. Please try again.'); + } + } catch (err) { + setError('Failed to unpublish note. Please try again.'); + console.error('Unpublish error:', err); + } finally { + setIsUnpublishing(false); + } + }, [note, isUnpublishing, onUnpublish, onClose]); + + const handleCopyUrl = useCallback(async () => { + if (!publicUrl) return; + + try { + await navigator.clipboard.writeText(publicUrl); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error('Failed to copy:', err); + } + }, [publicUrl]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !isPublished) { + e.preventDefault(); + handlePublish(); + } + }; + + const handleClose = () => { + if (!isPublishing && !isUnpublishing) { + onClose(); + } + }; + + return ( + + + + + {/* Header */} +
+
+ + + {isPublished ? 'Published Note' : 'Publish Note'} + + + {isPublished ? ( + <> + {note?.title || 'Untitled Note'}{' '} + is publicly available. + + ) : ( + <> + Create a public, shareable version of{' '} + {note?.title || 'Untitled Note'}. + + )} + +
+
+ + {/* Content */} +
+ {error && ( +
+ {error} +
+ )} + + {isPublished && publicUrl ? ( + // Published state - show URL and options +
+
+ +
+ + + +
+
+ +
+

+ Changes to your note are automatically synced to the public version + when you save. +

+
+ + {note?.publicUpdatedAt && ( +

+ Last synced: {new Date(note.publicUpdatedAt).toLocaleString()} +

+ )} +
+ ) : ( + // Unpublished state - show publish form +
+
+ + setAuthorName(e.target.value)} + onKeyDown={handleKeyDown} + disabled={isPublishing} + /> +

+ Displayed on the public page. Leave blank to publish anonymously. +

+
+ +
+

+ ⚠️ Warning: Publishing bypasses end-to-end encryption. + An unencrypted copy will be stored on our servers and anyone with the + link can view it. +

+
+
+ )} + + {/* Actions */} +
+ {isPublished ? ( + <> + + + + ) : ( + <> + + + + )} +
+
+ + + + +
+
+
+ ); +} diff --git a/src/components/folders/QuickAccessSection.tsx b/src/components/folders/QuickAccessSection.tsx index 20e5122..f1bb235 100644 --- a/src/components/folders/QuickAccessSection.tsx +++ b/src/components/folders/QuickAccessSection.tsx @@ -12,6 +12,7 @@ interface QuickAccessSectionProps { archivedCount: number; trashedCount: number; hiddenCount: number; + publicCount: number; onViewChange: (view: ViewMode) => void; onFolderSelect: (folder: Folder | null) => void; } @@ -24,6 +25,7 @@ export default function QuickAccessSection({ archivedCount, trashedCount, hiddenCount, + publicCount, onViewChange, onFolderSelect, }: QuickAccessSectionProps) { @@ -34,8 +36,9 @@ export default function QuickAccessSection({ archived: archivedCount, trash: trashedCount, hidden: hiddenCount, + public: publicCount, }), - [notesCount, starredCount, archivedCount, trashedCount, hiddenCount] + [notesCount, starredCount, archivedCount, trashedCount, hiddenCount, publicCount] ); const handleViewSelect = (view: ViewMode) => { diff --git a/src/components/folders/constants.ts b/src/components/folders/constants.ts index 35a3dc1..47dc562 100644 --- a/src/components/folders/constants.ts +++ b/src/components/folders/constants.ts @@ -1,4 +1,4 @@ -import { FileText, Star, Archive, Trash2 } from 'lucide-react'; +import { FileText, Star, Archive, Trash2, Globe } from 'lucide-react'; import type { ViewMode } from '@/types/note'; @@ -28,6 +28,11 @@ export const SPECIAL_VIEWS = [ label: 'Starred', icon: Star, }, + { + id: 'public' as ViewMode, + label: 'Public', + icon: Globe, + }, { id: 'archived' as ViewMode, label: 'Archived', diff --git a/src/components/folders/index.tsx b/src/components/folders/index.tsx index ec5745b..449e014 100644 --- a/src/components/folders/index.tsx +++ b/src/components/folders/index.tsx @@ -34,6 +34,7 @@ interface FolderPanelProps { archivedCount: number; trashedCount: number; hiddenCount: number; + publicCount: number; expandedFolders: Set; onCreateNote: (templateContent?: { title: string; content: string }) => void; onCreateFolder: (name: string, color: string, parentId?: string) => void; @@ -66,6 +67,7 @@ export default function FolderPanel({ archivedCount, trashedCount, hiddenCount, + publicCount, expandedFolders, onCreateFolder, onUpdateFolder, @@ -244,6 +246,7 @@ export default function FolderPanel({ archivedCount={archivedCount} trashedCount={trashedCount} hiddenCount={hiddenCount} + publicCount={publicCount} onViewChange={onViewChange} onFolderSelect={onFolderSelect} /> diff --git a/src/components/layout/MainLayout.tsx b/src/components/layout/MainLayout.tsx index db47ae6..e609dbb 100644 --- a/src/components/layout/MainLayout.tsx +++ b/src/components/layout/MainLayout.tsx @@ -54,6 +54,7 @@ export default function MainLayout() { archivedCount, trashedCount, hiddenCount, + publicCount, expandedFolders, updateFolder, createNote, @@ -68,6 +69,8 @@ export default function MainLayout() { hideNote, hidingNote, unhideNote, + publishNote, + unpublishNote, toggleFolderExpansion, reorderFolders, setSelectedNote, @@ -189,6 +192,7 @@ export default function MainLayout() { title: note.title || 'Untitled', type: note.type || 'note', isDirty: false, + isPublished: note.isPublished, }; setOpenTabs(prev => [...prev, newTab]); setActiveTabId(newTab.id); @@ -207,12 +211,13 @@ export default function MainLayout() { const tab = openTabs.find(t => t.id === tabId); if (tab) { setActiveTabId(tabId); - const note = notes.find(n => n.id === tab.noteId); + // Use allNotes to find notes that might be outside current view/folder + const note = allNotes.find(n => n.id === tab.noteId); if (note) { setSelectedNote(note); } } - }, [openTabs, notes, setSelectedNote]); + }, [openTabs, allNotes, setSelectedNote]); const handleTabClose = useCallback((tabId: string) => { // No warning needed - autosave handles saving changes @@ -225,7 +230,8 @@ export default function MainLayout() { const newActiveTab = newTabs[tabIndex - 1] || newTabs[0]; if (newActiveTab) { setActiveTabId(newActiveTab.id); - const note = notes.find(n => n.id === newActiveTab.noteId); + // Use allNotes to find notes that might be outside current view/folder + const note = allNotes.find(n => n.id === newActiveTab.noteId); if (note) { setSelectedNote(note); } @@ -234,7 +240,7 @@ export default function MainLayout() { setSelectedNote(null); } } - }, [openTabs, activeTabId, notes, setSelectedNote]); + }, [openTabs, activeTabId, allNotes, setSelectedNote]); const handleCloseAllTabs = useCallback(() => { // Close all tabs and clear selection @@ -273,14 +279,14 @@ export default function MainLayout() { ); }, [selectedNote]); - // Update tab title when note title changes + // Update tab properties when note changes useEffect(() => { if (!selectedNote?.id) return; setOpenTabs(tabs => tabs.map(tab => tab.noteId === selectedNote.id - ? { ...tab, title: selectedNote.title || 'Untitled', type: selectedNote.type || 'note' } + ? { ...tab, title: selectedNote.title || 'Untitled', type: selectedNote.type || 'note', isPublished: selectedNote.isPublished } : tab ) ); @@ -353,6 +359,7 @@ export default function MainLayout() { archivedCount, trashedCount, hiddenCount, + publicCount, expandedFolders, onUpdateFolder: async (id, name, color) => { await updateFolder(id, { name, color }); @@ -402,6 +409,8 @@ export default function MainLayout() { onUnhideNote: unhideNote, onRefreshNote: handleRefreshNote, onSelectNote: handleSelectNote, // For navigating to linked notes (opens in new tab) + onPublishNote: publishNote, + onUnpublishNote: unpublishNote, userId, isNotesPanelOpen: filesPanelOpen, onToggleNotesPanel: handleToggleNotesPanel, diff --git a/src/components/layout/TabBar.tsx b/src/components/layout/TabBar.tsx index 0a6ba6b..ccc3515 100644 --- a/src/components/layout/TabBar.tsx +++ b/src/components/layout/TabBar.tsx @@ -1,4 +1,4 @@ -import { X, Network, Code2, ChevronDown } from 'lucide-react'; +import { X, Network, Code2, ChevronDown, Globe } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { DropdownMenu, @@ -15,6 +15,7 @@ export interface Tab { title: string; type: 'note' | 'diagram' | 'code'; isDirty: boolean; + isPublished?: boolean; } interface TabBarProps { @@ -25,15 +26,32 @@ interface TabBarProps { onCloseAll?: () => void; } -const TabIcon = ({ type }: { type: Tab['type'] }) => { - switch (type) { - case 'code': - return ; - case 'diagram': - return ; - default: - return null; +const TabIcon = ({ type, isPublished }: { type: Tab['type']; isPublished?: boolean }) => { + const typeIcon = (() => { + switch (type) { + case 'code': + return ; + case 'diagram': + return ; + default: + return null; + } + })(); + + if (isPublished && typeIcon) { + return ( +
+ {typeIcon} + +
+ ); } + + if (isPublished) { + return ; + } + + return typeIcon; }; export function TabBar({ tabs, activeTabId, onTabClick, onTabClose, onCloseAll }: TabBarProps) { @@ -109,7 +127,7 @@ export function TabBar({ tabs, activeTabId, onTabClick, onTabClose, onCloseAll } `} onClick={() => onTabClick(tab.id)} > - + - + {tab.title || 'Untitled'} diff --git a/src/components/notes/NotesPanel/NoteCard.tsx b/src/components/notes/NotesPanel/NoteCard.tsx index ba4bc91..b4a8be3 100644 --- a/src/components/notes/NotesPanel/NoteCard.tsx +++ b/src/components/notes/NotesPanel/NoteCard.tsx @@ -1,5 +1,5 @@ import { memo, useMemo } from 'react'; -import { Star, Paperclip, Codesandbox, Network, Code2 } from 'lucide-react'; +import { Star, Paperclip, Codesandbox, Network, Code2, Globe } from 'lucide-react'; import type { Note, Folder as FolderType } from '@/types/note.ts'; interface NoteCardProps { @@ -147,6 +147,14 @@ function NoteCard({ )} + {note.isPublished && ( +
+ +
+ )} {hasExecutableCode && (
({ showAttachmentsOnly: false, - showStarredOnly: false, - showHiddenOnly: false, - showDiagramsOnly: false, showCodeOnly: false, + showDiagramsOnly: false, + showHiddenOnly: false, + showPublicOnly: false, + showStarredOnly: false, }); const [isRefreshing, setIsRefreshing] = useState(false); @@ -139,12 +141,13 @@ export default function FilesPanel({ // A note is included if it passes ALL active filters (AND logic) // Exclude if any active filter condition fails const excludeByAttachments = config.showAttachmentsOnly && !hasAttachments; - const excludeByStarred = config.showStarredOnly && !note.starred; - const excludeByHidden = config.showHiddenOnly && !note.hidden; - const excludeByDiagrams = config.showDiagramsOnly && note.type !== 'diagram'; const excludeByCode = config.showCodeOnly && note.type !== 'code'; + const excludeByDiagrams = config.showDiagramsOnly && note.type !== 'diagram'; + const excludeByHidden = config.showHiddenOnly && !note.hidden; + const excludeByPublic = config.showPublicOnly && !note.isPublished; + const excludeByStarred = config.showStarredOnly && !note.starred; - return !(excludeByAttachments || excludeByStarred || excludeByHidden || excludeByDiagrams || excludeByCode); + return !(excludeByAttachments || excludeByCode || excludeByDiagrams || excludeByHidden || excludeByPublic || excludeByStarred); }); }; @@ -166,12 +169,12 @@ export default function FilesPanel({ const getFilterLabel = () => { const activeFilters = []; - if (filterConfig.showAttachmentsOnly) - activeFilters.push('With Attachments'); - if (filterConfig.showStarredOnly) activeFilters.push('Starred'); - if (filterConfig.showHiddenOnly) activeFilters.push('Hidden'); - if (filterConfig.showDiagramsOnly) activeFilters.push('Diagrams'); + if (filterConfig.showAttachmentsOnly) activeFilters.push('Attachments'); if (filterConfig.showCodeOnly) activeFilters.push('Code'); + if (filterConfig.showDiagramsOnly) activeFilters.push('Diagrams'); + if (filterConfig.showHiddenOnly) activeFilters.push('Hidden'); + if (filterConfig.showPublicOnly) activeFilters.push('Public'); + if (filterConfig.showStarredOnly) activeFilters.push('Starred'); if (activeFilters.length === 0) return `Sort: ${getSortLabel()}`; return `Filter: ${activeFilters.join(', ')} | Sort: ${getSortLabel()}`; @@ -179,10 +182,11 @@ export default function FilesPanel({ const hasActiveFilters = filterConfig.showAttachmentsOnly || - filterConfig.showStarredOnly || - filterConfig.showHiddenOnly || + filterConfig.showCodeOnly || filterConfig.showDiagramsOnly || - filterConfig.showCodeOnly; + filterConfig.showHiddenOnly || + filterConfig.showPublicOnly || + filterConfig.showStarredOnly; const getPanelTitle = () => { if (selectedFolder) { @@ -196,6 +200,8 @@ export default function FilesPanel({ return 'Archived Notes'; case 'trash': return 'Trash'; + case 'public': + return 'Public Notes'; default: return 'All Notes'; } @@ -234,6 +240,8 @@ export default function FilesPanel({ return 'No starred notes'; case 'archived': return 'No archived notes'; + case 'public': + return 'No public notes'; default: return 'No notes yet'; } @@ -333,12 +341,23 @@ export default function FilesPanel({ onClick={() => setFilterConfig((prev) => ({ ...prev, - showStarredOnly: !prev.showStarredOnly, + showCodeOnly: !prev.showCodeOnly, })) } - className={`${filterConfig.showStarredOnly ? 'bg-accent' : ''} mb-1`} + className={`${filterConfig.showCodeOnly ? 'bg-accent' : ''} mb-1`} > - {filterConfig.showStarredOnly ? '✓' : '○'} Starred + {filterConfig.showCodeOnly ? '✓' : '○'} Code + + + setFilterConfig((prev) => ({ + ...prev, + showDiagramsOnly: !prev.showDiagramsOnly, + })) + } + className={`${filterConfig.showDiagramsOnly ? 'bg-accent' : ''} mb-1`} + > + {filterConfig.showDiagramsOnly ? '✓' : '○'} Diagrams @@ -355,38 +374,35 @@ export default function FilesPanel({ onClick={() => setFilterConfig((prev) => ({ ...prev, - showDiagramsOnly: !prev.showDiagramsOnly, + showPublicOnly: !prev.showPublicOnly, })) } - className={`${filterConfig.showDiagramsOnly ? 'bg-accent' : ''} mb-1`} + className={`${filterConfig.showPublicOnly ? 'bg-accent' : ''} mb-1`} > - {filterConfig.showDiagramsOnly ? '✓' : '○'} Diagrams + {filterConfig.showPublicOnly ? '✓' : '○'} Public setFilterConfig((prev) => ({ ...prev, - showCodeOnly: !prev.showCodeOnly, + showStarredOnly: !prev.showStarredOnly, })) } - className={`${filterConfig.showCodeOnly ? 'bg-accent' : ''} mb-1`} + className={`${filterConfig.showStarredOnly ? 'bg-accent' : ''} mb-1`} > - {filterConfig.showCodeOnly ? '✓' : '○'} Code + {filterConfig.showStarredOnly ? '✓' : '○'} Starred - {(filterConfig.showAttachmentsOnly || - filterConfig.showStarredOnly || - filterConfig.showHiddenOnly || - filterConfig.showDiagramsOnly || - filterConfig.showCodeOnly) && ( + {hasActiveFilters && ( setFilterConfig({ showAttachmentsOnly: false, - showStarredOnly: false, - showHiddenOnly: false, - showDiagramsOnly: false, showCodeOnly: false, + showDiagramsOnly: false, + showHiddenOnly: false, + showPublicOnly: false, + showStarredOnly: false, }) } className="text-muted-foreground mb-1" diff --git a/src/hooks/useNotes.ts b/src/hooks/useNotes.ts index 1a5ce15..c196d03 100644 --- a/src/hooks/useNotes.ts +++ b/src/hooks/useNotes.ts @@ -1,6 +1,6 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import { useAuth, useUser } from '@clerk/clerk-react'; -import { api, type ApiNote, type ApiFolder } from '@/lib/api/api.ts'; +import { api, type ApiNote, type ApiFolder, type ApiPublicNote } from '@/lib/api/api.ts'; import { fileService } from '@/services/fileService'; import { clearEncryptionKeys, @@ -21,6 +21,11 @@ const convertApiNote = (apiNote: ApiNote): Note => ({ createdAt: new Date(apiNote.createdAt), updatedAt: new Date(apiNote.updatedAt), hiddenAt: apiNote.hiddenAt ? new Date(apiNote.hiddenAt) : null, + // Public note fields + isPublished: apiNote.isPublished ?? false, + publicSlug: apiNote.publicSlug ?? null, + publishedAt: apiNote.publishedAt ? new Date(apiNote.publishedAt) : null, + publicUpdatedAt: apiNote.publicUpdatedAt ? new Date(apiNote.publicUpdatedAt) : null, }); const convertApiFolder = (apiFolder: ApiFolder): Folder => ({ @@ -369,6 +374,7 @@ export function useNotes() { archivedCount, trashedCount, hiddenCount, + publicCount, } = useNotesFiltering({ notes, folders, @@ -493,8 +499,22 @@ export function useNotes() { const updateNote = useCallback( async (noteId: string, updates: Partial) => { await restNotesOperations.updateNote(noteId, updates); + + // 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); + if (note?.isPublished && note?.publicSlug) { + // Sync in background without blocking the save + void api.updatePublicNote(note.publicSlug, { + title: updates.title ?? note.title, + content: updates.content ?? note.content, + }).catch(error => { + secureLogger.warn('Failed to sync public note', { noteId, error }); + }); + } + } }, - [restNotesOperations] + [restNotesOperations, notes] ); const deleteNote = async (noteId: string) => { @@ -595,6 +615,144 @@ export function useNotes() { await restNotesOperations.moveNoteToFolder(noteId, folderId); }; + // ===== Public Notes Functions ===== + + const publishNote = useCallback(async ( + noteId: string, + authorName?: string + ): Promise => { + // Find the note to publish + const note = notes.find(n => n.id === noteId); + if (!note) { + setError('Note not found'); + return null; + } + + try { + // Publish the note (content is already decrypted in memory) + const publicNote = await api.publishNote({ + noteId: note.id, + title: note.title, + content: note.content, + type: note.type, + authorName, + }); + + // Update local state with publish info using functional updates + setNotes(prev => prev.map(n => { + if (n.id === noteId) { + return { + ...n, + isPublished: true, + publicSlug: publicNote.slug, + publishedAt: new Date(publicNote.publishedAt), + publicUpdatedAt: new Date(publicNote.updatedAt), + }; + } + return n; + })); + + setSelectedNote(prev => { + if (prev?.id === noteId) { + return { + ...prev, + isPublished: true, + publicSlug: publicNote.slug, + publishedAt: new Date(publicNote.publishedAt), + publicUpdatedAt: new Date(publicNote.updatedAt), + }; + } + return prev; + }); + + return publicNote; + } catch (error) { + secureLogger.error('Failed to publish note', error); + setError('Failed to publish note'); + return null; + } + }, [notes, setNotes, setSelectedNote, setError]); + + const unpublishNote = useCallback(async (noteId: string): Promise => { + // Find the note to unpublish (for getting the publicSlug) + const note = notes.find(n => n.id === noteId); + if (!note || !note.publicSlug) { + setError('Note not found or not published'); + return false; + } + + try { + await api.unpublishNote(note.publicSlug); + + // Update local state using functional updates to ensure fresh state + setNotes(prev => prev.map(n => { + if (n.id === noteId) { + return { + ...n, + isPublished: false, + publicSlug: null, + publishedAt: null, + publicUpdatedAt: null, + }; + } + return n; + })); + + // Update selectedNote if it's the one being unpublished + setSelectedNote(prev => { + if (prev?.id === noteId) { + return { + ...prev, + isPublished: false, + publicSlug: null, + publishedAt: null, + publicUpdatedAt: null, + }; + } + return prev; + }); + + return true; + } catch (error) { + secureLogger.error('Failed to unpublish note', error); + setError('Failed to unpublish note'); + return false; + } + }, [notes, setNotes, setSelectedNote, setError]); + + const syncPublicNote = useCallback(async (noteId: string): Promise => { + // Find the note to sync + const note = notes.find(n => n.id === noteId); + if (!note || !note.publicSlug || !note.isPublished) { + return false; // Not published, nothing to sync + } + + try { + const publicNote = await api.updatePublicNote(note.publicSlug, { + title: note.title, + content: note.content, + }); + + // Update local state with new sync time + const updatedNote: Note = { + ...note, + publicUpdatedAt: new Date(publicNote.updatedAt), + }; + + setNotes(prev => prev.map(n => n.id === noteId ? updatedNote : n)); + + if (selectedNote?.id === noteId) { + setSelectedNote(updatedNote); + } + + return true; + } catch (error) { + secureLogger.error('Failed to sync public note', error); + // Don't set error state for background sync failures + return false; + } + }, [notes, selectedNote, setNotes, setSelectedNote]); + return { notes: filteredNotes, allNotes: notes, // Unfiltered notes for note linking feature @@ -612,6 +770,7 @@ export function useNotes() { archivedCount, trashedCount, hiddenCount, + publicCount, createNote: notesOpsCreateNote, creatingNote, createFolder, @@ -630,6 +789,9 @@ export function useNotes() { permanentlyDeleteNote, moveNoteToFolder, toggleFolderExpansion, + publishNote, + unpublishNote, + syncPublicNote, setSelectedNote, setSelectedFolder, setCurrentView, diff --git a/src/hooks/useNotesFiltering.ts b/src/hooks/useNotesFiltering.ts index 00146ec..081d01b 100644 --- a/src/hooks/useNotesFiltering.ts +++ b/src/hooks/useNotesFiltering.ts @@ -51,6 +51,8 @@ export function useNotesFiltering({ return note.deleted; case 'hidden': return note.hidden && !note.deleted; + case 'public': + return note.isPublished && !note.deleted && !note.archived; default: return !note.deleted && !note.archived; } @@ -64,6 +66,7 @@ export function useNotesFiltering({ if (!note.deleted && !note.archived) { acc.notesCount++; if (note.starred) acc.starredCount++; + if (note.isPublished) acc.publicCount++; } if (note.archived && !note.deleted) acc.archivedCount++; if (note.deleted) acc.trashedCount++; @@ -76,6 +79,7 @@ export function useNotesFiltering({ archivedCount: 0, trashedCount: 0, hiddenCount: 0, + publicCount: 0, } ); }, [notes]); diff --git a/src/lib/api/api.ts b/src/lib/api/api.ts index 78a7ad3..0e56281 100644 --- a/src/lib/api/api.ts +++ b/src/lib/api/api.ts @@ -39,6 +39,11 @@ export interface ApiNote { updatedAt: string; attachmentCount?: number; type?: 'note' | 'diagram'; + // Public note fields (from JOIN with public_notes table) + isPublished?: boolean; + publicSlug?: string | null; + publishedAt?: string | null; + publicUpdatedAt?: string | null; } export interface ApiFolder { @@ -69,6 +74,19 @@ export interface ApiUserUsage { }; } +export interface ApiPublicNote { + id: string; + slug: string; + noteId: string; + userId: string; + title: string; + content: string; // Plaintext HTML (not encrypted) + type?: 'note' | 'diagram' | 'code'; + authorName?: string; + publishedAt: string; + updatedAt: string; +} + export interface ApiUser { id: string; email: string; @@ -640,6 +658,73 @@ class ClerkEncryptedApiService { } ); } + + // ===== Public Notes API ===== + // These methods handle unencrypted public versions of notes + + /** + * Publish a note - creates a public, unencrypted copy + * The client must decrypt the note first and send plaintext content + */ + async publishNote(data: { + noteId: string; + title: string; + content: string; + type?: 'note' | 'diagram' | 'code'; + authorName?: string; + }): Promise { + return this.request('/public-notes', { + method: 'POST', + body: JSON.stringify(data), + }); + } + + /** + * Update a published note's content + * Called automatically when the original note is saved + */ + async updatePublicNote( + slug: string, + updates: { + title?: string; + content?: string; + authorName?: string; + } + ): Promise { + return this.request(`/public-notes/${slug}`, { + method: 'PUT', + body: JSON.stringify(updates), + }); + } + + /** + * Unpublish a note - removes the public copy + */ + async unpublishNote(slug: string): Promise<{ message: string }> { + return this.request<{ message: string }>(`/public-notes/${slug}`, { + method: 'DELETE', + }); + } + + /** + * Get a public note by slug (no auth required on backend) + * This is used for the public viewer page + */ + async getPublicNote(slug: string): Promise { + return this.request(`/public-notes/${slug}`); + } + + /** + * Check if a note is published and get its public info + */ + async getPublicNoteByNoteId(noteId: string): Promise { + try { + return await this.request(`/public-notes/note/${noteId}`); + } catch { + // Note is not published + return null; + } + } } export const api = new ClerkEncryptedApiService(); diff --git a/src/pages/PublicNotePage.tsx b/src/pages/PublicNotePage.tsx new file mode 100644 index 0000000..42e907c --- /dev/null +++ b/src/pages/PublicNotePage.tsx @@ -0,0 +1,1011 @@ +import { useEffect, useState, useRef } from 'react'; +import { Calendar, User, AlertCircle, Sun, Moon, ArrowUp } from 'lucide-react'; +import type { ApiPublicNote } from '@/lib/api/api'; +import DOMPurify from 'dompurify'; +import hljs from 'highlight.js/lib/core'; +import javascript from 'highlight.js/lib/languages/javascript'; +import typescript from 'highlight.js/lib/languages/typescript'; +import python from 'highlight.js/lib/languages/python'; +import css from 'highlight.js/lib/languages/css'; +import html from 'highlight.js/lib/languages/xml'; +import json from 'highlight.js/lib/languages/json'; +import bash from 'highlight.js/lib/languages/bash'; +import sql from 'highlight.js/lib/languages/sql'; +import java from 'highlight.js/lib/languages/java'; +import cpp from 'highlight.js/lib/languages/cpp'; +import csharp from 'highlight.js/lib/languages/csharp'; +import php from 'highlight.js/lib/languages/php'; +import markdown from 'highlight.js/lib/languages/markdown'; + +// Register languages +hljs.registerLanguage('javascript', javascript); +hljs.registerLanguage('js', javascript); +hljs.registerLanguage('typescript', typescript); +hljs.registerLanguage('ts', typescript); +hljs.registerLanguage('python', python); +hljs.registerLanguage('css', css); +hljs.registerLanguage('html', html); +hljs.registerLanguage('xml', html); +hljs.registerLanguage('json', json); +hljs.registerLanguage('bash', bash); +hljs.registerLanguage('shell', bash); +hljs.registerLanguage('sql', sql); +hljs.registerLanguage('java', java); +hljs.registerLanguage('cpp', cpp); +hljs.registerLanguage('csharp', csharp); +hljs.registerLanguage('php', php); +hljs.registerLanguage('markdown', markdown); + +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; + +// TipTap content styles for proper rendering +const tiptapContentStyles = ` +.tiptap-content { + line-height: 1.6; + font-size: 15px; + color: inherit; +} + +@media (min-width: 640px) { + .tiptap-content { + font-size: 16px; + } +} + +.tiptap-content h1 { + font-size: 2em; + font-weight: bold; + margin: 24px 0 12px 0; + line-height: 1.2; +} + +.tiptap-content h2 { + font-size: 1.5em; + font-weight: bold; + margin: 20px 0 10px 0; + line-height: 1.3; +} + +.tiptap-content h3 { + font-size: 1.25em; + font-weight: bold; + margin: 16px 0 8px 0; + line-height: 1.4; +} + +.tiptap-content p { + margin: 12px 0; +} + +/* Line breaks */ +.tiptap-content br { + display: block; + content: ""; + margin-top: 0.5em; +} + +.tiptap-content p:empty::before { + content: "\\00a0"; +} + +.tiptap-content ul, +.tiptap-content ol { + margin: 12px 0; + padding-left: 24px; +} + +.tiptap-content li { + margin: 6px 0; +} + +.tiptap-content ul { + list-style-type: disc; +} + +.tiptap-content ol { + list-style-type: decimal; +} + +/* Highlight styles */ +.tiptap-content mark { + background-color: #fef08a; + padding: 1px 4px; + border-radius: 2px; +} + +.dark .tiptap-content mark { + background-color: #854d0e; + color: #fef9c3; +} + +/* Task List Styles */ +.tiptap-content .task-list, +.tiptap-content ul[data-type="taskList"] { + list-style: none; + padding-left: 0; + margin: 12px 0; +} + +.tiptap-content .task-item, +.tiptap-content li[data-type="taskItem"] { + display: flex; + align-items: flex-start; + margin: 6px 0; + padding-left: 0; + line-height: 1.6; +} + +.tiptap-content .task-item > label, +.tiptap-content li[data-type="taskItem"] > label { + margin-right: 8px; + margin-top: 2px; + cursor: default; + user-select: none; + display: flex; + align-items: center; +} + +.tiptap-content .task-item > label input[type="checkbox"], +.tiptap-content li[data-type="taskItem"] > label input[type="checkbox"] { + margin: 0; + width: 16px; + height: 16px; + accent-color: #3b82f6; +} + +.tiptap-content .task-item > div, +.tiptap-content li[data-type="taskItem"] > div { + flex: 1; + min-width: 0; +} + +.tiptap-content .task-item[data-checked="true"] > div, +.tiptap-content li[data-type="taskItem"][data-checked="true"] > div { + text-decoration: line-through; + opacity: 0.6; +} + +/* Blockquote */ +.tiptap-content blockquote { + margin: 16px 0; + padding-left: 16px; + border-left: 4px solid #d1d5db; + font-style: italic; + color: #6b7280; +} + +.dark .tiptap-content blockquote { + border-left-color: #4b5563; + color: #9ca3af; +} + +/* Inline code */ +.tiptap-content code:not(pre code) { + background-color: #f3f4f6; + color: #1f2937; + padding: 2px 6px; + border-radius: 4px; + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + font-size: 0.9em; +} + +.dark .tiptap-content code:not(pre code) { + background-color: #374151; + color: #e5e7eb; +} + +/* Code Block Styles */ +.tiptap-content pre { + background-color: #ffffff; + border: 1px solid #e5e5e5; + border-radius: 8px; + margin: 16px 0; + overflow: hidden; + position: relative; + padding: 0; +} + +.dark .tiptap-content pre { + background-color: #1f2937; + border-color: #374151; +} + +/* Code block header with macOS-style dots */ +.tiptap-content pre::before { + content: attr(data-language); + display: block; + background: #f5f5f5; + border-bottom: 1px solid #e5e5e5; + padding: 8px 12px 8px 50px; + font-size: 0.7rem; + font-weight: 500; + color: #666666; + text-transform: capitalize; + font-family: system-ui, -apple-system, sans-serif; + position: relative; + min-height: 32px; + line-height: 16px; +} + +@media (min-width: 640px) { + .tiptap-content pre::before { + padding: 8px 16px 8px 60px; + font-size: 0.75rem; + min-height: 36px; + line-height: 20px; + } +} + +.dark .tiptap-content pre::before { + background: #374151; + border-bottom-color: #4b5563; + color: #9ca3af; +} + +/* macOS-style colored dots */ +.tiptap-content pre::after { + content: ""; + position: absolute; + top: 11px; + left: 12px; + width: 10px; + height: 10px; + border-radius: 50%; + background: #ef4444; + box-shadow: 15px 0 0 #f59e0b, 30px 0 0 #22c55e; +} + +@media (min-width: 640px) { + .tiptap-content pre::after { + top: 12px; + left: 16px; + width: 12px; + height: 12px; + box-shadow: 18px 0 0 #f59e0b, 36px 0 0 #22c55e; + } +} + +.tiptap-content pre code { + display: block; + background: transparent; + padding: 12px; + border-radius: 0; + color: #374151; + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + font-size: 0.8rem; + line-height: 1.5; + overflow-x: auto; + margin: 0; + white-space: pre; +} + +@media (min-width: 640px) { + .tiptap-content pre code { + padding: 16px; + font-size: 0.875rem; + line-height: 1.6; + } +} + +.dark .tiptap-content pre code { + color: #e5e7eb; +} + +/* Syntax highlighting */ +.tiptap-content pre .hljs-comment, +.tiptap-content pre .hljs-quote { + color: #6b7280; + font-style: italic; +} + +.tiptap-content pre .hljs-keyword, +.tiptap-content pre .hljs-selector-tag, +.tiptap-content pre .hljs-title { + color: #7c3aed; + font-weight: 600; +} + +.tiptap-content pre .hljs-string, +.tiptap-content pre .hljs-attr { + color: #059669; +} + +.tiptap-content pre .hljs-number, +.tiptap-content pre .hljs-literal { + color: #d97706; +} + +.tiptap-content pre .hljs-function, +.tiptap-content pre .hljs-title.function_ { + color: #2563eb; +} + +.tiptap-content pre .hljs-variable, +.tiptap-content pre .hljs-template-variable { + color: #dc2626; +} + +.tiptap-content pre .hljs-built_in, +.tiptap-content pre .hljs-type { + color: #7c2d12; +} + +.tiptap-content pre .hljs-operator, +.tiptap-content pre .hljs-punctuation { + color: #6b7280; +} + +/* Links */ +.tiptap-content a { + color: #2563eb; + text-decoration: underline; +} + +.tiptap-content a:hover { + color: #1d4ed8; +} + +.dark .tiptap-content a { + color: #60a5fa; +} + +.dark .tiptap-content a:hover { + color: #93c5fd; +} + +/* Note links */ +.tiptap-content a[data-note-link] { + color: #8b5cf6; + text-decoration: none; + background-color: #f5f3ff; + padding: 1px 4px; + border-radius: 4px; +} + +.dark .tiptap-content a[data-note-link] { + color: #a78bfa; + background-color: #1e1b4b; +} + +/* Horizontal rule */ +.tiptap-content hr { + border: none; + border-top: 1px solid #d1d5db; + margin: 24px 0; +} + +.dark .tiptap-content hr { + border-top-color: #4b5563; +} + +/* Images */ +.tiptap-content img { + max-width: 100%; + height: auto; + border-radius: 8px; + margin: 16px 0; + display: block; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +/* Strong and emphasis */ +.tiptap-content strong { + font-weight: bold; +} + +.tiptap-content em { + font-style: italic; +} + +/* Strikethrough */ +.tiptap-content s { + text-decoration: line-through; +} + +/* Underline */ +.tiptap-content u { + text-decoration: underline; +} + +/* Table of Contents Styles */ +.tiptap-content .toc-container { + background-color: #f9fafb; + border: 1px solid #e5e7eb; + border-radius: 8px; + padding: 16px; + margin: 16px 0; +} + +.dark .tiptap-content .toc-container { + background-color: #1f2937; + border-color: #374151; +} + +.tiptap-content .toc-header { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + background: none; + border: none; + cursor: pointer; + padding: 0; + margin: 0; + text-align: left; + font-family: inherit; +} + +.tiptap-content .toc-header.toc-toggle { + padding-bottom: 0; + border-bottom: none; + margin-bottom: 0; +} + +.tiptap-content .toc-container:not(.toc-collapsed) .toc-header.toc-toggle { + margin-bottom: 12px; + padding-bottom: 8px; + border-bottom: 1px solid #e5e7eb; +} + +.dark .tiptap-content .toc-container:not(.toc-collapsed) .toc-header.toc-toggle { + border-bottom-color: #374151; +} + +.tiptap-content .toc-header-left { + display: flex; + align-items: center; + gap: 8px; +} + +.tiptap-content .toc-chevron { + transition: transform 0.2s ease; + color: #6b7280; +} + +.dark .tiptap-content .toc-chevron { + color: #9ca3af; +} + +.tiptap-content .toc-container:not(.toc-collapsed) .toc-chevron { + transform: rotate(90deg); +} + +/* Collapsed state - hide items */ +.tiptap-content .toc-container.toc-collapsed .toc-items { + display: none !important; +} + +/* Expanded state - show items */ +.tiptap-content .toc-container:not(.toc-collapsed) .toc-items { + display: flex !important; +} + +.tiptap-content .toc-title { + font-size: 0.875rem; + font-weight: 600; + color: #111827; +} + +.dark .tiptap-content .toc-title { + color: #f3f4f6; +} + +.tiptap-content .toc-count { + font-size: 0.75rem; + color: #6b7280; +} + +.dark .tiptap-content .toc-count { + color: #9ca3af; +} + +.tiptap-content .toc-items { + display: flex; + flex-direction: column; + gap: 4px; +} + +.tiptap-content .toc-item { + display: flex; + align-items: center; + padding: 6px 8px; + border-radius: 4px; + text-decoration: none; + color: #374151; + font-size: 0.875rem; + transition: background-color 0.15s; +} + +.tiptap-content .toc-item:hover { + background-color: #f3f4f6; +} + +.dark .tiptap-content .toc-item { + color: #e5e7eb; +} + +.dark .tiptap-content .toc-item:hover { + background-color: #374151; +} + +.tiptap-content .toc-item.toc-level-2 { + padding-left: 24px; +} + +.tiptap-content .toc-item.toc-level-3 { + padding-left: 40px; + color: #6b7280; +} + +.dark .tiptap-content .toc-item.toc-level-3 { + color: #9ca3af; +} + +.tiptap-content .toc-bullet { + color: #9ca3af; + margin-right: 4px; +} + +.tiptap-content .toc-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tiptap-content .toc-empty { + background-color: #f9fafb; +} + +.dark .tiptap-content .toc-empty { + background-color: #1f2937; +} + +.tiptap-content .toc-empty-message { + display: flex; + align-items: center; + gap: 8px; + color: #6b7280; + font-size: 0.875rem; +} + +.dark .tiptap-content .toc-empty-message { + color: #9ca3af; +} +`; + +async function fetchPublicNote(slug: string): Promise { + const url = `${VITE_API_URL.replace(/\/$/, '')}/public-notes/${slug}`; + const response = await fetch(url); + + if (!response.ok) { + if (response.status === 404) { + throw new Error('Note not found'); + } + throw new Error('Failed to load note'); + } + + return response.json(); +} + +// Get initial theme from system preference +const getSystemTheme = (): Theme => { + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; +}; + +export default function PublicNotePage() { + const [note, setNote] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [theme, setTheme] = useState(getSystemTheme); + const [showScrollTop, setShowScrollTop] = useState(false); + const [processedContent, setProcessedContent] = useState(''); + const contentRef = useRef(null); + + // Get slug from URL + const slug = window.location.pathname.split('/p/')[1]; + + // Apply theme to document + useEffect(() => { + document.documentElement.classList.toggle('dark', theme === 'dark'); + }, [theme]); + + // Show/hide scroll to top button based on scroll position + useEffect(() => { + const handleScroll = () => { + setShowScrollTop(window.scrollY > 400); + }; + + window.addEventListener('scroll', handleScroll, { passive: true }); + return () => window.removeEventListener('scroll', handleScroll); + }, []); + + const scrollToTop = () => { + window.scrollTo({ top: 0, behavior: 'smooth' }); + }; + + // Fetch note + useEffect(() => { + if (!slug) { + setError('Invalid note URL'); + setLoading(false); + return; + } + + fetchPublicNote(slug) + .then((data) => { + setNote(data); + setLoading(false); + }) + .catch((err) => { + setError(err.message || 'Failed to load note'); + setLoading(false); + }); + }, [slug]); + + // Process content: add heading IDs, generate TOC, and sanitize sensitive data + useEffect(() => { + if (!note?.content) { + setProcessedContent(''); + return; + } + + let content = note.content; + + // SECURITY: Strip internal note IDs from note links + // Convert note links to plain styled text without exposing internal IDs + content = content.replace(/data-note-id="[^"]*"/gi, ''); + + // Also remove any other potential data attributes that might leak info + content = content.replace(/data-note-link="[^"]*"/gi, ''); + + // Parse headings and add IDs + const headingRegex = /<(h[1-3])([^>]*)>(.*?)<\/\1>/gi; + const headings: Array<{ level: number; text: string; id: string }> = []; + let headingIndex = 0; + + content = content.replace(headingRegex, (match, tag, attrs, text) => { + const level = parseInt(tag.charAt(1)); + const id = `heading-${headingIndex}`; + const plainText = text.replace(/<[^>]*>/g, ''); // Strip HTML tags from heading text + headings.push({ level, text: plainText, id }); + headingIndex++; + return `<${tag}${attrs} id="${id}">${text}`; + }); + + // Generate TOC HTML for any [data-toc] placeholders + if (content.includes('data-toc')) { + let tocHtml: string; + + if (headings.length === 0) { + tocHtml = ` +
+
+ No headings found in this document. +
+
+ `; + } else { + const tocItems = headings.map(({ level, text, id }) => { + const indent = level === 1 ? '' : level === 2 ? 'toc-level-2' : 'toc-level-3'; + const bullet = level === 1 ? '' : level === 2 ? '• ' : '◦ '; + return ` + + ${bullet} + ${text} + + `; + }).join(''); + + // Default to collapsed state + tocHtml = ` +
+ +
+ ${tocItems} +
+
+ `; + } + + // Replace all data-toc placeholders (handles both self-closing and with content) + content = content.replace(/]*data-toc[^>]*>[\s\S]*?<\/div>/gi, tocHtml); + } + + // SECURITY: Sanitize HTML to prevent XSS attacks + // Configure DOMPurify to allow safe HTML elements and attributes + const sanitizedContent = DOMPurify.sanitize(content, { + ADD_TAGS: ['style'], // Allow inline styles from TipTap + ADD_ATTR: [ + 'data-toc', + 'data-toc-interactive', + 'data-type', + 'data-checked', + 'data-language', + 'data-note-link', // Allow note link styling (IDs already stripped) + 'target', // For links opening in new tabs + 'rel', // For security attributes on links + ], + ALLOW_DATA_ATTR: false, // Disable all data-* except those explicitly added + }); + + setProcessedContent(sanitizedContent); + }, [note?.content]); + + // SEO: Update document title and meta tags + useEffect(() => { + if (!note) { + document.title = 'Typelets - Shared Note'; + return; + } + + // Set page title + const title = note.title ? `${note.title} - Typelets` : 'Typelets - Shared Note'; + document.title = title; + + // Set meta description + const description = note.content + ? note.content.replace(/<[^>]*>/g, '').slice(0, 160).trim() + '...' + : 'A note shared via Typelets'; + + let metaDescription = document.querySelector('meta[name="description"]'); + if (!metaDescription) { + metaDescription = document.createElement('meta'); + metaDescription.setAttribute('name', 'description'); + document.head.appendChild(metaDescription); + } + metaDescription.setAttribute('content', description); + + // Open Graph tags + const ogTags = [ + { property: 'og:title', content: title }, + { property: 'og:description', content: description }, + { property: 'og:type', content: 'article' }, + { property: 'og:url', content: window.location.href }, + { property: 'og:site_name', content: 'Typelets' }, + ]; + + ogTags.forEach(({ property, content }) => { + let meta = document.querySelector(`meta[property="${property}"]`); + if (!meta) { + meta = document.createElement('meta'); + meta.setAttribute('property', property); + document.head.appendChild(meta); + } + meta.setAttribute('content', content); + }); + + // Twitter Card tags + const twitterTags = [ + { name: 'twitter:card', content: 'summary' }, + { name: 'twitter:title', content: title }, + { name: 'twitter:description', content: description }, + ]; + + twitterTags.forEach(({ name, content }) => { + let meta = document.querySelector(`meta[name="${name}"]`); + if (!meta) { + meta = document.createElement('meta'); + meta.setAttribute('name', name); + document.head.appendChild(meta); + } + meta.setAttribute('content', content); + }); + + // Author meta if available + if (note.authorName) { + let metaAuthor = document.querySelector('meta[name="author"]'); + if (!metaAuthor) { + metaAuthor = document.createElement('meta'); + metaAuthor.setAttribute('name', 'author'); + document.head.appendChild(metaAuthor); + } + metaAuthor.setAttribute('content', note.authorName); + } + }, [note]); + + // Apply syntax highlighting and setup TOC toggle after content loads + useEffect(() => { + if (!processedContent || !contentRef.current) return; + + // Find all code blocks and highlight them + const codeBlocks = contentRef.current.querySelectorAll('pre code'); + codeBlocks.forEach((block) => { + // Skip if already highlighted + if (block.classList.contains('hljs')) return; + + // Get language from class or parent's data attribute + const pre = block.parentElement; + let language = pre?.getAttribute('data-language') || ''; + + // Also check for language- class + const classMatch = block.className.match(/language-(\w+)/); + if (classMatch) { + language = classMatch[1]; + } + + if (language && hljs.getLanguage(language)) { + const result = hljs.highlight(block.textContent || '', { language }); + block.innerHTML = result.value; + block.classList.add('hljs'); + } else { + // Auto-detect language + const result = hljs.highlightAuto(block.textContent || ''); + block.innerHTML = result.value; + block.classList.add('hljs'); + // Set detected language on pre element + if (result.language && pre) { + pre.setAttribute('data-language', result.language); + } + } + }); + + }, [processedContent]); + + // Setup TOC toggle using event delegation + useEffect(() => { + const container = contentRef.current; + if (!container) return; + + const handleClick = (e: MouseEvent) => { + const target = e.target as HTMLElement; + + // Check if click is within TOC header area + const toggleBtn = target.closest('.toc-toggle') || target.closest('.toc-header'); + + if (toggleBtn) { + e.preventDefault(); + e.stopPropagation(); + + const tocContainer = toggleBtn.closest('.toc-container'); + + if (tocContainer) { + tocContainer.classList.toggle('toc-collapsed'); + } + } + }; + + container.addEventListener('click', handleClick); + return () => container.removeEventListener('click', handleClick); + }, [processedContent]); + + const toggleTheme = () => { + setTheme((current) => current === 'light' ? 'dark' : 'light'); + }; + + const ThemeIcon = () => { + return theme === 'light' ? : ; + }; + + const formatDate = (dateString: string) => { + try { + return new Intl.DateTimeFormat('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }).format(new Date(dateString)); + } catch { + return 'Unknown date'; + } + }; + + if (loading) { + return ( +
+
+
+

Loading note...

+
+
+ ); + } + + if (error || !note) { + return ( +
+
+ +

+ {error === 'Note not found' ? 'Note Not Found' : 'Error Loading Note'} +

+

+ {error === 'Note not found' + ? 'This note may have been unpublished or the link is incorrect.' + : 'Something went wrong while loading this note. Please try again later.'} +

+
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+ + Typelets Shared Note + + +
+
+ + {/* Content */} +
+
+ {/* Title */} +

+ {note.title || 'Untitled Note'} +

+ + {/* Meta */} +
+ {note.authorName && ( +
+ + {note.authorName} +
+ )} +
+ + Published {formatDate(note.publishedAt)} +
+ {note.updatedAt !== note.publishedAt && ( + + (Updated {formatDate(note.updatedAt)}) + + )} +
+ + {/* Content */} +
+
+
+ + {/* TipTap content styles */} +