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:
6
.gitignore
vendored
6
.gitignore
vendored
@@ -22,6 +22,12 @@ dist/
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.production
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
10
Dockerfile
10
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
|
||||
|
||||
3
calcpad-web/.env.example
Normal file
3
calcpad-web/.env.example
Normal 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
|
||||
1
calcpad-web/.gitignore
vendored
1
calcpad-web/.gitignore
vendored
@@ -11,6 +11,7 @@ node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
.env
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
125
calcpad-web/src/auth/AuthModal.tsx
Normal file
125
calcpad-web/src/auth/AuthModal.tsx
Normal 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'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">
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
115
calcpad-web/src/auth/AuthProvider.tsx
Normal file
115
calcpad-web/src/auth/AuthProvider.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
145
calcpad-web/src/auth/DeviceSession.ts
Normal file
145
calcpad-web/src/auth/DeviceSession.ts
Normal 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
|
||||
}
|
||||
25
calcpad-web/src/auth/supabase.ts
Normal file
25
calcpad-web/src/auth/supabase.ts
Normal 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
|
||||
}
|
||||
75
calcpad-web/src/collab/CollabProvider.tsx
Normal file
75
calcpad-web/src/collab/CollabProvider.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
27
calcpad-web/src/collab/awareness.ts
Normal file
27
calcpad-web/src/collab/awareness.ts
Normal 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
|
||||
}
|
||||
86
calcpad-web/src/collab/useWebSocketProvider.ts
Normal file
86
calcpad-web/src/collab/useWebSocketProvider.ts
Normal 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 }
|
||||
}
|
||||
68
calcpad-web/src/collab/useYDoc.ts
Normal file
68
calcpad-web/src/collab/useYDoc.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
19
calcpad-web/src/components/CollabIndicator.tsx
Normal file
19
calcpad-web/src/components/CollabIndicator.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
34
calcpad-web/src/components/FontSizeControl.tsx
Normal file
34
calcpad-web/src/components/FontSizeControl.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
80
calcpad-web/src/components/SecuritySettings.tsx
Normal file
80
calcpad-web/src/components/SecuritySettings.tsx
Normal 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">×</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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}}
|
||||
|
||||
81
calcpad-web/src/components/UserMenu.tsx
Normal file
81
calcpad-web/src/components/UserMenu.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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': {
|
||||
|
||||
@@ -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}` }),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
39
calcpad-web/src/hooks/useFontSize.ts
Normal file
39
calcpad-web/src/hooks/useFontSize.ts
Normal 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 }
|
||||
}
|
||||
@@ -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>
|
||||
<App />
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
|
||||
19
calcpad-web/src/router/routes.ts
Normal file
19
calcpad-web/src/router/routes.ts
Normal 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' }
|
||||
}
|
||||
22
calcpad-web/src/router/useRoute.ts
Normal file
22
calcpad-web/src/router/useRoute.ts
Normal 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'))
|
||||
}
|
||||
110
calcpad-web/src/sharing/ShareDialog.tsx
Normal file
110
calcpad-web/src/sharing/ShareDialog.tsx
Normal 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}>×</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>
|
||||
)
|
||||
}
|
||||
70
calcpad-web/src/sharing/useShareToken.ts
Normal file
70
calcpad-web/src/sharing/useShareToken.ts
Normal 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 }
|
||||
}
|
||||
159
calcpad-web/src/styles/auth.css
Normal file
159
calcpad-web/src/styles/auth.css
Normal 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;
|
||||
}
|
||||
39
calcpad-web/src/styles/collab-indicator.css
Normal file
39
calcpad-web/src/styles/collab-indicator.css
Normal 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;
|
||||
}
|
||||
57
calcpad-web/src/styles/font-size-control.css
Normal file
57
calcpad-web/src/styles/font-size-control.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
181
calcpad-web/src/styles/share-dialog.css
Normal file
181
calcpad-web/src/styles/share-dialog.css
Normal 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;
|
||||
}
|
||||
109
calcpad-web/src/styles/user-menu.css
Normal file
109
calcpad-web/src/styles/user-menu.css
Normal 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;
|
||||
}
|
||||
}
|
||||
90
calcpad-web/src/sync/migrateLegacy.ts
Normal file
90
calcpad-web/src/sync/migrateLegacy.ts
Normal 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
|
||||
}
|
||||
103
calcpad-web/src/sync/useCloudSync.ts
Normal file
103
calcpad-web/src/sync/useCloudSync.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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
13
collab-server/Dockerfile
Normal 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"]
|
||||
24
collab-server/package.json
Normal file
24
collab-server/package.json
Normal 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
24
collab-server/src/auth.ts
Normal 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
|
||||
}
|
||||
}
|
||||
50
collab-server/src/index.ts
Normal file
50
collab-server/src/index.ts
Normal 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}`)
|
||||
})
|
||||
51
collab-server/src/storage.ts
Normal file
51
collab-server/src/storage.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
13
collab-server/tsconfig.json
Normal file
13
collab-server/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
31
nginx.conf
31
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;
|
||||
|
||||
165
supabase/migrations/001_create_schema.sql
Normal file
165
supabase/migrations/001_create_schema.sql
Normal 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;
|
||||
Reference in New Issue
Block a user