Compare commits
No commits in common. "main" and "master" have entirely different histories.
|
|
@ -1,21 +1,5 @@
|
|||
node_modules
|
||||
.git
|
||||
.gitignore
|
||||
*.md
|
||||
.env*
|
||||
Dockerfile
|
||||
docker-compose*.yml
|
||||
.dockerignore
|
||||
backlog
|
||||
.next
|
||||
out
|
||||
.cache
|
||||
dist
|
||||
build
|
||||
coverage
|
||||
.github
|
||||
.vscode
|
||||
.idea
|
||||
__pycache__
|
||||
*.pyc
|
||||
.pytest_cache
|
||||
.git
|
||||
*.md
|
||||
backlog
|
||||
|
|
|
|||
17
.env.example
|
|
@ -1,9 +1,18 @@
|
|||
# Database
|
||||
DB_PASSWORD=changeme
|
||||
DATABASE_URL=postgresql://rnotes:password@localhost:5432/rnotes
|
||||
|
||||
# rSpace integration
|
||||
NEXT_PUBLIC_RSPACE_URL=https://rspace.online
|
||||
RSPACE_INTERNAL_URL=http://rspace-online:3000
|
||||
# NextAuth
|
||||
NEXTAUTH_SECRET=your-secret-here
|
||||
NEXTAUTH_URL=http://localhost:3000
|
||||
|
||||
# EncryptID
|
||||
ENCRYPTID_SERVER_URL=https://auth.ridentity.online
|
||||
NEXT_PUBLIC_ENCRYPTID_SERVER_URL=https://auth.ridentity.online
|
||||
|
||||
# Domain
|
||||
ROOT_DOMAIN=localhost:3000
|
||||
NEXT_PUBLIC_ROOT_DOMAIN=localhost:3000
|
||||
|
||||
# Yjs WebSocket sync server
|
||||
SYNC_SERVER_PORT=4444
|
||||
NEXT_PUBLIC_SYNC_URL=ws://localhost:4444
|
||||
|
|
|
|||
|
|
@ -1,68 +0,0 @@
|
|||
# Gitea Actions CI/CD — Static Site (no tests, build + deploy only)
|
||||
# Copy to: <repo>/.gitea/workflows/ci.yml
|
||||
# Replace: rnotes-online, /opt/websites/rnotes-online, https://rnotes.online/
|
||||
|
||||
name: CI/CD
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
env:
|
||||
REGISTRY: localhost:3000
|
||||
IMAGE: localhost:3000/jeffemmett/rnotes-online
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: docker:cli
|
||||
steps:
|
||||
- name: Setup tools
|
||||
run: apk add --no-cache git openssh-client curl
|
||||
|
||||
- name: Checkout
|
||||
run: git clone --depth 1 --branch ${{ github.ref_name }} http://token:${{ github.token }}@server:3000/${{ github.repository }}.git .
|
||||
|
||||
- name: Set image tag
|
||||
run: |
|
||||
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-8)
|
||||
echo "IMAGE_TAG=${SHORT_SHA}" >> $GITHUB_ENV
|
||||
|
||||
- name: Build and push image
|
||||
run: |
|
||||
docker build -t ${{ env.IMAGE }}:${{ env.IMAGE_TAG }} -t ${{ env.IMAGE }}:latest .
|
||||
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login ${{ env.REGISTRY }} -u ${{ secrets.REGISTRY_USER }} --password-stdin
|
||||
docker push ${{ env.IMAGE }}:${{ env.IMAGE_TAG }}
|
||||
docker push ${{ env.IMAGE }}:latest
|
||||
|
||||
- name: Deploy
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.DEPLOY_SSH_KEY }}" | base64 -d > ~/.ssh/deploy_key
|
||||
chmod 600 ~/.ssh/deploy_key
|
||||
ssh -o StrictHostKeyChecking=no -i ~/.ssh/deploy_key root@${{ secrets.DEPLOY_HOST }} "
|
||||
cd /opt/websites/rnotes-online
|
||||
cat .last-deployed-tag 2>/dev/null > .rollback-tag || true
|
||||
echo '${{ env.IMAGE_TAG }}' > .last-deployed-tag
|
||||
docker pull ${{ env.IMAGE }}:${{ env.IMAGE_TAG }}
|
||||
IMAGE_TAG=${{ env.IMAGE_TAG }} docker compose up -d --no-build
|
||||
"
|
||||
|
||||
- name: Smoke test
|
||||
run: |
|
||||
sleep 15
|
||||
STATUS=$(ssh -o StrictHostKeyChecking=no -i ~/.ssh/deploy_key root@${{ secrets.DEPLOY_HOST }} \
|
||||
"cd /opt/websites/rnotes-online && docker compose ps --format '{{{{.Status}}}}' 2>/dev/null | head -1 || echo 'unknown'")
|
||||
if echo "$STATUS" | grep -qi "up"; then
|
||||
echo "Smoke test passed (container status: $STATUS)"
|
||||
else
|
||||
echo "Smoke test failed (container status: $STATUS) — rolling back"
|
||||
ROLLBACK_TAG=$(ssh -o StrictHostKeyChecking=no -i ~/.ssh/deploy_key root@${{ secrets.DEPLOY_HOST }} "cat /opt/websites/rnotes-online/.rollback-tag 2>/dev/null")
|
||||
if [ -n "$ROLLBACK_TAG" ]; then
|
||||
ssh -o StrictHostKeyChecking=no -i ~/.ssh/deploy_key root@${{ secrets.DEPLOY_HOST }} \
|
||||
"cd /opt/websites/rnotes-online && IMAGE_TAG=$ROLLBACK_TAG docker compose up -d --no-build"
|
||||
echo "Rolled back to $ROLLBACK_TAG"
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
|
@ -1,32 +1,5 @@
|
|||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
.yarn/install-state.gz
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
node_modules
|
||||
.next
|
||||
.env
|
||||
.env*.local
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
*.env.local
|
||||
sync-server/dist
|
||||
|
|
|
|||
72
Dockerfile
|
|
@ -1,46 +1,58 @@
|
|||
FROM node:20-alpine AS base
|
||||
|
||||
# Dependencies stage
|
||||
FROM base AS deps
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY rnotes-online/package.json rnotes-online/package-lock.json* ./
|
||||
COPY rnotes-online/prisma ./prisma/
|
||||
# Copy local SDK dependency to /encryptid-sdk (package.json references file:../encryptid-sdk)
|
||||
COPY encryptid-sdk /encryptid-sdk/
|
||||
RUN npm ci || npm install
|
||||
|
||||
# Build stage
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY --from=deps /encryptid-sdk /encryptid-sdk
|
||||
COPY rnotes-online/ .
|
||||
# Copy SDK to a location outside the app source
|
||||
COPY sdk/ /opt/encryptid-sdk/
|
||||
|
||||
# Install dependencies
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN sed -i 's|"file:../encryptid-sdk"|"file:/opt/encryptid-sdk"|' package.json
|
||||
RUN npm install --legacy-peer-deps
|
||||
|
||||
# Copy source files explicitly (avoid copying sdk/)
|
||||
COPY src/ ./src/
|
||||
COPY prisma/ ./prisma/
|
||||
COPY sync-server/ ./sync-server/
|
||||
COPY public/ ./public/
|
||||
COPY next.config.ts tsconfig.json postcss.config.mjs entrypoint.sh ./
|
||||
|
||||
# Generate Prisma client and build
|
||||
RUN npx prisma generate
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM base AS runner
|
||||
# ─── Production ───────────────────────────────────────────
|
||||
FROM node:20-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
RUN addgroup --system --gid 1001 nodejs && \
|
||||
adduser --system --uid 1001 nextjs
|
||||
|
||||
# Copy Next.js standalone output
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
COPY --from=builder /app/prisma ./prisma
|
||||
COPY --from=builder /app/scripts ./scripts
|
||||
COPY --from=builder /app/src/lib/content-convert.ts ./src/lib/content-convert.ts
|
||||
|
||||
# Copy sync server (plain JS, no compilation needed)
|
||||
COPY --from=builder /app/sync-server/src/index.js ./sync-server/index.js
|
||||
COPY --from=builder /app/node_modules/yjs ./node_modules/yjs
|
||||
COPY --from=builder /app/node_modules/y-websocket ./node_modules/y-websocket
|
||||
COPY --from=builder /app/node_modules/y-protocols ./node_modules/y-protocols
|
||||
COPY --from=builder /app/node_modules/lib0 ./node_modules/lib0
|
||||
COPY --from=builder /app/node_modules/ws ./node_modules/ws
|
||||
COPY --from=builder /app/node_modules/lodash.debounce ./node_modules/lodash.debounce
|
||||
|
||||
# Copy Prisma client
|
||||
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
|
||||
COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
|
||||
COPY rnotes-online/entrypoint.sh /app/entrypoint.sh
|
||||
RUN chmod +x /app/entrypoint.sh
|
||||
|
||||
# Copy entrypoint
|
||||
COPY --from=builder /app/entrypoint.sh ./entrypoint.sh
|
||||
RUN chmod +x entrypoint.sh
|
||||
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
EXPOSE 3000 4444
|
||||
|
||||
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||
ENTRYPOINT ["./entrypoint.sh"]
|
||||
CMD ["node", "server.js"]
|
||||
|
|
|
|||
128
MODULE_SPEC.md
|
|
@ -1,128 +0,0 @@
|
|||
# rNotes — Collaborative Notebooks
|
||||
|
||||
**Module ID:** `rnotes`
|
||||
**Domain:** `rnotes.online`
|
||||
**Version:** 0.1.0
|
||||
**Framework:** Next.js 14 / React 18 / Prisma / PostgreSQL / TipTap
|
||||
**Status:** Active
|
||||
|
||||
## Purpose
|
||||
|
||||
Rich note-taking with notebooks, tags, file uploads, voice transcription, and canvas integration. Supports per-notebook collaboration with role-based access. Notes can embed as shapes on the rSpace canvas.
|
||||
|
||||
## Data Model
|
||||
|
||||
### Core Entities (Prisma)
|
||||
|
||||
| Model | Key Fields | Relationships |
|
||||
|-------|-----------|---------------|
|
||||
| **User** | id, did (EncryptID DID), username | has many Notebook, Note, NotebookCollaborator |
|
||||
| **Notebook** | id, title, slug (unique), description, coverColor, isPublic, canvasSlug, canvasShapeId | has many Note, NotebookCollaborator, SharedAccess |
|
||||
| **NotebookCollaborator** | userId, notebookId, role (OWNER/EDITOR/VIEWER) | belongs to User, Notebook |
|
||||
| **Note** | id, title, content, contentPlain, type (enum), url, isPinned, sortOrder, canvasSlug, canvasShapeId | belongs to Notebook, User (author) |
|
||||
| **Tag** | id, name (unique), color | many-to-many with Note via NoteTag |
|
||||
| **SharedAccess** | notebookId, sharedByUserId, sharedWithDID, role | cross-user sharing |
|
||||
|
||||
### Note Types
|
||||
|
||||
NOTE, CLIP, BOOKMARK, CODE, IMAGE, FILE, AUDIO
|
||||
|
||||
### Collaborator Roles
|
||||
|
||||
OWNER, EDITOR, VIEWER (per-notebook)
|
||||
|
||||
## Permission Model
|
||||
|
||||
### Space Integration
|
||||
|
||||
rNotes currently operates at the notebook level, not the space level. The migration path adds a space-level role that sets the default notebook access.
|
||||
|
||||
- **SpaceVisibility:** Mapped to notebook `isPublic` flag (public → true)
|
||||
- **Default role for open spaces:** PARTICIPANT (can create notebooks and edit own notes)
|
||||
|
||||
### Capabilities
|
||||
|
||||
| Capability | Required SpaceRole | AuthLevel | Description |
|
||||
|-----------|-------------------|-----------|-------------|
|
||||
| `view_notebooks` | VIEWER | BASIC | See notebook list and read notes |
|
||||
| `create_notebook` | PARTICIPANT | STANDARD | Create new notebooks |
|
||||
| `edit_own_notes` | PARTICIPANT | STANDARD | Edit/delete own notes in any shared notebook |
|
||||
| `edit_any_notes` | MODERATOR | STANDARD | Edit/delete any user's notes |
|
||||
| `manage_notebooks` | ADMIN | ELEVATED | Delete notebooks, manage collaborators |
|
||||
|
||||
### Module-Specific Overrides
|
||||
|
||||
Per-notebook `CollaboratorRole` overrides the space-level default:
|
||||
- Space PARTICIPANT + Notebook OWNER → full notebook control
|
||||
- Space PARTICIPANT + Notebook VIEWER → read-only on that notebook
|
||||
- Space MODERATOR → EDITOR on all notebooks (override)
|
||||
- Space ADMIN → OWNER on all notebooks (override)
|
||||
|
||||
### Current Auth Implementation
|
||||
|
||||
- EncryptID DID from JWT claims
|
||||
- `getAuthUser(request)` → extracts DID, upserts User
|
||||
- `requireAuth(request)` → returns 401 if no valid token
|
||||
- `getNotebookRole(userId, notebookId)` → looks up CollaboratorRole
|
||||
- First authenticated user auto-claims orphaned notebooks
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Method | Path | Auth Required | Capability | Description |
|
||||
|--------|------|---------------|------------|-------------|
|
||||
| GET | /api/notebooks | Yes | view_notebooks | List user's notebooks |
|
||||
| POST | /api/notebooks | Yes | create_notebook | Create notebook |
|
||||
| GET | /api/notebooks/[id] | Depends | view_notebooks | Get notebook details |
|
||||
| PUT | /api/notebooks/[id] | Yes | edit_own_notes | Update notebook |
|
||||
| DELETE | /api/notebooks/[id] | Yes | manage_notebooks | Delete notebook |
|
||||
| GET | /api/notebooks/[id]/notes | Depends | view_notebooks | List notes |
|
||||
| GET | /api/notebooks/[id]/canvas | Yes | view_notebooks | Get canvas shape data |
|
||||
| PUT | /api/notebooks/[id]/canvas | Yes | edit_own_notes | Update canvas binding |
|
||||
| GET | /api/notes | Yes | view_notebooks | Global notes list |
|
||||
| POST | /api/notes | Yes | edit_own_notes | Create note |
|
||||
| GET | /api/notes/[id] | Depends | view_notebooks | Get note |
|
||||
| PUT | /api/notes/[id] | Yes | edit_own_notes | Update note |
|
||||
| DELETE | /api/notes/[id] | Yes | edit_own_notes | Delete own note |
|
||||
| GET | /api/notes/search | Yes | view_notebooks | Full-text search |
|
||||
| POST | /api/uploads | Yes | edit_own_notes | Upload file |
|
||||
| GET | /api/uploads/[filename] | Depends | view_notebooks | Download file |
|
||||
| POST | /api/voice/transcribe | Yes | edit_own_notes | Audio→text |
|
||||
| POST | /api/voice/diarize | Yes | edit_own_notes | Speaker diarization |
|
||||
|
||||
## Canvas Integration
|
||||
|
||||
Notes and notebooks embed as shapes on the rSpace canvas:
|
||||
- **`folk-note`**: Individual note card (title + content preview)
|
||||
- **`folk-notebook`**: Notebook container showing note count
|
||||
- Bidirectional binding via `canvasSlug` and `canvasShapeId` fields
|
||||
- Editing a note shape on canvas updates the note in PostgreSQL
|
||||
- Creating a note in rNotes can auto-place a shape on canvas
|
||||
|
||||
## Cross-Module Dependencies
|
||||
|
||||
| Module | Integration |
|
||||
|--------|------------|
|
||||
| **rSpace** | Canvas shape embedding (folk-note, folk-notebook) |
|
||||
| **EncryptID** | DID-based identity and authentication |
|
||||
| **rFiles** | File attachments referenced in notes |
|
||||
|
||||
## Local-First / Offline Support
|
||||
|
||||
- Currently server-authoritative (Prisma/PostgreSQL)
|
||||
- Client state managed with Zustand
|
||||
- Local-first Transformers.js for on-device AI inference
|
||||
- Future: TipTap Y.js integration for offline collaborative editing
|
||||
- Future: IndexedDB cache for offline note access
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. Add space concept: link notebooks to a space slug (optional, backwards-compatible)
|
||||
2. Import `SpaceRole` from SDK for space-level role resolution
|
||||
3. Add `resolveSpaceRole()` call in `getAuthUser()` or a new middleware layer
|
||||
4. Cascade space role → default notebook role:
|
||||
- Space PARTICIPANT → Notebook EDITOR (on newly accessed notebooks)
|
||||
- Space VIEWER → Notebook VIEWER
|
||||
- Space MODERATOR → Notebook EDITOR (all notebooks)
|
||||
- Space ADMIN → Notebook OWNER (all notebooks)
|
||||
5. Keep per-notebook `CollaboratorRole` overrides for fine-grained control
|
||||
6. Replace direct role checks with `hasCapability()` in API route handlers
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
project_name: "rNotes.online"
|
||||
default_status: "To Do"
|
||||
statuses: ["To Do", "In Progress", "Done"]
|
||||
labels: []
|
||||
milestones: []
|
||||
date_format: yyyy-mm-dd
|
||||
max_column_width: 20
|
||||
default_editor: "nvim"
|
||||
auto_open_browser: true
|
||||
default_port: 6420
|
||||
remote_operations: true
|
||||
auto_commit: false
|
||||
bypass_git_hooks: false
|
||||
check_active_branches: true
|
||||
active_branch_days: 30
|
||||
task_prefix: "task"
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
---
|
||||
id: TASK-1
|
||||
title: Initial deployment of rNotes.online
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-02-13 20:39'
|
||||
labels: []
|
||||
dependencies: []
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Full Next.js 14 app with Prisma + PostgreSQL, 6 note types, notebooks, canvas integration, search, deployed to Netcup via Docker + Traefik + Cloudflare tunnel
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
---
|
||||
id: TASK-10
|
||||
title: Mobile-responsive UI polish
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-02-13 20:39'
|
||||
updated_date: '2026-02-13 21:41'
|
||||
labels: []
|
||||
dependencies: []
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Ensure all pages work well on mobile. Hamburger nav, touch-friendly editor toolbar, responsive note grid.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Mobile responsive completed: responsive nav, grids, hero text, canvas overlay on mobile, icon-only buttons on small screens
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
---
|
||||
id: TASK-11
|
||||
title: Add offline Whisper transcription via Transformers.js
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-02-15 17:17'
|
||||
updated_date: '2026-02-15 20:42'
|
||||
labels: []
|
||||
dependencies: []
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Implement WhisperOffline.tsx component that loads @xenova/transformers Whisper model in the browser. Cache model via Cache API (~40MB). Use as fallback in VoiceRecorder when WebSocket streaming is unavailable (offline, server down). Show download progress on first use. Currently the fallback is batch transcription via server - this would enable fully offline transcription.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Offline Whisper via @xenova/transformers v2.17.2 deployed.
|
||||
Model: Xenova/whisper-tiny (~45MB, quantized, cached in browser).
|
||||
Fallback chain: WebSocket streaming > server batch API > offline browser Whisper.
|
||||
webpack config: IgnorePlugin for onnxruntime-node, fs/path/os polyfill stubs.
|
||||
Build passes, deployed to Netcup.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
---
|
||||
id: TASK-12
|
||||
title: Optimize Docker image size - use CPU-only torch
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-02-15 17:17'
|
||||
updated_date: '2026-02-15 17:29'
|
||||
labels: []
|
||||
dependencies: []
|
||||
priority: low
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Voice-command Docker image is ~3.5GB due to full torch with CUDA/nvidia libs. Netcup has no GPU. Switch to CPU-only torch wheel (pip install torch --index-url https://download.pytorch.org/whl/cpu) to cut ~2GB. Also consider if pyannote.audio can use ONNX runtime instead of torch for inference. Current memory limit is 4G.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
CPU-only torch optimization deployed to Netcup.
|
||||
Image size: 4.19GB (still large due to pyannote deps, but CUDA libs removed).
|
||||
Health check passes, WebSocket streaming verified working.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
---
|
||||
id: TASK-13
|
||||
title: E2E test WebSocket streaming transcription through Cloudflare tunnel
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-02-15 17:17'
|
||||
updated_date: '2026-02-15 21:15'
|
||||
labels: []
|
||||
dependencies: []
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Verify live streaming transcription works end-to-end: browser AudioWorklet -> WSS via Cloudflare tunnel -> voice-command VAD -> Whisper -> finalized segments back to browser. Check: 1) WSS upgrade works through Cloudflare (may need websocket setting enabled), 2) No idle timeout kills the connection during pauses, 3) Segments appear ~1-2s after silence detection, 4) Text never shifts once displayed, 5) Batch fallback works when WS fails.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
WebSocket streaming through Cloudflare tunnel: VERIFIED WORKING
|
||||
- WSS upgrade succeeds
|
||||
- Binary PCM16 data transmission works
|
||||
- Server responds with done message
|
||||
- No idle timeout issues observed
|
||||
- VAD correctly ignores non-speech (pure tone test)
|
||||
- No crashes in handler (torch tensor fix applied)
|
||||
Remaining: need real speech test via browser to confirm full transcription flow
|
||||
|
||||
CPU-only torch rebuild verified: health check OK, WebSocket OK.
|
||||
Still need browser-based real speech test for full E2E verification.
|
||||
|
||||
WSS through Cloudflare: verified working.
|
||||
VAD correctly rejects non-speech.
|
||||
Diarization endpoint: 200 OK.
|
||||
Offline Whisper fallback: deployed.
|
||||
Full browser real-speech test deferred to manual QA.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
---
|
||||
id: TASK-14
|
||||
title: Add SpaceRole bridge for cross-module membership sync
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-02-17 22:34'
|
||||
labels: []
|
||||
dependencies: []
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Maps notebook collaborator roles (OWNER/EDITOR/VIEWER) to SpaceRoles (ADMIN/PARTICIPANT/VIEWER). Falls back to EncryptID server for cross-space membership when notebook is linked via canvasSlug.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
---
|
||||
id: TASK-15
|
||||
title: 'EncryptID personal subdomains: <user>.r*.online with local-first data'
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-02-25 03:01'
|
||||
updated_date: '2026-02-25 04:48'
|
||||
labels:
|
||||
- architecture
|
||||
- auth
|
||||
- encryptid
|
||||
- infrastructure
|
||||
- cross-app
|
||||
dependencies: []
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
When a user logs in via EncryptID, they should operate out of `<encryptID>.r*.online` (e.g., `alice.rnotes.online`, `alice.rspace.online`), with all their data saved securely to their local space.
|
||||
|
||||
## Scope
|
||||
|
||||
This is a cross-cutting feature affecting all rStack apps. Key areas:
|
||||
|
||||
### 1. DNS & Routing
|
||||
- Wildcard DNS for each `r*.online` domain (`*.rnotes.online`, `*.rspace.online`, etc.)
|
||||
- Cloudflare wildcard CNAME records pointing to the tunnel
|
||||
- Traefik wildcard Host rules to route `*.r*.online` to the correct app container
|
||||
|
||||
### 2. Auth / EncryptID Integration
|
||||
- On login, redirect to `<encryptID>.r*.online`
|
||||
- Middleware to extract subdomain, validate it matches the authenticated EncryptID
|
||||
- Reject requests where subdomain doesn't match session identity
|
||||
|
||||
### 3. Data Isolation & Local-First Storage
|
||||
- Each user's data is scoped to their EncryptID
|
||||
- Explore local-first / CRDTs (e.g., Yjs, Automerge) for offline-capable storage
|
||||
- Sync strategy: local device ↔ user's personal encrypted space on server
|
||||
- Encryption at rest using keys derived from EncryptID
|
||||
|
||||
### 4. Multi-App Consistency
|
||||
- Shared auth session across `*.r*.online` subdomains (cross-subdomain cookies or token-based)
|
||||
- AppSwitcher links should resolve to `<user>.rspace.online`, `<user>.rnotes.online`, etc. when logged in
|
||||
- Consistent UX: user always sees their subdomain in the URL bar
|
||||
|
||||
### 5. Migration
|
||||
- Existing data needs a migration path to the new per-user scoped storage
|
||||
- Backward compat for users accessing the root domain (redirect to subdomain after login)
|
||||
|
||||
## Open Questions
|
||||
- What is the EncryptID identifier format? (alphanumeric, length constraints, case sensitivity)
|
||||
- Should unauthenticated users see public content at the root domain?
|
||||
- How does this interact with rSpace's existing space/room model?
|
||||
- Storage backend: SQLite per user? Postgres row-level security? Encrypted blob store?
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 Wildcard DNS configured for all r*.online domains
|
||||
- [ ] #2 Middleware extracts and validates EncryptID from subdomain
|
||||
- [ ] #3 User data is scoped and encrypted per EncryptID
|
||||
- [ ] #4 AppSwitcher links resolve to user's personal subdomain when logged in
|
||||
- [ ] #5 Cross-subdomain auth session works across rStack apps
|
||||
- [ ] #6 Migration path defined for existing data
|
||||
- [ ] #7 Local-first storage strategy documented and prototyped
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Research complete - EncryptID uses DID format (did:key:z<base64url>, 50+ chars). DIDs too long for DNS labels (63 char limit). Username-based subdomains recommended but usernames are currently optional.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Implemented username-based personal subdomains (`<username>.rnotes.online`) across 8 phases:
|
||||
|
||||
**Phase 1 - Infrastructure**: DNS wildcard and Traefik routing already configured. Cloudflared needs manual `*.rnotes.online` entry.
|
||||
|
||||
**Phase 2 - EncryptID**: Server already enforces username UNIQUE NOT NULL + includes in JWT. SDK updated: made `username` required in `EncryptIDClaims` type, updated `createSession()`.
|
||||
|
||||
**Phase 3 - Schema**: Added `workspaceSlug` field to Notebook model with index + migration SQL.
|
||||
|
||||
**Phase 4 - Middleware**: Sets `x-workspace-slug` header from subdomain extraction. New `workspace.ts` helper.
|
||||
|
||||
**Phase 5 - API Filtering**: All GET endpoints filter by workspace on subdomains (notebooks, notes, search, notebook detail, notebook notes).
|
||||
|
||||
**Phase 6 - AppSwitcher**: Fetches `/api/me` for username, generates `<username>.r*.online` links when logged in.
|
||||
|
||||
**Phase 7 - Sessions**: `SubdomainSession` component syncs localStorage ↔ `.rnotes.online` domain-wide cookie. `authFetch` falls back to domain cookie.
|
||||
|
||||
**Phase 8 - Migration**: Auto-assigns unscoped notebooks to user's workspace on auth.
|
||||
|
||||
**Manual steps remaining**: Remove stale container on netcup, run migration, add cloudflared wildcard entry.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
---
|
||||
id: TASK-16
|
||||
title: Add rStack AppSwitcher dropdown across all r*App headers
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-02-25 03:47'
|
||||
labels:
|
||||
- feature
|
||||
- ui
|
||||
- cross-app
|
||||
- branding
|
||||
dependencies: []
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Added the unified rStack AppSwitcher dropdown to the header/nav of all 19 r*-online repos plus the rSpace platform Web Component.
|
||||
|
||||
## What was done
|
||||
|
||||
### rSpace platform (rspace-online)
|
||||
- Rewrote `shared/components/rstack-app-switcher.ts` Web Component with:
|
||||
- Pastel rainbow badges (rS, rN, rP, rC, rT, etc.) replacing plain emoji icons
|
||||
- Emoji moved to right of app name in dropdown items
|
||||
- rStack header with gradient badge at top of dropdown
|
||||
- rStack footer link at bottom
|
||||
- Canvas renamed to rSpace
|
||||
- rMaps moved to Planning category
|
||||
- "Sharing & Media" renamed to "Social & Sharing" with rNetwork at top
|
||||
- Fixed light→dark theme across all 21 modules (was causing white header bar)
|
||||
- Renamed canvas module to "rSpace"
|
||||
|
||||
### rnotes-online
|
||||
- Created React `AppSwitcher.tsx` component
|
||||
- Created shared `Header.tsx` with AppSwitcher + SpaceSwitcher + breadcrumbs
|
||||
- Integrated into all 9 page files
|
||||
|
||||
### 14 other Next.js repos
|
||||
- Copied `AppSwitcher.tsx` React component into each
|
||||
- Integrated into existing Header/Navbar components
|
||||
- Repos: rPubs, rauctions, rcal, rcart, rchats, rfunds, rinbox, rmail, rmaps, rsocials, rtrips, rtube, rvote, rwork
|
||||
|
||||
### 4 non-Next.js repos
|
||||
- Created standalone HTML/CSS/JS AppSwitcher (no framework dependencies)
|
||||
- Repos: rNetwork (Vite), rfiles (Django), rstack (static), rwallet (static)
|
||||
|
||||
All repos committed and pushed to main on Gitea.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 AppSwitcher shows pastel badges with r* abbreviations
|
||||
- [ ] #2 Emoji displayed to the right of app name
|
||||
- [ ] #3 rStack header with gradient badge at top of dropdown
|
||||
- [ ] #4 5 categories: Creating, Planning, Discussing & Deciding, Funding & Commerce, Social & Sharing
|
||||
- [ ] #5 rMaps under Planning, rNetwork at top of Social & Sharing
|
||||
- [ ] #6 All 19 standalone repos have AppSwitcher integrated
|
||||
- [ ] #7 rSpace Web Component updated with matching branding
|
||||
- [ ] #8 Dark theme applied to all rSpace module shells
|
||||
- [ ] #9 All repos pushed to main on Gitea
|
||||
- [ ] #10 rspace-online and rnotes-online deployed to production
|
||||
<!-- AC:END -->
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
---
|
||||
id: TASK-18
|
||||
title: Encrypted IPFS file storage integration
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-04-01 01:11'
|
||||
updated_date: '2026-04-01 01:12'
|
||||
labels: []
|
||||
dependencies: []
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
End-to-end encrypted IPFS file storage for rNotes. Files are encrypted client-side with AES-256-GCM (per-file keys), uploaded to a self-hosted kubo IPFS node, and CID + encryption key stored in the database. Includes public gateway for reads, authenticated API for writes, and a proxy route for transparent decryption.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Self-hosted kubo IPFS node deployed on Netcup with Traefik routing
|
||||
- [x] #2 Public gateway at ipfs.jeffemmett.com serves encrypted content
|
||||
- [x] #3 IPFS API restricted to Docker-internal and Tailscale networks only
|
||||
- [x] #4 AES-256-GCM encryption module ported from fileverse POC to rNotes (src/lib/ipfs.ts)
|
||||
- [x] #5 Upload API (api/uploads) encrypts and stores files on IPFS when IPFS_ENABLED=true
|
||||
- [x] #6 IPFS proxy route (api/ipfs/[cid]) decrypts and serves files with LRU cache
|
||||
- [x] #7 Prisma schema updated with ipfsCid and ipfsEncKey columns on File model
|
||||
- [x] #8 FileUpload component prefers IPFS URLs when available
|
||||
- [x] #9 Feature-flagged via IPFS_ENABLED env var with graceful fallback to local storage
|
||||
- [x] #10 Docker DNS collision resolved (kubo container name avoids conflict with collab-server)
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
## Implementation Summary
|
||||
|
||||
### Infrastructure (dev-ops repo)
|
||||
- **Kubo node**: `/opt/apps/ipfs/docker-compose.yml` — ipfs/kubo:v0.34.1, 2GB mem, 1 CPU
|
||||
- **Init script**: `/opt/apps/ipfs/init.sh` — StorageMax 50GB, CORS, public DHT
|
||||
- **Container name**: `kubo` (renamed from `ipfs` to avoid DNS collision with collab-server)
|
||||
- **Traefik**: Gateway on port 8080 (`ipfs.jeffemmett.com`), API on 5001 with IP whitelist middleware
|
||||
- **Swarm ports**: 4001 TCP/UDP open in UFW for DHT participation
|
||||
- **PeerID**: 12D3KooWSFJanxDtgi4Z1d6hQRhnkA7t7tSHtHVaiASmak39wtCW
|
||||
|
||||
### rNotes Integration (rnotes-online repo)
|
||||
- `src/lib/ipfs.ts` — Encryption (AES-256-GCM), upload/download, kubo API client
|
||||
- `src/app/api/ipfs/[cid]/route.ts` — Proxy route with decrypt + LRU cache (100 items, 10min TTL)
|
||||
- `src/app/api/uploads/route.ts` — Modified to encrypt+upload to IPFS when enabled
|
||||
- `src/components/FileUpload.tsx` — Prefers IPFS gateway URLs
|
||||
- `prisma/schema.prisma` — Added `ipfsCid` and `ipfsEncKey` to File model
|
||||
- `docker-compose.yml` — IPFS_ENABLED, IPFS_API_URL (http://kubo:5001), IPFS_GATEWAY_URL
|
||||
- `next.config.mjs` — Added `typescript: { ignoreBuildErrors: true }` for encryptid-sdk subpath exports
|
||||
|
||||
### fileverse POC Updates
|
||||
- `ipfs-client.ts` — Added `fromEnv()` factory, auth token support, `getGatewayUrl()`
|
||||
- `test-live.ts` — Live integration test (encrypt/upload/download/decrypt roundtrip)
|
||||
|
||||
### Database Migration
|
||||
- Applied manually: `ALTER TABLE "File" ADD COLUMN "ipfsCid" TEXT; ALTER TABLE "File" ADD COLUMN "ipfsEncKey" TEXT;`
|
||||
|
||||
### Security
|
||||
- IPFS API NOT exposed through Cloudflare tunnel (removed from cloudflared config)
|
||||
- API access restricted to Docker internal (172.16.0.0/12) + Tailscale mesh (100.64.0.0/10)
|
||||
- All file content E2E encrypted — public gateway reads are safe
|
||||
|
||||
### Live URLs
|
||||
- Gateway: https://ipfs.jeffemmett.com
|
||||
- rNotes: https://rnotes.online
|
||||
- Health: https://rnotes.online/api/health
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
---
|
||||
id: TASK-2
|
||||
title: Full-text search with GIN index
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-02-13 20:39'
|
||||
labels: []
|
||||
dependencies: []
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
PostgreSQL GIN index on Note table, search route using ts_vector/ts_query with ranked results and highlighted snippets
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
---
|
||||
id: TASK-3
|
||||
title: EncryptID CORS origins
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-02-13 20:39'
|
||||
labels: []
|
||||
dependencies: []
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Added rnotes.online, rfunds.online, rtrips.online, rnetwork.online to EncryptID allowed origins in rspace-online
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
---
|
||||
id: TASK-4
|
||||
title: File/image upload support
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-02-13 20:39'
|
||||
labels: []
|
||||
dependencies: []
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Upload API with 50MB limit, MIME validation, drag-and-drop FileUpload component, image preview and file download in note detail, Docker volume for persistent storage
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
---
|
||||
id: TASK-5
|
||||
title: TipTap WYSIWYG editor
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-02-13 20:39'
|
||||
labels: []
|
||||
dependencies: []
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Replaced Markdown textarea with TipTap rich text editor. Toolbar, keyboard shortcuts, task lists, links, images, code blocks. Code notes keep plain textarea.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
---
|
||||
id: TASK-6
|
||||
title: Web clipper browser extension
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-02-13 20:39'
|
||||
updated_date: '2026-02-13 22:17'
|
||||
labels: []
|
||||
dependencies: []
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Browser extension to clip web content directly into rNotes. Capture page title, URL, selected text, full page, or screenshot as CLIP/BOOKMARK notes.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Web clipper extension created at browser-extension/. Features: page/selection/link/image clipping, notebook dropdown, tags, context menus, EncryptID token auth. Signin page updated for ?extension=true token display.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
---
|
||||
id: TASK-7
|
||||
title: EncryptID auth integration
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-02-13 20:39'
|
||||
updated_date: '2026-02-13 21:20'
|
||||
labels: []
|
||||
dependencies: []
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Wire up EncryptID SDK for user authentication. JWT session management, user-scoped notes/notebooks. Currently the app has no auth - all data is shared.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
EncryptID auth integrated:
|
||||
- @encryptid/sdk installed as local file dep
|
||||
- Server auth: getAuthUser, requireAuth, getNotebookRole in src/lib/auth.ts
|
||||
- Client auth: authFetch wrapper with JWT token in src/lib/authFetch.ts
|
||||
- UI: AuthProvider, UserMenu, /auth/signin passkey page
|
||||
- All write routes protected (POST/PUT/DELETE require auth)
|
||||
- Read routes remain public
|
||||
- First-user auto-claims orphaned notebooks/notes
|
||||
- Ownership: notebook collaborator roles, note author verification
|
||||
- Build verified clean (Next.js 14.2.35)
|
||||
- Pushed to Gitea main branch
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
---
|
||||
id: TASK-8
|
||||
title: Markdown export/import
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-02-13 20:39'
|
||||
updated_date: '2026-02-25 05:19'
|
||||
labels: []
|
||||
dependencies: []
|
||||
priority: low
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Export notes as .md files, import .md files as notes. Batch export notebooks as zip of markdown files.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Implemented plain Markdown export/import for rNotes.
|
||||
|
||||
## Export (`GET /api/export/markdown`)
|
||||
- **Single note**: `?noteId=<id>` returns a `.md` file with YAML frontmatter (type, tags, url, notebook, dates) and markdown body
|
||||
- **Batch**: Returns a ZIP archive of all user notes as `notes/*.md` + `attachments/*`
|
||||
- **Notebook filter**: `?notebookId=<id>` exports only that notebook's notes
|
||||
- Uses stored `bodyMarkdown` or converts from TipTap JSON via `tipTapJsonToMarkdown()`
|
||||
|
||||
## Import (`POST /api/import/markdown`)
|
||||
- Accepts multiple `.md` files and/or `.zip` archives via multipart form
|
||||
- Parses YAML frontmatter for metadata (type, tags, url, language, pinned)
|
||||
- Extracts title from first `# heading` or filename
|
||||
- Dual-write: converts markdown → TipTap JSON → HTML for full format coverage
|
||||
- Creates tags automatically via upsert
|
||||
- ZIP imports also handle `attachments/` and `assets/` directories
|
||||
- Optional `notebookId` form field to assign imported notes to a notebook
|
||||
|
||||
## Files
|
||||
- `src/app/api/export/markdown/route.ts` (new)
|
||||
- `src/app/api/import/markdown/route.ts` (new)
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
---
|
||||
id: TASK-9
|
||||
title: Canvas sync for notes
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-02-13 20:39'
|
||||
updated_date: '2026-02-13 22:04'
|
||||
labels: []
|
||||
dependencies: []
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Test and verify bidirectional canvas sync with rSpace: creating canvas from notebook, pinning notes to canvas, receiving shape updates.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Canvas sync callback wired up (onShapeUpdate → /api/sync). BLOCKED: rSpace /api/communities/:slug/shapes endpoint does not exist yet — pushShapesToCanvas will 404. Inbound sync works via postMessage.
|
||||
|
||||
rSpace shapes endpoint implemented and deployed. Internal API key for service-to-service auth. Canvas sync now functional: rnotes pushShapesToCanvas → rSpace POST /api/communities/:slug/shapes → Automerge doc update → WebSocket broadcast.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
|
@ -1,315 +0,0 @@
|
|||
const DEFAULT_HOST = 'https://rnotes.online';
|
||||
|
||||
// --- Context Menu Setup ---
|
||||
|
||||
chrome.runtime.onInstalled.addListener(() => {
|
||||
chrome.contextMenus.create({
|
||||
id: 'clip-page',
|
||||
title: 'Clip page to rNotes',
|
||||
contexts: ['page'],
|
||||
});
|
||||
|
||||
chrome.contextMenus.create({
|
||||
id: 'save-link',
|
||||
title: 'Save link to rNotes',
|
||||
contexts: ['link'],
|
||||
});
|
||||
|
||||
chrome.contextMenus.create({
|
||||
id: 'save-image',
|
||||
title: 'Save image to rNotes',
|
||||
contexts: ['image'],
|
||||
});
|
||||
|
||||
chrome.contextMenus.create({
|
||||
id: 'clip-selection',
|
||||
title: 'Clip selection to rNotes',
|
||||
contexts: ['selection'],
|
||||
});
|
||||
|
||||
chrome.contextMenus.create({
|
||||
id: 'unlock-article',
|
||||
title: 'Unlock & Clip article to rNotes',
|
||||
contexts: ['page', 'link'],
|
||||
});
|
||||
});
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
async function getSettings() {
|
||||
const result = await chrome.storage.sync.get(['rnotesHost']);
|
||||
return {
|
||||
host: result.rnotesHost || DEFAULT_HOST,
|
||||
};
|
||||
}
|
||||
|
||||
async function getToken() {
|
||||
const result = await chrome.storage.local.get(['encryptid_token']);
|
||||
return result.encryptid_token || null;
|
||||
}
|
||||
|
||||
async function getDefaultNotebook() {
|
||||
const result = await chrome.storage.local.get(['lastNotebookId']);
|
||||
return result.lastNotebookId || null;
|
||||
}
|
||||
|
||||
function showNotification(title, message) {
|
||||
chrome.notifications.create({
|
||||
type: 'basic',
|
||||
iconUrl: 'icons/icon-128.png',
|
||||
title: title,
|
||||
message: message,
|
||||
});
|
||||
}
|
||||
|
||||
async function createNote(data) {
|
||||
const token = await getToken();
|
||||
if (!token) {
|
||||
showNotification('rNotes Error', 'Not signed in. Open extension settings to sign in.');
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = await getSettings();
|
||||
const notebookId = await getDefaultNotebook();
|
||||
|
||||
const body = {
|
||||
title: data.title,
|
||||
content: data.content,
|
||||
type: data.type || 'CLIP',
|
||||
url: data.url,
|
||||
};
|
||||
|
||||
if (notebookId) body.notebookId = notebookId;
|
||||
if (data.fileUrl) body.fileUrl = data.fileUrl;
|
||||
if (data.mimeType) body.mimeType = data.mimeType;
|
||||
if (data.fileSize) body.fileSize = data.fileSize;
|
||||
|
||||
const response = await fetch(`${settings.host}/api/notes`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`${response.status}: ${text}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function uploadImage(imageUrl) {
|
||||
const token = await getToken();
|
||||
const settings = await getSettings();
|
||||
|
||||
// Fetch the image
|
||||
const imgResponse = await fetch(imageUrl);
|
||||
const blob = await imgResponse.blob();
|
||||
|
||||
// Extract filename
|
||||
let filename;
|
||||
try {
|
||||
const urlPath = new URL(imageUrl).pathname;
|
||||
filename = urlPath.split('/').pop() || `image-${Date.now()}.jpg`;
|
||||
} catch {
|
||||
filename = `image-${Date.now()}.jpg`;
|
||||
}
|
||||
|
||||
// Upload to rNotes
|
||||
const formData = new FormData();
|
||||
formData.append('file', blob, filename);
|
||||
|
||||
const response = await fetch(`${settings.host}/api/uploads`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Upload failed: ${response.status} ${text}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function unlockArticle(url) {
|
||||
const token = await getToken();
|
||||
if (!token) {
|
||||
showNotification('rNotes Error', 'Not signed in. Open extension settings to sign in.');
|
||||
return null;
|
||||
}
|
||||
|
||||
const settings = await getSettings();
|
||||
const response = await fetch(`${settings.host}/api/articles/unlock`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ url }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Unlock failed: ${response.status} ${text}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// --- Context Menu Handler ---
|
||||
|
||||
chrome.contextMenus.onClicked.addListener(async (info, tab) => {
|
||||
try {
|
||||
switch (info.menuItemId) {
|
||||
case 'clip-page': {
|
||||
// Get page HTML
|
||||
let content = '';
|
||||
try {
|
||||
const [result] = await chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: () => document.body.innerHTML,
|
||||
});
|
||||
content = result?.result || '';
|
||||
} catch {
|
||||
content = `<p>Clipped from <a href="${tab.url}">${tab.url}</a></p>`;
|
||||
}
|
||||
|
||||
await createNote({
|
||||
title: tab.title || 'Untitled Clip',
|
||||
content: content,
|
||||
type: 'CLIP',
|
||||
url: tab.url,
|
||||
});
|
||||
|
||||
showNotification('Page Clipped', `"${tab.title}" saved to rNotes`);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'save-link': {
|
||||
const linkUrl = info.linkUrl;
|
||||
const linkText = info.selectionText || linkUrl;
|
||||
|
||||
await createNote({
|
||||
title: linkText,
|
||||
content: `<p><a href="${linkUrl}">${linkText}</a></p><p>Found on: <a href="${tab.url}">${tab.title}</a></p>`,
|
||||
type: 'BOOKMARK',
|
||||
url: linkUrl,
|
||||
});
|
||||
|
||||
showNotification('Link Saved', `Bookmark saved to rNotes`);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'save-image': {
|
||||
const imageUrl = info.srcUrl;
|
||||
|
||||
// Upload the image first
|
||||
const upload = await uploadImage(imageUrl);
|
||||
|
||||
// Create IMAGE note with file reference
|
||||
await createNote({
|
||||
title: `Image from ${tab.title || 'page'}`,
|
||||
content: `<p><img src="${upload.url}" alt="Clipped image" /></p><p>Source: <a href="${tab.url}">${tab.title}</a></p>`,
|
||||
type: 'IMAGE',
|
||||
url: tab.url,
|
||||
fileUrl: upload.url,
|
||||
mimeType: upload.mimeType,
|
||||
fileSize: upload.size,
|
||||
});
|
||||
|
||||
showNotification('Image Saved', `Image saved to rNotes`);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'unlock-article': {
|
||||
const targetUrl = info.linkUrl || tab.url;
|
||||
showNotification('Unlocking Article', `Finding readable version of ${new URL(targetUrl).hostname}...`);
|
||||
|
||||
const result = await unlockArticle(targetUrl);
|
||||
if (result && result.success && result.archiveUrl) {
|
||||
// Create a CLIP note with the archive URL
|
||||
await createNote({
|
||||
title: tab.title || 'Unlocked Article',
|
||||
content: `<p>Unlocked via ${result.strategy}</p><p>Original: <a href="${targetUrl}">${targetUrl}</a></p><p>Archive: <a href="${result.archiveUrl}">${result.archiveUrl}</a></p>`,
|
||||
type: 'CLIP',
|
||||
url: targetUrl,
|
||||
});
|
||||
showNotification('Article Unlocked', `Readable version found via ${result.strategy}`);
|
||||
// Open the unlocked article in a new tab
|
||||
chrome.tabs.create({ url: result.archiveUrl });
|
||||
} else {
|
||||
showNotification('Unlock Failed', result?.error || 'No archived version found');
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'clip-selection': {
|
||||
// Get selection HTML
|
||||
let content = '';
|
||||
try {
|
||||
const [result] = await chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: () => {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) return '';
|
||||
const range = selection.getRangeAt(0);
|
||||
const div = document.createElement('div');
|
||||
div.appendChild(range.cloneContents());
|
||||
return div.innerHTML;
|
||||
},
|
||||
});
|
||||
content = result?.result || '';
|
||||
} catch {
|
||||
content = `<p>${info.selectionText || ''}</p>`;
|
||||
}
|
||||
|
||||
if (!content && info.selectionText) {
|
||||
content = `<p>${info.selectionText}</p>`;
|
||||
}
|
||||
|
||||
await createNote({
|
||||
title: `Selection from ${tab.title || 'page'}`,
|
||||
content: content,
|
||||
type: 'CLIP',
|
||||
url: tab.url,
|
||||
});
|
||||
|
||||
showNotification('Selection Clipped', `Saved to rNotes`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Context menu action failed:', err);
|
||||
showNotification('rNotes Error', err.message || 'Failed to save');
|
||||
}
|
||||
});
|
||||
|
||||
// --- Keyboard shortcut handler ---
|
||||
|
||||
chrome.commands.onCommand.addListener(async (command) => {
|
||||
if (command === 'open-voice-recorder') {
|
||||
const settings = await getSettings();
|
||||
chrome.windows.create({
|
||||
url: `${settings.host}/voice`,
|
||||
type: 'popup',
|
||||
width: 400,
|
||||
height: 600,
|
||||
focused: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// --- Message Handler (from popup) ---
|
||||
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
if (message.type === 'notify') {
|
||||
showNotification(message.title, message.message);
|
||||
}
|
||||
});
|
||||
|
Before Width: | Height: | Size: 837 B |
|
Before Width: | Height: | Size: 185 B |
|
Before Width: | Height: | Size: 349 B |
|
|
@ -1,50 +0,0 @@
|
|||
{
|
||||
"manifest_version": 3,
|
||||
"name": "rNotes Web Clipper & Voice",
|
||||
"version": "1.1.0",
|
||||
"description": "Clip pages, text, links, and images to rNotes.online. Record voice notes with transcription.",
|
||||
"permissions": [
|
||||
"activeTab",
|
||||
"contextMenus",
|
||||
"storage",
|
||||
"notifications",
|
||||
"offscreen"
|
||||
],
|
||||
"host_permissions": [
|
||||
"https://rnotes.online/*",
|
||||
"https://auth.ridentity.online/*",
|
||||
"*://*/*"
|
||||
],
|
||||
"action": {
|
||||
"default_popup": "popup.html",
|
||||
"default_icon": {
|
||||
"16": "icons/icon-16.png",
|
||||
"48": "icons/icon-48.png",
|
||||
"128": "icons/icon-128.png"
|
||||
}
|
||||
},
|
||||
"icons": {
|
||||
"16": "icons/icon-16.png",
|
||||
"48": "icons/icon-48.png",
|
||||
"128": "icons/icon-128.png"
|
||||
},
|
||||
"background": {
|
||||
"service_worker": "background.js"
|
||||
},
|
||||
"options_ui": {
|
||||
"page": "options.html",
|
||||
"open_in_tab": false
|
||||
},
|
||||
"content_security_policy": {
|
||||
"extension_pages": "script-src 'self' https://esm.sh; object-src 'self'"
|
||||
},
|
||||
"commands": {
|
||||
"open-voice-recorder": {
|
||||
"suggested_key": {
|
||||
"default": "Ctrl+Shift+V",
|
||||
"mac": "Command+Shift+V"
|
||||
},
|
||||
"description": "Open rVoice recorder"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,231 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
width: 400px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #0a0a0a;
|
||||
color: #e5e5e5;
|
||||
padding: 16px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 16px;
|
||||
color: #f59e0b;
|
||||
margin-bottom: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 16px;
|
||||
padding: 12px;
|
||||
background: #171717;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #262626;
|
||||
}
|
||||
|
||||
.section h3 {
|
||||
font-size: 13px;
|
||||
color: #d4d4d4;
|
||||
margin-bottom: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.field {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.field:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: #a3a3a3;
|
||||
margin-bottom: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
input[type="text"], input[type="password"], textarea {
|
||||
width: 100%;
|
||||
padding: 7px 10px;
|
||||
background: #0a0a0a;
|
||||
border: 1px solid #404040;
|
||||
border-radius: 4px;
|
||||
color: #e5e5e5;
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
}
|
||||
input:focus, textarea:focus {
|
||||
border-color: #f59e0b;
|
||||
}
|
||||
textarea {
|
||||
resize: vertical;
|
||||
min-height: 60px;
|
||||
font-family: 'SF Mono', 'Consolas', monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
select {
|
||||
width: 100%;
|
||||
padding: 7px 10px;
|
||||
background: #0a0a0a;
|
||||
border: 1px solid #404040;
|
||||
border-radius: 4px;
|
||||
color: #e5e5e5;
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.help {
|
||||
font-size: 10px;
|
||||
color: #737373;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.auth-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.auth-status.authed {
|
||||
background: #052e16;
|
||||
border: 1px solid #166534;
|
||||
color: #4ade80;
|
||||
}
|
||||
.auth-status.not-authed {
|
||||
background: #451a03;
|
||||
border: 1px solid #78350f;
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.btn-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 7px 14px;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
button:hover { opacity: 0.85; }
|
||||
button:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
|
||||
.btn-primary {
|
||||
background: #f59e0b;
|
||||
color: #0a0a0a;
|
||||
}
|
||||
.btn-secondary {
|
||||
background: #262626;
|
||||
color: #e5e5e5;
|
||||
border: 1px solid #404040;
|
||||
}
|
||||
.btn-danger {
|
||||
background: #991b1b;
|
||||
color: #fca5a5;
|
||||
}
|
||||
.btn-small {
|
||||
padding: 5px 10px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.status {
|
||||
margin-top: 12px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
display: none;
|
||||
}
|
||||
.status.success {
|
||||
background: #052e16;
|
||||
border: 1px solid #166534;
|
||||
color: #4ade80;
|
||||
display: block;
|
||||
}
|
||||
.status.error {
|
||||
background: #450a0a;
|
||||
border: 1px solid #991b1b;
|
||||
color: #fca5a5;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h2>rNotes Web Clipper Settings</h2>
|
||||
|
||||
<!-- Connection -->
|
||||
<div class="section">
|
||||
<h3>Connection</h3>
|
||||
<div class="field">
|
||||
<label for="host">rNotes URL</label>
|
||||
<input type="text" id="host" value="https://rnotes.online" />
|
||||
<div class="help">The URL of your rNotes instance</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Authentication -->
|
||||
<div class="section">
|
||||
<h3>Authentication</h3>
|
||||
<div id="authStatus" class="auth-status not-authed">
|
||||
Not signed in
|
||||
</div>
|
||||
|
||||
<div id="loginSection">
|
||||
<div class="field">
|
||||
<label>Step 1: Sign in on rNotes</label>
|
||||
<button class="btn-secondary btn-small" id="openSigninBtn">Open rNotes Sign-in</button>
|
||||
<div class="help">Opens rNotes in a new tab. Sign in with your passkey.</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="tokenInput">Step 2: Paste your token</label>
|
||||
<textarea id="tokenInput" placeholder="Paste your token from the rNotes sign-in page here..."></textarea>
|
||||
<div class="help">After signing in, copy the extension token and paste it here.</div>
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn-primary" id="saveTokenBtn">Save Token</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="loggedInSection" style="display: none;">
|
||||
<button class="btn-danger btn-small" id="logoutBtn">Logout</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Default Notebook -->
|
||||
<div class="section">
|
||||
<h3>Default Notebook</h3>
|
||||
<div class="field">
|
||||
<label for="defaultNotebook">Save clips to</label>
|
||||
<select id="defaultNotebook">
|
||||
<option value="">No default (choose each time)</option>
|
||||
</select>
|
||||
<div class="help">Pre-selected notebook when clipping</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="btn-row" style="justify-content: flex-end;">
|
||||
<button class="btn-secondary" id="testBtn">Test Connection</button>
|
||||
<button class="btn-primary" id="saveBtn">Save Settings</button>
|
||||
</div>
|
||||
|
||||
<div id="status" class="status"></div>
|
||||
|
||||
<script src="options.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,179 +0,0 @@
|
|||
const DEFAULT_HOST = 'https://rnotes.online';
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function decodeToken(token) {
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||
if (payload.exp && payload.exp * 1000 < Date.now()) {
|
||||
return null;
|
||||
}
|
||||
return payload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function showStatus(message, type) {
|
||||
const el = document.getElementById('status');
|
||||
el.textContent = message;
|
||||
el.className = `status ${type}`;
|
||||
if (type === 'success') {
|
||||
setTimeout(() => { el.className = 'status'; }, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Auth UI ---
|
||||
|
||||
async function updateAuthUI() {
|
||||
const { encryptid_token } = await chrome.storage.local.get(['encryptid_token']);
|
||||
const claims = encryptid_token ? decodeToken(encryptid_token) : null;
|
||||
|
||||
const authStatus = document.getElementById('authStatus');
|
||||
const loginSection = document.getElementById('loginSection');
|
||||
const loggedInSection = document.getElementById('loggedInSection');
|
||||
|
||||
if (claims) {
|
||||
const username = claims.username || claims.sub?.slice(0, 20) || 'Authenticated';
|
||||
authStatus.textContent = `Signed in as ${username}`;
|
||||
authStatus.className = 'auth-status authed';
|
||||
loginSection.style.display = 'none';
|
||||
loggedInSection.style.display = 'block';
|
||||
} else {
|
||||
authStatus.textContent = 'Not signed in';
|
||||
authStatus.className = 'auth-status not-authed';
|
||||
loginSection.style.display = 'block';
|
||||
loggedInSection.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
async function populateNotebooks() {
|
||||
const { encryptid_token } = await chrome.storage.local.get(['encryptid_token']);
|
||||
if (!encryptid_token) return;
|
||||
|
||||
const host = document.getElementById('host').value.replace(/\/+$/, '') || DEFAULT_HOST;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${host}/api/notebooks`, {
|
||||
headers: { 'Authorization': `Bearer ${encryptid_token}` },
|
||||
});
|
||||
|
||||
if (!response.ok) return;
|
||||
|
||||
const notebooks = await response.json();
|
||||
const select = document.getElementById('defaultNotebook');
|
||||
|
||||
// Clear existing options (keep first)
|
||||
while (select.options.length > 1) {
|
||||
select.remove(1);
|
||||
}
|
||||
|
||||
for (const nb of notebooks) {
|
||||
const option = document.createElement('option');
|
||||
option.value = nb.id;
|
||||
option.textContent = nb.title;
|
||||
select.appendChild(option);
|
||||
}
|
||||
|
||||
// Restore saved default
|
||||
const { lastNotebookId } = await chrome.storage.local.get(['lastNotebookId']);
|
||||
if (lastNotebookId) {
|
||||
select.value = lastNotebookId;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load notebooks:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Load settings ---
|
||||
|
||||
async function loadSettings() {
|
||||
const result = await chrome.storage.sync.get(['rnotesHost']);
|
||||
document.getElementById('host').value = result.rnotesHost || DEFAULT_HOST;
|
||||
|
||||
await updateAuthUI();
|
||||
await populateNotebooks();
|
||||
}
|
||||
|
||||
// --- Event handlers ---
|
||||
|
||||
// Open rNotes sign-in
|
||||
document.getElementById('openSigninBtn').addEventListener('click', () => {
|
||||
const host = document.getElementById('host').value.replace(/\/+$/, '') || DEFAULT_HOST;
|
||||
chrome.tabs.create({ url: `${host}/auth/signin?extension=true` });
|
||||
});
|
||||
|
||||
// Save token
|
||||
document.getElementById('saveTokenBtn').addEventListener('click', async () => {
|
||||
const tokenInput = document.getElementById('tokenInput').value.trim();
|
||||
|
||||
if (!tokenInput) {
|
||||
showStatus('Please paste a token', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const claims = decodeToken(tokenInput);
|
||||
if (!claims) {
|
||||
showStatus('Invalid or expired token', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
await chrome.storage.local.set({ encryptid_token: tokenInput });
|
||||
document.getElementById('tokenInput').value = '';
|
||||
|
||||
showStatus(`Signed in as ${claims.username || claims.sub}`, 'success');
|
||||
await updateAuthUI();
|
||||
await populateNotebooks();
|
||||
});
|
||||
|
||||
// Logout
|
||||
document.getElementById('logoutBtn').addEventListener('click', async () => {
|
||||
await chrome.storage.local.remove(['encryptid_token']);
|
||||
showStatus('Signed out', 'success');
|
||||
await updateAuthUI();
|
||||
});
|
||||
|
||||
// Save settings
|
||||
document.getElementById('saveBtn').addEventListener('click', async () => {
|
||||
const host = document.getElementById('host').value.trim().replace(/\/+$/, '');
|
||||
const notebookId = document.getElementById('defaultNotebook').value;
|
||||
|
||||
await chrome.storage.sync.set({ rnotesHost: host || DEFAULT_HOST });
|
||||
await chrome.storage.local.set({ lastNotebookId: notebookId });
|
||||
|
||||
showStatus('Settings saved', 'success');
|
||||
});
|
||||
|
||||
// Test connection
|
||||
document.getElementById('testBtn').addEventListener('click', async () => {
|
||||
const host = document.getElementById('host').value.trim().replace(/\/+$/, '') || DEFAULT_HOST;
|
||||
const { encryptid_token } = await chrome.storage.local.get(['encryptid_token']);
|
||||
|
||||
try {
|
||||
const headers = {};
|
||||
if (encryptid_token) {
|
||||
headers['Authorization'] = `Bearer ${encryptid_token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${host}/api/notebooks`, { headers });
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
showStatus(`Connected! Found ${data.length || 0} notebooks.`, 'success');
|
||||
} else if (response.status === 401) {
|
||||
showStatus('Connected but not authenticated. Sign in first.', 'error');
|
||||
} else {
|
||||
showStatus(`Connection failed: ${response.status}`, 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
showStatus(`Cannot connect: ${err.message}`, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
// Default notebook change
|
||||
document.getElementById('defaultNotebook').addEventListener('change', async (e) => {
|
||||
await chrome.storage.local.set({ lastNotebookId: e.target.value });
|
||||
});
|
||||
|
||||
// Init
|
||||
document.addEventListener('DOMContentLoaded', loadSettings);
|
||||
|
|
@ -1,147 +0,0 @@
|
|||
/**
|
||||
* Offline transcription using parakeet.js (NVIDIA Parakeet TDT 0.6B v2).
|
||||
* Loaded at runtime from CDN. Model ~634 MB (int8) on first download,
|
||||
* cached in IndexedDB after. Works fully offline after first download.
|
||||
*
|
||||
* Port of src/lib/parakeetOffline.ts for the browser extension.
|
||||
*/
|
||||
|
||||
const CACHE_KEY = 'parakeet-offline-cached';
|
||||
|
||||
// Singleton model — don't reload on subsequent calls
|
||||
let cachedModel = null;
|
||||
let loadingPromise = null;
|
||||
|
||||
/**
|
||||
* Check if the Parakeet model has been downloaded before.
|
||||
*/
|
||||
function isModelCached() {
|
||||
try {
|
||||
return localStorage.getItem(CACHE_KEY) === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect WebGPU availability.
|
||||
*/
|
||||
async function detectWebGPU() {
|
||||
if (!navigator.gpu) return false;
|
||||
try {
|
||||
const adapter = await navigator.gpu.requestAdapter();
|
||||
return !!adapter;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create the Parakeet model singleton.
|
||||
* @param {function} onProgress - callback({ status, progress, file, message })
|
||||
*/
|
||||
async function getModel(onProgress) {
|
||||
if (cachedModel) return cachedModel;
|
||||
if (loadingPromise) return loadingPromise;
|
||||
|
||||
loadingPromise = (async () => {
|
||||
onProgress?.({ status: 'loading', message: 'Loading Parakeet model...' });
|
||||
|
||||
// Dynamic import from CDN at runtime
|
||||
const { fromHub } = await import('https://esm.sh/parakeet.js@1.1.2');
|
||||
|
||||
const backend = (await detectWebGPU()) ? 'webgpu' : 'wasm';
|
||||
const fileProgress = {};
|
||||
|
||||
const model = await fromHub('parakeet-tdt-0.6b-v2', {
|
||||
backend,
|
||||
progress: ({ file, loaded, total }) => {
|
||||
fileProgress[file] = { loaded, total };
|
||||
|
||||
let totalBytes = 0;
|
||||
let loadedBytes = 0;
|
||||
for (const fp of Object.values(fileProgress)) {
|
||||
totalBytes += fp.total || 0;
|
||||
loadedBytes += fp.loaded || 0;
|
||||
}
|
||||
|
||||
if (totalBytes > 0) {
|
||||
const pct = Math.round((loadedBytes / totalBytes) * 100);
|
||||
onProgress?.({
|
||||
status: 'downloading',
|
||||
progress: pct,
|
||||
file,
|
||||
message: `Downloading model... ${pct}%`,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
localStorage.setItem(CACHE_KEY, 'true');
|
||||
onProgress?.({ status: 'loading', message: 'Model loaded' });
|
||||
|
||||
cachedModel = model;
|
||||
loadingPromise = null;
|
||||
return model;
|
||||
})();
|
||||
|
||||
return loadingPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode an audio Blob to Float32Array at 16 kHz mono.
|
||||
*/
|
||||
async function decodeAudioBlob(blob) {
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
const audioCtx = new AudioContext({ sampleRate: 16000 });
|
||||
try {
|
||||
const audioBuffer = await audioCtx.decodeAudioData(arrayBuffer);
|
||||
|
||||
if (audioBuffer.sampleRate === 16000 && audioBuffer.numberOfChannels === 1) {
|
||||
return audioBuffer.getChannelData(0);
|
||||
}
|
||||
|
||||
// Resample via OfflineAudioContext
|
||||
const numSamples = Math.ceil(audioBuffer.duration * 16000);
|
||||
const offlineCtx = new OfflineAudioContext(1, numSamples, 16000);
|
||||
const source = offlineCtx.createBufferSource();
|
||||
source.buffer = audioBuffer;
|
||||
source.connect(offlineCtx.destination);
|
||||
source.start();
|
||||
const resampled = await offlineCtx.startRendering();
|
||||
return resampled.getChannelData(0);
|
||||
} finally {
|
||||
await audioCtx.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transcribe an audio Blob offline using Parakeet in the browser.
|
||||
* First call downloads the model (~634 MB). Subsequent calls use cached.
|
||||
*
|
||||
* @param {Blob} audioBlob
|
||||
* @param {function} onProgress - callback({ status, progress, file, message })
|
||||
* @returns {Promise<string>} transcribed text
|
||||
*/
|
||||
async function transcribeOffline(audioBlob, onProgress) {
|
||||
const model = await getModel(onProgress);
|
||||
|
||||
onProgress?.({ status: 'transcribing', message: 'Transcribing audio...' });
|
||||
|
||||
const audioData = await decodeAudioBlob(audioBlob);
|
||||
|
||||
const result = await model.transcribe(audioData, 16000, {
|
||||
returnTimestamps: false,
|
||||
enableProfiling: false,
|
||||
});
|
||||
|
||||
const text = result.utterance_text?.trim() || '';
|
||||
onProgress?.({ status: 'done', message: 'Transcription complete' });
|
||||
return text;
|
||||
}
|
||||
|
||||
// Export for use in voice.js (loaded as ES module)
|
||||
window.ParakeetOffline = {
|
||||
isModelCached,
|
||||
transcribeOffline,
|
||||
};
|
||||
|
|
@ -1,262 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
width: 340px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #0a0a0a;
|
||||
color: #e5e5e5;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
background: #171717;
|
||||
border-bottom: 1px solid #262626;
|
||||
}
|
||||
.header .brand {
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
color: #f59e0b;
|
||||
}
|
||||
.header .user {
|
||||
font-size: 11px;
|
||||
color: #a3a3a3;
|
||||
}
|
||||
.header .user.not-authed {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.auth-warning {
|
||||
padding: 10px 14px;
|
||||
background: #451a03;
|
||||
border-bottom: 1px solid #78350f;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #fbbf24;
|
||||
}
|
||||
.auth-warning a {
|
||||
color: #f59e0b;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.current-page {
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid #262626;
|
||||
}
|
||||
.current-page .title {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.current-page .url {
|
||||
font-size: 11px;
|
||||
color: #737373;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.controls {
|
||||
padding: 10px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: #a3a3a3;
|
||||
margin-bottom: 3px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
select, input[type="text"] {
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
background: #171717;
|
||||
border: 1px solid #404040;
|
||||
border-radius: 4px;
|
||||
color: #e5e5e5;
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
}
|
||||
select:focus, input[type="text"]:focus {
|
||||
border-color: #f59e0b;
|
||||
}
|
||||
|
||||
.actions {
|
||||
padding: 0 14px 10px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
button {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
button:hover:not(:disabled) {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #f59e0b;
|
||||
color: #0a0a0a;
|
||||
}
|
||||
.btn-secondary {
|
||||
background: #262626;
|
||||
color: #e5e5e5;
|
||||
border: 1px solid #404040;
|
||||
}
|
||||
.btn-voice {
|
||||
background: #450a0a;
|
||||
color: #fca5a5;
|
||||
border: 1px solid #991b1b;
|
||||
}
|
||||
.btn-voice svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-unlock {
|
||||
background: #172554;
|
||||
color: #93c5fd;
|
||||
border: 1px solid #1e40af;
|
||||
}
|
||||
.btn-unlock svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status {
|
||||
margin: 0 14px 10px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
display: none;
|
||||
}
|
||||
.status.success {
|
||||
background: #052e16;
|
||||
border: 1px solid #166534;
|
||||
color: #4ade80;
|
||||
display: block;
|
||||
}
|
||||
.status.error {
|
||||
background: #450a0a;
|
||||
border: 1px solid #991b1b;
|
||||
color: #fca5a5;
|
||||
display: block;
|
||||
}
|
||||
.status.loading {
|
||||
background: #172554;
|
||||
border: 1px solid #1e40af;
|
||||
color: #93c5fd;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding: 8px 14px;
|
||||
border-top: 1px solid #262626;
|
||||
text-align: center;
|
||||
}
|
||||
.footer a {
|
||||
color: #737373;
|
||||
text-decoration: none;
|
||||
font-size: 11px;
|
||||
}
|
||||
.footer a:hover {
|
||||
color: #f59e0b;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<span class="brand">rNotes Clipper</span>
|
||||
<span class="user" id="userStatus">...</span>
|
||||
</div>
|
||||
|
||||
<div id="authWarning" class="auth-warning" style="display: none;">
|
||||
Sign in to clip pages. <a id="openSettings">Open Settings</a>
|
||||
</div>
|
||||
|
||||
<div class="current-page">
|
||||
<div class="title" id="pageTitle">Loading...</div>
|
||||
<div class="url" id="pageUrl"></div>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<div>
|
||||
<label for="notebook">Notebook</label>
|
||||
<select id="notebook">
|
||||
<option value="">No notebook</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="tags">Tags (comma-separated)</label>
|
||||
<input type="text" id="tags" placeholder="web-clip, research, ..." />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button class="btn-primary" id="clipPageBtn" disabled>
|
||||
<span>+</span> Clip Page
|
||||
</button>
|
||||
<button class="btn-secondary" id="clipSelectionBtn" disabled>
|
||||
<span>T</span> Clip Selection
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button class="btn-voice" id="voiceBtn" disabled>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"></path>
|
||||
<path d="M19 10v2a7 7 0 0 1-14 0v-2"></path>
|
||||
<line x1="12" y1="19" x2="12" y2="23"></line>
|
||||
<line x1="8" y1="23" x2="16" y2="23"></line>
|
||||
</svg>
|
||||
Voice Note
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button class="btn-unlock" id="unlockBtn" disabled>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
|
||||
<path d="M7 11V7a5 5 0 0 1 9.9-1"></path>
|
||||
</svg>
|
||||
Unlock Article
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="status" class="status"></div>
|
||||
|
||||
<div class="footer">
|
||||
<a href="#" id="optionsLink">Settings</a>
|
||||
</div>
|
||||
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,328 +0,0 @@
|
|||
const DEFAULT_HOST = 'https://rnotes.online';
|
||||
|
||||
let currentTab = null;
|
||||
let selectedText = '';
|
||||
let selectedHtml = '';
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
async function getSettings() {
|
||||
const result = await chrome.storage.sync.get(['rnotesHost']);
|
||||
return {
|
||||
host: result.rnotesHost || DEFAULT_HOST,
|
||||
};
|
||||
}
|
||||
|
||||
async function getToken() {
|
||||
const result = await chrome.storage.local.get(['encryptid_token']);
|
||||
return result.encryptid_token || null;
|
||||
}
|
||||
|
||||
function decodeToken(token) {
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||
// Check expiry
|
||||
if (payload.exp && payload.exp * 1000 < Date.now()) {
|
||||
return null; // expired
|
||||
}
|
||||
return payload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseTags(tagString) {
|
||||
if (!tagString || !tagString.trim()) return [];
|
||||
return tagString.split(',').map(t => t.trim().toLowerCase()).filter(Boolean);
|
||||
}
|
||||
|
||||
function showStatus(message, type) {
|
||||
const el = document.getElementById('status');
|
||||
el.textContent = message;
|
||||
el.className = `status ${type}`;
|
||||
if (type === 'success') {
|
||||
setTimeout(() => { el.className = 'status'; }, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
// --- API calls ---
|
||||
|
||||
async function createNote(data) {
|
||||
const token = await getToken();
|
||||
const settings = await getSettings();
|
||||
|
||||
const body = {
|
||||
title: data.title,
|
||||
content: data.content,
|
||||
type: data.type || 'CLIP',
|
||||
url: data.url,
|
||||
};
|
||||
|
||||
const notebookId = document.getElementById('notebook').value;
|
||||
if (notebookId) body.notebookId = notebookId;
|
||||
|
||||
const tags = parseTags(document.getElementById('tags').value);
|
||||
if (tags.length > 0) body.tags = tags;
|
||||
|
||||
const response = await fetch(`${settings.host}/api/notes`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`${response.status}: ${text}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function fetchNotebooks() {
|
||||
const token = await getToken();
|
||||
const settings = await getSettings();
|
||||
|
||||
const response = await fetch(`${settings.host}/api/notebooks`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) return [];
|
||||
const data = await response.json();
|
||||
return Array.isArray(data) ? data : [];
|
||||
}
|
||||
|
||||
// --- UI ---
|
||||
|
||||
async function populateNotebooks() {
|
||||
const select = document.getElementById('notebook');
|
||||
try {
|
||||
const notebooks = await fetchNotebooks();
|
||||
// Keep the "No notebook" option
|
||||
for (const nb of notebooks) {
|
||||
const option = document.createElement('option');
|
||||
option.value = nb.id;
|
||||
option.textContent = nb.title;
|
||||
select.appendChild(option);
|
||||
}
|
||||
|
||||
// Restore last used notebook
|
||||
const { lastNotebookId } = await chrome.storage.local.get(['lastNotebookId']);
|
||||
if (lastNotebookId) {
|
||||
select.value = lastNotebookId;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load notebooks:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Save last used notebook when changed
|
||||
function setupNotebookMemory() {
|
||||
document.getElementById('notebook').addEventListener('change', (e) => {
|
||||
chrome.storage.local.set({ lastNotebookId: e.target.value });
|
||||
});
|
||||
}
|
||||
|
||||
async function init() {
|
||||
// Get current tab
|
||||
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
currentTab = tab;
|
||||
|
||||
// Display page info
|
||||
document.getElementById('pageTitle').textContent = tab.title || 'Untitled';
|
||||
document.getElementById('pageUrl').textContent = tab.url || '';
|
||||
|
||||
// Check auth
|
||||
const token = await getToken();
|
||||
const claims = token ? decodeToken(token) : null;
|
||||
|
||||
if (!claims) {
|
||||
document.getElementById('userStatus').textContent = 'Not signed in';
|
||||
document.getElementById('userStatus').classList.add('not-authed');
|
||||
document.getElementById('authWarning').style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('userStatus').textContent = claims.username || claims.sub?.slice(0, 16) || 'Authenticated';
|
||||
document.getElementById('authWarning').style.display = 'none';
|
||||
|
||||
// Enable buttons
|
||||
document.getElementById('clipPageBtn').disabled = false;
|
||||
document.getElementById('unlockBtn').disabled = false;
|
||||
document.getElementById('voiceBtn').disabled = false;
|
||||
|
||||
// Load notebooks
|
||||
await populateNotebooks();
|
||||
setupNotebookMemory();
|
||||
|
||||
// Detect text selection
|
||||
try {
|
||||
const [result] = await chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: () => {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
|
||||
return { text: '', html: '' };
|
||||
}
|
||||
const range = selection.getRangeAt(0);
|
||||
const div = document.createElement('div');
|
||||
div.appendChild(range.cloneContents());
|
||||
return { text: selection.toString(), html: div.innerHTML };
|
||||
},
|
||||
});
|
||||
|
||||
if (result?.result?.text) {
|
||||
selectedText = result.result.text;
|
||||
selectedHtml = result.result.html;
|
||||
document.getElementById('clipSelectionBtn').disabled = false;
|
||||
}
|
||||
} catch (err) {
|
||||
// Can't access some pages (chrome://, etc.)
|
||||
console.warn('Cannot access page content:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Event handlers ---
|
||||
|
||||
document.getElementById('clipPageBtn').addEventListener('click', async () => {
|
||||
const btn = document.getElementById('clipPageBtn');
|
||||
btn.disabled = true;
|
||||
showStatus('Clipping page...', 'loading');
|
||||
|
||||
try {
|
||||
// Get page HTML content
|
||||
let pageContent = '';
|
||||
try {
|
||||
const [result] = await chrome.scripting.executeScript({
|
||||
target: { tabId: currentTab.id },
|
||||
func: () => document.body.innerHTML,
|
||||
});
|
||||
pageContent = result?.result || '';
|
||||
} catch {
|
||||
// Fallback: just use URL as content
|
||||
pageContent = `<p>Clipped from <a href="${currentTab.url}">${currentTab.url}</a></p>`;
|
||||
}
|
||||
|
||||
const note = await createNote({
|
||||
title: currentTab.title || 'Untitled Clip',
|
||||
content: pageContent,
|
||||
type: 'CLIP',
|
||||
url: currentTab.url,
|
||||
});
|
||||
|
||||
showStatus(`Clipped! Note saved.`, 'success');
|
||||
|
||||
// Notify background worker
|
||||
chrome.runtime.sendMessage({
|
||||
type: 'notify',
|
||||
title: 'Page Clipped',
|
||||
message: `"${currentTab.title}" saved to rNotes`,
|
||||
});
|
||||
} catch (err) {
|
||||
showStatus(`Error: ${err.message}`, 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('clipSelectionBtn').addEventListener('click', async () => {
|
||||
const btn = document.getElementById('clipSelectionBtn');
|
||||
btn.disabled = true;
|
||||
showStatus('Clipping selection...', 'loading');
|
||||
|
||||
try {
|
||||
const content = selectedHtml || `<p>${selectedText}</p>`;
|
||||
const note = await createNote({
|
||||
title: `Selection from ${currentTab.title || 'page'}`,
|
||||
content: content,
|
||||
type: 'CLIP',
|
||||
url: currentTab.url,
|
||||
});
|
||||
|
||||
showStatus(`Selection clipped!`, 'success');
|
||||
|
||||
chrome.runtime.sendMessage({
|
||||
type: 'notify',
|
||||
title: 'Selection Clipped',
|
||||
message: `Saved to rNotes`,
|
||||
});
|
||||
} catch (err) {
|
||||
showStatus(`Error: ${err.message}`, 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('unlockBtn').addEventListener('click', async () => {
|
||||
const btn = document.getElementById('unlockBtn');
|
||||
btn.disabled = true;
|
||||
showStatus('Unlocking article...', 'loading');
|
||||
|
||||
try {
|
||||
const token = await getToken();
|
||||
const settings = await getSettings();
|
||||
|
||||
const response = await fetch(`${settings.host}/api/articles/unlock`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ url: currentTab.url }),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success && result.archiveUrl) {
|
||||
// Also save as a note
|
||||
await createNote({
|
||||
title: currentTab.title || 'Unlocked Article',
|
||||
content: `<p>Unlocked via ${result.strategy}</p><p>Original: <a href="${currentTab.url}">${currentTab.url}</a></p><p>Archive: <a href="${result.archiveUrl}">${result.archiveUrl}</a></p>`,
|
||||
type: 'CLIP',
|
||||
url: currentTab.url,
|
||||
});
|
||||
|
||||
showStatus(`Unlocked via ${result.strategy}! Opening...`, 'success');
|
||||
|
||||
// Open archive in new tab
|
||||
chrome.tabs.create({ url: result.archiveUrl });
|
||||
} else {
|
||||
showStatus(result.error || 'No archived version found', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
showStatus(`Error: ${err.message}`, 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('voiceBtn').addEventListener('click', async () => {
|
||||
// Open rVoice PWA page in a popup window (supports PiP pop-out)
|
||||
const settings = await getSettings();
|
||||
chrome.windows.create({
|
||||
url: `${settings.host}/voice`,
|
||||
type: 'popup',
|
||||
width: 400,
|
||||
height: 600,
|
||||
focused: true,
|
||||
});
|
||||
// Close the current popup
|
||||
window.close();
|
||||
});
|
||||
|
||||
document.getElementById('optionsLink').addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
chrome.runtime.openOptionsPage();
|
||||
});
|
||||
|
||||
document.getElementById('openSettings')?.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
chrome.runtime.openOptionsPage();
|
||||
});
|
||||
|
||||
// Init on load
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
|
|
@ -1,414 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
width: 360px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #0a0a0a;
|
||||
color: #e5e5e5;
|
||||
font-size: 13px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 14px;
|
||||
background: #171717;
|
||||
border-bottom: 1px solid #262626;
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
.header .brand {
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
color: #ef4444;
|
||||
}
|
||||
.header .brand-sub {
|
||||
color: #a3a3a3;
|
||||
font-weight: 400;
|
||||
font-size: 12px;
|
||||
}
|
||||
.header .close-btn {
|
||||
-webkit-app-region: no-drag;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #737373;
|
||||
cursor: pointer;
|
||||
font-size: 18px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.header .close-btn:hover {
|
||||
color: #e5e5e5;
|
||||
background: #262626;
|
||||
}
|
||||
|
||||
.auth-warning {
|
||||
padding: 10px 14px;
|
||||
background: #451a03;
|
||||
border-bottom: 1px solid #78350f;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.recorder {
|
||||
padding: 20px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* Record button */
|
||||
.rec-btn {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid #404040;
|
||||
background: #171717;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
position: relative;
|
||||
}
|
||||
.rec-btn:hover {
|
||||
border-color: #ef4444;
|
||||
}
|
||||
.rec-btn .inner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: #ef4444;
|
||||
border-radius: 50%;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.rec-btn.recording {
|
||||
border-color: #ef4444;
|
||||
}
|
||||
.rec-btn.recording .inner {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
background: #ef4444;
|
||||
}
|
||||
.rec-btn.recording::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -6px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(239, 68, 68, 0.3);
|
||||
animation: pulse-ring 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-ring {
|
||||
0% { transform: scale(1); opacity: 1; }
|
||||
100% { transform: scale(1.15); opacity: 0; }
|
||||
}
|
||||
|
||||
.timer {
|
||||
font-size: 28px;
|
||||
font-family: 'SF Mono', 'Consolas', 'Courier New', monospace;
|
||||
font-weight: 600;
|
||||
color: #e5e5e5;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
.timer.recording {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.status-label {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.status-label.idle { color: #737373; }
|
||||
.status-label.recording { color: #ef4444; }
|
||||
.status-label.processing { color: #f59e0b; }
|
||||
.status-label.done { color: #4ade80; }
|
||||
|
||||
/* Transcript area */
|
||||
.transcript-area {
|
||||
width: 100%;
|
||||
padding: 0 14px 12px;
|
||||
display: none;
|
||||
}
|
||||
.transcript-area.visible {
|
||||
display: block;
|
||||
}
|
||||
.transcript-label {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: #737373;
|
||||
margin-bottom: 6px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.transcript-text {
|
||||
background: #171717;
|
||||
border: 1px solid #262626;
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: #d4d4d4;
|
||||
max-height: 120px;
|
||||
overflow-y: auto;
|
||||
min-height: 40px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.transcript-text.editable {
|
||||
outline: none;
|
||||
border-color: #404040;
|
||||
cursor: text;
|
||||
}
|
||||
.transcript-text.editable:focus {
|
||||
border-color: #f59e0b;
|
||||
}
|
||||
.transcript-text .placeholder {
|
||||
color: #525252;
|
||||
font-style: italic;
|
||||
}
|
||||
.transcript-text .final-text {
|
||||
color: #d4d4d4;
|
||||
}
|
||||
.transcript-text .interim-text {
|
||||
color: #737373;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Controls row */
|
||||
.controls {
|
||||
width: 100%;
|
||||
padding: 0 14px 10px;
|
||||
}
|
||||
.controls select {
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
background: #171717;
|
||||
border: 1px solid #404040;
|
||||
border-radius: 4px;
|
||||
color: #e5e5e5;
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
}
|
||||
.controls select:focus {
|
||||
border-color: #f59e0b;
|
||||
}
|
||||
.controls label {
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
color: #737373;
|
||||
margin-bottom: 3px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* Action buttons */
|
||||
.actions {
|
||||
width: 100%;
|
||||
padding: 0 14px 12px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.actions button {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.actions button:hover:not(:disabled) { opacity: 0.85; }
|
||||
.actions button:disabled { opacity: 0.35; cursor: not-allowed; }
|
||||
|
||||
.btn-save {
|
||||
background: #f59e0b;
|
||||
color: #0a0a0a;
|
||||
}
|
||||
.btn-discard {
|
||||
background: #262626;
|
||||
color: #a3a3a3;
|
||||
border: 1px solid #404040;
|
||||
}
|
||||
.btn-copy {
|
||||
background: #172554;
|
||||
color: #93c5fd;
|
||||
border: 1px solid #1e40af;
|
||||
}
|
||||
|
||||
/* Status bar */
|
||||
.status-bar {
|
||||
padding: 8px 14px;
|
||||
border-top: 1px solid #262626;
|
||||
font-size: 11px;
|
||||
color: #525252;
|
||||
text-align: center;
|
||||
display: none;
|
||||
}
|
||||
.status-bar.visible {
|
||||
display: block;
|
||||
}
|
||||
.status-bar.success { color: #4ade80; background: #052e16; border-top-color: #166534; }
|
||||
.status-bar.error { color: #fca5a5; background: #450a0a; border-top-color: #991b1b; }
|
||||
.status-bar.loading { color: #93c5fd; background: #172554; border-top-color: #1e40af; }
|
||||
|
||||
/* Live indicator */
|
||||
.live-indicator {
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.5px;
|
||||
color: #4ade80;
|
||||
}
|
||||
.live-indicator.visible {
|
||||
display: flex;
|
||||
}
|
||||
.live-indicator .dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #4ade80;
|
||||
animation: pulse-dot 1s infinite;
|
||||
}
|
||||
@keyframes pulse-dot {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
|
||||
/* Progress bar (for model download) */
|
||||
.progress-area {
|
||||
width: 100%;
|
||||
padding: 0 14px 8px;
|
||||
display: none;
|
||||
}
|
||||
.progress-area.visible {
|
||||
display: block;
|
||||
}
|
||||
.progress-label {
|
||||
font-size: 11px;
|
||||
color: #a3a3a3;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: #262626;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.progress-bar .fill {
|
||||
height: 100%;
|
||||
background: #f59e0b;
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s;
|
||||
width: 0%;
|
||||
}
|
||||
|
||||
/* Audio preview */
|
||||
.audio-preview {
|
||||
width: 100%;
|
||||
padding: 0 14px 8px;
|
||||
display: none;
|
||||
}
|
||||
.audio-preview.visible {
|
||||
display: block;
|
||||
}
|
||||
.audio-preview audio {
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
/* Keyboard hint */
|
||||
.kbd-hint {
|
||||
padding: 4px 14px 8px;
|
||||
text-align: center;
|
||||
font-size: 10px;
|
||||
color: #404040;
|
||||
}
|
||||
.kbd-hint kbd {
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
border-radius: 3px;
|
||||
padding: 1px 5px;
|
||||
font-family: inherit;
|
||||
font-size: 10px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<span>
|
||||
<span class="brand">rVoice</span>
|
||||
<span class="brand-sub">voice notes</span>
|
||||
</span>
|
||||
<button class="close-btn" id="closeBtn" title="Close">×</button>
|
||||
</div>
|
||||
|
||||
<div id="authWarning" class="auth-warning" style="display: none;">
|
||||
Sign in via rNotes Clipper settings first.
|
||||
</div>
|
||||
|
||||
<div class="recorder">
|
||||
<div class="status-label idle" id="statusLabel">Ready</div>
|
||||
<button class="rec-btn" id="recBtn" title="Start recording">
|
||||
<div class="inner"></div>
|
||||
</button>
|
||||
<div class="timer" id="timer">00:00</div>
|
||||
<div class="live-indicator" id="liveIndicator">
|
||||
<span class="dot"></span>
|
||||
Live transcribe
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="progress-area" id="progressArea">
|
||||
<div class="progress-label" id="progressLabel">Loading model...</div>
|
||||
<div class="progress-bar"><div class="fill" id="progressFill"></div></div>
|
||||
</div>
|
||||
|
||||
<div class="audio-preview" id="audioPreview">
|
||||
<audio controls id="audioPlayer"></audio>
|
||||
</div>
|
||||
|
||||
<div class="transcript-area" id="transcriptArea">
|
||||
<div class="transcript-label">Transcript</div>
|
||||
<div class="transcript-text editable" id="transcriptText" contenteditable="true">
|
||||
<span class="placeholder">Transcribing...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="controls" id="notebookControls">
|
||||
<label for="notebook">Save to notebook</label>
|
||||
<select id="notebook">
|
||||
<option value="">Default notebook</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="actions" id="postActions" style="display: none;">
|
||||
<button class="btn-discard" id="discardBtn">Discard</button>
|
||||
<button class="btn-copy" id="copyBtn" title="Copy transcript">Copy</button>
|
||||
<button class="btn-save" id="saveBtn">Save to rNotes</button>
|
||||
</div>
|
||||
|
||||
<div class="status-bar" id="statusBar"></div>
|
||||
|
||||
<div class="kbd-hint">
|
||||
<kbd>Space</kbd> to record · <kbd>Esc</kbd> to close · Offline ready
|
||||
</div>
|
||||
|
||||
<script src="parakeet-offline.js" type="module"></script>
|
||||
<script src="voice.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,610 +0,0 @@
|
|||
const DEFAULT_HOST = 'https://rnotes.online';
|
||||
|
||||
// --- State ---
|
||||
let state = 'idle'; // idle | recording | processing | done
|
||||
let mediaRecorder = null;
|
||||
let audioChunks = [];
|
||||
let timerInterval = null;
|
||||
let startTime = 0;
|
||||
let audioBlob = null;
|
||||
let audioUrl = null;
|
||||
let transcript = '';
|
||||
let liveTranscript = ''; // accumulated from Web Speech API
|
||||
let uploadedFileUrl = '';
|
||||
let uploadedMimeType = '';
|
||||
let uploadedFileSize = 0;
|
||||
let duration = 0;
|
||||
|
||||
// Web Speech API
|
||||
let recognition = null;
|
||||
let speechSupported = !!(window.SpeechRecognition || window.webkitSpeechRecognition);
|
||||
|
||||
// --- DOM refs ---
|
||||
const recBtn = document.getElementById('recBtn');
|
||||
const timerEl = document.getElementById('timer');
|
||||
const statusLabel = document.getElementById('statusLabel');
|
||||
const transcriptArea = document.getElementById('transcriptArea');
|
||||
const transcriptText = document.getElementById('transcriptText');
|
||||
const liveIndicator = document.getElementById('liveIndicator');
|
||||
const audioPreview = document.getElementById('audioPreview');
|
||||
const audioPlayer = document.getElementById('audioPlayer');
|
||||
const notebookSelect = document.getElementById('notebook');
|
||||
const postActions = document.getElementById('postActions');
|
||||
const saveBtn = document.getElementById('saveBtn');
|
||||
const discardBtn = document.getElementById('discardBtn');
|
||||
const copyBtn = document.getElementById('copyBtn');
|
||||
const statusBar = document.getElementById('statusBar');
|
||||
const authWarning = document.getElementById('authWarning');
|
||||
const closeBtn = document.getElementById('closeBtn');
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
async function getSettings() {
|
||||
const result = await chrome.storage.sync.get(['rnotesHost']);
|
||||
return { host: result.rnotesHost || DEFAULT_HOST };
|
||||
}
|
||||
|
||||
async function getToken() {
|
||||
const result = await chrome.storage.local.get(['encryptid_token']);
|
||||
return result.encryptid_token || null;
|
||||
}
|
||||
|
||||
function decodeToken(token) {
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||
if (payload.exp && payload.exp * 1000 < Date.now()) return null;
|
||||
return payload;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
function formatTime(seconds) {
|
||||
const m = Math.floor(seconds / 60).toString().padStart(2, '0');
|
||||
const s = (seconds % 60).toString().padStart(2, '0');
|
||||
return `${m}:${s}`;
|
||||
}
|
||||
|
||||
function setStatusLabel(text, cls) {
|
||||
statusLabel.textContent = text;
|
||||
statusLabel.className = `status-label ${cls}`;
|
||||
}
|
||||
|
||||
function showStatusBar(message, type) {
|
||||
statusBar.textContent = message;
|
||||
statusBar.className = `status-bar visible ${type}`;
|
||||
if (type === 'success') {
|
||||
setTimeout(() => { statusBar.className = 'status-bar'; }, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Parakeet progress UI ---
|
||||
|
||||
const progressArea = document.getElementById('progressArea');
|
||||
const progressLabel = document.getElementById('progressLabel');
|
||||
const progressFill = document.getElementById('progressFill');
|
||||
|
||||
function showParakeetProgress(p) {
|
||||
if (!progressArea) return;
|
||||
progressArea.classList.add('visible');
|
||||
|
||||
if (p.message) {
|
||||
progressLabel.textContent = p.message;
|
||||
}
|
||||
|
||||
if (p.status === 'downloading' && p.progress !== undefined) {
|
||||
progressFill.style.width = `${p.progress}%`;
|
||||
} else if (p.status === 'transcribing') {
|
||||
progressFill.style.width = '100%';
|
||||
} else if (p.status === 'loading') {
|
||||
progressFill.style.width = '0%';
|
||||
}
|
||||
}
|
||||
|
||||
function hideParakeetProgress() {
|
||||
if (progressArea) {
|
||||
progressArea.classList.remove('visible');
|
||||
progressFill.style.width = '0%';
|
||||
}
|
||||
}
|
||||
|
||||
// --- Notebook loader ---
|
||||
|
||||
async function loadNotebooks() {
|
||||
const token = await getToken();
|
||||
if (!token) return;
|
||||
const settings = await getSettings();
|
||||
|
||||
try {
|
||||
const res = await fetch(`${settings.host}/api/notebooks`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) return;
|
||||
const notebooks = await res.json();
|
||||
|
||||
for (const nb of notebooks) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = nb.id;
|
||||
opt.textContent = nb.title;
|
||||
notebookSelect.appendChild(opt);
|
||||
}
|
||||
|
||||
// Restore last used
|
||||
const { lastNotebookId } = await chrome.storage.local.get(['lastNotebookId']);
|
||||
if (lastNotebookId) notebookSelect.value = lastNotebookId;
|
||||
} catch (err) {
|
||||
console.error('Failed to load notebooks:', err);
|
||||
}
|
||||
}
|
||||
|
||||
notebookSelect.addEventListener('change', (e) => {
|
||||
chrome.storage.local.set({ lastNotebookId: e.target.value });
|
||||
});
|
||||
|
||||
// --- Live transcription (Web Speech API) ---
|
||||
|
||||
function startLiveTranscription() {
|
||||
if (!speechSupported) return;
|
||||
|
||||
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||
recognition = new SpeechRecognition();
|
||||
recognition.continuous = true;
|
||||
recognition.interimResults = true;
|
||||
recognition.lang = 'en-US';
|
||||
|
||||
let finalizedText = '';
|
||||
|
||||
recognition.onresult = (event) => {
|
||||
let interimText = '';
|
||||
// Rebuild finalized text from all final results
|
||||
finalizedText = '';
|
||||
for (let i = 0; i < event.results.length; i++) {
|
||||
const result = event.results[i];
|
||||
if (result.isFinal) {
|
||||
finalizedText += result[0].transcript.trim() + ' ';
|
||||
} else {
|
||||
interimText += result[0].transcript;
|
||||
}
|
||||
}
|
||||
|
||||
liveTranscript = finalizedText.trim();
|
||||
|
||||
// Update the live transcript display
|
||||
updateLiveDisplay(finalizedText.trim(), interimText.trim());
|
||||
};
|
||||
|
||||
recognition.onerror = (event) => {
|
||||
if (event.error !== 'aborted' && event.error !== 'no-speech') {
|
||||
console.warn('Speech recognition error:', event.error);
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-restart on end (Chrome stops after ~60s of silence)
|
||||
recognition.onend = () => {
|
||||
if (state === 'recording' && recognition) {
|
||||
try { recognition.start(); } catch {}
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
recognition.start();
|
||||
if (liveIndicator) liveIndicator.classList.add('visible');
|
||||
} catch (err) {
|
||||
console.warn('Could not start speech recognition:', err);
|
||||
speechSupported = false;
|
||||
}
|
||||
}
|
||||
|
||||
function stopLiveTranscription() {
|
||||
if (recognition) {
|
||||
const ref = recognition;
|
||||
recognition = null;
|
||||
try { ref.stop(); } catch {}
|
||||
}
|
||||
if (liveIndicator) liveIndicator.classList.remove('visible');
|
||||
}
|
||||
|
||||
function updateLiveDisplay(finalText, interimText) {
|
||||
if (state !== 'recording') return;
|
||||
|
||||
// Show transcript area while recording
|
||||
transcriptArea.classList.add('visible');
|
||||
|
||||
let html = '';
|
||||
if (finalText) {
|
||||
html += `<span class="final-text">${escapeHtml(finalText)}</span>`;
|
||||
}
|
||||
if (interimText) {
|
||||
html += `<span class="interim-text">${escapeHtml(interimText)}</span>`;
|
||||
}
|
||||
if (!finalText && !interimText) {
|
||||
html = '<span class="placeholder">Listening...</span>';
|
||||
}
|
||||
transcriptText.innerHTML = html;
|
||||
|
||||
// Auto-scroll
|
||||
transcriptText.scrollTop = transcriptText.scrollHeight;
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// --- Recording ---
|
||||
|
||||
async function startRecording() {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
|
||||
const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
|
||||
? 'audio/webm;codecs=opus'
|
||||
: 'audio/webm';
|
||||
|
||||
mediaRecorder = new MediaRecorder(stream, { mimeType });
|
||||
audioChunks = [];
|
||||
liveTranscript = '';
|
||||
|
||||
mediaRecorder.ondataavailable = (e) => {
|
||||
if (e.data.size > 0) audioChunks.push(e.data);
|
||||
};
|
||||
|
||||
mediaRecorder.start(1000);
|
||||
startTime = Date.now();
|
||||
state = 'recording';
|
||||
|
||||
// UI updates
|
||||
recBtn.classList.add('recording');
|
||||
timerEl.classList.add('recording');
|
||||
setStatusLabel('Recording', 'recording');
|
||||
postActions.style.display = 'none';
|
||||
audioPreview.classList.remove('visible');
|
||||
statusBar.className = 'status-bar';
|
||||
|
||||
// Show transcript area with listening placeholder
|
||||
if (speechSupported) {
|
||||
transcriptArea.classList.add('visible');
|
||||
transcriptText.innerHTML = '<span class="placeholder">Listening...</span>';
|
||||
} else {
|
||||
transcriptArea.classList.remove('visible');
|
||||
}
|
||||
|
||||
timerInterval = setInterval(() => {
|
||||
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
||||
timerEl.textContent = formatTime(elapsed);
|
||||
}, 1000);
|
||||
|
||||
// Start live transcription alongside recording
|
||||
startLiveTranscription();
|
||||
|
||||
} catch (err) {
|
||||
showStatusBar(err.message || 'Microphone access denied', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function stopRecording() {
|
||||
if (!mediaRecorder || mediaRecorder.state === 'inactive') return;
|
||||
|
||||
clearInterval(timerInterval);
|
||||
timerInterval = null;
|
||||
duration = Math.floor((Date.now() - startTime) / 1000);
|
||||
|
||||
// Capture live transcript before stopping recognition
|
||||
const capturedLiveTranscript = liveTranscript;
|
||||
|
||||
// Stop live transcription
|
||||
stopLiveTranscription();
|
||||
|
||||
state = 'processing';
|
||||
recBtn.classList.remove('recording');
|
||||
timerEl.classList.remove('recording');
|
||||
setStatusLabel('Processing...', 'processing');
|
||||
|
||||
// Stop recorder and collect blob
|
||||
audioBlob = await new Promise((resolve) => {
|
||||
mediaRecorder.onstop = () => {
|
||||
mediaRecorder.stream.getTracks().forEach(t => t.stop());
|
||||
resolve(new Blob(audioChunks, { type: mediaRecorder.mimeType }));
|
||||
};
|
||||
mediaRecorder.stop();
|
||||
});
|
||||
|
||||
// Show audio preview
|
||||
if (audioUrl) URL.revokeObjectURL(audioUrl);
|
||||
audioUrl = URL.createObjectURL(audioBlob);
|
||||
audioPlayer.src = audioUrl;
|
||||
audioPreview.classList.add('visible');
|
||||
|
||||
// Show live transcript while we process (if we have one)
|
||||
transcriptArea.classList.add('visible');
|
||||
if (capturedLiveTranscript) {
|
||||
transcriptText.textContent = capturedLiveTranscript;
|
||||
showStatusBar('Improving transcript...', 'loading');
|
||||
} else {
|
||||
transcriptText.innerHTML = '<span class="placeholder">Transcribing...</span>';
|
||||
showStatusBar('Uploading & transcribing...', 'loading');
|
||||
}
|
||||
|
||||
// Upload audio file
|
||||
const token = await getToken();
|
||||
const settings = await getSettings();
|
||||
|
||||
try {
|
||||
const uploadForm = new FormData();
|
||||
uploadForm.append('file', audioBlob, 'voice-note.webm');
|
||||
|
||||
const uploadRes = await fetch(`${settings.host}/api/uploads`, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
body: uploadForm,
|
||||
});
|
||||
|
||||
if (!uploadRes.ok) throw new Error('Upload failed');
|
||||
|
||||
const uploadResult = await uploadRes.json();
|
||||
uploadedFileUrl = uploadResult.url;
|
||||
uploadedMimeType = uploadResult.mimeType;
|
||||
uploadedFileSize = uploadResult.size;
|
||||
|
||||
// --- Three-tier transcription cascade ---
|
||||
|
||||
// Tier 1: Batch API (Whisper on server — highest quality)
|
||||
let bestTranscript = '';
|
||||
try {
|
||||
showStatusBar('Transcribing via server...', 'loading');
|
||||
const transcribeForm = new FormData();
|
||||
transcribeForm.append('audio', audioBlob, 'voice-note.webm');
|
||||
|
||||
const transcribeRes = await fetch(`${settings.host}/api/voice/transcribe`, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
body: transcribeForm,
|
||||
});
|
||||
|
||||
if (transcribeRes.ok) {
|
||||
const transcribeResult = await transcribeRes.json();
|
||||
bestTranscript = transcribeResult.text || '';
|
||||
}
|
||||
} catch {
|
||||
console.warn('Tier 1 (batch API) unavailable');
|
||||
}
|
||||
|
||||
// Tier 2: Live transcript from Web Speech API (already captured)
|
||||
if (!bestTranscript && capturedLiveTranscript) {
|
||||
bestTranscript = capturedLiveTranscript;
|
||||
}
|
||||
|
||||
// Tier 3: Offline Parakeet.js (NVIDIA, runs in browser)
|
||||
if (!bestTranscript && window.ParakeetOffline) {
|
||||
try {
|
||||
showStatusBar('Transcribing offline (Parakeet)...', 'loading');
|
||||
bestTranscript = await window.ParakeetOffline.transcribeOffline(audioBlob, (p) => {
|
||||
showParakeetProgress(p);
|
||||
});
|
||||
hideParakeetProgress();
|
||||
} catch (offlineErr) {
|
||||
console.warn('Tier 3 (Parakeet offline) failed:', offlineErr);
|
||||
hideParakeetProgress();
|
||||
}
|
||||
}
|
||||
|
||||
transcript = bestTranscript;
|
||||
|
||||
// Show transcript (editable)
|
||||
if (transcript) {
|
||||
transcriptText.textContent = transcript;
|
||||
} else {
|
||||
transcriptText.innerHTML = '<span class="placeholder">No transcript available - you can type one here</span>';
|
||||
}
|
||||
|
||||
state = 'done';
|
||||
setStatusLabel('Done', 'done');
|
||||
postActions.style.display = 'flex';
|
||||
statusBar.className = 'status-bar';
|
||||
|
||||
} catch (err) {
|
||||
// On upload error, try offline transcription directly
|
||||
let fallbackTranscript = capturedLiveTranscript || '';
|
||||
|
||||
if (!fallbackTranscript && window.ParakeetOffline) {
|
||||
try {
|
||||
showStatusBar('Upload failed, transcribing offline...', 'loading');
|
||||
fallbackTranscript = await window.ParakeetOffline.transcribeOffline(audioBlob, (p) => {
|
||||
showParakeetProgress(p);
|
||||
});
|
||||
hideParakeetProgress();
|
||||
} catch {
|
||||
hideParakeetProgress();
|
||||
}
|
||||
}
|
||||
|
||||
transcript = fallbackTranscript;
|
||||
if (transcript) {
|
||||
transcriptText.textContent = transcript;
|
||||
}
|
||||
|
||||
showStatusBar(`Error: ${err.message}`, 'error');
|
||||
state = 'done';
|
||||
setStatusLabel('Error', 'idle');
|
||||
postActions.style.display = 'flex';
|
||||
}
|
||||
}
|
||||
|
||||
function toggleRecording() {
|
||||
if (state === 'idle' || state === 'done') {
|
||||
startRecording();
|
||||
} else if (state === 'recording') {
|
||||
stopRecording();
|
||||
}
|
||||
// Ignore clicks while processing
|
||||
}
|
||||
|
||||
// --- Save to rNotes ---
|
||||
|
||||
async function saveToRNotes() {
|
||||
saveBtn.disabled = true;
|
||||
showStatusBar('Saving to rNotes...', 'loading');
|
||||
|
||||
const token = await getToken();
|
||||
const settings = await getSettings();
|
||||
|
||||
// Get current transcript text (user may have edited it)
|
||||
const editedTranscript = transcriptText.textContent.trim();
|
||||
const isPlaceholder = transcriptText.querySelector('.placeholder') !== null;
|
||||
const finalTranscript = isPlaceholder ? '' : editedTranscript;
|
||||
|
||||
const now = new Date();
|
||||
const timeStr = now.toLocaleString('en-US', {
|
||||
month: 'short', day: 'numeric',
|
||||
hour: 'numeric', minute: '2-digit',
|
||||
hour12: true
|
||||
});
|
||||
|
||||
const body = {
|
||||
title: `Voice note - ${timeStr}`,
|
||||
content: finalTranscript
|
||||
? `<p>${finalTranscript.replace(/\n/g, '</p><p>')}</p>`
|
||||
: '<p><em>Voice recording (no transcript)</em></p>',
|
||||
type: 'AUDIO',
|
||||
mimeType: uploadedMimeType || 'audio/webm',
|
||||
fileUrl: uploadedFileUrl,
|
||||
fileSize: uploadedFileSize,
|
||||
duration: duration,
|
||||
tags: ['voice'],
|
||||
};
|
||||
|
||||
const notebookId = notebookSelect.value;
|
||||
if (notebookId) body.notebookId = notebookId;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${settings.host}/api/notes`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`${res.status}: ${text}`);
|
||||
}
|
||||
|
||||
showStatusBar('Saved to rNotes!', 'success');
|
||||
|
||||
// Notify
|
||||
chrome.runtime.sendMessage({
|
||||
type: 'notify',
|
||||
title: 'Voice Note Saved',
|
||||
message: `${formatTime(duration)} recording saved to rNotes`,
|
||||
});
|
||||
|
||||
// Reset after short delay
|
||||
setTimeout(resetState, 1500);
|
||||
|
||||
} catch (err) {
|
||||
showStatusBar(`Save failed: ${err.message}`, 'error');
|
||||
} finally {
|
||||
saveBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Copy to clipboard ---
|
||||
|
||||
async function copyTranscript() {
|
||||
const text = transcriptText.textContent.trim();
|
||||
if (!text || transcriptText.querySelector('.placeholder')) {
|
||||
showStatusBar('No transcript to copy', 'error');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
showStatusBar('Copied to clipboard', 'success');
|
||||
} catch {
|
||||
showStatusBar('Copy failed', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// --- Discard ---
|
||||
|
||||
function resetState() {
|
||||
state = 'idle';
|
||||
mediaRecorder = null;
|
||||
audioChunks = [];
|
||||
audioBlob = null;
|
||||
transcript = '';
|
||||
liveTranscript = '';
|
||||
uploadedFileUrl = '';
|
||||
uploadedMimeType = '';
|
||||
uploadedFileSize = 0;
|
||||
duration = 0;
|
||||
|
||||
stopLiveTranscription();
|
||||
|
||||
if (audioUrl) {
|
||||
URL.revokeObjectURL(audioUrl);
|
||||
audioUrl = null;
|
||||
}
|
||||
|
||||
timerEl.textContent = '00:00';
|
||||
timerEl.classList.remove('recording');
|
||||
recBtn.classList.remove('recording');
|
||||
setStatusLabel('Ready', 'idle');
|
||||
postActions.style.display = 'none';
|
||||
audioPreview.classList.remove('visible');
|
||||
transcriptArea.classList.remove('visible');
|
||||
hideParakeetProgress();
|
||||
statusBar.className = 'status-bar';
|
||||
}
|
||||
|
||||
// --- Keyboard shortcuts ---
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
// Space bar: toggle recording (unless editing transcript)
|
||||
if (e.code === 'Space' && document.activeElement !== transcriptText) {
|
||||
e.preventDefault();
|
||||
toggleRecording();
|
||||
}
|
||||
// Escape: close window
|
||||
if (e.code === 'Escape') {
|
||||
window.close();
|
||||
}
|
||||
// Ctrl+Enter: save (when in done state)
|
||||
if ((e.ctrlKey || e.metaKey) && e.code === 'Enter' && state === 'done') {
|
||||
e.preventDefault();
|
||||
saveToRNotes();
|
||||
}
|
||||
});
|
||||
|
||||
// Clear placeholder on focus
|
||||
transcriptText.addEventListener('focus', () => {
|
||||
const ph = transcriptText.querySelector('.placeholder');
|
||||
if (ph) transcriptText.textContent = '';
|
||||
});
|
||||
|
||||
// --- Event listeners ---
|
||||
|
||||
recBtn.addEventListener('click', toggleRecording);
|
||||
saveBtn.addEventListener('click', saveToRNotes);
|
||||
discardBtn.addEventListener('click', resetState);
|
||||
copyBtn.addEventListener('click', copyTranscript);
|
||||
closeBtn.addEventListener('click', () => window.close());
|
||||
|
||||
// --- Init ---
|
||||
|
||||
async function init() {
|
||||
const token = await getToken();
|
||||
const claims = token ? decodeToken(token) : null;
|
||||
|
||||
if (!claims) {
|
||||
authWarning.style.display = 'block';
|
||||
recBtn.style.opacity = '0.3';
|
||||
recBtn.style.pointerEvents = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
authWarning.style.display = 'none';
|
||||
await loadNotebooks();
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
|
|
@ -1,115 +1,71 @@
|
|||
services:
|
||||
rnotes:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: rnotes-online/Dockerfile
|
||||
container_name: rnotes-online
|
||||
context: .
|
||||
container_name: rnotes-frontend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3100:3000"
|
||||
- "4444:4444"
|
||||
environment:
|
||||
- INFISICAL_CLIENT_ID=${INFISICAL_CLIENT_ID}
|
||||
- INFISICAL_CLIENT_SECRET=${INFISICAL_CLIENT_SECRET}
|
||||
- INFISICAL_PROJECT_SLUG=rnotes
|
||||
- INFISICAL_ENV=prod
|
||||
- INFISICAL_URL=http://infisical:8080
|
||||
- DATABASE_URL=postgresql://rnotes:${DB_PASSWORD}@rnotes-postgres:5432/rnotes
|
||||
# IPFS integration (encrypted file storage via rSpace collab-server kubo)
|
||||
- IPFS_ENABLED=true
|
||||
- IPFS_API_URL=https://ipfs-api.rspace.online
|
||||
- IPFS_GATEWAY_URL=https://ipfs.rspace.online
|
||||
# Y.js collaboration (client-side env var baked at build time)
|
||||
- NEXT_PUBLIC_COLLAB_WS_URL=wss://collab-ws.rnotes.online
|
||||
volumes:
|
||||
- uploads_data:/app/uploads
|
||||
- DATABASE_URL=postgresql://rnotes:${DB_PASSWORD}@rnotes-db:5432/rnotes
|
||||
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
|
||||
- NEXTAUTH_URL=https://rnotes.online
|
||||
- ENCRYPTID_SERVER_URL=https://auth.ridentity.online
|
||||
- NEXT_PUBLIC_ENCRYPTID_SERVER_URL=https://auth.ridentity.online
|
||||
- NEXT_PUBLIC_SYNC_URL=wss://rnotes.online/sync
|
||||
- SYNC_SERVER_PORT=4444
|
||||
- HOSTNAME=0.0.0.0
|
||||
depends_on:
|
||||
rnotes-db:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- traefik-public
|
||||
- rnotes-internal
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.rnotes.rule=Host(`rnotes.online`) || Host(`www.rnotes.online`)"
|
||||
# Main app
|
||||
- "traefik.http.routers.rnotes.rule=Host(`rnotes.online`) || Host(`www.rnotes.online`) || HostRegexp(`{subdomain:[a-z0-9-]+}.rnotes.online`)"
|
||||
- "traefik.http.routers.rnotes.entrypoints=web"
|
||||
- "traefik.http.routers.rnotes.priority=130"
|
||||
- "traefik.http.routers.rnotes.service=rnotes"
|
||||
- "traefik.http.services.rnotes.loadbalancer.server.port=3000"
|
||||
# Wildcard subdomain routing (e.g. cca.rnotes.online)
|
||||
- "traefik.http.routers.rnotes-wildcard.rule=HostRegexp(`{sub:[a-z0-9-]+}.rnotes.online`)"
|
||||
- "traefik.http.routers.rnotes-wildcard.entrypoints=web"
|
||||
- "traefik.http.routers.rnotes-wildcard.priority=100"
|
||||
- "traefik.http.routers.rnotes-wildcard.service=rnotes"
|
||||
networks:
|
||||
- traefik-public
|
||||
- rnotes-internal
|
||||
depends_on:
|
||||
rnotes-postgres:
|
||||
condition: service_healthy
|
||||
cap_drop:
|
||||
- ALL
|
||||
# WebSocket sync
|
||||
- "traefik.http.routers.rnotes-sync.rule=(Host(`rnotes.online`) || HostRegexp(`{subdomain:[a-z0-9-]+}.rnotes.online`)) && PathPrefix(`/sync`)"
|
||||
- "traefik.http.routers.rnotes-sync.entrypoints=web"
|
||||
- "traefik.http.routers.rnotes-sync.priority=200"
|
||||
- "traefik.http.routers.rnotes-sync.service=rnotes-sync"
|
||||
- "traefik.http.services.rnotes-sync.loadbalancer.server.port=4444"
|
||||
- "traefik.http.middlewares.rnotes-sync-strip.stripprefix.prefixes=/sync"
|
||||
- "traefik.http.routers.rnotes-sync.middlewares=rnotes-sync-strip"
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp
|
||||
- /home/nextjs/.npm
|
||||
|
||||
# Y.js WebSocket server for real-time collaboration
|
||||
rnotes-yws:
|
||||
image: node:22-slim
|
||||
container_name: rnotes-yws
|
||||
restart: unless-stopped
|
||||
working_dir: /app
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
npm install @y/websocket-server > /dev/null 2>&1
|
||||
node node_modules/@y/websocket-server/src/server.js
|
||||
environment:
|
||||
- HOST=0.0.0.0
|
||||
- PORT=1234
|
||||
networks:
|
||||
- traefik-public
|
||||
- rnotes-internal
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.rnotes-yws.rule=Host(`collab-ws.rnotes.online`)"
|
||||
- "traefik.http.routers.rnotes-yws.entrypoints=web"
|
||||
- "traefik.http.routers.rnotes-yws.priority=140"
|
||||
- "traefik.http.services.rnotes-yws.loadbalancer.server.port=1234"
|
||||
cap_drop:
|
||||
- ALL
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
tmpfs:
|
||||
- /app
|
||||
|
||||
rnotes-postgres:
|
||||
rnotes-db:
|
||||
image: postgres:16-alpine
|
||||
container_name: rnotes-postgres
|
||||
container_name: rnotes-db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- POSTGRES_DB=rnotes
|
||||
- POSTGRES_USER=rnotes
|
||||
- POSTGRES_PASSWORD=${DB_PASSWORD}
|
||||
- POSTGRES_DB=rnotes
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- rnotes-internal
|
||||
- rnotes-pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U rnotes -d rnotes"]
|
||||
interval: 5s
|
||||
test: ["CMD-SHELL", "pg_isready -U rnotes"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
cap_drop:
|
||||
- ALL
|
||||
cap_add:
|
||||
- DAC_OVERRIDE
|
||||
- FOWNER
|
||||
- SETGID
|
||||
- SETUID
|
||||
networks:
|
||||
- rnotes-internal
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
|
||||
volumes:
|
||||
rnotes-pgdata:
|
||||
|
||||
networks:
|
||||
traefik-public:
|
||||
external: true
|
||||
rnotes-internal:
|
||||
internal: true
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
uploads_data:
|
||||
driver: bridge
|
||||
|
|
|
|||
|
|
@ -1,82 +1,8 @@
|
|||
#!/bin/sh
|
||||
# Infisical secret injection entrypoint
|
||||
# Fetches secrets from Infisical API and injects them as env vars before starting the app.
|
||||
# Required env vars: INFISICAL_CLIENT_ID, INFISICAL_CLIENT_SECRET
|
||||
# Optional: INFISICAL_PROJECT_SLUG (default: rnotes), INFISICAL_ENV (default: prod),
|
||||
# INFISICAL_URL (default: http://infisical:8080)
|
||||
|
||||
set -e
|
||||
|
||||
INFISICAL_URL="${INFISICAL_URL:-http://infisical:8080}"
|
||||
INFISICAL_ENV="${INFISICAL_ENV:-prod}"
|
||||
INFISICAL_PROJECT_SLUG="${INFISICAL_PROJECT_SLUG:-rnotes}"
|
||||
|
||||
if [ -z "$INFISICAL_CLIENT_ID" ] || [ -z "$INFISICAL_CLIENT_SECRET" ]; then
|
||||
echo "[infisical] No credentials set, starting without secret injection"
|
||||
exec "$@"
|
||||
fi
|
||||
|
||||
echo "[infisical] Fetching secrets from ${INFISICAL_PROJECT_SLUG}/${INFISICAL_ENV}..."
|
||||
|
||||
# Use Node.js (already in the image) for reliable JSON parsing and HTTP calls
|
||||
EXPORTS=$(node -e "
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
const url = new URL(process.env.INFISICAL_URL);
|
||||
const client = url.protocol === 'https:' ? https : http;
|
||||
|
||||
const post = (path, body) => new Promise((resolve, reject) => {
|
||||
const data = JSON.stringify(body);
|
||||
const req = client.request({ hostname: url.hostname, port: url.port, path, method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Content-Length': data.length }
|
||||
}, res => { let d = ''; res.on('data', c => d += c); res.on('end', () => resolve(JSON.parse(d))); });
|
||||
req.on('error', reject);
|
||||
req.end(data);
|
||||
});
|
||||
|
||||
const get = (path, token) => new Promise((resolve, reject) => {
|
||||
const req = client.request({ hostname: url.hostname, port: url.port, path, method: 'GET',
|
||||
headers: { 'Authorization': 'Bearer ' + token }
|
||||
}, res => { let d = ''; res.on('data', c => d += c); res.on('end', () => resolve(JSON.parse(d))); });
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
});
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const auth = await post('/api/v1/auth/universal-auth/login', {
|
||||
clientId: process.env.INFISICAL_CLIENT_ID,
|
||||
clientSecret: process.env.INFISICAL_CLIENT_SECRET
|
||||
});
|
||||
if (!auth.accessToken) { console.error('[infisical] Auth failed'); process.exit(1); }
|
||||
|
||||
const slug = process.env.INFISICAL_PROJECT_SLUG;
|
||||
const env = process.env.INFISICAL_ENV;
|
||||
const secrets = await get('/api/v3/secrets/raw?workspaceSlug=' + slug + '&environment=' + env + '&secretPath=/&recursive=true', auth.accessToken);
|
||||
|
||||
if (!secrets.secrets) { console.error('[infisical] No secrets returned'); process.exit(1); }
|
||||
|
||||
// Output as shell-safe export statements
|
||||
for (const s of secrets.secrets) {
|
||||
// Single-quote the value to prevent shell expansion, escape existing single quotes
|
||||
const escaped = s.secretValue.replace(/'/g, \"'\\\\''\" );
|
||||
console.log('export ' + s.secretKey + \"='\" + escaped + \"'\");
|
||||
}
|
||||
} catch (e) { console.error('[infisical] Error:', e.message); process.exit(1); }
|
||||
})();
|
||||
" 2>&1) || {
|
||||
echo "[infisical] WARNING: Failed to fetch secrets, starting with existing env vars"
|
||||
exec "$@"
|
||||
}
|
||||
|
||||
# Check if we got export statements or error messages
|
||||
if echo "$EXPORTS" | grep -q "^export "; then
|
||||
COUNT=$(echo "$EXPORTS" | grep -c "^export ")
|
||||
eval "$EXPORTS"
|
||||
echo "[infisical] Injected ${COUNT} secrets"
|
||||
else
|
||||
echo "[infisical] WARNING: $EXPORTS"
|
||||
echo "[infisical] Starting with existing env vars"
|
||||
fi
|
||||
# Start the Yjs sync server in the background
|
||||
node sync-server/index.js &
|
||||
|
||||
# Start the Next.js server
|
||||
exec "$@"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference path="./.next/types/routes.d.ts" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
typescript: { ignoreBuildErrors: true },
|
||||
output: 'standalone',
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: '/:path*',
|
||||
headers: [
|
||||
{
|
||||
key: 'Content-Security-Policy',
|
||||
value: "frame-src 'self' https://opennotebook.rnotes.online https://notebook.jeffemmett.com;",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
webpack: (config, { isServer, webpack }) => {
|
||||
// Ignore onnxruntime-node if any dependency pulls it in.
|
||||
// We only use the browser ONNX runtime (loaded from CDN at runtime).
|
||||
config.plugins.push(
|
||||
new webpack.IgnorePlugin({
|
||||
resourceRegExp: /onnxruntime-node/,
|
||||
})
|
||||
);
|
||||
if (!isServer) {
|
||||
config.resolve.fallback = {
|
||||
...config.resolve.fallback,
|
||||
fs: false,
|
||||
path: false,
|
||||
os: false,
|
||||
};
|
||||
}
|
||||
return config;
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
91
package.json
|
|
@ -3,50 +3,67 @@
|
|||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"dev": "concurrently \"next dev -p 3000\" \"node sync-server/dist/index.js\"",
|
||||
"dev:next": "next dev -p 3000",
|
||||
"dev:sync": "npx tsx sync-server/src/index.ts",
|
||||
"build": "npx prisma generate && next build && npx tsc -p sync-server/tsconfig.json",
|
||||
"start": "node .next/standalone/server.js",
|
||||
"db:push": "npx prisma db push",
|
||||
"db:migrate": "npx prisma migrate dev",
|
||||
"db:studio": "npx prisma studio"
|
||||
"db:migrate": "npx prisma migrate dev"
|
||||
},
|
||||
"dependencies": {
|
||||
"@encryptid/sdk": "file:../encryptid-sdk",
|
||||
"@prisma/client": "^6.19.2",
|
||||
"@tiptap/core": "^3.19.0",
|
||||
"@tiptap/extension-code-block-lowlight": "^3.19.0",
|
||||
"@tiptap/extension-collaboration": "^3.22.0",
|
||||
"@tiptap/extension-collaboration-caret": "^3.22.0",
|
||||
"@tiptap/extension-image": "^3.19.0",
|
||||
"@tiptap/extension-link": "^3.19.0",
|
||||
"@tiptap/extension-placeholder": "^3.19.0",
|
||||
"@tiptap/extension-task-item": "^3.19.0",
|
||||
"@tiptap/extension-task-list": "^3.19.0",
|
||||
"@tiptap/pm": "^3.19.0",
|
||||
"@tiptap/react": "^3.19.0",
|
||||
"@tiptap/starter-kit": "^3.19.0",
|
||||
"archiver": "^7.0.1",
|
||||
"dompurify": "^3.2.0",
|
||||
"lowlight": "^3.3.0",
|
||||
"marked": "^15.0.0",
|
||||
"nanoid": "^5.0.9",
|
||||
"next": "14.2.35",
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
"y-websocket": "^3.0.0",
|
||||
"yjs": "^13.6.30",
|
||||
"zustand": "^5.0.11"
|
||||
"@radix-ui/react-avatar": "^1.1.3",
|
||||
"@radix-ui/react-dialog": "^1.1.6",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.6",
|
||||
"@radix-ui/react-popover": "^1.1.6",
|
||||
"@radix-ui/react-separator": "^1.1.2",
|
||||
"@radix-ui/react-tabs": "^1.1.3",
|
||||
"@radix-ui/react-tooltip": "^1.1.8",
|
||||
"@tiptap/core": "^2.11.0",
|
||||
"@tiptap/extension-collaboration": "^2.11.0",
|
||||
"@tiptap/extension-collaboration-cursor": "^2.11.0",
|
||||
"@tiptap/extension-color": "^2.11.0",
|
||||
"@tiptap/extension-highlight": "^2.11.0",
|
||||
"@tiptap/extension-image": "^2.11.0",
|
||||
"@tiptap/extension-link": "^2.11.0",
|
||||
"@tiptap/extension-placeholder": "^2.11.0",
|
||||
"@tiptap/extension-task-item": "^2.11.0",
|
||||
"@tiptap/extension-task-list": "^2.11.0",
|
||||
"@tiptap/extension-text-style": "^2.11.0",
|
||||
"@tiptap/extension-underline": "^2.11.0",
|
||||
"@tiptap/pm": "^2.11.0",
|
||||
"@tiptap/react": "^2.11.0",
|
||||
"@tiptap/starter-kit": "^2.11.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"concurrently": "^9.1.2",
|
||||
"date-fns": "^4.1.0",
|
||||
"lib0": "^0.2.99",
|
||||
"lucide-react": "^0.468.0",
|
||||
"next": "^15.3.0",
|
||||
"next-auth": "^5.0.0-beta.30",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"sonner": "^1.7.4",
|
||||
"tailwind-merge": "^3.0.2",
|
||||
"ws": "^8.18.0",
|
||||
"y-prosemirror": "^1.2.15",
|
||||
"y-protocols": "^1.0.6",
|
||||
"y-websocket": "^2.1.0",
|
||||
"yjs": "^13.6.22",
|
||||
"zustand": "^5.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/archiver": "^6.0.4",
|
||||
"@types/dompurify": "^3",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"postcss": "^8",
|
||||
"@tailwindcss/postcss": "^4.0.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@types/ws": "^8.5.14",
|
||||
"prisma": "^6.19.2",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5"
|
||||
"tailwindcss": "^4.0.0",
|
||||
"tsx": "^4.19.4",
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
-- Add workspaceSlug to Notebook for subdomain-based data isolation
|
||||
ALTER TABLE "Notebook" ADD COLUMN "workspaceSlug" TEXT NOT NULL DEFAULT '';
|
||||
|
||||
-- Index for efficient workspace-scoped queries
|
||||
CREATE INDEX "Notebook_workspaceSlug_idx" ON "Notebook"("workspaceSlug");
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
-- Add IPFS storage fields to File model
|
||||
ALTER TABLE "File" ADD COLUMN "ipfsCid" TEXT;
|
||||
ALTER TABLE "File" ADD COLUMN "ipfsEncKey" TEXT;
|
||||
|
|
@ -7,202 +7,169 @@ datasource db {
|
|||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
// ─── Users ──────────────────────────────────────────────────────────
|
||||
// ─── Auth ────────────────────────────────────────────────
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
did String @unique // EncryptID DID
|
||||
username String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
id String @id @default(cuid())
|
||||
did String? @unique
|
||||
username String? @unique
|
||||
name String?
|
||||
email String? @unique
|
||||
emailVerified DateTime?
|
||||
image String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
notebooks NotebookCollaborator[]
|
||||
notes Note[]
|
||||
files File[]
|
||||
sharedByMe SharedAccess[] @relation("SharedBy")
|
||||
memberships SpaceMember[]
|
||||
notebooks Notebook[]
|
||||
comments Comment[]
|
||||
suggestions Suggestion[]
|
||||
reactions Reaction[]
|
||||
}
|
||||
|
||||
// ─── Notebooks ──────────────────────────────────────────────────────
|
||||
// ─── Multi-tenant Spaces ─────────────────────────────────
|
||||
|
||||
model Notebook {
|
||||
id String @id @default(cuid())
|
||||
title String
|
||||
slug String @unique
|
||||
description String? @db.Text
|
||||
coverColor String @default("#f59e0b")
|
||||
canvasSlug String?
|
||||
canvasShapeId String?
|
||||
isPublic Boolean @default(false)
|
||||
sortOrder Int @default(0)
|
||||
workspaceSlug String @default("") // subdomain scope: "" = personal/unscoped
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
collaborators NotebookCollaborator[]
|
||||
notes Note[]
|
||||
sharedAccess SharedAccess[]
|
||||
|
||||
@@index([slug])
|
||||
@@index([workspaceSlug])
|
||||
}
|
||||
|
||||
enum CollaboratorRole {
|
||||
OWNER
|
||||
EDITOR
|
||||
enum SpaceRole {
|
||||
ADMIN
|
||||
MODERATOR
|
||||
MEMBER
|
||||
VIEWER
|
||||
}
|
||||
|
||||
model NotebookCollaborator {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
notebookId String
|
||||
notebook Notebook @relation(fields: [notebookId], references: [id], onDelete: Cascade)
|
||||
role CollaboratorRole @default(VIEWER)
|
||||
joinedAt DateTime @default(now())
|
||||
model Space {
|
||||
id String @id @default(cuid())
|
||||
slug String @unique
|
||||
name String
|
||||
description String @default("")
|
||||
icon String @default("")
|
||||
visibility String @default("public_read")
|
||||
ownerDid String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([userId, notebookId])
|
||||
@@index([notebookId])
|
||||
members SpaceMember[]
|
||||
notebooks Notebook[]
|
||||
}
|
||||
|
||||
// ─── Notes ──────────────────────────────────────────────────────────
|
||||
model SpaceMember {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
spaceId String
|
||||
role SpaceRole @default(MEMBER)
|
||||
joinedAt DateTime @default(now())
|
||||
|
||||
model Note {
|
||||
id String @id @default(cuid())
|
||||
notebookId String?
|
||||
notebook Notebook? @relation(fields: [notebookId], references: [id], onDelete: SetNull)
|
||||
authorId String?
|
||||
author User? @relation(fields: [authorId], references: [id], onDelete: SetNull)
|
||||
title String
|
||||
content String @db.Text
|
||||
contentPlain String? @db.Text
|
||||
type NoteType @default(NOTE)
|
||||
url String?
|
||||
archiveUrl String?
|
||||
language String?
|
||||
mimeType String?
|
||||
fileUrl String?
|
||||
fileSize Int?
|
||||
duration Int?
|
||||
isPinned Boolean @default(false)
|
||||
canvasShapeId String?
|
||||
sortOrder Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade)
|
||||
|
||||
// ─── Memory Card fields ─────────────────────────────────────────
|
||||
parentId String?
|
||||
parent Note? @relation("NoteTree", fields: [parentId], references: [id], onDelete: SetNull)
|
||||
children Note[] @relation("NoteTree")
|
||||
bodyJson Json? // TipTap JSON (canonical format)
|
||||
bodyMarkdown String? @db.Text // portable markdown for search + Logseq
|
||||
bodyFormat String @default("html") // "html" | "markdown" | "blocks"
|
||||
cardType String @default("note") // note|link|file|task|person|idea|reference
|
||||
summary String? // auto or manual
|
||||
visibility String @default("private") // private|space|public
|
||||
position Float? // fractional ordering
|
||||
properties Json @default("{}") // Logseq-compatible key-value
|
||||
archivedAt DateTime? // soft-delete
|
||||
|
||||
tags NoteTag[]
|
||||
attachments CardAttachment[]
|
||||
|
||||
@@index([notebookId])
|
||||
@@index([authorId])
|
||||
@@index([type])
|
||||
@@index([isPinned])
|
||||
@@index([parentId])
|
||||
@@index([cardType])
|
||||
@@index([archivedAt])
|
||||
@@index([position])
|
||||
@@unique([userId, spaceId])
|
||||
}
|
||||
|
||||
enum NoteType {
|
||||
NOTE
|
||||
CLIP
|
||||
BOOKMARK
|
||||
CODE
|
||||
IMAGE
|
||||
FILE
|
||||
AUDIO
|
||||
}
|
||||
// ─── Notebooks & Notes ───────────────────────────────────
|
||||
|
||||
// ─── Files & Attachments ────────────────────────────────────────────
|
||||
model Notebook {
|
||||
id String @id @default(cuid())
|
||||
spaceId String
|
||||
title String
|
||||
description String @default("")
|
||||
icon String @default("📓")
|
||||
createdBy String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
model File {
|
||||
id String @id @default(cuid())
|
||||
storageKey String @unique // unique filename on disk
|
||||
filename String // original filename
|
||||
mimeType String
|
||||
sizeBytes Int
|
||||
checksum String?
|
||||
authorId String?
|
||||
author User? @relation(fields: [authorId], references: [id], onDelete: SetNull)
|
||||
createdAt DateTime @default(now())
|
||||
space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade)
|
||||
creator User @relation(fields: [createdBy], references: [id])
|
||||
notes Note[]
|
||||
|
||||
// IPFS storage (optional — populated when IPFS_ENABLED=true)
|
||||
ipfsCid String? // IPFS content identifier
|
||||
ipfsEncKey String? // base64 AES-256-GCM key for this file
|
||||
|
||||
attachments CardAttachment[]
|
||||
}
|
||||
|
||||
model CardAttachment {
|
||||
id String @id @default(cuid())
|
||||
noteId String
|
||||
note Note @relation(fields: [noteId], references: [id], onDelete: Cascade)
|
||||
fileId String
|
||||
file File @relation(fields: [fileId], references: [id], onDelete: Cascade)
|
||||
role String @default("supporting") // "primary"|"preview"|"supporting"
|
||||
caption String?
|
||||
position Float @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([noteId, fileId])
|
||||
@@index([noteId])
|
||||
@@index([fileId])
|
||||
}
|
||||
|
||||
// ─── Tags ───────────────────────────────────────────────────────────
|
||||
|
||||
model Tag {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
color String? @default("#6b7280")
|
||||
spaceId String @default("") // "" = global, otherwise space-scoped
|
||||
schema Json?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
notes NoteTag[]
|
||||
|
||||
@@unique([spaceId, name])
|
||||
@@index([spaceId])
|
||||
}
|
||||
|
||||
model NoteTag {
|
||||
id String @id @default(cuid())
|
||||
noteId String
|
||||
note Note @relation(fields: [noteId], references: [id], onDelete: Cascade)
|
||||
tagId String
|
||||
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
|
||||
model Note {
|
||||
id String @id @default(cuid())
|
||||
notebookId String
|
||||
title String @default("Untitled")
|
||||
yjsDocId String @unique @default(cuid())
|
||||
sortOrder Int @default(0)
|
||||
createdBy String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
notebook Notebook @relation(fields: [notebookId], references: [id], onDelete: Cascade)
|
||||
comments Comment[]
|
||||
suggestions Suggestion[]
|
||||
|
||||
@@index([notebookId])
|
||||
}
|
||||
|
||||
// ─── Suggestions (Track Changes) ─────────────────────────
|
||||
|
||||
enum SuggestionStatus {
|
||||
PENDING
|
||||
ACCEPTED
|
||||
REJECTED
|
||||
}
|
||||
|
||||
enum SuggestionType {
|
||||
INSERT
|
||||
DELETE
|
||||
FORMAT
|
||||
REPLACE
|
||||
}
|
||||
|
||||
model Suggestion {
|
||||
id String @id @default(cuid())
|
||||
noteId String
|
||||
authorId String
|
||||
type SuggestionType
|
||||
status SuggestionStatus @default(PENDING)
|
||||
fromPos Int
|
||||
toPos Int
|
||||
content String?
|
||||
oldContent String?
|
||||
attrs Json?
|
||||
resolvedBy String?
|
||||
resolvedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
note Note @relation(fields: [noteId], references: [id], onDelete: Cascade)
|
||||
author User @relation(fields: [authorId], references: [id])
|
||||
|
||||
@@index([noteId, status])
|
||||
}
|
||||
|
||||
// ─── Comments & Threads ──────────────────────────────────
|
||||
|
||||
model Comment {
|
||||
id String @id @default(cuid())
|
||||
noteId String
|
||||
authorId String
|
||||
parentId String?
|
||||
body String
|
||||
fromPos Int
|
||||
toPos Int
|
||||
resolved Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
note Note @relation(fields: [noteId], references: [id], onDelete: Cascade)
|
||||
author User @relation(fields: [authorId], references: [id])
|
||||
parent Comment? @relation("CommentThread", fields: [parentId], references: [id])
|
||||
replies Comment[] @relation("CommentThread")
|
||||
reactions Reaction[]
|
||||
|
||||
@@unique([noteId, tagId])
|
||||
@@index([tagId])
|
||||
@@index([noteId])
|
||||
}
|
||||
|
||||
// ─── Shared Access ──────────────────────────────────────────────────
|
||||
// ─── Emoji Reactions ─────────────────────────────────────
|
||||
|
||||
model SharedAccess {
|
||||
id String @id @default(cuid())
|
||||
notebookId String
|
||||
notebook Notebook @relation(fields: [notebookId], references: [id], onDelete: Cascade)
|
||||
sharedById String
|
||||
sharedBy User @relation("SharedBy", fields: [sharedById], references: [id], onDelete: Cascade)
|
||||
targetDid String
|
||||
role CollaboratorRole @default(VIEWER)
|
||||
createdAt DateTime @default(now())
|
||||
model Reaction {
|
||||
id String @id @default(cuid())
|
||||
commentId String
|
||||
userId String
|
||||
emoji String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([notebookId, targetDid])
|
||||
@@index([targetDid])
|
||||
comment Comment @relation(fields: [commentId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
|
||||
@@unique([commentId, userId, emoji])
|
||||
}
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 6.7 KiB |
|
|
@ -1,67 +0,0 @@
|
|||
{
|
||||
"name": "rNotes - Universal Knowledge Capture",
|
||||
"short_name": "rNotes",
|
||||
"description": "Capture notes, clips, bookmarks, code, audio, and files. Organize in notebooks, tag freely.",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"id": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#0a0a0a",
|
||||
"theme_color": "#0a0a0a",
|
||||
"orientation": "portrait-primary",
|
||||
"categories": ["productivity", "utilities"],
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
],
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": "Voice Note",
|
||||
"short_name": "Voice",
|
||||
"description": "Record a voice note with live transcription",
|
||||
"url": "/voice",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "New Note",
|
||||
"short_name": "Note",
|
||||
"description": "Create a new note",
|
||||
"url": "/notes/new",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
/**
|
||||
* AudioWorklet processor that captures raw PCM16 audio for WebSocket streaming.
|
||||
* Runs in a separate thread, sends Int16 buffers to the main thread.
|
||||
*/
|
||||
class PCMProcessor extends AudioWorkletProcessor {
|
||||
process(inputs) {
|
||||
const input = inputs[0];
|
||||
if (input.length > 0) {
|
||||
const channelData = input[0]; // mono channel
|
||||
// Convert float32 [-1, 1] to int16 [-32768, 32767]
|
||||
const pcm16 = new Int16Array(channelData.length);
|
||||
for (let i = 0; i < channelData.length; i++) {
|
||||
const s = Math.max(-1, Math.min(1, channelData[i]));
|
||||
pcm16[i] = s < 0 ? s * 32768 : s * 32767;
|
||||
}
|
||||
this.port.postMessage(pcm16.buffer, [pcm16.buffer]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
registerProcessor('pcm-processor', PCMProcessor);
|
||||
43
public/sw.js
|
|
@ -1,43 +0,0 @@
|
|||
const CACHE_NAME = 'rnotes-v1';
|
||||
const PRECACHE = [
|
||||
'/',
|
||||
'/manifest.json',
|
||||
'/icon-192.png',
|
||||
'/icon-512.png',
|
||||
];
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE))
|
||||
);
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then((names) =>
|
||||
Promise.all(names.filter((n) => n !== CACHE_NAME).map((n) => caches.delete(n)))
|
||||
)
|
||||
);
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const url = new URL(event.request.url);
|
||||
|
||||
// Always go to network for API calls
|
||||
if (url.pathname.startsWith('/api/')) return;
|
||||
|
||||
// Network-first for pages, cache-first for static assets
|
||||
event.respondWith(
|
||||
fetch(event.request)
|
||||
.then((response) => {
|
||||
if (response.status === 200) {
|
||||
const clone = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
|
||||
}
|
||||
return response;
|
||||
})
|
||||
.catch(() => caches.match(event.request))
|
||||
);
|
||||
});
|
||||
|
|
@ -1,158 +0,0 @@
|
|||
/**
|
||||
* Backfill script: Populate Memory Card fields for existing notes.
|
||||
*
|
||||
* Processes all notes where bodyJson IS NULL:
|
||||
* 1. htmlToTipTapJson(content) → bodyJson
|
||||
* 2. tipTapJsonToMarkdown(bodyJson) → bodyMarkdown
|
||||
* 3. mapNoteTypeToCardType(type) → cardType
|
||||
* 4. sortOrder * 1.0 → position
|
||||
* 5. bodyFormat = 'html'
|
||||
*
|
||||
* Also backfills fileUrl references into File + CardAttachment records.
|
||||
*
|
||||
* Run: docker exec rnotes-online npx tsx scripts/backfill-memory-card.ts
|
||||
*/
|
||||
|
||||
import { PrismaClient, Prisma } from '@prisma/client';
|
||||
import { htmlToTipTapJson, tipTapJsonToMarkdown, mapNoteTypeToCardType } from '../src/lib/content-convert';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
const BATCH_SIZE = 50;
|
||||
|
||||
async function backfillNotes() {
|
||||
console.log('=== Memory Card Backfill ===\n');
|
||||
|
||||
// Count notes needing backfill
|
||||
const total = await prisma.note.count({
|
||||
where: { bodyJson: { equals: Prisma.DbNull }, content: { not: '' } },
|
||||
});
|
||||
console.log(`Found ${total} notes to backfill.\n`);
|
||||
|
||||
if (total === 0) {
|
||||
console.log('Nothing to do!');
|
||||
return;
|
||||
}
|
||||
|
||||
let processed = 0;
|
||||
let errors = 0;
|
||||
|
||||
while (true) {
|
||||
const notes = await prisma.note.findMany({
|
||||
where: { bodyJson: { equals: Prisma.DbNull }, content: { not: '' } },
|
||||
select: { id: true, content: true, type: true, sortOrder: true, fileUrl: true, mimeType: true, fileSize: true, authorId: true },
|
||||
take: BATCH_SIZE,
|
||||
});
|
||||
|
||||
if (notes.length === 0) break;
|
||||
|
||||
for (const note of notes) {
|
||||
try {
|
||||
const bodyJson = htmlToTipTapJson(note.content);
|
||||
const bodyMarkdown = tipTapJsonToMarkdown(bodyJson);
|
||||
const cardType = mapNoteTypeToCardType(note.type);
|
||||
|
||||
await prisma.note.update({
|
||||
where: { id: note.id },
|
||||
data: {
|
||||
bodyJson: bodyJson as unknown as Prisma.InputJsonValue,
|
||||
bodyMarkdown,
|
||||
bodyFormat: 'html',
|
||||
cardType,
|
||||
position: note.sortOrder * 1.0,
|
||||
},
|
||||
});
|
||||
|
||||
// Backfill fileUrl → File + CardAttachment
|
||||
if (note.fileUrl) {
|
||||
const storageKey = note.fileUrl.replace(/^\/api\/uploads\//, '');
|
||||
if (storageKey && storageKey !== note.fileUrl) {
|
||||
// Check if File record already exists
|
||||
const existingFile = await prisma.file.findUnique({
|
||||
where: { storageKey },
|
||||
});
|
||||
|
||||
if (!existingFile) {
|
||||
const file = await prisma.file.create({
|
||||
data: {
|
||||
storageKey,
|
||||
filename: storageKey.replace(/^[a-zA-Z0-9_-]+_/, ''), // strip nanoid prefix
|
||||
mimeType: note.mimeType || 'application/octet-stream',
|
||||
sizeBytes: note.fileSize || 0,
|
||||
authorId: note.authorId,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.cardAttachment.create({
|
||||
data: {
|
||||
noteId: note.id,
|
||||
fileId: file.id,
|
||||
role: 'primary',
|
||||
position: 0,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// File exists, just link it
|
||||
const existingLink = await prisma.cardAttachment.findUnique({
|
||||
where: { noteId_fileId: { noteId: note.id, fileId: existingFile.id } },
|
||||
});
|
||||
if (!existingLink) {
|
||||
await prisma.cardAttachment.create({
|
||||
data: {
|
||||
noteId: note.id,
|
||||
fileId: existingFile.id,
|
||||
role: 'primary',
|
||||
position: 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
processed++;
|
||||
} catch (err) {
|
||||
errors++;
|
||||
console.error(` Error on note ${note.id}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(` Processed ${processed}/${total} (${errors} errors)`);
|
||||
}
|
||||
|
||||
// Also backfill notes with empty content (set bodyJson to empty doc)
|
||||
const emptyNotes = await prisma.note.count({
|
||||
where: { bodyJson: { equals: Prisma.DbNull }, content: '' },
|
||||
});
|
||||
if (emptyNotes > 0) {
|
||||
await prisma.note.updateMany({
|
||||
where: { bodyJson: { equals: Prisma.DbNull }, content: '' },
|
||||
data: {
|
||||
bodyJson: { type: 'doc', content: [] },
|
||||
bodyMarkdown: '',
|
||||
bodyFormat: 'html',
|
||||
cardType: 'note',
|
||||
},
|
||||
});
|
||||
console.log(` Set ${emptyNotes} empty notes to empty doc.`);
|
||||
}
|
||||
|
||||
// Update tags: set spaceId to '' where null
|
||||
const nullSpaceTags = await prisma.$executeRaw`
|
||||
UPDATE "Tag" SET "spaceId" = '' WHERE "spaceId" IS NULL
|
||||
`;
|
||||
if (nullSpaceTags > 0) {
|
||||
console.log(` Updated ${nullSpaceTags} tags with null spaceId to ''.`);
|
||||
}
|
||||
|
||||
console.log(`\n=== Done! ${processed} notes backfilled, ${errors} errors ===`);
|
||||
|
||||
// Verify
|
||||
const remaining = await prisma.note.count({
|
||||
where: { bodyJson: { equals: Prisma.DbNull }, content: { not: '' } },
|
||||
});
|
||||
console.log(`Remaining unprocessed notes: ${remaining}`);
|
||||
}
|
||||
|
||||
backfillNotes()
|
||||
.catch(console.error)
|
||||
.finally(() => prisma.$disconnect());
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function AIPage() {
|
||||
redirect('/opennotebook');
|
||||
}
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { requireAuth, isAuthed } from '@/lib/auth';
|
||||
import { unlockArticle } from '@/lib/article-unlock';
|
||||
|
||||
/**
|
||||
* POST /api/articles/unlock
|
||||
*
|
||||
* Attempts to find an archived/readable version of a paywalled article.
|
||||
*
|
||||
* Body: { url: string, noteId?: string }
|
||||
* - url: The article URL to unlock
|
||||
* - noteId: (optional) If provided, updates the note's archiveUrl on success
|
||||
*
|
||||
* Returns: { success, strategy, archiveUrl, error? }
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const auth = await requireAuth(request);
|
||||
if (!isAuthed(auth)) return auth;
|
||||
|
||||
const body = await request.json();
|
||||
const { url, noteId } = body;
|
||||
|
||||
if (!url || typeof url !== 'string') {
|
||||
return NextResponse.json({ error: 'URL is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate URL format
|
||||
try {
|
||||
new URL(url);
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid URL format' }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await unlockArticle(url);
|
||||
|
||||
// If successful and noteId provided, update the note's archiveUrl
|
||||
if (result.success && result.archiveUrl && noteId) {
|
||||
const existing = await prisma.note.findUnique({
|
||||
where: { id: noteId },
|
||||
select: { authorId: true },
|
||||
});
|
||||
|
||||
if (existing && (!existing.authorId || existing.authorId === auth.user.id)) {
|
||||
await prisma.note.update({
|
||||
where: { id: noteId },
|
||||
data: { archiveUrl: result.archiveUrl },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
console.error('Article unlock error:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, strategy: 'none', error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,109 +0,0 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { requireAuth, isAuthed } from '@/lib/auth';
|
||||
import { noteToLogseqPage, sanitizeLogseqFilename } from '@/lib/logseq-format';
|
||||
import archiver from 'archiver';
|
||||
import { readFile } from 'fs/promises';
|
||||
import { existsSync } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const UPLOAD_DIR = process.env.UPLOAD_DIR || '/app/uploads';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const auth = await requireAuth(request);
|
||||
if (!isAuthed(auth)) return auth;
|
||||
const { user } = auth;
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const notebookId = searchParams.get('notebookId');
|
||||
|
||||
// Fetch notes
|
||||
const where: Record<string, unknown> = {
|
||||
authorId: user.id,
|
||||
archivedAt: null,
|
||||
};
|
||||
if (notebookId) where.notebookId = notebookId;
|
||||
|
||||
const notes = await prisma.note.findMany({
|
||||
where,
|
||||
include: {
|
||||
tags: { include: { tag: true } },
|
||||
children: {
|
||||
select: { id: true, title: true, cardType: true },
|
||||
where: { archivedAt: null },
|
||||
},
|
||||
attachments: {
|
||||
include: { file: true },
|
||||
},
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
});
|
||||
|
||||
// Build ZIP archive
|
||||
const archive = archiver('zip', { zlib: { level: 6 } });
|
||||
const chunks: Buffer[] = [];
|
||||
|
||||
archive.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
|
||||
// Create pages directory
|
||||
const usedFilenames = new Set<string>();
|
||||
|
||||
for (const note of notes) {
|
||||
// Generate Logseq page content
|
||||
const pageContent = noteToLogseqPage({
|
||||
title: note.title,
|
||||
cardType: note.cardType,
|
||||
visibility: note.visibility,
|
||||
bodyMarkdown: note.bodyMarkdown,
|
||||
contentPlain: note.contentPlain,
|
||||
properties: note.properties as Record<string, unknown>,
|
||||
tags: note.tags,
|
||||
children: note.children,
|
||||
attachments: note.attachments.map((a) => ({
|
||||
file: { storageKey: a.file.storageKey, filename: a.file.filename },
|
||||
caption: a.caption,
|
||||
})),
|
||||
});
|
||||
|
||||
// Unique filename
|
||||
let filename = sanitizeLogseqFilename(note.title);
|
||||
if (usedFilenames.has(filename)) {
|
||||
filename = `${filename}_${note.id.slice(0, 6)}`;
|
||||
}
|
||||
usedFilenames.add(filename);
|
||||
|
||||
archive.append(pageContent, { name: `pages/${filename}.md` });
|
||||
|
||||
// Copy attachments into assets/
|
||||
for (const att of note.attachments) {
|
||||
const filePath = path.join(UPLOAD_DIR, att.file.storageKey);
|
||||
if (existsSync(filePath)) {
|
||||
const fileData = await readFile(filePath);
|
||||
archive.append(fileData, { name: `assets/${att.file.storageKey}` });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add logseq config
|
||||
archive.append(JSON.stringify({
|
||||
"meta/version": 2,
|
||||
"block/journal?": false,
|
||||
}, null, 2), { name: 'logseq/config.edn' });
|
||||
|
||||
await archive.finalize();
|
||||
|
||||
const buffer = Buffer.concat(chunks);
|
||||
|
||||
return new NextResponse(buffer, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/zip',
|
||||
'Content-Disposition': `attachment; filename="rnotes-logseq-export.zip"`,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Logseq export error:', error);
|
||||
return NextResponse.json({ error: 'Failed to export' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,180 +0,0 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { requireAuth, isAuthed } from '@/lib/auth';
|
||||
import { tipTapJsonToMarkdown } from '@/lib/content-convert';
|
||||
import archiver from 'archiver';
|
||||
import { readFile } from 'fs/promises';
|
||||
import { existsSync } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const UPLOAD_DIR = process.env.UPLOAD_DIR || '/app/uploads';
|
||||
|
||||
/** Build YAML frontmatter from note metadata */
|
||||
function buildFrontmatter(note: {
|
||||
id: string;
|
||||
type: string;
|
||||
cardType: string;
|
||||
url: string | null;
|
||||
language: string | null;
|
||||
isPinned: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
tags: { tag: { name: string } }[];
|
||||
notebook?: { title: string; slug: string } | null;
|
||||
}): string {
|
||||
const lines: string[] = ['---'];
|
||||
|
||||
lines.push(`type: ${note.cardType}`);
|
||||
if (note.url) lines.push(`url: ${note.url}`);
|
||||
if (note.language) lines.push(`language: ${note.language}`);
|
||||
if (note.isPinned) lines.push(`pinned: true`);
|
||||
if (note.notebook) lines.push(`notebook: ${note.notebook.title}`);
|
||||
|
||||
if (note.tags.length > 0) {
|
||||
lines.push(`tags:`);
|
||||
for (const t of note.tags) {
|
||||
lines.push(` - ${t.tag.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(`created: ${note.createdAt.toISOString()}`);
|
||||
lines.push(`updated: ${note.updatedAt.toISOString()}`);
|
||||
lines.push('---');
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/** Extract markdown body from a note, preferring bodyMarkdown */
|
||||
function getMarkdownBody(note: {
|
||||
bodyMarkdown: string | null;
|
||||
bodyJson: unknown;
|
||||
contentPlain: string | null;
|
||||
content: string;
|
||||
}): string {
|
||||
// Prefer the stored markdown
|
||||
if (note.bodyMarkdown) return note.bodyMarkdown;
|
||||
|
||||
// Convert from TipTap JSON if available
|
||||
if (note.bodyJson && typeof note.bodyJson === 'object') {
|
||||
try {
|
||||
return tipTapJsonToMarkdown(note.bodyJson as Parameters<typeof tipTapJsonToMarkdown>[0]);
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to plain text
|
||||
return note.contentPlain || note.content || '';
|
||||
}
|
||||
|
||||
/** Sanitize title to a safe filename */
|
||||
function sanitizeFilename(title: string): string {
|
||||
return title
|
||||
.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, 200) || 'untitled';
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const auth = await requireAuth(request);
|
||||
if (!isAuthed(auth)) return auth;
|
||||
const { user } = auth;
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const notebookId = searchParams.get('notebookId');
|
||||
const noteId = searchParams.get('noteId');
|
||||
|
||||
// --- Single note export ---
|
||||
if (noteId) {
|
||||
const note = await prisma.note.findFirst({
|
||||
where: { id: noteId, authorId: user.id, archivedAt: null },
|
||||
include: {
|
||||
tags: { include: { tag: true } },
|
||||
notebook: { select: { title: true, slug: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!note) {
|
||||
return NextResponse.json({ error: 'Note not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const frontmatter = buildFrontmatter(note);
|
||||
const body = getMarkdownBody(note);
|
||||
const md = `${frontmatter}\n\n# ${note.title}\n\n${body}\n`;
|
||||
|
||||
const filename = sanitizeFilename(note.title) + '.md';
|
||||
|
||||
return new NextResponse(md, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/markdown; charset=utf-8',
|
||||
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// --- Batch export as ZIP ---
|
||||
const where: Record<string, unknown> = {
|
||||
authorId: user.id,
|
||||
archivedAt: null,
|
||||
};
|
||||
if (notebookId) where.notebookId = notebookId;
|
||||
|
||||
const notes = await prisma.note.findMany({
|
||||
where,
|
||||
include: {
|
||||
tags: { include: { tag: true } },
|
||||
notebook: { select: { title: true, slug: true } },
|
||||
attachments: { include: { file: true } },
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
});
|
||||
|
||||
const archive = archiver('zip', { zlib: { level: 6 } });
|
||||
const chunks: Buffer[] = [];
|
||||
archive.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
|
||||
const usedFilenames = new Set<string>();
|
||||
|
||||
for (const note of notes) {
|
||||
const frontmatter = buildFrontmatter(note);
|
||||
const body = getMarkdownBody(note);
|
||||
const md = `${frontmatter}\n\n# ${note.title}\n\n${body}\n`;
|
||||
|
||||
let filename = sanitizeFilename(note.title);
|
||||
if (usedFilenames.has(filename)) {
|
||||
filename = `${filename}_${note.id.slice(0, 6)}`;
|
||||
}
|
||||
usedFilenames.add(filename);
|
||||
|
||||
archive.append(md, { name: `notes/${filename}.md` });
|
||||
|
||||
// Include attachments
|
||||
for (const att of note.attachments) {
|
||||
const filePath = path.join(UPLOAD_DIR, att.file.storageKey);
|
||||
if (existsSync(filePath)) {
|
||||
const fileData = await readFile(filePath);
|
||||
archive.append(fileData, { name: `attachments/${att.file.storageKey}` });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await archive.finalize();
|
||||
|
||||
const buffer = Buffer.concat(chunks);
|
||||
const zipName = notebookId ? 'rnotes-notebook-export.zip' : 'rnotes-export.zip';
|
||||
|
||||
return new NextResponse(buffer, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/zip',
|
||||
'Content-Disposition': `attachment; filename="${zipName}"`,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Markdown export error:', error);
|
||||
return NextResponse.json({ error: 'Failed to export' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({ status: 'ok', service: 'rnotes-online' });
|
||||
}
|
||||
|
|
@ -1,231 +0,0 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { requireAuth, isAuthed } from '@/lib/auth';
|
||||
import { logseqPageToNote } from '@/lib/logseq-format';
|
||||
import { writeFile, mkdir } from 'fs/promises';
|
||||
import { existsSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
const UPLOAD_DIR = process.env.UPLOAD_DIR || '/app/uploads';
|
||||
|
||||
// Simple unzip using built-in Node.js zlib
|
||||
async function extractZip(buffer: Buffer): Promise<Map<string, Buffer>> {
|
||||
const entries = new Map<string, Buffer>();
|
||||
|
||||
// Manual ZIP parsing (PKZip format)
|
||||
let offset = 0;
|
||||
while (offset < buffer.length - 4) {
|
||||
// Local file header signature
|
||||
if (buffer.readUInt32LE(offset) !== 0x04034b50) break;
|
||||
|
||||
const compressionMethod = buffer.readUInt16LE(offset + 8);
|
||||
const compressedSize = buffer.readUInt32LE(offset + 18);
|
||||
const uncompressedSize = buffer.readUInt32LE(offset + 22);
|
||||
const filenameLength = buffer.readUInt16LE(offset + 26);
|
||||
const extraLength = buffer.readUInt16LE(offset + 28);
|
||||
|
||||
const filename = buffer.toString('utf8', offset + 30, offset + 30 + filenameLength);
|
||||
const dataStart = offset + 30 + filenameLength + extraLength;
|
||||
|
||||
if (compressedSize > 0 && !filename.endsWith('/')) {
|
||||
const compressedData = buffer.subarray(dataStart, dataStart + compressedSize);
|
||||
|
||||
if (compressionMethod === 0) {
|
||||
// Stored (no compression)
|
||||
entries.set(filename, Buffer.from(compressedData));
|
||||
} else if (compressionMethod === 8) {
|
||||
// Deflate
|
||||
const zlib = await import('zlib');
|
||||
try {
|
||||
const inflated = zlib.inflateRawSync(compressedData);
|
||||
entries.set(filename, inflated);
|
||||
} catch {
|
||||
// Skip corrupted entries
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
offset = dataStart + compressedSize;
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const auth = await requireAuth(request);
|
||||
if (!isAuthed(auth)) return auth;
|
||||
const { user } = auth;
|
||||
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('file') as File | null;
|
||||
const notebookId = formData.get('notebookId') as string | null;
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: 'No file provided' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!file.name.endsWith('.zip')) {
|
||||
return NextResponse.json({ error: 'File must be a ZIP archive' }, { status: 400 });
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
const entries = await extractZip(buffer);
|
||||
|
||||
// Ensure upload directory exists
|
||||
if (!existsSync(UPLOAD_DIR)) {
|
||||
await mkdir(UPLOAD_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
// Phase 1: Extract assets
|
||||
const assetFiles = new Map<string, string>(); // original path → storageKey
|
||||
for (const [name, data] of Array.from(entries.entries())) {
|
||||
if (name.startsWith('assets/') && data.length > 0) {
|
||||
const assetName = name.replace('assets/', '');
|
||||
const ext = path.extname(assetName);
|
||||
const storageKey = `${nanoid(12)}_${assetName.replace(/[^a-zA-Z0-9._-]/g, '_')}`;
|
||||
const filePath = path.join(UPLOAD_DIR, storageKey);
|
||||
await writeFile(filePath, data);
|
||||
|
||||
// Create File record
|
||||
await prisma.file.create({
|
||||
data: {
|
||||
storageKey,
|
||||
filename: assetName,
|
||||
mimeType: guessMimeType(ext),
|
||||
sizeBytes: data.length,
|
||||
authorId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
assetFiles.set(assetName, storageKey);
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: Parse pages
|
||||
const importedNotes: { filename: string; parsed: ReturnType<typeof logseqPageToNote>; noteId?: string }[] = [];
|
||||
|
||||
for (const [name, data] of Array.from(entries.entries())) {
|
||||
if (name.startsWith('pages/') && name.endsWith('.md') && data.length > 0) {
|
||||
const filename = name.replace('pages/', '');
|
||||
const content = data.toString('utf8');
|
||||
const parsed = logseqPageToNote(filename, content);
|
||||
importedNotes.push({ filename, parsed });
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3: Create notes
|
||||
const titleToId = new Map<string, string>();
|
||||
|
||||
for (const item of importedNotes) {
|
||||
const { parsed } = item;
|
||||
|
||||
// Convert bodyMarkdown to HTML (simple)
|
||||
const htmlContent = parsed.bodyMarkdown
|
||||
.split('\n\n')
|
||||
.map((p) => `<p>${p.replace(/\n/g, '<br>')}</p>`)
|
||||
.join('');
|
||||
|
||||
// Find or create tags
|
||||
const tagRecords = [];
|
||||
for (const tagName of parsed.tags) {
|
||||
const tag = await prisma.tag.upsert({
|
||||
where: { spaceId_name: { spaceId: '', name: tagName } },
|
||||
update: {},
|
||||
create: { name: tagName, spaceId: '' },
|
||||
});
|
||||
tagRecords.push(tag);
|
||||
}
|
||||
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
title: parsed.title,
|
||||
content: htmlContent,
|
||||
contentPlain: parsed.bodyMarkdown,
|
||||
bodyMarkdown: parsed.bodyMarkdown,
|
||||
bodyFormat: 'markdown',
|
||||
cardType: parsed.cardType,
|
||||
visibility: parsed.visibility,
|
||||
properties: parsed.properties,
|
||||
type: cardTypeToNoteType(parsed.cardType),
|
||||
authorId: user.id,
|
||||
notebookId: notebookId || null,
|
||||
tags: {
|
||||
create: tagRecords.map((tag) => ({ tagId: tag.id })),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
item.noteId = note.id;
|
||||
titleToId.set(parsed.title, note.id);
|
||||
|
||||
// Link attachments
|
||||
for (const assetPath of parsed.attachmentPaths) {
|
||||
const storageKey = assetFiles.get(assetPath);
|
||||
if (storageKey) {
|
||||
const fileRecord = await prisma.file.findUnique({ where: { storageKey } });
|
||||
if (fileRecord) {
|
||||
await prisma.cardAttachment.create({
|
||||
data: {
|
||||
noteId: note.id,
|
||||
fileId: fileRecord.id,
|
||||
role: 'supporting',
|
||||
position: 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 4: Link parent-child relationships
|
||||
for (const item of importedNotes) {
|
||||
if (!item.noteId) continue;
|
||||
const { parsed } = item;
|
||||
for (const childTitle of parsed.childTitles) {
|
||||
const childId = titleToId.get(childTitle);
|
||||
if (childId) {
|
||||
await prisma.note.update({
|
||||
where: { id: childId },
|
||||
data: { parentId: item.noteId },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
imported: importedNotes.length,
|
||||
assets: assetFiles.size,
|
||||
notes: importedNotes.map((n) => ({ title: n.parsed.title, id: n.noteId })),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Logseq import error:', error);
|
||||
return NextResponse.json({ error: 'Failed to import' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
function cardTypeToNoteType(cardType: string): 'NOTE' | 'BOOKMARK' | 'CLIP' | 'CODE' | 'IMAGE' | 'FILE' | 'AUDIO' {
|
||||
const map: Record<string, 'NOTE' | 'BOOKMARK' | 'CLIP' | 'CODE' | 'IMAGE' | 'FILE' | 'AUDIO'> = {
|
||||
note: 'NOTE',
|
||||
link: 'BOOKMARK',
|
||||
reference: 'CLIP',
|
||||
file: 'FILE',
|
||||
task: 'NOTE',
|
||||
person: 'NOTE',
|
||||
idea: 'NOTE',
|
||||
};
|
||||
return map[cardType] || 'NOTE';
|
||||
}
|
||||
|
||||
function guessMimeType(ext: string): string {
|
||||
const map: Record<string, string> = {
|
||||
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png',
|
||||
'.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml',
|
||||
'.pdf': 'application/pdf', '.txt': 'text/plain', '.md': 'text/markdown',
|
||||
'.json': 'application/json', '.csv': 'text/csv',
|
||||
'.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.ogg': 'audio/ogg',
|
||||
'.webm': 'audio/webm', '.mp4': 'audio/mp4',
|
||||
};
|
||||
return map[ext.toLowerCase()] || 'application/octet-stream';
|
||||
}
|
||||
|
|
@ -1,304 +0,0 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { requireAuth, isAuthed } from '@/lib/auth';
|
||||
import { markdownToTipTapJson, tipTapJsonToHtml } from '@/lib/content-convert';
|
||||
import { stripHtml } from '@/lib/strip-html';
|
||||
import { writeFile, mkdir } from 'fs/promises';
|
||||
import { existsSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
const UPLOAD_DIR = process.env.UPLOAD_DIR || '/app/uploads';
|
||||
|
||||
// ─── ZIP extraction (reused from logseq import) ─────────────────────
|
||||
|
||||
async function extractZip(buffer: Buffer): Promise<Map<string, Buffer>> {
|
||||
const entries = new Map<string, Buffer>();
|
||||
let offset = 0;
|
||||
|
||||
while (offset < buffer.length - 4) {
|
||||
if (buffer.readUInt32LE(offset) !== 0x04034b50) break;
|
||||
|
||||
const compressionMethod = buffer.readUInt16LE(offset + 8);
|
||||
const compressedSize = buffer.readUInt32LE(offset + 18);
|
||||
const filenameLength = buffer.readUInt16LE(offset + 26);
|
||||
const extraLength = buffer.readUInt16LE(offset + 28);
|
||||
|
||||
const filename = buffer.toString('utf8', offset + 30, offset + 30 + filenameLength);
|
||||
const dataStart = offset + 30 + filenameLength + extraLength;
|
||||
|
||||
if (compressedSize > 0 && !filename.endsWith('/')) {
|
||||
const compressedData = buffer.subarray(dataStart, dataStart + compressedSize);
|
||||
|
||||
if (compressionMethod === 0) {
|
||||
entries.set(filename, Buffer.from(compressedData));
|
||||
} else if (compressionMethod === 8) {
|
||||
const zlib = await import('zlib');
|
||||
try {
|
||||
const inflated = zlib.inflateRawSync(compressedData);
|
||||
entries.set(filename, inflated);
|
||||
} catch {
|
||||
// Skip corrupted entries
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
offset = dataStart + compressedSize;
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
// ─── YAML frontmatter parser ─────────────────────────────────────────
|
||||
|
||||
interface Frontmatter {
|
||||
type?: string;
|
||||
url?: string;
|
||||
language?: string;
|
||||
pinned?: boolean;
|
||||
notebook?: string;
|
||||
tags?: string[];
|
||||
created?: string;
|
||||
updated?: string;
|
||||
}
|
||||
|
||||
function parseFrontmatter(content: string): { frontmatter: Frontmatter; body: string } {
|
||||
const fmRegex = /^---\s*\n([\s\S]*?)\n---\s*\n/;
|
||||
const match = content.match(fmRegex);
|
||||
|
||||
if (!match) {
|
||||
return { frontmatter: {}, body: content };
|
||||
}
|
||||
|
||||
const yamlBlock = match[1];
|
||||
const body = content.slice(match[0].length);
|
||||
const fm: Frontmatter = {};
|
||||
|
||||
// Simple YAML key: value parser (handles scalar values and tag lists)
|
||||
const lines = yamlBlock.split('\n');
|
||||
let currentKey: string | null = null;
|
||||
let tagList: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const scalarMatch = line.match(/^(\w+):\s*(.+)$/);
|
||||
const listKeyMatch = line.match(/^(\w+):\s*$/);
|
||||
const listItemMatch = line.match(/^\s+-\s+(.+)$/);
|
||||
|
||||
if (scalarMatch) {
|
||||
const [, key, value] = scalarMatch;
|
||||
currentKey = key;
|
||||
switch (key) {
|
||||
case 'type': fm.type = value; break;
|
||||
case 'url': fm.url = value; break;
|
||||
case 'language': fm.language = value; break;
|
||||
case 'pinned': fm.pinned = value === 'true'; break;
|
||||
case 'notebook': fm.notebook = value; break;
|
||||
case 'created': fm.created = value; break;
|
||||
case 'updated': fm.updated = value; break;
|
||||
}
|
||||
} else if (listKeyMatch) {
|
||||
currentKey = listKeyMatch[1];
|
||||
if (currentKey === 'tags') tagList = [];
|
||||
} else if (listItemMatch && currentKey === 'tags') {
|
||||
tagList.push(listItemMatch[1].trim().toLowerCase());
|
||||
}
|
||||
}
|
||||
|
||||
if (tagList.length > 0) fm.tags = tagList;
|
||||
|
||||
return { frontmatter: fm, body };
|
||||
}
|
||||
|
||||
/** Extract title from first # heading or filename */
|
||||
function extractTitle(body: string, filename: string): { title: string; bodyWithoutTitle: string } {
|
||||
const headingMatch = body.match(/^\s*#\s+(.+)\s*\n/);
|
||||
if (headingMatch) {
|
||||
return {
|
||||
title: headingMatch[1].trim(),
|
||||
bodyWithoutTitle: body.slice(headingMatch[0].length).trimStart(),
|
||||
};
|
||||
}
|
||||
// Fall back to filename without extension
|
||||
return {
|
||||
title: filename.replace(/\.md$/i, '').replace(/[_-]/g, ' '),
|
||||
bodyWithoutTitle: body,
|
||||
};
|
||||
}
|
||||
|
||||
function cardTypeToNoteType(cardType: string): 'NOTE' | 'BOOKMARK' | 'CLIP' | 'CODE' | 'IMAGE' | 'FILE' | 'AUDIO' {
|
||||
const map: Record<string, 'NOTE' | 'BOOKMARK' | 'CLIP' | 'CODE' | 'IMAGE' | 'FILE' | 'AUDIO'> = {
|
||||
note: 'NOTE',
|
||||
link: 'BOOKMARK',
|
||||
reference: 'CLIP',
|
||||
file: 'FILE',
|
||||
task: 'NOTE',
|
||||
person: 'NOTE',
|
||||
idea: 'NOTE',
|
||||
};
|
||||
return map[cardType] || 'NOTE';
|
||||
}
|
||||
|
||||
function guessMimeType(ext: string): string {
|
||||
const map: Record<string, string> = {
|
||||
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png',
|
||||
'.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml',
|
||||
'.pdf': 'application/pdf', '.txt': 'text/plain', '.md': 'text/markdown',
|
||||
'.json': 'application/json', '.csv': 'text/csv',
|
||||
'.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.ogg': 'audio/ogg',
|
||||
'.webm': 'audio/webm', '.mp4': 'audio/mp4',
|
||||
};
|
||||
return map[ext.toLowerCase()] || 'application/octet-stream';
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const auth = await requireAuth(request);
|
||||
if (!isAuthed(auth)) return auth;
|
||||
const { user } = auth;
|
||||
|
||||
const formData = await request.formData();
|
||||
const files = formData.getAll('file') as File[];
|
||||
const notebookId = formData.get('notebookId') as string | null;
|
||||
|
||||
if (files.length === 0) {
|
||||
return NextResponse.json({ error: 'No files provided' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Ensure upload directory exists
|
||||
if (!existsSync(UPLOAD_DIR)) {
|
||||
await mkdir(UPLOAD_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
const importedNotes: { title: string; id: string }[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
if (file.name.endsWith('.zip')) {
|
||||
// ── ZIP of markdown files ──
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
const entries = await extractZip(buffer);
|
||||
|
||||
// First pass: extract attachment files
|
||||
const assetFiles = new Map<string, string>();
|
||||
for (const [name, data] of Array.from(entries.entries())) {
|
||||
if ((name.startsWith('attachments/') || name.startsWith('assets/')) && data.length > 0) {
|
||||
const assetName = name.replace(/^(attachments|assets)\//, '');
|
||||
const ext = path.extname(assetName);
|
||||
const storageKey = `${nanoid(12)}_${assetName.replace(/[^a-zA-Z0-9._-]/g, '_')}`;
|
||||
const filePath = path.join(UPLOAD_DIR, storageKey);
|
||||
await writeFile(filePath, data);
|
||||
|
||||
await prisma.file.create({
|
||||
data: {
|
||||
storageKey,
|
||||
filename: assetName,
|
||||
mimeType: guessMimeType(ext),
|
||||
sizeBytes: data.length,
|
||||
authorId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
assetFiles.set(assetName, storageKey);
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: import markdown files
|
||||
for (const [name, data] of Array.from(entries.entries())) {
|
||||
if (name.endsWith('.md') && data.length > 0) {
|
||||
const filename = path.basename(name);
|
||||
const content = data.toString('utf8');
|
||||
const note = await importMarkdownNote(content, filename, user.id, notebookId);
|
||||
if (note) importedNotes.push(note);
|
||||
}
|
||||
}
|
||||
} else if (file.name.endsWith('.md')) {
|
||||
// ── Single .md file ──
|
||||
const content = await file.text();
|
||||
const note = await importMarkdownNote(content, file.name, user.id, notebookId);
|
||||
if (note) importedNotes.push(note);
|
||||
} else {
|
||||
// Skip non-markdown files
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
imported: importedNotes.length,
|
||||
notes: importedNotes,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Markdown import error:', error);
|
||||
return NextResponse.json({ error: 'Failed to import' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/** Import a single markdown string as a note */
|
||||
async function importMarkdownNote(
|
||||
content: string,
|
||||
filename: string,
|
||||
authorId: string,
|
||||
notebookId: string | null,
|
||||
): Promise<{ title: string; id: string } | null> {
|
||||
const { frontmatter, body } = parseFrontmatter(content);
|
||||
const { title, bodyWithoutTitle } = extractTitle(body, filename);
|
||||
|
||||
if (!title.trim()) return null;
|
||||
|
||||
const bodyMarkdown = bodyWithoutTitle.trim();
|
||||
const cardType = frontmatter.type || 'note';
|
||||
const noteType = cardTypeToNoteType(cardType);
|
||||
|
||||
// Convert markdown → TipTap JSON → HTML for dual-write
|
||||
let bodyJson = null;
|
||||
let htmlContent = '';
|
||||
try {
|
||||
bodyJson = await markdownToTipTapJson(bodyMarkdown);
|
||||
htmlContent = tipTapJsonToHtml(bodyJson);
|
||||
} catch {
|
||||
htmlContent = bodyMarkdown
|
||||
.split('\n\n')
|
||||
.map((p) => `<p>${p.replace(/\n/g, '<br>')}</p>`)
|
||||
.join('');
|
||||
}
|
||||
|
||||
const contentPlain = stripHtml(htmlContent);
|
||||
|
||||
// Find or create tags
|
||||
const tagRecords = [];
|
||||
if (frontmatter.tags) {
|
||||
for (const tagName of frontmatter.tags) {
|
||||
const name = tagName.trim().toLowerCase();
|
||||
if (!name) continue;
|
||||
const tag = await prisma.tag.upsert({
|
||||
where: { spaceId_name: { spaceId: '', name } },
|
||||
update: {},
|
||||
create: { name, spaceId: '' },
|
||||
});
|
||||
tagRecords.push(tag);
|
||||
}
|
||||
}
|
||||
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
title: title.trim(),
|
||||
content: htmlContent,
|
||||
contentPlain,
|
||||
bodyMarkdown,
|
||||
bodyJson: bodyJson ? JSON.parse(JSON.stringify(bodyJson)) : undefined,
|
||||
bodyFormat: 'markdown',
|
||||
cardType,
|
||||
visibility: 'private',
|
||||
properties: {},
|
||||
type: noteType,
|
||||
authorId,
|
||||
notebookId: notebookId || null,
|
||||
url: frontmatter.url || null,
|
||||
language: frontmatter.language || null,
|
||||
isPinned: frontmatter.pinned || false,
|
||||
tags: {
|
||||
create: tagRecords.map((tag) => ({ tagId: tag.id })),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return { title: note.title, id: note.id };
|
||||
}
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
/**
|
||||
* Internal provision endpoint — called by rSpace Registry when activating
|
||||
* this app for a space. No auth required (only reachable from Docker network).
|
||||
*
|
||||
* Payload: { space, description, admin_email, public, owner_did }
|
||||
* The owner_did identifies who registered the space via the registry.
|
||||
*
|
||||
* Creates a default Notebook scoped to the workspace slug + a system collaborator.
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
const body = await request.json();
|
||||
const space: string = body.space?.trim();
|
||||
if (!space) {
|
||||
return NextResponse.json({ error: "Missing space name" }, { status: 400 });
|
||||
}
|
||||
const ownerDid: string = body.owner_did || "";
|
||||
|
||||
// Check if a notebook already exists for this workspace
|
||||
const existing = await prisma.notebook.findFirst({
|
||||
where: { workspaceSlug: space },
|
||||
});
|
||||
if (existing) {
|
||||
return NextResponse.json({ status: "exists", id: existing.id, slug: existing.slug, owner_did: ownerDid });
|
||||
}
|
||||
|
||||
const systemDid = `did:system:${space}`;
|
||||
const user = await prisma.user.upsert({
|
||||
where: { did: systemDid },
|
||||
update: {},
|
||||
create: { did: systemDid, username: `${space}-admin` },
|
||||
});
|
||||
|
||||
const notebook = await prisma.notebook.create({
|
||||
data: {
|
||||
title: `${space.charAt(0).toUpperCase() + space.slice(1)} Notes`,
|
||||
slug: `${space}-notes`,
|
||||
description: body.description || `Shared notes for ${space}`,
|
||||
workspaceSlug: space,
|
||||
isPublic: body.public ?? false,
|
||||
collaborators: {
|
||||
create: { userId: user.id, role: "OWNER" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ status: "created", id: notebook.id, slug: notebook.slug, owner_did: ownerDid }, { status: 201 });
|
||||
}
|
||||
|
|
@ -1,242 +0,0 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
/**
|
||||
* Internal seed endpoint — populates the demo workspace with sample
|
||||
* notebooks, notes, and tags. Only reachable from Docker network.
|
||||
*
|
||||
* POST /api/internal/seed { space: "demo" }
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
const body = await request.json();
|
||||
const spaceSlug: string = body.space?.trim();
|
||||
if (!spaceSlug) {
|
||||
return NextResponse.json({ error: "Missing space" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Check if already seeded
|
||||
const existingNotes = await prisma.note.count({
|
||||
where: { notebook: { workspaceSlug: spaceSlug } },
|
||||
});
|
||||
if (existingNotes > 0) {
|
||||
return NextResponse.json({ status: "already_seeded", notes: existingNotes });
|
||||
}
|
||||
|
||||
// Create demo user
|
||||
const alice = await prisma.user.upsert({
|
||||
where: { did: "did:demo:alice" },
|
||||
update: {},
|
||||
create: { did: "did:demo:alice", username: "Alice" },
|
||||
});
|
||||
|
||||
// ─── Notebook 1: Research ────────────────────────────────────
|
||||
const research = await prisma.notebook.create({
|
||||
data: {
|
||||
title: "Open Source Governance Research",
|
||||
slug: `${spaceSlug}-governance-research`,
|
||||
description: "Patterns and case studies in decentralized decision-making",
|
||||
coverColor: "#3b82f6",
|
||||
isPublic: true,
|
||||
workspaceSlug: spaceSlug,
|
||||
collaborators: { create: { userId: alice.id, role: "OWNER" } },
|
||||
},
|
||||
});
|
||||
|
||||
// Create tags
|
||||
const tagGovernance = await prisma.tag.create({
|
||||
data: { name: "governance", color: "#8b5cf6", spaceId: spaceSlug },
|
||||
});
|
||||
const tagWeb3 = await prisma.tag.create({
|
||||
data: { name: "web3", color: "#06b6d4", spaceId: spaceSlug },
|
||||
});
|
||||
const tagIdeas = await prisma.tag.create({
|
||||
data: { name: "ideas", color: "#f59e0b", spaceId: spaceSlug },
|
||||
});
|
||||
const tagMeeting = await prisma.tag.create({
|
||||
data: { name: "meeting-notes", color: "#10b981", spaceId: spaceSlug },
|
||||
});
|
||||
|
||||
// Notes in research notebook
|
||||
const note1 = await prisma.note.create({
|
||||
data: {
|
||||
notebookId: research.id,
|
||||
authorId: alice.id,
|
||||
title: "Quadratic Voting: Fair Group Decisions",
|
||||
content:
|
||||
"<p>Quadratic voting lets participants express <strong>intensity of preference</strong>, not just direction. Cost of votes scales quadratically — 1 vote costs 1 credit, 2 votes cost 4, 3 cost 9, etc.</p><p>This prevents whale dominance while letting those who care most have a stronger voice. Used by Gitcoin, RadicalxChange, and Colorado state legislature experiments.</p>",
|
||||
type: "NOTE",
|
||||
cardType: "reference",
|
||||
visibility: "public",
|
||||
isPinned: true,
|
||||
position: 1,
|
||||
},
|
||||
});
|
||||
await prisma.noteTag.createMany({
|
||||
data: [
|
||||
{ noteId: note1.id, tagId: tagGovernance.id },
|
||||
{ noteId: note1.id, tagId: tagWeb3.id },
|
||||
],
|
||||
});
|
||||
|
||||
const note2 = await prisma.note.create({
|
||||
data: {
|
||||
notebookId: research.id,
|
||||
authorId: alice.id,
|
||||
title: "Gitcoin Grants: Quadratic Funding in Practice",
|
||||
content:
|
||||
"<p>Gitcoin has distributed $50M+ using quadratic funding. The number of contributors matters more than amount — many small donations get amplified by the matching pool.</p><p>Key insight: QF aligns funding with community preference, not just whale wallets.</p>",
|
||||
type: "BOOKMARK",
|
||||
url: "https://gitcoin.co",
|
||||
cardType: "link",
|
||||
visibility: "public",
|
||||
position: 2,
|
||||
},
|
||||
});
|
||||
await prisma.noteTag.createMany({
|
||||
data: [
|
||||
{ noteId: note2.id, tagId: tagGovernance.id },
|
||||
{ noteId: note2.id, tagId: tagWeb3.id },
|
||||
],
|
||||
});
|
||||
|
||||
await prisma.note.create({
|
||||
data: {
|
||||
notebookId: research.id,
|
||||
authorId: alice.id,
|
||||
title: "DAO Treasury Management Patterns",
|
||||
content:
|
||||
"<p>Multi-sig wallets (like Safe) reduce speed but increase trust. Common patterns:</p><ul><li><strong>3-of-5</strong> for small DAOs — fast enough, secure enough</li><li><strong>Streaming payments</strong> via Superfluid for ongoing commitments</li><li><strong>Retroactive funding</strong> — reward impact after it's proven</li></ul><p>The hardest part isn't the tech — it's agreeing on priorities.</p>",
|
||||
type: "NOTE",
|
||||
cardType: "idea",
|
||||
visibility: "space",
|
||||
position: 3,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.note.create({
|
||||
data: {
|
||||
notebookId: research.id,
|
||||
authorId: alice.id,
|
||||
title: "Consent-Based Decision Making",
|
||||
content:
|
||||
"<p>Unlike consensus (everyone agrees), consent means <em>no one has a principled objection</em>. Much faster for groups.</p><p>Process: Propose → Clarify → React → Amend → Consent check. If no objections, proceed. Objections are gifts — they reveal risks.</p>",
|
||||
type: "NOTE",
|
||||
cardType: "reference",
|
||||
visibility: "public",
|
||||
position: 4,
|
||||
},
|
||||
});
|
||||
|
||||
// ─── Notebook 2: Meeting Notes ────────────────────────────────
|
||||
const meetings = await prisma.notebook.create({
|
||||
data: {
|
||||
title: "Community Meetings",
|
||||
slug: `${spaceSlug}-meetings`,
|
||||
description: "Notes and action items from our regular check-ins",
|
||||
coverColor: "#f59e0b",
|
||||
isPublic: true,
|
||||
workspaceSlug: spaceSlug,
|
||||
collaborators: { create: { userId: alice.id, role: "OWNER" } },
|
||||
},
|
||||
});
|
||||
|
||||
const meeting1 = await prisma.note.create({
|
||||
data: {
|
||||
notebookId: meetings.id,
|
||||
authorId: alice.id,
|
||||
title: "Weekly Standup — Feb 24",
|
||||
content:
|
||||
"<h3>Attendees</h3><p>Alice, Bob, Charlie</p><h3>Updates</h3><ul><li><strong>Alice:</strong> Finished governance research, ready to implement voting</li><li><strong>Bob:</strong> Garden supplies cart is 65% funded</li><li><strong>Charlie:</strong> Whiteboard arrived — will install Thursday</li></ul><h3>Blockers</h3><p>None this week</p>",
|
||||
type: "NOTE",
|
||||
cardType: "note",
|
||||
visibility: "space",
|
||||
isPinned: true,
|
||||
position: 1,
|
||||
},
|
||||
});
|
||||
await prisma.noteTag.create({
|
||||
data: { noteId: meeting1.id, tagId: tagMeeting.id },
|
||||
});
|
||||
|
||||
// Action items as child notes
|
||||
await prisma.note.create({
|
||||
data: {
|
||||
notebookId: meetings.id,
|
||||
authorId: alice.id,
|
||||
parentId: meeting1.id,
|
||||
title: "Action: Set up quadratic voting for treasury proposals",
|
||||
content: "<p>Alice to configure rvote with 7-day voting period and 50 starting credits per member.</p>",
|
||||
type: "NOTE",
|
||||
cardType: "task",
|
||||
visibility: "space",
|
||||
position: 1.1,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.note.create({
|
||||
data: {
|
||||
notebookId: meetings.id,
|
||||
authorId: alice.id,
|
||||
parentId: meeting1.id,
|
||||
title: "Action: Share garden cart link with broader community",
|
||||
content: "<p>Bob to post the rcart link in rsocials to get more contributors.</p>",
|
||||
type: "NOTE",
|
||||
cardType: "task",
|
||||
visibility: "space",
|
||||
position: 1.2,
|
||||
},
|
||||
});
|
||||
|
||||
// ─── Notebook 3: Ideas ────────────────────────────────────────
|
||||
const ideas = await prisma.notebook.create({
|
||||
data: {
|
||||
title: "Project Ideas",
|
||||
slug: `${spaceSlug}-ideas`,
|
||||
description: "Brainstorms, what-ifs, and possibilities",
|
||||
coverColor: "#ec4899",
|
||||
isPublic: true,
|
||||
workspaceSlug: spaceSlug,
|
||||
collaborators: { create: { userId: alice.id, role: "OWNER" } },
|
||||
},
|
||||
});
|
||||
|
||||
const idea1 = await prisma.note.create({
|
||||
data: {
|
||||
notebookId: ideas.id,
|
||||
authorId: alice.id,
|
||||
title: "Community Currency / Time Banking",
|
||||
content:
|
||||
"<p>What if we tracked contributions in a local currency? Members earn credits for volunteering, teaching, or helping — then spend them on services from other members.</p><p><strong>Tools to explore:</strong> Circles UBI, Grassroots Economics, or a simple ledger in rcart.</p>",
|
||||
type: "NOTE",
|
||||
cardType: "idea",
|
||||
visibility: "public",
|
||||
isPinned: true,
|
||||
position: 1,
|
||||
},
|
||||
});
|
||||
await prisma.noteTag.create({
|
||||
data: { noteId: idea1.id, tagId: tagIdeas.id },
|
||||
});
|
||||
|
||||
await prisma.note.create({
|
||||
data: {
|
||||
notebookId: ideas.id,
|
||||
authorId: alice.id,
|
||||
title: "Skill-Share Wednesdays",
|
||||
content:
|
||||
"<p>Rotating weekly sessions where someone teaches something they know. Could be cooking, coding, gardening, music — anything. Low pressure, high connection.</p>",
|
||||
type: "NOTE",
|
||||
cardType: "idea",
|
||||
visibility: "public",
|
||||
position: 2,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
status: "seeded",
|
||||
space: spaceSlug,
|
||||
notebooks: 3,
|
||||
notes: 9,
|
||||
tags: 4,
|
||||
}, { status: 201 });
|
||||
}
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { downloadFromIPFS, ipfsGatewayUrl } from '@/lib/ipfs';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
// Simple LRU cache for decrypted content
|
||||
const cache = new Map<string, { data: Buffer; mimeType: string; ts: number }>();
|
||||
const MAX_CACHE = 100;
|
||||
const CACHE_TTL = 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
function evictStale() {
|
||||
if (cache.size <= MAX_CACHE) return;
|
||||
const now = Date.now();
|
||||
const keys = Array.from(cache.keys());
|
||||
for (const k of keys) {
|
||||
const entry = cache.get(k);
|
||||
if (!entry || now - entry.ts > CACHE_TTL || cache.size > MAX_CACHE) {
|
||||
cache.delete(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ cid: string }> }
|
||||
) {
|
||||
const { cid } = await params;
|
||||
const key = request.nextUrl.searchParams.get('key');
|
||||
|
||||
if (!cid || !/^[a-zA-Z0-9]+$/.test(cid)) {
|
||||
return NextResponse.json({ error: 'Invalid CID' }, { status: 400 });
|
||||
}
|
||||
|
||||
// No key = redirect to public gateway (serves encrypted blob)
|
||||
if (!key) {
|
||||
return NextResponse.redirect(ipfsGatewayUrl(cid));
|
||||
}
|
||||
|
||||
// Check cache
|
||||
const cached = cache.get(cid);
|
||||
if (cached && Date.now() - cached.ts < CACHE_TTL) {
|
||||
return new NextResponse(cached.data as unknown as BodyInit, {
|
||||
headers: {
|
||||
'Content-Type': cached.mimeType,
|
||||
'Cache-Control': 'private, max-age=3600',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Look up mime type from DB
|
||||
const file = await prisma.file.findFirst({
|
||||
where: { ipfsCid: cid } as Record<string, unknown>,
|
||||
select: { mimeType: true },
|
||||
});
|
||||
const mimeType = file?.mimeType || 'application/octet-stream';
|
||||
|
||||
// Download and decrypt
|
||||
const decrypted = await downloadFromIPFS(cid, key);
|
||||
const buf = Buffer.from(decrypted.buffer as ArrayBuffer);
|
||||
|
||||
// Cache result
|
||||
evictStale();
|
||||
cache.set(cid, { data: buf, mimeType, ts: Date.now() });
|
||||
|
||||
return new NextResponse(buf as unknown as BodyInit, {
|
||||
headers: {
|
||||
'Content-Type': mimeType,
|
||||
'Cache-Control': 'private, max-age=3600',
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('IPFS proxy error:', err);
|
||||
return NextResponse.json({ error: 'Failed to fetch from IPFS' }, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,52 +1,22 @@
|
|||
/**
|
||||
* /api/me — Returns current user's auth status.
|
||||
*
|
||||
* Checks for EncryptID token in Authorization header or cookie,
|
||||
* then verifies it against the EncryptID server.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const ENCRYPTID_URL = process.env.ENCRYPTID_URL || 'https://auth.ridentity.online';
|
||||
import { verifyEncryptIDToken, extractToken } from '@/lib/encryptid';
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
// Extract token from Authorization header or cookie
|
||||
const auth = req.headers.get('Authorization');
|
||||
let token: string | null = null;
|
||||
|
||||
if (auth?.startsWith('Bearer ')) {
|
||||
token = auth.slice(7);
|
||||
} else {
|
||||
const tokenCookie = req.cookies.get('encryptid_token');
|
||||
if (tokenCookie) token = tokenCookie.value;
|
||||
}
|
||||
|
||||
const token = extractToken(req.headers, req.cookies);
|
||||
if (!token) {
|
||||
return NextResponse.json({ authenticated: false });
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${ENCRYPTID_URL}/api/session/verify`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json({ authenticated: false });
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
if (data.valid) {
|
||||
return NextResponse.json({
|
||||
authenticated: true,
|
||||
user: {
|
||||
username: data.username || null,
|
||||
did: data.did || data.userId || null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ authenticated: false });
|
||||
} catch {
|
||||
const claims = await verifyEncryptIDToken(token);
|
||||
if (!claims) {
|
||||
return NextResponse.json({ authenticated: false });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
authenticated: true,
|
||||
user: {
|
||||
username: claims.username || null,
|
||||
did: claims.did,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,108 +0,0 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { pushShapesToCanvas } from '@/lib/canvas-sync';
|
||||
import { requireAuth, isAuthed, getNotebookRole } from '@/lib/auth';
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const auth = await requireAuth(request);
|
||||
if (!isAuthed(auth)) return auth;
|
||||
const { user } = auth;
|
||||
const role = await getNotebookRole(user.id, params.id);
|
||||
if (!role || role === 'VIEWER') {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const notebook = await prisma.notebook.findUnique({
|
||||
where: { id: params.id },
|
||||
include: {
|
||||
notes: {
|
||||
where: { archivedAt: null },
|
||||
include: {
|
||||
tags: { include: { tag: true } },
|
||||
children: { select: { id: true }, where: { archivedAt: null } },
|
||||
attachments: { select: { id: true } },
|
||||
},
|
||||
orderBy: [{ isPinned: 'desc' }, { sortOrder: 'asc' }, { updatedAt: 'desc' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!notebook) {
|
||||
return NextResponse.json({ error: 'Notebook not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const canvasSlug = notebook.canvasSlug || notebook.slug;
|
||||
const shapes: Record<string, unknown>[] = [];
|
||||
|
||||
// Notebook shape (top-left)
|
||||
shapes.push({
|
||||
type: 'folk-notebook',
|
||||
x: 50,
|
||||
y: 50,
|
||||
width: 350,
|
||||
height: 300,
|
||||
notebookTitle: notebook.title,
|
||||
description: notebook.description || '',
|
||||
noteCount: notebook.notes.length,
|
||||
coverColor: notebook.coverColor,
|
||||
notebookId: notebook.id,
|
||||
});
|
||||
|
||||
// Note shapes (grid layout, 4 columns)
|
||||
const colWidth = 320;
|
||||
const rowHeight = 220;
|
||||
const cols = 4;
|
||||
const startX = 450;
|
||||
const startY = 50;
|
||||
|
||||
notebook.notes.forEach((note, i) => {
|
||||
const col = i % cols;
|
||||
const row = Math.floor(i / cols);
|
||||
|
||||
shapes.push({
|
||||
type: 'folk-note',
|
||||
x: startX + col * colWidth,
|
||||
y: startY + row * rowHeight,
|
||||
width: 300,
|
||||
height: 200,
|
||||
noteTitle: note.title,
|
||||
noteType: note.type,
|
||||
snippet: (note.contentPlain || note.content || '').slice(0, 200),
|
||||
url: note.url || '',
|
||||
tags: note.tags.map((nt) => nt.tag.name),
|
||||
noteId: note.id,
|
||||
// Memory Card enrichments
|
||||
cardType: note.cardType,
|
||||
summary: note.summary || '',
|
||||
visibility: note.visibility,
|
||||
properties: note.properties || {},
|
||||
parentId: note.parentId || '',
|
||||
hasChildren: note.children.length > 0,
|
||||
childCount: note.children.length,
|
||||
attachmentCount: note.attachments.length,
|
||||
});
|
||||
});
|
||||
|
||||
await pushShapesToCanvas(canvasSlug, shapes);
|
||||
|
||||
if (!notebook.canvasSlug) {
|
||||
await prisma.notebook.update({
|
||||
where: { id: notebook.id },
|
||||
data: { canvasSlug },
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
canvasSlug,
|
||||
shapesCreated: shapes.length,
|
||||
canvasUrl: `https://${canvasSlug}.rspace.online`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Create canvas error:', error);
|
||||
return NextResponse.json({ error: 'Failed to create canvas' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,143 +0,0 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { stripHtml } from '@/lib/strip-html';
|
||||
import { requireAuth, isAuthed, getNotebookRole } from '@/lib/auth';
|
||||
import { htmlToTipTapJson, tipTapJsonToMarkdown, mapNoteTypeToCardType } from '@/lib/content-convert';
|
||||
import { getWorkspaceSlug } from '@/lib/workspace';
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const workspaceSlug = getWorkspaceSlug();
|
||||
|
||||
// Verify notebook belongs to current workspace
|
||||
if (workspaceSlug) {
|
||||
const notebook = await prisma.notebook.findUnique({
|
||||
where: { id: params.id },
|
||||
select: { workspaceSlug: true },
|
||||
});
|
||||
if (!notebook || notebook.workspaceSlug !== workspaceSlug) {
|
||||
return NextResponse.json({ error: 'Notebook not found' }, { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
const notes = await prisma.note.findMany({
|
||||
where: { notebookId: params.id, archivedAt: null },
|
||||
include: {
|
||||
tags: { include: { tag: true } },
|
||||
parent: { select: { id: true, title: true } },
|
||||
children: { select: { id: true, title: true, cardType: true }, where: { archivedAt: null } },
|
||||
attachments: { include: { file: true }, orderBy: { position: 'asc' } },
|
||||
},
|
||||
orderBy: [{ isPinned: 'desc' }, { sortOrder: 'asc' }, { updatedAt: 'desc' }],
|
||||
});
|
||||
|
||||
return NextResponse.json(notes);
|
||||
} catch (error) {
|
||||
console.error('List notebook notes error:', error);
|
||||
return NextResponse.json({ error: 'Failed to list notes' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const auth = await requireAuth(request);
|
||||
if (!isAuthed(auth)) return auth;
|
||||
const { user } = auth;
|
||||
const role = await getNotebookRole(user.id, params.id);
|
||||
if (!role || role === 'VIEWER') {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const {
|
||||
title, content, type, url, language, tags, fileUrl, mimeType, fileSize, duration,
|
||||
parentId, cardType: cardTypeOverride, visibility, properties, summary, position, bodyJson: clientBodyJson,
|
||||
} = body;
|
||||
|
||||
if (!title?.trim()) {
|
||||
return NextResponse.json({ error: 'Title is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const contentPlain = content ? stripHtml(content) : null;
|
||||
|
||||
// Dual-write
|
||||
let bodyJson = clientBodyJson || null;
|
||||
let bodyMarkdown: string | null = null;
|
||||
let bodyFormat = 'html';
|
||||
|
||||
if (clientBodyJson) {
|
||||
bodyJson = clientBodyJson;
|
||||
bodyMarkdown = tipTapJsonToMarkdown(clientBodyJson);
|
||||
bodyFormat = 'blocks';
|
||||
} else if (content) {
|
||||
bodyJson = htmlToTipTapJson(content);
|
||||
bodyMarkdown = tipTapJsonToMarkdown(bodyJson);
|
||||
}
|
||||
|
||||
const noteType = type || 'NOTE';
|
||||
const resolvedCardType = cardTypeOverride || mapNoteTypeToCardType(noteType);
|
||||
|
||||
// Find or create tags
|
||||
const tagRecords = [];
|
||||
if (tags && Array.isArray(tags)) {
|
||||
for (const tagName of tags) {
|
||||
const name = tagName.trim().toLowerCase();
|
||||
if (!name) continue;
|
||||
const tag = await prisma.tag.upsert({
|
||||
where: { spaceId_name: { spaceId: '', name } },
|
||||
update: {},
|
||||
create: { name, spaceId: '' },
|
||||
});
|
||||
tagRecords.push(tag);
|
||||
}
|
||||
}
|
||||
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
notebookId: params.id,
|
||||
authorId: user.id,
|
||||
title: title.trim(),
|
||||
content: content || '',
|
||||
contentPlain,
|
||||
type: noteType,
|
||||
url: url || null,
|
||||
language: language || null,
|
||||
fileUrl: fileUrl || null,
|
||||
mimeType: mimeType || null,
|
||||
fileSize: fileSize || null,
|
||||
duration: duration || null,
|
||||
bodyJson: bodyJson || undefined,
|
||||
bodyMarkdown,
|
||||
bodyFormat,
|
||||
cardType: resolvedCardType,
|
||||
parentId: parentId || null,
|
||||
visibility: visibility || 'private',
|
||||
properties: properties || {},
|
||||
summary: summary || null,
|
||||
position: position ?? null,
|
||||
tags: {
|
||||
create: tagRecords.map((tag) => ({
|
||||
tagId: tag.id,
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
tags: { include: { tag: true } },
|
||||
parent: { select: { id: true, title: true } },
|
||||
children: { select: { id: true, title: true, cardType: true }, where: { archivedAt: null } },
|
||||
attachments: { include: { file: true }, orderBy: { position: 'asc' } },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(note, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('Create note error:', error);
|
||||
return NextResponse.json({ error: 'Failed to create note' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { requireAuth, isAuthed, getNotebookRole } from '@/lib/auth';
|
||||
import { getWorkspaceSlug } from '@/lib/workspace';
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const workspaceSlug = getWorkspaceSlug();
|
||||
|
||||
const notebook = await prisma.notebook.findUnique({
|
||||
where: { id: params.id },
|
||||
include: {
|
||||
notes: {
|
||||
include: {
|
||||
tags: { include: { tag: true } },
|
||||
},
|
||||
where: { archivedAt: null },
|
||||
orderBy: [{ isPinned: 'desc' }, { sortOrder: 'asc' }, { updatedAt: 'desc' }],
|
||||
},
|
||||
collaborators: {
|
||||
include: { user: { select: { id: true, username: true } } },
|
||||
},
|
||||
_count: { select: { notes: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!notebook) {
|
||||
return NextResponse.json({ error: 'Notebook not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Workspace boundary check: if on a subdomain, only show notebooks from that workspace
|
||||
if (workspaceSlug && notebook.workspaceSlug !== workspaceSlug) {
|
||||
return NextResponse.json({ error: 'Notebook not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json(notebook);
|
||||
} catch (error) {
|
||||
console.error('Get notebook error:', error);
|
||||
return NextResponse.json({ error: 'Failed to get notebook' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const auth = await requireAuth(request);
|
||||
if (!isAuthed(auth)) return auth;
|
||||
const { user } = auth;
|
||||
const role = await getNotebookRole(user.id, params.id);
|
||||
if (!role || role === 'VIEWER') {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { title, description, coverColor, isPublic } = body;
|
||||
|
||||
const notebook = await prisma.notebook.update({
|
||||
where: { id: params.id },
|
||||
data: {
|
||||
...(title !== undefined && { title: title.trim() }),
|
||||
...(description !== undefined && { description: description?.trim() || null }),
|
||||
...(coverColor !== undefined && { coverColor }),
|
||||
...(isPublic !== undefined && { isPublic }),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(notebook);
|
||||
} catch (error) {
|
||||
console.error('Update notebook error:', error);
|
||||
return NextResponse.json({ error: 'Failed to update notebook' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const auth = await requireAuth(request);
|
||||
if (!isAuthed(auth)) return auth;
|
||||
const { user } = auth;
|
||||
const role = await getNotebookRole(user.id, params.id);
|
||||
if (role !== 'OWNER') {
|
||||
return NextResponse.json({ error: 'Only the owner can delete a notebook' }, { status: 403 });
|
||||
}
|
||||
|
||||
await prisma.notebook.delete({ where: { id: params.id } });
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
console.error('Delete notebook error:', error);
|
||||
return NextResponse.json({ error: 'Failed to delete notebook' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { verifyEncryptIDToken, extractToken } from '@/lib/encryptid';
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ notebookId: string }> }
|
||||
) {
|
||||
const { notebookId } = await params;
|
||||
const token = extractToken(req.headers, req.cookies);
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const claims = await verifyEncryptIDToken(token);
|
||||
if (!claims) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
let user = await prisma.user.findUnique({ where: { did: claims.did } });
|
||||
if (!user) {
|
||||
user = await prisma.user.create({
|
||||
data: {
|
||||
did: claims.did,
|
||||
username: claims.username,
|
||||
name: claims.username,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
|
||||
// Get next sort order
|
||||
const lastNote = await prisma.note.findFirst({
|
||||
where: { notebookId },
|
||||
orderBy: { sortOrder: 'desc' },
|
||||
});
|
||||
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
title: body.title || 'Untitled',
|
||||
notebookId,
|
||||
createdBy: user.id,
|
||||
sortOrder: (lastNote?.sortOrder || 0) + 1,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
id: note.id,
|
||||
title: note.title,
|
||||
yjsDocId: note.yjsDocId,
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ notebookId: string }> }
|
||||
) {
|
||||
const { notebookId } = await params;
|
||||
|
||||
const notebook = await prisma.notebook.findUnique({
|
||||
where: { id: notebookId },
|
||||
include: {
|
||||
notes: {
|
||||
select: { id: true, title: true, updatedAt: true },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!notebook) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
id: notebook.id,
|
||||
title: notebook.title,
|
||||
icon: notebook.icon,
|
||||
description: notebook.description,
|
||||
notes: notebook.notes.map((n) => ({
|
||||
id: n.id,
|
||||
title: n.title,
|
||||
updatedAt: n.updatedAt.toISOString(),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ noteId: string }> }
|
||||
) {
|
||||
const { noteId } = await params;
|
||||
|
||||
const note = await prisma.note.findUnique({
|
||||
where: { id: noteId },
|
||||
include: {
|
||||
notebook: { select: { id: true, title: true, space: { select: { slug: true } } } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!note) {
|
||||
return NextResponse.json({ error: 'Note not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
id: note.id,
|
||||
title: note.title,
|
||||
yjsDocId: note.yjsDocId,
|
||||
notebookId: note.notebook.id,
|
||||
notebookTitle: note.notebook.title,
|
||||
spaceSlug: note.notebook.space.slug,
|
||||
});
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ noteId: string }> }
|
||||
) {
|
||||
const { noteId } = await params;
|
||||
const body = await req.json();
|
||||
|
||||
const note = await prisma.note.update({
|
||||
where: { id: noteId },
|
||||
data: {
|
||||
title: body.title,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ id: note.id, title: note.title });
|
||||
}
|
||||
|
|
@ -1,75 +1,109 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { generateSlug } from '@/lib/slug';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { requireAuth, isAuthed } from '@/lib/auth';
|
||||
import { getWorkspaceSlug } from '@/lib/workspace';
|
||||
import { verifyEncryptIDToken, extractToken } from '@/lib/encryptid';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const workspaceSlug = getWorkspaceSlug();
|
||||
export async function GET(req: NextRequest) {
|
||||
const token = extractToken(req.headers, req.cookies);
|
||||
let userId: string | null = null;
|
||||
|
||||
const where: Record<string, unknown> = {};
|
||||
if (workspaceSlug) {
|
||||
// On a subdomain: show only that workspace's notebooks
|
||||
where.workspaceSlug = workspaceSlug;
|
||||
if (token) {
|
||||
const claims = await verifyEncryptIDToken(token);
|
||||
if (claims) {
|
||||
const user = await prisma.user.findUnique({ where: { did: claims.did } });
|
||||
userId = user?.id || null;
|
||||
}
|
||||
// On bare domain: show all notebooks (cross-workspace view)
|
||||
|
||||
const notebooks = await prisma.notebook.findMany({
|
||||
where,
|
||||
include: {
|
||||
_count: { select: { notes: true } },
|
||||
collaborators: {
|
||||
include: { user: { select: { id: true, username: true } } },
|
||||
},
|
||||
},
|
||||
orderBy: [{ sortOrder: 'asc' }, { updatedAt: 'desc' }],
|
||||
});
|
||||
|
||||
return NextResponse.json(notebooks);
|
||||
} catch (error) {
|
||||
console.error('List notebooks error:', error);
|
||||
return NextResponse.json({ error: 'Failed to list notebooks' }, { status: 500 });
|
||||
}
|
||||
|
||||
// Return notebooks the user has access to
|
||||
const notebooks = await prisma.notebook.findMany({
|
||||
include: {
|
||||
_count: { select: { notes: true } },
|
||||
space: { select: { slug: true } },
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
notebooks: notebooks.map((nb) => ({
|
||||
id: nb.id,
|
||||
title: nb.title,
|
||||
description: nb.description,
|
||||
icon: nb.icon,
|
||||
noteCount: nb._count.notes,
|
||||
collaborators: 0,
|
||||
updatedAt: nb.updatedAt.toISOString(),
|
||||
spaceSlug: nb.space.slug,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const auth = await requireAuth(request);
|
||||
if (!isAuthed(auth)) return auth;
|
||||
const { user } = auth;
|
||||
const workspaceSlug = getWorkspaceSlug();
|
||||
const body = await request.json();
|
||||
const { title, description, coverColor } = body;
|
||||
export async function POST(req: NextRequest) {
|
||||
const token = extractToken(req.headers, req.cookies);
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (!title?.trim()) {
|
||||
return NextResponse.json({ error: 'Title is required' }, { status: 400 });
|
||||
}
|
||||
const claims = await verifyEncryptIDToken(token);
|
||||
if (!claims) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const baseSlug = generateSlug(title);
|
||||
const slug = baseSlug || nanoid(8);
|
||||
|
||||
// Ensure unique slug
|
||||
const existing = await prisma.notebook.findUnique({ where: { slug } });
|
||||
const finalSlug = existing ? `${slug}-${nanoid(4)}` : slug;
|
||||
|
||||
const notebook = await prisma.notebook.create({
|
||||
// Ensure user exists
|
||||
let user = await prisma.user.findUnique({ where: { did: claims.did } });
|
||||
if (!user) {
|
||||
user = await prisma.user.create({
|
||||
data: {
|
||||
title: title.trim(),
|
||||
slug: finalSlug,
|
||||
description: description?.trim() || null,
|
||||
coverColor: coverColor || '#f59e0b',
|
||||
workspaceSlug: workspaceSlug || '',
|
||||
collaborators: {
|
||||
create: { userId: user.id, role: 'OWNER' },
|
||||
},
|
||||
did: claims.did,
|
||||
username: claims.username,
|
||||
name: claims.username,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(notebook, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('Create notebook error:', error);
|
||||
return NextResponse.json({ error: 'Failed to create notebook' }, { status: 500 });
|
||||
}
|
||||
|
||||
// Ensure default space exists
|
||||
let space = await prisma.space.findUnique({ where: { slug: 'default' } });
|
||||
if (!space) {
|
||||
space = await prisma.space.create({
|
||||
data: {
|
||||
slug: 'default',
|
||||
name: 'rNotes',
|
||||
ownerDid: claims.did,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const notebook = await prisma.notebook.create({
|
||||
data: {
|
||||
title: body.title || 'Untitled Notebook',
|
||||
description: body.description || '',
|
||||
icon: body.icon || '📓',
|
||||
spaceId: space.id,
|
||||
createdBy: user.id,
|
||||
},
|
||||
include: {
|
||||
_count: { select: { notes: true } },
|
||||
space: { select: { slug: true } },
|
||||
},
|
||||
});
|
||||
|
||||
// Create a first note in the notebook
|
||||
await prisma.note.create({
|
||||
data: {
|
||||
title: 'Getting Started',
|
||||
notebookId: notebook.id,
|
||||
createdBy: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
id: notebook.id,
|
||||
title: notebook.title,
|
||||
description: notebook.description,
|
||||
icon: notebook.icon,
|
||||
noteCount: 1,
|
||||
collaborators: 0,
|
||||
updatedAt: notebook.updatedAt.toISOString(),
|
||||
spaceSlug: notebook.space.slug,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,104 +0,0 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { requireAuth, isAuthed } from '@/lib/auth';
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const attachments = await prisma.cardAttachment.findMany({
|
||||
where: { noteId: params.id },
|
||||
include: { file: true },
|
||||
orderBy: { position: 'asc' },
|
||||
});
|
||||
|
||||
return NextResponse.json(attachments);
|
||||
} catch (error) {
|
||||
console.error('List attachments error:', error);
|
||||
return NextResponse.json({ error: 'Failed to list attachments' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const auth = await requireAuth(request);
|
||||
if (!isAuthed(auth)) return auth;
|
||||
|
||||
const body = await request.json();
|
||||
const { fileId, role, caption, position } = body;
|
||||
|
||||
if (!fileId) {
|
||||
return NextResponse.json({ error: 'fileId is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Verify note exists
|
||||
const note = await prisma.note.findUnique({
|
||||
where: { id: params.id },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!note) {
|
||||
return NextResponse.json({ error: 'Note not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Verify file exists
|
||||
const file = await prisma.file.findUnique({
|
||||
where: { id: fileId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: 'File not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const attachment = await prisma.cardAttachment.upsert({
|
||||
where: { noteId_fileId: { noteId: params.id, fileId } },
|
||||
update: {
|
||||
role: role || 'supporting',
|
||||
caption: caption || null,
|
||||
position: position ?? 0,
|
||||
},
|
||||
create: {
|
||||
noteId: params.id,
|
||||
fileId,
|
||||
role: role || 'supporting',
|
||||
caption: caption || null,
|
||||
position: position ?? 0,
|
||||
},
|
||||
include: { file: true },
|
||||
});
|
||||
|
||||
return NextResponse.json(attachment, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('Create attachment error:', error);
|
||||
return NextResponse.json({ error: 'Failed to create attachment' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const auth = await requireAuth(request);
|
||||
if (!isAuthed(auth)) return auth;
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const fileId = searchParams.get('fileId');
|
||||
|
||||
if (!fileId) {
|
||||
return NextResponse.json({ error: 'fileId query parameter required' }, { status: 400 });
|
||||
}
|
||||
|
||||
await prisma.cardAttachment.delete({
|
||||
where: { noteId_fileId: { noteId: params.id, fileId } },
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
console.error('Delete attachment error:', error);
|
||||
return NextResponse.json({ error: 'Failed to delete attachment' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const children = await prisma.note.findMany({
|
||||
where: { parentId: params.id, archivedAt: null },
|
||||
include: {
|
||||
tags: { include: { tag: true } },
|
||||
children: {
|
||||
select: { id: true },
|
||||
where: { archivedAt: null },
|
||||
},
|
||||
},
|
||||
orderBy: [{ position: 'asc' }, { updatedAt: 'desc' }],
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
children.map((c) => ({
|
||||
...c,
|
||||
childCount: c.children.length,
|
||||
children: undefined,
|
||||
}))
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('List children error:', error);
|
||||
return NextResponse.json({ error: 'Failed to list children' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,183 +0,0 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { stripHtml } from '@/lib/strip-html';
|
||||
import { requireAuth, isAuthed } from '@/lib/auth';
|
||||
import { htmlToTipTapJson, tipTapJsonToHtml, tipTapJsonToMarkdown, mapNoteTypeToCardType } from '@/lib/content-convert';
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const note = await prisma.note.findUnique({
|
||||
where: { id: params.id },
|
||||
include: {
|
||||
tags: { include: { tag: true } },
|
||||
notebook: { select: { id: true, title: true, slug: true } },
|
||||
author: { select: { id: true, username: true } },
|
||||
parent: { select: { id: true, title: true, cardType: true } },
|
||||
children: {
|
||||
select: { id: true, title: true, cardType: true },
|
||||
where: { archivedAt: null },
|
||||
orderBy: { position: 'asc' },
|
||||
},
|
||||
attachments: {
|
||||
include: { file: true },
|
||||
orderBy: { position: 'asc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!note) {
|
||||
return NextResponse.json({ error: 'Note not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json(note);
|
||||
} catch (error) {
|
||||
console.error('Get note error:', error);
|
||||
return NextResponse.json({ error: 'Failed to get note' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const auth = await requireAuth(request);
|
||||
if (!isAuthed(auth)) return auth;
|
||||
const { user } = auth;
|
||||
|
||||
const existing = await prisma.note.findUnique({
|
||||
where: { id: params.id },
|
||||
select: { authorId: true },
|
||||
});
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'Note not found' }, { status: 404 });
|
||||
}
|
||||
if (existing.authorId && existing.authorId !== user.id) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const {
|
||||
title, content, type, url, archiveUrl, language, isPinned, notebookId, tags,
|
||||
// Memory Card fields
|
||||
parentId, cardType, visibility, properties, summary, position,
|
||||
bodyJson: clientBodyJson,
|
||||
} = body;
|
||||
|
||||
const data: Record<string, unknown> = {};
|
||||
if (title !== undefined) data.title = title.trim();
|
||||
if (type !== undefined) data.type = type;
|
||||
if (url !== undefined) data.url = url || null;
|
||||
if (archiveUrl !== undefined) data.archiveUrl = archiveUrl || null;
|
||||
if (language !== undefined) data.language = language || null;
|
||||
if (isPinned !== undefined) data.isPinned = isPinned;
|
||||
if (notebookId !== undefined) data.notebookId = notebookId || null;
|
||||
|
||||
// Memory Card field updates
|
||||
if (parentId !== undefined) data.parentId = parentId || null;
|
||||
if (cardType !== undefined) data.cardType = cardType;
|
||||
if (visibility !== undefined) data.visibility = visibility;
|
||||
if (properties !== undefined) data.properties = properties;
|
||||
if (summary !== undefined) data.summary = summary || null;
|
||||
if (position !== undefined) data.position = position;
|
||||
|
||||
// Dual-write: if client sends bodyJson, it's canonical
|
||||
if (clientBodyJson) {
|
||||
data.bodyJson = clientBodyJson;
|
||||
data.content = tipTapJsonToHtml(clientBodyJson);
|
||||
data.bodyMarkdown = tipTapJsonToMarkdown(clientBodyJson);
|
||||
data.contentPlain = stripHtml(data.content as string);
|
||||
data.bodyFormat = 'blocks';
|
||||
} else if (content !== undefined) {
|
||||
// HTML content — compute all derived formats
|
||||
data.content = content;
|
||||
data.contentPlain = stripHtml(content);
|
||||
const json = htmlToTipTapJson(content);
|
||||
data.bodyJson = json;
|
||||
data.bodyMarkdown = tipTapJsonToMarkdown(json);
|
||||
}
|
||||
|
||||
// If type changed, update cardType too (unless explicitly set)
|
||||
if (type !== undefined && cardType === undefined) {
|
||||
data.cardType = mapNoteTypeToCardType(type);
|
||||
}
|
||||
|
||||
// Handle tag updates: replace all tags
|
||||
if (tags !== undefined && Array.isArray(tags)) {
|
||||
await prisma.noteTag.deleteMany({ where: { noteId: params.id } });
|
||||
|
||||
for (const tagName of tags) {
|
||||
const name = tagName.trim().toLowerCase();
|
||||
if (!name) continue;
|
||||
const tag = await prisma.tag.upsert({
|
||||
where: { spaceId_name: { spaceId: '', name } },
|
||||
update: {},
|
||||
create: { name, spaceId: '' },
|
||||
});
|
||||
await prisma.noteTag.create({
|
||||
data: { noteId: params.id, tagId: tag.id },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const note = await prisma.note.update({
|
||||
where: { id: params.id },
|
||||
data,
|
||||
include: {
|
||||
tags: { include: { tag: true } },
|
||||
notebook: { select: { id: true, title: true, slug: true } },
|
||||
parent: { select: { id: true, title: true, cardType: true } },
|
||||
children: {
|
||||
select: { id: true, title: true, cardType: true },
|
||||
where: { archivedAt: null },
|
||||
orderBy: { position: 'asc' },
|
||||
},
|
||||
attachments: {
|
||||
include: { file: true },
|
||||
orderBy: { position: 'asc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(note);
|
||||
} catch (error) {
|
||||
console.error('Update note error:', error);
|
||||
return NextResponse.json({ error: 'Failed to update note' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const auth = await requireAuth(request);
|
||||
if (!isAuthed(auth)) return auth;
|
||||
const { user } = auth;
|
||||
|
||||
const existing = await prisma.note.findUnique({
|
||||
where: { id: params.id },
|
||||
select: { authorId: true },
|
||||
});
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'Note not found' }, { status: 404 });
|
||||
}
|
||||
if (existing.authorId && existing.authorId !== user.id) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Soft-delete: set archivedAt instead of deleting
|
||||
await prisma.note.update({
|
||||
where: { id: params.id },
|
||||
data: { archivedAt: new Date() },
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
console.error('Delete note error:', error);
|
||||
return NextResponse.json({ error: 'Failed to delete note' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,156 +0,0 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { stripHtml } from '@/lib/strip-html';
|
||||
import { NoteType } from '@prisma/client';
|
||||
import { requireAuth, isAuthed } from '@/lib/auth';
|
||||
import { htmlToTipTapJson, tipTapJsonToMarkdown, mapNoteTypeToCardType } from '@/lib/content-convert';
|
||||
import { getWorkspaceSlug } from '@/lib/workspace';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const notebookId = searchParams.get('notebookId');
|
||||
const type = searchParams.get('type');
|
||||
const cardType = searchParams.get('cardType');
|
||||
const tag = searchParams.get('tag');
|
||||
const pinned = searchParams.get('pinned');
|
||||
const workspaceSlug = getWorkspaceSlug();
|
||||
|
||||
const where: Record<string, unknown> = {
|
||||
archivedAt: null, // exclude soft-deleted
|
||||
};
|
||||
if (notebookId) where.notebookId = notebookId;
|
||||
if (type) where.type = type as NoteType;
|
||||
if (cardType) where.cardType = cardType;
|
||||
if (pinned === 'true') where.isPinned = true;
|
||||
if (tag) {
|
||||
where.tags = { some: { tag: { name: tag.toLowerCase() } } };
|
||||
}
|
||||
|
||||
// Workspace boundary: filter notes by their notebook's workspace
|
||||
if (workspaceSlug) {
|
||||
where.notebook = {
|
||||
...(where.notebook as object || {}),
|
||||
workspaceSlug,
|
||||
};
|
||||
}
|
||||
|
||||
const notes = await prisma.note.findMany({
|
||||
where,
|
||||
include: {
|
||||
tags: { include: { tag: true } },
|
||||
notebook: { select: { id: true, title: true, slug: true, workspaceSlug: true } },
|
||||
parent: { select: { id: true, title: true } },
|
||||
children: { select: { id: true, title: true, cardType: true }, where: { archivedAt: null } },
|
||||
attachments: { include: { file: true }, orderBy: { position: 'asc' } },
|
||||
},
|
||||
orderBy: [{ isPinned: 'desc' }, { updatedAt: 'desc' }],
|
||||
take: 100,
|
||||
});
|
||||
|
||||
return NextResponse.json(notes);
|
||||
} catch (error) {
|
||||
console.error('List notes error:', error);
|
||||
return NextResponse.json({ error: 'Failed to list notes' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const auth = await requireAuth(request);
|
||||
if (!isAuthed(auth)) return auth;
|
||||
const { user } = auth;
|
||||
const body = await request.json();
|
||||
const {
|
||||
title, content, type, notebookId, url, archiveUrl, language, tags,
|
||||
fileUrl, mimeType, fileSize, duration,
|
||||
// Memory Card fields
|
||||
parentId, cardType: cardTypeOverride, visibility, properties, summary, position, bodyJson: clientBodyJson,
|
||||
} = body;
|
||||
|
||||
if (!title?.trim()) {
|
||||
return NextResponse.json({ error: 'Title is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const contentPlain = content ? stripHtml(content) : null;
|
||||
|
||||
// Dual-write: compute bodyJson + bodyMarkdown
|
||||
let bodyJson = clientBodyJson || null;
|
||||
let bodyMarkdown: string | null = null;
|
||||
let bodyFormat = 'html';
|
||||
|
||||
if (clientBodyJson) {
|
||||
// Client sent TipTap JSON — it's canonical
|
||||
bodyJson = clientBodyJson;
|
||||
bodyMarkdown = tipTapJsonToMarkdown(clientBodyJson);
|
||||
bodyFormat = 'blocks';
|
||||
} else if (content) {
|
||||
// HTML content — convert to JSON + markdown
|
||||
bodyJson = htmlToTipTapJson(content);
|
||||
bodyMarkdown = tipTapJsonToMarkdown(bodyJson);
|
||||
}
|
||||
|
||||
const noteType = type || 'NOTE';
|
||||
const resolvedCardType = cardTypeOverride || mapNoteTypeToCardType(noteType);
|
||||
|
||||
// Find or create tags
|
||||
const tagRecords = [];
|
||||
if (tags && Array.isArray(tags)) {
|
||||
for (const tagName of tags) {
|
||||
const name = tagName.trim().toLowerCase();
|
||||
if (!name) continue;
|
||||
const tag = await prisma.tag.upsert({
|
||||
where: { spaceId_name: { spaceId: '', name } },
|
||||
update: {},
|
||||
create: { name, spaceId: '' },
|
||||
});
|
||||
tagRecords.push(tag);
|
||||
}
|
||||
}
|
||||
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
title: title.trim(),
|
||||
content: content || '',
|
||||
contentPlain,
|
||||
type: noteType,
|
||||
notebookId: notebookId || null,
|
||||
authorId: user.id,
|
||||
url: url || null,
|
||||
archiveUrl: archiveUrl || null,
|
||||
language: language || null,
|
||||
fileUrl: fileUrl || null,
|
||||
mimeType: mimeType || null,
|
||||
fileSize: fileSize || null,
|
||||
duration: duration || null,
|
||||
// Memory Card fields
|
||||
bodyJson: bodyJson || undefined,
|
||||
bodyMarkdown,
|
||||
bodyFormat,
|
||||
cardType: resolvedCardType,
|
||||
parentId: parentId || null,
|
||||
visibility: visibility || 'private',
|
||||
properties: properties || {},
|
||||
summary: summary || null,
|
||||
position: position ?? null,
|
||||
tags: {
|
||||
create: tagRecords.map((tag) => ({
|
||||
tagId: tag.id,
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
tags: { include: { tag: true } },
|
||||
notebook: { select: { id: true, title: true, slug: true } },
|
||||
parent: { select: { id: true, title: true } },
|
||||
children: { select: { id: true, title: true, cardType: true }, where: { archivedAt: null } },
|
||||
attachments: { include: { file: true }, orderBy: { position: 'asc' } },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(note, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('Create note error:', error);
|
||||
return NextResponse.json({ error: 'Failed to create note' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,131 +0,0 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { getWorkspaceSlug } from '@/lib/workspace';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const q = searchParams.get('q')?.trim();
|
||||
const type = searchParams.get('type');
|
||||
const cardType = searchParams.get('cardType');
|
||||
const notebookId = searchParams.get('notebookId');
|
||||
const workspaceSlug = getWorkspaceSlug();
|
||||
|
||||
if (!q) {
|
||||
return NextResponse.json({ error: 'Query parameter q is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Build WHERE clauses for optional filters
|
||||
const filters: string[] = ['n."archivedAt" IS NULL'];
|
||||
const params: (string | null)[] = [q]; // $1 = search query
|
||||
|
||||
if (type) {
|
||||
params.push(type);
|
||||
filters.push(`n."type" = $${params.length}::"NoteType"`);
|
||||
}
|
||||
if (cardType) {
|
||||
params.push(cardType);
|
||||
filters.push(`n."cardType" = $${params.length}`);
|
||||
}
|
||||
if (notebookId) {
|
||||
params.push(notebookId);
|
||||
filters.push(`n."notebookId" = $${params.length}`);
|
||||
}
|
||||
|
||||
// Workspace boundary: only search within the current workspace's notebooks
|
||||
if (workspaceSlug) {
|
||||
params.push(workspaceSlug);
|
||||
filters.push(`nb."workspaceSlug" = $${params.length}`);
|
||||
}
|
||||
|
||||
const whereClause = filters.length > 0 ? `AND ${filters.join(' AND ')}` : '';
|
||||
|
||||
// Full-text search — prefer bodyMarkdown over contentPlain
|
||||
const results = await prisma.$queryRawUnsafe<Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
contentPlain: string | null;
|
||||
bodyMarkdown: string | null;
|
||||
type: string;
|
||||
cardType: string;
|
||||
notebookId: string | null;
|
||||
notebookTitle: string | null;
|
||||
isPinned: boolean;
|
||||
summary: string | null;
|
||||
updatedAt: Date;
|
||||
rank: number;
|
||||
headline: string | null;
|
||||
}>>(
|
||||
`SELECT
|
||||
n.id,
|
||||
n.title,
|
||||
n.content,
|
||||
n."contentPlain",
|
||||
n."bodyMarkdown",
|
||||
n.type::"text" as type,
|
||||
n."cardType",
|
||||
n."notebookId",
|
||||
nb.title as "notebookTitle",
|
||||
n."isPinned",
|
||||
n.summary,
|
||||
n."updatedAt",
|
||||
ts_rank(
|
||||
to_tsvector('english', COALESCE(n."bodyMarkdown", n."contentPlain", '') || ' ' || n.title),
|
||||
plainto_tsquery('english', $1)
|
||||
) as rank,
|
||||
ts_headline('english',
|
||||
COALESCE(n."bodyMarkdown", n."contentPlain", n.content, ''),
|
||||
plainto_tsquery('english', $1),
|
||||
'StartSel=<mark>, StopSel=</mark>, MaxWords=35, MinWords=15, MaxFragments=1'
|
||||
) as headline
|
||||
FROM "Note" n
|
||||
LEFT JOIN "Notebook" nb ON n."notebookId" = nb.id
|
||||
WHERE (
|
||||
to_tsvector('english', COALESCE(n."bodyMarkdown", n."contentPlain", '') || ' ' || n.title) @@ plainto_tsquery('english', $1)
|
||||
OR n.title ILIKE '%' || $1 || '%'
|
||||
OR n."bodyMarkdown" ILIKE '%' || $1 || '%'
|
||||
OR n."contentPlain" ILIKE '%' || $1 || '%'
|
||||
)
|
||||
${whereClause}
|
||||
ORDER BY rank DESC, n."updatedAt" DESC
|
||||
LIMIT 50`,
|
||||
...params
|
||||
);
|
||||
|
||||
// Fetch tags for matched notes
|
||||
const noteIds = results.map((r) => r.id);
|
||||
const noteTags = noteIds.length > 0
|
||||
? await prisma.noteTag.findMany({
|
||||
where: { noteId: { in: noteIds } },
|
||||
include: { tag: true },
|
||||
})
|
||||
: [];
|
||||
|
||||
const tagsByNoteId = new Map<string, Array<{ id: string; name: string; color: string | null }>>();
|
||||
for (const nt of noteTags) {
|
||||
const arr = tagsByNoteId.get(nt.noteId) || [];
|
||||
arr.push({ id: nt.tag.id, name: nt.tag.name, color: nt.tag.color });
|
||||
tagsByNoteId.set(nt.noteId, arr);
|
||||
}
|
||||
|
||||
const response = results.map((note) => ({
|
||||
id: note.id,
|
||||
title: note.title,
|
||||
snippet: note.summary || note.headline || (note.bodyMarkdown || note.contentPlain || note.content || '').slice(0, 150),
|
||||
type: note.type,
|
||||
cardType: note.cardType,
|
||||
notebookId: note.notebookId,
|
||||
notebookTitle: note.notebookTitle,
|
||||
updatedAt: new Date(note.updatedAt).toISOString(),
|
||||
tags: tagsByNoteId.get(note.id) || [],
|
||||
}));
|
||||
|
||||
return NextResponse.json(response);
|
||||
} catch (error) {
|
||||
console.error('Search notes error:', error);
|
||||
return NextResponse.json({ error: 'Failed to search notes' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,45 +1,28 @@
|
|||
/**
|
||||
* Spaces API proxy — forwards to rSpace (the canonical spaces authority).
|
||||
*
|
||||
* Every r*App proxies /api/spaces to rSpace so the SpaceSwitcher dropdown
|
||||
* shows the same spaces everywhere. The EncryptID token is forwarded so
|
||||
* rSpace can return user-specific spaces (owned/member).
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const RSPACE_API = process.env.RSPACE_API_URL || 'https://rspace.online';
|
||||
const RSPACE_API = process.env.RSPACE_API_URL || 'https://rspace.online/api';
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
// Forward the EncryptID token (from Authorization header or cookie)
|
||||
const auth = req.headers.get('Authorization');
|
||||
if (auth) {
|
||||
headers['Authorization'] = auth;
|
||||
} else {
|
||||
// Fallback: check for encryptid_token cookie
|
||||
const tokenCookie = req.cookies.get('encryptid_token');
|
||||
if (tokenCookie) {
|
||||
headers['Authorization'] = `Bearer ${tokenCookie.value}`;
|
||||
}
|
||||
}
|
||||
const token =
|
||||
req.headers.get('Authorization')?.replace('Bearer ', '') ||
|
||||
req.cookies.get('encryptid_token')?.value;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${RSPACE_API}/api/spaces`, {
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
|
||||
const res = await fetch(`${RSPACE_API}/spaces`, {
|
||||
headers,
|
||||
next: { revalidate: 30 }, // cache for 30s to avoid hammering rSpace
|
||||
next: { revalidate: 30 },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
// If rSpace is down, return empty spaces (graceful degradation)
|
||||
return NextResponse.json({ spaces: [] });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
return NextResponse.json(data);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
return NextResponse.json(data);
|
||||
} catch {
|
||||
// rSpace unreachable — return empty list
|
||||
return NextResponse.json({ spaces: [] });
|
||||
// rSpace unavailable
|
||||
}
|
||||
|
||||
return NextResponse.json({ spaces: [] });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,82 +0,0 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
/**
|
||||
* POST /api/sync
|
||||
*
|
||||
* Receives shape update events from the rSpace canvas and updates DB records.
|
||||
* Handles Memory Card fields: cardType, summary, properties, visibility.
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { shapeId, type, data } = body;
|
||||
|
||||
if (!shapeId || !type) {
|
||||
return NextResponse.json({ error: 'Missing shapeId or type' }, { status: 400 });
|
||||
}
|
||||
|
||||
const shapeType = data?.type as string | undefined;
|
||||
|
||||
if (type === 'shape-deleted') {
|
||||
await Promise.all([
|
||||
prisma.note.updateMany({
|
||||
where: { canvasShapeId: shapeId },
|
||||
data: { canvasShapeId: null },
|
||||
}),
|
||||
prisma.notebook.updateMany({
|
||||
where: { canvasShapeId: shapeId },
|
||||
data: { canvasShapeId: null },
|
||||
}),
|
||||
]);
|
||||
|
||||
return NextResponse.json({ ok: true, action: 'unlinked' });
|
||||
}
|
||||
|
||||
// shape-updated: try to match and update
|
||||
if (shapeType === 'folk-note') {
|
||||
const note = await prisma.note.findFirst({
|
||||
where: { canvasShapeId: shapeId },
|
||||
});
|
||||
|
||||
if (note) {
|
||||
const updateData: Record<string, unknown> = {};
|
||||
if (data.noteTitle) updateData.title = data.noteTitle;
|
||||
if (data.cardType) updateData.cardType = data.cardType;
|
||||
if (data.summary !== undefined) updateData.summary = data.summary || null;
|
||||
if (data.visibility) updateData.visibility = data.visibility;
|
||||
if (data.properties && typeof data.properties === 'object') updateData.properties = data.properties;
|
||||
|
||||
if (Object.keys(updateData).length > 0) {
|
||||
await prisma.note.update({
|
||||
where: { id: note.id },
|
||||
data: updateData,
|
||||
});
|
||||
}
|
||||
return NextResponse.json({ ok: true, action: 'updated', entity: 'note', id: note.id });
|
||||
}
|
||||
}
|
||||
|
||||
if (shapeType === 'folk-notebook') {
|
||||
const notebook = await prisma.notebook.findFirst({
|
||||
where: { canvasShapeId: shapeId },
|
||||
});
|
||||
|
||||
if (notebook) {
|
||||
await prisma.notebook.update({
|
||||
where: { id: notebook.id },
|
||||
data: {
|
||||
title: (data.notebookTitle as string) || notebook.title,
|
||||
description: (data.description as string) ?? notebook.description,
|
||||
},
|
||||
});
|
||||
return NextResponse.json({ ok: true, action: 'updated', entity: 'notebook', id: notebook.id });
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, action: 'no-match' });
|
||||
} catch (error) {
|
||||
console.error('Canvas sync error:', error);
|
||||
return NextResponse.json({ error: 'Failed to sync canvas update' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
|
||||
/**
|
||||
* POST /api/try — Create a scratch note for instant demo access.
|
||||
* No auth required. Creates a guest user + default space + scratch notebook + note.
|
||||
* Returns the note URL so the frontend can redirect immediately.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
// Ensure default space
|
||||
let space = await prisma.space.findUnique({ where: { slug: 'default' } });
|
||||
if (!space) {
|
||||
space = await prisma.space.create({
|
||||
data: { slug: 'default', name: 'rNotes' },
|
||||
});
|
||||
}
|
||||
|
||||
// Ensure guest user
|
||||
let guest = await prisma.user.findUnique({ where: { username: 'guest' } });
|
||||
if (!guest) {
|
||||
guest = await prisma.user.create({
|
||||
data: { username: 'guest', name: 'Guest', did: 'did:guest:anonymous' },
|
||||
});
|
||||
}
|
||||
|
||||
// Find or create the Scratch Pad notebook
|
||||
let scratchPad = await prisma.notebook.findFirst({
|
||||
where: { spaceId: space.id, title: 'Scratch Pad' },
|
||||
});
|
||||
if (!scratchPad) {
|
||||
scratchPad = await prisma.notebook.create({
|
||||
data: {
|
||||
title: 'Scratch Pad',
|
||||
description: 'Try the editor — no sign-in required',
|
||||
icon: '🗒️',
|
||||
spaceId: space.id,
|
||||
createdBy: guest.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Create a new scratch note
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
title: 'Scratch Note',
|
||||
notebookId: scratchPad.id,
|
||||
createdBy: guest.id,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
noteId: note.id,
|
||||
url: `/s/default/n/${note.id}`,
|
||||
});
|
||||
}
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { readFile } from 'fs/promises';
|
||||
import { existsSync } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const UPLOAD_DIR = process.env.UPLOAD_DIR || '/app/uploads';
|
||||
|
||||
const MIME_TYPES: Record<string, string> = {
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.png': 'image/png',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.avif': 'image/avif',
|
||||
'.pdf': 'application/pdf',
|
||||
'.txt': 'text/plain',
|
||||
'.md': 'text/markdown',
|
||||
'.csv': 'text/csv',
|
||||
'.json': 'application/json',
|
||||
'.xml': 'application/xml',
|
||||
'.zip': 'application/zip',
|
||||
'.gz': 'application/gzip',
|
||||
'.js': 'text/javascript',
|
||||
'.ts': 'text/typescript',
|
||||
'.html': 'text/html',
|
||||
'.css': 'text/css',
|
||||
'.py': 'text/x-python',
|
||||
};
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: { filename: string } }
|
||||
) {
|
||||
try {
|
||||
const filename = params.filename;
|
||||
|
||||
// Validate filename (no path traversal)
|
||||
if (filename.includes('/') || filename.includes('\\') || filename.includes('..')) {
|
||||
return NextResponse.json({ error: 'Invalid filename' }, { status: 400 });
|
||||
}
|
||||
|
||||
const filePath = path.join(UPLOAD_DIR, filename);
|
||||
|
||||
// Validate resolved path stays within UPLOAD_DIR
|
||||
const resolvedPath = path.resolve(filePath);
|
||||
if (!resolvedPath.startsWith(path.resolve(UPLOAD_DIR))) {
|
||||
return NextResponse.json({ error: 'Invalid filename' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!existsSync(filePath)) {
|
||||
return NextResponse.json({ error: 'File not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const data = await readFile(filePath);
|
||||
const ext = path.extname(filename).toLowerCase();
|
||||
const contentType = MIME_TYPES[ext] || 'application/octet-stream';
|
||||
|
||||
return new NextResponse(data, {
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Cache-Control': 'public, max-age=31536000, immutable',
|
||||
'Content-Length': data.length.toString(),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Serve file error:', error);
|
||||
return NextResponse.json({ error: 'Failed to serve file' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,123 +0,0 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { writeFile, mkdir } from 'fs/promises';
|
||||
import { existsSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { requireAuth, isAuthed } from '@/lib/auth';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { isIPFSEnabled, uploadToIPFS, ipfsProxyUrl } from '@/lib/ipfs';
|
||||
|
||||
const UPLOAD_DIR = process.env.UPLOAD_DIR || '/app/uploads';
|
||||
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
|
||||
|
||||
const ALLOWED_MIME_TYPES = new Set([
|
||||
// Images
|
||||
'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml', 'image/avif',
|
||||
// Documents
|
||||
'application/pdf', 'text/plain', 'text/markdown', 'text/csv',
|
||||
'application/json', 'application/xml',
|
||||
// Archives
|
||||
'application/zip', 'application/gzip',
|
||||
// Code
|
||||
'text/javascript', 'text/typescript', 'text/html', 'text/css',
|
||||
'application/x-python-code', 'text/x-python',
|
||||
// Audio
|
||||
'audio/webm', 'audio/mpeg', 'audio/wav', 'audio/ogg',
|
||||
'audio/mp4', 'audio/x-m4a', 'audio/aac', 'audio/flac',
|
||||
]);
|
||||
|
||||
function sanitizeFilename(name: string): string {
|
||||
return name.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 200);
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const auth = await requireAuth(request);
|
||||
if (!isAuthed(auth)) return auth;
|
||||
const { user } = auth;
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('file') as File | null;
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: 'No file provided' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
return NextResponse.json({ error: 'File too large (max 50MB)' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!ALLOWED_MIME_TYPES.has(file.type)) {
|
||||
return NextResponse.json(
|
||||
{ error: `File type "${file.type}" not allowed` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Ensure upload directory exists
|
||||
if (!existsSync(UPLOAD_DIR)) {
|
||||
await mkdir(UPLOAD_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
// Generate unique filename
|
||||
const ext = path.extname(file.name) || '';
|
||||
const safeName = sanitizeFilename(path.basename(file.name, ext));
|
||||
const uniqueName = `${nanoid(12)}_${safeName}${ext}`;
|
||||
const filePath = path.join(UPLOAD_DIR, uniqueName);
|
||||
|
||||
// Validate the resolved path is within UPLOAD_DIR
|
||||
const resolvedPath = path.resolve(filePath);
|
||||
if (!resolvedPath.startsWith(path.resolve(UPLOAD_DIR))) {
|
||||
return NextResponse.json({ error: 'Invalid file path' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Write file
|
||||
const bytes = await file.arrayBuffer();
|
||||
await writeFile(filePath, Buffer.from(bytes));
|
||||
|
||||
const fileUrl = `/api/uploads/${uniqueName}`;
|
||||
|
||||
// IPFS upload (if enabled)
|
||||
let ipfsData: { cid: string; encKey: string; gateway: string } | undefined;
|
||||
if (isIPFSEnabled()) {
|
||||
try {
|
||||
const fileBytes = new Uint8Array(bytes);
|
||||
const metadata = await uploadToIPFS(fileBytes, file.name, file.type);
|
||||
ipfsData = {
|
||||
cid: metadata.cid,
|
||||
encKey: metadata.encryptionKey,
|
||||
gateway: ipfsProxyUrl(metadata.cid, metadata.encryptionKey),
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('IPFS upload failed (falling back to local):', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Create File record in database
|
||||
const fileRecord = await prisma.file.create({
|
||||
data: {
|
||||
storageKey: uniqueName,
|
||||
filename: file.name,
|
||||
mimeType: file.type,
|
||||
sizeBytes: file.size,
|
||||
authorId: user.id,
|
||||
...(ipfsData && {
|
||||
ipfsCid: ipfsData.cid,
|
||||
ipfsEncKey: ipfsData.encKey,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
url: fileUrl,
|
||||
filename: uniqueName,
|
||||
originalName: file.name,
|
||||
size: file.size,
|
||||
mimeType: file.type,
|
||||
fileId: fileRecord.id,
|
||||
...(ipfsData && { ipfs: ipfsData }),
|
||||
}, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
return NextResponse.json({ error: 'Failed to upload file' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, isAuthed } from '@/lib/auth';
|
||||
|
||||
const VOICE_API_URL = process.env.VOICE_API_URL || 'http://voice-command-api:8000';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const auth = await requireAuth(request);
|
||||
if (!isAuthed(auth)) return auth;
|
||||
|
||||
const formData = await request.formData();
|
||||
const audio = formData.get('audio') as File | null;
|
||||
|
||||
if (!audio) {
|
||||
return NextResponse.json({ error: 'No audio file provided' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Forward to voice-command API diarization endpoint
|
||||
const proxyForm = new FormData();
|
||||
proxyForm.append('audio', audio, audio.name || 'recording.webm');
|
||||
|
||||
const res = await fetch(`${VOICE_API_URL}/api/voice/diarize`, {
|
||||
method: 'POST',
|
||||
body: proxyForm,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
console.error('Diarization API error:', res.status, err);
|
||||
return NextResponse.json(
|
||||
{ error: 'Diarization failed' },
|
||||
{ status: res.status }
|
||||
);
|
||||
}
|
||||
|
||||
const result = await res.json();
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
console.error('Diarize proxy error:', error);
|
||||
return NextResponse.json({ error: 'Diarization failed' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, isAuthed } from '@/lib/auth';
|
||||
|
||||
const VOICE_API_URL = process.env.VOICE_API_URL || 'http://voice-command-api:8000';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const auth = await requireAuth(request);
|
||||
if (!isAuthed(auth)) return auth;
|
||||
|
||||
const formData = await request.formData();
|
||||
const audio = formData.get('audio') as File | null;
|
||||
|
||||
if (!audio) {
|
||||
return NextResponse.json({ error: 'No audio file provided' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Forward to voice-command API
|
||||
const proxyForm = new FormData();
|
||||
proxyForm.append('audio', audio, audio.name || 'recording.webm');
|
||||
|
||||
const res = await fetch(`${VOICE_API_URL}/api/voice/transcribe`, {
|
||||
method: 'POST',
|
||||
body: proxyForm,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
console.error('Voice API error:', res.status, err);
|
||||
return NextResponse.json(
|
||||
{ error: 'Transcription failed' },
|
||||
{ status: res.status }
|
||||
);
|
||||
}
|
||||
|
||||
const result = await res.json();
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
console.error('Transcribe proxy error:', error);
|
||||
return NextResponse.json({ error: 'Transcription failed' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,207 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect, Suspense } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useEncryptID } from '@encryptid/sdk/ui/react';
|
||||
import { Header } from '@/components/Header';
|
||||
|
||||
function SignInForm() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const returnUrl = searchParams.get('returnUrl') || '/';
|
||||
const isExtension = searchParams.get('extension') === 'true';
|
||||
const { isAuthenticated, loading: authLoading, login, register } = useEncryptID();
|
||||
|
||||
const [mode, setMode] = useState<'signin' | 'register'>('signin');
|
||||
const [username, setUsername] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [tokenCopied, setTokenCopied] = useState(false);
|
||||
|
||||
// Redirect if already authenticated (skip if extension mode — show token instead)
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && !authLoading && !isExtension) {
|
||||
router.push(returnUrl);
|
||||
}
|
||||
}, [isAuthenticated, authLoading, router, returnUrl, isExtension]);
|
||||
|
||||
const handleSignIn = async () => {
|
||||
setError('');
|
||||
setBusy(true);
|
||||
try {
|
||||
await login();
|
||||
if (!isExtension) router.push(returnUrl);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Sign in failed. Make sure you have a registered passkey.');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegister = async () => {
|
||||
if (!username.trim()) {
|
||||
setError('Username is required');
|
||||
return;
|
||||
}
|
||||
setError('');
|
||||
setBusy(true);
|
||||
try {
|
||||
await register(username.trim());
|
||||
if (!isExtension) router.push(returnUrl);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Registration failed.');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (authLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center">
|
||||
<svg className="animate-spin h-8 w-8 text-amber-400" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0a0a0a] flex flex-col">
|
||||
<Header breadcrumbs={[{ label: 'Sign In' }]} />
|
||||
|
||||
<main className="flex-1 flex items-center justify-center px-6">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-amber-400 to-orange-500 flex items-center justify-center text-2xl font-bold text-black mx-auto mb-4">
|
||||
rN
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-white">
|
||||
{mode === 'signin' ? 'Sign in to rNotes' : 'Create Account'}
|
||||
</h1>
|
||||
<p className="text-slate-400 mt-2 text-sm">
|
||||
{mode === 'signin'
|
||||
? 'Use your passkey to sign in'
|
||||
: 'Register with a passkey for passwordless auth'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Mode toggle */}
|
||||
<div className="flex rounded-lg bg-slate-800/50 border border-slate-700 p-1 mb-6">
|
||||
<button
|
||||
onClick={() => { setMode('signin'); setError(''); }}
|
||||
className={`flex-1 py-2 text-sm font-medium rounded-md transition-colors ${
|
||||
mode === 'signin'
|
||||
? 'bg-amber-500 text-black'
|
||||
: 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Sign In
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setMode('register'); setError(''); }}
|
||||
className={`flex-1 py-2 text-sm font-medium rounded-md transition-colors ${
|
||||
mode === 'register'
|
||||
? 'bg-amber-500 text-black'
|
||||
: 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Register
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error display */}
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-500/10 border border-red-500/30 rounded-lg text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === 'register' && (
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-slate-300 mb-2">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="Choose a username"
|
||||
className="w-full px-4 py-3 bg-slate-800/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:border-amber-500/50"
|
||||
autoFocus
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleRegister()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={mode === 'signin' ? handleSignIn : handleRegister}
|
||||
disabled={busy || (mode === 'register' && !username.trim())}
|
||||
className="w-full py-3 bg-amber-500 hover:bg-amber-400 disabled:bg-slate-700 disabled:text-slate-400 text-black font-semibold rounded-lg transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
{busy ? (
|
||||
<>
|
||||
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
{mode === 'signin' ? 'Signing in...' : 'Registering...'}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||
</svg>
|
||||
{mode === 'signin' ? 'Sign In with Passkey' : 'Register with Passkey'}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Extension token display */}
|
||||
{isExtension && isAuthenticated && (
|
||||
<div className="mt-6 p-4 bg-amber-500/10 border border-amber-500/30 rounded-lg">
|
||||
<h3 className="text-sm font-semibold text-amber-400 mb-2">Extension Token</h3>
|
||||
<p className="text-xs text-slate-400 mb-3">
|
||||
Copy this token and paste it in the rNotes Web Clipper extension settings.
|
||||
</p>
|
||||
<textarea
|
||||
readOnly
|
||||
value={typeof window !== 'undefined' ? localStorage.getItem('encryptid_token') || '' : ''}
|
||||
className="w-full h-20 px-3 py-2 bg-slate-900 border border-slate-700 rounded text-xs text-slate-300 font-mono resize-none focus:outline-none"
|
||||
onClick={(e) => (e.target as HTMLTextAreaElement).select()}
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
const token = localStorage.getItem('encryptid_token') || '';
|
||||
navigator.clipboard.writeText(token);
|
||||
setTokenCopied(true);
|
||||
setTimeout(() => setTokenCopied(false), 2000);
|
||||
}}
|
||||
className="mt-2 w-full py-2 bg-amber-500 hover:bg-amber-400 text-black font-semibold rounded-lg text-sm transition-colors"
|
||||
>
|
||||
{tokenCopied ? 'Copied!' : 'Copy to Clipboard'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-center text-xs text-slate-500 mt-6">
|
||||
Powered by EncryptID — passwordless, decentralized identity
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SignInPage() {
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center">
|
||||
<svg className="animate-spin h-8 w-8 text-amber-400" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
</div>
|
||||
}>
|
||||
<SignInForm />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Header } from '@/components/Header';
|
||||
import { Plus, BookOpen, Clock, Users } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface Notebook {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
noteCount: number;
|
||||
collaborators: number;
|
||||
updatedAt: string;
|
||||
spaceSlug: string;
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [notebooks, setNotebooks] = useState<Notebook[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/notebooks')
|
||||
.then((r) => r.json())
|
||||
.then((data) => setNotebooks(data.notebooks || []))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const handleCreate = async () => {
|
||||
const title = window.prompt('Notebook name:');
|
||||
if (!title?.trim()) return;
|
||||
|
||||
const res = await fetch('/api/notebooks', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title: title.trim() }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const nb = await res.json();
|
||||
setNotebooks((prev) => [nb, ...prev]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<Header
|
||||
breadcrumbs={[{ label: 'Notebooks' }]}
|
||||
actions={
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-primary text-primary-foreground rounded-lg text-sm font-medium hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
<Plus size={16} /> New Notebook
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
<main className="flex-1 max-w-5xl mx-auto w-full px-6 py-8">
|
||||
{loading ? (
|
||||
<div className="text-center text-slate-400 py-20">Loading notebooks...</div>
|
||||
) : notebooks.length === 0 ? (
|
||||
<div className="text-center py-20">
|
||||
<div className="text-5xl mb-4">📓</div>
|
||||
<h2 className="text-xl font-semibold mb-2">No notebooks yet</h2>
|
||||
<p className="text-slate-400 mb-6">Create your first notebook to start writing collaboratively.</p>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
className="px-5 py-2.5 bg-primary text-primary-foreground rounded-lg font-medium hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Create Notebook
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{notebooks.map((nb) => (
|
||||
<Link
|
||||
key={nb.id}
|
||||
href={`/s/${nb.spaceSlug || 'default'}/notebook/${nb.id}`}
|
||||
className="group block p-5 rounded-xl bg-card border border-slate-800 hover:border-primary/30 hover:bg-card/80 transition-all no-underline"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<span className="text-2xl">{nb.icon}</span>
|
||||
<span className="text-[10px] text-slate-500 bg-slate-800 px-2 py-0.5 rounded-full">
|
||||
{nb.noteCount} {nb.noteCount === 1 ? 'note' : 'notes'}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="font-semibold text-slate-200 group-hover:text-white transition-colors mb-1">
|
||||
{nb.title}
|
||||
</h3>
|
||||
{nb.description && (
|
||||
<p className="text-sm text-slate-400 line-clamp-2 mb-3">{nb.description}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-3 text-[11px] text-slate-500">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock size={11} />
|
||||
{new Date(nb.updatedAt).toLocaleDateString()}
|
||||
</span>
|
||||
{nb.collaborators > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Users size={11} />
|
||||
{nb.collaborators}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{/* Add button card */}
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
className="flex flex-col items-center justify-center p-5 rounded-xl border border-dashed border-slate-700 hover:border-primary/40 hover:bg-primary/[0.03] transition-all min-h-[140px]"
|
||||
>
|
||||
<Plus size={24} className="text-slate-500 mb-2" />
|
||||
<span className="text-sm text-slate-400">New Notebook</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,890 +0,0 @@
|
|||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useState, useMemo, useCallback } from 'react'
|
||||
import { useDemoSync, type DemoShape } from '@/lib/demo-sync'
|
||||
import { TranscriptionDemo } from '@/components/TranscriptionDemo'
|
||||
import { Header } from '@/components/Header'
|
||||
|
||||
/* --- Types -------------------------------------------------------------- */
|
||||
|
||||
interface NotebookData {
|
||||
notebookTitle: string
|
||||
description: string
|
||||
noteCount: number
|
||||
collaborators: string[]
|
||||
}
|
||||
|
||||
interface NoteData {
|
||||
noteTitle: string
|
||||
content: string
|
||||
tags: string[]
|
||||
editor: string
|
||||
editedAt: string
|
||||
}
|
||||
|
||||
interface PackingItem {
|
||||
name: string
|
||||
packed: boolean
|
||||
category: string
|
||||
}
|
||||
|
||||
interface PackingListData {
|
||||
listTitle: string
|
||||
items: PackingItem[]
|
||||
}
|
||||
|
||||
/* --- Markdown Renderer -------------------------------------------------- */
|
||||
|
||||
function RenderMarkdown({ content }: { content: string }) {
|
||||
const lines = content.split('\n')
|
||||
const elements: React.ReactNode[] = []
|
||||
let i = 0
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i]
|
||||
|
||||
// Heading 3
|
||||
if (line.startsWith('### ')) {
|
||||
elements.push(
|
||||
<h5 key={i} className="text-sm font-semibold text-slate-300 mt-3 mb-1">
|
||||
{renderInline(line.slice(4))}
|
||||
</h5>
|
||||
)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// Heading 2
|
||||
if (line.startsWith('## ')) {
|
||||
elements.push(
|
||||
<h4 key={i} className="text-base font-semibold text-slate-200 mt-4 mb-2">
|
||||
{renderInline(line.slice(3))}
|
||||
</h4>
|
||||
)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// Heading 1
|
||||
if (line.startsWith('# ')) {
|
||||
elements.push(
|
||||
<h3 key={i} className="text-lg font-bold text-white mt-4 mb-2">
|
||||
{renderInline(line.slice(2))}
|
||||
</h3>
|
||||
)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// Blockquote
|
||||
if (line.startsWith('> ')) {
|
||||
elements.push(
|
||||
<div key={i} className="bg-amber-500/10 border-l-2 border-amber-500 px-4 py-2 rounded-r-lg my-2">
|
||||
<p className="text-amber-200 text-sm">{renderInline(line.slice(2))}</p>
|
||||
</div>
|
||||
)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// Unordered list item
|
||||
if (line.startsWith('- ') || line.startsWith('* ')) {
|
||||
const listItems: React.ReactNode[] = []
|
||||
while (i < lines.length && (lines[i].startsWith('- ') || lines[i].startsWith('* '))) {
|
||||
listItems.push(
|
||||
<li key={i} className="flex items-start gap-2">
|
||||
<span className="text-amber-400 mt-0.5">•</span>
|
||||
<span>{renderInline(lines[i].slice(2))}</span>
|
||||
</li>
|
||||
)
|
||||
i++
|
||||
}
|
||||
elements.push(
|
||||
<ul key={`ul-${i}`} className="space-y-1 text-slate-300 text-sm my-2">
|
||||
{listItems}
|
||||
</ul>
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
// Ordered list item
|
||||
if (/^\d+\.\s/.test(line)) {
|
||||
const listItems: React.ReactNode[] = []
|
||||
while (i < lines.length && /^\d+\.\s/.test(lines[i])) {
|
||||
const text = lines[i].replace(/^\d+\.\s/, '')
|
||||
listItems.push(
|
||||
<li key={i} className="flex items-start gap-2">
|
||||
<span className="text-amber-400 font-medium min-w-[1.2em] text-right">
|
||||
{listItems.length + 1}.
|
||||
</span>
|
||||
<span>{renderInline(text)}</span>
|
||||
</li>
|
||||
)
|
||||
i++
|
||||
}
|
||||
elements.push(
|
||||
<ol key={`ol-${i}`} className="space-y-1 text-slate-300 text-sm my-2">
|
||||
{listItems}
|
||||
</ol>
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
// Code block (fenced)
|
||||
if (line.startsWith('```')) {
|
||||
const lang = line.slice(3).trim()
|
||||
const codeLines: string[] = []
|
||||
i++
|
||||
while (i < lines.length && !lines[i].startsWith('```')) {
|
||||
codeLines.push(lines[i])
|
||||
i++
|
||||
}
|
||||
i++ // skip closing ```
|
||||
elements.push(
|
||||
<div key={`code-${i}`} className="bg-slate-950 rounded-lg border border-slate-700/50 overflow-hidden my-2">
|
||||
{lang && (
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-slate-800/50 border-b border-slate-700/50">
|
||||
<span className="text-xs text-slate-400 font-mono">{lang}</span>
|
||||
</div>
|
||||
)}
|
||||
<pre className="px-4 py-3 text-xs text-slate-300 font-mono overflow-x-auto leading-relaxed">
|
||||
{codeLines.join('\n')}
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
// Empty line
|
||||
if (line.trim() === '') {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// Regular paragraph
|
||||
elements.push(
|
||||
<p key={i} className="text-slate-300 text-sm my-1">
|
||||
{renderInline(line)}
|
||||
</p>
|
||||
)
|
||||
i++
|
||||
}
|
||||
|
||||
return <div className="space-y-1">{elements}</div>
|
||||
}
|
||||
|
||||
/** Render inline markdown: **bold**, *italic*, `code`, [links](url) */
|
||||
function renderInline(text: string): React.ReactNode {
|
||||
// Split on bold, italic, code, and links
|
||||
const parts: React.ReactNode[] = []
|
||||
let remaining = text
|
||||
let key = 0
|
||||
|
||||
while (remaining.length > 0) {
|
||||
// Bold **text**
|
||||
const boldMatch = remaining.match(/^([\s\S]*?)\*\*([\s\S]+?)\*\*([\s\S]*)$/)
|
||||
if (boldMatch) {
|
||||
if (boldMatch[1]) parts.push(<span key={key++}>{boldMatch[1]}</span>)
|
||||
parts.push(<strong key={key++} className="text-white font-medium">{boldMatch[2]}</strong>)
|
||||
remaining = boldMatch[3]
|
||||
continue
|
||||
}
|
||||
|
||||
// Italic *text*
|
||||
const italicMatch = remaining.match(/^([\s\S]*?)\*([\s\S]+?)\*([\s\S]*)$/)
|
||||
if (italicMatch) {
|
||||
if (italicMatch[1]) parts.push(<span key={key++}>{italicMatch[1]}</span>)
|
||||
parts.push(<em key={key++} className="text-slate-300 italic">{italicMatch[2]}</em>)
|
||||
remaining = italicMatch[3]
|
||||
continue
|
||||
}
|
||||
|
||||
// Inline code `text`
|
||||
const codeMatch = remaining.match(/^([\s\S]*?)`([\s\S]+?)`([\s\S]*)$/)
|
||||
if (codeMatch) {
|
||||
if (codeMatch[1]) parts.push(<span key={key++}>{codeMatch[1]}</span>)
|
||||
parts.push(
|
||||
<code key={key++} className="text-amber-300 bg-slate-800 px-1.5 py-0.5 rounded text-xs font-mono">
|
||||
{codeMatch[2]}
|
||||
</code>
|
||||
)
|
||||
remaining = codeMatch[3]
|
||||
continue
|
||||
}
|
||||
|
||||
// No more inline formatting
|
||||
parts.push(<span key={key++}>{remaining}</span>)
|
||||
break
|
||||
}
|
||||
|
||||
return <>{parts}</>
|
||||
}
|
||||
|
||||
/* --- Editor Colors ------------------------------------------------------ */
|
||||
|
||||
const EDITOR_COLORS: Record<string, string> = {
|
||||
Maya: 'bg-teal-500',
|
||||
Liam: 'bg-cyan-500',
|
||||
Priya: 'bg-violet-500',
|
||||
Omar: 'bg-rose-500',
|
||||
Alex: 'bg-blue-500',
|
||||
Sam: 'bg-green-500',
|
||||
}
|
||||
|
||||
function editorColor(name: string): string {
|
||||
return EDITOR_COLORS[name] || 'bg-slate-500'
|
||||
}
|
||||
|
||||
/* --- Note Card Component ------------------------------------------------ */
|
||||
|
||||
function NoteCard({
|
||||
note,
|
||||
expanded,
|
||||
onToggle,
|
||||
}: {
|
||||
note: NoteData & { id: string }
|
||||
expanded: boolean
|
||||
onToggle: () => void
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`bg-slate-800/50 rounded-xl border border-slate-700/50 overflow-hidden ${
|
||||
expanded ? 'ring-1 ring-amber-500/30' : 'hover:border-slate-600/50 cursor-pointer'
|
||||
} transition-colors`}
|
||||
onClick={!expanded ? onToggle : undefined}
|
||||
>
|
||||
<div className="p-4">
|
||||
{/* Header row */}
|
||||
<div className="flex items-start justify-between gap-3 mb-2">
|
||||
<h3
|
||||
className={`font-semibold ${expanded ? 'text-lg text-white' : 'text-sm text-slate-200'} ${!expanded ? 'cursor-pointer hover:text-white' : ''}`}
|
||||
onClick={expanded ? onToggle : undefined}
|
||||
>
|
||||
{expanded && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onToggle() }}
|
||||
className="mr-2 text-slate-500 hover:text-slate-300 transition-colors"
|
||||
aria-label="Collapse note"
|
||||
>
|
||||
<svg className="w-4 h-4 inline" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{note.noteTitle}
|
||||
</h3>
|
||||
<span className="flex-shrink-0 flex items-center gap-1.5 text-xs px-2.5 py-1 bg-teal-500/10 border border-teal-500/20 text-teal-400 rounded-full">
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.172 13.828a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.102 1.101" />
|
||||
</svg>
|
||||
Synced to rSpace
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Preview text (only for collapsed notes) */}
|
||||
{!expanded && (
|
||||
<p className="text-sm text-slate-400 mb-3 line-clamp-2">{note.content.slice(0, 150)}...</p>
|
||||
)}
|
||||
|
||||
{/* Expanded content */}
|
||||
{expanded && (
|
||||
<div className="mt-4 space-y-2 text-sm">
|
||||
<RenderMarkdown content={note.content} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
<div className="flex flex-wrap gap-1.5 mt-3 mb-3">
|
||||
{note.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="text-xs px-2 py-0.5 bg-slate-700/50 text-slate-400 rounded-md border border-slate-600/30"
|
||||
>
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Footer: editor info */}
|
||||
<div className="flex items-center justify-between text-xs text-slate-500">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`w-5 h-5 ${editorColor(note.editor)} rounded-full flex items-center justify-center text-[10px] font-bold text-white`}
|
||||
>
|
||||
{note.editor[0]}
|
||||
</div>
|
||||
<span className="text-slate-400">{note.editor}</span>
|
||||
</div>
|
||||
<span>{note.editedAt}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* --- Packing List Component --------------------------------------------- */
|
||||
|
||||
function PackingList({
|
||||
packingList,
|
||||
shapeId,
|
||||
updateShape,
|
||||
}: {
|
||||
packingList: PackingListData
|
||||
shapeId: string
|
||||
updateShape: (id: string, data: Partial<DemoShape>) => void
|
||||
}) {
|
||||
const categories = useMemo(() => {
|
||||
const cats: Record<string, PackingItem[]> = {}
|
||||
for (const item of packingList.items) {
|
||||
if (!cats[item.category]) cats[item.category] = []
|
||||
cats[item.category].push(item)
|
||||
}
|
||||
return cats
|
||||
}, [packingList.items])
|
||||
|
||||
const totalItems = packingList.items.length
|
||||
const packedCount = packingList.items.filter((i) => i.packed).length
|
||||
|
||||
const toggleItem = useCallback(
|
||||
(itemName: string) => {
|
||||
const updatedItems = packingList.items.map((item) =>
|
||||
item.name === itemName ? { ...item, packed: !item.packed } : item
|
||||
)
|
||||
updateShape(shapeId, { items: updatedItems } as Partial<DemoShape>)
|
||||
},
|
||||
[packingList.items, shapeId, updateShape]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="bg-slate-800/50 rounded-xl border border-slate-700/50 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="px-5 py-4 border-b border-slate-700/50">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xl">🎒</span>
|
||||
<div>
|
||||
<h3 className="font-semibold text-white text-sm">{packingList.listTitle}</h3>
|
||||
<p className="text-xs text-slate-500 mt-0.5">
|
||||
{packedCount} of {totalItems} items packed
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-24 h-2 bg-slate-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-amber-400 to-orange-500 rounded-full transition-all duration-300"
|
||||
style={{ width: `${totalItems > 0 ? (packedCount / totalItems) * 100 : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-slate-400">{totalItems > 0 ? Math.round((packedCount / totalItems) * 100) : 0}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Categories */}
|
||||
<div className="p-4 space-y-4">
|
||||
{Object.entries(categories).map(([category, items]) => (
|
||||
<div key={category}>
|
||||
<h4 className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">{category}</h4>
|
||||
<div className="space-y-1">
|
||||
{items.map((item) => (
|
||||
<label
|
||||
key={item.name}
|
||||
className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-slate-700/30 cursor-pointer transition-colors group"
|
||||
>
|
||||
<div
|
||||
className={`w-5 h-5 rounded border-2 flex items-center justify-center transition-colors ${
|
||||
item.packed
|
||||
? 'bg-amber-500 border-amber-500'
|
||||
: 'border-slate-600 group-hover:border-slate-500'
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
toggleItem(item.name)
|
||||
}}
|
||||
>
|
||||
{item.packed && (
|
||||
<svg className="w-3 h-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={`text-sm transition-colors ${
|
||||
item.packed ? 'text-slate-500 line-through' : 'text-slate-300'
|
||||
}`}
|
||||
>
|
||||
{item.name}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* --- Sidebar Component -------------------------------------------------- */
|
||||
|
||||
function Sidebar({
|
||||
notebook,
|
||||
noteCount,
|
||||
}: {
|
||||
notebook: NotebookData | null
|
||||
noteCount: number
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-slate-800/30 rounded-xl border border-slate-700/50 overflow-hidden">
|
||||
{/* Sidebar header */}
|
||||
<div className="px-4 py-3 border-b border-slate-700/50">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wider">Notebook</span>
|
||||
<span className="text-xs text-slate-500">{noteCount} notes</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active notebook */}
|
||||
<div className="p-2">
|
||||
<div className="mb-1">
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-lg text-sm bg-amber-500/10 text-amber-300 transition-colors">
|
||||
<span>📓</span>
|
||||
<span className="font-medium">{notebook?.notebookTitle || 'Loading...'}</span>
|
||||
</div>
|
||||
<div className="ml-4 mt-0.5 space-y-0.5">
|
||||
<div className="flex items-center justify-between px-3 py-1.5 rounded-md text-xs bg-slate-700/40 text-white transition-colors">
|
||||
<span>Notes</span>
|
||||
<span className="text-slate-600">{noteCount}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between px-3 py-1.5 rounded-md text-xs text-slate-500 hover:text-slate-300 hover:bg-slate-700/20 transition-colors">
|
||||
<span>Packing List</span>
|
||||
<span className="text-slate-600">1</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick info */}
|
||||
<div className="px-4 py-3 border-t border-slate-700/50 space-y-2">
|
||||
<div className="flex items-center gap-2 text-xs text-slate-500">
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<span>Search notes...</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-slate-500">
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
|
||||
</svg>
|
||||
<span>Browse tags</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-slate-500">
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>Recent edits</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* --- Loading Skeleton --------------------------------------------------- */
|
||||
|
||||
function LoadingSkeleton() {
|
||||
return (
|
||||
<div className="space-y-4 animate-pulse">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="bg-slate-800/50 rounded-xl border border-slate-700/50 p-4">
|
||||
<div className="h-4 bg-slate-700/50 rounded w-2/3 mb-3" />
|
||||
<div className="h-3 bg-slate-700/30 rounded w-full mb-2" />
|
||||
<div className="h-3 bg-slate-700/30 rounded w-4/5 mb-3" />
|
||||
<div className="flex gap-2">
|
||||
<div className="h-5 bg-slate-700/30 rounded w-16" />
|
||||
<div className="h-5 bg-slate-700/30 rounded w-20" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* --- Main Demo Content -------------------------------------------------- */
|
||||
|
||||
export default function DemoContent() {
|
||||
const { shapes, updateShape, connected, resetDemo } = useDemoSync({
|
||||
filter: ['folk-note', 'folk-notebook', 'folk-packing-list'],
|
||||
})
|
||||
|
||||
const [expandedNotes, setExpandedNotes] = useState<Set<string>>(new Set(['demo-note-packing']))
|
||||
const [resetting, setResetting] = useState(false)
|
||||
|
||||
// Extract data from shapes
|
||||
const notebook = useMemo<NotebookData | null>(() => {
|
||||
const shape = Object.values(shapes).find((s) => s.type === 'folk-notebook')
|
||||
if (!shape) return null
|
||||
return {
|
||||
notebookTitle: (shape.notebookTitle as string) || 'Untitled Notebook',
|
||||
description: (shape.description as string) || '',
|
||||
noteCount: (shape.noteCount as number) || 0,
|
||||
collaborators: (shape.collaborators as string[]) || [],
|
||||
}
|
||||
}, [shapes])
|
||||
|
||||
const notes = useMemo(() => {
|
||||
return Object.entries(shapes)
|
||||
.filter(([, s]) => s.type === 'folk-note')
|
||||
.map(([id, s]) => ({
|
||||
id,
|
||||
noteTitle: (s.noteTitle as string) || 'Untitled Note',
|
||||
content: (s.content as string) || '',
|
||||
tags: (s.tags as string[]) || [],
|
||||
editor: (s.editor as string) || 'Unknown',
|
||||
editedAt: (s.editedAt as string) || '',
|
||||
}))
|
||||
}, [shapes])
|
||||
|
||||
const packingList = useMemo<{ data: PackingListData; shapeId: string } | null>(() => {
|
||||
const entry = Object.entries(shapes).find(([, s]) => s.type === 'folk-packing-list')
|
||||
if (!entry) return null
|
||||
const [id, s] = entry
|
||||
return {
|
||||
shapeId: id,
|
||||
data: {
|
||||
listTitle: (s.listTitle as string) || 'Packing List',
|
||||
items: (s.items as PackingItem[]) || [],
|
||||
},
|
||||
}
|
||||
}, [shapes])
|
||||
|
||||
const toggleNote = useCallback((id: string) => {
|
||||
setExpandedNotes((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) {
|
||||
next.delete(id)
|
||||
} else {
|
||||
next.add(id)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleReset = useCallback(async () => {
|
||||
setResetting(true)
|
||||
try {
|
||||
await resetDemo()
|
||||
} catch (err) {
|
||||
console.error('Reset failed:', err)
|
||||
} finally {
|
||||
setTimeout(() => setResetting(false), 1000)
|
||||
}
|
||||
}, [resetDemo])
|
||||
|
||||
const hasData = Object.keys(shapes).length > 0
|
||||
const collaborators = notebook?.collaborators || []
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 text-white">
|
||||
<Header
|
||||
maxWidth="max-w-7xl"
|
||||
breadcrumbs={[{ label: 'Demo' }]}
|
||||
actions={
|
||||
<>
|
||||
<div className="flex items-center gap-1.5 text-xs text-slate-400">
|
||||
<span
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
connected ? 'bg-green-400' : 'bg-red-400'
|
||||
}`}
|
||||
/>
|
||||
<span className="hidden sm:inline">{connected ? 'Connected' : 'Disconnected'}</span>
|
||||
</div>
|
||||
<Link
|
||||
href="/notebooks/new"
|
||||
className="text-sm px-4 py-2 bg-amber-500 hover:bg-amber-400 rounded-lg transition-colors font-medium text-slate-900"
|
||||
>
|
||||
Start Taking Notes
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="max-w-7xl mx-auto px-6 pt-12 pb-8">
|
||||
<div className="text-center max-w-3xl mx-auto">
|
||||
{/* Badge */}
|
||||
<div className="inline-flex items-center gap-2 px-4 py-1.5 bg-amber-500/10 border border-amber-500/20 rounded-full text-sm text-amber-300 mb-6">
|
||||
<span
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
connected ? 'bg-green-400 animate-pulse' : 'bg-amber-400 animate-pulse'
|
||||
}`}
|
||||
/>
|
||||
{connected ? 'Live Demo' : 'Interactive Demo'}
|
||||
</div>
|
||||
|
||||
<h1 className="text-4xl sm:text-5xl font-bold mb-4 bg-gradient-to-r from-amber-300 via-orange-300 to-rose-300 bg-clip-text text-transparent">
|
||||
See how rNotes works
|
||||
</h1>
|
||||
<p className="text-lg text-slate-300 mb-2">
|
||||
{notebook?.description || 'A collaborative knowledge base for your team'}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center justify-center gap-4 text-sm text-slate-400 mb-6">
|
||||
<span>Live transcription</span>
|
||||
<span>Audio & video</span>
|
||||
<span>Organized notebooks</span>
|
||||
<span>Canvas sync</span>
|
||||
<span>Real-time collaboration</span>
|
||||
</div>
|
||||
|
||||
{/* Collaborator avatars */}
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{(collaborators.length > 0 ? collaborators : ['...']).map((name, i) => {
|
||||
const colors = ['bg-teal-500', 'bg-cyan-500', 'bg-violet-500', 'bg-rose-500', 'bg-blue-500']
|
||||
return (
|
||||
<div
|
||||
key={name}
|
||||
className={`w-10 h-10 ${colors[i % colors.length]} rounded-full flex items-center justify-center text-sm font-bold text-white ring-2 ring-slate-800`}
|
||||
title={name}
|
||||
>
|
||||
{name[0]}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{collaborators.length > 0 && (
|
||||
<span className="text-sm text-slate-400 ml-2">
|
||||
{collaborators.length} collaborator{collaborators.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Context line + Reset button */}
|
||||
<section className="max-w-7xl mx-auto px-6 pb-6">
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||
<p className="text-center text-sm text-slate-400 max-w-2xl">
|
||||
This demo shows a <span className="text-slate-200 font-medium">Trip Planning Notebook</span> scenario
|
||||
with notes, a packing list, tags, and canvas sync -- all powered by the{' '}
|
||||
<span className="text-slate-200 font-medium">r* ecosystem</span> with live data from{' '}
|
||||
<span className="text-slate-200 font-medium">rSpace</span>.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleReset}
|
||||
disabled={resetting}
|
||||
className="flex-shrink-0 text-xs px-4 py-2 bg-slate-700/60 hover:bg-slate-600/60 disabled:opacity-50 rounded-lg text-slate-300 hover:text-white transition-colors border border-slate-600/30"
|
||||
>
|
||||
{resetting ? 'Resetting...' : 'Reset Demo'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Demo Content: Sidebar + Notes + Packing List */}
|
||||
<section className="max-w-7xl mx-auto px-6 pb-16">
|
||||
{/* Notebook header card */}
|
||||
<div className="bg-slate-800/50 rounded-2xl border border-slate-700/50 overflow-hidden mb-6">
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b border-slate-700/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl">📓</span>
|
||||
<span className="font-semibold text-sm">{notebook?.notebookTitle || 'Loading...'}</span>
|
||||
<span className="text-xs text-slate-500 ml-2">
|
||||
{notebook?.noteCount ?? notes.length} notes
|
||||
</span>
|
||||
</div>
|
||||
<a
|
||||
href="https://rnotes.online"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs px-3 py-1.5 bg-slate-700/60 hover:bg-slate-600/60 rounded-lg text-slate-300 hover:text-white transition-colors"
|
||||
>
|
||||
Open in rNotes
|
||||
</a>
|
||||
</div>
|
||||
<div className="px-5 py-3">
|
||||
<p className="text-sm text-slate-400">{notebook?.description || 'Loading notebook data...'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main layout: sidebar + notes + packing list */}
|
||||
<div className="grid lg:grid-cols-4 gap-6">
|
||||
{/* Sidebar */}
|
||||
<div className="lg:col-span-1">
|
||||
<Sidebar notebook={notebook} noteCount={notes.length} />
|
||||
</div>
|
||||
|
||||
{/* Notes + Packing list */}
|
||||
<div className="lg:col-span-3 space-y-6">
|
||||
{/* Notes section */}
|
||||
<div>
|
||||
{/* Section header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-sm font-semibold text-slate-300">Notes</h2>
|
||||
<span className="text-xs text-slate-500">{notes.length} notes</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-slate-500">
|
||||
<span>Sort: Recently edited</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Note cards or loading */}
|
||||
{!hasData ? (
|
||||
<LoadingSkeleton />
|
||||
) : notes.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{notes.map((note) => (
|
||||
<NoteCard
|
||||
key={note.id}
|
||||
note={note}
|
||||
expanded={expandedNotes.has(note.id)}
|
||||
onToggle={() => toggleNote(note.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-slate-800/50 rounded-xl border border-slate-700/50 p-8 text-center">
|
||||
<p className="text-slate-400 text-sm">No notes found. Try resetting the demo.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Packing List section */}
|
||||
{packingList && (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<h2 className="text-sm font-semibold text-slate-300">Packing List</h2>
|
||||
</div>
|
||||
<PackingList
|
||||
packingList={packingList.data}
|
||||
shapeId={packingList.shapeId}
|
||||
updateShape={updateShape}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Live transcription demo */}
|
||||
<section className="max-w-7xl mx-auto px-6 pb-16">
|
||||
<div className="text-center mb-8">
|
||||
<h2 className="text-2xl font-bold text-white mb-3">Live Voice Transcription</h2>
|
||||
<p className="text-sm text-slate-400 max-w-lg mx-auto">
|
||||
Speak and see your words appear in real time. rNotes transcribes audio and video — live or from files — with offline privacy via NVIDIA Parakeet.
|
||||
</p>
|
||||
</div>
|
||||
<TranscriptionDemo />
|
||||
</section>
|
||||
|
||||
{/* Features showcase */}
|
||||
<section className="max-w-7xl mx-auto px-6 pb-16">
|
||||
<h2 className="text-2xl font-bold text-white text-center mb-8">Everything you need to capture knowledge</h2>
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-5 gap-4">
|
||||
{[
|
||||
{
|
||||
icon: 'voice',
|
||||
title: 'Live Transcription',
|
||||
desc: 'Record and transcribe in real time. Stream audio via WebSocket or transcribe offline with Parakeet.js.',
|
||||
},
|
||||
{
|
||||
icon: 'rich-edit',
|
||||
title: 'Rich Editing',
|
||||
desc: 'Headings, lists, code blocks, highlights, images, and file attachments in every note.',
|
||||
},
|
||||
{
|
||||
icon: 'notebooks',
|
||||
title: 'Notebooks',
|
||||
desc: 'Organize notes into notebooks with sections. Nest as deep as you need.',
|
||||
},
|
||||
{
|
||||
icon: 'tags',
|
||||
title: 'Flexible Tags',
|
||||
desc: 'Cross-cutting tags let you find notes across all notebooks instantly.',
|
||||
},
|
||||
{
|
||||
icon: 'canvas',
|
||||
title: 'Canvas Sync',
|
||||
desc: 'Pin any note to your rSpace canvas for visual collaboration with your team.',
|
||||
},
|
||||
].map((feature) => (
|
||||
<div
|
||||
key={feature.title}
|
||||
className="bg-slate-800/50 rounded-xl border border-slate-700/50 p-5"
|
||||
>
|
||||
<div className="w-10 h-10 bg-amber-500/10 rounded-lg flex items-center justify-center mb-3">
|
||||
{feature.icon === 'voice' && (
|
||||
<svg className="w-5 h-5 text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z" />
|
||||
</svg>
|
||||
)}
|
||||
{feature.icon === 'rich-edit' && (
|
||||
<svg className="w-5 h-5 text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
)}
|
||||
{feature.icon === 'notebooks' && (
|
||||
<svg className="w-5 h-5 text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
)}
|
||||
{feature.icon === 'tags' && (
|
||||
<svg className="w-5 h-5 text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
|
||||
</svg>
|
||||
)}
|
||||
{feature.icon === 'canvas' && (
|
||||
<svg className="w-5 h-5 text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.102 1.101" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-white mb-1">{feature.title}</h3>
|
||||
<p className="text-xs text-slate-400">{feature.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className="max-w-7xl mx-auto px-6 pb-20 text-center">
|
||||
<div className="bg-slate-800/50 rounded-2xl border border-slate-700/50 p-10">
|
||||
<h2 className="text-3xl font-bold mb-3">Ready to capture everything?</h2>
|
||||
<p className="text-slate-400 mb-6 max-w-lg mx-auto">
|
||||
rNotes gives your team a shared knowledge base with rich editing, flexible organization,
|
||||
and deep integration with the r* ecosystem -- all on a collaborative canvas.
|
||||
</p>
|
||||
<Link
|
||||
href="/notebooks/new"
|
||||
className="inline-block px-8 py-4 bg-amber-500 hover:bg-amber-400 rounded-xl text-lg font-medium transition-all shadow-lg shadow-amber-900/30 text-slate-900"
|
||||
>
|
||||
Start Taking Notes
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="border-t border-slate-700/50 py-8">
|
||||
<div className="max-w-7xl mx-auto px-6">
|
||||
<div className="flex flex-wrap items-center justify-center gap-4 text-sm text-slate-500 mb-4">
|
||||
<span className="font-medium text-slate-400">r* Ecosystem</span>
|
||||
<a href="https://rspace.online" className="hover:text-slate-300 transition-colors">rSpace</a>
|
||||
<a href="https://rmaps.online" className="hover:text-slate-300 transition-colors">rMaps</a>
|
||||
<a href="https://rnotes.online" className="hover:text-slate-300 transition-colors font-medium text-slate-300">rNotes</a>
|
||||
<a href="https://rvote.online" className="hover:text-slate-300 transition-colors">rVote</a>
|
||||
<a href="https://rfunds.online" className="hover:text-slate-300 transition-colors">rFunds</a>
|
||||
<a href="https://rtrips.online" className="hover:text-slate-300 transition-colors">rTrips</a>
|
||||
<a href="https://rcart.online" className="hover:text-slate-300 transition-colors">rCart</a>
|
||||
<a href="https://rwallet.online" className="hover:text-slate-300 transition-colors">rWallet</a>
|
||||
<a href="https://rfiles.online" className="hover:text-slate-300 transition-colors">rFiles</a>
|
||||
<a href="https://rinbox.online" className="hover:text-slate-300 transition-colors">rInbox</a>
|
||||
<a href="https://rnetwork.online" className="hover:text-slate-300 transition-colors">rNetwork</a>
|
||||
</div>
|
||||
<p className="text-center text-xs text-slate-600">
|
||||
Part of the r* ecosystem -- collaborative tools for communities.
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
import type { Metadata } from 'next'
|
||||
import DemoContent from './demo-content'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'rNotes Demo - Team Knowledge Base',
|
||||
description: 'See how rNotes powers collaborative note-taking and knowledge management. A demo showcasing notebooks, rich notes, tags, canvas sync, and the full r* ecosystem.',
|
||||
openGraph: {
|
||||
title: 'rNotes Demo - Team Knowledge Base',
|
||||
description: 'See how rNotes powers collaborative note-taking and knowledge management with notebooks, rich notes, tags, and canvas sync.',
|
||||
type: 'website',
|
||||
url: 'https://rnotes.online/demo',
|
||||
},
|
||||
}
|
||||
|
||||
export default function DemoPage() {
|
||||
return <DemoContent />
|
||||
}
|
||||
|
|
@ -1,26 +1,117 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #0f172a;
|
||||
--foreground: #e2e8f0;
|
||||
@theme {
|
||||
--color-background: oklch(0.145 0.015 285);
|
||||
--color-foreground: oklch(0.95 0.01 285);
|
||||
--color-card: oklch(0.18 0.015 285);
|
||||
--color-card-foreground: oklch(0.95 0.01 285);
|
||||
--color-popover: oklch(0.18 0.015 285);
|
||||
--color-popover-foreground: oklch(0.95 0.01 285);
|
||||
--color-primary: oklch(0.72 0.14 195);
|
||||
--color-primary-foreground: oklch(0.15 0.02 285);
|
||||
--color-secondary: oklch(0.25 0.015 285);
|
||||
--color-secondary-foreground: oklch(0.9 0.01 285);
|
||||
--color-muted: oklch(0.22 0.015 285);
|
||||
--color-muted-foreground: oklch(0.65 0.015 285);
|
||||
--color-accent: oklch(0.25 0.015 285);
|
||||
--color-accent-foreground: oklch(0.9 0.01 285);
|
||||
--color-destructive: oklch(0.6 0.2 25);
|
||||
--color-destructive-foreground: oklch(0.98 0.01 285);
|
||||
--color-border: oklch(0.28 0.015 285);
|
||||
--color-input: oklch(0.28 0.015 285);
|
||||
--color-ring: oklch(0.72 0.14 195);
|
||||
--color-suggestion-insert: oklch(0.55 0.15 155);
|
||||
--color-suggestion-delete: oklch(0.55 0.15 25);
|
||||
--color-comment-highlight: oklch(0.65 0.15 85);
|
||||
--radius: 0.625rem;
|
||||
}
|
||||
|
||||
* {
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
body {
|
||||
color: var(--foreground);
|
||||
background: var(--background);
|
||||
background: var(--color-background);
|
||||
color: var(--color-foreground);
|
||||
font-family: var(--font-geist-sans), system-ui, sans-serif;
|
||||
}
|
||||
|
||||
/* ─── TipTap Editor ───────────────────────────────────── */
|
||||
|
||||
.tiptap {
|
||||
outline: none;
|
||||
min-height: 60vh;
|
||||
padding: 2rem 0;
|
||||
}
|
||||
|
||||
/* TipTap editor styles */
|
||||
.tiptap p.is-editor-empty:first-child::before {
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
color: #475569;
|
||||
color: var(--color-muted-foreground);
|
||||
pointer-events: none;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.tiptap h1 { font-size: 2em; font-weight: 700; margin: 1em 0 0.4em; line-height: 1.2; }
|
||||
.tiptap h2 { font-size: 1.5em; font-weight: 600; margin: 0.8em 0 0.3em; line-height: 1.3; }
|
||||
.tiptap h3 { font-size: 1.25em; font-weight: 600; margin: 0.6em 0 0.2em; line-height: 1.4; }
|
||||
|
||||
.tiptap p { margin: 0.5em 0; line-height: 1.7; }
|
||||
|
||||
.tiptap ul,
|
||||
.tiptap ol { padding-left: 1.5em; margin: 0.5em 0; }
|
||||
.tiptap li { margin: 0.25em 0; }
|
||||
.tiptap ul { list-style-type: disc; }
|
||||
.tiptap ol { list-style-type: decimal; }
|
||||
|
||||
.tiptap blockquote {
|
||||
border-left: 3px solid var(--color-primary);
|
||||
padding-left: 1em;
|
||||
margin: 0.5em 0;
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
|
||||
.tiptap pre {
|
||||
background: oklch(0.12 0.015 285);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.75em 1em;
|
||||
margin: 0.5em 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.tiptap code {
|
||||
background: oklch(0.22 0.015 285);
|
||||
border-radius: 0.25em;
|
||||
padding: 0.15em 0.4em;
|
||||
font-size: 0.9em;
|
||||
font-family: var(--font-geist-mono), monospace;
|
||||
}
|
||||
|
||||
.tiptap pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.tiptap hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--color-border);
|
||||
margin: 1.5em 0;
|
||||
}
|
||||
|
||||
.tiptap img {
|
||||
max-width: 100%;
|
||||
border-radius: var(--radius);
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
|
||||
.tiptap a {
|
||||
color: var(--color-primary);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Task lists */
|
||||
.tiptap ul[data-type="taskList"] {
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
|
|
@ -29,32 +120,63 @@ body {
|
|||
.tiptap ul[data-type="taskList"] li {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
gap: 0.5em;
|
||||
}
|
||||
|
||||
.tiptap ul[data-type="taskList"] li label {
|
||||
.tiptap ul[data-type="taskList"] li > label {
|
||||
flex-shrink: 0;
|
||||
margin-top: 0.2rem;
|
||||
margin-top: 0.25em;
|
||||
}
|
||||
|
||||
.tiptap ul[data-type="taskList"] li label input[type="checkbox"] {
|
||||
accent-color: #f59e0b;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
/* ─── Suggestion marks ────────────────────────────────── */
|
||||
|
||||
.suggestion-insert {
|
||||
background: oklch(0.55 0.15 155 / 0.2);
|
||||
border-bottom: 2px solid var(--color-suggestion-insert);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tiptap ul[data-type="taskList"] li div {
|
||||
flex: 1;
|
||||
.suggestion-delete {
|
||||
background: oklch(0.55 0.15 25 / 0.2);
|
||||
text-decoration: line-through;
|
||||
border-bottom: 2px solid var(--color-suggestion-delete);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tiptap img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 0.5rem;
|
||||
/* ─── Comment highlights ──────────────────────────────── */
|
||||
|
||||
.comment-highlight {
|
||||
background: oklch(0.65 0.15 85 / 0.15);
|
||||
border-bottom: 2px solid var(--color-comment-highlight);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tiptap hr {
|
||||
border-color: #334155;
|
||||
margin: 1.5rem 0;
|
||||
.comment-highlight.active {
|
||||
background: oklch(0.65 0.15 85 / 0.3);
|
||||
}
|
||||
|
||||
/* ─── Collaboration cursors ───────────────────────────── */
|
||||
|
||||
.collaboration-cursor__caret {
|
||||
border-left: 2px solid;
|
||||
border-right: none;
|
||||
margin-left: -1px;
|
||||
margin-right: -1px;
|
||||
pointer-events: none;
|
||||
position: relative;
|
||||
word-break: normal;
|
||||
}
|
||||
|
||||
.collaboration-cursor__label {
|
||||
border-radius: 3px 3px 3px 0;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
left: -1px;
|
||||
line-height: 1;
|
||||
padding: 2px 6px;
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y=".9em" font-size="90">📝</text></svg>
|
||||
|
Before Width: | Height: | Size: 109 B |
|
|
@ -1,62 +1,22 @@
|
|||
import type { Metadata, Viewport } from 'next'
|
||||
import { Inter } from 'next/font/google'
|
||||
import './globals.css'
|
||||
import { AuthProvider } from '@/components/AuthProvider'
|
||||
import { PWAInstall } from '@/components/PWAInstall'
|
||||
import { SubdomainSession } from '@/components/SubdomainSession'
|
||||
import { Header } from '@/components/Header'
|
||||
import InfoPopup from "@/components/InfoPopup"
|
||||
import { LANDING_HTML } from "@/components/landing-content"
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-inter',
|
||||
})
|
||||
const geistSans = Geist({ variable: "--font-geist-sans", subsets: ["latin"] });
|
||||
const geistMono = Geist_Mono({ variable: "--font-geist-mono", subsets: ["latin"] });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: '(you)rNotes — Universal Knowledge Capture',
|
||||
description: 'Capture notes, clips, bookmarks, code, and files. Organize in notebooks, tag freely, and collaborate on a visual canvas shared across r*Spaces.',
|
||||
icons: {
|
||||
icon: "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📝</text></svg>",
|
||||
},
|
||||
manifest: '/manifest.json',
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
statusBarStyle: 'black-translucent',
|
||||
title: 'rNotes',
|
||||
},
|
||||
openGraph: {
|
||||
title: '(you)rNotes — Universal Knowledge Capture',
|
||||
description: 'Capture notes, clips, bookmarks, code, and files with a collaborative canvas.',
|
||||
type: 'website',
|
||||
url: 'https://rnotes.online',
|
||||
},
|
||||
}
|
||||
title: "rNotes — Collaborative Notes",
|
||||
description: "Real-time collaborative note-taking with suggestions, comments, and approvals",
|
||||
icons: { icon: "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg'><text y='28' font-size='28'>📝</text></svg>" },
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: '#0a0a0a',
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode
|
||||
}>) {
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<script defer src="https://rdata.online/collect.js" data-website-id="5ca0ec67-ed51-4907-b064-413e20b1d947" />
|
||||
</head>
|
||||
<body className={`${inter.variable} font-sans antialiased`}>
|
||||
<AuthProvider>
|
||||
<SubdomainSession />
|
||||
<Header current="notes" />
|
||||
{children}
|
||||
<PWAInstall />
|
||||
</AuthProvider>
|
||||
<InfoPopup appName="rNotes" appIcon="📝" landingHtml={LANDING_HTML} />
|
||||
<html lang="en" className="dark">
|
||||
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,64 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { CanvasEmbed } from '@/components/CanvasEmbed';
|
||||
|
||||
export default function FullScreenCanvas() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const [canvasSlug, setCanvasSlug] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/notebooks/${params.id}`)
|
||||
.then((res) => res.json())
|
||||
.then((nb) => setCanvasSlug(nb.canvasSlug))
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false));
|
||||
}, [params.id]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="h-screen bg-[#0a0a0a] flex items-center justify-center">
|
||||
<svg className="animate-spin h-8 w-8 text-amber-400" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!canvasSlug) {
|
||||
return (
|
||||
<div className="h-screen bg-[#0a0a0a] flex items-center justify-center text-white">
|
||||
<div className="text-center">
|
||||
<p className="text-slate-400 mb-4">No canvas linked to this notebook yet.</p>
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="text-amber-400 hover:text-amber-300"
|
||||
>
|
||||
Back to Notebook
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen bg-[#0a0a0a] relative">
|
||||
<div className="absolute top-4 left-4 z-20">
|
||||
<button
|
||||
onClick={() => router.push(`/notebooks/${params.id}`)}
|
||||
className="px-4 py-2 bg-slate-800/90 hover:bg-slate-700 border border-slate-600/50 rounded-lg text-sm text-white backdrop-blur-sm transition-colors flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
Back to Notebook
|
||||
</button>
|
||||
</div>
|
||||
<CanvasEmbed canvasSlug={canvasSlug} className="h-full w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,263 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { NoteCard } from '@/components/NoteCard';
|
||||
import { CanvasEmbed } from '@/components/CanvasEmbed';
|
||||
import { OpenNotebookEmbed } from '@/components/OpenNotebookEmbed';
|
||||
import { Header } from '@/components/Header';
|
||||
import { authFetch } from '@/lib/authFetch';
|
||||
import type { CanvasShapeMessage } from '@/lib/canvas-sync';
|
||||
|
||||
interface NoteData {
|
||||
id: string;
|
||||
title: string;
|
||||
type: string;
|
||||
contentPlain: string | null;
|
||||
isPinned: boolean;
|
||||
updatedAt: string;
|
||||
url: string | null;
|
||||
tags: { tag: { id: string; name: string; color: string | null } }[];
|
||||
}
|
||||
|
||||
interface NotebookData {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
coverColor: string;
|
||||
canvasSlug: string | null;
|
||||
isPublic: boolean;
|
||||
notes: NoteData[];
|
||||
_count: { notes: number };
|
||||
}
|
||||
|
||||
export default function NotebookDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const [notebook, setNotebook] = useState<NotebookData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showCanvas, setShowCanvas] = useState(false);
|
||||
const [creatingCanvas, setCreatingCanvas] = useState(false);
|
||||
const [tab, setTab] = useState<'notes' | 'pinned' | 'ai'>('notes');
|
||||
|
||||
const fetchNotebook = useCallback(() => {
|
||||
fetch(`/api/notebooks/${params.id}`)
|
||||
.then((res) => res.json())
|
||||
.then(setNotebook)
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false));
|
||||
}, [params.id]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchNotebook();
|
||||
}, [fetchNotebook]);
|
||||
|
||||
const handleShapeUpdate = useCallback(async (message: CanvasShapeMessage) => {
|
||||
try {
|
||||
await fetch('/api/sync', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
shapeId: message.shapeId,
|
||||
type: message.type,
|
||||
data: message.data,
|
||||
}),
|
||||
});
|
||||
fetchNotebook();
|
||||
} catch (err) {
|
||||
console.error('Canvas sync error:', err);
|
||||
}
|
||||
}, [fetchNotebook]);
|
||||
|
||||
const handleCreateCanvas = async () => {
|
||||
if (creatingCanvas) return;
|
||||
setCreatingCanvas(true);
|
||||
try {
|
||||
const res = await authFetch(`/api/notebooks/${params.id}/canvas`, { method: 'POST' });
|
||||
if (res.ok) {
|
||||
fetchNotebook();
|
||||
setShowCanvas(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create canvas:', error);
|
||||
} finally {
|
||||
setCreatingCanvas(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirm('Delete this notebook and all its notes?')) return;
|
||||
await authFetch(`/api/notebooks/${params.id}`, { method: 'DELETE' });
|
||||
router.push('/notebooks');
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center">
|
||||
<svg className="animate-spin h-8 w-8 text-amber-400" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!notebook) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center text-white">
|
||||
Notebook not found
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const filteredNotes = tab === 'pinned'
|
||||
? notebook.notes.filter((n) => n.isPinned)
|
||||
: notebook.notes;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0a0a0a]">
|
||||
<Header
|
||||
breadcrumbs={[
|
||||
{ label: 'Notebooks', href: '/notebooks' },
|
||||
{ label: notebook.title },
|
||||
]}
|
||||
actions={
|
||||
<>
|
||||
{notebook.canvasSlug ? (
|
||||
<button
|
||||
onClick={() => setShowCanvas(!showCanvas)}
|
||||
className={`px-2 md:px-3 py-1.5 text-sm rounded-lg transition-colors ${
|
||||
showCanvas
|
||||
? 'bg-amber-500/20 text-amber-400 border border-amber-500/30'
|
||||
: 'bg-slate-800 text-slate-400 border border-slate-700 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<span className="hidden sm:inline">{showCanvas ? 'Hide Canvas' : 'Show Canvas'}</span>
|
||||
<svg className="w-4 h-4 sm:hidden" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z" /></svg>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleCreateCanvas}
|
||||
disabled={creatingCanvas}
|
||||
className="px-2 md:px-3 py-1.5 text-sm bg-slate-800 text-slate-400 border border-slate-700 rounded-lg hover:text-white transition-colors hidden sm:inline-flex"
|
||||
>
|
||||
{creatingCanvas ? 'Creating...' : 'Create Canvas'}
|
||||
</button>
|
||||
)}
|
||||
<Link
|
||||
href={`/notes/new?notebookId=${notebook.id}`}
|
||||
className="px-3 md:px-4 py-2 bg-amber-500 hover:bg-amber-400 text-black text-sm font-medium rounded-lg transition-colors"
|
||||
>
|
||||
<span className="hidden sm:inline">Add Note</span>
|
||||
<svg className="w-4 h-4 sm:hidden" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" /></svg>
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="px-2 md:px-3 py-1.5 text-sm text-red-400 hover:text-red-300 border border-red-900/30 hover:border-red-800 rounded-lg transition-colors"
|
||||
>
|
||||
<span className="hidden sm:inline">Delete</span>
|
||||
<svg className="w-4 h-4 sm:hidden" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className={`flex ${showCanvas ? 'gap-0' : ''}`}>
|
||||
{/* Notes panel */}
|
||||
<main className={`${showCanvas ? 'hidden md:block md:w-3/5' : 'w-full'} max-w-6xl mx-auto px-4 md:px-6 py-6 md:py-8`}>
|
||||
{/* Header */}
|
||||
<div className="mb-6 md:mb-8">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: notebook.coverColor }} />
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-white">{notebook.title}</h1>
|
||||
</div>
|
||||
{notebook.description && (
|
||||
<p className="text-slate-400 ml-7">{notebook.description}</p>
|
||||
)}
|
||||
<p className="text-sm text-slate-500 ml-7 mt-1">{notebook._count.notes} notes</p>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-4 border-b border-slate-800 mb-6">
|
||||
<button
|
||||
onClick={() => setTab('notes')}
|
||||
className={`pb-3 text-sm font-medium transition-colors ${
|
||||
tab === 'notes'
|
||||
? 'text-amber-400 border-b-2 border-amber-400'
|
||||
: 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
All Notes
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTab('pinned')}
|
||||
className={`pb-3 text-sm font-medium transition-colors ${
|
||||
tab === 'pinned'
|
||||
? 'text-amber-400 border-b-2 border-amber-400'
|
||||
: 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Pinned
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTab('ai')}
|
||||
className={`pb-3 text-sm font-medium transition-colors ${
|
||||
tab === 'ai'
|
||||
? 'text-amber-400 border-b-2 border-amber-400'
|
||||
: 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Open Notebook
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
{tab === 'ai' ? (
|
||||
<OpenNotebookEmbed className="h-[calc(100vh-220px)] min-h-[500px]" />
|
||||
) : filteredNotes.length === 0 ? (
|
||||
<div className="text-center py-12 text-slate-400">
|
||||
{tab === 'pinned' ? 'No pinned notes' : 'No notes yet. Add one!'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{filteredNotes.map((note) => (
|
||||
<NoteCard
|
||||
key={note.id}
|
||||
id={note.id}
|
||||
title={note.title}
|
||||
type={note.type}
|
||||
contentPlain={note.contentPlain}
|
||||
isPinned={note.isPinned}
|
||||
updatedAt={note.updatedAt}
|
||||
url={note.url}
|
||||
tags={note.tags.map((nt) => ({
|
||||
id: nt.tag.id,
|
||||
name: nt.tag.name,
|
||||
color: nt.tag.color,
|
||||
}))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Canvas sidebar — full screen on mobile, split on desktop */}
|
||||
{showCanvas && notebook.canvasSlug && (
|
||||
<div className="fixed inset-0 z-40 md:relative md:inset-auto md:w-2/5 md:z-auto border-l border-slate-800 md:sticky md:top-0 md:h-screen bg-[#0a0a0a]">
|
||||
<div className="md:hidden flex items-center justify-between px-4 py-3 border-b border-slate-800">
|
||||
<span className="text-sm font-medium text-white">Canvas</span>
|
||||
<button
|
||||
onClick={() => setShowCanvas(false)}
|
||||
className="p-1 text-slate-400 hover:text-white"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
<CanvasEmbed canvasSlug={notebook.canvasSlug} className="h-full" onShapeUpdate={handleShapeUpdate} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { Header } from '@/components/Header';
|
||||
import { authFetch } from '@/lib/authFetch';
|
||||
|
||||
const COVER_COLORS = [
|
||||
'#f59e0b', '#ef4444', '#8b5cf6', '#3b82f6',
|
||||
'#10b981', '#ec4899', '#f97316', '#6366f1',
|
||||
];
|
||||
|
||||
export default function NewNotebookPage() {
|
||||
const router = useRouter();
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [coverColor, setCoverColor] = useState('#f59e0b');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!title.trim() || saving) return;
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await authFetch('/api/notebooks', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title, description, coverColor }),
|
||||
});
|
||||
const notebook = await res.json();
|
||||
if (res.ok) {
|
||||
router.push(`/notebooks/${notebook.id}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create notebook:', error);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0a0a0a]">
|
||||
<Header breadcrumbs={[{ label: 'Notebooks', href: '/notebooks' }, { label: 'New' }]} />
|
||||
|
||||
<main className="max-w-2xl mx-auto px-4 md:px-6 py-8 md:py-12">
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-white mb-6 md:mb-8">Create Notebook</h1>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-300 mb-2">Title</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="My Research Notes"
|
||||
className="w-full px-4 py-3 bg-slate-800/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:border-amber-500/50"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-300 mb-2">Description (optional)</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="What's this notebook about?"
|
||||
rows={3}
|
||||
className="w-full px-4 py-3 bg-slate-800/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:border-amber-500/50 resize-y"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-300 mb-2">Cover Color</label>
|
||||
<div className="flex gap-3">
|
||||
{COVER_COLORS.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
type="button"
|
||||
onClick={() => setCoverColor(color)}
|
||||
className={`w-8 h-8 rounded-full transition-all ${
|
||||
coverColor === color ? 'ring-2 ring-white ring-offset-2 ring-offset-[#0a0a0a] scale-110' : 'hover:scale-105'
|
||||
}`}
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!title.trim() || saving}
|
||||
className="px-6 py-3 bg-amber-500 hover:bg-amber-400 disabled:bg-slate-700 disabled:text-slate-400 text-black font-semibold rounded-lg transition-colors"
|
||||
>
|
||||
{saving ? 'Creating...' : 'Create Notebook'}
|
||||
</button>
|
||||
<Link
|
||||
href="/notebooks"
|
||||
className="px-6 py-3 border border-slate-700 hover:border-slate-600 text-white rounded-lg transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { NotebookCard } from '@/components/NotebookCard';
|
||||
import { Header } from '@/components/Header';
|
||||
|
||||
interface NotebookData {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
coverColor: string;
|
||||
updatedAt: string;
|
||||
_count: { notes: number };
|
||||
}
|
||||
|
||||
export default function NotebooksPage() {
|
||||
const [notebooks, setNotebooks] = useState<NotebookData[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/notebooks')
|
||||
.then((res) => res.json())
|
||||
.then(setNotebooks)
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0a0a0a]">
|
||||
<Header
|
||||
breadcrumbs={[{ label: 'Notebooks' }]}
|
||||
actions={
|
||||
<Link
|
||||
href="/notebooks/new"
|
||||
className="px-3 md:px-4 py-2 bg-amber-500 hover:bg-amber-400 text-black text-sm font-medium rounded-lg transition-colors"
|
||||
>
|
||||
<span className="hidden sm:inline">New Notebook</span>
|
||||
<svg className="w-4 h-4 sm:hidden" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" /></svg>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
<main className="max-w-6xl mx-auto px-4 md:px-6 py-6 md:py-8">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<svg className="animate-spin h-8 w-8 text-amber-400" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
</div>
|
||||
) : notebooks.length === 0 ? (
|
||||
<div className="text-center py-20">
|
||||
<p className="text-slate-400 mb-4">No notebooks yet. Create your first one!</p>
|
||||
<Link
|
||||
href="/notebooks/new"
|
||||
className="inline-flex px-6 py-3 bg-amber-500 hover:bg-amber-400 text-black font-semibold rounded-lg transition-colors"
|
||||
>
|
||||
Create Notebook
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid sm:grid-cols-2 md:grid-cols-3 gap-3 md:gap-4">
|
||||
{notebooks.map((nb) => (
|
||||
<NotebookCard
|
||||
key={nb.id}
|
||||
id={nb.id}
|
||||
title={nb.title}
|
||||
description={nb.description}
|
||||
coverColor={nb.coverColor}
|
||||
noteCount={nb._count.notes}
|
||||
updatedAt={nb.updatedAt}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,543 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { NoteEditor } from '@/components/NoteEditor';
|
||||
import { TagBadge } from '@/components/TagBadge';
|
||||
import { Header } from '@/components/Header';
|
||||
import { authFetch } from '@/lib/authFetch';
|
||||
|
||||
const TYPE_COLORS: Record<string, string> = {
|
||||
NOTE: 'bg-amber-500/20 text-amber-400',
|
||||
CLIP: 'bg-purple-500/20 text-purple-400',
|
||||
BOOKMARK: 'bg-blue-500/20 text-blue-400',
|
||||
CODE: 'bg-green-500/20 text-green-400',
|
||||
IMAGE: 'bg-pink-500/20 text-pink-400',
|
||||
FILE: 'bg-slate-500/20 text-slate-400',
|
||||
AUDIO: 'bg-red-500/20 text-red-400',
|
||||
};
|
||||
|
||||
const CARD_TYPE_COLORS: Record<string, string> = {
|
||||
note: 'bg-amber-500/20 text-amber-400',
|
||||
link: 'bg-blue-500/20 text-blue-400',
|
||||
file: 'bg-slate-500/20 text-slate-400',
|
||||
task: 'bg-green-500/20 text-green-400',
|
||||
person: 'bg-purple-500/20 text-purple-400',
|
||||
idea: 'bg-yellow-500/20 text-yellow-400',
|
||||
reference: 'bg-pink-500/20 text-pink-400',
|
||||
};
|
||||
|
||||
interface NoteData {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
contentPlain: string | null;
|
||||
bodyJson: object | null;
|
||||
bodyMarkdown: string | null;
|
||||
bodyFormat: string;
|
||||
type: string;
|
||||
cardType: string;
|
||||
url: string | null;
|
||||
archiveUrl: string | null;
|
||||
language: string | null;
|
||||
fileUrl: string | null;
|
||||
mimeType: string | null;
|
||||
fileSize: number | null;
|
||||
duration: number | null;
|
||||
isPinned: boolean;
|
||||
canvasShapeId: string | null;
|
||||
summary: string | null;
|
||||
visibility: string;
|
||||
properties: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
notebook: { id: string; title: string; slug: string } | null;
|
||||
parent: { id: string; title: string; cardType: string } | null;
|
||||
children: { id: string; title: string; cardType: string }[];
|
||||
tags: { tag: { id: string; name: string; color: string | null } }[];
|
||||
attachments: { id: string; role: string; caption: string | null; file: { id: string; filename: string; mimeType: string; sizeBytes: number; storageKey: string } }[];
|
||||
}
|
||||
|
||||
export default function NoteDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const [note, setNote] = useState<NoteData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editTitle, setEditTitle] = useState('');
|
||||
const [editContent, setEditContent] = useState('');
|
||||
const [editBodyJson, setEditBodyJson] = useState<object | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [diarizing, setDiarizing] = useState(false);
|
||||
const [speakers, setSpeakers] = useState<{ speaker: string; start: number; end: number }[] | null>(null);
|
||||
const [unlocking, setUnlocking] = useState(false);
|
||||
const [unlockError, setUnlockError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/notes/${params.id}`)
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
setNote(data);
|
||||
setEditTitle(data.title);
|
||||
setEditContent(data.content);
|
||||
setEditBodyJson(data.bodyJson || null);
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false));
|
||||
}, [params.id]);
|
||||
|
||||
const handleEditorChange = (html: string, json?: object) => {
|
||||
setEditContent(html);
|
||||
if (json) setEditBodyJson(json);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (saving) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload: Record<string, unknown> = { title: editTitle };
|
||||
if (editBodyJson) {
|
||||
payload.bodyJson = editBodyJson;
|
||||
} else {
|
||||
payload.content = editContent;
|
||||
}
|
||||
|
||||
const res = await authFetch(`/api/notes/${params.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (res.ok) {
|
||||
const updated = await res.json();
|
||||
setNote(updated);
|
||||
setEditing(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save:', error);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTogglePin = async () => {
|
||||
if (!note) return;
|
||||
const res = await authFetch(`/api/notes/${params.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ isPinned: !note.isPinned }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const updated = await res.json();
|
||||
setNote(updated);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirm('Archive this note? It can be restored later.')) return;
|
||||
await authFetch(`/api/notes/${params.id}`, { method: 'DELETE' });
|
||||
if (note?.notebook) {
|
||||
router.push(`/notebooks/${note.notebook.id}`);
|
||||
} else {
|
||||
router.push('/');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDiarize = async () => {
|
||||
if (!note?.fileUrl || diarizing) return;
|
||||
setDiarizing(true);
|
||||
try {
|
||||
const audioRes = await fetch(note.fileUrl);
|
||||
const audioBlob = await audioRes.blob();
|
||||
|
||||
const form = new FormData();
|
||||
form.append('audio', audioBlob, 'recording.webm');
|
||||
|
||||
const res = await authFetch('/api/voice/diarize', {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const result = await res.json();
|
||||
setSpeakers(result.speakers || []);
|
||||
} else {
|
||||
console.error('Diarization failed');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Diarization error:', error);
|
||||
} finally {
|
||||
setDiarizing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnlock = async () => {
|
||||
if (!note?.url || unlocking) return;
|
||||
setUnlocking(true);
|
||||
setUnlockError(null);
|
||||
try {
|
||||
const res = await authFetch('/api/articles/unlock', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url: note.url, noteId: note.id }),
|
||||
});
|
||||
const result = await res.json();
|
||||
if (result.success && result.archiveUrl) {
|
||||
setNote({ ...note, archiveUrl: result.archiveUrl });
|
||||
} else {
|
||||
setUnlockError(result.error || 'No archived version found');
|
||||
}
|
||||
} catch (error) {
|
||||
setUnlockError('Failed to unlock article');
|
||||
console.error('Unlock error:', error);
|
||||
} finally {
|
||||
setUnlocking(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center">
|
||||
<svg className="animate-spin h-8 w-8 text-amber-400" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!note) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center text-white">
|
||||
Note not found
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const properties = note.properties && typeof note.properties === 'object' ? Object.entries(note.properties).filter(([, v]) => v != null && v !== '') : [];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0a0a0a]">
|
||||
<Header
|
||||
maxWidth="max-w-4xl"
|
||||
breadcrumbs={[
|
||||
...(note.parent
|
||||
? [{ label: note.parent.title, href: `/notes/${note.parent.id}` }]
|
||||
: []),
|
||||
...(note.notebook
|
||||
? [{ label: note.notebook.title, href: `/notebooks/${note.notebook.id}` }]
|
||||
: []),
|
||||
{ label: note.title },
|
||||
]}
|
||||
actions={
|
||||
<>
|
||||
<button
|
||||
onClick={handleTogglePin}
|
||||
className={`px-2 md:px-3 py-1.5 text-sm rounded-lg border transition-colors ${
|
||||
note.isPinned
|
||||
? 'bg-amber-500/20 text-amber-400 border-amber-500/30'
|
||||
: 'text-slate-400 border-slate-700 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<span className="hidden sm:inline">{note.isPinned ? 'Unpin' : 'Pin to Canvas'}</span>
|
||||
<svg className="w-4 h-4 sm:hidden" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" /></svg>
|
||||
</button>
|
||||
{editing ? (
|
||||
<>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="px-2 md:px-3 py-1.5 text-sm bg-amber-500 hover:bg-amber-400 text-black font-medium rounded-lg transition-colors"
|
||||
>
|
||||
{saving ? '...' : 'Save'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditing(false);
|
||||
setEditTitle(note.title);
|
||||
setEditContent(note.content);
|
||||
setEditBodyJson(note.bodyJson || null);
|
||||
}}
|
||||
className="px-2 md:px-3 py-1.5 text-sm text-slate-400 border border-slate-700 rounded-lg hover:text-white transition-colors hidden sm:inline-flex"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setEditing(true)}
|
||||
className="px-2 md:px-3 py-1.5 text-sm text-slate-400 border border-slate-700 rounded-lg hover:text-white transition-colors"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="px-2 md:px-3 py-1.5 text-sm text-red-400 hover:text-red-300 border border-red-900/30 rounded-lg transition-colors"
|
||||
>
|
||||
<span className="hidden sm:inline">Forget</span>
|
||||
<svg className="w-4 h-4 sm:hidden" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<main className="max-w-4xl mx-auto px-4 md:px-6 py-6 md:py-8">
|
||||
{/* Metadata */}
|
||||
<div className="flex flex-wrap items-center gap-2 md:gap-3 mb-6">
|
||||
<span className={`text-xs font-bold uppercase px-2 py-1 rounded ${TYPE_COLORS[note.type] || TYPE_COLORS.NOTE}`}>
|
||||
{note.type}
|
||||
</span>
|
||||
{note.cardType !== 'note' && (
|
||||
<span className={`text-xs font-medium px-2 py-1 rounded ${CARD_TYPE_COLORS[note.cardType] || CARD_TYPE_COLORS.note}`}>
|
||||
{note.cardType}
|
||||
</span>
|
||||
)}
|
||||
{note.visibility !== 'private' && (
|
||||
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded bg-slate-700/50 text-slate-400">
|
||||
{note.visibility}
|
||||
</span>
|
||||
)}
|
||||
{note.tags.map((nt) => (
|
||||
<TagBadge key={nt.tag.id} name={nt.tag.name} color={nt.tag.color} />
|
||||
))}
|
||||
<span className="text-xs text-slate-500 ml-auto">
|
||||
Created {new Date(note.createdAt).toLocaleDateString()} · Updated {new Date(note.updatedAt).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
{note.summary && (
|
||||
<div className="mb-4 p-3 bg-slate-800/30 border border-slate-700/50 rounded-lg text-sm text-slate-300 italic">
|
||||
{note.summary}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Properties */}
|
||||
{properties.length > 0 && (
|
||||
<div className="mb-4 flex flex-wrap gap-2">
|
||||
{properties.map(([key, value]) => (
|
||||
<span key={key} className="text-[10px] px-2 py-1 rounded bg-slate-800/50 border border-slate-700/50 text-slate-400">
|
||||
<span className="text-slate-500">{key}:</span> {String(value)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* URL + Unlock */}
|
||||
{note.url && (
|
||||
<div className="mb-4 space-y-2">
|
||||
<a
|
||||
href={note.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-blue-400 hover:text-blue-300 block truncate"
|
||||
>
|
||||
{note.url}
|
||||
</a>
|
||||
|
||||
{note.archiveUrl ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<a
|
||||
href={note.archiveUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 rounded-lg hover:bg-emerald-500/20 transition-colors"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 11V7a4 4 0 118 0m-4 8v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2z" />
|
||||
</svg>
|
||||
View Unlocked Article
|
||||
</a>
|
||||
<span className="text-[10px] text-slate-500 truncate max-w-[200px]">{note.archiveUrl}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleUnlock}
|
||||
disabled={unlocking}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-amber-500/10 text-amber-400 border border-amber-500/20 rounded-lg hover:bg-amber-500/20 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{unlocking ? (
|
||||
<>
|
||||
<svg className="animate-spin w-3.5 h-3.5" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
Unlocking...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
Unlock Article
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{unlockError && (
|
||||
<span className="text-[10px] text-red-400">{unlockError}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Uploaded file/image */}
|
||||
{note.fileUrl && note.type === 'IMAGE' && (
|
||||
<div className="mb-6 rounded-lg overflow-hidden border border-slate-700">
|
||||
<img
|
||||
src={note.fileUrl}
|
||||
alt={note.title}
|
||||
className="max-w-full max-h-[600px] object-contain mx-auto bg-slate-900"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{note.fileUrl && note.type === 'FILE' && (
|
||||
<div className="mb-6 flex items-center gap-3 p-4 bg-slate-800/50 border border-slate-700 rounded-lg">
|
||||
<svg className="w-8 h-8 text-slate-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-white truncate">{note.fileUrl.split('/').pop()}</p>
|
||||
{note.mimeType && <p className="text-xs text-slate-500">{note.mimeType}{note.fileSize ? ` · ${(note.fileSize / 1024).toFixed(1)} KB` : ''}</p>}
|
||||
</div>
|
||||
<a
|
||||
href={note.fileUrl}
|
||||
download
|
||||
className="px-3 py-1.5 text-sm bg-slate-700 hover:bg-slate-600 text-white rounded-lg transition-colors"
|
||||
>
|
||||
Download
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{note.fileUrl && note.type === 'AUDIO' && (
|
||||
<div className="mb-6 p-4 bg-slate-800/50 border border-slate-700 rounded-lg space-y-3">
|
||||
<audio controls src={note.fileUrl} className="w-full" />
|
||||
<div className="flex items-center gap-3 text-xs text-slate-500">
|
||||
{note.duration != null && <span>{Math.floor(note.duration / 60)}:{(note.duration % 60).toString().padStart(2, '0')}</span>}
|
||||
{note.mimeType && <span>{note.mimeType}</span>}
|
||||
{note.fileSize && <span>{(note.fileSize / 1024).toFixed(1)} KB</span>}
|
||||
{!speakers && (
|
||||
<button
|
||||
onClick={handleDiarize}
|
||||
disabled={diarizing}
|
||||
className="ml-auto px-3 py-1 text-xs rounded-lg border border-slate-600 text-slate-400 hover:text-white hover:border-slate-500 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{diarizing ? 'Identifying speakers...' : 'Identify speakers'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{speakers && speakers.length > 0 && (
|
||||
<div className="pt-2 border-t border-slate-700 space-y-1.5">
|
||||
<div className="text-xs text-slate-500 uppercase tracking-wider mb-2">Speakers</div>
|
||||
{speakers.map((s, i) => {
|
||||
const colors: Record<string, string> = {
|
||||
SPEAKER_00: 'border-blue-500/50 text-blue-300',
|
||||
SPEAKER_01: 'border-green-500/50 text-green-300',
|
||||
SPEAKER_02: 'border-purple-500/50 text-purple-300',
|
||||
SPEAKER_03: 'border-orange-500/50 text-orange-300',
|
||||
};
|
||||
const color = colors[s.speaker] || 'border-slate-500/50 text-slate-300';
|
||||
return (
|
||||
<div key={i} className={`text-xs px-2 py-1.5 rounded border-l-2 bg-slate-800/50 ${color}`}>
|
||||
<span className="font-medium">{s.speaker.replace('SPEAKER_', 'Speaker ')}</span>
|
||||
<span className="text-slate-500 ml-2">
|
||||
{Math.floor(s.start / 60)}:{Math.floor(s.start % 60).toString().padStart(2, '0')}
|
||||
–
|
||||
{Math.floor(s.end / 60)}:{Math.floor(s.end % 60).toString().padStart(2, '0')}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Attachments gallery */}
|
||||
{note.attachments.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<h3 className="text-xs font-medium text-slate-500 uppercase tracking-wider mb-2">Attachments</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{note.attachments.map((att) => (
|
||||
<a
|
||||
key={att.id}
|
||||
href={`/api/uploads/${att.file.storageKey}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 px-3 py-2 bg-slate-800/50 border border-slate-700/50 rounded-lg text-sm text-slate-300 hover:border-slate-600 transition-colors"
|
||||
>
|
||||
{att.file.mimeType.startsWith('image/') ? (
|
||||
<img src={`/api/uploads/${att.file.storageKey}`} alt={att.caption || att.file.filename} className="w-8 h-8 object-cover rounded" />
|
||||
) : (
|
||||
<svg className="w-4 h-4 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||
</svg>
|
||||
)}
|
||||
<span className="truncate max-w-[150px]">{att.caption || att.file.filename}</span>
|
||||
<span className="text-[10px] text-slate-500">{att.role}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
{editing ? (
|
||||
<div className="space-y-4">
|
||||
<input
|
||||
type="text"
|
||||
value={editTitle}
|
||||
onChange={(e) => setEditTitle(e.target.value)}
|
||||
className="w-full text-3xl font-bold bg-transparent text-white border-b border-slate-700 pb-2 focus:outline-none focus:border-amber-500/50"
|
||||
/>
|
||||
<NoteEditor
|
||||
value={editContent}
|
||||
valueJson={editBodyJson || undefined}
|
||||
onChange={handleEditorChange}
|
||||
type={note.type}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-white mb-6">{note.title}</h1>
|
||||
{note.type === 'CODE' ? (
|
||||
<pre className="bg-slate-800/50 border border-slate-700 rounded-lg p-4 overflow-x-auto">
|
||||
<code className="text-sm text-slate-200 font-mono">
|
||||
{note.content}
|
||||
</code>
|
||||
</pre>
|
||||
) : (
|
||||
<div
|
||||
className="prose prose-invert prose-sm max-w-none text-slate-300 leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: note.content }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Children */}
|
||||
{note.children.length > 0 && (
|
||||
<div className="mt-8 border-t border-slate-800 pt-6">
|
||||
<h3 className="text-xs font-medium text-slate-500 uppercase tracking-wider mb-3">Child Notes</h3>
|
||||
<div className="space-y-2">
|
||||
{note.children.map((child) => (
|
||||
<Link
|
||||
key={child.id}
|
||||
href={`/notes/${child.id}`}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-slate-800/30 hover:bg-slate-800/50 border border-slate-700/30 rounded-lg transition-colors"
|
||||
>
|
||||
<span className={`text-[10px] font-bold px-1.5 py-0.5 rounded ${CARD_TYPE_COLORS[child.cardType] || CARD_TYPE_COLORS.note}`}>
|
||||
{child.cardType}
|
||||
</span>
|
||||
<span className="text-sm text-slate-300 hover:text-white">{child.title}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,326 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import { Suspense, useState, useEffect } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { NoteEditor } from '@/components/NoteEditor';
|
||||
import { FileUpload } from '@/components/FileUpload';
|
||||
import { VoiceRecorder } from '@/components/VoiceRecorder';
|
||||
import { Header } from '@/components/Header';
|
||||
import { authFetch } from '@/lib/authFetch';
|
||||
|
||||
const NOTE_TYPES = [
|
||||
{ value: 'NOTE', label: 'Note', desc: 'Rich text note' },
|
||||
{ value: 'CLIP', label: 'Clip', desc: 'Web clipping' },
|
||||
{ value: 'BOOKMARK', label: 'Bookmark', desc: 'Save a URL' },
|
||||
{ value: 'CODE', label: 'Code', desc: 'Code snippet' },
|
||||
{ value: 'IMAGE', label: 'Image', desc: 'Upload image' },
|
||||
{ value: 'FILE', label: 'File', desc: 'Upload file' },
|
||||
{ value: 'AUDIO', label: 'Audio', desc: 'Voice recording' },
|
||||
];
|
||||
|
||||
interface NotebookOption {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export default function NewNotePage() {
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center">
|
||||
<svg className="animate-spin h-8 w-8 text-amber-400" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
</div>
|
||||
}>
|
||||
<NewNoteForm />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function NewNoteForm() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const preselectedNotebook = searchParams.get('notebookId');
|
||||
const preselectedType = searchParams.get('type');
|
||||
|
||||
const [title, setTitle] = useState('');
|
||||
const [content, setContent] = useState('');
|
||||
const [bodyJson, setBodyJson] = useState<object | null>(null);
|
||||
const [type, setType] = useState(
|
||||
NOTE_TYPES.some((t) => t.value === preselectedType) ? preselectedType! : 'NOTE'
|
||||
);
|
||||
const [url, setUrl] = useState('');
|
||||
const [language, setLanguage] = useState('');
|
||||
const [tags, setTags] = useState('');
|
||||
const [fileUrl, setFileUrl] = useState('');
|
||||
const [mimeType, setMimeType] = useState('');
|
||||
const [fileSize, setFileSize] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [notebookId, setNotebookId] = useState(preselectedNotebook || '');
|
||||
const [notebooks, setNotebooks] = useState<NotebookOption[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/notebooks')
|
||||
.then((res) => res.json())
|
||||
.then((data) => setNotebooks(data.map((nb: NotebookOption) => ({ id: nb.id, title: nb.title }))))
|
||||
.catch(console.error);
|
||||
}, []);
|
||||
|
||||
const handleContentChange = (html: string, json?: object) => {
|
||||
setContent(html);
|
||||
if (json) setBodyJson(json);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!title.trim() || saving) return;
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const body: Record<string, unknown> = {
|
||||
title,
|
||||
content,
|
||||
type,
|
||||
tags: tags.split(',').map((t) => t.trim()).filter(Boolean),
|
||||
};
|
||||
if (bodyJson) body.bodyJson = bodyJson;
|
||||
if (notebookId) body.notebookId = notebookId;
|
||||
if (url) body.url = url;
|
||||
if (language) body.language = language;
|
||||
if (fileUrl) body.fileUrl = fileUrl;
|
||||
if (mimeType) body.mimeType = mimeType;
|
||||
if (fileSize) body.fileSize = fileSize;
|
||||
if (duration) body.duration = duration;
|
||||
|
||||
const endpoint = notebookId
|
||||
? `/api/notebooks/${notebookId}/notes`
|
||||
: '/api/notes';
|
||||
|
||||
const res = await authFetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const note = await res.json();
|
||||
if (res.ok) {
|
||||
router.push(`/notes/${note.id}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create note:', error);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const showUrl = ['CLIP', 'BOOKMARK'].includes(type);
|
||||
const showUpload = ['IMAGE', 'FILE'].includes(type);
|
||||
const showLanguage = type === 'CODE';
|
||||
const showRecorder = type === 'AUDIO';
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0a0a0a]">
|
||||
<Header breadcrumbs={[{ label: 'New Note' }]} />
|
||||
|
||||
<main className="max-w-3xl mx-auto px-4 md:px-6 py-8 md:py-12">
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-white mb-6 md:mb-8">Create Note</h1>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Type selector */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-300 mb-2">Type</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{NOTE_TYPES.map((t) => (
|
||||
<button
|
||||
key={t.value}
|
||||
type="button"
|
||||
onClick={() => setType(t.value)}
|
||||
className={`px-3 py-2 text-sm rounded-lg border transition-colors ${
|
||||
type === t.value
|
||||
? 'bg-amber-500/20 text-amber-400 border-amber-500/30'
|
||||
: 'bg-slate-800/50 text-slate-400 border-slate-700 hover:text-white hover:border-slate-600'
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-300 mb-2">Title</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Note title"
|
||||
className="w-full px-4 py-3 bg-slate-800/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:border-amber-500/50"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* URL field */}
|
||||
{showUrl && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-300 mb-2">URL</label>
|
||||
<input
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="https://..."
|
||||
className="w-full px-4 py-3 bg-slate-800/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:border-amber-500/50"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File upload */}
|
||||
{showUpload && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-300 mb-2">
|
||||
{type === 'IMAGE' ? 'Upload Image' : 'Upload File'}
|
||||
</label>
|
||||
{fileUrl ? (
|
||||
<div className="flex items-center gap-3 p-3 bg-slate-800/50 border border-slate-700 rounded-lg">
|
||||
{type === 'IMAGE' && (
|
||||
<img src={fileUrl} alt="Preview" className="w-16 h-16 object-cover rounded" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-white truncate">{fileUrl.split('/').pop()}</p>
|
||||
<p className="text-xs text-slate-500">{mimeType} · {(fileSize / 1024).toFixed(1)} KB</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setFileUrl(''); setMimeType(''); setFileSize(0); }}
|
||||
className="text-slate-400 hover:text-red-400 text-sm"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<FileUpload
|
||||
accept={type === 'IMAGE' ? 'image/*' : undefined}
|
||||
onUpload={(result) => {
|
||||
setFileUrl(result.url);
|
||||
setMimeType(result.mimeType);
|
||||
setFileSize(result.size);
|
||||
if (!title) setTitle(result.originalName);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="mt-2">
|
||||
<label className="block text-xs text-slate-500 mb-1">Or paste a URL</label>
|
||||
<input
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="https://..."
|
||||
className="w-full px-3 py-2 bg-slate-800/50 border border-slate-700 rounded-lg text-white text-sm placeholder-slate-500 focus:outline-none focus:border-amber-500/50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Language field */}
|
||||
{showLanguage && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-300 mb-2">Language</label>
|
||||
<input
|
||||
type="text"
|
||||
value={language}
|
||||
onChange={(e) => setLanguage(e.target.value)}
|
||||
placeholder="typescript, python, rust..."
|
||||
className="w-full px-4 py-3 bg-slate-800/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:border-amber-500/50"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Voice recorder */}
|
||||
{showRecorder && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-300 mb-2">Recording</label>
|
||||
<VoiceRecorder
|
||||
onResult={(result) => {
|
||||
setFileUrl(result.fileUrl);
|
||||
setMimeType(result.mimeType);
|
||||
setFileSize(result.fileSize);
|
||||
setDuration(result.duration);
|
||||
setContent(result.transcript);
|
||||
if (!title) setTitle(`Voice note ${new Date().toLocaleDateString()}`);
|
||||
}}
|
||||
/>
|
||||
{content && (
|
||||
<div className="mt-4">
|
||||
<label className="block text-sm font-medium text-slate-300 mb-2">Transcript</label>
|
||||
<div className="p-4 bg-slate-800/50 border border-slate-700 rounded-lg text-slate-300 text-sm leading-relaxed">
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
{!showRecorder && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-300 mb-2">Content</label>
|
||||
<NoteEditor
|
||||
value={content}
|
||||
onChange={handleContentChange}
|
||||
type={type}
|
||||
placeholder={type === 'CODE' ? 'Paste your code here...' : 'Write in Markdown...'}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notebook */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-300 mb-2">Notebook (optional)</label>
|
||||
<select
|
||||
value={notebookId}
|
||||
onChange={(e) => setNotebookId(e.target.value)}
|
||||
className="w-full px-4 py-3 bg-slate-800/50 border border-slate-700 rounded-lg text-white focus:outline-none focus:border-amber-500/50"
|
||||
>
|
||||
<option value="">No notebook (standalone)</option>
|
||||
{notebooks.map((nb) => (
|
||||
<option key={nb.id} value={nb.id}>{nb.title}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-300 mb-2">Tags (comma-separated)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={tags}
|
||||
onChange={(e) => setTags(e.target.value)}
|
||||
placeholder="research, web3, draft"
|
||||
className="w-full px-4 py-3 bg-slate-800/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:border-amber-500/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Submit */}
|
||||
<div className="flex gap-3 pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!title.trim() || saving}
|
||||
className="px-6 py-3 bg-amber-500 hover:bg-amber-400 disabled:bg-slate-700 disabled:text-slate-400 text-black font-semibold rounded-lg transition-colors"
|
||||
>
|
||||
{saving ? 'Creating...' : 'Create Note'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
className="px-6 py-3 border border-slate-700 hover:border-slate-600 text-white rounded-lg transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { OpenNotebookEmbed } from '@/components/OpenNotebookEmbed';
|
||||
import { UserMenu } from '@/components/UserMenu';
|
||||
import { SearchBar } from '@/components/SearchBar';
|
||||
import { useSpaceContext } from '@/lib/space-context';
|
||||
|
||||
export default function OpenNotebookPage() {
|
||||
const space = useSpaceContext();
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col bg-[#0a0a0a]">
|
||||
<nav className="border-b border-slate-800 px-4 md:px-6 py-4 flex-shrink-0">
|
||||
<div className="max-w-6xl mx-auto flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/" className="flex-shrink-0">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-amber-400 to-orange-500 flex items-center justify-center text-sm font-bold text-black">
|
||||
rN
|
||||
</div>
|
||||
</Link>
|
||||
<span className="text-slate-600 hidden sm:inline">/</span>
|
||||
{space && (
|
||||
<>
|
||||
<span className="text-amber-400 font-medium">{space}</span>
|
||||
<span className="text-slate-600 hidden sm:inline">/</span>
|
||||
</>
|
||||
)}
|
||||
<span className="text-white font-semibold">Open Notebook</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 md:gap-4">
|
||||
<div className="hidden md:block w-64">
|
||||
<SearchBar />
|
||||
</div>
|
||||
<Link
|
||||
href="/notebooks"
|
||||
className="text-sm text-slate-400 hover:text-white transition-colors hidden sm:inline"
|
||||
>
|
||||
Notebooks
|
||||
</Link>
|
||||
<Link
|
||||
href="/demo"
|
||||
className="text-sm text-slate-400 hover:text-white transition-colors hidden sm:inline"
|
||||
>
|
||||
Demo
|
||||
</Link>
|
||||
<UserMenu />
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main className="flex-1 min-h-0">
|
||||
<OpenNotebookEmbed className="h-full rounded-none border-0" />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
337
src/app/page.tsx
|
|
@ -1,281 +1,94 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { NotebookCard } from '@/components/NotebookCard';
|
||||
import { EcosystemFooter } from '@/components/EcosystemFooter';
|
||||
import { TranscriptionDemo } from '@/components/TranscriptionDemo';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { Header } from '@/components/Header';
|
||||
|
||||
interface NotebookData {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
coverColor: string;
|
||||
updatedAt: string;
|
||||
_count: { notes: number };
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
const [notebooks, setNotebooks] = useState<NotebookData[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/notebooks')
|
||||
.then((res) => res.json())
|
||||
.then(setNotebooks)
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
export default function LandingPage() {
|
||||
const router = useRouter();
|
||||
const [trying, setTrying] = useState(false);
|
||||
|
||||
const handleTry = async () => {
|
||||
setTrying(true);
|
||||
try {
|
||||
const res = await fetch('/api/try', { method: 'POST' });
|
||||
if (res.ok) {
|
||||
const { url } = await res.json();
|
||||
router.push(url);
|
||||
}
|
||||
} catch {}
|
||||
setTrying(false);
|
||||
};
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0a0a0a]">
|
||||
{/* Hero */}
|
||||
<section className="py-12 md:py-20 px-4 md:px-6">
|
||||
<div className="max-w-4xl mx-auto text-center">
|
||||
<h1 className="text-3xl md:text-5xl font-bold mb-4">
|
||||
<span className="bg-gradient-to-r from-amber-400 to-orange-500 bg-clip-text text-transparent">
|
||||
Capture Everything, Find Anything,
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<Header />
|
||||
|
||||
<main className="flex-1">
|
||||
{/* Hero */}
|
||||
<section className="max-w-4xl mx-auto px-6 pt-20 pb-16 text-center">
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1 mb-6 rounded-full bg-primary/10 border border-primary/20 text-primary text-sm">
|
||||
<span>📝</span> Real-time collaborative notes
|
||||
</div>
|
||||
|
||||
<h1 className="text-4xl sm:text-5xl font-bold leading-tight mb-4">
|
||||
Write together.{' '}
|
||||
<span className="bg-gradient-to-r from-primary to-cyan-300 bg-clip-text text-transparent">
|
||||
Think together.
|
||||
</span>
|
||||
<br />
|
||||
<span className="text-white">and <em>Share</em> your Insights</span>
|
||||
</h1>
|
||||
<p className="text-base md:text-lg text-slate-400 mb-6 md:mb-8 max-w-2xl mx-auto">
|
||||
Notes, clips, voice recordings, and live transcription — all in one place.
|
||||
Speak and watch your words appear in real time, or drop in audio and video files to transcribe offline.
|
||||
|
||||
<p className="text-lg text-slate-400 max-w-2xl mx-auto mb-8">
|
||||
Google Docs-style collaborative editing with suggestions, comments,
|
||||
approvals, and emoji reactions — all self-hosted and community-owned.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-3 sm:gap-4">
|
||||
<Link
|
||||
href="/opennotebook"
|
||||
className="w-full sm:w-auto flex items-center justify-center gap-2 px-6 py-3 bg-amber-500 hover:bg-amber-400 text-black font-semibold rounded-lg transition-colors"
|
||||
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<button
|
||||
onClick={handleTry}
|
||||
disabled={trying}
|
||||
className="px-6 py-2.5 bg-primary text-primary-foreground rounded-lg font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
Open Notebook
|
||||
</Link>
|
||||
{trying ? 'Creating note...' : 'Try it Now'}
|
||||
</button>
|
||||
<Link
|
||||
href="/notes/new?type=CLIP"
|
||||
className="w-full sm:w-auto flex items-center justify-center gap-2 px-6 py-3 border border-green-500/30 hover:border-green-400/50 text-green-400 hover:text-green-300 font-semibold rounded-lg transition-colors"
|
||||
href="/dashboard"
|
||||
className="px-6 py-2.5 border border-slate-700 rounded-lg text-slate-300 hover:bg-white/[0.04] transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 11V7a4 4 0 118 0m-4 8v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2z" />
|
||||
</svg>
|
||||
Unlock Article
|
||||
Dashboard
|
||||
</Link>
|
||||
<Link
|
||||
href="/notes/new?type=AUDIO"
|
||||
className="w-full sm:w-auto flex items-center justify-center gap-2 px-6 py-3 border border-violet-500/30 hover:border-violet-400/50 text-violet-400 hover:text-violet-300 font-semibold rounded-lg transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z" />
|
||||
</svg>
|
||||
Transcribe
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Try it — live transcription */}
|
||||
<section className="py-12 md:py-16 px-4 md:px-6 border-t border-slate-800">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<h2 className="text-xl md:text-2xl font-bold text-white text-center mb-3">Try Live Transcription</h2>
|
||||
<p className="text-sm text-slate-400 text-center mb-8 max-w-lg mx-auto">
|
||||
Hit the mic and start talking. Your speech is transcribed live in the browser — no account needed.
|
||||
</p>
|
||||
<TranscriptionDemo />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* How it works */}
|
||||
<section className="py-12 md:py-16 px-4 md:px-6 border-t border-slate-800">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<h2 className="text-xl md:text-2xl font-bold text-white text-center mb-8 md:mb-12">How It Works</h2>
|
||||
<div className="grid sm:grid-cols-2 md:grid-cols-4 gap-6 md:gap-8">
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 rounded-xl bg-amber-500/10 border border-amber-500/20 flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="w-6 h-6 text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-white mb-2">Live Transcribe</h3>
|
||||
<p className="text-sm text-slate-400">Speak and watch your words appear in real time. WebSocket streaming with live timestamps.</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 rounded-xl bg-amber-500/10 border border-amber-500/20 flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="w-6 h-6 text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 4V2m0 2a2 2 0 00-2 2v1a2 2 0 002 2h0a2 2 0 002-2V6a2 2 0 00-2-2zm0 8v2m0-2a2 2 0 01-2-2V9a2 2 0 012-2h0a2 2 0 012 2v1a2 2 0 01-2 2zm8-8V2m0 2a2 2 0 00-2 2v1a2 2 0 002 2h0a2 2 0 002-2V6a2 2 0 00-2-2zm0 8v2m0-2a2 2 0 01-2-2V9a2 2 0 012-2h0a2 2 0 012 2v1a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-white mb-2">Audio & Video</h3>
|
||||
<p className="text-sm text-slate-400">Drop in audio or video files and get a full transcript. Powered by NVIDIA Parakeet — runs entirely in your browser.</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 rounded-xl bg-amber-500/10 border border-amber-500/20 flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="w-6 h-6 text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-white mb-2">Notebooks & Tags</h3>
|
||||
<p className="text-sm text-slate-400">Organize transcripts into notebooks alongside notes, clips, code, and files. Tag freely, search everything.</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 rounded-xl bg-amber-500/10 border border-amber-500/20 flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="w-6 h-6 text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-white mb-2">Private & Offline</h3>
|
||||
<p className="text-sm text-slate-400">Parakeet.js runs entirely in the browser. Your audio never leaves your device — full offline support.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Memory Cards */}
|
||||
<section className="py-12 md:py-16 px-4 md:px-6 border-t border-slate-800">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<h2 className="text-xl md:text-2xl font-bold text-white text-center mb-3">
|
||||
Memory Cards
|
||||
</h2>
|
||||
<p className="text-sm text-slate-400 text-center mb-8 md:mb-12 max-w-2xl mx-auto">
|
||||
Every note is a Memory Card — a typed, structured unit of knowledge with hierarchy,
|
||||
properties, and attachments. Designed for round-trip interoperability with Logseq.
|
||||
</p>
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4 md:gap-6">
|
||||
{/* Card Types */}
|
||||
<div className="p-5 rounded-xl border border-slate-700/50 bg-slate-800/30">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-amber-500/10 border border-amber-500/20 flex items-center justify-center">
|
||||
<svg className="w-4 h-4 text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-base font-semibold text-white">7 Card Types</h3>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5 mb-3">
|
||||
<span className="text-[10px] font-bold uppercase px-2 py-0.5 rounded border bg-amber-500/20 text-amber-400 border-amber-500/30">note</span>
|
||||
<span className="text-[10px] font-bold uppercase px-2 py-0.5 rounded border bg-blue-500/20 text-blue-400 border-blue-500/30">link</span>
|
||||
<span className="text-[10px] font-bold uppercase px-2 py-0.5 rounded border bg-green-500/20 text-green-400 border-green-500/30">task</span>
|
||||
<span className="text-[10px] font-bold uppercase px-2 py-0.5 rounded border bg-yellow-500/20 text-yellow-400 border-yellow-500/30">idea</span>
|
||||
<span className="text-[10px] font-bold uppercase px-2 py-0.5 rounded border bg-purple-500/20 text-purple-400 border-purple-500/30">person</span>
|
||||
<span className="text-[10px] font-bold uppercase px-2 py-0.5 rounded border bg-pink-500/20 text-pink-400 border-pink-500/30">reference</span>
|
||||
<span className="text-[10px] font-bold uppercase px-2 py-0.5 rounded border bg-slate-500/20 text-slate-400 border-slate-500/30">file</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500">Each card type has distinct styling and behavior. Typed notes surface in filtered views and canvas visualizations.</p>
|
||||
</div>
|
||||
|
||||
{/* Hierarchy & Properties */}
|
||||
<div className="p-5 rounded-xl border border-slate-700/50 bg-slate-800/30">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-amber-500/10 border border-amber-500/20 flex items-center justify-center">
|
||||
<svg className="w-4 h-4 text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-base font-semibold text-white">Hierarchy & Properties</h3>
|
||||
</div>
|
||||
<p className="text-sm text-slate-400 mb-3">
|
||||
Nest cards under parents to build knowledge trees. Add structured <span className="font-mono text-amber-400/80">key:: value</span> properties — compatible with Logseq's property syntax.
|
||||
</p>
|
||||
<div className="space-y-1 text-xs font-mono text-slate-500">
|
||||
<div><span className="text-slate-400">type::</span> idea</div>
|
||||
<div><span className="text-slate-400">status::</span> doing</div>
|
||||
<div><span className="text-slate-400">tags::</span> #research, #web3</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Logseq Interop */}
|
||||
<div className="p-5 rounded-xl border border-slate-700/50 bg-slate-800/30">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-amber-500/10 border border-amber-500/20 flex items-center justify-center">
|
||||
<svg className="w-4 h-4 text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-base font-semibold text-white">Logseq Import & Export</h3>
|
||||
</div>
|
||||
<p className="text-sm text-slate-400 mb-3">
|
||||
Export your notebooks as Logseq-compatible ZIP archives. Import a Logseq graph and keep your pages, properties, tags, and hierarchy intact.
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">Round-trip fidelity: card types, tags, attachments, and parent-child structure all survive the journey.</p>
|
||||
</div>
|
||||
|
||||
{/* Dual Format */}
|
||||
<div className="p-5 rounded-xl border border-slate-700/50 bg-slate-800/30">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-amber-500/10 border border-amber-500/20 flex items-center justify-center">
|
||||
<svg className="w-4 h-4 text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-base font-semibold text-white">Dual Format Storage</h3>
|
||||
</div>
|
||||
<p className="text-sm text-slate-400">
|
||||
Every card stores rich TipTap JSON for editing and portable Markdown for search, export, and interoperability. Write once, read anywhere.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Attachments */}
|
||||
<div className="p-5 rounded-xl border border-slate-700/50 bg-slate-800/30">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-amber-500/10 border border-amber-500/20 flex items-center justify-center">
|
||||
<svg className="w-4 h-4 text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-base font-semibold text-white">Structured Attachments</h3>
|
||||
</div>
|
||||
<p className="text-sm text-slate-400">
|
||||
Attach images, PDFs, audio, and files to any card with roles (primary, preview, supporting) and captions. Thumbnails render inline.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* FUN Model */}
|
||||
<div className="p-5 rounded-xl border border-slate-700/50 bg-slate-800/30">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-amber-500/10 border border-amber-500/20 flex items-center justify-center">
|
||||
<svg className="w-4 h-4 text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-base font-semibold text-white">FUN, Not CRUD</h3>
|
||||
</div>
|
||||
<p className="text-sm text-slate-400">
|
||||
<span className="text-amber-400 font-medium">F</span>orget, <span className="text-amber-400 font-medium">U</span>pdate, <span className="text-amber-400 font-medium">N</span>ew — nothing is permanently destroyed. Forgotten cards are archived and can be remembered at any time.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Recent notebooks */}
|
||||
{!loading && notebooks.length > 0 && (
|
||||
<section className="py-12 md:py-16 px-4 md:px-6 border-t border-slate-800">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-6 md:mb-8">
|
||||
<h2 className="text-xl md:text-2xl font-bold text-white">Recent Notebooks</h2>
|
||||
<Link href="/notebooks" className="text-sm text-amber-400 hover:text-amber-300">
|
||||
View all
|
||||
</Link>
|
||||
</div>
|
||||
<div className="grid sm:grid-cols-2 md:grid-cols-3 gap-3 md:gap-4">
|
||||
{notebooks.slice(0, 6).map((nb) => (
|
||||
<NotebookCard
|
||||
key={nb.id}
|
||||
id={nb.id}
|
||||
title={nb.title}
|
||||
description={nb.description}
|
||||
coverColor={nb.coverColor}
|
||||
noteCount={nb._count.notes}
|
||||
updatedAt={nb.updatedAt}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<EcosystemFooter current="rNotes" />
|
||||
{/* Features */}
|
||||
<section className="max-w-5xl mx-auto px-6 pb-20">
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{[
|
||||
{ icon: '✍️', title: 'Real-time Co-editing', desc: 'See cursors, selections, and changes from collaborators instantly via CRDT sync' },
|
||||
{ icon: '💡', title: 'Suggestions Mode', desc: 'Propose edits without changing the document — authors review, accept, or reject' },
|
||||
{ icon: '💬', title: 'Inline Comments', desc: 'Select text and start threaded discussions right where they matter' },
|
||||
{ icon: '🎉', title: 'Emoji Reactions', desc: 'React to comments with emojis — quick feedback without cluttering the thread' },
|
||||
{ icon: '✅', title: 'Approvals', desc: 'Accept or reject suggestions individually or in bulk with one click' },
|
||||
{ icon: '📚', title: 'Notebooks', desc: 'Organize notes into notebooks within your space — keep everything structured' },
|
||||
{ icon: '🔐', title: 'Passkey Auth', desc: 'Sign in with WebAuthn passkeys via EncryptID — no passwords, no tracking' },
|
||||
{ icon: '🔌', title: 'Offline-First', desc: 'Keep writing when disconnected — changes sync automatically when back online' },
|
||||
{ icon: '📦', title: 'rStack Ecosystem', desc: 'Part of the self-hosted community app suite — integrates with all rApps' },
|
||||
].map((f) => (
|
||||
<div key={f.title} className="p-5 rounded-xl bg-card border border-slate-800 hover:border-slate-700 transition-colors">
|
||||
<div className="text-2xl mb-2">{f.icon}</div>
|
||||
<h3 className="font-semibold mb-1">{f.title}</h3>
|
||||
<p className="text-sm text-slate-400">{f.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer className="border-t border-slate-800 py-6 text-center text-xs text-slate-500">
|
||||
<a href="https://rstack.online" className="hover:text-primary transition-colors">
|
||||
rstack.online — self-hosted, community-run
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,109 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { Header } from '@/components/Header';
|
||||
import { CollaborativeEditor } from '@/components/editor/CollaborativeEditor';
|
||||
|
||||
const SYNC_URL = process.env.NEXT_PUBLIC_SYNC_URL || 'ws://localhost:4444';
|
||||
|
||||
interface NoteData {
|
||||
id: string;
|
||||
title: string;
|
||||
yjsDocId: string;
|
||||
notebookId: string;
|
||||
notebookTitle: string;
|
||||
}
|
||||
|
||||
interface UserInfo {
|
||||
id: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export default function NotePage() {
|
||||
const params = useParams<{ slug: string; noteId: string }>();
|
||||
const [note, setNote] = useState<NoteData | null>(null);
|
||||
const [user, setUser] = useState<UserInfo | null>(null);
|
||||
const [title, setTitle] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Load note data
|
||||
fetch(`/api/notebooks/notes/${params.noteId}`)
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
setNote(data);
|
||||
setTitle(data.title || 'Untitled');
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
|
||||
// Get current user
|
||||
fetch('/api/me')
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (data.authenticated && data.user) {
|
||||
setUser({ id: data.user.did || data.user.username, username: data.user.username });
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [params.noteId]);
|
||||
|
||||
// Auto-save title changes
|
||||
useEffect(() => {
|
||||
if (!note || !title || title === note.title) return;
|
||||
const timeout = setTimeout(() => {
|
||||
fetch(`/api/notebooks/notes/${params.noteId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title }),
|
||||
}).catch(() => {});
|
||||
}, 1000);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [title, note, params.noteId]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<Header />
|
||||
<div className="flex-1 flex items-center justify-center text-slate-400">
|
||||
Loading note...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!note) {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<Header />
|
||||
<div className="flex-1 flex items-center justify-center text-slate-400">
|
||||
Note not found
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Guest mode for unauthenticated users
|
||||
const userId = user?.id || `guest-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const userName = user?.username || 'Guest';
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<Header
|
||||
breadcrumbs={[
|
||||
{ label: note.notebookTitle, href: `/s/${params.slug}/notebook/${note.notebookId}` },
|
||||
{ label: title },
|
||||
]}
|
||||
/>
|
||||
<CollaborativeEditor
|
||||
noteId={note.id}
|
||||
yjsDocId={note.yjsDocId}
|
||||
userId={userId}
|
||||
userName={userName}
|
||||
syncUrl={SYNC_URL}
|
||||
onTitleChange={setTitle}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||