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) <noreply@anthropic.com>
This commit is contained in:
2026-03-19 16:21:04 -04:00
parent 42d88fd7b4
commit ef302ebda9
49 changed files with 2499 additions and 36 deletions

6
.gitignore vendored
View File

@@ -22,6 +22,12 @@ dist/
*.swo
*~
# Environment
.env
.env.production
.env.local
.env.*.local
# OS
.DS_Store
Thumbs.db

View File

@@ -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

3
calcpad-web/.env.example Normal file
View File

@@ -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

View File

@@ -11,6 +11,7 @@ node_modules
dist
dist-ssr
*.local
.env
# Editor directories and files
.vscode/*

View File

@@ -10,6 +10,9 @@
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="CalcPad" />
<link rel="apple-touch-icon" href="/icons/icon-192.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet" />
<title>CalcText</title>
<script>
// Apply theme before React mounts to prevent FOUC
@@ -20,6 +23,9 @@
t = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
document.documentElement.setAttribute('data-theme', t);
// Apply font size before React mounts
var fs = localStorage.getItem('calctext-fontsize');
if (fs) document.documentElement.style.setProperty('--editor-font-size', fs + 'px');
})();
</script>
</head>

View File

@@ -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",

View File

@@ -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<EditorView | null>(null)
const resultsPanelRef = useRef<HTMLDivElement>(null)
const [modifiedIds, setModifiedIds] = useState<Set<string>>(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}
/>
<FontSizeControl
fontSize={fontSizeCtx.fontSize}
onFontSizeChange={fontSizeCtx.setFontSize}
min={fontSizeCtx.MIN_SIZE}
max={fontSizeCtx.MAX_SIZE}
/>
<ThemePicker
theme={themeCtx.theme}
resolvedTheme={themeCtx.resolvedTheme}
accentColor={themeCtx.accentColor}
onThemeChange={themeCtx.setTheme}
onAccentChange={themeCtx.setAccent}
/>
<UserMenu
onOpenAuth={() => setShowAuthModal(true)}
onOpenSecurity={() => setShowSecuritySettings(true)}
/>
</div>
</header>
@@ -340,6 +383,21 @@ function App() {
onInstall={installPrompt.handleInstall}
onDismiss={installPrompt.handleDismiss}
/>
{showAuthModal && (
<AuthModal onClose={() => setShowAuthModal(false)} />
)}
{auth.needsPasswordRenewal && (
<AuthModal
onClose={() => auth.clearPasswordRenewal()}
renewalMode
/>
)}
{showSecuritySettings && (
<SecuritySettings onClose={() => setShowSecuritySettings(false)} />
)}
</div>
)
}

View File

@@ -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<string | null>(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 (
<div className="auth-backdrop" onClick={onClose}>
<div className="auth-modal" onClick={e => e.stopPropagation()}>
<h2>Check your email</h2>
<p className="auth-success-msg">
We sent a confirmation link to <strong>{email}</strong>.
Click the link to activate your account, then sign in.
</p>
<button className="auth-btn auth-btn-primary" onClick={onClose}>
Got it
</button>
</div>
</div>
)
}
return (
<div className="auth-backdrop" onClick={renewalMode ? undefined : onClose}>
<div className="auth-modal" onClick={e => e.stopPropagation()}>
<h2>{renewalMode ? 'Re-enter password' : mode === 'signin' ? 'Sign In' : 'Create Account'}</h2>
{renewalMode && (
<p className="auth-renewal-msg">
Your session on this device has expired. Please re-enter your password.
</p>
)}
<form onSubmit={handleSubmit}>
<div className="auth-field">
<label htmlFor="auth-email">Email</label>
<input
id="auth-email"
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
disabled={renewalMode}
required
autoFocus={!renewalMode}
autoComplete="email"
/>
</div>
<div className="auth-field">
<label htmlFor="auth-password">Password</label>
<input
id="auth-password"
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
required
autoFocus={renewalMode}
autoComplete={mode === 'signup' ? 'new-password' : 'current-password'}
minLength={6}
/>
</div>
{error && <p className="auth-error">{error}</p>}
<button className="auth-btn auth-btn-primary" type="submit" disabled={loading}>
{loading ? 'Loading...' : renewalMode ? 'Confirm' : mode === 'signin' ? 'Sign In' : 'Create Account'}
</button>
</form>
{!renewalMode && (
<p className="auth-toggle">
{mode === 'signin' ? (
<>Don&apos;t have an account?{' '}<button className="auth-link" onClick={() => { setMode('signup'); setError(null) }}>Sign up</button></>
) : (
<>Already have an account?{' '}<button className="auth-link" onClick={() => { setMode('signin'); setError(null) }}>Sign in</button></>
)}
</p>
)}
{!renewalMode && (
<button className="auth-close" onClick={onClose} aria-label="Close" title="Close">
&times;
</button>
)}
</div>
</div>
)
}

View File

@@ -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<void>
clearPasswordRenewal: () => void
}
const AuthContext = createContext<AuthContextValue | null>(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<User | null>(null)
const [session, setSession] = useState<Session | null>(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 (
<AuthContext.Provider
value={{
user,
session,
isAuthenticated: !!user,
isLoading,
needsPasswordRenewal,
configured,
signUp,
signIn,
signOut,
clearPasswordRenewal,
}}
>
{children}
</AuthContext.Provider>
)
}

View File

@@ -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<DeviceSession | null> {
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<boolean> {
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<void> {
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<DeviceSession | null> {
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
}

View File

@@ -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
}

View File

@@ -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<CollabContextValue | null>(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 (
<CollabContext.Provider
value={{
ydoc,
ytext,
awareness,
ready,
connected,
peerCount,
initContent,
getContent,
}}
>
{children}
</CollabContext.Provider>
)
}

View File

@@ -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
}

View File

@@ -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<WebsocketProvider | null>(null)
const [awareness, setAwareness] = useState<Awareness | null>(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 }
}

View File

@@ -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<Y.Doc | null>(null)
const persistenceRef = useRef<IndexeddbPersistence | null>(null)
const [ytext, setYtext] = useState<Y.Text | null>(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,
}
}

View File

@@ -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 (
<div className="collab-indicator" title={connected ? `${peerCount + 1} users online` : 'Connecting...'}>
<span className={`collab-dot ${connected ? 'connected' : 'connecting'}`} />
{peerCount > 0 && (
<span className="collab-count">{peerCount + 1}</span>
)}
</div>
)
}

View File

@@ -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 (
<div className="font-size-control">
<button
className="font-size-btn"
onClick={() => onFontSizeChange(fontSize - 1)}
disabled={fontSize <= min}
title="Decrease font size"
aria-label="Decrease font size"
>
A
</button>
<span className="font-size-value">{fontSize}</span>
<button
className="font-size-btn"
onClick={() => onFontSizeChange(fontSize + 1)}
disabled={fontSize >= max}
title="Increase font size"
aria-label="Increase font size"
>
A+
</button>
</div>
)
}

View File

@@ -93,7 +93,12 @@ export function FormatToolbar({ editorView, previewMode, onPreviewToggle }: Form
onClick={onPreviewToggle}
title={previewMode ? 'Show raw markdown' : 'Show formatted preview'}
>
👁
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
{!previewMode && <line x1="2" y1="2" x2="22" y2="22" stroke="currentColor" strokeWidth="2.5" />}
</svg>
<span className="format-preview-label">{previewMode ? 'Preview' : 'Raw'}</span>
</button>
</div>
@@ -124,9 +129,12 @@ export function FormatToolbar({ editorView, previewMode, onPreviewToggle }: Form
<button
className="format-btn"
onClick={() => editorView && insertPrefix(editorView, '// ')}
title="Comment (toggle // prefix)"
title="Comment — line won't be calculated"
>
//
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
<line x1="9" y1="10" x2="15" y2="10" />
</svg>
</button>
</div>

View File

@@ -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<number>(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 (
<div className="auth-backdrop" onClick={onClose}>
<div className="auth-modal" onClick={e => e.stopPropagation()}>
<h2>Security Settings</h2>
<button className="auth-close" onClick={onClose} aria-label="Close">&times;</button>
<div className="auth-field">
<label>Current Device</label>
<p style={{ margin: '4px 0', fontSize: '14px', color: 'var(--text-h)' }}>{deviceName}</p>
</div>
<div className="auth-field">
<label>Re-enter password every</label>
{loading ? (
<p style={{ fontSize: '13px', color: 'var(--text)' }}>Loading...</p>
) : (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', marginTop: '8px' }}>
{TTL_OPTIONS.map(opt => (
<button
key={opt.value}
className={`auth-btn ${currentTTL === opt.value ? 'auth-btn-primary' : ''}`}
style={{
width: 'auto',
padding: '6px 14px',
fontSize: '13px',
marginTop: 0,
background: currentTTL === opt.value ? 'var(--accent)' : 'var(--bg-secondary)',
color: currentTTL === opt.value ? '#fff' : 'var(--text-h)',
border: `1px solid ${currentTTL === opt.value ? 'var(--accent)' : 'var(--border)'}`,
}}
onClick={() => handleTTLChange(opt.value)}
disabled={saving}
>
{opt.label}
</button>
))}
</div>
)}
</div>
<p style={{ fontSize: '12px', color: 'var(--text)', marginTop: '16px', lineHeight: '1.5' }}>
This setting controls how often you need to re-enter your password on this device.
Other devices are not affected.
</p>
</div>
</div>
)
}

View File

@@ -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<HTMLDivElement>(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 (
<div className="theme-picker-container" ref={ref}>
@@ -68,13 +69,13 @@ export function ThemePicker({ theme, accentColor, onThemeChange, onAccentChange
{THEMES.map(t => (
<button
key={t.id}
className={`theme-picker-item ${resolvedTheme === t.id ? 'active' : ''}`}
className={`theme-picker-item ${theme === t.id ? 'active' : ''}`}
onClick={() => { onThemeChange(t.id); setOpen(false) }}
role="menuitem"
>
<span className="theme-picker-item-icon">{t.icon}</span>
<span className="theme-picker-item-label">{t.name}</span>
{resolvedTheme === t.id && <span className="theme-picker-check"></span>}
{theme === t.id && <span className="theme-picker-check"></span>}
</button>
))}
@@ -86,9 +87,8 @@ export function ThemePicker({ theme, accentColor, onThemeChange, onAccentChange
<button
key={c.name}
className={`theme-picker-swatch ${accentColor === c.light || accentColor === c.dark ? 'active' : ''}`}
style={{ backgroundColor: c.light }}
style={{ backgroundColor: isDark ? c.dark : c.light }}
onClick={() => {
const isDark = ['dark', 'matrix', 'midnight'].includes(resolvedTheme ?? '')
const color = isDark ? c.dark : c.light
onAccentChange(accentColor === color ? null : color)
}}

View File

@@ -0,0 +1,81 @@
import { useState, useRef, useEffect } from 'react'
import { useAuth } from '../auth/AuthProvider.tsx'
import '../styles/user-menu.css'
interface UserMenuProps {
onOpenAuth: () => void
onOpenSecurity: () => void
}
export function UserMenu({ onOpenAuth, onOpenSecurity }: UserMenuProps) {
const auth = useAuth()
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
}
function handleKey(e: KeyboardEvent) {
if (e.key === 'Escape') setOpen(false)
}
document.addEventListener('mousedown', handleClick)
document.addEventListener('keydown', handleKey)
return () => {
document.removeEventListener('mousedown', handleClick)
document.removeEventListener('keydown', handleKey)
}
}, [open])
if (!auth.configured) return null
if (!auth.isAuthenticated) {
return (
<button
className="user-menu-login-btn"
onClick={onOpenAuth}
title="Sign in"
>
Sign in
</button>
)
}
const initial = (auth.user?.email?.[0] ?? '?').toUpperCase()
return (
<div className="user-menu-container" ref={ref}>
<button
className="user-menu-avatar"
onClick={() => setOpen(p => !p)}
title={auth.user?.email ?? 'Account'}
aria-label="Account menu"
aria-expanded={open}
>
{initial}
</button>
{open && (
<div className="user-menu-dropdown" role="menu">
<div className="user-menu-email">{auth.user?.email}</div>
<div className="user-menu-separator" />
<button
className="user-menu-item"
onClick={() => { onOpenSecurity(); setOpen(false) }}
role="menuitem"
>
Security Settings
</button>
<button
className="user-menu-item user-menu-signout"
onClick={() => { auth.signOut(); setOpen(false) }}
role="menuitem"
>
Sign Out
</button>
</div>
)}
</div>
)
}

View File

@@ -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': {

View File

@@ -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:
if (!isActive) {
// Non-active lines: hide syntax, show only colored content
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,
})
} 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}` }),
})
}
}
}
}

View File

@@ -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 }
}

View File

@@ -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(
<StrictMode>
<AuthProvider>
<App />
</AuthProvider>
</StrictMode>,
)

View File

@@ -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' }
}

View File

@@ -0,0 +1,22 @@
import { useState, useEffect } from 'react'
import { parseRoute, type Route } from './routes.ts'
export function useRoute(): Route {
const [route, setRoute] = useState<Route>(() => 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'))
}

View File

@@ -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<SharePermission>(
(currentSharePermission as SharePermission) ?? 'view',
)
const [shareUrl, setShareUrl] = useState<string | null>(
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 (
<div className="share-backdrop" onClick={onClose}>
<div className="share-dialog" onClick={e => e.stopPropagation()}>
<h2>Share "{documentTitle}"</h2>
<button className="share-close" onClick={onClose}>&times;</button>
<div className="share-permission">
<label>Permission</label>
<div className="share-permission-options">
<button
className={`share-perm-btn ${permission === 'view' ? 'active' : ''}`}
onClick={() => setPermission('view')}
>
View only
</button>
<button
className={`share-perm-btn ${permission === 'edit' ? 'active' : ''}`}
onClick={() => setPermission('edit')}
>
Can edit
</button>
</div>
</div>
{shareUrl ? (
<div className="share-link-section">
<div className="share-link-row">
<input
className="share-link-input"
value={shareUrl}
readOnly
onClick={e => (e.target as HTMLInputElement).select()}
/>
<button className="share-copy-btn" onClick={handleCopy}>
{copied ? 'Copied!' : 'Copy'}
</button>
</div>
<button
className="share-revoke-btn"
onClick={handleRevokeLink}
disabled={loading}
>
Revoke link
</button>
</div>
) : (
<button
className="share-create-btn"
onClick={handleCreateLink}
disabled={loading}
>
{loading ? 'Creating...' : 'Create share link'}
</button>
)}
</div>
</div>
)
}

View File

@@ -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<ShareInfo | null> => {
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<void> => {
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 }
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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 {

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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<number> {
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
}

View File

@@ -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<CloudDocument[]> => {
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<CloudDocument | null> => {
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<Pick<CloudDocument, 'title' | 'folder_id' | 'is_favorite'>>,
): Promise<void> => {
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<void> => {
if (!supabase) return
await supabase.from('documents').delete().eq('id', id)
}, [])
return {
fetchDocuments,
createCloudDocument,
updateCloudDocument,
deleteCloudDocument,
isAuthenticated: auth.isAuthenticated,
}
}

View File

@@ -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',

13
collab-server/Dockerfile Normal file
View File

@@ -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"]

View File

@@ -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"
}
}

24
collab-server/src/auth.ts Normal file
View File

@@ -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
}
}

View File

@@ -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}`)
})

View File

@@ -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<Uint8Array | null> {
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<void> {
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)
}
}

View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src"]
}

View File

@@ -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:

View File

@@ -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;

View File

@@ -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;