refactor: remove OddJS dependency and fix Automerge sync
Major Changes: - Fix Automerge "Document unavailable" error by awaiting repo.find() - Remove @oddjs/odd package and all related dependencies (205 packages) - Remove location sharing features (OddJS filesystem-dependent) - Simplify auth to use only CryptoAuthService (WebCryptoAPI-based) Auth System Changes: - Refactor AuthService to remove OddJS filesystem integration - Update AuthContext to remove FileSystem references - Delete unused auth files (account.ts, backup.ts, linking.ts) - Delete unused auth components (Register.tsx, LinkDevice.tsx) Location Features Removed: - Delete all location components and routes - Remove LocationShareShape from shape registry - Clean up location references across codebase Documentation Updates: - Update WEBCRYPTO_AUTH.md to remove OddJS references - Correct component names (CryptoLogin → CryptID) - Update file structure and dependencies - Fix Automerge README WebSocket path documentation Build System: - Successfully builds without OddJS dependencies - All TypeScript errors resolved - Production bundle size optimized 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
e1f4e83383
commit
e96e6480fe
|
|
@ -4,7 +4,7 @@ This document describes the complete WebCryptoAPI authentication system implemen
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
The WebCryptoAPI authentication system provides cryptographic authentication using ECDSA P-256 key pairs, challenge-response authentication, and secure key storage. It integrates with the existing ODD (Open Data Directory) framework while providing a fallback authentication mechanism.
|
The WebCryptoAPI authentication system provides cryptographic authentication using ECDSA P-256 key pairs, challenge-response authentication, and secure key storage. This is the primary authentication mechanism for the application.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
|
|
@ -23,13 +23,14 @@ The WebCryptoAPI authentication system provides cryptographic authentication usi
|
||||||
- User registration and login
|
- User registration and login
|
||||||
- Credential verification
|
- Credential verification
|
||||||
|
|
||||||
3. **Enhanced AuthService** (`src/lib/auth/authService.ts`)
|
3. **AuthService** (`src/lib/auth/authService.ts`)
|
||||||
- Integrates crypto authentication with ODD
|
- Simplified authentication service
|
||||||
- Fallback mechanisms
|
|
||||||
- Session management
|
- Session management
|
||||||
|
- Integration with CryptoAuthService
|
||||||
|
|
||||||
4. **UI Components**
|
4. **UI Components**
|
||||||
- `CryptoLogin.tsx` - Cryptographic authentication UI
|
- `CryptID.tsx` - Cryptographic authentication UI
|
||||||
|
- `CryptoDebug.tsx` - Debug component for verification
|
||||||
- `CryptoTest.tsx` - Test component for verification
|
- `CryptoTest.tsx` - Test component for verification
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
@ -41,7 +42,6 @@ The WebCryptoAPI authentication system provides cryptographic authentication usi
|
||||||
- **Public Key Infrastructure**: Store and verify public keys
|
- **Public Key Infrastructure**: Store and verify public keys
|
||||||
- **Browser Support Detection**: Checks for WebCryptoAPI availability
|
- **Browser Support Detection**: Checks for WebCryptoAPI availability
|
||||||
- **Secure Context Validation**: Ensures HTTPS requirement
|
- **Secure Context Validation**: Ensures HTTPS requirement
|
||||||
- **Fallback Authentication**: Works with existing ODD system
|
|
||||||
- **Modern UI**: Responsive design with dark mode support
|
- **Modern UI**: Responsive design with dark mode support
|
||||||
- **Comprehensive Testing**: Test component for verification
|
- **Comprehensive Testing**: Test component for verification
|
||||||
|
|
||||||
|
|
@ -86,7 +86,7 @@ const isValid = await crypto.verifySignature(publicKey, signature, challenge);
|
||||||
|
|
||||||
### Feature Detection
|
### Feature Detection
|
||||||
```typescript
|
```typescript
|
||||||
const hasWebCrypto = typeof window.crypto !== 'undefined' &&
|
const hasWebCrypto = typeof window.crypto !== 'undefined' &&
|
||||||
typeof window.crypto.subtle !== 'undefined';
|
typeof window.crypto.subtle !== 'undefined';
|
||||||
const isSecure = window.isSecureContext;
|
const isSecure = window.isSecureContext;
|
||||||
```
|
```
|
||||||
|
|
@ -98,26 +98,26 @@ const isSecure = window.isSecureContext;
|
||||||
1. **Secure Context Requirement**: Only works over HTTPS
|
1. **Secure Context Requirement**: Only works over HTTPS
|
||||||
2. **ECDSA P-256**: Industry-standard elliptic curve
|
2. **ECDSA P-256**: Industry-standard elliptic curve
|
||||||
3. **Challenge-Response**: Prevents replay attacks
|
3. **Challenge-Response**: Prevents replay attacks
|
||||||
4. **Key Storage**: Public keys stored securely
|
4. **Key Storage**: Public keys stored securely in localStorage
|
||||||
5. **Input Validation**: Username format validation
|
5. **Input Validation**: Username format validation
|
||||||
6. **Error Handling**: Comprehensive error management
|
6. **Error Handling**: Comprehensive error management
|
||||||
|
|
||||||
### ⚠️ Security Notes
|
### ⚠️ Security Notes
|
||||||
|
|
||||||
1. **Private Key Storage**: Currently simplified for demo purposes
|
1. **Private Key Storage**: Currently uses localStorage for demo purposes
|
||||||
- In production, use Web Crypto API's key storage
|
- In production, consider using Web Crypto API's non-extractable keys
|
||||||
- Consider hardware security modules (HSM)
|
- Consider hardware security modules (HSM)
|
||||||
- Implement proper key derivation
|
- Implement proper key derivation
|
||||||
|
|
||||||
2. **Session Management**:
|
2. **Session Management**:
|
||||||
- Integrates with existing ODD session system
|
- Uses localStorage for session persistence
|
||||||
- Consider implementing JWT tokens
|
- Consider implementing JWT tokens for server-side verification
|
||||||
- Add session expiration
|
- Add session expiration and refresh logic
|
||||||
|
|
||||||
3. **Network Security**:
|
3. **Network Security**:
|
||||||
- All crypto operations happen client-side
|
- All crypto operations happen client-side
|
||||||
- No private keys transmitted over network
|
- No private keys transmitted over network
|
||||||
- Consider adding server-side verification
|
- Consider adding server-side signature verification
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
|
|
@ -146,11 +146,22 @@ import { useAuth } from './context/AuthContext';
|
||||||
|
|
||||||
const { login, register } = useAuth();
|
const { login, register } = useAuth();
|
||||||
|
|
||||||
// The AuthService automatically tries crypto auth first,
|
// AuthService automatically uses crypto auth
|
||||||
// then falls back to ODD authentication
|
|
||||||
const success = await login('username');
|
const success = await login('username');
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Using the CryptID Component
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import CryptID from './components/auth/CryptID';
|
||||||
|
|
||||||
|
// Render the authentication component
|
||||||
|
<CryptID
|
||||||
|
onSuccess={() => console.log('Login successful')}
|
||||||
|
onCancel={() => console.log('Login cancelled')}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
### Testing the Implementation
|
### Testing the Implementation
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
|
|
@ -166,31 +177,42 @@ import CryptoTest from './components/auth/CryptoTest';
|
||||||
src/
|
src/
|
||||||
├── lib/
|
├── lib/
|
||||||
│ ├── auth/
|
│ ├── auth/
|
||||||
│ │ ├── crypto.ts # WebCryptoAPI wrapper
|
│ │ ├── crypto.ts # WebCryptoAPI wrapper
|
||||||
│ │ ├── cryptoAuthService.ts # High-level auth service
|
│ │ ├── cryptoAuthService.ts # High-level auth service
|
||||||
│ │ ├── authService.ts # Enhanced auth service
|
│ │ ├── authService.ts # Simplified auth service
|
||||||
│ │ └── account.ts # User account management
|
│ │ ├── sessionPersistence.ts # Session storage utilities
|
||||||
|
│ │ └── types.ts # TypeScript types
|
||||||
│ └── utils/
|
│ └── utils/
|
||||||
│ └── browser.ts # Browser support detection
|
│ └── browser.ts # Browser support detection
|
||||||
├── components/
|
├── components/
|
||||||
│ └── auth/
|
│ └── auth/
|
||||||
│ ├── CryptoLogin.tsx # Crypto auth UI
|
│ ├── CryptID.tsx # Main crypto auth UI
|
||||||
│ └── CryptoTest.tsx # Test component
|
│ ├── CryptoDebug.tsx # Debug component
|
||||||
|
│ └── CryptoTest.tsx # Test component
|
||||||
|
├── context/
|
||||||
|
│ └── AuthContext.tsx # React context for auth state
|
||||||
└── css/
|
└── css/
|
||||||
└── crypto-auth.css # Styles for crypto components
|
└── crypto-auth.css # Styles for crypto components
|
||||||
```
|
```
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
### Required Packages
|
### Required Packages
|
||||||
- `one-webcrypto`: WebCryptoAPI polyfill (^1.0.3)
|
- `one-webcrypto`: WebCryptoAPI polyfill (^1.0.3)
|
||||||
- `@oddjs/odd`: Open Data Directory framework (^0.37.2)
|
|
||||||
|
|
||||||
### Browser APIs Used
|
### Browser APIs Used
|
||||||
- `window.crypto.subtle`: WebCryptoAPI
|
- `window.crypto.subtle`: WebCryptoAPI
|
||||||
- `window.localStorage`: Key storage
|
- `window.localStorage`: Key and session storage
|
||||||
- `window.isSecureContext`: Security context check
|
- `window.isSecureContext`: Security context check
|
||||||
|
|
||||||
|
## Storage
|
||||||
|
|
||||||
|
### localStorage Keys Used
|
||||||
|
- `registeredUsers`: Array of registered usernames
|
||||||
|
- `${username}_publicKey`: User's public key (Base64)
|
||||||
|
- `${username}_authData`: Authentication data (challenge, signature, timestamp)
|
||||||
|
- `session`: Current user session data
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
### Manual Testing
|
### Manual Testing
|
||||||
|
|
@ -208,6 +230,7 @@ src/
|
||||||
- [x] User registration
|
- [x] User registration
|
||||||
- [x] User login
|
- [x] User login
|
||||||
- [x] Credential verification
|
- [x] Credential verification
|
||||||
|
- [x] Session persistence
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
|
|
@ -228,13 +251,13 @@ src/
|
||||||
- Try refreshing the page
|
- Try refreshing the page
|
||||||
|
|
||||||
4. **"Authentication failed"**
|
4. **"Authentication failed"**
|
||||||
- Verify user exists
|
- Verify user exists in localStorage
|
||||||
- Check stored credentials
|
- Check stored credentials
|
||||||
- Clear browser data and retry
|
- Clear browser data and retry
|
||||||
|
|
||||||
### Debug Mode
|
### Debug Mode
|
||||||
|
|
||||||
Enable debug logging by setting:
|
Enable debug logging by opening the browser console:
|
||||||
```typescript
|
```typescript
|
||||||
localStorage.setItem('debug_crypto', 'true');
|
localStorage.setItem('debug_crypto', 'true');
|
||||||
```
|
```
|
||||||
|
|
@ -242,7 +265,7 @@ localStorage.setItem('debug_crypto', 'true');
|
||||||
## Future Enhancements
|
## Future Enhancements
|
||||||
|
|
||||||
### Planned Improvements
|
### Planned Improvements
|
||||||
1. **Enhanced Key Storage**: Use Web Crypto API's key storage
|
1. **Enhanced Key Storage**: Use Web Crypto API's non-extractable keys
|
||||||
2. **Server-Side Verification**: Add server-side signature verification
|
2. **Server-Side Verification**: Add server-side signature verification
|
||||||
3. **Multi-Factor Authentication**: Add additional authentication factors
|
3. **Multi-Factor Authentication**: Add additional authentication factors
|
||||||
4. **Key Rotation**: Implement automatic key rotation
|
4. **Key Rotation**: Implement automatic key rotation
|
||||||
|
|
@ -254,6 +277,15 @@ localStorage.setItem('debug_crypto', 'true');
|
||||||
3. **Post-Quantum Cryptography**: Prepare for quantum threats
|
3. **Post-Quantum Cryptography**: Prepare for quantum threats
|
||||||
4. **Biometric Integration**: Add biometric authentication
|
4. **Biometric Integration**: Add biometric authentication
|
||||||
|
|
||||||
|
## Integration with Automerge Sync
|
||||||
|
|
||||||
|
The authentication system works seamlessly with the Automerge-based real-time collaboration:
|
||||||
|
|
||||||
|
- **User Identification**: Each user is identified by their username in Automerge
|
||||||
|
- **Session Management**: Sessions persist across page reloads via localStorage
|
||||||
|
- **Collaboration**: Authenticated users can join shared canvas rooms
|
||||||
|
- **Privacy**: Only authenticated users can access canvas data
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
When contributing to the WebCryptoAPI authentication system:
|
When contributing to the WebCryptoAPI authentication system:
|
||||||
|
|
@ -269,4 +301,4 @@ When contributing to the WebCryptoAPI authentication system:
|
||||||
- [WebCryptoAPI Specification](https://www.w3.org/TR/WebCryptoAPI/)
|
- [WebCryptoAPI Specification](https://www.w3.org/TR/WebCryptoAPI/)
|
||||||
- [ECDSA Algorithm](https://en.wikipedia.org/wiki/Elliptic_Curve_Digital_Signature_Algorithm)
|
- [ECDSA Algorithm](https://en.wikipedia.org/wiki/Elliptic_Curve_Digital_Signature_Algorithm)
|
||||||
- [P-256 Curve](https://en.wikipedia.org/wiki/NIST_Curve_P-256)
|
- [P-256 Curve](https://en.wikipedia.org/wiki/NIST_Curve_P-256)
|
||||||
- [Challenge-Response Authentication](https://en.wikipedia.org/wiki/Challenge%E2%80%93response_authentication)
|
- [Challenge-Response Authentication](https://en.wikipedia.org/wiki/Challenge%E2%80%93response_authentication)
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -28,7 +28,6 @@
|
||||||
"@chengsokdara/use-whisper": "^0.2.0",
|
"@chengsokdara/use-whisper": "^0.2.0",
|
||||||
"@daily-co/daily-js": "^0.60.0",
|
"@daily-co/daily-js": "^0.60.0",
|
||||||
"@daily-co/daily-react": "^0.20.0",
|
"@daily-co/daily-react": "^0.20.0",
|
||||||
"@oddjs/odd": "^0.37.2",
|
|
||||||
"@tldraw/assets": "^3.15.4",
|
"@tldraw/assets": "^3.15.4",
|
||||||
"@tldraw/tldraw": "^3.15.4",
|
"@tldraw/tldraw": "^3.15.4",
|
||||||
"@tldraw/tlschema": "^3.15.4",
|
"@tldraw/tlschema": "^3.15.4",
|
||||||
|
|
|
||||||
20
src/App.tsx
20
src/App.tsx
|
|
@ -16,11 +16,7 @@ import "@/css/auth.css"; // Import auth styles
|
||||||
import "@/css/crypto-auth.css"; // Import crypto auth styles
|
import "@/css/crypto-auth.css"; // Import crypto auth styles
|
||||||
import "@/css/starred-boards.css"; // Import starred boards styles
|
import "@/css/starred-boards.css"; // Import starred boards styles
|
||||||
import "@/css/user-profile.css"; // Import user profile styles
|
import "@/css/user-profile.css"; // Import user profile styles
|
||||||
import "@/css/location.css"; // Import location sharing styles
|
|
||||||
import { Dashboard } from "./routes/Dashboard";
|
import { Dashboard } from "./routes/Dashboard";
|
||||||
import { LocationShareCreate } from "./routes/LocationShareCreate";
|
|
||||||
import { LocationShareView } from "./routes/LocationShareView";
|
|
||||||
import { LocationDashboardRoute } from "./routes/LocationDashboardRoute";
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
// Import React Context providers
|
// Import React Context providers
|
||||||
|
|
@ -149,22 +145,6 @@ const AppWithProviders = () => {
|
||||||
<Resilience />
|
<Resilience />
|
||||||
</OptionalAuthRoute>
|
</OptionalAuthRoute>
|
||||||
} />
|
} />
|
||||||
{/* Location sharing routes */}
|
|
||||||
<Route path="/share-location" element={
|
|
||||||
<OptionalAuthRoute>
|
|
||||||
<LocationShareCreate />
|
|
||||||
</OptionalAuthRoute>
|
|
||||||
} />
|
|
||||||
<Route path="/location/:token" element={
|
|
||||||
<OptionalAuthRoute>
|
|
||||||
<LocationShareView />
|
|
||||||
</OptionalAuthRoute>
|
|
||||||
} />
|
|
||||||
<Route path="/location-dashboard" element={
|
|
||||||
<OptionalAuthRoute>
|
|
||||||
<LocationDashboardRoute />
|
|
||||||
</OptionalAuthRoute>
|
|
||||||
} />
|
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</DailyProvider>
|
</DailyProvider>
|
||||||
|
|
|
||||||
|
|
@ -593,7 +593,7 @@ export function sanitizeRecord(record: any): TLRecord {
|
||||||
'holon': 'Holon',
|
'holon': 'Holon',
|
||||||
'obsidianBrowser': 'ObsidianBrowser',
|
'obsidianBrowser': 'ObsidianBrowser',
|
||||||
'fathomMeetingsBrowser': 'FathomMeetingsBrowser',
|
'fathomMeetingsBrowser': 'FathomMeetingsBrowser',
|
||||||
'locationShare': 'LocationShare',
|
// locationShare removed
|
||||||
'imageGen': 'ImageGen',
|
'imageGen': 'ImageGen',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,6 @@ To switch from TLdraw sync to Automerge sync:
|
||||||
|
|
||||||
1. Update the Board component to use `useAutomergeSync`
|
1. Update the Board component to use `useAutomergeSync`
|
||||||
2. Deploy the new worker with Automerge Durable Object
|
2. Deploy the new worker with Automerge Durable Object
|
||||||
3. Update the URI to use `/automerge/connect/` instead of `/connect/`
|
3. The CloudflareAdapter will automatically connect to `/connect/{roomId}` via WebSocket
|
||||||
|
|
||||||
The migration is backward compatible - existing TLdraw sync will continue to work while you test the new system.
|
The migration is backward compatible - the system will handle both legacy and new document formats.
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,7 @@ import { FathomNoteShape } from "@/shapes/FathomNoteShapeUtil"
|
||||||
import { HolonShape } from "@/shapes/HolonShapeUtil"
|
import { HolonShape } from "@/shapes/HolonShapeUtil"
|
||||||
import { ObsidianBrowserShape } from "@/shapes/ObsidianBrowserShapeUtil"
|
import { ObsidianBrowserShape } from "@/shapes/ObsidianBrowserShapeUtil"
|
||||||
import { FathomMeetingsBrowserShape } from "@/shapes/FathomMeetingsBrowserShapeUtil"
|
import { FathomMeetingsBrowserShape } from "@/shapes/FathomMeetingsBrowserShapeUtil"
|
||||||
import { LocationShareShape } from "@/shapes/LocationShareShapeUtil"
|
// Location shape removed - no longer needed
|
||||||
|
|
||||||
export function useAutomergeStoreV2({
|
export function useAutomergeStoreV2({
|
||||||
handle,
|
handle,
|
||||||
|
|
@ -173,7 +173,6 @@ export function useAutomergeStoreV2({
|
||||||
HolonShape,
|
HolonShape,
|
||||||
ObsidianBrowserShape,
|
ObsidianBrowserShape,
|
||||||
FathomMeetingsBrowserShape,
|
FathomMeetingsBrowserShape,
|
||||||
LocationShareShape,
|
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
return store
|
return store
|
||||||
|
|
|
||||||
|
|
@ -146,15 +146,10 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
|
||||||
let handle: DocHandle<TLStoreSnapshot>
|
let handle: DocHandle<TLStoreSnapshot>
|
||||||
|
|
||||||
if (documentId) {
|
if (documentId) {
|
||||||
// Try to find the existing document
|
// Find the existing document (will sync from network if not available locally)
|
||||||
const foundHandle = await repo.find<TLStoreSnapshot>(documentId as any)
|
console.log(`🔍 Finding document ${documentId} (will sync from network if needed)`)
|
||||||
if (!foundHandle) {
|
handle = await repo.find<TLStoreSnapshot>(documentId as any)
|
||||||
console.log(`📝 Document ${documentId} not in local repo, creating handle`)
|
console.log(`✅ Got handle for document: ${documentId}`)
|
||||||
handle = repo.create<TLStoreSnapshot>()
|
|
||||||
} else {
|
|
||||||
console.log(`✅ Found existing document in local repo: ${documentId}`)
|
|
||||||
handle = foundHandle
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Create a new document and register its ID with the server
|
// Create a new document and register its ID with the server
|
||||||
handle = repo.create<TLStoreSnapshot>()
|
handle = repo.create<TLStoreSnapshot>()
|
||||||
|
|
|
||||||
|
|
@ -1,102 +0,0 @@
|
||||||
import React, { useState, useEffect } from 'react'
|
|
||||||
import { useNavigate } from 'react-router-dom'
|
|
||||||
import { createAccountLinkingConsumer } from '../../lib/auth/linking'
|
|
||||||
import { useAuth } from '../../context/AuthContext'
|
|
||||||
import { useNotifications } from '../../context/NotificationContext'
|
|
||||||
|
|
||||||
const LinkDevice: React.FC = () => {
|
|
||||||
const [username, setUsername] = useState('')
|
|
||||||
const [displayPin, setDisplayPin] = useState('')
|
|
||||||
const [view, setView] = useState<'enter-username' | 'show-pin' | 'load-filesystem'>('enter-username')
|
|
||||||
const [accountLinkingConsumer, setAccountLinkingConsumer] = useState<any>(null)
|
|
||||||
const navigate = useNavigate()
|
|
||||||
const { login } = useAuth()
|
|
||||||
const { addNotification } = useNotifications()
|
|
||||||
|
|
||||||
const initAccountLinkingConsumer = async () => {
|
|
||||||
try {
|
|
||||||
const consumer = await createAccountLinkingConsumer(username)
|
|
||||||
setAccountLinkingConsumer(consumer)
|
|
||||||
|
|
||||||
consumer.on('challenge', ({ pin }: { pin: number[] }) => {
|
|
||||||
setDisplayPin(pin.join(''))
|
|
||||||
setView('show-pin')
|
|
||||||
})
|
|
||||||
|
|
||||||
consumer.on('link', async ({ approved, username }: { approved: boolean, username: string }) => {
|
|
||||||
if (approved) {
|
|
||||||
setView('load-filesystem')
|
|
||||||
|
|
||||||
const success = await login(username)
|
|
||||||
|
|
||||||
if (success) {
|
|
||||||
addNotification("You're now connected!", "success")
|
|
||||||
navigate('/')
|
|
||||||
} else {
|
|
||||||
addNotification("Connection successful but login failed", "error")
|
|
||||||
navigate('/login')
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
addNotification('The connection attempt was cancelled', "warning")
|
|
||||||
navigate('/')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error initializing account linking consumer:', error)
|
|
||||||
addNotification('Failed to initialize device linking', "error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSubmitUsername = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
initAccountLinkingConsumer()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up consumer on unmount
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (accountLinkingConsumer) {
|
|
||||||
accountLinkingConsumer.destroy()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [accountLinkingConsumer])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="link-device-container">
|
|
||||||
{view === 'enter-username' && (
|
|
||||||
<>
|
|
||||||
<h2>Link a New Device</h2>
|
|
||||||
<form onSubmit={handleSubmitUsername}>
|
|
||||||
<div className="form-group">
|
|
||||||
<label htmlFor="username">Username</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="username"
|
|
||||||
value={username}
|
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button type="submit" disabled={!username}>Continue</button>
|
|
||||||
</form>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{view === 'show-pin' && (
|
|
||||||
<div className="pin-display">
|
|
||||||
<h2>Enter this PIN on your other device</h2>
|
|
||||||
<div className="pin-code">{displayPin}</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{view === 'load-filesystem' && (
|
|
||||||
<div className="loading">
|
|
||||||
<h2>Loading your filesystem...</h2>
|
|
||||||
<p>Please wait while we connect to your account.</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default LinkDevice
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
import React, { useState } from 'react'
|
|
||||||
import { register } from '../../lib/auth/account'
|
|
||||||
|
|
||||||
const Register: React.FC = () => {
|
|
||||||
const [username, setUsername] = useState('')
|
|
||||||
const [checkingUsername, setCheckingUsername] = useState(false)
|
|
||||||
const [initializingFilesystem, setInitializingFilesystem] = useState(false)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const handleRegister = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
|
|
||||||
if (checkingUsername) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setInitializingFilesystem(true)
|
|
||||||
setError(null)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const success = await register(username)
|
|
||||||
|
|
||||||
if (!success) {
|
|
||||||
setError('Registration failed. Username may be taken.')
|
|
||||||
setInitializingFilesystem(false)
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setError('An error occurred during registration')
|
|
||||||
setInitializingFilesystem(false)
|
|
||||||
console.error(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="register-container">
|
|
||||||
<h2>Create an Account</h2>
|
|
||||||
|
|
||||||
<form onSubmit={handleRegister}>
|
|
||||||
<div className="form-group">
|
|
||||||
<label htmlFor="username">Username</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="username"
|
|
||||||
value={username}
|
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
|
||||||
disabled={initializingFilesystem}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && <div className="error-message">{error}</div>}
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={initializingFilesystem || !username}
|
|
||||||
>
|
|
||||||
{initializingFilesystem ? 'Creating Account...' : 'Create Account'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Register
|
|
||||||
|
|
@ -1,187 +0,0 @@
|
||||||
"use client"
|
|
||||||
|
|
||||||
import type React from "react"
|
|
||||||
import { useState, useEffect } from "react"
|
|
||||||
import { useAuth } from "@/context/AuthContext"
|
|
||||||
import { LocationStorageService, type LocationData } from "@/lib/location/locationStorage"
|
|
||||||
import type { GeolocationPosition } from "@/lib/location/types"
|
|
||||||
|
|
||||||
interface LocationCaptureProps {
|
|
||||||
onLocationCaptured?: (location: LocationData) => void
|
|
||||||
onError?: (error: string) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export const LocationCapture: React.FC<LocationCaptureProps> = ({ onLocationCaptured, onError }) => {
|
|
||||||
const { session, fileSystem } = useAuth()
|
|
||||||
const [isCapturing, setIsCapturing] = useState(false)
|
|
||||||
const [permissionState, setPermissionState] = useState<"prompt" | "granted" | "denied">("prompt")
|
|
||||||
const [currentLocation, setCurrentLocation] = useState<GeolocationPosition | null>(null)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
// Show loading state while auth is initializing
|
|
||||||
if (session.loading) {
|
|
||||||
return (
|
|
||||||
<div className="location-capture-loading flex items-center justify-center min-h-[200px]">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-2xl mb-2 animate-spin">⏳</div>
|
|
||||||
<p className="text-sm text-muted-foreground">Loading authentication...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check permission status on mount
|
|
||||||
useEffect(() => {
|
|
||||||
if ("permissions" in navigator) {
|
|
||||||
navigator.permissions.query({ name: "geolocation" }).then((result) => {
|
|
||||||
setPermissionState(result.state as "prompt" | "granted" | "denied")
|
|
||||||
|
|
||||||
result.addEventListener("change", () => {
|
|
||||||
setPermissionState(result.state as "prompt" | "granted" | "denied")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const captureLocation = async () => {
|
|
||||||
// Don't proceed if still loading
|
|
||||||
if (session.loading) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!session.authed) {
|
|
||||||
const errorMsg = "You must be logged in to share your location. Please log in and try again."
|
|
||||||
setError(errorMsg)
|
|
||||||
onError?.(errorMsg)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!fileSystem) {
|
|
||||||
const errorMsg = "File system not available. Please refresh the page and try again."
|
|
||||||
setError(errorMsg)
|
|
||||||
onError?.(errorMsg)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsCapturing(true)
|
|
||||||
setError(null)
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Request geolocation
|
|
||||||
const position = await new Promise<GeolocationPosition>((resolve, reject) => {
|
|
||||||
navigator.geolocation.getCurrentPosition(
|
|
||||||
(pos) => resolve(pos as GeolocationPosition),
|
|
||||||
(err) => reject(err),
|
|
||||||
{
|
|
||||||
enableHighAccuracy: true,
|
|
||||||
timeout: 10000,
|
|
||||||
maximumAge: 0,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
setCurrentLocation(position)
|
|
||||||
|
|
||||||
// Create location data
|
|
||||||
const locationData: LocationData = {
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
userId: session.username,
|
|
||||||
latitude: position.coords.latitude,
|
|
||||||
longitude: position.coords.longitude,
|
|
||||||
accuracy: position.coords.accuracy,
|
|
||||||
timestamp: position.timestamp,
|
|
||||||
expiresAt: null, // Will be set when creating a share
|
|
||||||
precision: "exact",
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save to filesystem
|
|
||||||
const storageService = new LocationStorageService(fileSystem)
|
|
||||||
await storageService.initialize()
|
|
||||||
await storageService.saveLocation(locationData)
|
|
||||||
|
|
||||||
onLocationCaptured?.(locationData)
|
|
||||||
} catch (err: any) {
|
|
||||||
let errorMsg = "Failed to capture location"
|
|
||||||
|
|
||||||
if (err.code === 1) {
|
|
||||||
errorMsg = "Location permission denied. Please enable location access in your browser settings."
|
|
||||||
setPermissionState("denied")
|
|
||||||
} else if (err.code === 2) {
|
|
||||||
errorMsg = "Location unavailable. Please check your device settings."
|
|
||||||
} else if (err.code === 3) {
|
|
||||||
errorMsg = "Location request timed out. Please try again."
|
|
||||||
}
|
|
||||||
|
|
||||||
setError(errorMsg)
|
|
||||||
onError?.(errorMsg)
|
|
||||||
} finally {
|
|
||||||
setIsCapturing(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="location-capture">
|
|
||||||
<div className="capture-header">
|
|
||||||
<h2 className="text-2xl font-semibold text-balance">Share Your Location</h2>
|
|
||||||
<p className="text-sm text-muted-foreground mt-2">Securely share your current location with others</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Permission status */}
|
|
||||||
{permissionState === "denied" && (
|
|
||||||
<div className="permission-denied bg-destructive/10 border border-destructive/20 rounded-lg p-4 mt-4">
|
|
||||||
<p className="text-sm text-destructive">
|
|
||||||
Location access is blocked. Please enable it in your browser settings to continue.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Current location display */}
|
|
||||||
{currentLocation && (
|
|
||||||
<div className="current-location bg-muted/50 rounded-lg p-4 mt-4">
|
|
||||||
<h3 className="text-sm font-medium mb-2">Current Location</h3>
|
|
||||||
<div className="location-details text-xs space-y-1">
|
|
||||||
<p>
|
|
||||||
<span className="text-muted-foreground">Latitude:</span> {currentLocation.coords.latitude.toFixed(6)}
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<span className="text-muted-foreground">Longitude:</span> {currentLocation.coords.longitude.toFixed(6)}
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<span className="text-muted-foreground">Accuracy:</span> ±{Math.round(currentLocation.coords.accuracy)}m
|
|
||||||
</p>
|
|
||||||
<p className="text-muted-foreground">Captured {new Date(currentLocation.timestamp).toLocaleString()}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Error display */}
|
|
||||||
{error && (
|
|
||||||
<div className="error-message bg-destructive/10 border border-destructive/20 rounded-lg p-4 mt-4">
|
|
||||||
<p className="text-sm text-destructive">{error}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Capture button */}
|
|
||||||
<button
|
|
||||||
onClick={captureLocation}
|
|
||||||
disabled={isCapturing || permissionState === "denied" || !session.authed}
|
|
||||||
className="capture-button w-full mt-6 bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed rounded-lg px-6 py-3 font-medium transition-colors"
|
|
||||||
>
|
|
||||||
{isCapturing ? (
|
|
||||||
<span className="flex items-center justify-center gap-2">
|
|
||||||
<span className="spinner" />
|
|
||||||
Capturing Location...
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
"Capture My Location"
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{!session.authed && (
|
|
||||||
<p className="text-xs text-muted-foreground text-center mt-3">Please log in to share your location</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,270 +0,0 @@
|
||||||
"use client"
|
|
||||||
|
|
||||||
import type React from "react"
|
|
||||||
import { useState, useEffect } from "react"
|
|
||||||
import { useAuth } from "@/context/AuthContext"
|
|
||||||
import { LocationStorageService, type LocationData, type LocationShare } from "@/lib/location/locationStorage"
|
|
||||||
import { LocationMap } from "./LocationMap"
|
|
||||||
|
|
||||||
interface ShareWithLocation {
|
|
||||||
share: LocationShare
|
|
||||||
location: LocationData
|
|
||||||
}
|
|
||||||
|
|
||||||
export const LocationDashboard: React.FC = () => {
|
|
||||||
const { session, fileSystem } = useAuth()
|
|
||||||
const [shares, setShares] = useState<ShareWithLocation[]>([])
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [selectedShare, setSelectedShare] = useState<ShareWithLocation | null>(null)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const loadShares = async () => {
|
|
||||||
if (!fileSystem) {
|
|
||||||
setError("File system not available")
|
|
||||||
setLoading(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const storageService = new LocationStorageService(fileSystem)
|
|
||||||
await storageService.initialize()
|
|
||||||
|
|
||||||
// Get all shares
|
|
||||||
const allShares = await storageService.getAllShares()
|
|
||||||
|
|
||||||
// Get locations for each share
|
|
||||||
const sharesWithLocations: ShareWithLocation[] = []
|
|
||||||
|
|
||||||
for (const share of allShares) {
|
|
||||||
const location = await storageService.getLocation(share.locationId)
|
|
||||||
if (location) {
|
|
||||||
sharesWithLocations.push({ share, location })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by creation date (newest first)
|
|
||||||
sharesWithLocations.sort((a, b) => b.share.createdAt - a.share.createdAt)
|
|
||||||
|
|
||||||
setShares(sharesWithLocations)
|
|
||||||
setLoading(false)
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Error loading shares:", err)
|
|
||||||
setError("Failed to load location shares")
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (session.authed && fileSystem) {
|
|
||||||
loadShares()
|
|
||||||
}
|
|
||||||
}, [session.authed, fileSystem])
|
|
||||||
|
|
||||||
const handleCopyLink = async (shareToken: string) => {
|
|
||||||
const baseUrl = window.location.origin
|
|
||||||
const link = `${baseUrl}/location/${shareToken}`
|
|
||||||
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(link)
|
|
||||||
alert("Link copied to clipboard!")
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to copy link:", err)
|
|
||||||
alert("Failed to copy link")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const isExpired = (share: LocationShare): boolean => {
|
|
||||||
return share.expiresAt ? share.expiresAt < Date.now() : false
|
|
||||||
}
|
|
||||||
|
|
||||||
const isMaxViewsReached = (share: LocationShare): boolean => {
|
|
||||||
return share.maxViews ? share.viewCount >= share.maxViews : false
|
|
||||||
}
|
|
||||||
|
|
||||||
const getShareStatus = (share: LocationShare): { label: string; color: string } => {
|
|
||||||
if (isExpired(share)) {
|
|
||||||
return { label: "Expired", color: "text-destructive" }
|
|
||||||
}
|
|
||||||
if (isMaxViewsReached(share)) {
|
|
||||||
return { label: "Max Views Reached", color: "text-destructive" }
|
|
||||||
}
|
|
||||||
return { label: "Active", color: "text-green-600" }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!session.authed) {
|
|
||||||
return (
|
|
||||||
<div className="location-dashboard-auth flex items-center justify-center min-h-[400px]">
|
|
||||||
<div className="text-center max-w-md">
|
|
||||||
<div className="text-4xl mb-4">🔒</div>
|
|
||||||
<h2 className="text-xl font-semibold mb-2">Authentication Required</h2>
|
|
||||||
<p className="text-sm text-muted-foreground">Please log in to view your location shares</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="location-dashboard flex items-center justify-center min-h-[400px]">
|
|
||||||
<div className="flex flex-col items-center gap-3">
|
|
||||||
<div className="spinner" />
|
|
||||||
<p className="text-sm text-muted-foreground">Loading your shares...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className="location-dashboard flex items-center justify-center min-h-[400px]">
|
|
||||||
<div className="text-center max-w-md">
|
|
||||||
<div className="text-4xl mb-4">⚠️</div>
|
|
||||||
<h2 className="text-xl font-semibold mb-2">Error Loading Dashboard</h2>
|
|
||||||
<p className="text-sm text-muted-foreground">{error}</p>
|
|
||||||
<button
|
|
||||||
onClick={loadShares}
|
|
||||||
className="mt-4 px-6 py-2 rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
|
|
||||||
>
|
|
||||||
Retry
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="location-dashboard max-w-6xl mx-auto p-6">
|
|
||||||
<div className="dashboard-header mb-8">
|
|
||||||
<h1 className="text-3xl font-bold text-balance">Location Shares</h1>
|
|
||||||
<p className="text-sm text-muted-foreground mt-2">Manage your shared locations and privacy settings</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{shares.length === 0 ? (
|
|
||||||
<div className="empty-state flex flex-col items-center justify-center min-h-[400px] text-center">
|
|
||||||
<div className="text-6xl mb-4">📍</div>
|
|
||||||
<h2 className="text-xl font-semibold mb-2">No Location Shares Yet</h2>
|
|
||||||
<p className="text-sm text-muted-foreground mb-6 max-w-md">
|
|
||||||
You haven't shared any locations yet. Create your first share to get started.
|
|
||||||
</p>
|
|
||||||
<a
|
|
||||||
href="/share-location"
|
|
||||||
className="px-6 py-3 rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 transition-colors font-medium"
|
|
||||||
>
|
|
||||||
Share Your Location
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="dashboard-content">
|
|
||||||
{/* Stats Overview */}
|
|
||||||
<div className="stats-grid grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
|
|
||||||
<div className="stat-card bg-muted/50 rounded-lg p-4 border border-border">
|
|
||||||
<div className="stat-label text-sm text-muted-foreground mb-1">Total Shares</div>
|
|
||||||
<div className="stat-value text-3xl font-bold">{shares.length}</div>
|
|
||||||
</div>
|
|
||||||
<div className="stat-card bg-muted/50 rounded-lg p-4 border border-border">
|
|
||||||
<div className="stat-label text-sm text-muted-foreground mb-1">Active Shares</div>
|
|
||||||
<div className="stat-value text-3xl font-bold text-green-600">
|
|
||||||
{shares.filter((s) => !isExpired(s.share) && !isMaxViewsReached(s.share)).length}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="stat-card bg-muted/50 rounded-lg p-4 border border-border">
|
|
||||||
<div className="stat-label text-sm text-muted-foreground mb-1">Total Views</div>
|
|
||||||
<div className="stat-value text-3xl font-bold">
|
|
||||||
{shares.reduce((sum, s) => sum + s.share.viewCount, 0)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Shares List */}
|
|
||||||
<div className="shares-list space-y-4">
|
|
||||||
{shares.map(({ share, location }) => {
|
|
||||||
const status = getShareStatus(share)
|
|
||||||
const isSelected = selectedShare?.share.id === share.id
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={share.id}
|
|
||||||
className={`share-card bg-background rounded-lg border-2 transition-colors ${
|
|
||||||
isSelected ? "border-primary" : "border-border hover:border-primary/50"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="share-card-header p-4 flex items-start justify-between gap-4">
|
|
||||||
<div className="share-info flex-1">
|
|
||||||
<div className="flex items-center gap-3 mb-2">
|
|
||||||
<h3 className="font-semibold">Location Share</h3>
|
|
||||||
<span className={`text-xs font-medium ${status.color}`}>{status.label}</span>
|
|
||||||
</div>
|
|
||||||
<div className="share-meta text-xs text-muted-foreground space-y-1">
|
|
||||||
<p>Created: {new Date(share.createdAt).toLocaleString()}</p>
|
|
||||||
{share.expiresAt && <p>Expires: {new Date(share.expiresAt).toLocaleString()}</p>}
|
|
||||||
<p>
|
|
||||||
Views: {share.viewCount}
|
|
||||||
{share.maxViews && ` / ${share.maxViews}`}
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Precision: <span className="capitalize">{share.precision}</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="share-actions flex gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => handleCopyLink(share.shareToken)}
|
|
||||||
disabled={isExpired(share) || isMaxViewsReached(share)}
|
|
||||||
className="px-4 py-2 rounded-lg border border-border hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors text-sm"
|
|
||||||
>
|
|
||||||
Copy Link
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setSelectedShare(isSelected ? null : { share, location })}
|
|
||||||
className="px-4 py-2 rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 transition-colors text-sm"
|
|
||||||
>
|
|
||||||
{isSelected ? "Hide" : "View"} Map
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isSelected && (
|
|
||||||
<div className="share-card-body p-4 pt-0 border-t border-border mt-4">
|
|
||||||
<LocationMap location={location} precision={share.precision} showAccuracy={true} height="300px" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,241 +0,0 @@
|
||||||
"use client"
|
|
||||||
|
|
||||||
import type React from "react"
|
|
||||||
import { useEffect, useRef, useState } from "react"
|
|
||||||
import type { LocationData } from "@/lib/location/locationStorage"
|
|
||||||
import { obfuscateLocation } from "@/lib/location/locationStorage"
|
|
||||||
import type { PrecisionLevel } from "@/lib/location/types"
|
|
||||||
|
|
||||||
// Leaflet types
|
|
||||||
interface LeafletMap {
|
|
||||||
setView: (coords: [number, number], zoom: number) => void
|
|
||||||
remove: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LeafletMarker {
|
|
||||||
addTo: (map: LeafletMap) => LeafletMarker
|
|
||||||
bindPopup: (content: string) => LeafletMarker
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LeafletCircle {
|
|
||||||
addTo: (map: LeafletMap) => LeafletCircle
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LeafletTileLayer {
|
|
||||||
addTo: (map: LeafletMap) => LeafletTileLayer
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Leaflet {
|
|
||||||
map: (element: HTMLElement, options?: any) => LeafletMap
|
|
||||||
marker: (coords: [number, number], options?: any) => LeafletMarker
|
|
||||||
circle: (coords: [number, number], options?: any) => LeafletCircle
|
|
||||||
tileLayer: (url: string, options?: any) => LeafletTileLayer
|
|
||||||
icon: (options: any) => any
|
|
||||||
}
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface Window {
|
|
||||||
L?: Leaflet
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LocationMapProps {
|
|
||||||
location: LocationData
|
|
||||||
precision?: PrecisionLevel
|
|
||||||
showAccuracy?: boolean
|
|
||||||
height?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const LocationMap: React.FC<LocationMapProps> = ({
|
|
||||||
location,
|
|
||||||
precision = "exact",
|
|
||||||
showAccuracy = true,
|
|
||||||
height = "400px",
|
|
||||||
}) => {
|
|
||||||
const mapContainer = useRef<HTMLDivElement>(null)
|
|
||||||
const mapInstance = useRef<LeafletMap | null>(null)
|
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Load Leaflet CSS and JS
|
|
||||||
const loadLeaflet = async () => {
|
|
||||||
try {
|
|
||||||
// Load CSS
|
|
||||||
if (!document.querySelector('link[href*="leaflet.css"]')) {
|
|
||||||
const link = document.createElement("link")
|
|
||||||
link.rel = "stylesheet"
|
|
||||||
link.href = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
|
||||||
link.integrity = "sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
|
||||||
link.crossOrigin = ""
|
|
||||||
document.head.appendChild(link)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load JS
|
|
||||||
if (!window.L) {
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
const script = document.createElement("script")
|
|
||||||
script.src = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
|
|
||||||
script.integrity = "sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
|
|
||||||
script.crossOrigin = ""
|
|
||||||
script.onload = () => resolve()
|
|
||||||
script.onerror = () => reject(new Error("Failed to load Leaflet"))
|
|
||||||
document.head.appendChild(script)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoading(false)
|
|
||||||
} catch (err) {
|
|
||||||
setError("Failed to load map library")
|
|
||||||
setIsLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
loadLeaflet()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!mapContainer.current || !window.L || isLoading) return
|
|
||||||
|
|
||||||
// Clean up existing map
|
|
||||||
if (mapInstance.current) {
|
|
||||||
mapInstance.current.remove()
|
|
||||||
}
|
|
||||||
|
|
||||||
const L = window.L!
|
|
||||||
|
|
||||||
// Get obfuscated location based on precision
|
|
||||||
const { lat, lng, radius } = obfuscateLocation(location.latitude, location.longitude, precision)
|
|
||||||
|
|
||||||
// Create map
|
|
||||||
const map = L.map(mapContainer.current, {
|
|
||||||
center: [lat, lng],
|
|
||||||
zoom: precision === "exact" ? 15 : precision === "street" ? 14 : precision === "neighborhood" ? 12 : 10,
|
|
||||||
zoomControl: true,
|
|
||||||
attributionControl: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Add OpenStreetMap tiles
|
|
||||||
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
|
||||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
|
||||||
maxZoom: 19,
|
|
||||||
}).addTo(map)
|
|
||||||
|
|
||||||
// Add marker
|
|
||||||
const marker = L.marker([lat, lng], {
|
|
||||||
icon: L.icon({
|
|
||||||
iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png",
|
|
||||||
iconRetinaUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png",
|
|
||||||
shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png",
|
|
||||||
iconSize: [25, 41],
|
|
||||||
iconAnchor: [12, 41],
|
|
||||||
popupAnchor: [1, -34],
|
|
||||||
shadowSize: [41, 41],
|
|
||||||
}),
|
|
||||||
}).addTo(map)
|
|
||||||
|
|
||||||
// Add popup with location info
|
|
||||||
const popupContent = `
|
|
||||||
<div style="font-family: system-ui, sans-serif;">
|
|
||||||
<strong>Shared Location</strong><br/>
|
|
||||||
<small style="color: #666;">
|
|
||||||
Precision: ${precision}<br/>
|
|
||||||
${new Date(location.timestamp).toLocaleString()}
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
marker.bindPopup(popupContent)
|
|
||||||
|
|
||||||
// Add accuracy circle if showing accuracy
|
|
||||||
if (showAccuracy && radius > 0) {
|
|
||||||
L.circle([lat, lng], {
|
|
||||||
radius: radius,
|
|
||||||
color: "#3b82f6",
|
|
||||||
fillColor: "#3b82f6",
|
|
||||||
fillOpacity: 0.1,
|
|
||||||
weight: 2,
|
|
||||||
}).addTo(map)
|
|
||||||
}
|
|
||||||
|
|
||||||
mapInstance.current = map
|
|
||||||
|
|
||||||
// Cleanup
|
|
||||||
return () => {
|
|
||||||
if (mapInstance.current) {
|
|
||||||
mapInstance.current.remove()
|
|
||||||
mapInstance.current = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [location, precision, showAccuracy, isLoading])
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="map-error flex items-center justify-center bg-muted/50 rounded-lg border border-border"
|
|
||||||
style={{ height }}
|
|
||||||
>
|
|
||||||
<p className="text-sm text-destructive">{error}</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="map-loading flex items-center justify-center bg-muted/50 rounded-lg border border-border"
|
|
||||||
style={{ height }}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col items-center gap-3">
|
|
||||||
<div className="spinner" />
|
|
||||||
<p className="text-sm text-muted-foreground">Loading map...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="location-map-wrapper">
|
|
||||||
<div
|
|
||||||
ref={mapContainer}
|
|
||||||
className="location-map rounded-lg border border-border overflow-hidden"
|
|
||||||
style={{ height, width: "100%" }}
|
|
||||||
/>
|
|
||||||
<div className="map-info mt-3 text-xs text-muted-foreground">
|
|
||||||
<p>
|
|
||||||
Showing {precision} location • Last updated {new Date(location.timestamp).toLocaleTimeString()}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
import {
|
|
||||||
TLUiDialogProps,
|
|
||||||
TldrawUiDialogBody,
|
|
||||||
TldrawUiDialogCloseButton,
|
|
||||||
TldrawUiDialogHeader,
|
|
||||||
TldrawUiDialogTitle,
|
|
||||||
} from "tldraw"
|
|
||||||
import React from "react"
|
|
||||||
import { ShareLocation } from "./ShareLocation"
|
|
||||||
|
|
||||||
export function LocationShareDialog({ onClose: _onClose }: TLUiDialogProps) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<TldrawUiDialogHeader>
|
|
||||||
<TldrawUiDialogTitle>Share Location</TldrawUiDialogTitle>
|
|
||||||
<TldrawUiDialogCloseButton />
|
|
||||||
</TldrawUiDialogHeader>
|
|
||||||
<TldrawUiDialogBody style={{ maxWidth: 800, maxHeight: "90vh", overflow: "auto" }}>
|
|
||||||
<ShareLocation />
|
|
||||||
</TldrawUiDialogBody>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,183 +0,0 @@
|
||||||
"use client"
|
|
||||||
|
|
||||||
import type React from "react"
|
|
||||||
import { useState, useEffect } from "react"
|
|
||||||
import { LocationMap } from "./LocationMap"
|
|
||||||
import type { LocationData, LocationShare } from "@/lib/location/locationStorage"
|
|
||||||
import { LocationStorageService } from "@/lib/location/locationStorage"
|
|
||||||
import { useAuth } from "@/context/AuthContext"
|
|
||||||
|
|
||||||
interface LocationViewerProps {
|
|
||||||
shareToken: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const LocationViewer: React.FC<LocationViewerProps> = ({ shareToken }) => {
|
|
||||||
const { fileSystem } = useAuth()
|
|
||||||
const [location, setLocation] = useState<LocationData | null>(null)
|
|
||||||
const [share, setShare] = useState<LocationShare | null>(null)
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const loadSharedLocation = async () => {
|
|
||||||
if (!fileSystem) {
|
|
||||||
setError("File system not available")
|
|
||||||
setLoading(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const storageService = new LocationStorageService(fileSystem)
|
|
||||||
await storageService.initialize()
|
|
||||||
|
|
||||||
// Get share by token
|
|
||||||
const shareData = await storageService.getShareByToken(shareToken)
|
|
||||||
if (!shareData) {
|
|
||||||
setError("Share not found or expired")
|
|
||||||
setLoading(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if share is expired
|
|
||||||
if (shareData.expiresAt && shareData.expiresAt < Date.now()) {
|
|
||||||
setError("This share has expired")
|
|
||||||
setLoading(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if max views reached
|
|
||||||
if (shareData.maxViews && shareData.viewCount >= shareData.maxViews) {
|
|
||||||
setError("This share has reached its maximum view limit")
|
|
||||||
setLoading(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get location data
|
|
||||||
const locationData = await storageService.getLocation(shareData.locationId)
|
|
||||||
if (!locationData) {
|
|
||||||
setError("Location data not found")
|
|
||||||
setLoading(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setShare(shareData)
|
|
||||||
setLocation(locationData)
|
|
||||||
|
|
||||||
// Increment view count
|
|
||||||
await storageService.incrementShareViews(shareData.id)
|
|
||||||
|
|
||||||
setLoading(false)
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Error loading shared location:", err)
|
|
||||||
setError("Failed to load shared location")
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
loadSharedLocation()
|
|
||||||
}, [shareToken, fileSystem])
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="location-viewer flex items-center justify-center min-h-[400px]">
|
|
||||||
<div className="flex flex-col items-center gap-3">
|
|
||||||
<div className="spinner" />
|
|
||||||
<p className="text-sm text-muted-foreground">Loading shared location...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className="location-viewer flex items-center justify-center min-h-[400px]">
|
|
||||||
<div className="text-center max-w-md">
|
|
||||||
<div className="text-4xl mb-4">📍</div>
|
|
||||||
<h2 className="text-xl font-semibold mb-2">Unable to Load Location</h2>
|
|
||||||
<p className="text-sm text-muted-foreground">{error}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!location || !share) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="location-viewer max-w-4xl mx-auto p-6">
|
|
||||||
<div className="viewer-header mb-6">
|
|
||||||
<h1 className="text-3xl font-bold text-balance">Shared Location</h1>
|
|
||||||
<p className="text-sm text-muted-foreground mt-2">Someone has shared their location with you</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="viewer-content space-y-6">
|
|
||||||
{/* Map Display */}
|
|
||||||
<LocationMap location={location} precision={share.precision} showAccuracy={true} height="500px" />
|
|
||||||
|
|
||||||
{/* Share Info */}
|
|
||||||
<div className="share-info bg-muted/50 rounded-lg p-4 space-y-2">
|
|
||||||
<div className="info-row flex justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">Precision Level:</span>
|
|
||||||
<span className="font-medium capitalize">{share.precision}</span>
|
|
||||||
</div>
|
|
||||||
<div className="info-row flex justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">Views:</span>
|
|
||||||
<span className="font-medium">
|
|
||||||
{share.viewCount} {share.maxViews ? `/ ${share.maxViews}` : ""}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{share.expiresAt && (
|
|
||||||
<div className="info-row flex justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">Expires:</span>
|
|
||||||
<span className="font-medium">{new Date(share.expiresAt).toLocaleString()}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="info-row flex justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">Shared:</span>
|
|
||||||
<span className="font-medium">{new Date(share.createdAt).toLocaleString()}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Privacy Notice */}
|
|
||||||
<div className="privacy-notice bg-primary/5 border border-primary/20 rounded-lg p-4">
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
This location is shared securely and will expire based on the sender's privacy settings. The location data
|
|
||||||
is stored in a decentralized filesystem and is only accessible via this unique link.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,279 +0,0 @@
|
||||||
"use client"
|
|
||||||
|
|
||||||
import React, { useState } from "react"
|
|
||||||
import { LocationCapture } from "./LocationCapture"
|
|
||||||
import { ShareSettingsComponent } from "./ShareSettings"
|
|
||||||
import { LocationMap } from "./LocationMap"
|
|
||||||
import type { LocationData, LocationShare } from "@/lib/location/locationStorage"
|
|
||||||
import { LocationStorageService, generateShareToken } from "@/lib/location/locationStorage"
|
|
||||||
import type { ShareSettings } from "@/lib/location/types"
|
|
||||||
import { useAuth } from "@/context/AuthContext"
|
|
||||||
|
|
||||||
export const ShareLocation: React.FC = () => {
|
|
||||||
const { session, fileSystem } = useAuth()
|
|
||||||
const [step, setStep] = useState<"capture" | "settings" | "share">("capture")
|
|
||||||
const [capturedLocation, setCapturedLocation] = useState<LocationData | null>(null)
|
|
||||||
const [shareSettings, setShareSettings] = useState<ShareSettings>({
|
|
||||||
duration: 24 * 3600000, // 24 hours
|
|
||||||
maxViews: null,
|
|
||||||
precision: "street",
|
|
||||||
})
|
|
||||||
const [shareLink, setShareLink] = useState<string | null>(null)
|
|
||||||
const [isCreatingShare, setIsCreatingShare] = useState(false)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
// Show loading state while auth is initializing
|
|
||||||
if (session.loading) {
|
|
||||||
return (
|
|
||||||
<div className="share-location-loading flex items-center justify-center min-h-[400px]">
|
|
||||||
<div className="text-center max-w-md">
|
|
||||||
<div className="text-4xl mb-4 animate-spin">⏳</div>
|
|
||||||
<h2 className="text-xl font-semibold mb-2">Loading...</h2>
|
|
||||||
<p className="text-sm text-muted-foreground">Initializing authentication</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleLocationCaptured = (location: LocationData) => {
|
|
||||||
setCapturedLocation(location)
|
|
||||||
setStep("settings")
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCreateShare = async () => {
|
|
||||||
if (!capturedLocation || !fileSystem) {
|
|
||||||
setError("Location or filesystem not available")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsCreatingShare(true)
|
|
||||||
setError(null)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const storageService = new LocationStorageService(fileSystem)
|
|
||||||
await storageService.initialize()
|
|
||||||
|
|
||||||
// Generate share token
|
|
||||||
const shareToken = generateShareToken()
|
|
||||||
|
|
||||||
// Calculate expiration
|
|
||||||
const expiresAt = shareSettings.duration ? Date.now() + shareSettings.duration : null
|
|
||||||
|
|
||||||
// Update location with expiration
|
|
||||||
const updatedLocation: LocationData = {
|
|
||||||
...capturedLocation,
|
|
||||||
expiresAt,
|
|
||||||
precision: shareSettings.precision,
|
|
||||||
}
|
|
||||||
|
|
||||||
await storageService.saveLocation(updatedLocation)
|
|
||||||
|
|
||||||
// Create share
|
|
||||||
const share: LocationShare = {
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
locationId: capturedLocation.id,
|
|
||||||
shareToken,
|
|
||||||
createdAt: Date.now(),
|
|
||||||
expiresAt,
|
|
||||||
maxViews: shareSettings.maxViews,
|
|
||||||
viewCount: 0,
|
|
||||||
precision: shareSettings.precision,
|
|
||||||
}
|
|
||||||
|
|
||||||
await storageService.createShare(share)
|
|
||||||
|
|
||||||
// Generate share link
|
|
||||||
const baseUrl = window.location.origin
|
|
||||||
const link = `${baseUrl}/location/${shareToken}`
|
|
||||||
|
|
||||||
setShareLink(link)
|
|
||||||
setStep("share")
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Error creating share:", err)
|
|
||||||
setError("Failed to create share link")
|
|
||||||
} finally {
|
|
||||||
setIsCreatingShare(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCopyLink = async () => {
|
|
||||||
if (!shareLink) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(shareLink)
|
|
||||||
// Could add a toast notification here
|
|
||||||
alert("Link copied to clipboard!")
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to copy link:", err)
|
|
||||||
alert("Failed to copy link. Please copy manually.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleReset = () => {
|
|
||||||
setStep("capture")
|
|
||||||
setCapturedLocation(null)
|
|
||||||
setShareLink(null)
|
|
||||||
setError(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!session.authed) {
|
|
||||||
return (
|
|
||||||
<div className="share-location-auth flex items-center justify-center min-h-[400px]">
|
|
||||||
<div className="text-center max-w-md">
|
|
||||||
<div className="text-4xl mb-4">🔒</div>
|
|
||||||
<h2 className="text-xl font-semibold mb-2">Authentication Required</h2>
|
|
||||||
<p className="text-sm text-muted-foreground">Please log in to share your location securely</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="share-location max-w-4xl mx-auto p-6">
|
|
||||||
{/* Progress Steps */}
|
|
||||||
<div className="progress-steps flex items-center justify-center gap-4 mb-8">
|
|
||||||
{["capture", "settings", "share"].map((s, index) => (
|
|
||||||
<React.Fragment key={s}>
|
|
||||||
<div className="step-item flex items-center gap-2">
|
|
||||||
<div
|
|
||||||
className={`step-number w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium transition-colors ${
|
|
||||||
step === s
|
|
||||||
? "bg-primary text-primary-foreground"
|
|
||||||
: index < ["capture", "settings", "share"].indexOf(step)
|
|
||||||
? "bg-primary/20 text-primary"
|
|
||||||
: "bg-muted text-muted-foreground"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{index + 1}
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
className={`step-label text-sm font-medium capitalize ${
|
|
||||||
step === s ? "text-foreground" : "text-muted-foreground"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{s}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{index < 2 && (
|
|
||||||
<div
|
|
||||||
className={`step-connector h-0.5 w-12 ${
|
|
||||||
index < ["capture", "settings", "share"].indexOf(step) ? "bg-primary" : "bg-muted"
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</React.Fragment>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Error Display */}
|
|
||||||
{error && (
|
|
||||||
<div className="error-message bg-destructive/10 border border-destructive/20 rounded-lg p-4 mb-6">
|
|
||||||
<p className="text-sm text-destructive">{error}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Step Content */}
|
|
||||||
<div className="step-content">
|
|
||||||
{step === "capture" && <LocationCapture onLocationCaptured={handleLocationCaptured} onError={setError} />}
|
|
||||||
|
|
||||||
{step === "settings" && capturedLocation && (
|
|
||||||
<div className="settings-step space-y-6">
|
|
||||||
<div className="location-preview">
|
|
||||||
<h3 className="text-lg font-semibold mb-4">Preview Your Location</h3>
|
|
||||||
<LocationMap
|
|
||||||
location={capturedLocation}
|
|
||||||
precision={shareSettings.precision}
|
|
||||||
showAccuracy={true}
|
|
||||||
height="300px"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ShareSettingsComponent onSettingsChange={setShareSettings} initialSettings={shareSettings} />
|
|
||||||
|
|
||||||
<div className="settings-actions flex gap-3">
|
|
||||||
<button
|
|
||||||
onClick={() => setStep("capture")}
|
|
||||||
className="flex-1 px-6 py-3 rounded-lg border border-border hover:bg-muted transition-colors"
|
|
||||||
>
|
|
||||||
Back
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleCreateShare}
|
|
||||||
disabled={isCreatingShare}
|
|
||||||
className="flex-1 px-6 py-3 rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium"
|
|
||||||
>
|
|
||||||
{isCreatingShare ? "Creating Share..." : "Create Share Link"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === "share" && shareLink && capturedLocation && (
|
|
||||||
<div className="share-step space-y-6">
|
|
||||||
<div className="share-success text-center mb-6">
|
|
||||||
<div className="text-5xl mb-4">✓</div>
|
|
||||||
<h2 className="text-2xl font-bold mb-2">Share Link Created!</h2>
|
|
||||||
<p className="text-sm text-muted-foreground">Your location is ready to share securely</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="share-link-box bg-muted/50 rounded-lg p-4 border border-border">
|
|
||||||
<label className="block text-sm font-medium mb-2">Share Link</label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={shareLink}
|
|
||||||
readOnly
|
|
||||||
className="flex-1 px-3 py-2 rounded-lg border border-border bg-background text-sm"
|
|
||||||
onClick={(e) => e.currentTarget.select()}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={handleCopyLink}
|
|
||||||
className="px-4 py-2 rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 transition-colors text-sm font-medium whitespace-nowrap"
|
|
||||||
>
|
|
||||||
Copy Link
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="share-preview">
|
|
||||||
<h3 className="text-lg font-semibold mb-4">Location Preview</h3>
|
|
||||||
<LocationMap
|
|
||||||
location={capturedLocation}
|
|
||||||
precision={shareSettings.precision}
|
|
||||||
showAccuracy={true}
|
|
||||||
height="300px"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="share-details bg-muted/50 rounded-lg p-4 space-y-2">
|
|
||||||
<h4 className="font-medium mb-3">Share Settings</h4>
|
|
||||||
<div className="detail-row flex justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">Precision:</span>
|
|
||||||
<span className="font-medium capitalize">{shareSettings.precision}</span>
|
|
||||||
</div>
|
|
||||||
<div className="detail-row flex justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">Duration:</span>
|
|
||||||
<span className="font-medium">
|
|
||||||
{shareSettings.duration ? `${shareSettings.duration / 3600000} hours` : "No expiration"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="detail-row flex justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">Max Views:</span>
|
|
||||||
<span className="font-medium">{shareSettings.maxViews || "Unlimited"}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handleReset}
|
|
||||||
className="w-full px-6 py-3 rounded-lg border border-border hover:bg-muted transition-colors"
|
|
||||||
>
|
|
||||||
Share Another Location
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,150 +0,0 @@
|
||||||
"use client"
|
|
||||||
|
|
||||||
import React, { useState } from "react"
|
|
||||||
import type { ShareSettings, PrecisionLevel } from "@/lib/location/types"
|
|
||||||
|
|
||||||
interface ShareSettingsProps {
|
|
||||||
onSettingsChange: (settings: ShareSettings) => void
|
|
||||||
initialSettings?: Partial<ShareSettings>
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ShareSettingsComponent: React.FC<ShareSettingsProps> = ({ onSettingsChange, initialSettings = {} }) => {
|
|
||||||
const [duration, setDuration] = useState<string>(
|
|
||||||
initialSettings.duration ? String(initialSettings.duration / 3600000) : "24",
|
|
||||||
)
|
|
||||||
const [maxViews, setMaxViews] = useState<string>(
|
|
||||||
initialSettings.maxViews ? String(initialSettings.maxViews) : "unlimited",
|
|
||||||
)
|
|
||||||
const [precision, setPrecision] = useState<PrecisionLevel>(initialSettings.precision || "street")
|
|
||||||
|
|
||||||
const handleChange = () => {
|
|
||||||
const settings: ShareSettings = {
|
|
||||||
duration: duration === "unlimited" ? null : Number(duration) * 3600000,
|
|
||||||
maxViews: maxViews === "unlimited" ? null : Number(maxViews),
|
|
||||||
precision,
|
|
||||||
}
|
|
||||||
onSettingsChange(settings)
|
|
||||||
}
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
handleChange()
|
|
||||||
}, [duration, maxViews, precision])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="share-settings space-y-6">
|
|
||||||
<div className="settings-header">
|
|
||||||
<h3 className="text-lg font-semibold">Privacy Settings</h3>
|
|
||||||
<p className="text-sm text-muted-foreground mt-1">Control how your location is shared</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Precision Level */}
|
|
||||||
<div className="setting-group">
|
|
||||||
<label className="block text-sm font-medium mb-3">Location Precision</label>
|
|
||||||
<div className="precision-options space-y-2">
|
|
||||||
{[
|
|
||||||
{ value: "exact", label: "Exact Location", desc: "Share precise coordinates" },
|
|
||||||
{ value: "street", label: "Street Level", desc: "~100m radius" },
|
|
||||||
{ value: "neighborhood", label: "Neighborhood", desc: "~1km radius" },
|
|
||||||
{ value: "city", label: "City Level", desc: "~10km radius" },
|
|
||||||
].map((option) => (
|
|
||||||
<label
|
|
||||||
key={option.value}
|
|
||||||
className={`precision-option flex items-start gap-3 p-3 rounded-lg border-2 cursor-pointer transition-colors ${
|
|
||||||
precision === option.value ? "border-primary bg-primary/5" : "border-border hover:border-primary/50"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="precision"
|
|
||||||
value={option.value}
|
|
||||||
checked={precision === option.value}
|
|
||||||
onChange={(e) => setPrecision(e.target.value as PrecisionLevel)}
|
|
||||||
className="mt-0.5"
|
|
||||||
/>
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="font-medium text-sm">{option.label}</div>
|
|
||||||
<div className="text-xs text-muted-foreground">{option.desc}</div>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Duration */}
|
|
||||||
<div className="setting-group">
|
|
||||||
<label htmlFor="duration" className="block text-sm font-medium mb-2">
|
|
||||||
Share Duration
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="duration"
|
|
||||||
value={duration}
|
|
||||||
onChange={(e) => setDuration(e.target.value)}
|
|
||||||
className="w-full px-3 py-2 rounded-lg border border-border bg-background focus:outline-none focus:ring-2 focus:ring-primary"
|
|
||||||
>
|
|
||||||
<option value="1">1 hour</option>
|
|
||||||
<option value="6">6 hours</option>
|
|
||||||
<option value="24">24 hours</option>
|
|
||||||
<option value="168">1 week</option>
|
|
||||||
<option value="unlimited">No expiration</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Max Views */}
|
|
||||||
<div className="setting-group">
|
|
||||||
<label htmlFor="maxViews" className="block text-sm font-medium mb-2">
|
|
||||||
Maximum Views
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="maxViews"
|
|
||||||
value={maxViews}
|
|
||||||
onChange={(e) => setMaxViews(e.target.value)}
|
|
||||||
className="w-full px-3 py-2 rounded-lg border border-border bg-background focus:outline-none focus:ring-2 focus:ring-primary"
|
|
||||||
>
|
|
||||||
<option value="1">1 view</option>
|
|
||||||
<option value="5">5 views</option>
|
|
||||||
<option value="10">10 views</option>
|
|
||||||
<option value="unlimited">Unlimited</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Privacy Notice */}
|
|
||||||
<div className="privacy-notice bg-muted/50 rounded-lg p-4">
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Your location data is stored securely in your private filesystem. Only people with the share link can view
|
|
||||||
your location, and shares automatically expire based on your settings.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import React, { createContext, useContext, useState, useEffect, useCallback, useMemo, ReactNode } from 'react';
|
import React, { createContext, useContext, useState, useEffect, useCallback, useMemo, ReactNode } from 'react';
|
||||||
import type FileSystem from '@oddjs/odd/fs/index';
|
|
||||||
import { Session, SessionError } from '../lib/auth/types';
|
import { Session, SessionError } from '../lib/auth/types';
|
||||||
import { AuthService } from '../lib/auth/authService';
|
import { AuthService } from '../lib/auth/authService';
|
||||||
import { saveSession, clearStoredSession } from '../lib/auth/sessionPersistence';
|
import { saveSession, clearStoredSession } from '../lib/auth/sessionPersistence';
|
||||||
|
|
@ -9,8 +8,6 @@ interface AuthContextType {
|
||||||
setSession: (updatedSession: Partial<Session>) => void;
|
setSession: (updatedSession: Partial<Session>) => void;
|
||||||
updateSession: (updatedSession: Partial<Session>) => void;
|
updateSession: (updatedSession: Partial<Session>) => void;
|
||||||
clearSession: () => void;
|
clearSession: () => void;
|
||||||
fileSystem: FileSystem | null;
|
|
||||||
setFileSystem: (fs: FileSystem | null) => void;
|
|
||||||
initialize: () => Promise<void>;
|
initialize: () => Promise<void>;
|
||||||
login: (username: string) => Promise<boolean>;
|
login: (username: string) => Promise<boolean>;
|
||||||
register: (username: string) => Promise<boolean>;
|
register: (username: string) => Promise<boolean>;
|
||||||
|
|
@ -30,47 +27,40 @@ export const AuthContext = createContext<AuthContextType | undefined>(undefined)
|
||||||
|
|
||||||
export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||||
const [session, setSessionState] = useState<Session>(initialSession);
|
const [session, setSessionState] = useState<Session>(initialSession);
|
||||||
const [fileSystem, setFileSystemState] = useState<FileSystem | null>(null);
|
|
||||||
|
|
||||||
// Update session with partial data
|
// Update session with partial data
|
||||||
const setSession = useCallback((updatedSession: Partial<Session>) => {
|
const setSession = useCallback((updatedSession: Partial<Session>) => {
|
||||||
setSessionState(prev => {
|
setSessionState(prev => {
|
||||||
const newSession = { ...prev, ...updatedSession };
|
const newSession = { ...prev, ...updatedSession };
|
||||||
|
|
||||||
// Save session to localStorage if authenticated
|
// Save session to localStorage if authenticated
|
||||||
if (newSession.authed && newSession.username) {
|
if (newSession.authed && newSession.username) {
|
||||||
saveSession(newSession);
|
saveSession(newSession);
|
||||||
}
|
}
|
||||||
|
|
||||||
return newSession;
|
return newSession;
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Set file system
|
|
||||||
const setFileSystem = useCallback((fs: FileSystem | null) => {
|
|
||||||
setFileSystemState(fs);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the authentication state
|
* Initialize the authentication state
|
||||||
*/
|
*/
|
||||||
const initialize = useCallback(async (): Promise<void> => {
|
const initialize = useCallback(async (): Promise<void> => {
|
||||||
setSessionState(prev => ({ ...prev, loading: true }));
|
setSessionState(prev => ({ ...prev, loading: true }));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { session: newSession, fileSystem: newFs } = await AuthService.initialize();
|
const { session: newSession } = await AuthService.initialize();
|
||||||
setSessionState(newSession);
|
setSessionState(newSession);
|
||||||
setFileSystemState(newFs);
|
|
||||||
|
|
||||||
// Save session to localStorage if authenticated
|
// Save session to localStorage if authenticated
|
||||||
if (newSession.authed && newSession.username) {
|
if (newSession.authed && newSession.username) {
|
||||||
saveSession(newSession);
|
saveSession(newSession);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Auth initialization error:', error);
|
console.error('Auth initialization error:', error);
|
||||||
setSessionState(prev => ({
|
setSessionState(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
loading: false,
|
loading: false,
|
||||||
authed: false,
|
authed: false,
|
||||||
error: error as SessionError
|
error: error as SessionError
|
||||||
}));
|
}));
|
||||||
|
|
@ -82,21 +72,20 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||||
*/
|
*/
|
||||||
const login = useCallback(async (username: string): Promise<boolean> => {
|
const login = useCallback(async (username: string): Promise<boolean> => {
|
||||||
setSessionState(prev => ({ ...prev, loading: true }));
|
setSessionState(prev => ({ ...prev, loading: true }));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await AuthService.login(username);
|
const result = await AuthService.login(username);
|
||||||
|
|
||||||
if (result.success && result.session && result.fileSystem) {
|
if (result.success && result.session) {
|
||||||
setSessionState(result.session);
|
setSessionState(result.session);
|
||||||
setFileSystemState(result.fileSystem);
|
|
||||||
|
|
||||||
// Save session to localStorage if authenticated
|
// Save session to localStorage if authenticated
|
||||||
if (result.session.authed && result.session.username) {
|
if (result.session.authed && result.session.username) {
|
||||||
saveSession(result.session);
|
saveSession(result.session);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
} else {
|
} else {
|
||||||
setSessionState(prev => ({
|
setSessionState(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
loading: false,
|
loading: false,
|
||||||
error: result.error as SessionError
|
error: result.error as SessionError
|
||||||
|
|
@ -105,7 +94,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Login error:', error);
|
console.error('Login error:', error);
|
||||||
setSessionState(prev => ({
|
setSessionState(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
loading: false,
|
loading: false,
|
||||||
error: error as SessionError
|
error: error as SessionError
|
||||||
|
|
@ -119,21 +108,20 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||||
*/
|
*/
|
||||||
const register = useCallback(async (username: string): Promise<boolean> => {
|
const register = useCallback(async (username: string): Promise<boolean> => {
|
||||||
setSessionState(prev => ({ ...prev, loading: true }));
|
setSessionState(prev => ({ ...prev, loading: true }));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await AuthService.register(username);
|
const result = await AuthService.register(username);
|
||||||
|
|
||||||
if (result.success && result.session && result.fileSystem) {
|
if (result.success && result.session) {
|
||||||
setSessionState(result.session);
|
setSessionState(result.session);
|
||||||
setFileSystemState(result.fileSystem);
|
|
||||||
|
|
||||||
// Save session to localStorage if authenticated
|
// Save session to localStorage if authenticated
|
||||||
if (result.session.authed && result.session.username) {
|
if (result.session.authed && result.session.username) {
|
||||||
saveSession(result.session);
|
saveSession(result.session);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
} else {
|
} else {
|
||||||
setSessionState(prev => ({
|
setSessionState(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
loading: false,
|
loading: false,
|
||||||
error: result.error as SessionError
|
error: result.error as SessionError
|
||||||
|
|
@ -142,7 +130,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Register error:', error);
|
console.error('Register error:', error);
|
||||||
setSessionState(prev => ({
|
setSessionState(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
loading: false,
|
loading: false,
|
||||||
error: error as SessionError
|
error: error as SessionError
|
||||||
|
|
@ -164,7 +152,6 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||||
obsidianVaultPath: undefined,
|
obsidianVaultPath: undefined,
|
||||||
obsidianVaultName: undefined
|
obsidianVaultName: undefined
|
||||||
});
|
});
|
||||||
setFileSystemState(null);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -200,13 +187,11 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||||
setSession,
|
setSession,
|
||||||
updateSession: setSession,
|
updateSession: setSession,
|
||||||
clearSession,
|
clearSession,
|
||||||
fileSystem,
|
|
||||||
setFileSystem,
|
|
||||||
initialize,
|
initialize,
|
||||||
login,
|
login,
|
||||||
register,
|
register,
|
||||||
logout
|
logout
|
||||||
}), [session, setSession, clearSession, fileSystem, setFileSystem, initialize, login, register, logout]);
|
}), [session, setSession, clearSession, initialize, login, register, logout]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={contextValue}>
|
<AuthContext.Provider value={contextValue}>
|
||||||
|
|
@ -221,4 +206,4 @@ export const useAuth = (): AuthContextType => {
|
||||||
throw new Error('useAuth must be used within an AuthProvider');
|
throw new Error('useAuth must be used within an AuthProvider');
|
||||||
}
|
}
|
||||||
return context;
|
return context;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,259 +0,0 @@
|
||||||
import * as odd from '@oddjs/odd';
|
|
||||||
import type FileSystem from '@oddjs/odd/fs/index';
|
|
||||||
import { asyncDebounce } from '../utils/asyncDebounce';
|
|
||||||
import * as browser from '../utils/browser';
|
|
||||||
import { DIRECTORIES } from '../../context/FileSystemContext';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Constants for filesystem paths
|
|
||||||
*/
|
|
||||||
export const ACCOUNT_SETTINGS_DIR = ['private', 'settings'];
|
|
||||||
export const GALLERY_DIRS = {
|
|
||||||
PUBLIC: ['public', 'gallery'],
|
|
||||||
PRIVATE: ['private', 'gallery']
|
|
||||||
};
|
|
||||||
export const AREAS = {
|
|
||||||
PUBLIC: 'public',
|
|
||||||
PRIVATE: 'private'
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if a username is valid according to ODD's rules
|
|
||||||
* @param username The username to check
|
|
||||||
* @returns A boolean indicating if the username is valid
|
|
||||||
*/
|
|
||||||
export const isUsernameValid = async (username: string): Promise<boolean> => {
|
|
||||||
console.log('Checking if username is valid:', username);
|
|
||||||
try {
|
|
||||||
// Fallback if ODD account functions are not available
|
|
||||||
if (odd.account && odd.account.isUsernameValid) {
|
|
||||||
const isValid = await odd.account.isUsernameValid(username);
|
|
||||||
console.log('Username validity check result:', isValid);
|
|
||||||
return Boolean(isValid);
|
|
||||||
}
|
|
||||||
// Default validation if ODD is not available
|
|
||||||
const usernameRegex = /^[a-zA-Z0-9_-]{3,20}$/;
|
|
||||||
const isValid = usernameRegex.test(username);
|
|
||||||
console.log('Username validity check result (fallback):', isValid);
|
|
||||||
return isValid;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error checking username validity:', error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Debounced function to check if a username is available
|
|
||||||
*/
|
|
||||||
const debouncedIsUsernameAvailable = asyncDebounce(
|
|
||||||
(username: string) => {
|
|
||||||
// Fallback if ODD account functions are not available
|
|
||||||
if (odd.account && odd.account.isUsernameAvailable) {
|
|
||||||
return odd.account.isUsernameAvailable(username);
|
|
||||||
}
|
|
||||||
// Default to true if ODD is not available
|
|
||||||
return Promise.resolve(true);
|
|
||||||
},
|
|
||||||
300
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if a username is available
|
|
||||||
* @param username The username to check
|
|
||||||
* @returns A boolean indicating if the username is available
|
|
||||||
*/
|
|
||||||
export const isUsernameAvailable = async (
|
|
||||||
username: string
|
|
||||||
): Promise<boolean> => {
|
|
||||||
console.log('Checking if username is available:', username);
|
|
||||||
try {
|
|
||||||
// In a local development environment, simulate the availability check
|
|
||||||
// by checking if the username exists in localStorage
|
|
||||||
if (browser.isBrowser()) {
|
|
||||||
const isAvailable = await browser.isUsernameAvailable(username);
|
|
||||||
console.log('Username availability check result:', isAvailable);
|
|
||||||
return isAvailable;
|
|
||||||
} else {
|
|
||||||
// If not in a browser (SSR), use the ODD API
|
|
||||||
const isAvailable = await debouncedIsUsernameAvailable(username);
|
|
||||||
console.log('Username availability check result:', isAvailable);
|
|
||||||
return Boolean(isAvailable);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error checking username availability:', error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create additional directories and files needed by the app
|
|
||||||
* @param fs FileSystem
|
|
||||||
*/
|
|
||||||
export const initializeFilesystem = async (fs: FileSystem): Promise<void> => {
|
|
||||||
try {
|
|
||||||
// Create required directories
|
|
||||||
console.log('Creating required directories...');
|
|
||||||
|
|
||||||
// Fallback if ODD path is not available
|
|
||||||
if (!odd.path || !odd.path.directory) {
|
|
||||||
console.log('ODD path not available, skipping filesystem initialization');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Public directories
|
|
||||||
await fs.mkdir(odd.path.directory(...DIRECTORIES.PUBLIC.ROOT));
|
|
||||||
await fs.mkdir(odd.path.directory(...DIRECTORIES.PUBLIC.GALLERY));
|
|
||||||
await fs.mkdir(odd.path.directory(...DIRECTORIES.PUBLIC.DOCUMENTS));
|
|
||||||
|
|
||||||
// Private directories
|
|
||||||
await fs.mkdir(odd.path.directory(...DIRECTORIES.PRIVATE.ROOT));
|
|
||||||
await fs.mkdir(odd.path.directory(...DIRECTORIES.PRIVATE.GALLERY));
|
|
||||||
await fs.mkdir(odd.path.directory(...DIRECTORIES.PRIVATE.SETTINGS));
|
|
||||||
await fs.mkdir(odd.path.directory(...DIRECTORIES.PRIVATE.DOCUMENTS));
|
|
||||||
|
|
||||||
console.log('Filesystem initialized successfully');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error during filesystem initialization:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks data root for a username with retries
|
|
||||||
* @param username The username to check
|
|
||||||
*/
|
|
||||||
export const checkDataRoot = async (username: string): Promise<void> => {
|
|
||||||
console.log('Looking up data root for username:', username);
|
|
||||||
|
|
||||||
// Fallback if ODD dataRoot is not available
|
|
||||||
if (!odd.dataRoot || !odd.dataRoot.lookup) {
|
|
||||||
console.log('ODD dataRoot not available, skipping data root lookup');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let dataRoot = await odd.dataRoot.lookup(username);
|
|
||||||
console.log('Initial data root lookup result:', dataRoot ? 'found' : 'not found');
|
|
||||||
|
|
||||||
if (dataRoot) return;
|
|
||||||
|
|
||||||
console.log('Data root not found, starting retry process...');
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const maxRetries = 20;
|
|
||||||
let attempt = 0;
|
|
||||||
|
|
||||||
const dataRootInterval = setInterval(async () => {
|
|
||||||
console.warn(`Could not fetch filesystem data root. Retrying (${attempt + 1}/${maxRetries})`);
|
|
||||||
|
|
||||||
dataRoot = await odd.dataRoot.lookup(username);
|
|
||||||
console.log(`Retry ${attempt + 1} result:`, dataRoot ? 'found' : 'not found');
|
|
||||||
|
|
||||||
if (!dataRoot && attempt < maxRetries) {
|
|
||||||
attempt++;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Retry process completed. Data root ${dataRoot ? 'found' : 'not found'} after ${attempt + 1} attempts`);
|
|
||||||
clearInterval(dataRootInterval);
|
|
||||||
|
|
||||||
if (dataRoot) {
|
|
||||||
resolve();
|
|
||||||
} else {
|
|
||||||
reject(new Error(`Data root not found after ${maxRetries} attempts`));
|
|
||||||
}
|
|
||||||
}, 500);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a cryptographic key pair and store in localStorage during registration
|
|
||||||
* @param username The username being registered
|
|
||||||
*/
|
|
||||||
export const generateUserCredentials = async (username: string): Promise<boolean> => {
|
|
||||||
if (!browser.isBrowser()) return false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
console.log('Generating cryptographic keys for user...');
|
|
||||||
// Generate a key pair using Web Crypto API
|
|
||||||
const keyPair = await browser.generateKeyPair();
|
|
||||||
|
|
||||||
if (!keyPair) {
|
|
||||||
console.error('Failed to generate key pair');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export the public key
|
|
||||||
const publicKeyBase64 = await browser.exportPublicKey(keyPair.publicKey);
|
|
||||||
|
|
||||||
if (!publicKeyBase64) {
|
|
||||||
console.error('Failed to export public key');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Keys generated successfully');
|
|
||||||
|
|
||||||
// Store the username and public key
|
|
||||||
browser.addRegisteredUser(username);
|
|
||||||
browser.storePublicKey(username, publicKeyBase64);
|
|
||||||
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error generating user credentials:', error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate a user's stored credentials (for development mode)
|
|
||||||
* @param username The username to validate
|
|
||||||
*/
|
|
||||||
export const validateStoredCredentials = (username: string): boolean => {
|
|
||||||
if (!browser.isBrowser()) return false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const users = browser.getRegisteredUsers();
|
|
||||||
const publicKey = browser.getPublicKey(username);
|
|
||||||
|
|
||||||
return users.includes(username) && Boolean(publicKey);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error validating stored credentials:', error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register a new user with the specified username
|
|
||||||
* @param username The username to register
|
|
||||||
* @returns A boolean indicating if registration was successful
|
|
||||||
*/
|
|
||||||
export const register = async (username: string): Promise<boolean> => {
|
|
||||||
try {
|
|
||||||
console.log('Registering user:', username);
|
|
||||||
|
|
||||||
// Check if username is valid
|
|
||||||
const isValid = await isUsernameValid(username);
|
|
||||||
if (!isValid) {
|
|
||||||
console.error('Invalid username format');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if username is available
|
|
||||||
const isAvailable = await isUsernameAvailable(username);
|
|
||||||
if (!isAvailable) {
|
|
||||||
console.error('Username is not available');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate user credentials
|
|
||||||
const credentialsGenerated = await generateUserCredentials(username);
|
|
||||||
if (!credentialsGenerated) {
|
|
||||||
console.error('Failed to generate user credentials');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('User registration successful');
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error during user registration:', error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -1,10 +1,6 @@
|
||||||
import * as odd from '@oddjs/odd';
|
|
||||||
import type FileSystem from '@oddjs/odd/fs/index';
|
|
||||||
import { checkDataRoot, initializeFilesystem, isUsernameValid, isUsernameAvailable } from './account';
|
|
||||||
import { getBackupStatus } from './backup';
|
|
||||||
import { Session } from './types';
|
import { Session } from './types';
|
||||||
import { CryptoAuthService } from './cryptoAuthService';
|
import { CryptoAuthService } from './cryptoAuthService';
|
||||||
import { loadSession, saveSession, clearStoredSession, getStoredUsername } from './sessionPersistence';
|
import { loadSession, saveSession, clearStoredSession } from './sessionPersistence';
|
||||||
|
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
/**
|
/**
|
||||||
|
|
@ -12,91 +8,32 @@ export class AuthService {
|
||||||
*/
|
*/
|
||||||
static async initialize(): Promise<{
|
static async initialize(): Promise<{
|
||||||
session: Session;
|
session: Session;
|
||||||
fileSystem: FileSystem | null;
|
|
||||||
}> {
|
}> {
|
||||||
// First try to load stored session
|
// Try to load stored session
|
||||||
const storedSession = loadSession();
|
const storedSession = loadSession();
|
||||||
let session: Session;
|
let session: Session;
|
||||||
let fileSystem: FileSystem | null = null;
|
|
||||||
|
|
||||||
if (storedSession && storedSession.authed && storedSession.username) {
|
if (storedSession && storedSession.authed && storedSession.username) {
|
||||||
// Try to restore ODD session with stored username
|
// Restore existing session
|
||||||
try {
|
session = {
|
||||||
const program = await odd.program({
|
username: storedSession.username,
|
||||||
namespace: { creator: 'mycrozine', name: 'app' },
|
authed: true,
|
||||||
username: storedSession.username
|
loading: false,
|
||||||
});
|
backupCreated: storedSession.backupCreated,
|
||||||
|
obsidianVaultPath: storedSession.obsidianVaultPath,
|
||||||
if (program.session) {
|
obsidianVaultName: storedSession.obsidianVaultName
|
||||||
// ODD session restored successfully
|
};
|
||||||
fileSystem = program.session.fs;
|
|
||||||
const backupStatus = await getBackupStatus(fileSystem);
|
|
||||||
session = {
|
|
||||||
username: storedSession.username,
|
|
||||||
authed: true,
|
|
||||||
loading: false,
|
|
||||||
backupCreated: backupStatus.created,
|
|
||||||
obsidianVaultPath: storedSession.obsidianVaultPath,
|
|
||||||
obsidianVaultName: storedSession.obsidianVaultName
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
// ODD session not available, but we have crypto auth
|
|
||||||
session = {
|
|
||||||
username: storedSession.username,
|
|
||||||
authed: true,
|
|
||||||
loading: false,
|
|
||||||
backupCreated: storedSession.backupCreated,
|
|
||||||
obsidianVaultPath: storedSession.obsidianVaultPath,
|
|
||||||
obsidianVaultName: storedSession.obsidianVaultName
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} catch (oddError) {
|
|
||||||
// ODD session restoration failed, using stored session
|
|
||||||
session = {
|
|
||||||
username: storedSession.username,
|
|
||||||
authed: true,
|
|
||||||
loading: false,
|
|
||||||
backupCreated: storedSession.backupCreated,
|
|
||||||
obsidianVaultPath: storedSession.obsidianVaultPath,
|
|
||||||
obsidianVaultName: storedSession.obsidianVaultName
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// No stored session, try ODD initialization
|
// No stored session
|
||||||
try {
|
session = {
|
||||||
const program = await odd.program({
|
username: '',
|
||||||
namespace: { creator: 'mycrozine', name: 'app' }
|
authed: false,
|
||||||
});
|
loading: false,
|
||||||
|
backupCreated: null
|
||||||
if (program.session) {
|
};
|
||||||
fileSystem = program.session.fs;
|
|
||||||
const backupStatus = await getBackupStatus(fileSystem);
|
|
||||||
session = {
|
|
||||||
username: program.session.username,
|
|
||||||
authed: true,
|
|
||||||
loading: false,
|
|
||||||
backupCreated: backupStatus.created
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
session = {
|
|
||||||
username: '',
|
|
||||||
authed: false,
|
|
||||||
loading: false,
|
|
||||||
backupCreated: null
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
session = {
|
|
||||||
username: '',
|
|
||||||
authed: false,
|
|
||||||
loading: false,
|
|
||||||
backupCreated: null,
|
|
||||||
error: String(error)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { session, fileSystem };
|
return { session };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -105,81 +42,25 @@ export class AuthService {
|
||||||
static async login(username: string): Promise<{
|
static async login(username: string): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
session?: Session;
|
session?: Session;
|
||||||
fileSystem?: FileSystem;
|
|
||||||
error?: string;
|
error?: string;
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
// First try cryptographic authentication
|
// Use cryptographic authentication
|
||||||
const cryptoResult = await CryptoAuthService.login(username);
|
const cryptoResult = await CryptoAuthService.login(username);
|
||||||
|
|
||||||
if (cryptoResult.success && cryptoResult.session) {
|
if (cryptoResult.success && cryptoResult.session) {
|
||||||
// If crypto auth succeeds, also try to load ODD session
|
|
||||||
try {
|
|
||||||
const program = await odd.program({
|
|
||||||
namespace: { creator: 'mycrozine', name: 'app' },
|
|
||||||
username
|
|
||||||
});
|
|
||||||
|
|
||||||
if (program.session) {
|
|
||||||
const fs = program.session.fs;
|
|
||||||
const backupStatus = await getBackupStatus(fs);
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
session: {
|
|
||||||
username,
|
|
||||||
authed: true,
|
|
||||||
loading: false,
|
|
||||||
backupCreated: backupStatus.created
|
|
||||||
},
|
|
||||||
fileSystem: fs
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} catch (oddError) {
|
|
||||||
// ODD session not available, using crypto auth only
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return crypto auth result if ODD is not available
|
|
||||||
const session = cryptoResult.session;
|
const session = cryptoResult.session;
|
||||||
if (session) {
|
saveSession(session);
|
||||||
saveSession(session);
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
session: cryptoResult.session,
|
session: cryptoResult.session
|
||||||
fileSystem: undefined
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to ODD authentication
|
|
||||||
const program = await odd.program({
|
|
||||||
namespace: { creator: 'mycrozine', name: 'app' },
|
|
||||||
username
|
|
||||||
});
|
|
||||||
|
|
||||||
if (program.session) {
|
|
||||||
const fs = program.session.fs;
|
|
||||||
const backupStatus = await getBackupStatus(fs);
|
|
||||||
|
|
||||||
const session = {
|
|
||||||
username,
|
|
||||||
authed: true,
|
|
||||||
loading: false,
|
|
||||||
backupCreated: backupStatus.created
|
|
||||||
};
|
|
||||||
saveSession(session);
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
session,
|
|
||||||
fileSystem: fs
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: cryptoResult.error || 'Failed to authenticate'
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: cryptoResult.error || 'Failed to authenticate'
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
|
|
@ -194,99 +75,33 @@ export class AuthService {
|
||||||
static async register(username: string): Promise<{
|
static async register(username: string): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
session?: Session;
|
session?: Session;
|
||||||
fileSystem?: FileSystem;
|
|
||||||
error?: string;
|
error?: string;
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
// Validate username
|
// Validate username format (basic check)
|
||||||
const valid = await isUsernameValid(username);
|
if (!username || username.length < 3) {
|
||||||
if (!valid) {
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Invalid username format'
|
error: 'Username must be at least 3 characters'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// First try cryptographic registration
|
// Use cryptographic registration
|
||||||
const cryptoResult = await CryptoAuthService.register(username);
|
const cryptoResult = await CryptoAuthService.register(username);
|
||||||
|
|
||||||
if (cryptoResult.success && cryptoResult.session) {
|
if (cryptoResult.success && cryptoResult.session) {
|
||||||
// If crypto registration succeeds, also try to create ODD session
|
|
||||||
try {
|
|
||||||
const program = await odd.program({
|
|
||||||
namespace: { creator: 'mycrozine', name: 'app' },
|
|
||||||
username
|
|
||||||
});
|
|
||||||
|
|
||||||
if (program.session) {
|
|
||||||
const fs = program.session.fs;
|
|
||||||
|
|
||||||
// Initialize filesystem with required directories
|
|
||||||
await initializeFilesystem(fs);
|
|
||||||
|
|
||||||
// Check backup status
|
|
||||||
const backupStatus = await getBackupStatus(fs);
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
session: {
|
|
||||||
username,
|
|
||||||
authed: true,
|
|
||||||
loading: false,
|
|
||||||
backupCreated: backupStatus.created
|
|
||||||
},
|
|
||||||
fileSystem: fs
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} catch (oddError) {
|
|
||||||
// ODD session creation failed, using crypto auth only
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return crypto registration result if ODD is not available
|
|
||||||
const session = cryptoResult.session;
|
const session = cryptoResult.session;
|
||||||
if (session) {
|
|
||||||
saveSession(session);
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
session: cryptoResult.session,
|
|
||||||
fileSystem: undefined
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to ODD-only registration
|
|
||||||
const program = await odd.program({
|
|
||||||
namespace: { creator: 'mycrozine', name: 'app' },
|
|
||||||
username
|
|
||||||
});
|
|
||||||
|
|
||||||
if (program.session) {
|
|
||||||
const fs = program.session.fs;
|
|
||||||
|
|
||||||
// Initialize filesystem with required directories
|
|
||||||
await initializeFilesystem(fs);
|
|
||||||
|
|
||||||
// Check backup status
|
|
||||||
const backupStatus = await getBackupStatus(fs);
|
|
||||||
|
|
||||||
const session = {
|
|
||||||
username,
|
|
||||||
authed: true,
|
|
||||||
loading: false,
|
|
||||||
backupCreated: backupStatus.created
|
|
||||||
};
|
|
||||||
saveSession(session);
|
saveSession(session);
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
session,
|
session: cryptoResult.session
|
||||||
fileSystem: fs
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: cryptoResult.error || 'Failed to create account'
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: cryptoResult.error || 'Failed to create account'
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
|
|
@ -302,17 +117,9 @@ export class AuthService {
|
||||||
try {
|
try {
|
||||||
// Clear stored session
|
// Clear stored session
|
||||||
clearStoredSession();
|
clearStoredSession();
|
||||||
|
|
||||||
// Try to destroy ODD session
|
|
||||||
try {
|
|
||||||
await odd.session.destroy();
|
|
||||||
} catch (oddError) {
|
|
||||||
// ODD session destroy failed
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
import * as odd from '@oddjs/odd'
|
|
||||||
|
|
||||||
export type BackupStatus = {
|
|
||||||
created: boolean | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getBackupStatus = async (fs: odd.FileSystem): Promise<BackupStatus> => {
|
|
||||||
try {
|
|
||||||
// Check if the required methods exist
|
|
||||||
if ((fs as any).exists && odd.path && (odd.path as any).backups) {
|
|
||||||
const backupStatus = await (fs as any).exists((odd.path as any).backups());
|
|
||||||
return { created: backupStatus };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback if methods don't exist
|
|
||||||
console.warn('Backup methods not available in current ODD version');
|
|
||||||
return { created: null };
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error checking backup status:', error);
|
|
||||||
return { created: null };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
import * as odd from '@oddjs/odd';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates an account linking consumer for the specified username
|
|
||||||
* @param username The username to create a consumer for
|
|
||||||
* @returns A Promise resolving to an AccountLinkingConsumer-like object
|
|
||||||
*/
|
|
||||||
export const createAccountLinkingConsumer = async (
|
|
||||||
username: string
|
|
||||||
): Promise<any> => {
|
|
||||||
// Check if the method exists in the current ODD version
|
|
||||||
if (odd.account && typeof (odd.account as any).createConsumer === 'function') {
|
|
||||||
return await (odd.account as any).createConsumer({ username });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: create a mock consumer for development
|
|
||||||
console.warn('Account linking consumer not available in current ODD version, using mock implementation');
|
|
||||||
return {
|
|
||||||
on: (event: string, callback: Function) => {
|
|
||||||
// Mock event handling
|
|
||||||
if (event === 'challenge') {
|
|
||||||
// Simulate PIN challenge
|
|
||||||
setTimeout(() => callback({ pin: [1, 2, 3, 4] }), 1000);
|
|
||||||
} else if (event === 'link') {
|
|
||||||
// Simulate successful link
|
|
||||||
setTimeout(() => callback({ approved: true, username }), 2000);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
destroy: () => {
|
|
||||||
// Cleanup mock consumer
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates an account linking producer for the specified username
|
|
||||||
* @param username The username to create a producer for
|
|
||||||
* @returns A Promise resolving to an AccountLinkingProducer-like object
|
|
||||||
*/
|
|
||||||
export const createAccountLinkingProducer = async (
|
|
||||||
username: string
|
|
||||||
): Promise<any> => {
|
|
||||||
// Check if the method exists in the current ODD version
|
|
||||||
if (odd.account && typeof (odd.account as any).createProducer === 'function') {
|
|
||||||
return await (odd.account as any).createProducer({ username });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: create a mock producer for development
|
|
||||||
console.warn('Account linking producer not available in current ODD version, using mock implementation');
|
|
||||||
return {
|
|
||||||
on: (_event: string, _callback: Function) => {
|
|
||||||
// Mock event handling - parameters unused in mock implementation
|
|
||||||
},
|
|
||||||
destroy: () => {
|
|
||||||
// Cleanup mock producer
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
@ -1,302 +0,0 @@
|
||||||
import type FileSystem from '@oddjs/odd/fs/index';
|
|
||||||
import * as odd from '@oddjs/odd';
|
|
||||||
import type { PrecisionLevel } from './types';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Location data stored in the filesystem
|
|
||||||
*/
|
|
||||||
export interface LocationData {
|
|
||||||
id: string;
|
|
||||||
userId: string;
|
|
||||||
latitude: number;
|
|
||||||
longitude: number;
|
|
||||||
accuracy: number;
|
|
||||||
timestamp: number;
|
|
||||||
expiresAt: number | null;
|
|
||||||
precision: PrecisionLevel;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Location share metadata
|
|
||||||
*/
|
|
||||||
export interface LocationShare {
|
|
||||||
id: string;
|
|
||||||
locationId: string;
|
|
||||||
shareToken: string;
|
|
||||||
createdAt: number;
|
|
||||||
expiresAt: number | null;
|
|
||||||
maxViews: number | null;
|
|
||||||
viewCount: number;
|
|
||||||
precision: PrecisionLevel;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Location storage service
|
|
||||||
* Handles storing and retrieving locations from the ODD.js filesystem
|
|
||||||
*/
|
|
||||||
export class LocationStorageService {
|
|
||||||
private fs: FileSystem;
|
|
||||||
private locationsPath: string[];
|
|
||||||
private sharesPath: string[];
|
|
||||||
private publicSharesPath: string[];
|
|
||||||
|
|
||||||
constructor(fs: FileSystem) {
|
|
||||||
this.fs = fs;
|
|
||||||
// Private storage paths
|
|
||||||
this.locationsPath = ['private', 'locations'];
|
|
||||||
this.sharesPath = ['private', 'location-shares'];
|
|
||||||
// Public reference path for share validation
|
|
||||||
this.publicSharesPath = ['public', 'location-shares'];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize directories
|
|
||||||
*/
|
|
||||||
async initialize(): Promise<void> {
|
|
||||||
// Ensure private directories exist
|
|
||||||
await this.ensureDirectory(this.locationsPath);
|
|
||||||
await this.ensureDirectory(this.sharesPath);
|
|
||||||
// Ensure public directory for share references
|
|
||||||
await this.ensureDirectory(this.publicSharesPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensure a directory exists
|
|
||||||
*/
|
|
||||||
private async ensureDirectory(path: string[]): Promise<void> {
|
|
||||||
try {
|
|
||||||
const dirPath = odd.path.directory(...path);
|
|
||||||
const fs = this.fs as any;
|
|
||||||
const exists = await fs.exists(dirPath);
|
|
||||||
if (!exists) {
|
|
||||||
await fs.mkdir(dirPath);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error ensuring directory:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Save a location to the filesystem
|
|
||||||
*/
|
|
||||||
async saveLocation(location: LocationData): Promise<void> {
|
|
||||||
try {
|
|
||||||
const filePath = (odd.path as any).file(...this.locationsPath, `${location.id}.json`);
|
|
||||||
const content = new TextEncoder().encode(JSON.stringify(location, null, 2));
|
|
||||||
const fs = this.fs as any;
|
|
||||||
await fs.write(filePath, content);
|
|
||||||
await fs.publish();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error saving location:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a location by ID
|
|
||||||
*/
|
|
||||||
async getLocation(locationId: string): Promise<LocationData | null> {
|
|
||||||
try {
|
|
||||||
const filePath = (odd.path as any).file(...this.locationsPath, `${locationId}.json`);
|
|
||||||
const fs = this.fs as any;
|
|
||||||
const exists = await fs.exists(filePath);
|
|
||||||
if (!exists) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const content = await fs.read(filePath);
|
|
||||||
const text = new TextDecoder().decode(content as Uint8Array);
|
|
||||||
return JSON.parse(text) as LocationData;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error reading location:', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a location share
|
|
||||||
*/
|
|
||||||
async createShare(share: LocationShare): Promise<void> {
|
|
||||||
try {
|
|
||||||
// Save share metadata in private directory
|
|
||||||
const sharePath = (odd.path as any).file(...this.sharesPath, `${share.id}.json`);
|
|
||||||
const shareContent = new TextEncoder().encode(JSON.stringify(share, null, 2));
|
|
||||||
const fs = this.fs as any;
|
|
||||||
await fs.write(sharePath, shareContent);
|
|
||||||
|
|
||||||
// Create public reference file for share validation (only token, not full data)
|
|
||||||
const publicSharePath = (odd.path as any).file(...this.publicSharesPath, `${share.shareToken}.json`);
|
|
||||||
const publicShareRef = {
|
|
||||||
shareToken: share.shareToken,
|
|
||||||
shareId: share.id,
|
|
||||||
createdAt: share.createdAt,
|
|
||||||
expiresAt: share.expiresAt,
|
|
||||||
};
|
|
||||||
const publicContent = new TextEncoder().encode(JSON.stringify(publicShareRef, null, 2));
|
|
||||||
await fs.write(publicSharePath, publicContent);
|
|
||||||
|
|
||||||
await fs.publish();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error creating share:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a share by token
|
|
||||||
*/
|
|
||||||
async getShareByToken(shareToken: string): Promise<LocationShare | null> {
|
|
||||||
try {
|
|
||||||
// First check public reference
|
|
||||||
const publicSharePath = (odd.path as any).file(...this.publicSharesPath, `${shareToken}.json`);
|
|
||||||
const fs = this.fs as any;
|
|
||||||
const publicExists = await fs.exists(publicSharePath);
|
|
||||||
if (!publicExists) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const publicContent = await fs.read(publicSharePath);
|
|
||||||
const publicText = new TextDecoder().decode(publicContent as Uint8Array);
|
|
||||||
const publicRef = JSON.parse(publicText);
|
|
||||||
|
|
||||||
// Now get full share from private directory
|
|
||||||
const sharePath = (odd.path as any).file(...this.sharesPath, `${publicRef.shareId}.json`);
|
|
||||||
const shareExists = await fs.exists(sharePath);
|
|
||||||
if (!shareExists) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const shareContent = await fs.read(sharePath);
|
|
||||||
const shareText = new TextDecoder().decode(shareContent as Uint8Array);
|
|
||||||
return JSON.parse(shareText) as LocationShare;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error reading share:', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all shares for the current user
|
|
||||||
*/
|
|
||||||
async getAllShares(): Promise<LocationShare[]> {
|
|
||||||
try {
|
|
||||||
const dirPath = odd.path.directory(...this.sharesPath);
|
|
||||||
const fs = this.fs as any;
|
|
||||||
const exists = await fs.exists(dirPath);
|
|
||||||
if (!exists) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const files = await fs.ls(dirPath);
|
|
||||||
const shares: LocationShare[] = [];
|
|
||||||
|
|
||||||
for (const fileName of Object.keys(files)) {
|
|
||||||
if (fileName.endsWith('.json')) {
|
|
||||||
const shareId = fileName.replace('.json', '');
|
|
||||||
const share = await this.getShareById(shareId);
|
|
||||||
if (share) {
|
|
||||||
shares.push(share);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return shares;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error listing shares:', error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a share by ID
|
|
||||||
*/
|
|
||||||
private async getShareById(shareId: string): Promise<LocationShare | null> {
|
|
||||||
try {
|
|
||||||
const sharePath = (odd.path as any).file(...this.sharesPath, `${shareId}.json`);
|
|
||||||
const fs = this.fs as any;
|
|
||||||
const exists = await fs.exists(sharePath);
|
|
||||||
if (!exists) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const content = await fs.read(sharePath);
|
|
||||||
const text = new TextDecoder().decode(content as Uint8Array);
|
|
||||||
return JSON.parse(text) as LocationShare;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error reading share:', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Increment view count for a share
|
|
||||||
*/
|
|
||||||
async incrementShareViews(shareId: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
const share = await this.getShareById(shareId);
|
|
||||||
if (!share) {
|
|
||||||
throw new Error('Share not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
share.viewCount += 1;
|
|
||||||
await this.createShare(share); // Re-save the share
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error incrementing share views:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Obfuscate location based on precision level
|
|
||||||
*/
|
|
||||||
export function obfuscateLocation(
|
|
||||||
lat: number,
|
|
||||||
lng: number,
|
|
||||||
precision: PrecisionLevel
|
|
||||||
): { lat: number; lng: number; radius: number } {
|
|
||||||
let radius = 0;
|
|
||||||
|
|
||||||
switch (precision) {
|
|
||||||
case 'exact':
|
|
||||||
radius = 0;
|
|
||||||
break;
|
|
||||||
case 'street':
|
|
||||||
radius = 100; // ~100m radius
|
|
||||||
break;
|
|
||||||
case 'neighborhood':
|
|
||||||
radius = 1000; // ~1km radius
|
|
||||||
break;
|
|
||||||
case 'city':
|
|
||||||
radius = 10000; // ~10km radius
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (radius === 0) {
|
|
||||||
return { lat, lng, radius: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add random offset within the radius
|
|
||||||
const angle = Math.random() * 2 * Math.PI;
|
|
||||||
const distance = Math.random() * radius;
|
|
||||||
|
|
||||||
// Convert distance to degrees (rough approximation: 1 degree ≈ 111km)
|
|
||||||
const latOffset = (distance / 111000) * Math.cos(angle);
|
|
||||||
const lngOffset = (distance / (111000 * Math.cos(lat * Math.PI / 180))) * Math.sin(angle);
|
|
||||||
|
|
||||||
return {
|
|
||||||
lat: lat + latOffset,
|
|
||||||
lng: lng + lngOffset,
|
|
||||||
radius,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a secure share token
|
|
||||||
*/
|
|
||||||
export function generateShareToken(): string {
|
|
||||||
// Generate a cryptographically secure random token
|
|
||||||
const array = new Uint8Array(32);
|
|
||||||
crypto.getRandomValues(array);
|
|
||||||
return Array.from(array, (byte) => byte.toString(16).padStart(2, '0')).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
/**
|
|
||||||
* Location sharing types
|
|
||||||
*/
|
|
||||||
|
|
||||||
export type PrecisionLevel = "exact" | "street" | "neighborhood" | "city";
|
|
||||||
|
|
||||||
export interface ShareSettings {
|
|
||||||
duration: number | null; // Duration in milliseconds
|
|
||||||
maxViews: number | null; // Maximum number of views allowed
|
|
||||||
precision: PrecisionLevel; // Precision level for location obfuscation
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GeolocationPosition {
|
|
||||||
coords: {
|
|
||||||
latitude: number;
|
|
||||||
longitude: number;
|
|
||||||
accuracy: number;
|
|
||||||
altitude?: number | null;
|
|
||||||
altitudeAccuracy?: number | null;
|
|
||||||
heading?: number | null;
|
|
||||||
speed?: number | null;
|
|
||||||
};
|
|
||||||
timestamp: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -41,7 +41,7 @@ import { FathomMeetingsTool } from "@/tools/FathomMeetingsTool"
|
||||||
import { HolonBrowserShape } from "@/shapes/HolonBrowserShapeUtil"
|
import { HolonBrowserShape } from "@/shapes/HolonBrowserShapeUtil"
|
||||||
import { ObsidianBrowserShape } from "@/shapes/ObsidianBrowserShapeUtil"
|
import { ObsidianBrowserShape } from "@/shapes/ObsidianBrowserShapeUtil"
|
||||||
import { FathomMeetingsBrowserShape } from "@/shapes/FathomMeetingsBrowserShapeUtil"
|
import { FathomMeetingsBrowserShape } from "@/shapes/FathomMeetingsBrowserShapeUtil"
|
||||||
import { LocationShareShape } from "@/shapes/LocationShareShapeUtil"
|
// Location shape removed - no longer needed
|
||||||
import {
|
import {
|
||||||
lockElement,
|
lockElement,
|
||||||
unlockElement,
|
unlockElement,
|
||||||
|
|
@ -81,7 +81,6 @@ const customShapeUtils = [
|
||||||
HolonBrowserShape,
|
HolonBrowserShape,
|
||||||
ObsidianBrowserShape,
|
ObsidianBrowserShape,
|
||||||
FathomMeetingsBrowserShape,
|
FathomMeetingsBrowserShape,
|
||||||
LocationShareShape,
|
|
||||||
]
|
]
|
||||||
const customTools = [
|
const customTools = [
|
||||||
ChatBoxTool,
|
ChatBoxTool,
|
||||||
|
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import { LocationDashboard } from '@/components/location/LocationDashboard';
|
|
||||||
|
|
||||||
export const LocationDashboardRoute: React.FC = () => {
|
|
||||||
return <LocationDashboard />;
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import { ShareLocation } from '@/components/location/ShareLocation';
|
|
||||||
|
|
||||||
export const LocationShareCreate: React.FC = () => {
|
|
||||||
return <ShareLocation />;
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import { useParams } from 'react-router-dom';
|
|
||||||
import { LocationViewer } from '@/components/location/LocationViewer';
|
|
||||||
|
|
||||||
export const LocationShareView: React.FC = () => {
|
|
||||||
const { token } = useParams<{ token: string }>();
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center min-h-[400px]">
|
|
||||||
<div className="text-center">
|
|
||||||
<h2 className="text-xl font-semibold mb-2">Invalid Share Link</h2>
|
|
||||||
<p className="text-sm text-muted-foreground">No share token provided in the URL</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return <LocationViewer shareToken={token} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
import {
|
|
||||||
BaseBoxShapeUtil,
|
|
||||||
HTMLContainer,
|
|
||||||
TLBaseShape,
|
|
||||||
RecordProps,
|
|
||||||
T
|
|
||||||
} from "tldraw"
|
|
||||||
import { ShareLocation } from "@/components/location/ShareLocation"
|
|
||||||
|
|
||||||
export type ILocationShare = TLBaseShape<
|
|
||||||
"LocationShare",
|
|
||||||
{
|
|
||||||
w: number
|
|
||||||
h: number
|
|
||||||
}
|
|
||||||
>
|
|
||||||
|
|
||||||
export class LocationShareShape extends BaseBoxShapeUtil<ILocationShare> {
|
|
||||||
static override type = "LocationShare" as const
|
|
||||||
|
|
||||||
static override props: RecordProps<ILocationShare> = {
|
|
||||||
w: T.number,
|
|
||||||
h: T.number
|
|
||||||
}
|
|
||||||
|
|
||||||
getDefaultProps(): ILocationShare["props"] {
|
|
||||||
return {
|
|
||||||
w: 800,
|
|
||||||
h: 600
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
component(shape: ILocationShare) {
|
|
||||||
return (
|
|
||||||
<HTMLContainer
|
|
||||||
id={shape.id}
|
|
||||||
style={{
|
|
||||||
overflow: "auto",
|
|
||||||
pointerEvents: "all",
|
|
||||||
backgroundColor: "var(--color-panel)",
|
|
||||||
borderRadius: "8px",
|
|
||||||
border: "1px solid var(--color-panel-contrast)"
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
padding: "0"
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ShareLocation />
|
|
||||||
</div>
|
|
||||||
</HTMLContainer>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
indicator(shape: ILocationShare) {
|
|
||||||
return <rect width={shape.props.w} height={shape.props.h} />
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
declare module '@oddjs/odd' {
|
|
||||||
export interface Program {
|
|
||||||
session?: Session;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Session {
|
|
||||||
username: string;
|
|
||||||
fs: FileSystem;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FileSystem {
|
|
||||||
mkdir(path: string): Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const program: (options: { namespace: { creator: string; name: string }; username?: string }) => Promise<Program>;
|
|
||||||
export const session: {
|
|
||||||
destroy(): Promise<void>;
|
|
||||||
};
|
|
||||||
export const account: {
|
|
||||||
isUsernameValid(username: string): Promise<boolean>;
|
|
||||||
isUsernameAvailable(username: string): Promise<boolean>;
|
|
||||||
};
|
|
||||||
export const dataRoot: {
|
|
||||||
lookup(username: string): Promise<any>;
|
|
||||||
};
|
|
||||||
export const path: {
|
|
||||||
directory(...parts: string[]): string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
declare module '@oddjs/odd/fs/index' {
|
|
||||||
export interface FileSystem {
|
|
||||||
mkdir(path: string): Promise<void>;
|
|
||||||
}
|
|
||||||
export default FileSystem;
|
|
||||||
}
|
|
||||||
|
|
@ -29,7 +29,7 @@ export function CustomMainMenu() {
|
||||||
const validateAndNormalizeShapeType = (shape: any): string => {
|
const validateAndNormalizeShapeType = (shape: any): string => {
|
||||||
if (!shape || !shape.type) return 'text'
|
if (!shape || !shape.type) return 'text'
|
||||||
|
|
||||||
const validCustomShapes = ['ObsNote', 'VideoChat', 'Transcription', 'Prompt', 'ChatBox', 'Embed', 'Markdown', 'MycrozineTemplate', 'Slide', 'Holon', 'ObsidianBrowser', 'HolonBrowser', 'FathomMeetingsBrowser', 'LocationShare']
|
const validCustomShapes = ['ObsNote', 'VideoChat', 'Transcription', 'Prompt', 'ChatBox', 'Embed', 'Markdown', 'MycrozineTemplate', 'Slide', 'Holon', 'ObsidianBrowser', 'HolonBrowser', 'FathomMeetingsBrowser']
|
||||||
const validDefaultShapes = ['arrow', 'bookmark', 'draw', 'embed', 'frame', 'geo', 'group', 'highlight', 'image', 'line', 'note', 'text', 'video']
|
const validDefaultShapes = ['arrow', 'bookmark', 'draw', 'embed', 'frame', 'geo', 'group', 'highlight', 'image', 'line', 'note', 'text', 'video']
|
||||||
const allValidShapes = [...validCustomShapes, ...validDefaultShapes]
|
const allValidShapes = [...validCustomShapes, ...validDefaultShapes]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ import { createShapeId } from "tldraw"
|
||||||
import type { ObsidianObsNote } from "../lib/obsidianImporter"
|
import type { ObsidianObsNote } from "../lib/obsidianImporter"
|
||||||
import { HolonData } from "../lib/HoloSphereService"
|
import { HolonData } from "../lib/HoloSphereService"
|
||||||
import { FathomMeetingsPanel } from "../components/FathomMeetingsPanel"
|
import { FathomMeetingsPanel } from "../components/FathomMeetingsPanel"
|
||||||
import { LocationShareDialog } from "../components/location/LocationShareDialog"
|
|
||||||
import { getFathomApiKey, saveFathomApiKey, removeFathomApiKey, isFathomApiKeyConfigured } from "../lib/fathomApiKey"
|
import { getFathomApiKey, saveFathomApiKey, removeFathomApiKey, isFathomApiKeyConfigured } from "../lib/fathomApiKey"
|
||||||
|
|
||||||
export function CustomToolbar() {
|
export function CustomToolbar() {
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ export function resolveOverlaps(editor: Editor, shapeId: string): void {
|
||||||
const allShapes = editor.getCurrentPageShapes()
|
const allShapes = editor.getCurrentPageShapes()
|
||||||
const customShapeTypes = [
|
const customShapeTypes = [
|
||||||
'ObsNote', 'ObsidianBrowser', 'HolonBrowser', 'VideoChat',
|
'ObsNote', 'ObsidianBrowser', 'HolonBrowser', 'VideoChat',
|
||||||
'Transcription', 'Holon', 'LocationShare', 'FathomMeetingsBrowser', 'Prompt',
|
'Transcription', 'Holon', 'FathomMeetingsBrowser', 'Prompt',
|
||||||
'Embed', 'Slide', 'Markdown', 'MycrozineTemplate', 'ChatBox'
|
'Embed', 'Slide', 'Markdown', 'MycrozineTemplate', 'ChatBox'
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -119,7 +119,7 @@ export function findNonOverlappingPosition(
|
||||||
const allShapes = editor.getCurrentPageShapes()
|
const allShapes = editor.getCurrentPageShapes()
|
||||||
const customShapeTypes = [
|
const customShapeTypes = [
|
||||||
'ObsNote', 'ObsidianBrowser', 'HolonBrowser', 'VideoChat',
|
'ObsNote', 'ObsidianBrowser', 'HolonBrowser', 'VideoChat',
|
||||||
'Transcription', 'Holon', 'LocationShare', 'FathomMeetingsBrowser', 'Prompt',
|
'Transcription', 'Holon', 'FathomMeetingsBrowser', 'Prompt',
|
||||||
'Embed', 'Slide', 'Markdown', 'MycrozineTemplate', 'ChatBox'
|
'Embed', 'Slide', 'Markdown', 'MycrozineTemplate', 'ChatBox'
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue