Compare commits

...

No commits in common. "main" and "master" have entirely different histories.
main ... master

156 changed files with 7323 additions and 18218 deletions

View File

@ -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

View File

@ -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

View File

@ -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

35
.gitignore vendored
View File

@ -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

View File

@ -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"]

View File

@ -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

View File

@ -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"

View File

@ -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 -->

View File

@ -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 -->

View File

@ -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 -->

View File

@ -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 -->

View File

@ -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 -->

View File

@ -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 -->

View File

@ -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 -->

View File

@ -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 -->

View File

@ -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 -->

View File

@ -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 -->

View File

@ -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 -->

View File

@ -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 -->

View File

@ -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 -->

View File

@ -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 -->

View File

@ -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 -->

View File

@ -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 -->

View File

@ -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 -->

View File

@ -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);
}
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 837 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 185 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 349 B

View File

@ -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"
}
}
}

View File

@ -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>

View File

@ -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);

View File

@ -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,
};

View File

@ -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>

View File

@ -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);

View File

@ -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">&times;</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 &middot; <kbd>Esc</kbd> to close &middot; Offline ready
</div>
<script src="parakeet-offline.js" type="module"></script>
<script src="voice.js"></script>
</body>
</html>

View File

@ -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);

View File

@ -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

View File

@ -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 "$@"

6
next-env.d.ts vendored Normal file
View File

@ -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.

View File

@ -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;

7
next.config.ts Normal file
View File

@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
};
export default nextConfig;

6528
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -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"
}
}

View File

@ -1,7 +1,7 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
"@tailwindcss/postcss": {},
},
};

View File

@ -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");

View File

@ -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;

View File

@ -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?
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 {
model Space {
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())
@@unique([userId, notebookId])
@@index([notebookId])
}
// ─── Notes ──────────────────────────────────────────────────────────
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)
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
// ─── 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])
members SpaceMember[]
notebooks Notebook[]
}
enum NoteType {
NOTE
CLIP
BOOKMARK
CODE
IMAGE
FILE
AUDIO
}
// ─── Files & Attachments ────────────────────────────────────────────
model File {
model SpaceMember {
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())
userId String
spaceId String
role SpaceRole @default(MEMBER)
joinedAt DateTime @default(now())
// IPFS storage (optional — populated when IPFS_ENABLED=true)
ipfsCid String? // IPFS content identifier
ipfsEncKey String? // base64 AES-256-GCM key for this file
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade)
attachments CardAttachment[]
@@unique([userId, spaceId])
}
model CardAttachment {
// ─── Notebooks & Notes ───────────────────────────────────
model Notebook {
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)
spaceId String
title String
description String @default("")
icon String @default("📓")
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([noteId, fileId])
@@index([noteId])
@@index([fileId])
}
space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade)
creator User @relation(fields: [createdBy], references: [id])
notes Note[]
// ─── 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 {
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
note Note @relation(fields: [noteId], references: [id], onDelete: Cascade)
tagId String
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
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 {
model Reaction {
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)
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])
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

View File

@ -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"
}
]
}
]
}

View File

@ -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);

View File

@ -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))
);
});

View File

@ -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());

View File

@ -1,5 +0,0 @@
import { redirect } from 'next/navigation';
export default function AIPage() {
redirect('/opennotebook');
}

View File

@ -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 }
);
}
}

View File

@ -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 });
}
}

View File

@ -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 });
}
}

View File

@ -1,5 +0,0 @@
import { NextResponse } from 'next/server';
export async function GET() {
return NextResponse.json({ status: 'ok', service: 'rnotes-online' });
}

View File

@ -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';
}

View File

@ -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 };
}

View File

@ -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 });
}

View File

@ -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 });
}

View File

@ -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 });
}
}

View File

@ -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) {
const claims = await verifyEncryptIDToken(token);
if (!claims) {
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,
username: claims.username || null,
did: claims.did,
},
});
}
return NextResponse.json({ authenticated: false });
} catch {
return NextResponse.json({ authenticated: false });
}
}

View File

@ -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 });
}
}

View File

@ -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 });
}
}

View File

@ -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 });
}
}

View File

@ -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,
});
}

View File

@ -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(),
})),
});
}

View File

@ -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 });
}

View File

@ -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)
// Return notebooks the user has access to
const notebooks = await prisma.notebook.findMany({
where,
include: {
_count: { select: { notes: true } },
collaborators: {
include: { user: { select: { id: true, username: true } } },
space: { select: { slug: true } },
},
},
orderBy: [{ sortOrder: 'asc' }, { updatedAt: 'desc' }],
orderBy: { 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 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;
if (!title?.trim()) {
return NextResponse.json({ error: 'Title is required' }, { status: 400 });
export async function POST(req: NextRequest) {
const token = extractToken(req.headers, req.cookies);
if (!token) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const baseSlug = generateSlug(title);
const slug = baseSlug || nanoid(8);
const claims = await verifyEncryptIDToken(token);
if (!claims) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Ensure unique slug
const existing = await prisma.notebook.findUnique({ where: { slug } });
const finalSlug = existing ? `${slug}-${nanoid(4)}` : slug;
// Ensure user exists
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,
},
});
}
// 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: title.trim(),
slug: finalSlug,
description: description?.trim() || null,
coverColor: coverColor || '#f59e0b',
workspaceSlug: workspaceSlug || '',
collaborators: {
create: { userId: user.id, role: 'OWNER' },
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 } },
},
});
return NextResponse.json(notebook, { status: 201 });
} catch (error) {
console.error('Create notebook error:', error);
return NextResponse.json({ error: 'Failed to create notebook' }, { status: 500 });
}
// 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,
});
}

View File

@ -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 });
}
}

View File

@ -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 });
}
}

View File

@ -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 });
}
}

View File

@ -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 });
}
}

View File

@ -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 });
}
}

View File

@ -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);
}
} catch {
// rSpace unreachable — return empty list
// rSpace unavailable
}
return NextResponse.json({ spaces: [] });
}
}

View File

@ -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 });
}
}

55
src/app/api/try/route.ts Normal file
View File

@ -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}`,
});
}

View File

@ -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 });
}
}

View File

@ -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 });
}
}

View File

@ -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 });
}
}

View File

@ -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 });
}
}

View File

@ -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 &mdash; 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>
);
}

125
src/app/dashboard/page.tsx Normal file
View File

@ -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>
);
}

View File

@ -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">&#8226;</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>
)
}

View File

@ -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 />
}

View File

@ -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;
}

View File

@ -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

View File

@ -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" />
<html lang="en" className="dark">
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
{children}
<PWAInstall />
</AuthProvider>
<InfoPopup appName="rNotes" appIcon="📝" landingHtml={LANDING_HTML} />
</body>
</html>
)
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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()} &middot; 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')}
&ndash;
{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>
);
}

View File

@ -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} &middot; {(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>
);
}

View File

@ -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>
);
}

View File

@ -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 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);
}
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));
}, []);
} catch {}
setTrying(false);
};
return (
<div className="min-h-screen bg-[#0a0a0a]">
<div className="min-h-screen flex flex-col">
<Header />
<main className="flex-1">
{/* 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,
<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
</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
Dashboard
</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 />
{/* 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>
</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&apos;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>
)}
</main>
<EcosystemFooter current="rNotes" />
<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>
);
}

View File

@ -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>
);
}

Some files were not shown because too many files have changed in this diff Show More