From ef302ebda9885056ef805d38f519e5adfaa80a84 Mon Sep 17 00:00:00 2001 From: "C. Cassel" Date: Thu, 19 Mar 2026 16:21:04 -0400 Subject: [PATCH] feat: add auth, real-time collaboration, sharing, font control, and UI fixes Phase 1 - Bug fixes: - Fix color labels not showing on active line in format preview - Replace eye emoji with SVG icon showing clear preview/raw state - Replace // button with comment icon + better tooltip - Fix ThemePicker accent colors when using system theme Phase 2 - Font: - Load JetBrains Mono via Google Fonts with offline fallback - Add font size control (A-/A+) with keyboard shortcuts - Persist font size preference in localStorage Phase 3 - Auth: - Supabase-based email/password authentication - Device session management with configurable password renewal TTL - AuthModal, UserMenu, SecuritySettings components Phase 4 - Cloud sync: - Document metadata sync to Supabase PostgreSQL - Legacy localStorage migration on first login - IndexedDB persistence via y-indexeddb Phase 5 - Real-time collaboration: - Y.js CRDT integration with CodeMirror 6 - Hocuspocus WebSocket server with JWT auth - Collaborative cursor awareness - CollabIndicator component Phase 6 - Sharing: - Share links with view/edit permissions - ShareDialog component with copy-to-clipboard - Minimal client-side router for /s/{token} URLs Infrastructure: - Docker Compose with PostgreSQL, GoTrue, PostgREST, Hocuspocus - Nginx reverse proxy for all backend services - SQL migrations with RLS policies - Production-ready Dockerfile with build args Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 6 + Dockerfile | 10 + calcpad-web/.env.example | 3 + calcpad-web/.gitignore | 1 + calcpad-web/index.html | 6 + calcpad-web/package.json | 8 +- calcpad-web/src/App.tsx | 60 +++++- calcpad-web/src/auth/AuthModal.tsx | 125 ++++++++++++ calcpad-web/src/auth/AuthProvider.tsx | 115 +++++++++++ calcpad-web/src/auth/DeviceSession.ts | 145 ++++++++++++++ calcpad-web/src/auth/supabase.ts | 25 +++ calcpad-web/src/collab/CollabProvider.tsx | 75 ++++++++ calcpad-web/src/collab/awareness.ts | 27 +++ .../src/collab/useWebSocketProvider.ts | 86 +++++++++ calcpad-web/src/collab/useYDoc.ts | 68 +++++++ .../src/components/CollabIndicator.tsx | 19 ++ .../src/components/FontSizeControl.tsx | 34 ++++ calcpad-web/src/components/FormatToolbar.tsx | 14 +- .../src/components/SecuritySettings.tsx | 80 ++++++++ calcpad-web/src/components/ThemePicker.tsx | 12 +- calcpad-web/src/components/UserMenu.tsx | 81 ++++++++ calcpad-web/src/editor/CalcEditor.tsx | 4 +- calcpad-web/src/editor/format-preview.ts | 39 ++-- calcpad-web/src/hooks/useFontSize.ts | 39 ++++ calcpad-web/src/main.tsx | 5 +- calcpad-web/src/router/routes.ts | 19 ++ calcpad-web/src/router/useRoute.ts | 22 +++ calcpad-web/src/sharing/ShareDialog.tsx | 110 +++++++++++ calcpad-web/src/sharing/useShareToken.ts | 70 +++++++ calcpad-web/src/styles/auth.css | 159 +++++++++++++++ calcpad-web/src/styles/collab-indicator.css | 39 ++++ calcpad-web/src/styles/font-size-control.css | 57 ++++++ calcpad-web/src/styles/format-toolbar.css | 15 ++ calcpad-web/src/styles/index.css | 4 +- calcpad-web/src/styles/results-panel.css | 4 +- calcpad-web/src/styles/share-dialog.css | 181 ++++++++++++++++++ calcpad-web/src/styles/user-menu.css | 109 +++++++++++ calcpad-web/src/sync/migrateLegacy.ts | 90 +++++++++ calcpad-web/src/sync/useCloudSync.ts | 103 ++++++++++ calcpad-web/vite.config.ts | 11 ++ collab-server/Dockerfile | 13 ++ collab-server/package.json | 24 +++ collab-server/src/auth.ts | 24 +++ collab-server/src/index.ts | 50 +++++ collab-server/src/storage.ts | 51 +++++ collab-server/tsconfig.json | 13 ++ docker-compose.yml | 84 +++++++- nginx.conf | 31 +++ supabase/migrations/001_create_schema.sql | 165 ++++++++++++++++ 49 files changed, 2499 insertions(+), 36 deletions(-) create mode 100644 calcpad-web/.env.example create mode 100644 calcpad-web/src/auth/AuthModal.tsx create mode 100644 calcpad-web/src/auth/AuthProvider.tsx create mode 100644 calcpad-web/src/auth/DeviceSession.ts create mode 100644 calcpad-web/src/auth/supabase.ts create mode 100644 calcpad-web/src/collab/CollabProvider.tsx create mode 100644 calcpad-web/src/collab/awareness.ts create mode 100644 calcpad-web/src/collab/useWebSocketProvider.ts create mode 100644 calcpad-web/src/collab/useYDoc.ts create mode 100644 calcpad-web/src/components/CollabIndicator.tsx create mode 100644 calcpad-web/src/components/FontSizeControl.tsx create mode 100644 calcpad-web/src/components/SecuritySettings.tsx create mode 100644 calcpad-web/src/components/UserMenu.tsx create mode 100644 calcpad-web/src/hooks/useFontSize.ts create mode 100644 calcpad-web/src/router/routes.ts create mode 100644 calcpad-web/src/router/useRoute.ts create mode 100644 calcpad-web/src/sharing/ShareDialog.tsx create mode 100644 calcpad-web/src/sharing/useShareToken.ts create mode 100644 calcpad-web/src/styles/auth.css create mode 100644 calcpad-web/src/styles/collab-indicator.css create mode 100644 calcpad-web/src/styles/font-size-control.css create mode 100644 calcpad-web/src/styles/share-dialog.css create mode 100644 calcpad-web/src/styles/user-menu.css create mode 100644 calcpad-web/src/sync/migrateLegacy.ts create mode 100644 calcpad-web/src/sync/useCloudSync.ts create mode 100644 collab-server/Dockerfile create mode 100644 collab-server/package.json create mode 100644 collab-server/src/auth.ts create mode 100644 collab-server/src/index.ts create mode 100644 collab-server/src/storage.ts create mode 100644 collab-server/tsconfig.json create mode 100644 supabase/migrations/001_create_schema.sql diff --git a/.gitignore b/.gitignore index af6fce7..38b2a63 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,12 @@ dist/ *.swo *~ +# Environment +.env +.env.production +.env.local +.env.*.local + # OS .DS_Store Thumbs.db diff --git a/Dockerfile b/Dockerfile index 4440b7c..d148f2f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,6 +30,16 @@ FROM node:22-slim AS web-builder WORKDIR /app/calcpad-web +# Build-time env vars for Vite (inlined at build time) +ARG VITE_SUPABASE_URL +ARG VITE_SUPABASE_ANON_KEY +ARG VITE_AUTH_URL +ARG VITE_COLLAB_WS_URL +ENV VITE_SUPABASE_URL=$VITE_SUPABASE_URL +ENV VITE_SUPABASE_ANON_KEY=$VITE_SUPABASE_ANON_KEY +ENV VITE_AUTH_URL=$VITE_AUTH_URL +ENV VITE_COLLAB_WS_URL=$VITE_COLLAB_WS_URL + # Install dependencies first (layer caching) COPY calcpad-web/package.json calcpad-web/package-lock.json* ./ RUN npm install diff --git a/calcpad-web/.env.example b/calcpad-web/.env.example new file mode 100644 index 0000000..b7d8599 --- /dev/null +++ b/calcpad-web/.env.example @@ -0,0 +1,3 @@ +VITE_SUPABASE_URL=https://your-project.supabase.co +VITE_SUPABASE_ANON_KEY=your-anon-key +VITE_COLLAB_WS_URL=ws://localhost:4000 diff --git a/calcpad-web/.gitignore b/calcpad-web/.gitignore index a547bf3..438657a 100644 --- a/calcpad-web/.gitignore +++ b/calcpad-web/.gitignore @@ -11,6 +11,7 @@ node_modules dist dist-ssr *.local +.env # Editor directories and files .vscode/* diff --git a/calcpad-web/index.html b/calcpad-web/index.html index 9fc8ca9..fc129b1 100644 --- a/calcpad-web/index.html +++ b/calcpad-web/index.html @@ -10,6 +10,9 @@ + + + CalcText diff --git a/calcpad-web/package.json b/calcpad-web/package.json index 546455b..f76a1ca 100644 --- a/calcpad-web/package.json +++ b/calcpad-web/package.json @@ -17,9 +17,15 @@ "@codemirror/state": "^6.5.2", "@codemirror/view": "^6.36.5", "@lezer/highlight": "^1.2.1", + "@supabase/supabase-js": "^2.99.3", "codemirror": "^6.0.1", + "nanoid": "^5.1.7", "react": "^19.2.4", - "react-dom": "^19.2.4" + "react-dom": "^19.2.4", + "y-codemirror.next": "^0.3.5", + "y-indexeddb": "^9.0.12", + "y-websocket": "^3.0.0", + "yjs": "^13.6.30" }, "devDependencies": { "@types/react": "^19.2.14", diff --git a/calcpad-web/src/App.tsx b/calcpad-web/src/App.tsx index 9c29130..94800ad 100644 --- a/calcpad-web/src/App.tsx +++ b/calcpad-web/src/App.tsx @@ -23,7 +23,13 @@ import { StatusBar } from './components/StatusBar.tsx' import { AlignToolbar } from './components/AlignToolbar.tsx' import type { Alignment } from './components/AlignToolbar.tsx' import { FormatToolbar } from './components/FormatToolbar.tsx' +import { FontSizeControl } from './components/FontSizeControl.tsx' +import { useFontSize } from './hooks/useFontSize.ts' import { MobileResultsTray } from './components/MobileResultsTray.tsx' +import { UserMenu } from './components/UserMenu.tsx' +import { SecuritySettings } from './components/SecuritySettings.tsx' +import { useAuth } from './auth/AuthProvider.tsx' +import { AuthModal } from './auth/AuthModal.tsx' import './styles/app.css' function App() { @@ -31,8 +37,13 @@ function App() { const isOnline = useOnlineStatus() const installPrompt = useInstallPrompt() const themeCtx = useTheme() + const fontSizeCtx = useFontSize() + const auth = useAuth() const store = useDocumentStore() + const [showAuthModal, setShowAuthModal] = useState(false) + const [showSecuritySettings, setShowSecuritySettings] = useState(false) + const [editorView, setEditorView] = useState(null) const resultsPanelRef = useRef(null) const [modifiedIds, setModifiedIds] = useState>(new Set()) @@ -207,11 +218,32 @@ function App() { } return } + + // Ctrl+= — increase font size + if (mod && (e.key === '=' || e.key === '+')) { + e.preventDefault() + fontSizeCtx.setFontSize(fontSizeCtx.fontSize + 1) + return + } + + // Ctrl+- — decrease font size + if (mod && e.key === '-') { + e.preventDefault() + fontSizeCtx.setFontSize(fontSizeCtx.fontSize - 1) + return + } + + // Ctrl+0 — reset font size + if (mod && e.key === '0') { + e.preventDefault() + fontSizeCtx.resetFontSize() + return + } } document.addEventListener('keydown', handleKey) return () => document.removeEventListener('keydown', handleKey) - }, [store.activeTabId, store.openTabIds, handleNewTab, handleTabClose, handleTabClick, sidebarState.visible, setSidebarVisible]) + }, [store.activeTabId, store.openTabIds, handleNewTab, handleTabClose, handleTabClick, sidebarState.visible, setSidebarVisible, fontSizeCtx]) // Compute flex styles from divider position const editorStyle: React.CSSProperties = dividerX !== null @@ -249,12 +281,23 @@ function App() { onEditorAlignChange={setEditorAlign} onResultsAlignChange={setResultsAlign} /> + + setShowAuthModal(true)} + onOpenSecurity={() => setShowSecuritySettings(true)} + /> @@ -340,6 +383,21 @@ function App() { onInstall={installPrompt.handleInstall} onDismiss={installPrompt.handleDismiss} /> + + {showAuthModal && ( + setShowAuthModal(false)} /> + )} + + {auth.needsPasswordRenewal && ( + auth.clearPasswordRenewal()} + renewalMode + /> + )} + + {showSecuritySettings && ( + setShowSecuritySettings(false)} /> + )} ) } diff --git a/calcpad-web/src/auth/AuthModal.tsx b/calcpad-web/src/auth/AuthModal.tsx new file mode 100644 index 0000000..1d76e76 --- /dev/null +++ b/calcpad-web/src/auth/AuthModal.tsx @@ -0,0 +1,125 @@ +import { useState } from 'react' +import { useAuth } from './AuthProvider.tsx' +import '../styles/auth.css' + +interface AuthModalProps { + onClose: () => void + /** When true, forces password re-entry (device renewal) instead of full login */ + renewalMode?: boolean +} + +export function AuthModal({ onClose, renewalMode = false }: AuthModalProps) { + const auth = useAuth() + const [mode, setMode] = useState<'signin' | 'signup'>(renewalMode ? 'signin' : 'signin') + const [email, setEmail] = useState(renewalMode ? (auth.user?.email ?? '') : '') + const [password, setPassword] = useState('') + const [error, setError] = useState(null) + const [loading, setLoading] = useState(false) + const [signupSuccess, setSignupSuccess] = useState(false) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError(null) + setLoading(true) + + if (mode === 'signup') { + const result = await auth.signUp(email, password) + if (result.error) { + setError(result.error) + } else { + setSignupSuccess(true) + } + } else { + const result = await auth.signIn(email, password) + if (result.error) { + setError(result.error) + } else { + onClose() + } + } + + setLoading(false) + } + + if (signupSuccess) { + return ( +
+
e.stopPropagation()}> +

Check your email

+

+ We sent a confirmation link to {email}. + Click the link to activate your account, then sign in. +

+ +
+
+ ) + } + + return ( +
+
e.stopPropagation()}> +

{renewalMode ? 'Re-enter password' : mode === 'signin' ? 'Sign In' : 'Create Account'}

+ {renewalMode && ( +

+ Your session on this device has expired. Please re-enter your password. +

+ )} + +
+
+ + setEmail(e.target.value)} + disabled={renewalMode} + required + autoFocus={!renewalMode} + autoComplete="email" + /> +
+ +
+ + setPassword(e.target.value)} + required + autoFocus={renewalMode} + autoComplete={mode === 'signup' ? 'new-password' : 'current-password'} + minLength={6} + /> +
+ + {error &&

{error}

} + + +
+ + {!renewalMode && ( +

+ {mode === 'signin' ? ( + <>Don't have an account?{' '} + ) : ( + <>Already have an account?{' '} + )} +

+ )} + + {!renewalMode && ( + + )} +
+
+ ) +} diff --git a/calcpad-web/src/auth/AuthProvider.tsx b/calcpad-web/src/auth/AuthProvider.tsx new file mode 100644 index 0000000..7e15fa9 --- /dev/null +++ b/calcpad-web/src/auth/AuthProvider.tsx @@ -0,0 +1,115 @@ +import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react' +import type { User, Session } from '@supabase/supabase-js' +import { supabase, isSupabaseConfigured } from './supabase.ts' +import { recordPasswordAuth, checkPasswordRenewal } from './DeviceSession.ts' + +interface AuthContextValue { + user: User | null + session: Session | null + isAuthenticated: boolean + isLoading: boolean + needsPasswordRenewal: boolean + configured: boolean + signUp: (email: string, password: string) => Promise<{ error: string | null }> + signIn: (email: string, password: string) => Promise<{ error: string | null }> + signOut: () => Promise + clearPasswordRenewal: () => void +} + +const AuthContext = createContext(null) + +export function useAuth(): AuthContextValue { + const ctx = useContext(AuthContext) + if (!ctx) throw new Error('useAuth must be used within AuthProvider') + return ctx +} + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null) + const [session, setSession] = useState(null) + const [isLoading, setIsLoading] = useState(true) + const [needsPasswordRenewal, setNeedsPasswordRenewal] = useState(false) + const configured = isSupabaseConfigured() + + // Initialize auth state + useEffect(() => { + if (!supabase) { + setIsLoading(false) + return + } + + // Get current session + supabase.auth.getSession().then(async ({ data: { session: s } }) => { + setSession(s) + setUser(s?.user ?? null) + + // Check password renewal for current device + if (s?.user) { + const needsRenewal = await checkPasswordRenewal(s.user.id) + setNeedsPasswordRenewal(needsRenewal) + } + + setIsLoading(false) + }) + + // Listen for auth changes + const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, s) => { + setSession(s) + setUser(s?.user ?? null) + }) + + return () => subscription.unsubscribe() + }, []) + + const signUp = useCallback(async (email: string, password: string) => { + if (!supabase) return { error: 'Supabase not configured' } + + const { error } = await supabase.auth.signUp({ email, password }) + if (error) return { error: error.message } + return { error: null } + }, []) + + const signIn = useCallback(async (email: string, password: string) => { + if (!supabase) return { error: 'Supabase not configured' } + + const { data, error } = await supabase.auth.signInWithPassword({ email, password }) + if (error) return { error: error.message } + + // Record successful password auth for this device + if (data.user) { + await recordPasswordAuth(data.user.id) + setNeedsPasswordRenewal(false) + } + + return { error: null } + }, []) + + const signOut = useCallback(async () => { + if (!supabase) return + await supabase.auth.signOut() + setNeedsPasswordRenewal(false) + }, []) + + const clearPasswordRenewal = useCallback(() => { + setNeedsPasswordRenewal(false) + }, []) + + return ( + + {children} + + ) +} diff --git a/calcpad-web/src/auth/DeviceSession.ts b/calcpad-web/src/auth/DeviceSession.ts new file mode 100644 index 0000000..4c492c8 --- /dev/null +++ b/calcpad-web/src/auth/DeviceSession.ts @@ -0,0 +1,145 @@ +import { supabase } from './supabase.ts' + +const DEVICE_ID_KEY = 'calctext-device-id' +const DEVICE_NAME_KEY = 'calctext-device-name' + +/** Get or create a stable device fingerprint */ +export function getDeviceId(): string { + let id = localStorage.getItem(DEVICE_ID_KEY) + if (!id) { + id = crypto.randomUUID() + localStorage.setItem(DEVICE_ID_KEY, id) + } + return id +} + +/** Generate a human-readable device name from user agent */ +export function getDeviceName(): string { + const stored = localStorage.getItem(DEVICE_NAME_KEY) + if (stored) return stored + + const ua = navigator.userAgent + let name = 'Unknown Device' + if (/Mac/.test(ua)) name = 'Mac' + else if (/Windows/.test(ua)) name = 'Windows' + else if (/Linux/.test(ua)) name = 'Linux' + else if (/iPhone|iPad/.test(ua)) name = 'iOS' + else if (/Android/.test(ua)) name = 'Android' + + const browser = /Chrome/.test(ua) ? 'Chrome' + : /Firefox/.test(ua) ? 'Firefox' + : /Safari/.test(ua) ? 'Safari' + : /Edge/.test(ua) ? 'Edge' + : '' + + if (browser) name += ` ${browser}` + localStorage.setItem(DEVICE_NAME_KEY, name) + return name +} + +export interface DeviceSession { + id: string + user_id: string + device_fingerprint: string + device_name: string | null + session_ttl_seconds: number + last_password_auth_at: string + created_at: string +} + +/** Default TTL options in seconds */ +export const TTL_OPTIONS = [ + { label: '1 hour', value: 3600 }, + { label: '1 day', value: 86400 }, + { label: '1 week', value: 604800 }, + { label: '30 days', value: 2592000 }, + { label: 'Never', value: 0 }, +] as const + +/** + * Upsert the device session after successful password authentication. + */ +export async function recordPasswordAuth(userId: string): Promise { + if (!supabase) return null + + const deviceId = getDeviceId() + const deviceName = getDeviceName() + + const { data, error } = await supabase + .from('device_sessions') + .upsert( + { + user_id: userId, + device_fingerprint: deviceId, + device_name: deviceName, + last_password_auth_at: new Date().toISOString(), + }, + { onConflict: 'user_id,device_fingerprint' }, + ) + .select() + .single() + + if (error) { + console.error('Failed to record device session:', error) + return null + } + return data +} + +/** + * Check if the current device session requires password re-entry. + */ +export async function checkPasswordRenewal(userId: string): Promise { + if (!supabase) return false + + const deviceId = getDeviceId() + + const { data, error } = await supabase + .from('device_sessions') + .select('session_ttl_seconds, last_password_auth_at') + .eq('user_id', userId) + .eq('device_fingerprint', deviceId) + .single() + + if (error || !data) return false + + // TTL of 0 means "never expire" + if (data.session_ttl_seconds === 0) return false + + const lastAuth = new Date(data.last_password_auth_at).getTime() + const ttlMs = data.session_ttl_seconds * 1000 + return Date.now() - lastAuth > ttlMs +} + +/** + * Update the session TTL for the current device. + */ +export async function updateDeviceTTL(userId: string, ttlSeconds: number): Promise { + if (!supabase) return + + const deviceId = getDeviceId() + + await supabase + .from('device_sessions') + .update({ session_ttl_seconds: ttlSeconds }) + .eq('user_id', userId) + .eq('device_fingerprint', deviceId) +} + +/** + * Get the current device session. + */ +export async function getDeviceSession(userId: string): Promise { + if (!supabase) return null + + const deviceId = getDeviceId() + + const { data } = await supabase + .from('device_sessions') + .select('*') + .eq('user_id', userId) + .eq('device_fingerprint', deviceId) + .single() + + return data +} diff --git a/calcpad-web/src/auth/supabase.ts b/calcpad-web/src/auth/supabase.ts new file mode 100644 index 0000000..59a5299 --- /dev/null +++ b/calcpad-web/src/auth/supabase.ts @@ -0,0 +1,25 @@ +import { createClient } from '@supabase/supabase-js' + +const supabaseUrl = import.meta.env.VITE_SUPABASE_URL as string +const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY as string +const authUrl = import.meta.env.VITE_AUTH_URL as string | undefined + +/** + * Supabase client singleton. + * Returns null if env vars are not configured (local-only mode). + */ +export const supabase = + supabaseUrl && supabaseAnonKey + ? createClient(supabaseUrl, supabaseAnonKey, { + auth: { + persistSession: true, + autoRefreshToken: true, + detectSessionInUrl: true, + ...(authUrl ? { url: authUrl } : {}), + }, + }) + : null + +export function isSupabaseConfigured(): boolean { + return supabase !== null +} diff --git a/calcpad-web/src/collab/CollabProvider.tsx b/calcpad-web/src/collab/CollabProvider.tsx new file mode 100644 index 0000000..66e099b --- /dev/null +++ b/calcpad-web/src/collab/CollabProvider.tsx @@ -0,0 +1,75 @@ +import { createContext, useContext, useMemo, type ReactNode } from 'react' +import type { Awareness } from 'y-protocols/awareness' +import type * as Y from 'yjs' +import { useAuth } from '../auth/AuthProvider.tsx' +import { useYDoc } from './useYDoc.ts' +import { useWebSocketProvider } from './useWebSocketProvider.ts' +import { getUserColor, getDisplayName } from './awareness.ts' + +interface CollabContextValue { + ydoc: Y.Doc | null + ytext: Y.Text | null + awareness: Awareness | null + ready: boolean + connected: boolean + peerCount: number + initContent: (content: string) => void + getContent: () => string +} + +const CollabContext = createContext(null) + +export function useCollab(): CollabContextValue { + const ctx = useContext(CollabContext) + if (!ctx) throw new Error('useCollab must be used within CollabProvider') + return ctx +} + +interface CollabProviderProps { + documentId: string | null + cloudDocId: string | null + children: ReactNode +} + +export function CollabProvider({ documentId, cloudDocId, children }: CollabProviderProps) { + const auth = useAuth() + const { ydoc, ytext, ready, getContent, initContent } = useYDoc(documentId) + + const userName = useMemo( + () => getDisplayName(auth.user?.email), + [auth.user?.email], + ) + const userColor = useMemo( + () => getUserColor(auth.user?.id ?? 'anon'), + [auth.user?.id], + ) + + // Only connect WebSocket if authenticated and we have a cloud doc ID + const roomName = cloudDocId ? `doc:${cloudDocId}` : null + const token = auth.session?.access_token ?? null + + const { awareness, connected, peerCount } = useWebSocketProvider({ + ydoc, + roomName, + token, + userName, + userColor, + }) + + return ( + + {children} + + ) +} diff --git a/calcpad-web/src/collab/awareness.ts b/calcpad-web/src/collab/awareness.ts new file mode 100644 index 0000000..35dd39f --- /dev/null +++ b/calcpad-web/src/collab/awareness.ts @@ -0,0 +1,27 @@ +/** Predefined colors for collaborative cursors */ +const CURSOR_COLORS = [ + '#f43f5e', // rose + '#6366f1', // indigo + '#10b981', // emerald + '#f59e0b', // amber + '#0ea5e9', // sky + '#8b5cf6', // violet + '#14b8a6', // teal + '#ef4444', // red +] + +/** Assign a stable color based on user ID */ +export function getUserColor(userId: string): string { + let hash = 0 + for (let i = 0; i < userId.length; i++) { + hash = ((hash << 5) - hash + userId.charCodeAt(i)) | 0 + } + return CURSOR_COLORS[Math.abs(hash) % CURSOR_COLORS.length] +} + +/** Get display name from email */ +export function getDisplayName(email: string | undefined): string { + if (!email) return 'Anonymous' + const atIndex = email.indexOf('@') + return atIndex > 0 ? email.slice(0, atIndex) : email +} diff --git a/calcpad-web/src/collab/useWebSocketProvider.ts b/calcpad-web/src/collab/useWebSocketProvider.ts new file mode 100644 index 0000000..d8cb4c7 --- /dev/null +++ b/calcpad-web/src/collab/useWebSocketProvider.ts @@ -0,0 +1,86 @@ +import { useRef, useEffect, useState } from 'react' +import * as Y from 'yjs' +import { WebsocketProvider } from 'y-websocket' +import type { Awareness } from 'y-protocols/awareness' + +const COLLAB_WS_URL = import.meta.env.VITE_COLLAB_WS_URL as string || 'ws://localhost:4000' + +interface UseWebSocketProviderOptions { + ydoc: Y.Doc | null + roomName: string | null + token: string | null + userName: string + userColor: string +} + +/** + * Manages a Y.js WebSocket provider for real-time collaboration. + * Connects to the Hocuspocus server. + */ +export function useWebSocketProvider({ + ydoc, + roomName, + token, + userName, + userColor, +}: UseWebSocketProviderOptions) { + const providerRef = useRef(null) + const [awareness, setAwareness] = useState(null) + const [connected, setConnected] = useState(false) + const [peerCount, setPeerCount] = useState(0) + + useEffect(() => { + if (!ydoc || !roomName || !token) { + setAwareness(null) + setConnected(false) + setPeerCount(0) + return + } + + const provider = new WebsocketProvider( + COLLAB_WS_URL, + roomName, + ydoc, + { + params: { token }, + connect: true, + }, + ) + + providerRef.current = provider + + // Set local user awareness + provider.awareness.setLocalStateField('user', { + name: userName, + color: userColor, + }) + + setAwareness(provider.awareness) + + // Track connection status + provider.on('status', (event: { status: string }) => { + setConnected(event.status === 'connected') + }) + + // Track peer count + const updatePeerCount = () => { + const states = provider.awareness.getStates() + setPeerCount(Math.max(0, states.size - 1)) // Exclude self + } + + provider.awareness.on('change', updatePeerCount) + updatePeerCount() + + return () => { + provider.awareness.off('change', updatePeerCount) + provider.disconnect() + provider.destroy() + providerRef.current = null + setAwareness(null) + setConnected(false) + setPeerCount(0) + } + }, [ydoc, roomName, token, userName, userColor]) + + return { awareness, connected, peerCount } +} diff --git a/calcpad-web/src/collab/useYDoc.ts b/calcpad-web/src/collab/useYDoc.ts new file mode 100644 index 0000000..8631abd --- /dev/null +++ b/calcpad-web/src/collab/useYDoc.ts @@ -0,0 +1,68 @@ +import { useRef, useEffect, useState, useCallback } from 'react' +import * as Y from 'yjs' +import { IndexeddbPersistence } from 'y-indexeddb' + +/** + * Manages a Y.Doc for a given document ID. + * Persists to IndexedDB for offline support. + */ +export function useYDoc(documentId: string | null) { + const ydocRef = useRef(null) + const persistenceRef = useRef(null) + const [ytext, setYtext] = useState(null) + const [ready, setReady] = useState(false) + + useEffect(() => { + if (!documentId) { + setYtext(null) + setReady(false) + return + } + + const ydoc = new Y.Doc() + ydocRef.current = ydoc + + const text = ydoc.getText('content') + setYtext(text) + + // Persist to IndexedDB + const persistence = new IndexeddbPersistence(`calctext-${documentId}`, ydoc) + persistenceRef.current = persistence + + persistence.on('synced', () => { + setReady(true) + }) + + return () => { + persistence.destroy() + ydoc.destroy() + ydocRef.current = null + persistenceRef.current = null + setYtext(null) + setReady(false) + } + }, [documentId]) + + /** Get the current document content as a plain string */ + const getContent = useCallback((): string => { + return ytext?.toString() ?? '' + }, [ytext]) + + /** Initialize the Y.Doc with content if it's empty (first time) */ + const initContent = useCallback((content: string) => { + if (!ytext || !ydocRef.current) return + if (ytext.length === 0 && content.length > 0) { + ydocRef.current.transact(() => { + ytext.insert(0, content) + }) + } + }, [ytext]) + + return { + ydoc: ydocRef.current, + ytext, + ready, + getContent, + initContent, + } +} diff --git a/calcpad-web/src/components/CollabIndicator.tsx b/calcpad-web/src/components/CollabIndicator.tsx new file mode 100644 index 0000000..9391930 --- /dev/null +++ b/calcpad-web/src/components/CollabIndicator.tsx @@ -0,0 +1,19 @@ +import '../styles/collab-indicator.css' + +interface CollabIndicatorProps { + connected: boolean + peerCount: number +} + +export function CollabIndicator({ connected, peerCount }: CollabIndicatorProps) { + if (!connected && peerCount === 0) return null + + return ( +
+ + {peerCount > 0 && ( + {peerCount + 1} + )} +
+ ) +} diff --git a/calcpad-web/src/components/FontSizeControl.tsx b/calcpad-web/src/components/FontSizeControl.tsx new file mode 100644 index 0000000..6a7bf5d --- /dev/null +++ b/calcpad-web/src/components/FontSizeControl.tsx @@ -0,0 +1,34 @@ +import '../styles/font-size-control.css' + +interface FontSizeControlProps { + fontSize: number + onFontSizeChange: (size: number) => void + min: number + max: number +} + +export function FontSizeControl({ fontSize, onFontSizeChange, min, max }: FontSizeControlProps) { + return ( +
+ + {fontSize} + +
+ ) +} diff --git a/calcpad-web/src/components/FormatToolbar.tsx b/calcpad-web/src/components/FormatToolbar.tsx index 3b53d62..d1c3ca6 100644 --- a/calcpad-web/src/components/FormatToolbar.tsx +++ b/calcpad-web/src/components/FormatToolbar.tsx @@ -93,7 +93,12 @@ export function FormatToolbar({ editorView, previewMode, onPreviewToggle }: Form onClick={onPreviewToggle} title={previewMode ? 'Show raw markdown' : 'Show formatted preview'} > - 👁 + + + + {!previewMode && } + + {previewMode ? 'Preview' : 'Raw'} @@ -124,9 +129,12 @@ export function FormatToolbar({ editorView, previewMode, onPreviewToggle }: Form diff --git a/calcpad-web/src/components/SecuritySettings.tsx b/calcpad-web/src/components/SecuritySettings.tsx new file mode 100644 index 0000000..7984b95 --- /dev/null +++ b/calcpad-web/src/components/SecuritySettings.tsx @@ -0,0 +1,80 @@ +import { useState, useEffect } from 'react' +import { useAuth } from '../auth/AuthProvider.tsx' +import { TTL_OPTIONS, getDeviceSession, updateDeviceTTL, getDeviceName } from '../auth/DeviceSession.ts' +import '../styles/auth.css' + +interface SecuritySettingsProps { + onClose: () => void +} + +export function SecuritySettings({ onClose }: SecuritySettingsProps) { + const auth = useAuth() + const [currentTTL, setCurrentTTL] = useState(86400) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const deviceName = getDeviceName() + + useEffect(() => { + if (!auth.user) return + getDeviceSession(auth.user.id).then(session => { + if (session) setCurrentTTL(session.session_ttl_seconds) + setLoading(false) + }) + }, [auth.user]) + + const handleTTLChange = async (ttl: number) => { + if (!auth.user) return + setSaving(true) + setCurrentTTL(ttl) + await updateDeviceTTL(auth.user.id, ttl) + setSaving(false) + } + + return ( +
+
e.stopPropagation()}> +

Security Settings

+ + +
+ +

{deviceName}

+
+ +
+ + {loading ? ( +

Loading...

+ ) : ( +
+ {TTL_OPTIONS.map(opt => ( + + ))} +
+ )} +
+ +

+ This setting controls how often you need to re-enter your password on this device. + Other devices are not affected. +

+
+
+ ) +} diff --git a/calcpad-web/src/components/ThemePicker.tsx b/calcpad-web/src/components/ThemePicker.tsx index 8153046..ec1be76 100644 --- a/calcpad-web/src/components/ThemePicker.tsx +++ b/calcpad-web/src/components/ThemePicker.tsx @@ -5,12 +5,13 @@ import '../styles/theme-picker.css' interface ThemePickerProps { theme: ThemeId + resolvedTheme: string accentColor: string | null onThemeChange: (id: ThemeId) => void onAccentChange: (color: string | null) => void } -export function ThemePicker({ theme, accentColor, onThemeChange, onAccentChange }: ThemePickerProps) { +export function ThemePicker({ theme, resolvedTheme, accentColor, onThemeChange, onAccentChange }: ThemePickerProps) { const [open, setOpen] = useState(false) const ref = useRef(null) @@ -46,8 +47,8 @@ export function ThemePicker({ theme, accentColor, onThemeChange, onAccentChange }, []) const currentTheme = THEMES.find(t => t.id === theme) - const resolvedTheme = theme === 'system' ? undefined : theme const icon = currentTheme?.icon ?? '⚙️' + const isDark = ['dark', 'matrix', 'midnight'].includes(resolvedTheme) return (
@@ -68,13 +69,13 @@ export function ThemePicker({ theme, accentColor, onThemeChange, onAccentChange {THEMES.map(t => ( ))} @@ -86,9 +87,8 @@ export function ThemePicker({ theme, accentColor, onThemeChange, onAccentChange + ) + } + + const initial = (auth.user?.email?.[0] ?? '?').toUpperCase() + + return ( +
+ + + {open && ( +
+
{auth.user?.email}
+
+ + +
+ )} +
+ ) +} diff --git a/calcpad-web/src/editor/CalcEditor.tsx b/calcpad-web/src/editor/CalcEditor.tsx index 1fb54bb..f4e630e 100644 --- a/calcpad-web/src/editor/CalcEditor.tsx +++ b/calcpad-web/src/editor/CalcEditor.tsx @@ -194,7 +194,7 @@ const calcpadHighlight = HighlightStyle.define([ const calcpadEditorTheme = EditorView.baseTheme({ '&': { height: '100%', - fontSize: '15px', + fontSize: 'var(--editor-font-size, 15px)', fontFamily: 'var(--mono, ui-monospace, Consolas, "Courier New", monospace)', }, '.cm-scroller': { @@ -218,7 +218,7 @@ const calcpadEditorTheme = EditorView.baseTheme({ padding: '0 6px 0 12px', color: 'var(--text, #9ca3af)', opacity: '0.4', - fontSize: '13px', + fontSize: 'calc(var(--editor-font-size, 15px) - 2px)', minWidth: '32px', }, '.cm-activeLineGutter .cm-gutterElement': { diff --git a/calcpad-web/src/editor/format-preview.ts b/calcpad-web/src/editor/format-preview.ts index eb9799e..c39f6cc 100644 --- a/calcpad-web/src/editor/format-preview.ts +++ b/calcpad-web/src/editor/format-preview.ts @@ -116,28 +116,35 @@ function buildDecorations(view: EditorView): DecorationSet { } } - // Color labels: [color:text] (on non-active lines, show colored text) - if (!isActive) { + // Color labels: [color:text] — always show color, even on active line + { const colorRegex = /\[(red|orange|yellow|green|blue|purple):(.+?)\]/g let match while ((match = colorRegex.exec(text)) !== null) { const start = line.from + match.index const color = match[1] const content = match[2] - // Hide [color: - decorations.push({ from: start, to: start + color.length + 2, dec: hiddenWidget }) - // Color the content - decorations.push({ - from: start + color.length + 2, - to: start + color.length + 2 + content.length, - dec: Decoration.mark({ class: `cm-fmt-color-${color}` }), - }) - // Hide ] - decorations.push({ - from: start + match[0].length - 1, - to: start + match[0].length, - dec: hiddenWidget, - }) + if (!isActive) { + // Non-active lines: hide syntax, show only colored content + decorations.push({ from: start, to: start + color.length + 2, dec: hiddenWidget }) + decorations.push({ + from: start + color.length + 2, + to: start + color.length + 2 + content.length, + dec: Decoration.mark({ class: `cm-fmt-color-${color}` }), + }) + decorations.push({ + from: start + match[0].length - 1, + to: start + match[0].length, + dec: hiddenWidget, + }) + } else { + // Active line: keep syntax visible but color the entire expression + decorations.push({ + from: start, + to: start + match[0].length, + dec: Decoration.mark({ class: `cm-fmt-color-${color}` }), + }) + } } } } diff --git a/calcpad-web/src/hooks/useFontSize.ts b/calcpad-web/src/hooks/useFontSize.ts new file mode 100644 index 0000000..3b190b0 --- /dev/null +++ b/calcpad-web/src/hooks/useFontSize.ts @@ -0,0 +1,39 @@ +import { useState, useEffect, useCallback } from 'react' + +const STORAGE_KEY = 'calctext-fontsize' +const DEFAULT_SIZE = 15 +export const MIN_FONT_SIZE = 11 +export const MAX_FONT_SIZE = 24 + +function getStoredFontSize(): number { + try { + const stored = localStorage.getItem(STORAGE_KEY) + if (stored) { + const n = parseInt(stored, 10) + if (n >= MIN_FONT_SIZE && n <= MAX_FONT_SIZE) return n + } + } catch { /* localStorage unavailable */ } + return DEFAULT_SIZE +} + +export function useFontSize() { + const [fontSize, setFontSizeState] = useState(getStoredFontSize) + + const setFontSize = useCallback((size: number) => { + const clamped = Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, size)) + setFontSizeState(clamped) + try { localStorage.setItem(STORAGE_KEY, String(clamped)) } catch { /* */ } + document.documentElement.style.setProperty('--editor-font-size', `${clamped}px`) + }, []) + + const resetFontSize = useCallback(() => { + setFontSize(DEFAULT_SIZE) + }, [setFontSize]) + + // Apply on mount + useEffect(() => { + document.documentElement.style.setProperty('--editor-font-size', `${fontSize}px`) + }, []) // eslint-disable-line react-hooks/exhaustive-deps + + return { fontSize, setFontSize, resetFontSize, MIN_SIZE: MIN_FONT_SIZE, MAX_SIZE: MAX_FONT_SIZE } +} diff --git a/calcpad-web/src/main.tsx b/calcpad-web/src/main.tsx index dc25e58..90201bd 100644 --- a/calcpad-web/src/main.tsx +++ b/calcpad-web/src/main.tsx @@ -2,10 +2,13 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import './styles/index.css' import App from './App.tsx' +import { AuthProvider } from './auth/AuthProvider.tsx' createRoot(document.getElementById('root')!).render( - + + + , ) diff --git a/calcpad-web/src/router/routes.ts b/calcpad-web/src/router/routes.ts new file mode 100644 index 0000000..1891515 --- /dev/null +++ b/calcpad-web/src/router/routes.ts @@ -0,0 +1,19 @@ +export type Route = + | { type: 'app' } + | { type: 'shared'; token: string } + | { type: 'auth-confirm' } + +export function parseRoute(pathname: string = window.location.pathname): Route { + // /s/{token} — shared document + const shareMatch = pathname.match(/^\/s\/([a-zA-Z0-9_-]+)$/) + if (shareMatch) { + return { type: 'shared', token: shareMatch[1] } + } + + // /auth/confirm — email confirmation callback + if (pathname.startsWith('/auth/confirm')) { + return { type: 'auth-confirm' } + } + + return { type: 'app' } +} diff --git a/calcpad-web/src/router/useRoute.ts b/calcpad-web/src/router/useRoute.ts new file mode 100644 index 0000000..30c45b7 --- /dev/null +++ b/calcpad-web/src/router/useRoute.ts @@ -0,0 +1,22 @@ +import { useState, useEffect } from 'react' +import { parseRoute, type Route } from './routes.ts' + +export function useRoute(): Route { + const [route, setRoute] = useState(() => parseRoute()) + + useEffect(() => { + function onPopState() { + setRoute(parseRoute()) + } + window.addEventListener('popstate', onPopState) + return () => window.removeEventListener('popstate', onPopState) + }, []) + + return route +} + +/** Navigate without page reload */ +export function navigate(path: string) { + window.history.pushState(null, '', path) + window.dispatchEvent(new PopStateEvent('popstate')) +} diff --git a/calcpad-web/src/sharing/ShareDialog.tsx b/calcpad-web/src/sharing/ShareDialog.tsx new file mode 100644 index 0000000..01b7c58 --- /dev/null +++ b/calcpad-web/src/sharing/ShareDialog.tsx @@ -0,0 +1,110 @@ +import { useState } from 'react' +import { useShareToken, type SharePermission } from './useShareToken.ts' +import '../styles/share-dialog.css' + +interface ShareDialogProps { + documentId: string + documentTitle: string + currentShareToken: string | null + currentSharePermission: string | null + onClose: () => void +} + +export function ShareDialog({ + documentId, + documentTitle, + currentShareToken, + currentSharePermission, + onClose, +}: ShareDialogProps) { + const { createShareLink, revokeShareLink } = useShareToken() + const [permission, setPermission] = useState( + (currentSharePermission as SharePermission) ?? 'view', + ) + const [shareUrl, setShareUrl] = useState( + currentShareToken ? `${window.location.origin}/s/${currentShareToken}` : null, + ) + const [loading, setLoading] = useState(false) + const [copied, setCopied] = useState(false) + + const handleCreateLink = async () => { + setLoading(true) + const result = await createShareLink(documentId, permission) + if (result) { + setShareUrl(result.url) + } + setLoading(false) + } + + const handleRevokeLink = async () => { + setLoading(true) + await revokeShareLink(documentId) + setShareUrl(null) + setLoading(false) + } + + const handleCopy = async () => { + if (!shareUrl) return + await navigator.clipboard.writeText(shareUrl) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + + return ( +
+
e.stopPropagation()}> +

Share "{documentTitle}"

+ + +
+ +
+ + +
+
+ + {shareUrl ? ( +
+
+ (e.target as HTMLInputElement).select()} + /> + +
+ +
+ ) : ( + + )} +
+
+ ) +} diff --git a/calcpad-web/src/sharing/useShareToken.ts b/calcpad-web/src/sharing/useShareToken.ts new file mode 100644 index 0000000..21b2bba --- /dev/null +++ b/calcpad-web/src/sharing/useShareToken.ts @@ -0,0 +1,70 @@ +import { useCallback } from 'react' +import { nanoid } from 'nanoid' +import { supabase } from '../auth/supabase.ts' + +export type SharePermission = 'view' | 'edit' + +interface ShareInfo { + token: string + permission: SharePermission + url: string +} + +/** + * Hook for managing document share tokens. + */ +export function useShareToken() { + /** Generate a share link for a document */ + const createShareLink = useCallback(async ( + documentId: string, + permission: SharePermission = 'view', + ): Promise => { + if (!supabase) return null + + const token = nanoid(21) + const { error } = await supabase + .from('documents') + .update({ + share_token: token, + share_permission: permission, + }) + .eq('id', documentId) + + if (error) { + console.error('Failed to create share link:', error) + return null + } + + const url = `${window.location.origin}/s/${token}` + return { token, permission, url } + }, []) + + /** Revoke a share link */ + const revokeShareLink = useCallback(async (documentId: string): Promise => { + if (!supabase) return + + await supabase + .from('documents') + .update({ + share_token: null, + share_permission: null, + }) + .eq('id', documentId) + }, []) + + /** Resolve a share token to document info */ + const resolveShareToken = useCallback(async (token: string) => { + if (!supabase) return null + + const { data, error } = await supabase + .from('documents') + .select('id, title, share_permission, owner_id') + .eq('share_token', token) + .single() + + if (error || !data) return null + return data + }, []) + + return { createShareLink, revokeShareLink, resolveShareToken } +} diff --git a/calcpad-web/src/styles/auth.css b/calcpad-web/src/styles/auth.css new file mode 100644 index 0000000..750b8f4 --- /dev/null +++ b/calcpad-web/src/styles/auth.css @@ -0,0 +1,159 @@ +/* ---------- Auth Modal ---------- */ + +.auth-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + backdrop-filter: blur(2px); +} + +.auth-modal { + position: relative; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 12px; + padding: 32px; + width: 100%; + max-width: 380px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2); + animation: auth-slide-in 0.2s ease; +} + +@keyframes auth-slide-in { + from { opacity: 0; transform: translateY(-10px) scale(0.98); } + to { opacity: 1; transform: translateY(0) scale(1); } +} + +.auth-modal h2 { + margin: 0 0 20px; + font-size: 20px; + font-weight: 600; + color: var(--text-h); +} + +.auth-field { + margin-bottom: 16px; +} + +.auth-field label { + display: block; + font-size: 13px; + font-weight: 500; + color: var(--text); + margin-bottom: 6px; +} + +.auth-field input { + width: 100%; + padding: 10px 12px; + font-size: 14px; + font-family: var(--sans); + color: var(--text-h); + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 6px; + outline: none; + transition: border-color 0.15s; + box-sizing: border-box; +} + +.auth-field input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--accent-bg); +} + +.auth-field input:disabled { + opacity: 0.6; +} + +.auth-btn { + width: 100%; + padding: 10px; + font-size: 14px; + font-weight: 600; + font-family: var(--sans); + border: none; + border-radius: 6px; + cursor: pointer; + transition: opacity 0.15s; + margin-top: 8px; +} + +.auth-btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.auth-btn-primary { + background: var(--accent); + color: #fff; +} + +.auth-btn-primary:hover:not(:disabled) { + opacity: 0.9; +} + +.auth-error { + color: var(--error); + font-size: 13px; + margin: 0 0 8px; +} + +.auth-success-msg { + color: var(--text); + font-size: 14px; + line-height: 1.5; + margin: 0 0 20px; +} + +.auth-renewal-msg { + color: var(--text); + font-size: 13px; + line-height: 1.5; + margin: -12px 0 16px; +} + +.auth-toggle { + text-align: center; + font-size: 13px; + color: var(--text); + margin: 16px 0 0; +} + +.auth-link { + background: none; + border: none; + color: var(--accent); + font-size: 13px; + font-family: var(--sans); + cursor: pointer; + text-decoration: underline; + padding: 0; +} + +.auth-link:hover { + opacity: 0.8; +} + +.auth-close { + position: absolute; + top: 12px; + right: 12px; + background: none; + border: none; + font-size: 22px; + color: var(--text); + cursor: pointer; + padding: 4px; + line-height: 1; + opacity: 0.5; + transition: opacity 0.15s; +} + +.auth-close:hover { + opacity: 1; +} diff --git a/calcpad-web/src/styles/collab-indicator.css b/calcpad-web/src/styles/collab-indicator.css new file mode 100644 index 0000000..209e5d6 --- /dev/null +++ b/calcpad-web/src/styles/collab-indicator.css @@ -0,0 +1,39 @@ +/* ---------- Collab Indicator ---------- */ + +.collab-indicator { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + border-radius: 10px; + background: var(--bg-secondary); + border: 1px solid var(--border); + font-size: 11px; + font-family: var(--sans); + color: var(--text); +} + +.collab-dot { + width: 6px; + height: 6px; + border-radius: 50%; +} + +.collab-dot.connected { + background: var(--success); + box-shadow: 0 0 4px var(--success); +} + +.collab-dot.connecting { + background: var(--warning); + animation: collab-pulse 1s infinite; +} + +@keyframes collab-pulse { + 0%, 100% { opacity: 0.4; } + 50% { opacity: 1; } +} + +.collab-count { + font-weight: 600; +} diff --git a/calcpad-web/src/styles/font-size-control.css b/calcpad-web/src/styles/font-size-control.css new file mode 100644 index 0000000..89352db --- /dev/null +++ b/calcpad-web/src/styles/font-size-control.css @@ -0,0 +1,57 @@ +/* ---------- Font Size Control ---------- */ + +.font-size-control { + display: inline-flex; + align-items: center; + gap: 2px; +} + +.font-size-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 22px; + padding: 0; + border: 1px solid transparent; + border-radius: 3px; + background: transparent; + color: var(--text); + font-size: 11px; + font-family: var(--sans); + font-weight: 600; + cursor: pointer; + transition: background 0.1s, border-color 0.1s, color 0.1s; +} + +.font-size-btn:hover:not(:disabled) { + background: var(--accent-bg); + border-color: var(--border); + color: var(--text-h); +} + +.font-size-btn:active:not(:disabled) { + background: var(--accent-bg); + border-color: var(--accent-border); + color: var(--accent); +} + +.font-size-btn:disabled { + opacity: 0.3; + cursor: default; +} + +.font-size-value { + font-size: 11px; + font-family: var(--mono); + color: var(--text); + min-width: 18px; + text-align: center; + user-select: none; +} + +@media (max-width: 768px) { + .font-size-control { + display: none; + } +} diff --git a/calcpad-web/src/styles/format-toolbar.css b/calcpad-web/src/styles/format-toolbar.css index 3d73535..d4133ac 100644 --- a/calcpad-web/src/styles/format-toolbar.css +++ b/calcpad-web/src/styles/format-toolbar.css @@ -52,12 +52,27 @@ font-family: Georgia, serif; } +.format-preview-toggle { + width: auto; + padding: 0 6px; + gap: 3px; +} + +.format-preview-label { + font-size: 10px; + opacity: 0.7; +} + .format-preview-toggle.active { background: var(--accent-bg); border-color: var(--accent-border); color: var(--accent); } +.format-preview-toggle.active .format-preview-label { + opacity: 1; +} + /* ---------- Color Buttons ---------- */ .format-colors { diff --git a/calcpad-web/src/styles/index.css b/calcpad-web/src/styles/index.css index fbf8c4b..ad1b9ca 100644 --- a/calcpad-web/src/styles/index.css +++ b/calcpad-web/src/styles/index.css @@ -3,7 +3,7 @@ :root { --sans: system-ui, 'Segoe UI', Roboto, sans-serif; --heading: system-ui, 'Segoe UI', Roboto, sans-serif; - --mono: ui-monospace, Consolas, 'Courier New', monospace; + --mono: 'JetBrains Mono', ui-monospace, Consolas, 'Courier New', monospace; --warning: #f59e0b; --warning-bg: rgba(245, 158, 11, 0.1); @@ -111,7 +111,7 @@ --result-datetime: #39ff14; --result-boolean: #00ff41; - --mono: 'Courier New', 'Fira Code', monospace; + --mono: 'JetBrains Mono', 'Courier New', 'Fira Code', monospace; --success: #00ff41; --error: #ff0000; } diff --git a/calcpad-web/src/styles/results-panel.css b/calcpad-web/src/styles/results-panel.css index a1250f4..be80006 100644 --- a/calcpad-web/src/styles/results-panel.css +++ b/calcpad-web/src/styles/results-panel.css @@ -9,9 +9,9 @@ .result-line { padding: 0 12px; font-family: var(--mono); - font-size: 15px; + font-size: var(--editor-font-size, 15px); line-height: 1.6; - height: 24px; + height: calc(var(--editor-font-size, 15px) * 1.6); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; diff --git a/calcpad-web/src/styles/share-dialog.css b/calcpad-web/src/styles/share-dialog.css new file mode 100644 index 0000000..c7ea7db --- /dev/null +++ b/calcpad-web/src/styles/share-dialog.css @@ -0,0 +1,181 @@ +/* ---------- Share Dialog ---------- */ + +.share-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + backdrop-filter: blur(2px); +} + +.share-dialog { + position: relative; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 12px; + padding: 28px; + width: 100%; + max-width: 420px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2); + animation: share-in 0.2s ease; +} + +@keyframes share-in { + from { opacity: 0; transform: translateY(-10px) scale(0.98); } + to { opacity: 1; transform: translateY(0) scale(1); } +} + +.share-dialog h2 { + margin: 0 0 20px; + font-size: 18px; + font-weight: 600; + color: var(--text-h); + padding-right: 24px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.share-close { + position: absolute; + top: 12px; + right: 12px; + background: none; + border: none; + font-size: 22px; + color: var(--text); + cursor: pointer; + padding: 4px; + line-height: 1; + opacity: 0.5; +} + +.share-close:hover { + opacity: 1; +} + +.share-permission { + margin-bottom: 20px; +} + +.share-permission label { + display: block; + font-size: 13px; + font-weight: 500; + color: var(--text); + margin-bottom: 8px; +} + +.share-permission-options { + display: flex; + gap: 8px; +} + +.share-perm-btn { + flex: 1; + padding: 8px; + font-size: 13px; + font-family: var(--sans); + font-weight: 500; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text-h); + cursor: pointer; + transition: all 0.15s; +} + +.share-perm-btn.active { + background: var(--accent); + border-color: var(--accent); + color: #fff; +} + +.share-perm-btn:hover:not(.active) { + background: var(--accent-bg); +} + +.share-link-section { + display: flex; + flex-direction: column; + gap: 12px; +} + +.share-link-row { + display: flex; + gap: 8px; +} + +.share-link-input { + flex: 1; + padding: 8px 12px; + font-size: 13px; + font-family: var(--mono); + color: var(--text-h); + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 6px; + outline: none; +} + +.share-link-input:focus { + border-color: var(--accent); +} + +.share-copy-btn { + padding: 8px 16px; + font-size: 13px; + font-weight: 600; + font-family: var(--sans); + background: var(--accent); + color: #fff; + border: none; + border-radius: 6px; + cursor: pointer; + white-space: nowrap; + transition: opacity 0.15s; +} + +.share-copy-btn:hover { + opacity: 0.9; +} + +.share-create-btn { + width: 100%; + padding: 10px; + font-size: 14px; + font-weight: 600; + font-family: var(--sans); + background: var(--accent); + color: #fff; + border: none; + border-radius: 6px; + cursor: pointer; + transition: opacity 0.15s; +} + +.share-create-btn:hover:not(:disabled) { + opacity: 0.9; +} + +.share-create-btn:disabled { + opacity: 0.6; +} + +.share-revoke-btn { + background: none; + border: none; + color: var(--error); + font-size: 13px; + font-family: var(--sans); + cursor: pointer; + padding: 0; + text-align: left; +} + +.share-revoke-btn:hover { + text-decoration: underline; +} diff --git a/calcpad-web/src/styles/user-menu.css b/calcpad-web/src/styles/user-menu.css new file mode 100644 index 0000000..4473ef4 --- /dev/null +++ b/calcpad-web/src/styles/user-menu.css @@ -0,0 +1,109 @@ +/* ---------- User Menu ---------- */ + +.user-menu-login-btn { + display: inline-flex; + align-items: center; + padding: 4px 12px; + font-size: 12px; + font-weight: 500; + font-family: var(--sans); + color: var(--accent); + background: transparent; + border: 1px solid var(--accent-border); + border-radius: 4px; + cursor: pointer; + transition: background 0.15s; + white-space: nowrap; +} + +.user-menu-login-btn:hover { + background: var(--accent-bg); +} + +.user-menu-container { + position: relative; +} + +.user-menu-avatar { + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + border-radius: 50%; + background: var(--accent); + color: #fff; + font-size: 12px; + font-weight: 600; + font-family: var(--sans); + border: none; + cursor: pointer; + transition: opacity 0.15s; +} + +.user-menu-avatar:hover { + opacity: 0.85; +} + +.user-menu-dropdown { + position: absolute; + top: calc(100% + 6px); + right: 0; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 8px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15); + min-width: 200px; + padding: 6px 0; + z-index: 200; + animation: user-menu-in 0.15s ease; +} + +@keyframes user-menu-in { + from { opacity: 0; transform: translateY(-4px) scale(0.97); } + to { opacity: 1; transform: translateY(0) scale(1); } +} + +.user-menu-email { + padding: 8px 14px; + font-size: 12px; + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.user-menu-separator { + height: 1px; + background: var(--border); + margin: 4px 0; +} + +.user-menu-item { + display: block; + width: 100%; + padding: 8px 14px; + font-size: 13px; + font-family: var(--sans); + color: var(--text-h); + background: none; + border: none; + cursor: pointer; + text-align: left; + transition: background 0.1s; +} + +.user-menu-item:hover { + background: var(--accent-bg); +} + +.user-menu-signout { + color: var(--error); +} + +@media (max-width: 768px) { + .user-menu-login-btn { + font-size: 11px; + padding: 3px 8px; + } +} diff --git a/calcpad-web/src/sync/migrateLegacy.ts b/calcpad-web/src/sync/migrateLegacy.ts new file mode 100644 index 0000000..32ccc0d --- /dev/null +++ b/calcpad-web/src/sync/migrateLegacy.ts @@ -0,0 +1,90 @@ +import * as Y from 'yjs' +import { supabase } from '../auth/supabase.ts' + +const MIGRATED_KEY = 'calctext-migrated-to-cloud' + +interface LegacyDoc { + id: string + title: string + content: string + folderId: string | null + isFavorite: boolean + createdAt: string + updatedAt: string +} + +/** + * Migrate documents from localStorage to Supabase + IndexedDB. + * Only runs once per user. Idempotent. + */ +export async function migrateLegacyDocuments(userId: string): Promise { + if (!supabase) return 0 + + // Check if already migrated + const migratedFlag = localStorage.getItem(MIGRATED_KEY) + if (migratedFlag === userId) return 0 + + // Read legacy documents from localStorage + let legacyDocs: LegacyDoc[] = [] + try { + const raw = localStorage.getItem('calctext-documents') + if (raw) legacyDocs = JSON.parse(raw) + } catch { + return 0 + } + + if (legacyDocs.length === 0) { + localStorage.setItem(MIGRATED_KEY, userId) + return 0 + } + + // Upload each document to Supabase + let migrated = 0 + for (const doc of legacyDocs) { + try { + // Create document metadata in Supabase + const { data: cloudDoc, error: docError } = await supabase + .from('documents') + .insert({ + owner_id: userId, + title: doc.title, + folder_id: null, // Folders migrated separately if needed + is_favorite: doc.isFavorite, + created_at: doc.createdAt, + updated_at: doc.updatedAt, + }) + .select('id') + .single() + + if (docError || !cloudDoc) { + console.error('Failed to migrate doc:', doc.title, docError) + continue + } + + // Create Y.Doc with the document content + const ydoc = new Y.Doc() + const ytext = ydoc.getText('content') + ytext.insert(0, doc.content) + const state = Y.encodeStateAsUpdate(ydoc) + + // Store Y.Doc snapshot + await supabase + .from('ydoc_snapshots') + .upsert({ + document_id: cloudDoc.id, + state: Array.from(state), // Store as integer array (JSON-compatible) + updated_at: new Date().toISOString(), + }) + + ydoc.destroy() + migrated++ + } catch (err) { + console.error('Migration error for doc:', doc.title, err) + } + } + + // Mark migration complete + localStorage.setItem(MIGRATED_KEY, userId) + + return migrated +} diff --git a/calcpad-web/src/sync/useCloudSync.ts b/calcpad-web/src/sync/useCloudSync.ts new file mode 100644 index 0000000..5fcfadd --- /dev/null +++ b/calcpad-web/src/sync/useCloudSync.ts @@ -0,0 +1,103 @@ +import { useEffect, useCallback, useRef } from 'react' +import { supabase } from '../auth/supabase.ts' +import { useAuth } from '../auth/AuthProvider.tsx' +import { migrateLegacyDocuments } from './migrateLegacy.ts' + +export interface CloudDocument { + id: string + owner_id: string + title: string + folder_id: string | null + is_favorite: boolean + share_token: string | null + share_permission: string | null + created_at: string + updated_at: string +} + +/** + * Hook to sync document metadata with Supabase. + * Handles initial migration from localStorage and ongoing sync. + */ +export function useCloudSync() { + const auth = useAuth() + const migrationDone = useRef(false) + + // Run legacy migration on first login + useEffect(() => { + if (!auth.isAuthenticated || !auth.user || migrationDone.current) return + migrationDone.current = true + + migrateLegacyDocuments(auth.user.id).then(count => { + if (count > 0) { + console.log(`Migrated ${count} documents to cloud`) + } + }) + }, [auth.isAuthenticated, auth.user]) + + /** Fetch all documents for the current user */ + const fetchDocuments = useCallback(async (): Promise => { + if (!supabase || !auth.user) return [] + + const { data, error } = await supabase + .from('documents') + .select('*') + .eq('owner_id', auth.user.id) + .order('updated_at', { ascending: false }) + + if (error) { + console.error('Failed to fetch documents:', error) + return [] + } + + return data ?? [] + }, [auth.user]) + + /** Create a new document in the cloud */ + const createCloudDocument = useCallback(async (title: string): Promise => { + if (!supabase || !auth.user) return null + + const { data, error } = await supabase + .from('documents') + .insert({ + owner_id: auth.user.id, + title, + }) + .select() + .single() + + if (error) { + console.error('Failed to create document:', error) + return null + } + + return data + }, [auth.user]) + + /** Update document metadata */ + const updateCloudDocument = useCallback(async ( + id: string, + updates: Partial>, + ): Promise => { + if (!supabase) return + + await supabase + .from('documents') + .update({ ...updates, updated_at: new Date().toISOString() }) + .eq('id', id) + }, []) + + /** Delete a document */ + const deleteCloudDocument = useCallback(async (id: string): Promise => { + if (!supabase) return + await supabase.from('documents').delete().eq('id', id) + }, []) + + return { + fetchDocuments, + createCloudDocument, + updateCloudDocument, + deleteCloudDocument, + isAuthenticated: auth.isAuthenticated, + } +} diff --git a/calcpad-web/vite.config.ts b/calcpad-web/vite.config.ts index d6fcf81..3d752e4 100644 --- a/calcpad-web/vite.config.ts +++ b/calcpad-web/vite.config.ts @@ -55,6 +55,17 @@ export default defineConfig({ networkTimeoutSeconds: 5, }, }, + { + urlPattern: /^https:\/\/fonts\.(?:googleapis|gstatic)\.com/, + handler: 'CacheFirst', + options: { + cacheName: 'google-fonts', + expiration: { + maxEntries: 10, + maxAgeSeconds: 60 * 60 * 24 * 365, + }, + }, + }, { urlPattern: /\.(?:png|jpg|jpeg|svg|gif|webp)$/, handler: 'CacheFirst', diff --git a/collab-server/Dockerfile b/collab-server/Dockerfile new file mode 100644 index 0000000..5e41bf3 --- /dev/null +++ b/collab-server/Dockerfile @@ -0,0 +1,13 @@ +FROM node:22-alpine + +WORKDIR /app + +COPY package.json package-lock.json* ./ +RUN npm install --production + +COPY . . +RUN npx tsc + +EXPOSE 4000 + +CMD ["node", "dist/index.js"] diff --git a/collab-server/package.json b/collab-server/package.json new file mode 100644 index 0000000..0f1a239 --- /dev/null +++ b/collab-server/package.json @@ -0,0 +1,24 @@ +{ + "name": "calctext-collab-server", + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc", + "start": "node dist/index.js" + }, + "dependencies": { + "@hocuspocus/server": "^2.13.0", + "@hocuspocus/extension-database": "^2.13.0", + "jsonwebtoken": "^9.0.0", + "pg": "^8.13.0", + "yjs": "^13.6.0" + }, + "devDependencies": { + "@types/jsonwebtoken": "^9.0.0", + "@types/node": "^22.0.0", + "@types/pg": "^8.11.0", + "tsx": "^4.19.0", + "typescript": "^5.9.0" + } +} diff --git a/collab-server/src/auth.ts b/collab-server/src/auth.ts new file mode 100644 index 0000000..8d49704 --- /dev/null +++ b/collab-server/src/auth.ts @@ -0,0 +1,24 @@ +import jwt from 'jsonwebtoken' + +const JWT_SECRET = process.env.JWT_SECRET || 'super-secret-jwt-token-for-calctext-local-dev-only' + +interface JWTPayload { + sub: string + email?: string + role?: string + exp?: number + iat?: number +} + +/** + * Verify a Supabase JWT token. + * Returns the decoded payload or null if invalid. + */ +export function verifyToken(token: string): JWTPayload | null { + try { + const decoded = jwt.verify(token, JWT_SECRET) as JWTPayload + return decoded + } catch { + return null + } +} diff --git a/collab-server/src/index.ts b/collab-server/src/index.ts new file mode 100644 index 0000000..a920029 --- /dev/null +++ b/collab-server/src/index.ts @@ -0,0 +1,50 @@ +import { Hocuspocus } from '@hocuspocus/server' +import { Database } from '@hocuspocus/extension-database' +import { verifyToken } from './auth.js' +import { fetchDocument, storeDocument } from './storage.js' + +const PORT = parseInt(process.env.PORT || '4000', 10) + +const server = new Hocuspocus({ + port: PORT, + + async onAuthenticate({ token, documentName }) { + // Verify JWT from Supabase + const user = verifyToken(token) + if (!user) { + throw new Error('Unauthorized') + } + + // Extract document ID from room name (format: doc:{uuid}) + const docId = documentName.replace('doc:', '') + + // TODO: Check if user has access to this document + // For now, any authenticated user can access any document + // In production, check documents table + document_collaborators + + return { + user: { + id: user.sub, + email: user.email, + }, + } + }, + + extensions: [ + new Database({ + async fetch({ documentName }) { + const docId = documentName.replace('doc:', '') + return fetchDocument(docId) + }, + + async store({ documentName, state }) { + const docId = documentName.replace('doc:', '') + await storeDocument(docId, state) + }, + }), + ], +}) + +server.listen().then(() => { + console.log(`Hocuspocus collaboration server running on port ${PORT}`) +}) diff --git a/collab-server/src/storage.ts b/collab-server/src/storage.ts new file mode 100644 index 0000000..382e26c --- /dev/null +++ b/collab-server/src/storage.ts @@ -0,0 +1,51 @@ +import pg from 'pg' + +const DATABASE_URL = process.env.DATABASE_URL || 'postgres://postgres:postgres@localhost:5432/calctext' + +const pool = new pg.Pool({ connectionString: DATABASE_URL }) + +/** + * Fetch a Y.Doc snapshot from the database. + * Returns the binary state or null if not found. + */ +export async function fetchDocument(documentId: string): Promise { + try { + const result = await pool.query( + 'SELECT state FROM ydoc_snapshots WHERE document_id = $1', + [documentId], + ) + + if (result.rows.length === 0) return null + + const state = result.rows[0].state + // state is stored as BYTEA in PostgreSQL, pg returns it as a Buffer + if (Buffer.isBuffer(state)) { + return new Uint8Array(state) + } + // If stored as integer array (from JSON), convert + if (Array.isArray(state)) { + return new Uint8Array(state) + } + + return null + } catch (error) { + console.error('Failed to fetch document:', documentId, error) + return null + } +} + +/** + * Store a Y.Doc snapshot to the database. + */ +export async function storeDocument(documentId: string, state: Uint8Array): Promise { + try { + await pool.query( + `INSERT INTO ydoc_snapshots (document_id, state, updated_at) + VALUES ($1, $2, NOW()) + ON CONFLICT (document_id) DO UPDATE SET state = $2, updated_at = NOW()`, + [documentId, Buffer.from(state)], + ) + } catch (error) { + console.error('Failed to store document:', documentId, error) + } +} diff --git a/collab-server/tsconfig.json b/collab-server/tsconfig.json new file mode 100644 index 0000000..2c1f0a5 --- /dev/null +++ b/collab-server/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["src"] +} diff --git a/docker-compose.yml b/docker-compose.yml index 8e46ffc..492ad76 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,85 @@ services: + # PostgreSQL database + db: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres} + POSTGRES_DB: calctext + volumes: + - calctext-db:/var/lib/postgresql/data + - ./supabase/migrations:/docker-entrypoint-initdb.d + healthcheck: + test: pg_isready -U postgres + interval: 5s + timeout: 5s + retries: 10 + + # Supabase Auth (GoTrue) + auth: + image: supabase/gotrue:v2.170.0 + restart: unless-stopped + depends_on: + db: + condition: service_healthy + environment: + GOTRUE_API_HOST: 0.0.0.0 + GOTRUE_API_PORT: 9999 + API_EXTERNAL_URL: ${SITE_URL:-http://localhost:5173}/auth + GOTRUE_DB_DRIVER: postgres + GOTRUE_DB_DATABASE_URL: postgres://postgres:${DB_PASSWORD:-postgres}@db:5432/calctext?sslmode=disable + GOTRUE_SITE_URL: ${SITE_URL:-http://localhost:5173} + GOTRUE_URI_ALLOW_LIST: ${SITE_URL:-http://localhost:5173}/** + GOTRUE_DISABLE_SIGNUP: 'false' + GOTRUE_JWT_SECRET: ${JWT_SECRET} + GOTRUE_JWT_EXP: 3600 + GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated + GOTRUE_EXTERNAL_EMAIL_ENABLED: 'true' + GOTRUE_MAILER_AUTOCONFIRM: 'true' + GOTRUE_SMTP_ADMIN_EMAIL: admin@calctext.local + + # PostgREST (REST API for database) + rest: + image: postgrest/postgrest:v12.2.8 + restart: unless-stopped + depends_on: + db: + condition: service_healthy + environment: + PGRST_DB_URI: postgres://postgres:${DB_PASSWORD:-postgres}@db:5432/calctext + PGRST_DB_SCHEMAS: public + PGRST_DB_ANON_ROLE: anon + PGRST_JWT_SECRET: ${JWT_SECRET} + PGRST_DB_USE_LEGACY_GUCS: 'false' + + # Hocuspocus collaboration server + collab: + build: ./collab-server + restart: unless-stopped + depends_on: + db: + condition: service_healthy + environment: + PORT: 4000 + DATABASE_URL: postgres://postgres:${DB_PASSWORD:-postgres}@db:5432/calctext + JWT_SECRET: ${JWT_SECRET} + + # Web frontend (nginx + static files) web: - build: . + build: + context: . + args: + VITE_SUPABASE_URL: ${SITE_URL:-http://localhost:8080}/rest + VITE_SUPABASE_ANON_KEY: ${ANON_KEY} + VITE_AUTH_URL: ${SITE_URL:-http://localhost:8080}/auth + VITE_COLLAB_WS_URL: ${COLLAB_WS_URL:-ws://localhost:8080/ws} + restart: unless-stopped ports: - - "8080:8080" + - "${PORT:-8080}:8080" + depends_on: + - auth + - rest + - collab + +volumes: + calctext-db: diff --git a/nginx.conf b/nginx.conf index 697886b..67883db 100644 --- a/nginx.conf +++ b/nginx.conf @@ -5,10 +5,41 @@ server { index index.html; # SPA fallback — serve index.html for all non-file routes + # Handles /s/{token} share links and other client-side routes location / { try_files $uri $uri/ /index.html; } + # Reverse proxy: PostgREST (database REST API) + location /rest/ { + proxy_pass http://rest:3000/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Reverse proxy: GoTrue (auth) + location /auth/ { + proxy_pass http://auth:9999/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Reverse proxy: Hocuspocus (WebSocket collaboration) + location /ws/ { + proxy_pass http://collab:4000/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_read_timeout 86400; + } + # Cache static assets aggressively (hashed filenames) location /assets/ { expires 1y; diff --git a/supabase/migrations/001_create_schema.sql b/supabase/migrations/001_create_schema.sql new file mode 100644 index 0000000..3611e88 --- /dev/null +++ b/supabase/migrations/001_create_schema.sql @@ -0,0 +1,165 @@ +-- CalcText database schema +-- Run via Docker init or manually + +-- Enable UUID generation +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +-- Roles for RLS +DO $$ BEGIN + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'anon') THEN + CREATE ROLE anon NOLOGIN; + END IF; + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'authenticated') THEN + CREATE ROLE authenticated NOLOGIN; + END IF; +END $$; + +GRANT USAGE ON SCHEMA public TO anon, authenticated; + +-- ============================================================ +-- User profiles (extends GoTrue auth.users) +-- ============================================================ +CREATE TABLE IF NOT EXISTS profiles ( + id UUID PRIMARY KEY, + display_name TEXT, + avatar_url TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +ALTER TABLE profiles ENABLE ROW LEVEL SECURITY; + +CREATE POLICY profiles_own ON profiles + FOR ALL USING (id = current_setting('request.jwt.claims', true)::json->>'sub'::text::uuid); + +GRANT SELECT, INSERT, UPDATE ON profiles TO authenticated; + +-- ============================================================ +-- Folders +-- ============================================================ +CREATE TABLE IF NOT EXISTS folders ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + owner_id UUID NOT NULL, + name TEXT NOT NULL DEFAULT 'New Folder', + parent_id UUID REFERENCES folders(id) ON DELETE CASCADE, + sort_order INTEGER DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +ALTER TABLE folders ENABLE ROW LEVEL SECURITY; + +CREATE POLICY folders_own ON folders + FOR ALL USING (owner_id = current_setting('request.jwt.claims', true)::json->>'sub'::text::uuid); + +GRANT SELECT, INSERT, UPDATE, DELETE ON folders TO authenticated; + +-- ============================================================ +-- Documents +-- ============================================================ +CREATE TABLE IF NOT EXISTS documents ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + owner_id UUID NOT NULL, + title TEXT NOT NULL DEFAULT 'Untitled', + folder_id UUID REFERENCES folders(id) ON DELETE SET NULL, + is_favorite BOOLEAN NOT NULL DEFAULT false, + share_token TEXT UNIQUE, + share_permission TEXT CHECK (share_permission IN ('view', 'edit')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_documents_owner ON documents(owner_id); +CREATE INDEX idx_documents_share_token ON documents(share_token) WHERE share_token IS NOT NULL; + +ALTER TABLE documents ENABLE ROW LEVEL SECURITY; + +-- Owner can do anything +CREATE POLICY documents_owner ON documents + FOR ALL USING (owner_id = current_setting('request.jwt.claims', true)::json->>'sub'::text::uuid); + +-- Shared documents can be read via share token (anon access) +CREATE POLICY documents_shared_read ON documents + FOR SELECT USING (share_token IS NOT NULL AND share_permission IS NOT NULL); + +GRANT SELECT, INSERT, UPDATE, DELETE ON documents TO authenticated; +GRANT SELECT ON documents TO anon; + +-- ============================================================ +-- Document collaborators (explicit user-to-user sharing) +-- ============================================================ +CREATE TABLE IF NOT EXISTS document_collaborators ( + document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE, + user_id UUID NOT NULL, + permission TEXT NOT NULL CHECK (permission IN ('view', 'edit')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (document_id, user_id) +); + +ALTER TABLE document_collaborators ENABLE ROW LEVEL SECURITY; + +-- Users can see their own collaborations +CREATE POLICY collab_own ON document_collaborators + FOR SELECT USING (user_id = current_setting('request.jwt.claims', true)::json->>'sub'::text::uuid); + +-- Document owners can manage collaborators +CREATE POLICY collab_owner ON document_collaborators + FOR ALL USING ( + document_id IN ( + SELECT id FROM documents + WHERE owner_id = current_setting('request.jwt.claims', true)::json->>'sub'::text::uuid + ) + ); + +GRANT SELECT, INSERT, UPDATE, DELETE ON document_collaborators TO authenticated; + +-- ============================================================ +-- Device sessions (password renewal per device) +-- ============================================================ +CREATE TABLE IF NOT EXISTS device_sessions ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL, + device_fingerprint TEXT NOT NULL, + device_name TEXT, + session_ttl_seconds INTEGER NOT NULL DEFAULT 86400, + last_password_auth_at TIMESTAMPTZ NOT NULL DEFAULT now(), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (user_id, device_fingerprint) +); + +ALTER TABLE device_sessions ENABLE ROW LEVEL SECURITY; + +CREATE POLICY device_sessions_own ON device_sessions + FOR ALL USING (user_id = current_setting('request.jwt.claims', true)::json->>'sub'::text::uuid); + +GRANT SELECT, INSERT, UPDATE, DELETE ON device_sessions TO authenticated; + +-- ============================================================ +-- Y.js document snapshots storage +-- ============================================================ +CREATE TABLE IF NOT EXISTS ydoc_snapshots ( + document_id UUID PRIMARY KEY REFERENCES documents(id) ON DELETE CASCADE, + state BYTEA NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +ALTER TABLE ydoc_snapshots ENABLE ROW LEVEL SECURITY; + +-- Owner can read/write snapshots +CREATE POLICY ydoc_owner ON ydoc_snapshots + FOR ALL USING ( + document_id IN ( + SELECT id FROM documents + WHERE owner_id = current_setting('request.jwt.claims', true)::json->>'sub'::text::uuid + ) + ); + +-- Collaborators can read/write snapshots +CREATE POLICY ydoc_collab ON ydoc_snapshots + FOR ALL USING ( + document_id IN ( + SELECT document_id FROM document_collaborators + WHERE user_id = current_setting('request.jwt.claims', true)::json->>'sub'::text::uuid + AND permission = 'edit' + ) + ); + +GRANT SELECT, INSERT, UPDATE ON ydoc_snapshots TO authenticated;