Add local database mode for browser-only diagrams
- Add /db/local/:db path type that stores diagrams locally without syncing - New diagrams now default to local storage (browser-only) - Share button creates public copy when sharing local diagrams - Add storage type badges (Local/Public/Private) in diagram manager - Add GitHub Actions workflow for automated builds - Block local- database requests at server with 404 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
a772372b2b
commit
1e174e81d3
31
.github/workflows/build.yml
vendored
Normal file
31
.github/workflows/build.yml
vendored
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
name: Build
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ main ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ main ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: linux_amd64
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Build Front End
|
||||||
|
run: npm run build
|
||||||
|
env:
|
||||||
|
NODE_OPTIONS: '--max-old-space-size=4096'
|
||||||
|
|
||||||
|
- name: Restart Server
|
||||||
|
run: |
|
||||||
|
# Kill existing process on port 3001
|
||||||
|
lsof -ti:3001 | xargs kill -9 2>/dev/null || true
|
||||||
|
|
||||||
|
# Start server in background with nohup
|
||||||
|
nohup npm run start > /tmp/immersive.log 2>&1 &
|
||||||
150
SHARING_PLAN.md
Normal file
150
SHARING_PLAN.md
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
# Self-Hosted Diagram Sharing with Express-PouchDB
|
||||||
|
|
||||||
|
## Requirements (Confirmed)
|
||||||
|
- **Storage**: In-memory (ephemeral) - lost on server restart
|
||||||
|
- **Content**: Copy current diagram entities when creating share
|
||||||
|
- **Expiration**: No expiration - links work until server restart
|
||||||
|
- **Encryption**: None - keep it simple, anyone with link can access
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────┐
|
||||||
|
│ Express Server (port 3001) │
|
||||||
|
├─────────────────────────────────────────────────┤
|
||||||
|
│ /api/share/* → Share management API │
|
||||||
|
│ /pouchdb/* → express-pouchdb (sync) │
|
||||||
|
│ /share/:uuid → Client share route │
|
||||||
|
│ /api/* → Existing API routes │
|
||||||
|
│ /* → Vite static files │
|
||||||
|
└─────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
└── In-Memory PouchDB (per share UUID)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation Steps
|
||||||
|
|
||||||
|
### Phase 1: Server-Side Setup
|
||||||
|
|
||||||
|
#### 1.1 Add Dependencies
|
||||||
|
```bash
|
||||||
|
npm install express-pouchdb pouchdb-adapter-memory
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 1.2 Create PouchDB Server Service
|
||||||
|
**New file: `server/services/pouchdbServer.js`**
|
||||||
|
- Initialize PouchDB with memory adapter
|
||||||
|
- Track active share databases in a Map
|
||||||
|
- Export `getShareDB(shareId)`, `shareExists(shareId)`, `createPouchDBMiddleware()`
|
||||||
|
|
||||||
|
#### 1.3 Create Share API
|
||||||
|
**New file: `server/api/share.js`**
|
||||||
|
- `POST /api/share/create` - Generate UUID, create in-memory DB, copy entities
|
||||||
|
- `GET /api/share/:id/exists` - Check if share exists
|
||||||
|
- `GET /api/share/stats` - Debug endpoint for active shares
|
||||||
|
|
||||||
|
#### 1.4 Update API Router
|
||||||
|
**Edit: `server/api/index.js`**
|
||||||
|
- Add `import shareRouter from "./share.js"`
|
||||||
|
- Mount at `router.use("/share", shareRouter)`
|
||||||
|
|
||||||
|
#### 1.5 Mount Express-PouchDB
|
||||||
|
**Edit: `server.js`**
|
||||||
|
- Import `createPouchDBMiddleware` from pouchdbServer.js
|
||||||
|
- Mount at `app.use("/pouchdb", createPouchDBMiddleware())`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 2: Client-Side Integration
|
||||||
|
|
||||||
|
#### 2.1 Update URL Parsing
|
||||||
|
**Edit: `src/util/functions/getPath.ts`**
|
||||||
|
- Add `getPathInfo()` function returning `{ dbName, isShare, shareId }`
|
||||||
|
- Detect `/share/:uuid` pattern
|
||||||
|
|
||||||
|
#### 2.2 Update PouchDB Persistence Manager
|
||||||
|
**Edit: `src/integration/database/pouchdbPersistenceManager.ts`**
|
||||||
|
|
||||||
|
In `initLocal()`:
|
||||||
|
- Call `getPathInfo()` to detect share URLs
|
||||||
|
- If share: use `share-{uuid}` as local DB name, call `beginShareSync()`
|
||||||
|
|
||||||
|
Add new method `beginShareSync(shareId)`:
|
||||||
|
- Check share exists via `/api/share/:id/exists`
|
||||||
|
- Connect to `${origin}/pouchdb/share-${shareId}`
|
||||||
|
- Set up presence with `share-${shareId}` as DB name
|
||||||
|
- Begin live sync (no encryption)
|
||||||
|
|
||||||
|
#### 2.3 Add React Route
|
||||||
|
**Edit: `src/react/webRouter.tsx`**
|
||||||
|
- Add route `{ path: "/share/:uuid", element: <VrExperience isShare={true} /> }`
|
||||||
|
- No ProtectedRoute wrapper (public access)
|
||||||
|
|
||||||
|
#### 2.4 Add Share Button Handler
|
||||||
|
**Edit: `src/react/pages/vrExperience.tsx`**
|
||||||
|
- Add `isShare` prop
|
||||||
|
- Add `handleShare()` function:
|
||||||
|
1. Get all entities from local PouchDB
|
||||||
|
2. POST to `/api/share/create` with entities
|
||||||
|
3. Copy resulting URL to clipboard
|
||||||
|
4. Show confirmation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 3: Presence Integration
|
||||||
|
|
||||||
|
The WebSocket presence system already routes by database name. Since shares use `share-{uuid}` as the database name, presence works automatically.
|
||||||
|
|
||||||
|
**Edit: `server/server.js`** (WebSocket server)
|
||||||
|
- Update `originIsAllowed()` to allow localhost for development
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files to Modify
|
||||||
|
|
||||||
|
| File | Action | Purpose |
|
||||||
|
|------|--------|---------|
|
||||||
|
| `package.json` | Edit | Add express-pouchdb, pouchdb-adapter-memory |
|
||||||
|
| `server.js` | Edit | Mount /pouchdb middleware |
|
||||||
|
| `server/api/index.js` | Edit | Add share router |
|
||||||
|
| `server/services/pouchdbServer.js` | Create | PouchDB memory initialization |
|
||||||
|
| `server/api/share.js` | Create | Share API endpoints |
|
||||||
|
| `server/server.js` | Edit | Allow localhost origins |
|
||||||
|
| `src/util/functions/getPath.ts` | Edit | Add getPathInfo() |
|
||||||
|
| `src/integration/database/pouchdbPersistenceManager.ts` | Edit | Add share sync logic |
|
||||||
|
| `src/react/webRouter.tsx` | Edit | Add /share/:uuid route |
|
||||||
|
| `src/react/pages/vrExperience.tsx` | Edit | Add share button handler |
|
||||||
|
| `src/util/featureConfig.ts` | Edit | Enable shareCollaborate feature |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## User Flow
|
||||||
|
|
||||||
|
### Creating a Share
|
||||||
|
1. User has a diagram open at `/db/public/mydiagram`
|
||||||
|
2. Clicks "Share" button
|
||||||
|
3. Client fetches all entities from local PouchDB
|
||||||
|
4. POSTs to `/api/share/create` with entities
|
||||||
|
5. Server creates in-memory DB, copies entities, returns UUID
|
||||||
|
6. Client copies `https://server.com/share/{uuid}` to clipboard
|
||||||
|
7. User shares link with collaborators
|
||||||
|
|
||||||
|
### Joining a Share
|
||||||
|
1. User navigates to `https://server.com/share/{uuid}`
|
||||||
|
2. React Router renders VrExperience with `isShare=true`
|
||||||
|
3. PouchdbPersistenceManager detects share URL
|
||||||
|
4. Checks `/api/share/:uuid/exists` - returns true
|
||||||
|
5. Creates local PouchDB `share-{uuid}`
|
||||||
|
6. Connects to `/pouchdb/share-{uuid}` for sync
|
||||||
|
7. Entities replicate to local, render in scene
|
||||||
|
8. Presence WebSocket connects with `share-{uuid}` as room
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Future Authentication (Not Implemented Now)
|
||||||
|
|
||||||
|
Structure allows easy addition later:
|
||||||
|
- express-pouchdb middleware can be wrapped with auth middleware
|
||||||
|
- Share API can require JWT/session tokens
|
||||||
|
- Could add password-protected shares
|
||||||
|
- Could add read-only vs read-write permissions
|
||||||
179
SYNC_PLAN.md
Normal file
179
SYNC_PLAN.md
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
# Future Sync Strategy: Keeping Local and Public Clones in Sync
|
||||||
|
|
||||||
|
## Current State (v1)
|
||||||
|
|
||||||
|
- Sharing creates a **ONE-TIME COPY** from local to public
|
||||||
|
- Copies diverge independently after sharing
|
||||||
|
- No automatic sync between local and public versions
|
||||||
|
- Local diagrams are browser-only (IndexedDB via PouchDB)
|
||||||
|
- Public diagrams sync with server via express-pouchdb
|
||||||
|
|
||||||
|
### URL Scheme
|
||||||
|
|
||||||
|
| Route | Sync | Access | Status |
|
||||||
|
|-------|------|--------|--------|
|
||||||
|
| `/db/local/:id` | None | Browser-only | Implemented |
|
||||||
|
| `/db/public/:id` | Yes | Anyone | Implemented |
|
||||||
|
| `/db/private/:id` | Yes | Authorized users | Route only (no auth) |
|
||||||
|
|
||||||
|
## Future Options
|
||||||
|
|
||||||
|
### Option 1: Manual Push/Pull (Recommended for v2)
|
||||||
|
|
||||||
|
Add explicit user-triggered sync between local and public copies.
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- "Push to Public" button - sends local changes to public copy
|
||||||
|
- "Pull from Public" button - gets public changes into local
|
||||||
|
- Track `lastSyncedAt` timestamp
|
||||||
|
- Show indicator when copies have diverged
|
||||||
|
- Conflict resolution: Last write wins (simple) or user choice (advanced)
|
||||||
|
|
||||||
|
**Pros:**
|
||||||
|
- User stays in control
|
||||||
|
- Clear mental model
|
||||||
|
- Simple to implement incrementally
|
||||||
|
|
||||||
|
**Cons:**
|
||||||
|
- Manual effort required
|
||||||
|
- Risk of forgetting to sync
|
||||||
|
|
||||||
|
### Option 2: Automatic Background Sync
|
||||||
|
|
||||||
|
Continuous bidirectional sync between local and public copies.
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- Real-time sync like Google Docs
|
||||||
|
- Works across devices
|
||||||
|
- Offline-first with automatic merge
|
||||||
|
|
||||||
|
**Pros:**
|
||||||
|
- Seamless experience
|
||||||
|
- Always up to date
|
||||||
|
|
||||||
|
**Cons:**
|
||||||
|
- Complex conflict resolution (may need CRDTs)
|
||||||
|
- Higher performance overhead
|
||||||
|
- Harder to reason about state
|
||||||
|
|
||||||
|
### Option 3: Fork/Branch Model
|
||||||
|
|
||||||
|
One-way relationship: local is "draft", public is "published".
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- Push only (local → public)
|
||||||
|
- No pull mechanism
|
||||||
|
- Public is the "source of truth" once published
|
||||||
|
|
||||||
|
**Pros:**
|
||||||
|
- Clear mental model
|
||||||
|
- No merge conflicts
|
||||||
|
- Simple implementation
|
||||||
|
|
||||||
|
**Cons:**
|
||||||
|
- Cannot incorporate public changes back to local
|
||||||
|
- Multiple people can't collaborate on draft
|
||||||
|
|
||||||
|
## Recommended Implementation (v2)
|
||||||
|
|
||||||
|
Implement **Option 1 (Manual Push/Pull)** as it provides the best balance of user control and simplicity.
|
||||||
|
|
||||||
|
### Data Model Changes
|
||||||
|
|
||||||
|
Add to diagram directory entry:
|
||||||
|
```typescript
|
||||||
|
interface DiagramEntry {
|
||||||
|
_id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
storageType: 'local' | 'public' | 'private';
|
||||||
|
createdAt: string;
|
||||||
|
|
||||||
|
// New fields for sync tracking
|
||||||
|
publicCopyId?: string; // ID of the public clone (if shared)
|
||||||
|
lastPushedAt?: string; // When changes were last pushed to public
|
||||||
|
lastPulledAt?: string; // When public changes were last pulled
|
||||||
|
publicVersion?: number; // Version number of public copy at last sync
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### API Endpoints
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Push local changes to public
|
||||||
|
POST /api/sync/push
|
||||||
|
Body: { localDbName: string, publicDbName: string }
|
||||||
|
Response: { success: boolean, documentsUpdated: number }
|
||||||
|
|
||||||
|
// Pull public changes to local
|
||||||
|
POST /api/sync/pull
|
||||||
|
Body: { localDbName: string, publicDbName: string }
|
||||||
|
Response: { success: boolean, documentsUpdated: number }
|
||||||
|
|
||||||
|
// Check if copies have diverged
|
||||||
|
GET /api/sync/status?local={localDbName}&public={publicDbName}
|
||||||
|
Response: {
|
||||||
|
diverged: boolean,
|
||||||
|
localChanges: number,
|
||||||
|
publicChanges: number,
|
||||||
|
lastSyncedAt: string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### UI Components
|
||||||
|
|
||||||
|
1. **Sync Status Indicator**
|
||||||
|
- Shows in header when viewing a local diagram that has a public copy
|
||||||
|
- Green check: In sync
|
||||||
|
- Orange dot: Changes pending
|
||||||
|
- Red warning: Conflicts detected
|
||||||
|
|
||||||
|
2. **Push/Pull Buttons**
|
||||||
|
- In hamburger menu under "Share" section
|
||||||
|
- "Push to Public" - shows confirmation with change count
|
||||||
|
- "Pull from Public" - shows confirmation with change count
|
||||||
|
|
||||||
|
3. **Divergence Warning Badge**
|
||||||
|
- Shows on diagram card in Manage Diagrams modal
|
||||||
|
- Indicates when local and public have diverged
|
||||||
|
|
||||||
|
4. **Conflict Resolution Dialog**
|
||||||
|
- Shows when both local and public have changes to same entity
|
||||||
|
- Options: Keep Local, Keep Public, Keep Both (creates duplicate)
|
||||||
|
|
||||||
|
### Implementation Phases
|
||||||
|
|
||||||
|
**Phase 1: Tracking**
|
||||||
|
- Add `publicCopyId` when sharing local → public
|
||||||
|
- Track sharing relationship in directory
|
||||||
|
|
||||||
|
**Phase 2: Push**
|
||||||
|
- Implement push from local to public
|
||||||
|
- Overwrite public with local changes
|
||||||
|
- Update `lastPushedAt` timestamp
|
||||||
|
|
||||||
|
**Phase 3: Pull**
|
||||||
|
- Implement pull from public to local
|
||||||
|
- Merge public changes into local
|
||||||
|
- Update `lastPulledAt` timestamp
|
||||||
|
|
||||||
|
**Phase 4: Status**
|
||||||
|
- Implement divergence detection
|
||||||
|
- Add UI indicators
|
||||||
|
- Show sync status in Manage Diagrams
|
||||||
|
|
||||||
|
**Phase 5: Conflict Resolution**
|
||||||
|
- Detect entity-level conflicts
|
||||||
|
- Show resolution dialog
|
||||||
|
- Allow user to choose resolution strategy
|
||||||
|
|
||||||
|
## Migration Notes
|
||||||
|
|
||||||
|
Existing diagrams without `storageType` are treated as `public` for backwards compatibility. When such diagrams are loaded, the UI should work correctly but sync tracking features won't be available until the diagram metadata is updated.
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
- Push/pull operations should validate that the user has access to both databases
|
||||||
|
- Public databases remain world-readable/writable
|
||||||
|
- Private database sync will require authentication tokens
|
||||||
|
- Rate limiting should be applied to sync operations
|
||||||
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "immersive",
|
"name": "immersive",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.8-42",
|
"version": "0.0.8-43",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@ -9,6 +9,7 @@
|
|||||||
*
|
*
|
||||||
* Database naming patterns:
|
* Database naming patterns:
|
||||||
* / - Root endpoint, always allowed (server info)
|
* / - Root endpoint, always allowed (server info)
|
||||||
|
* /local-{dbname} - Should never reach server (client-only), return 404
|
||||||
* /public-{dbname} - No auth required, anyone can read/write
|
* /public-{dbname} - No auth required, anyone can read/write
|
||||||
* /private-{dbname} - Auth required
|
* /private-{dbname} - Auth required
|
||||||
* /{dbname} - Treated as private by default
|
* /{dbname} - Treated as private by default
|
||||||
@ -24,6 +25,15 @@ export function dbAuthMiddleware(req, res, next) {
|
|||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Local databases should never reach the server (they're browser-only)
|
||||||
|
if (dbName.startsWith('local-')) {
|
||||||
|
console.log(`[DB Auth] Local database access rejected: ${req.method} ${req.path}`);
|
||||||
|
return res.status(404).json({
|
||||||
|
error: 'not_found',
|
||||||
|
reason: 'Local databases are browser-only and do not sync to server'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Check if this is a public database (name starts with 'public-')
|
// Check if this is a public database (name starts with 'public-')
|
||||||
const isPublic = dbName.startsWith('public-');
|
const isPublic = dbName.startsWith('public-');
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import {DiagramEventObserverMask} from "../../diagram/types/diagramEventObserver
|
|||||||
import log, {Logger} from "loglevel";
|
import log, {Logger} from "loglevel";
|
||||||
import PouchDB from 'pouchdb';
|
import PouchDB from 'pouchdb';
|
||||||
import {importDiagramFromJSON, DiagramExport} from "../../util/functions/exportDiagramAsJSON";
|
import {importDiagramFromJSON, DiagramExport} from "../../util/functions/exportDiagramAsJSON";
|
||||||
import {isPublicPath, getRemoteDbPath} from "../../util/functions/getPath";
|
import {getDbType, getRemoteDbPath, shouldSync} from "../../util/functions/getPath";
|
||||||
|
|
||||||
export class PouchData {
|
export class PouchData {
|
||||||
public readonly onDBEntityUpdateObservable: Observable<DiagramEntity> = new Observable<DiagramEntity>();
|
public readonly onDBEntityUpdateObservable: Observable<DiagramEntity> = new Observable<DiagramEntity>();
|
||||||
@ -26,14 +26,25 @@ export class PouchData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize sync with remote express-pouchdb for public databases
|
* Initialize sync with remote express-pouchdb for public/private databases
|
||||||
|
* Local databases do not sync
|
||||||
*/
|
*/
|
||||||
private initSync() {
|
private initSync() {
|
||||||
|
const dbType = getDbType();
|
||||||
const remoteDbPath = getRemoteDbPath();
|
const remoteDbPath = getRemoteDbPath();
|
||||||
const isPublic = isPublicPath();
|
|
||||||
|
|
||||||
if (!remoteDbPath || !isPublic) {
|
if (dbType === 'local') {
|
||||||
this._logger.debug('[Sync] Not a public path, skipping remote sync');
|
this._logger.debug('[Sync] Local database - no remote sync');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dbType === 'private') {
|
||||||
|
this._logger.info('[Sync] Private database sync not yet implemented');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!remoteDbPath || !shouldSync()) {
|
||||||
|
this._logger.debug('[Sync] No remote path or sync disabled, skipping');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -186,4 +197,52 @@ export class PouchData {
|
|||||||
this._logger.warn('CONFLICTS!', doc._conflicts);
|
this._logger.warn('CONFLICTS!', doc._conflicts);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy all documents from this database to a new public database.
|
||||||
|
* Used when sharing a local diagram to make it publicly accessible.
|
||||||
|
* @param newDbName - The name for the new public database
|
||||||
|
* @returns The URL path to the new public diagram
|
||||||
|
*/
|
||||||
|
public async copyToPublic(newDbName: string): Promise<string> {
|
||||||
|
this._logger.info(`[Copy] Starting copy to public-${newDbName}`);
|
||||||
|
|
||||||
|
// Create the remote public database
|
||||||
|
const remoteUrl = `${window.location.origin}/pouchdb/public-${newDbName}`;
|
||||||
|
const remoteDb = new PouchDB(remoteUrl);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get all docs from local database
|
||||||
|
const allDocs = await this._db.allDocs({ include_docs: true });
|
||||||
|
this._logger.debug(`[Copy] Found ${allDocs.rows.length} documents to copy`);
|
||||||
|
|
||||||
|
// Copy each document to the remote database
|
||||||
|
for (const row of allDocs.rows) {
|
||||||
|
if (row.doc) {
|
||||||
|
// Remove PouchDB internal fields for clean insert
|
||||||
|
const { _rev, ...docWithoutRev } = row.doc;
|
||||||
|
try {
|
||||||
|
await remoteDb.put(docWithoutRev);
|
||||||
|
} catch (err) {
|
||||||
|
// Document might already exist if this is a retry
|
||||||
|
this._logger.warn(`[Copy] Failed to copy doc ${row.id}:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this._logger.info(`[Copy] Successfully copied ${allDocs.rows.length} documents`);
|
||||||
|
return `/db/public/${newDbName}`;
|
||||||
|
} catch (err) {
|
||||||
|
this._logger.error('[Copy] Error copying to public:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all documents in the database (for export/copy operations)
|
||||||
|
*/
|
||||||
|
public async getAllDocs(): Promise<any[]> {
|
||||||
|
const result = await this._db.allDocs({ include_docs: true });
|
||||||
|
return result.rows.map(row => row.doc).filter(doc => doc && doc.id !== 'metadata');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -7,11 +7,13 @@ import {useFeatureState} from "../hooks/useFeatures";
|
|||||||
import ComingSoonBadge from "../components/ComingSoonBadge";
|
import ComingSoonBadge from "../components/ComingSoonBadge";
|
||||||
import UpgradeBadge from "../components/UpgradeBadge";
|
import UpgradeBadge from "../components/UpgradeBadge";
|
||||||
import {useAuth0} from "@auth0/auth0-react";
|
import {useAuth0} from "@auth0/auth0-react";
|
||||||
|
import {useNavigate} from "react-router-dom";
|
||||||
|
|
||||||
export default function CreateDiagramModal({createOpened, closeCreate}) {
|
export default function CreateDiagramModal({createOpened, closeCreate}) {
|
||||||
const logger = log.getLogger('createDiagramModal');
|
const logger = log.getLogger('createDiagramModal');
|
||||||
const db = usePouch();
|
const db = usePouch();
|
||||||
const { loginWithRedirect } = useAuth0();
|
const { loginWithRedirect } = useAuth0();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
// Feature flags
|
// Feature flags
|
||||||
const privateDesignsState = useFeatureState('privateDesigns');
|
const privateDesignsState = useFeatureState('privateDesigns');
|
||||||
@ -43,7 +45,14 @@ export default function CreateDiagramModal({createOpened, closeCreate}) {
|
|||||||
logger.warn('cannot find directory', err);
|
logger.warn('cannot find directory', err);
|
||||||
}
|
}
|
||||||
const id = 'diagram-' + v4();
|
const id = 'diagram-' + v4();
|
||||||
const newDiagram = {...diagram, _id: id, type: 'diagram'};
|
// All new diagrams start as local (browser-only)
|
||||||
|
const newDiagram = {
|
||||||
|
...diagram,
|
||||||
|
_id: id,
|
||||||
|
type: 'diagram',
|
||||||
|
storageType: 'local',
|
||||||
|
createdAt: new Date().toISOString()
|
||||||
|
};
|
||||||
if (!doc) {
|
if (!doc) {
|
||||||
await db.put({_id: 'directory', diagrams: [newDiagram], type: 'directory'});
|
await db.put({_id: 'directory', diagrams: [newDiagram], type: 'directory'});
|
||||||
} else {
|
} else {
|
||||||
@ -56,6 +65,8 @@ export default function CreateDiagramModal({createOpened, closeCreate}) {
|
|||||||
await db.put(doc);
|
await db.put(doc);
|
||||||
}
|
}
|
||||||
closeCreate();
|
closeCreate();
|
||||||
|
// Navigate to the local diagram
|
||||||
|
navigate(`/db/local/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import {Button, Card, Container, Group, Modal, Paper, SimpleGrid, Stack} from "@mantine/core";
|
import {Badge, Button, Card, Container, Group, Modal, Paper, SimpleGrid, Stack} from "@mantine/core";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import {useDoc, usePouch} from "use-pouchdb";
|
import {useDoc, usePouch} from "use-pouchdb";
|
||||||
import {IconTrash, IconDownload} from "@tabler/icons-react";
|
import {IconTrash, IconDownload} from "@tabler/icons-react";
|
||||||
@ -7,6 +7,26 @@ import log from "loglevel";
|
|||||||
import {useFeatureLimit} from "../hooks/useFeatures";
|
import {useFeatureLimit} from "../hooks/useFeatures";
|
||||||
import {exportDiagramAsJSON} from "../../util/functions/exportDiagramAsJSON";
|
import {exportDiagramAsJSON} from "../../util/functions/exportDiagramAsJSON";
|
||||||
|
|
||||||
|
// Helper to get the correct URL path based on storage type
|
||||||
|
function getDiagramPath(diagram: any): string {
|
||||||
|
const storageType = diagram.storageType || 'public'; // Default to public for backwards compat
|
||||||
|
return `/db/${storageType}/${diagram._id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to get badge color and text for storage type
|
||||||
|
function getStorageTypeBadge(storageType: string): { color: string; label: string } {
|
||||||
|
switch (storageType) {
|
||||||
|
case 'local':
|
||||||
|
return { color: 'gray', label: 'Local' };
|
||||||
|
case 'public':
|
||||||
|
return { color: 'green', label: 'Public' };
|
||||||
|
case 'private':
|
||||||
|
return { color: 'blue', label: 'Private' };
|
||||||
|
default:
|
||||||
|
return { color: 'green', label: 'Public' }; // Default for backwards compat
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default function ManageDiagramsModal({openCreate, manageOpened, closeManage}) {
|
export default function ManageDiagramsModal({openCreate, manageOpened, closeManage}) {
|
||||||
const logger = log.getLogger('manageDiagramsModal');
|
const logger = log.getLogger('manageDiagramsModal');
|
||||||
const {doc: diagram, error} = useDoc('directory', {}, {_id: 'directory', diagrams: []});
|
const {doc: diagram, error} = useDoc('directory', {}, {_id: 'directory', diagrams: []});
|
||||||
@ -34,10 +54,16 @@ export default function ManageDiagramsModal({openCreate, manageOpened, closeMana
|
|||||||
};
|
};
|
||||||
|
|
||||||
const cards = diagrams.map((diagram) => {
|
const cards = diagrams.map((diagram) => {
|
||||||
|
const badgeInfo = getStorageTypeBadge(diagram.storageType);
|
||||||
|
const diagramPath = getDiagramPath(diagram);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card key={diagram._id}>
|
<Card key={diagram._id}>
|
||||||
<Card.Section>
|
<Card.Section>
|
||||||
<Container w={512} h={64}>{diagram.name}</Container>
|
<Group justify="space-between" p="xs">
|
||||||
|
<Container w={400} h={64}>{diagram.name}</Container>
|
||||||
|
<Badge color={badgeInfo.color} size="sm">{badgeInfo.label}</Badge>
|
||||||
|
</Group>
|
||||||
</Card.Section>
|
</Card.Section>
|
||||||
<Card.Section>
|
<Card.Section>
|
||||||
<Container w={512} h={128}>
|
<Container w={512} h={128}>
|
||||||
@ -46,7 +72,7 @@ export default function ManageDiagramsModal({openCreate, manageOpened, closeMana
|
|||||||
</Card.Section>
|
</Card.Section>
|
||||||
<Card.Section>
|
<Card.Section>
|
||||||
<Group justify="space-evenly">
|
<Group justify="space-evenly">
|
||||||
<Button component={Link} key="examples" to={"/db/public/" + diagram._id} p={5} c="myColor"
|
<Button component={Link} key="examples" to={diagramPath} p={5} c="myColor"
|
||||||
bg="none">Select</Button>
|
bg="none">Select</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@ -20,6 +20,9 @@ import VREntryPrompt from "../components/VREntryPrompt";
|
|||||||
import ComingSoonBadge from "../components/ComingSoonBadge";
|
import ComingSoonBadge from "../components/ComingSoonBadge";
|
||||||
import UpgradeBadge from "../components/UpgradeBadge";
|
import UpgradeBadge from "../components/UpgradeBadge";
|
||||||
import ChatPanel from "../components/ChatPanel";
|
import ChatPanel from "../components/ChatPanel";
|
||||||
|
import {getDbType} from "../../util/functions/getPath";
|
||||||
|
import PouchDB from 'pouchdb';
|
||||||
|
import {v4} from "uuid";
|
||||||
|
|
||||||
let vrApp: VrApp = null;
|
let vrApp: VrApp = null;
|
||||||
|
|
||||||
@ -122,10 +125,76 @@ export default function VrExperience() {
|
|||||||
const [showVRPrompt, setShowVRPrompt] = useState(false);
|
const [showVRPrompt, setShowVRPrompt] = useState(false);
|
||||||
const [chatOpen, setChatOpen] = useState(!isMobileVRDevice()); // Show chat by default on desktop
|
const [chatOpen, setChatOpen] = useState(!isMobileVRDevice()); // Show chat by default on desktop
|
||||||
|
|
||||||
// Handle share: simply copy current URL to clipboard
|
// Handle share based on current database type:
|
||||||
|
// - Public: copy current URL to clipboard
|
||||||
|
// - Local: create one-time copy to public, share new URL
|
||||||
|
// - Private: show "not yet supported" message
|
||||||
const handleShare = async () => {
|
const handleShare = async () => {
|
||||||
|
const dbType = getDbType();
|
||||||
|
logger.info(`[Share] Sharing diagram - type: ${dbType}, dbName: ${dbName}`);
|
||||||
|
|
||||||
|
if (dbType === 'private') {
|
||||||
|
alert('Sharing private diagrams is not yet supported.\n\nPrivate diagram sharing with access control is coming soon!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dbType === 'local') {
|
||||||
|
// Create a one-time copy to public
|
||||||
|
logger.info('[Share] Creating public copy of local diagram...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Generate new ID for the public copy
|
||||||
|
const publicDbName = 'diagram-' + v4();
|
||||||
|
const localDb = new PouchDB(dbName);
|
||||||
|
const remoteUrl = `${window.location.origin}/pouchdb/public-${publicDbName}`;
|
||||||
|
const remoteDb = new PouchDB(remoteUrl);
|
||||||
|
|
||||||
|
// Get all docs from local database
|
||||||
|
const allDocs = await localDb.allDocs({ include_docs: true });
|
||||||
|
logger.debug(`[Share] Found ${allDocs.rows.length} documents to copy`);
|
||||||
|
|
||||||
|
// Copy each document to the remote database
|
||||||
|
for (const row of allDocs.rows) {
|
||||||
|
if (row.doc) {
|
||||||
|
// Remove PouchDB internal fields for clean insert
|
||||||
|
const { _rev, ...docWithoutRev } = row.doc as any;
|
||||||
|
try {
|
||||||
|
await remoteDb.put(docWithoutRev);
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(`[Share] Failed to copy doc ${row.id}:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicUrl = `${window.location.origin}/db/public/${publicDbName}`;
|
||||||
|
logger.info(`[Share] Public copy created at: ${publicUrl}`);
|
||||||
|
|
||||||
|
// Copy URL to clipboard
|
||||||
|
let copied = false;
|
||||||
|
try {
|
||||||
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
|
await navigator.clipboard.writeText(publicUrl);
|
||||||
|
copied = true;
|
||||||
|
}
|
||||||
|
} catch (clipboardError) {
|
||||||
|
logger.warn('Clipboard API failed:', clipboardError);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (copied) {
|
||||||
|
alert(`Public copy created!\n\nURL copied to clipboard:\n${publicUrl}\n\nNote: Your local diagram remains unchanged. The public copy will diverge independently.`);
|
||||||
|
} else {
|
||||||
|
prompt('Public copy created! Share URL (copy manually):', publicUrl);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('[Share] Failed to create public copy:', err);
|
||||||
|
alert('Failed to create public copy. Please try again.');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public diagram - just copy current URL
|
||||||
const shareUrl = window.location.href;
|
const shareUrl = window.location.href;
|
||||||
logger.info(`Sharing URL: ${shareUrl}`);
|
logger.info(`[Share] Sharing public URL: ${shareUrl}`);
|
||||||
|
|
||||||
// Try to copy URL to clipboard with fallback
|
// Try to copy URL to clipboard with fallback
|
||||||
let copied = false;
|
let copied = false;
|
||||||
|
|||||||
@ -36,6 +36,11 @@ export const webRouter = createBrowserRouter([
|
|||||||
<Pricing/>
|
<Pricing/>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
)
|
)
|
||||||
|
}, {
|
||||||
|
path: "/db/local/:db",
|
||||||
|
element: (
|
||||||
|
<VrExperience/> // No ProtectedRoute - works offline, browser-only
|
||||||
|
)
|
||||||
}, {
|
}, {
|
||||||
path: "/db/public/:db",
|
path: "/db/public/:db",
|
||||||
element: (
|
element: (
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
export function getPath(): string {
|
export function getPath(): string {
|
||||||
const path = window.location.pathname.split('/');
|
const path = window.location.pathname.split('/');
|
||||||
// Handle /db/public/:db or /db/private/:db patterns
|
// Handle /db/local/:db, /db/public/:db, or /db/private/:db patterns
|
||||||
if (path.length >= 4 && path[1] === 'db') {
|
if (path.length >= 4 && path[1] === 'db') {
|
||||||
return path[3];
|
return path[3];
|
||||||
}
|
}
|
||||||
@ -11,37 +11,67 @@ export function getPath(): string {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the current path is a local database (no sync)
|
||||||
|
* Local paths: /db/local/:db
|
||||||
|
*/
|
||||||
|
export function isLocalPath(): boolean {
|
||||||
|
const path = window.location.pathname.split('/');
|
||||||
|
return path.length >= 3 && path[1] === 'db' && path[2] === 'local';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if the current path is a public database
|
* Check if the current path is a public database
|
||||||
* Public paths: /db/public/:db
|
* Public paths: /db/public/:db
|
||||||
* Private paths: /db/private/:db
|
|
||||||
*/
|
*/
|
||||||
export function isPublicPath(): boolean {
|
export function isPublicPath(): boolean {
|
||||||
const path = window.location.pathname.split('/');
|
const path = window.location.pathname.split('/');
|
||||||
return path.length >= 3 && path[1] === 'db' && path[2] === 'public';
|
return path.length >= 3 && path[1] === 'db' && path[2] === 'public';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the current path is a private database
|
||||||
|
* Private paths: /db/private/:db
|
||||||
|
*/
|
||||||
|
export function isPrivatePath(): boolean {
|
||||||
|
const path = window.location.pathname.split('/');
|
||||||
|
return path.length >= 3 && path[1] === 'db' && path[2] === 'private';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the database type from the current path
|
* Get the database type from the current path
|
||||||
*/
|
*/
|
||||||
export function getDbType(): 'public' | 'private' | null {
|
export function getDbType(): 'local' | 'public' | 'private' | null {
|
||||||
const path = window.location.pathname.split('/');
|
const path = window.location.pathname.split('/');
|
||||||
if (path.length >= 3 && path[1] === 'db') {
|
if (path.length >= 3 && path[1] === 'db') {
|
||||||
|
if (path[2] === 'local') return 'local';
|
||||||
if (path[2] === 'public') return 'public';
|
if (path[2] === 'public') return 'public';
|
||||||
if (path[2] === 'private') return 'private';
|
if (path[2] === 'private') return 'private';
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the current diagram should sync with the server
|
||||||
|
* Only public and private paths should sync; local is browser-only
|
||||||
|
*/
|
||||||
|
export function shouldSync(): boolean {
|
||||||
|
const dbType = getDbType();
|
||||||
|
return dbType === 'public' || dbType === 'private';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the full database path for PouchDB sync
|
* Get the full database path for PouchDB sync
|
||||||
* Returns: public-{dbname} or private-{dbname}
|
* Returns: public-{dbname} or private-{dbname}
|
||||||
|
* Returns null for local paths (no sync)
|
||||||
* Uses dash separator instead of slash for express-pouchdb compatibility
|
* Uses dash separator instead of slash for express-pouchdb compatibility
|
||||||
*/
|
*/
|
||||||
export function getRemoteDbPath(): string | null {
|
export function getRemoteDbPath(): string | null {
|
||||||
const path = window.location.pathname.split('/');
|
const path = window.location.pathname.split('/');
|
||||||
if (path.length >= 4 && path[1] === 'db') {
|
if (path.length >= 4 && path[1] === 'db') {
|
||||||
const type = path[2]; // 'public' or 'private'
|
const type = path[2]; // 'local', 'public', or 'private'
|
||||||
|
// Local paths don't sync
|
||||||
|
if (type === 'local') return null;
|
||||||
const dbName = path[3];
|
const dbName = path[3];
|
||||||
return `${type}-${dbName}`;
|
return `${type}-${dbName}`;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user