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",
|
||||
"private": true,
|
||||
"version": "0.0.8-42",
|
||||
"version": "0.0.8-43",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
*
|
||||
* Database naming patterns:
|
||||
* / - 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
|
||||
* /private-{dbname} - Auth required
|
||||
* /{dbname} - Treated as private by default
|
||||
@ -24,6 +25,15 @@ export function dbAuthMiddleware(req, res, 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-')
|
||||
const isPublic = dbName.startsWith('public-');
|
||||
|
||||
|
||||
@ -5,7 +5,7 @@ import {DiagramEventObserverMask} from "../../diagram/types/diagramEventObserver
|
||||
import log, {Logger} from "loglevel";
|
||||
import PouchDB from 'pouchdb';
|
||||
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 {
|
||||
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() {
|
||||
const dbType = getDbType();
|
||||
const remoteDbPath = getRemoteDbPath();
|
||||
const isPublic = isPublicPath();
|
||||
|
||||
if (!remoteDbPath || !isPublic) {
|
||||
this._logger.debug('[Sync] Not a public path, skipping remote sync');
|
||||
if (dbType === 'local') {
|
||||
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;
|
||||
}
|
||||
|
||||
@ -186,4 +197,52 @@ export class PouchData {
|
||||
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 UpgradeBadge from "../components/UpgradeBadge";
|
||||
import {useAuth0} from "@auth0/auth0-react";
|
||||
import {useNavigate} from "react-router-dom";
|
||||
|
||||
export default function CreateDiagramModal({createOpened, closeCreate}) {
|
||||
const logger = log.getLogger('createDiagramModal');
|
||||
const db = usePouch();
|
||||
const { loginWithRedirect } = useAuth0();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Feature flags
|
||||
const privateDesignsState = useFeatureState('privateDesigns');
|
||||
@ -43,7 +45,14 @@ export default function CreateDiagramModal({createOpened, closeCreate}) {
|
||||
logger.warn('cannot find directory', err);
|
||||
}
|
||||
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) {
|
||||
await db.put({_id: 'directory', diagrams: [newDiagram], type: 'directory'});
|
||||
} else {
|
||||
@ -56,6 +65,8 @@ export default function CreateDiagramModal({createOpened, closeCreate}) {
|
||||
await db.put(doc);
|
||||
}
|
||||
closeCreate();
|
||||
// Navigate to the local diagram
|
||||
navigate(`/db/local/${id}`);
|
||||
}
|
||||
|
||||
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 {useDoc, usePouch} from "use-pouchdb";
|
||||
import {IconTrash, IconDownload} from "@tabler/icons-react";
|
||||
@ -7,6 +7,26 @@ import log from "loglevel";
|
||||
import {useFeatureLimit} from "../hooks/useFeatures";
|
||||
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}) {
|
||||
const logger = log.getLogger('manageDiagramsModal');
|
||||
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 badgeInfo = getStorageTypeBadge(diagram.storageType);
|
||||
const diagramPath = getDiagramPath(diagram);
|
||||
|
||||
return (
|
||||
<Card key={diagram._id}>
|
||||
<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>
|
||||
<Container w={512} h={128}>
|
||||
@ -46,7 +72,7 @@ export default function ManageDiagramsModal({openCreate, manageOpened, closeMana
|
||||
</Card.Section>
|
||||
<Card.Section>
|
||||
<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>
|
||||
|
||||
<Button
|
||||
|
||||
@ -20,6 +20,9 @@ import VREntryPrompt from "../components/VREntryPrompt";
|
||||
import ComingSoonBadge from "../components/ComingSoonBadge";
|
||||
import UpgradeBadge from "../components/UpgradeBadge";
|
||||
import ChatPanel from "../components/ChatPanel";
|
||||
import {getDbType} from "../../util/functions/getPath";
|
||||
import PouchDB from 'pouchdb';
|
||||
import {v4} from "uuid";
|
||||
|
||||
let vrApp: VrApp = null;
|
||||
|
||||
@ -122,10 +125,76 @@ export default function VrExperience() {
|
||||
const [showVRPrompt, setShowVRPrompt] = useState(false);
|
||||
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 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;
|
||||
logger.info(`Sharing URL: ${shareUrl}`);
|
||||
logger.info(`[Share] Sharing public URL: ${shareUrl}`);
|
||||
|
||||
// Try to copy URL to clipboard with fallback
|
||||
let copied = false;
|
||||
|
||||
@ -36,6 +36,11 @@ export const webRouter = createBrowserRouter([
|
||||
<Pricing/>
|
||||
</ProtectedRoute>
|
||||
)
|
||||
}, {
|
||||
path: "/db/local/:db",
|
||||
element: (
|
||||
<VrExperience/> // No ProtectedRoute - works offline, browser-only
|
||||
)
|
||||
}, {
|
||||
path: "/db/public/:db",
|
||||
element: (
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
export function getPath(): string {
|
||||
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') {
|
||||
return path[3];
|
||||
}
|
||||
@ -11,37 +11,67 @@ export function getPath(): string {
|
||||
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
|
||||
* Public paths: /db/public/:db
|
||||
* Private paths: /db/private/:db
|
||||
*/
|
||||
export function isPublicPath(): boolean {
|
||||
const path = window.location.pathname.split('/');
|
||||
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
|
||||
*/
|
||||
export function getDbType(): 'public' | 'private' | null {
|
||||
export function getDbType(): 'local' | 'public' | 'private' | null {
|
||||
const path = window.location.pathname.split('/');
|
||||
if (path.length >= 3 && path[1] === 'db') {
|
||||
if (path[2] === 'local') return 'local';
|
||||
if (path[2] === 'public') return 'public';
|
||||
if (path[2] === 'private') return 'private';
|
||||
}
|
||||
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
|
||||
* Returns: public-{dbname} or private-{dbname}
|
||||
* Returns null for local paths (no sync)
|
||||
* Uses dash separator instead of slash for express-pouchdb compatibility
|
||||
*/
|
||||
export function getRemoteDbPath(): string | null {
|
||||
const path = window.location.pathname.split('/');
|
||||
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];
|
||||
return `${type}-${dbName}`;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user