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
|
*.swo
|
||||||
*~
|
*~
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.production
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|||||||
10
Dockerfile
10
Dockerfile
@@ -30,6 +30,16 @@ FROM node:22-slim AS web-builder
|
|||||||
|
|
||||||
WORKDIR /app/calcpad-web
|
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)
|
# Install dependencies first (layer caching)
|
||||||
COPY calcpad-web/package.json calcpad-web/package-lock.json* ./
|
COPY calcpad-web/package.json calcpad-web/package-lock.json* ./
|
||||||
RUN npm install
|
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
|
||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
|
.env
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
|
|||||||
@@ -10,6 +10,9 @@
|
|||||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||||
<meta name="apple-mobile-web-app-title" content="CalcPad" />
|
<meta name="apple-mobile-web-app-title" content="CalcPad" />
|
||||||
<link rel="apple-touch-icon" href="/icons/icon-192.svg" />
|
<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>
|
<title>CalcText</title>
|
||||||
<script>
|
<script>
|
||||||
// Apply theme before React mounts to prevent FOUC
|
// Apply theme before React mounts to prevent FOUC
|
||||||
@@ -20,6 +23,9 @@
|
|||||||
t = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
t = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||||
}
|
}
|
||||||
document.documentElement.setAttribute('data-theme', t);
|
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>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -17,9 +17,15 @@
|
|||||||
"@codemirror/state": "^6.5.2",
|
"@codemirror/state": "^6.5.2",
|
||||||
"@codemirror/view": "^6.36.5",
|
"@codemirror/view": "^6.36.5",
|
||||||
"@lezer/highlight": "^1.2.1",
|
"@lezer/highlight": "^1.2.1",
|
||||||
|
"@supabase/supabase-js": "^2.99.3",
|
||||||
"codemirror": "^6.0.1",
|
"codemirror": "^6.0.1",
|
||||||
|
"nanoid": "^5.1.7",
|
||||||
"react": "^19.2.4",
|
"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": {
|
"devDependencies": {
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
|
|||||||
@@ -23,7 +23,13 @@ import { StatusBar } from './components/StatusBar.tsx'
|
|||||||
import { AlignToolbar } from './components/AlignToolbar.tsx'
|
import { AlignToolbar } from './components/AlignToolbar.tsx'
|
||||||
import type { Alignment } from './components/AlignToolbar.tsx'
|
import type { Alignment } from './components/AlignToolbar.tsx'
|
||||||
import { FormatToolbar } from './components/FormatToolbar.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 { 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'
|
import './styles/app.css'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
@@ -31,8 +37,13 @@ function App() {
|
|||||||
const isOnline = useOnlineStatus()
|
const isOnline = useOnlineStatus()
|
||||||
const installPrompt = useInstallPrompt()
|
const installPrompt = useInstallPrompt()
|
||||||
const themeCtx = useTheme()
|
const themeCtx = useTheme()
|
||||||
|
const fontSizeCtx = useFontSize()
|
||||||
|
const auth = useAuth()
|
||||||
const store = useDocumentStore()
|
const store = useDocumentStore()
|
||||||
|
|
||||||
|
const [showAuthModal, setShowAuthModal] = useState(false)
|
||||||
|
const [showSecuritySettings, setShowSecuritySettings] = useState(false)
|
||||||
|
|
||||||
const [editorView, setEditorView] = useState<EditorView | null>(null)
|
const [editorView, setEditorView] = useState<EditorView | null>(null)
|
||||||
const resultsPanelRef = useRef<HTMLDivElement>(null)
|
const resultsPanelRef = useRef<HTMLDivElement>(null)
|
||||||
const [modifiedIds, setModifiedIds] = useState<Set<string>>(new Set())
|
const [modifiedIds, setModifiedIds] = useState<Set<string>>(new Set())
|
||||||
@@ -207,11 +218,32 @@ function App() {
|
|||||||
}
|
}
|
||||||
return
|
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)
|
document.addEventListener('keydown', handleKey)
|
||||||
return () => document.removeEventListener('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
|
// Compute flex styles from divider position
|
||||||
const editorStyle: React.CSSProperties = dividerX !== null
|
const editorStyle: React.CSSProperties = dividerX !== null
|
||||||
@@ -249,12 +281,23 @@ function App() {
|
|||||||
onEditorAlignChange={setEditorAlign}
|
onEditorAlignChange={setEditorAlign}
|
||||||
onResultsAlignChange={setResultsAlign}
|
onResultsAlignChange={setResultsAlign}
|
||||||
/>
|
/>
|
||||||
|
<FontSizeControl
|
||||||
|
fontSize={fontSizeCtx.fontSize}
|
||||||
|
onFontSizeChange={fontSizeCtx.setFontSize}
|
||||||
|
min={fontSizeCtx.MIN_SIZE}
|
||||||
|
max={fontSizeCtx.MAX_SIZE}
|
||||||
|
/>
|
||||||
<ThemePicker
|
<ThemePicker
|
||||||
theme={themeCtx.theme}
|
theme={themeCtx.theme}
|
||||||
|
resolvedTheme={themeCtx.resolvedTheme}
|
||||||
accentColor={themeCtx.accentColor}
|
accentColor={themeCtx.accentColor}
|
||||||
onThemeChange={themeCtx.setTheme}
|
onThemeChange={themeCtx.setTheme}
|
||||||
onAccentChange={themeCtx.setAccent}
|
onAccentChange={themeCtx.setAccent}
|
||||||
/>
|
/>
|
||||||
|
<UserMenu
|
||||||
|
onOpenAuth={() => setShowAuthModal(true)}
|
||||||
|
onOpenSecurity={() => setShowSecuritySettings(true)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -340,6 +383,21 @@ function App() {
|
|||||||
onInstall={installPrompt.handleInstall}
|
onInstall={installPrompt.handleInstall}
|
||||||
onDismiss={installPrompt.handleDismiss}
|
onDismiss={installPrompt.handleDismiss}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{showAuthModal && (
|
||||||
|
<AuthModal onClose={() => setShowAuthModal(false)} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{auth.needsPasswordRenewal && (
|
||||||
|
<AuthModal
|
||||||
|
onClose={() => auth.clearPasswordRenewal()}
|
||||||
|
renewalMode
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showSecuritySettings && (
|
||||||
|
<SecuritySettings onClose={() => setShowSecuritySettings(false)} />
|
||||||
|
)}
|
||||||
</div>
|
</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}
|
onClick={onPreviewToggle}
|
||||||
title={previewMode ? 'Show raw markdown' : 'Show formatted preview'}
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -124,9 +129,12 @@ export function FormatToolbar({ editorView, previewMode, onPreviewToggle }: Form
|
|||||||
<button
|
<button
|
||||||
className="format-btn"
|
className="format-btn"
|
||||||
onClick={() => editorView && insertPrefix(editorView, '// ')}
|
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>
|
</button>
|
||||||
</div>
|
</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 {
|
interface ThemePickerProps {
|
||||||
theme: ThemeId
|
theme: ThemeId
|
||||||
|
resolvedTheme: string
|
||||||
accentColor: string | null
|
accentColor: string | null
|
||||||
onThemeChange: (id: ThemeId) => void
|
onThemeChange: (id: ThemeId) => void
|
||||||
onAccentChange: (color: string | null) => 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 [open, setOpen] = useState(false)
|
||||||
const ref = useRef<HTMLDivElement>(null)
|
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 currentTheme = THEMES.find(t => t.id === theme)
|
||||||
const resolvedTheme = theme === 'system' ? undefined : theme
|
|
||||||
const icon = currentTheme?.icon ?? '⚙️'
|
const icon = currentTheme?.icon ?? '⚙️'
|
||||||
|
const isDark = ['dark', 'matrix', 'midnight'].includes(resolvedTheme)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="theme-picker-container" ref={ref}>
|
<div className="theme-picker-container" ref={ref}>
|
||||||
@@ -68,13 +69,13 @@ export function ThemePicker({ theme, accentColor, onThemeChange, onAccentChange
|
|||||||
{THEMES.map(t => (
|
{THEMES.map(t => (
|
||||||
<button
|
<button
|
||||||
key={t.id}
|
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) }}
|
onClick={() => { onThemeChange(t.id); setOpen(false) }}
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
>
|
>
|
||||||
<span className="theme-picker-item-icon">{t.icon}</span>
|
<span className="theme-picker-item-icon">{t.icon}</span>
|
||||||
<span className="theme-picker-item-label">{t.name}</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>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
@@ -86,9 +87,8 @@ export function ThemePicker({ theme, accentColor, onThemeChange, onAccentChange
|
|||||||
<button
|
<button
|
||||||
key={c.name}
|
key={c.name}
|
||||||
className={`theme-picker-swatch ${accentColor === c.light || accentColor === c.dark ? 'active' : ''}`}
|
className={`theme-picker-swatch ${accentColor === c.light || accentColor === c.dark ? 'active' : ''}`}
|
||||||
style={{ backgroundColor: c.light }}
|
style={{ backgroundColor: isDark ? c.dark : c.light }}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const isDark = ['dark', 'matrix', 'midnight'].includes(resolvedTheme ?? '')
|
|
||||||
const color = isDark ? c.dark : c.light
|
const color = isDark ? c.dark : c.light
|
||||||
onAccentChange(accentColor === color ? null : color)
|
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({
|
const calcpadEditorTheme = EditorView.baseTheme({
|
||||||
'&': {
|
'&': {
|
||||||
height: '100%',
|
height: '100%',
|
||||||
fontSize: '15px',
|
fontSize: 'var(--editor-font-size, 15px)',
|
||||||
fontFamily: 'var(--mono, ui-monospace, Consolas, "Courier New", monospace)',
|
fontFamily: 'var(--mono, ui-monospace, Consolas, "Courier New", monospace)',
|
||||||
},
|
},
|
||||||
'.cm-scroller': {
|
'.cm-scroller': {
|
||||||
@@ -218,7 +218,7 @@ const calcpadEditorTheme = EditorView.baseTheme({
|
|||||||
padding: '0 6px 0 12px',
|
padding: '0 6px 0 12px',
|
||||||
color: 'var(--text, #9ca3af)',
|
color: 'var(--text, #9ca3af)',
|
||||||
opacity: '0.4',
|
opacity: '0.4',
|
||||||
fontSize: '13px',
|
fontSize: 'calc(var(--editor-font-size, 15px) - 2px)',
|
||||||
minWidth: '32px',
|
minWidth: '32px',
|
||||||
},
|
},
|
||||||
'.cm-activeLineGutter .cm-gutterElement': {
|
'.cm-activeLineGutter .cm-gutterElement': {
|
||||||
|
|||||||
@@ -116,28 +116,35 @@ function buildDecorations(view: EditorView): DecorationSet {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Color labels: [color:text] (on non-active lines, show colored text)
|
// Color labels: [color:text] — always show color, even on active line
|
||||||
if (!isActive) {
|
{
|
||||||
const colorRegex = /\[(red|orange|yellow|green|blue|purple):(.+?)\]/g
|
const colorRegex = /\[(red|orange|yellow|green|blue|purple):(.+?)\]/g
|
||||||
let match
|
let match
|
||||||
while ((match = colorRegex.exec(text)) !== null) {
|
while ((match = colorRegex.exec(text)) !== null) {
|
||||||
const start = line.from + match.index
|
const start = line.from + match.index
|
||||||
const color = match[1]
|
const color = match[1]
|
||||||
const content = match[2]
|
const content = match[2]
|
||||||
// Hide [color:
|
if (!isActive) {
|
||||||
decorations.push({ from: start, to: start + color.length + 2, dec: hiddenWidget })
|
// Non-active lines: hide syntax, show only colored content
|
||||||
// Color the content
|
decorations.push({ from: start, to: start + color.length + 2, dec: hiddenWidget })
|
||||||
decorations.push({
|
decorations.push({
|
||||||
from: start + color.length + 2,
|
from: start + color.length + 2,
|
||||||
to: start + color.length + 2 + content.length,
|
to: start + color.length + 2 + content.length,
|
||||||
dec: Decoration.mark({ class: `cm-fmt-color-${color}` }),
|
dec: Decoration.mark({ class: `cm-fmt-color-${color}` }),
|
||||||
})
|
})
|
||||||
// Hide ]
|
decorations.push({
|
||||||
decorations.push({
|
from: start + match[0].length - 1,
|
||||||
from: start + match[0].length - 1,
|
to: start + match[0].length,
|
||||||
to: start + match[0].length,
|
dec: hiddenWidget,
|
||||||
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 { createRoot } from 'react-dom/client'
|
||||||
import './styles/index.css'
|
import './styles/index.css'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
|
import { AuthProvider } from './auth/AuthProvider.tsx'
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<AuthProvider>
|
||||||
|
<App />
|
||||||
|
</AuthProvider>
|
||||||
</StrictMode>,
|
</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;
|
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 {
|
.format-preview-toggle.active {
|
||||||
background: var(--accent-bg);
|
background: var(--accent-bg);
|
||||||
border-color: var(--accent-border);
|
border-color: var(--accent-border);
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.format-preview-toggle.active .format-preview-label {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- Color Buttons ---------- */
|
/* ---------- Color Buttons ---------- */
|
||||||
|
|
||||||
.format-colors {
|
.format-colors {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
:root {
|
:root {
|
||||||
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
|
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||||
--heading: 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: #f59e0b;
|
||||||
--warning-bg: rgba(245, 158, 11, 0.1);
|
--warning-bg: rgba(245, 158, 11, 0.1);
|
||||||
@@ -111,7 +111,7 @@
|
|||||||
--result-datetime: #39ff14;
|
--result-datetime: #39ff14;
|
||||||
--result-boolean: #00ff41;
|
--result-boolean: #00ff41;
|
||||||
|
|
||||||
--mono: 'Courier New', 'Fira Code', monospace;
|
--mono: 'JetBrains Mono', 'Courier New', 'Fira Code', monospace;
|
||||||
--success: #00ff41;
|
--success: #00ff41;
|
||||||
--error: #ff0000;
|
--error: #ff0000;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,9 +9,9 @@
|
|||||||
.result-line {
|
.result-line {
|
||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
font-size: 15px;
|
font-size: var(--editor-font-size, 15px);
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
height: 24px;
|
height: calc(var(--editor-font-size, 15px) * 1.6);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
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,
|
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)$/,
|
urlPattern: /\.(?:png|jpg|jpeg|svg|gif|webp)$/,
|
||||||
handler: 'CacheFirst',
|
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:
|
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:
|
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:
|
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;
|
index index.html;
|
||||||
|
|
||||||
# SPA fallback — serve index.html for all non-file routes
|
# SPA fallback — serve index.html for all non-file routes
|
||||||
|
# Handles /s/{token} share links and other client-side routes
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
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)
|
# Cache static assets aggressively (hashed filenames)
|
||||||
location /assets/ {
|
location /assets/ {
|
||||||
expires 1y;
|
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