diff --git a/.env.template b/.env.template index ea20fd71..ec02df0e 100644 --- a/.env.template +++ b/.env.template @@ -1,7 +1,7 @@ NODE_ENV=development NEXT_PUBLIC_API_URL=https://scriptio.app -NEXT_PUBLIC_COLLAB_WEBSOCKET_URL=ws://127.0.0.1:8787 +NEXT_PUBLIC_CLOUD_URL=http://127.0.0.1:8787 # Secrets AUTH_SECRET= @@ -18,7 +18,6 @@ DB_NAME=scriptio # Basic Auth (htpasswd format, escape $ as $$ in this file) PROD_AUTH_USERS= -STAGING_AUTH_USERS= MONITORING_AUTH_USERS= # Monitoring diff --git a/.github/actions/apply-version/action.yml b/.github/actions/apply-version/action.yml index 5a08be3f..dfad6eda 100644 --- a/.github/actions/apply-version/action.yml +++ b/.github/actions/apply-version/action.yml @@ -24,19 +24,26 @@ runs: SEMVER=$(echo "$INPUT_VERSION" | cut -d. -f1-3) MAJOR=$(echo "$INPUT_VERSION" | cut -d. -f1) MINOR=$(echo "$INPUT_VERSION" | cut -d. -f2) + PATCH=$(echo "$INPUT_VERSION" | cut -d. -f3) + REVISION=$(echo "$INPUT_VERSION" | cut -d. -f4) if [[ "$INPUT_STAGING" == "true" ]]; then PRODUCT_NAME="Scriptio (Staging)" WINDOW_TITLE="Scriptio (Staging)" APPSTORE_IDENTIFIER="app.scriptio.staging" WINDOWS_IDENTIFIER="ArkoLogic.ScriptioStaging" - MSIX_VERSION="${MAJOR}.${MINOR}.${GITHUB_RUN_NUMBER:-1}.0" + # MS Store reserves the 4th version field (must stay 0). Staging's + # build counter (REVISION) sits in that slot, so fold PATCH+REVISION + # into the 3rd field to keep MSIX versions strictly increasing + # across staging pushes and release bumps. + MSIX_VERSION="${MAJOR}.${MINOR}.$(( PATCH * 10000 + REVISION )).0" else PRODUCT_NAME="Scriptio" WINDOW_TITLE="Scriptio" APPSTORE_IDENTIFIER="app.scriptio" WINDOWS_IDENTIFIER="ArkoLogic.Scriptio" - MSIX_VERSION="$INPUT_VERSION" + # Release builds carry REVISION=0, so the app version is Store-legal as-is. + MSIX_VERSION="${MAJOR}.${MINOR}.${PATCH}.0" fi jq --arg v "$SEMVER" --arg t "$WINDOW_TITLE" --arg n "$PRODUCT_NAME" \ diff --git a/.github/actions/compute-version/action.yml b/.github/actions/compute-version/action.yml index 4bd4fd57..61438b3f 100644 --- a/.github/actions/compute-version/action.yml +++ b/.github/actions/compute-version/action.yml @@ -14,6 +14,9 @@ outputs: git_tag: description: Tag to create on release (e.g. 2.0.1). Empty on other branches. value: ${{ steps.compute.outputs.git_tag }} + commit_sha: + description: Short git commit SHA. + value: ${{ steps.compute.outputs.commit_sha }} runs: using: composite @@ -69,3 +72,4 @@ runs: echo "short_version=$SHORT_VERSION" >> $GITHUB_OUTPUT echo "revision=$REVISION" >> $GITHUB_OUTPUT echo "git_tag=$GIT_TAG" >> $GITHUB_OUTPUT + echo "commit_sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT diff --git a/.github/actions/setup-desktop-build/action.yml b/.github/actions/setup-desktop-build/action.yml index d11ca429..e06dd6d3 100644 --- a/.github/actions/setup-desktop-build/action.yml +++ b/.github/actions/setup-desktop-build/action.yml @@ -1,5 +1,5 @@ name: Setup Desktop Build Toolchain -description: Installs Node.js, Rust with platform-appropriate targets, MSIX bundler on Windows, and runs npm install. +description: Installs Node.js, Rust with platform-appropriate targets, MSIX bundler on Windows, and runs npm ci. inputs: platform: @@ -39,4 +39,4 @@ runs: - name: Install dependencies shell: bash - run: npm install + run: npm ci diff --git a/.github/workflows/checks.yaml b/.github/workflows/checks.yaml index 7bb72205..4144b77e 100644 --- a/.github/workflows/checks.yaml +++ b/.github/workflows/checks.yaml @@ -17,14 +17,14 @@ jobs: cache: "npm" - name: Install dependencies - run: npm install + run: npm ci - name: Lint run: npm run lint continue-on-error: true - name: Typecheck - run: npx tsc --noEmit + run: npx prisma generate && npx tsc --noEmit build-macos: runs-on: macos-latest @@ -59,3 +59,104 @@ jobs: - name: Build Docker image run: docker compose build app-prod + + benchmark: + runs-on: ubuntu-latest + strategy: + matrix: + browser: [chromium, webkit] + steps: + - uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: "25.2.1" + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Get Playwright version + id: pw-version + run: echo "version=$(node -e "console.log(require('./node_modules/playwright-core/package.json').version)")" >> $GITHUB_OUTPUT + + - name: Cache Playwright browser + uses: actions/cache@v4 + id: pw-cache + with: + path: ~/.cache/ms-playwright + key: playwright-${{ matrix.browser }}-${{ steps.pw-version.outputs.version }} + + - name: Install Playwright browser and system deps + if: steps.pw-cache.outputs.cache-hit != 'true' + run: npx playwright install --with-deps ${{ matrix.browser }} + + - name: Install Playwright system deps only + if: steps.pw-cache.outputs.cache-hit == 'true' + run: npx playwright install-deps ${{ matrix.browser }} + + - name: Run benchmarks + run: npx vitest bench --project ${{ matrix.browser }} + + - name: Upload benchmark results + uses: actions/upload-artifact@v4 + with: + name: bench-results-${{ matrix.browser }} + path: bench-results.json + retention-days: 90 + overwrite: true + + benchmark-compare: + runs-on: ubuntu-latest + needs: benchmark + steps: + - uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: "25.2.1" + cache: "npm" + + - name: Download chromium results + uses: actions/download-artifact@v4 + with: + name: bench-results-chromium + path: bench-current-chromium + + - name: Download webkit results + uses: actions/download-artifact@v4 + with: + name: bench-results-webkit + path: bench-current-webkit + + - name: Merge current results + run: node scripts/merge-bench.mjs bench-current-chromium/bench-results.json bench-current-webkit/bench-results.json > bench-results.json + + - name: Download previous benchmark baseline + uses: dawidd6/action-download-artifact@v9 + continue-on-error: true + with: + name: bench-results + path: bench-baseline + workflow: checks.yaml + branch: ${{ github.ref_name }} + if_no_artifact_found: warn + + - name: Compare and report + run: | + if [ -f bench-baseline/bench-results.json ]; then + node scripts/compare-bench.mjs bench-baseline/bench-results.json bench-results.json >> $GITHUB_STEP_SUMMARY + else + echo "

Benchmark Results

No baseline found — results uploaded as the new baseline.

" >> $GITHUB_STEP_SUMMARY + fi + continue-on-error: true + + - name: Upload merged benchmark results as new baseline + uses: actions/upload-artifact@v4 + with: + name: bench-results + path: bench-results.json + retention-days: 90 + overwrite: true diff --git a/.github/workflows/deploy-release.yaml b/.github/workflows/deploy-release.yaml index bdc92cf6..d0be15ad 100644 --- a/.github/workflows/deploy-release.yaml +++ b/.github/workflows/deploy-release.yaml @@ -16,6 +16,7 @@ jobs: short_version: ${{ steps.version.outputs.short_version }} revision: ${{ steps.version.outputs.revision }} git_tag: ${{ steps.version.outputs.git_tag }} + commit_sha: ${{ steps.version.outputs.commit_sha }} steps: - uses: actions/checkout@v6 with: @@ -27,7 +28,7 @@ jobs: publish-macos: runs-on: macos-latest - needs: prepare + needs: [prepare, deploy-web] env: APP_PATH: src-tauri/target/universal-apple-darwin/release/bundle/macos/Scriptio.app PKG_PATH: Scriptio.pkg @@ -41,7 +42,7 @@ jobs: p12-password: ${{ secrets.APPLE_CERT_PASSWORD }} - name: Download provisioning profile - uses: apple-actions/download-provisioning-profiles@v5 + uses: apple-actions/download-provisioning-profiles@v6 with: bundle-id: "app.scriptio" profile-type: "MAC_APP_STORE" @@ -52,7 +53,6 @@ jobs: - name: Copy provisioning profile to known path run: | PROFILE=$(ls "$HOME/Library/MobileDevice/Provisioning Profiles/"*.provisionprofile \ - "$HOME/Library/MobileDevice/Provisioning Profiles/"*.mobileprovision \ 2>/dev/null | head -n 1) cp "$PROFILE" src-tauri/embedded.provisionprofile @@ -71,7 +71,9 @@ jobs: env: APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} NEXT_PUBLIC_API_URL: https://scriptio.app + NEXT_PUBLIC_CLOUD_URL: https://cloud.scriptio.app NEXT_PUBLIC_APP_VERSION: ${{ needs.prepare.outputs.version }} + NEXT_PUBLIC_COMMIT_SHA: ${{ needs.prepare.outputs.commit_sha }} - name: Build signed .pkg installer run: | @@ -81,17 +83,18 @@ jobs: "$PKG_PATH" - name: Upload to App Store Connect - uses: apple-actions/upload-testflight-build@v3 + uses: apple-actions/upload-testflight-build@v5 with: app-path: ${{ env.PKG_PATH }} issuer-id: ${{ secrets.APPSTORE_ISSUER_ID }} api-key-id: ${{ secrets.APPSTORE_KEY_ID }} api-private-key: ${{ secrets.APPSTORE_API_KEY_P8 }} + backend: "altool" app-type: "macos" publish-windows: runs-on: windows-latest - needs: prepare + needs: [prepare, deploy-web] env: APP_PATH: src-tauri/target/msix/Scriptio.msixbundle steps: @@ -111,7 +114,9 @@ jobs: run: npm run build:windows env: NEXT_PUBLIC_API_URL: https://scriptio.app + NEXT_PUBLIC_CLOUD_URL: https://cloud.scriptio.app NEXT_PUBLIC_APP_VERSION: ${{ needs.prepare.outputs.version }} + NEXT_PUBLIC_COMMIT_SHA: ${{ needs.prepare.outputs.commit_sha }} - name: Rename output to fixed name run: | @@ -130,6 +135,24 @@ jobs: - name: Publish to Microsoft Store run: msstore publish "$env:APP_PATH" -id "9P4M1XPHJKS1" + deploy-cloud: + runs-on: ubuntu-latest + needs: prepare + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: "25.2.1" + cache: "npm" + + - run: npm ci + + - name: Deploy Cloudflare Worker (production) + run: npm run cloud:deploy:prod + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + deploy-web: runs-on: ubuntu-latest needs: prepare @@ -161,6 +184,7 @@ jobs: ${{ env.IMAGE_NAME }}:${{ needs.prepare.outputs.version }} build-args: | NEXT_PUBLIC_API_URL=https://scriptio.app + NEXT_PUBLIC_CLOUD_URL=https://cloud.scriptio.app NEXT_PUBLIC_COMMIT_SHA=${{ env.COMMIT_SHA }} NEXT_PUBLIC_APP_VERSION=${{ needs.prepare.outputs.version }} @@ -190,7 +214,7 @@ jobs: tag-release: runs-on: ubuntu-latest - needs: [prepare, publish-macos, publish-windows, deploy-web] + needs: [prepare, deploy-web] steps: - uses: actions/checkout@v6 with: diff --git a/.github/workflows/deploy-staging.yaml b/.github/workflows/deploy-staging.yaml index a3d31760..c9a0c01c 100644 --- a/.github/workflows/deploy-staging.yaml +++ b/.github/workflows/deploy-staging.yaml @@ -15,6 +15,7 @@ jobs: version: ${{ steps.version.outputs.version }} short_version: ${{ steps.version.outputs.short_version }} revision: ${{ steps.version.outputs.revision }} + commit_sha: ${{ steps.version.outputs.commit_sha }} steps: - uses: actions/checkout@v6 with: @@ -26,9 +27,9 @@ jobs: publish-macos: runs-on: macos-latest - needs: prepare + needs: [prepare] env: - APP_PATH: src-tauri/target/universal-apple-darwin/release/bundle/macos/Scriptio (Staging).app + APP_PATH: src-tauri/target/universal-apple-darwin/debug/bundle/macos/Scriptio (Staging).app PKG_PATH: Scriptio.pkg steps: - uses: actions/checkout@v6 @@ -40,7 +41,7 @@ jobs: p12-password: ${{ secrets.APPLE_CERT_PASSWORD }} - name: Download provisioning profile - uses: apple-actions/download-provisioning-profiles@v5 + uses: apple-actions/download-provisioning-profiles@v6 with: bundle-id: "app.scriptio.staging" profile-type: "MAC_APP_STORE" @@ -51,7 +52,6 @@ jobs: - name: Copy provisioning profile to known path run: | PROFILE=$(ls "$HOME/Library/MobileDevice/Provisioning Profiles/"*.provisionprofile \ - "$HOME/Library/MobileDevice/Provisioning Profiles/"*.mobileprovision \ 2>/dev/null | head -n 1) cp "$PROFILE" src-tauri/embedded.provisionprofile @@ -67,12 +67,13 @@ jobs: staging: "true" - name: Build and sign .app with Tauri - run: npm run tauri build -- --bundles app --target universal-apple-darwin --config src-tauri/tauri.appstore.conf.json --config "{\"bundle\":{\"macOS\":{\"bundleVersion\":\"${{ needs.prepare.outputs.revision }}\"}}}" + run: npm run tauri build -- --debug --bundles app --target universal-apple-darwin --config src-tauri/tauri.appstore.conf.json --config "{\"bundle\":{\"macOS\":{\"bundleVersion\":\"${{ needs.prepare.outputs.revision }}\"}}}" env: APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} NEXT_PUBLIC_API_URL: https://staging.scriptio.app - NEXT_PUBLIC_STAGING_BASIC_AUTH: ${{ secrets.STAGING_BASIC_AUTH }} + NEXT_PUBLIC_CLOUD_URL: https://cloud.staging.scriptio.app NEXT_PUBLIC_APP_VERSION: ${{ needs.prepare.outputs.version }} + NEXT_PUBLIC_COMMIT_SHA: ${{ needs.prepare.outputs.commit_sha }} - name: Build signed .pkg installer run: | @@ -82,17 +83,18 @@ jobs: "$PKG_PATH" - name: Upload to App Store Connect - uses: apple-actions/upload-testflight-build@v3 + uses: apple-actions/upload-testflight-build@v5 with: app-path: ${{ env.PKG_PATH }} issuer-id: ${{ secrets.APPSTORE_ISSUER_ID }} api-key-id: ${{ secrets.APPSTORE_KEY_ID }} api-private-key: ${{ secrets.APPSTORE_API_KEY_P8 }} + backend: "altool" app-type: "macos" publish-windows: runs-on: windows-latest - needs: prepare + needs: [prepare] env: APP_PATH: src-tauri/target/msix/Scriptio_${{ needs.prepare.outputs.version }}.msixbundle steps: @@ -110,16 +112,29 @@ jobs: staging: "true" - name: Build MSIX bundle - run: npm run build:windows + run: npm run debug:windows env: NEXT_PUBLIC_API_URL: https://staging.scriptio.app - NEXT_PUBLIC_STAGING_BASIC_AUTH: ${{ secrets.STAGING_BASIC_AUTH }} + NEXT_PUBLIC_CLOUD_URL: https://cloud.staging.scriptio.app NEXT_PUBLIC_APP_VERSION: ${{ needs.prepare.outputs.version }} + NEXT_PUBLIC_COMMIT_SHA: ${{ needs.prepare.outputs.commit_sha }} - name: Rename output to fixed name run: | Get-Item "src-tauri/target/msix/*.msixbundle" | Rename-Item -NewName "Scriptio_${{ needs.prepare.outputs.version }}.msixbundle" + - name: Verify MSIX package version + run: | + $zip = "$env:APP_PATH" + Copy-Item $zip bundle.zip + Expand-Archive bundle.zip out -Force + $line = Select-String -Path out/AppxMetadata/AppxBundleManifest.xml -Pattern 'Version="([\d.]+)"' | Select-Object -First 1 + $version = $line.Matches[0].Groups[1].Value + Write-Host "Built MSIX Identity Version: $version" + if ($version -notmatch '\.0$') { + throw "MSIX revision field must be 0 (got '$version'); the MS Store reserves the 4th component." + } + - name: Configure Microsoft Store CLI uses: microsoft/microsoft-store-apppublisher@v1.1 @@ -133,6 +148,24 @@ jobs: - name: Publish to Microsoft Store (staging) run: msstore publish "$env:APP_PATH" -id "9P1N2HMSH40L" + deploy-cloud: + runs-on: ubuntu-latest + needs: prepare + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: "25.2.1" + cache: "npm" + + - run: npm ci + + - name: Deploy Cloudflare Worker (staging) + run: npm run cloud:deploy:staging + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + deploy-web: runs-on: ubuntu-latest needs: prepare @@ -164,7 +197,7 @@ jobs: ${{ env.IMAGE_NAME }}:staging-${{ needs.prepare.outputs.version }} build-args: | NEXT_PUBLIC_API_URL=https://staging.scriptio.app - NEXT_PUBLIC_STAGING_BASIC_AUTH=${{ secrets.STAGING_BASIC_AUTH }} + NEXT_PUBLIC_CLOUD_URL=https://cloud.staging.scriptio.app NEXT_PUBLIC_COMMIT_SHA=${{ env.COMMIT_SHA }} NEXT_PUBLIC_APP_VERSION=${{ needs.prepare.outputs.version }} diff --git a/.gitignore b/.gitignore index d3c18813..a2ee5728 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,7 @@ .pnp.js # collaboration -/src/lib/collaboration/node_modules +/src/lib/cloud/node_modules .wrangler/ # testing @@ -21,6 +21,7 @@ next-env.d.ts # production /build /target +/src/generated # misc .DS_Store diff --git a/Dockerfile b/Dockerfile index 35a5f10c..507afe3c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,26 +6,26 @@ RUN apk add --no-cache git WORKDIR /usr/app COPY ./package*.json ./ -RUN npm install +RUN npm ci COPY ./ ./ -RUN chmod +x ./scripts/launch.sh ENV DATABASE_URL="postgresql://dummy:dummy@localhost:5432/dummy" ARG NEXT_PUBLIC_API_URL=https://scriptio.app +ARG NEXT_PUBLIC_CLOUD_URL=https://cloud.scriptio.app ARG NEXT_PUBLIC_COMMIT_SHA ARG NEXT_PUBLIC_APP_VERSION ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL +ENV NEXT_PUBLIC_CLOUD_URL=$NEXT_PUBLIC_CLOUD_URL ENV NEXT_PUBLIC_COMMIT_SHA=$NEXT_PUBLIC_COMMIT_SHA ENV NEXT_PUBLIC_APP_VERSION=$NEXT_PUBLIC_APP_VERSION RUN chown -R node:node . +USER node RUN npm run build EXPOSE 3000 -USER node -ENV NEXT_TELEMETRY_DISABLED 1 +ENV NEXT_TELEMETRY_DISABLED=1 -ENTRYPOINT ["./scripts/launch.sh"] -CMD [ "npm", "start" ] \ No newline at end of file +CMD npx prisma migrate deploy && npx prisma db seed && npm start \ No newline at end of file diff --git a/README.md b/README.md index 0eaac3cd..9f139bfa 100644 --- a/README.md +++ b/README.md @@ -19,16 +19,18 @@ - **Cloud Synchronization** - **Real-time Collaboration** - **Cross-platform** (Windows, MacOS, browser) -- **Industry-standard Formats** (PDF, Fountain, Final Draft) +- **Industry Formatting** (layout, page breaks, dual dialogue) +- **Compatibility Formats** (PDF, Fountain, Final Draft) +- **Production Ready** (revisions, scene & page locking) - **Scene Management** (easy navigation, reordering) - **Character Management** (color highlighting, synopsis) - **Beat Board** (story cards, outlining) -- **Smart formatting** (context aware auto-completion) +- **Smart Editor** (context aware auto-completion, spellchecker) - **Customization** (themes & custom keybinds) - **Statistics** (distribution, frequency) - **Search & Replace** (advanced filtering) - **Script Comments** (inline annotations) -- **Focus mode** (distraction-free writing) +- **Focus Mode** (distraction-free writing) # Core Values diff --git a/components/admin/AdminShell.module.css b/components/admin/AdminShell.module.css index 07024496..eebea079 100644 --- a/components/admin/AdminShell.module.css +++ b/components/admin/AdminShell.module.css @@ -21,11 +21,25 @@ align-items: center; gap: 10px; padding: 0 8px; - color: var(--primary-text); - font-family: var(--font-josefin), sans-serif; - font-weight: 700; - font-size: 18px; - letter-spacing: 0.02em; +} + +.brandLogo { + display: block; + height: 28px; + width: auto; + object-fit: contain; +} + +.brandEmail { + display: block; + padding: 0 8px; + color: var(--secondary-text); + font-family: var(--font-inter), sans-serif; + font-size: 12px; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .brandBadge { @@ -95,16 +109,6 @@ font-family: var(--font-inter), sans-serif; } -.footerEmail { - color: var(--primary-text); - font-weight: 600; - display: block; - margin-bottom: 2px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - .main { flex: 1; overflow-y: auto; diff --git a/components/admin/AdminShell.tsx b/components/admin/AdminShell.tsx index a0db4ec0..54105565 100644 --- a/components/admin/AdminShell.tsx +++ b/components/admin/AdminShell.tsx @@ -1,6 +1,7 @@ "use client"; import { ReactNode } from "react"; +import Image from "next/image"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { BarChart3, Users, FolderOpen, LogOut } from "lucide-react"; @@ -28,9 +29,16 @@ export default function AdminShell({ email, title, subtitle, children }: Props)
- {!debounced && ( -

- Start typing an email (partial, case-insensitive) or paste a full user ID. -

- )} + {!debounced && isLoading &&

Loading…

} {debounced && isLoading &&

Searching…

} diff --git a/components/board/BoardCanvas.module.css b/components/board/BoardCanvas.module.css index a38eda82..a9bd10a2 100644 --- a/components/board/BoardCanvas.module.css +++ b/components/board/BoardCanvas.module.css @@ -17,7 +17,13 @@ pointer-events: none; background: linear-gradient(to bottom, var(--editor-shadow) 0%, transparent 100%); mask-image: linear-gradient(to right, transparent 0%, black 20%, black 80%, transparent 100%); - -webkit-mask-image: linear-gradient(to right, transparent 0%, black 20%, black 80%, transparent 100%); + -webkit-mask-image: linear-gradient( + to right, + transparent 0%, + black 20%, + black 80%, + transparent 100% + ); } .container { @@ -32,6 +38,12 @@ cursor: grabbing; } +/* Highlight the canvas while an image file is dragged over it. */ +.container.drag_over { + outline: 2px dashed var(--secondary-text); + outline-offset: -8px; +} + .grid { position: absolute; inset: 0; @@ -58,8 +70,6 @@ position: absolute; display: flex; flex-direction: column; - border-radius: 8px; - border: 8px solid; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); cursor: move; user-select: none; @@ -76,6 +86,147 @@ z-index: 1000; } +/* Image resource card: bare image, no colored header/border chrome. */ +.card.image_card { + border: 1px solid rgba(0, 0, 0, 0.12); + background-color: var(--main-bg); + padding: 0; + overflow: hidden; +} + +.card_image { + width: 100%; + height: 100%; + object-fit: contain; + display: block; + pointer-events: none; + user-select: none; +} + +.card_image_placeholder { + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.05); +} + +/* Audio voice-note card: a colored header (title) over a play/timeline row. */ +.card.audio_card { + overflow: hidden; +} + +.audio_content { + flex: 1; + display: flex; + align-items: center; + gap: 10px; + padding: 0 12px; + overflow: hidden; +} + +/* Remaining-time readout next to the play button. */ +.audio_time { + flex-shrink: 0; + font-size: 12px; + font-variant-numeric: tabular-nums; + color: var(--board-card-body-text); + user-select: none; +} + +/* Seekable timeline. The card content is always a light tint, so the track uses + theme greys (--separator / --secondary-text) that read across every theme. + `--audio-progress` (set from JS) fills the track up to the cursor on WebKit; + Firefox uses its native ::-moz-range-progress. */ +.audio_timeline { + flex: 1; + min-width: 0; + height: 16px; + margin: 0; + padding: 0; + cursor: pointer; + background: transparent; + -webkit-appearance: none; + appearance: none; + --audio-progress: 0%; +} + +.audio_timeline::-webkit-slider-runnable-track { + height: 4px; + border-radius: 999px; + background: linear-gradient( + to right, + var(--secondary-text) var(--audio-progress), + var(--separator) var(--audio-progress) + ); +} + +.audio_timeline::-moz-range-track { + height: 4px; + border-radius: 999px; + background: var(--separator); +} + +.audio_timeline::-moz-range-progress { + height: 4px; + border-radius: 999px; + background: var(--secondary-text); +} + +.audio_timeline::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + box-sizing: border-box; + width: 12px; + height: 12px; + margin-top: -4px; + border: 2px solid var(--secondary); + border-radius: 50%; + background: var(--secondary-text); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.25); + transition: transform 0.1s ease; +} + +.audio_timeline::-moz-range-thumb { + box-sizing: border-box; + width: 12px; + height: 12px; + border: 2px solid var(--secondary); + border-radius: 50%; + background: var(--secondary-text); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.25); +} + +.audio_timeline:hover:not(:disabled)::-webkit-slider-thumb { + transform: scale(1.15); +} + +.audio_timeline:disabled { + cursor: default; + opacity: 0.5; +} + +.audio_play_btn { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 32px; + height: 32px; + border: none; + border-radius: 50%; + background-color: var(--secondary); + color: var(--secondary-text); + cursor: pointer; +} + +.audio_play_btn:hover:not(:disabled) { + background-color: var(--secondary-hover); +} + +.audio_play_btn:disabled { + opacity: 0.4; + cursor: default; +} + .card_header { position: relative; display: flex; @@ -229,81 +380,101 @@ } /* Context menu - matching ContextMenu.module.css */ -.context_menu { +/* Recording indicator: a pill in the top toolbar row, right of the zoom controls. */ +.recording_indicator { + position: absolute; + top: 8px; + left: 180px; + z-index: 11; display: flex; - flex-direction: column; - width: 150px; - z-index: 1000; - position: fixed; - overflow-y: auto; - border-radius: 12px; - background-color: var(--editor-sidebar); - box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.2); -} - -.context_menu_item { - display: flex; /* Enables flexbox layout */ - align-items: center; /* Vertically centers the icon and text */ - gap: 10px; /* Adds space between the icon and the text */ - - /* Your existing styles */ - padding-block: 8px; - padding-left: 12px; - padding-right: 35px; - font-size: 14px; - border-bottom: 1px solid; - border-color: var(--separator); - cursor: pointer; + align-items: center; + gap: 8px; + height: 36px; + padding: 0 10px; + border-radius: 16px; + background-color: var(--secondary); + color: var(--secondary-text); + user-select: none; +} + +.recording_dot { + width: 10px; + height: 10px; + border-radius: 50%; + background-color: #e5484d; + animation: recording_pulse 1s ease-in-out infinite; } -.context_menu_item:last-child { - border-bottom: none; +@keyframes recording_pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.3; + } } -.context_menu_item:hover { - background-color: var(--editor-sidebar-hover); +.recording_time { + font-size: 13px; + font-variant-numeric: tabular-nums; } -.context_menu_colors { +.asset_error { + position: absolute; + top: 8px; + left: 50%; + transform: translateX(-50%); + z-index: 12; display: flex; - flex-wrap: wrap; - gap: 6px; - padding: 10px 12px; - border-bottom: 1px solid var(--separator); + align-items: center; + gap: 8px; + max-width: 80%; + height: 36px; + padding: 0 14px; + border-radius: 16px; + background-color: var(--error); + color: #fff; + font-size: 13px; + user-select: none; } -.context_menu_color_swatch { - width: 20px; - height: 20px; - border: 2px solid transparent; - border-radius: 50%; +.recording_stop { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border: none; + border-radius: 12px; + background-color: #e5484d; + color: white; + font-size: 13px; cursor: pointer; - transition: - transform 0.15s ease, - border-color 0.15s ease; } -.context_menu_color_swatch:hover { - transform: scale(1.15); +.recording_stop:hover { + background-color: #d33b40; } -.context_menu_color_swatch_active { - border-color: white; - box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.3); -} -/* Zoom controls - matching navbar button style */ +/* Zoom controls - styled like the panel switcher buttons (secondary pill, no + border, secondary-hover on hover) and sitting in the same top toolbar row, + just to their right. The anchor is at top:8/left:8; the primary side has two + 20px buttons + 4px gap ending ~52px, so left:56px keeps a matching 4px gap. + z-index sits above the board's top shadow gradient (z-index:10). */ .zoom_controls { position: absolute; - bottom: 24px; - left: 24px; + top: 8px; + left: 56px; + z-index: 11; display: flex; align-items: center; - gap: 4px; - padding: 6px 12px; - border-radius: 64px; - border: 2px solid var(--tertiary); + gap: 2px; + height: 36px; + padding: 0 4px; + border-radius: 16px; background-color: var(--secondary); + color: var(--secondary-text); user-select: none; } @@ -311,42 +482,26 @@ display: flex; align-items: center; justify-content: center; - width: 28px; + width: 26px; height: 28px; border: none; - border-radius: 50%; + border-radius: 14px; background: transparent; - color: var(--primary-text); + color: var(--secondary-text); cursor: pointer; transition: background-color 0.15s ease; } .zoom_btn:hover { - background-color: var(--tertiary); + background-color: var(--secondary-hover); } .zoom_level { - min-width: 50px; + min-width: 38px; text-align: center; - font-size: 13px; - color: var(--primary-text); - font-weight: 500; -} - -/* Hints in corner with low opacity */ -.hints { - position: absolute; - bottom: 24px; - right: 24px; - display: flex; - flex-direction: column; - align-items: flex-end; - gap: 4px; font-size: 12px; + font-weight: 500; color: var(--secondary-text); - opacity: 0.5; - pointer-events: none; - user-select: none; } /* Arrows SVG layer */ @@ -404,7 +559,7 @@ transform 0.15s ease, background-color 0.15s ease; z-index: 10; - background: radial-gradient(circle, var(--secondary) 30%, transparent 30%); + background: radial-gradient(circle, white 30%, transparent 30%); } .connection_handle::before { @@ -415,7 +570,7 @@ width: 16px; height: 16px; transform: translate(-50%, -50%); - border: 2px solid var(--secondary); + border: 2px solid white; border-radius: 50%; opacity: 0.6; } @@ -426,7 +581,7 @@ .connection_handle:hover { transform: scale(1.2); - background: radial-gradient(circle, var(--secondary) 40%, transparent 40%); + background: radial-gradient(circle, white 40%, transparent 40%); } .connection_handle:hover::before { @@ -456,6 +611,6 @@ .card_connecting:hover { box-shadow: - 0 0 0 3px var(--secondary), + 0 0 0 3px white, 0 4px 16px rgba(0, 0, 0, 0.2); } diff --git a/components/board/BoardCanvas.tsx b/components/board/BoardCanvas.tsx index 91bfcd93..360ad865 100644 --- a/components/board/BoardCanvas.tsx +++ b/components/board/BoardCanvas.tsx @@ -2,43 +2,51 @@ import { useContext, useRef, useState, useCallback, useEffect, useMemo } from "react"; import { ProjectContext } from "@src/context/ProjectContext"; -import { getBoardMap, BoardCardData, BoardArrowData } from "@src/lib/project/project-state"; +import { UserContext } from "@src/context/UserContext"; +import { BoardCardData, BoardArrowData } from "@src/lib/project/project-state"; import BoardCard from "./BoardCard"; +import { + ContextMenuItem, + ContextMenuSeparator, + ContextMenuColorRow, +} from "@components/utils/ContextMenu"; import styles from "./BoardCanvas.module.css"; import { v7 as uuidv7 } from "uuid"; -import { Trash2, Plus, Minus, Copy } from "lucide-react"; +import { Trash2, Plus, Minus, Copy, ListTree, Mic, Square } from "lucide-react"; import { useTranslations } from "next-intl"; +import { DEFAULT_ITEM_COLORS } from "@src/lib/utils/colors"; +import { importImageFile, importAudioFile, syncAssetToCloud } from "@src/lib/assets/asset-store"; +import { CloudQuotaError } from "@src/lib/assets/cloud-asset-sync"; +import { scheduleAssetGc } from "@src/lib/assets/asset-gc"; +import { useAudioRecorder } from "./use-audio-recorder"; const GRID_SIZE = 20; const MIN_SCALE = 0.25; const MAX_SCALE = 2; - -const DEFAULT_CARD_COLORS = [ - "#ef4444", - "#f97316", - "#eab308", - "#22c55e", - "#06b6d4", - "#3b82f6", - "#8b5cf6", - "#ec4899", - "#6b7280", -]; - -interface CardContextMenuState { - position: { x: number; y: number }; - card: BoardCardData; +/** Largest edge (in canvas px) an image card is sized to on first drop. */ +const MAX_IMAGE_CARD_SIZE = 400; +/** Default size (in canvas px) of an audio voice-note card. */ +const AUDIO_CARD_WIDTH = 260; +const AUDIO_CARD_HEIGHT = 96; + +/** A random swatch from the default palette (used for new colored cards). */ +function randomCardColor(): string { + return DEFAULT_ITEM_COLORS[Math.floor(Math.random() * DEFAULT_ITEM_COLORS.length)]; } -interface ArrowContextMenuState { - position: { x: number; y: number }; - arrow: BoardArrowData; +/** Seconds → `m:ss` for the recording indicator. */ +function formatRecordingTime(seconds: number): string { + const m = Math.floor(seconds / 60); + const s = seconds % 60; + return `${m}:${s.toString().padStart(2, "0")}`; } -const BoardCanvas = ({ isVisible }: { isVisible: boolean }) => { - const { repository, isYjsReady } = useContext(ProjectContext); +const BoardCanvas =({ isVisible, docId }: { isVisible: boolean; docId: string }) => { + const { projectId, repository, isYjsReady, isReadOnly, boardFocusCardId, setBoardFocusCardId } = + useContext(ProjectContext); + const { updateContextMenu } = useContext(UserContext); const t = useTranslations("board"); - const ydoc = repository?.getState(); + const projectState = repository?.getState(); const containerRef = useRef(null); const canvasRef = useRef(null); @@ -47,20 +55,21 @@ const BoardCanvas = ({ isVisible }: { isVisible: boolean }) => { const [offset, setOffset] = useState({ x: 0, y: 0 }); const [scale, setScale] = useState(1); const [isPanning, setIsPanning] = useState(false); + const [isDraggingFile, setIsDraggingFile] = useState(false); const [isSnapping, setIsSnapping] = useState(true); - const [cardContextMenu, setCardContextMenu] = useState(null); - const [arrowContextMenu, setArrowContextMenu] = useState(null); + /** Transient banner shown when an asset can't be saved (e.g. cloud quota). */ + const [assetError, setAssetError] = useState(null); + const assetErrorTimer = useRef | null>(null); + const recorder = useAudioRecorder(); const [prevIsVisible, setPrevIsVisible] = useState(isVisible); if (prevIsVisible !== isVisible) { setPrevIsVisible(isVisible); - if (!isVisible) { - setIsSnapping(true); - if (cardContextMenu) setCardContextMenu(null); - if (arrowContextMenu) setArrowContextMenu(null); - } + if (!isVisible) setIsSnapping(true); } const [isCameraReady, setIsCameraReady] = useState(false); - const [connectingFrom, setConnectingFrom] = useState<{ cardId: string; side: string } | null>(null); + const [connectingFrom, setConnectingFrom] = useState<{ cardId: string; side: string } | null>( + null, + ); const [connectingLine, setConnectingLine] = useState<{ x: number; y: number } | null>(null); const [selectedCardIds, setSelectedCardIds] = useState>(new Set()); const [selectionRect, setSelectionRect] = useState<{ @@ -121,17 +130,29 @@ const BoardCanvas = ({ isVisible }: { isVisible: boolean }) => { setOffset({ x: newOffsetX, y: newOffsetY }); }, []); + // Focus a specific card when navigated to from the Outline. Waits until the + // board's cards have loaded and the target exists on this board, then centers + // on it and clears the request so it fires once. + useEffect(() => { + if (!boardFocusCardId || !isVisible) return; + const card = cards.find((c) => c.id === boardFocusCardId); + if (!card) return; + centerCameraOnCards([card]); + setBoardFocusCardId(null); + }, [boardFocusCardId, isVisible, cards, centerCameraOnCards, setBoardFocusCardId]); + // Sync cards with Yjs useEffect(() => { - if (!ydoc || !isYjsReady) return; + if (!projectState || !isYjsReady) return; - const boardMap = getBoardMap(ydoc); + const boardMap = projectState.boardData(docId); const syncCards = () => { const cardsData = boardMap.get("cards"); if (cardsData) { try { - const parsed = typeof cardsData === "string" ? JSON.parse(cardsData) : cardsData; + const parsed = + typeof cardsData === "string" ? JSON.parse(cardsData) : cardsData; setCards(parsed); // Center camera on first load @@ -162,7 +183,8 @@ const BoardCanvas = ({ isVisible }: { isVisible: boolean }) => { const arrowsData = boardMap.get("arrows"); if (arrowsData) { try { - const parsed = typeof arrowsData === "string" ? JSON.parse(arrowsData) : arrowsData; + const parsed = + typeof arrowsData === "string" ? JSON.parse(arrowsData) : arrowsData; setArrows(parsed); } catch (e) { console.error("[BoardCanvas] Failed to parse arrows:", e); @@ -176,26 +198,26 @@ const BoardCanvas = ({ isVisible }: { isVisible: boolean }) => { return () => { boardMap.unobserve(syncCards); }; - }, [ydoc, isYjsReady, centerCameraOnCards]); + }, [projectState, isYjsReady, docId, centerCameraOnCards]); // Save cards to Yjs const saveCards = useCallback( (newCards: BoardCardData[]) => { - if (!ydoc || !isYjsReady) return; - const boardMap = getBoardMap(ydoc); + if (!projectState || !isYjsReady || isReadOnly) return; + const boardMap = projectState.boardData(docId); boardMap.set("cards", JSON.stringify(newCards)); }, - [ydoc, isYjsReady], + [projectState, isYjsReady, isReadOnly, docId], ); // Save arrows to Yjs const saveArrows = useCallback( (newArrows: BoardArrowData[]) => { - if (!ydoc || !isYjsReady) return; - const boardMap = getBoardMap(ydoc); + if (!projectState || !isYjsReady || isReadOnly) return; + const boardMap = projectState.boardData(docId); boardMap.set("arrows", JSON.stringify(newArrows)); }, - [ydoc, isYjsReady], + [projectState, isYjsReady, isReadOnly, docId], ); // Handle keyboard events for snapping @@ -226,21 +248,6 @@ const BoardCanvas = ({ isVisible }: { isVisible: boolean }) => { }; }, [isVisible]); - // Close context menus on click anywhere - useEffect(() => { - if (!isVisible) return; - - const handleClick = () => { - if (cardContextMenu) setCardContextMenu(null); - if (arrowContextMenu) setArrowContextMenu(null); - }; - - window.addEventListener("click", handleClick); - return () => { - window.removeEventListener("click", handleClick); - }; - }, [cardContextMenu, arrowContextMenu, isVisible]); - // Panning with middle-click const handlePanMouseDown = useCallback( (e: React.MouseEvent) => { @@ -264,8 +271,7 @@ const BoardCanvas = ({ isVisible }: { isVisible: boolean }) => { if (e.button !== 0) return; if ((e.target as HTMLElement).closest(`.${styles.card}`)) return; if ((e.target as HTMLElement).closest(`.${styles.zoom_controls}`)) return; - if ((e.target as HTMLElement).closest(`.${styles.hints}`)) return; - if ((e.target as HTMLElement).closest(`.${styles.context_menu}`)) return; + if ((e.target as HTMLElement).closest("[data-context-menu]")) return; const container = containerRef.current; if (!container) return; @@ -324,6 +330,8 @@ const BoardCanvas = ({ isVisible }: { isVisible: boolean }) => { const offsetRef = useRef(offset); const scaleRef = useRef(scale); const cardsRef = useRef(cards); + /** Canvas-space coords captured when recording starts, for the resulting card. */ + const recordCoords = useRef({ x: 0, y: 0 }); useEffect(() => { offsetRef.current = offset; }, [offset]); @@ -381,7 +389,12 @@ const BoardCanvas = ({ isVisible }: { isVisible: boolean }) => { const cardRight = card.x + card.width; const cardBottom = card.y + card.height; - if (card.x < right && cardRight > left && card.y < bottom && cardBottom > top) { + if ( + card.x < right && + cardRight > left && + card.y < bottom && + cardBottom > top + ) { selected.add(card.id); } } @@ -403,9 +416,11 @@ const BoardCanvas = ({ isVisible }: { isVisible: boolean }) => { }; }, [selectionRect !== null]); // eslint-disable-line react-hooks/exhaustive-deps - // Zoom with mouse wheel - centered on cursor + // Zoom with mouse wheel - centered on cursor. + // Attached as a native non-passive listener (see effect below) because React + // registers onWheel as passive, which makes preventDefault() a no-op and warns. const handleWheel = useCallback( - (e: React.WheelEvent) => { + (e: WheelEvent) => { e.preventDefault(); const container = containerRef.current; @@ -432,6 +447,13 @@ const BoardCanvas = ({ isVisible }: { isVisible: boolean }) => { [scale, offset], ); + useEffect(() => { + const container = containerRef.current; + if (!container) return; + container.addEventListener("wheel", handleWheel, { passive: false }); + return () => container.removeEventListener("wheel", handleWheel); + }, [handleWheel]); + // Zoom from buttons - centered on viewport const zoomFromCenter = useCallback( (zoomIn: boolean) => { @@ -463,7 +485,6 @@ const BoardCanvas = ({ isVisible }: { isVisible: boolean }) => { e.preventDefault(); if ((e.target as HTMLElement).closest(`.${styles.card}`)) return; if ((e.target as HTMLElement).closest(`.${styles.zoom_controls}`)) return; - if ((e.target as HTMLElement).closest(`.${styles.hints}`)) return; // Clear selection when creating a new card setSelectedCardIds(new Set()); @@ -475,13 +496,11 @@ const BoardCanvas = ({ isVisible }: { isVisible: boolean }) => { const x = (e.clientX - rect.left - offset.x) / scale; const y = (e.clientY - rect.top - offset.y) / scale; - const randomColor = DEFAULT_CARD_COLORS[Math.floor(Math.random() * DEFAULT_CARD_COLORS.length)]; - const newCard: BoardCardData = { id: uuidv7(), title: "", description: "", - color: randomColor, + color: randomCardColor(), x: isSnapping ? Math.round(x / GRID_SIZE) * GRID_SIZE : x, y: isSnapping ? Math.round(y / GRID_SIZE) * GRID_SIZE : y, width: 450, @@ -495,6 +514,245 @@ const BoardCanvas = ({ isVisible }: { isVisible: boolean }) => { [cards, offset, scale, isSnapping, saveCards], ); + // Highlight the canvas while an OS file drag hovers over it. + const handleDragOver = useCallback( + (e: React.DragEvent) => { + if (isReadOnly) return; + if (!Array.from(e.dataTransfer.types).includes("Files")) return; + e.preventDefault(); + e.dataTransfer.dropEffect = "copy"; + setIsDraggingFile(true); + }, + [isReadOnly], + ); + + const handleDragLeave = useCallback((e: React.DragEvent) => { + // Ignore leave events fired when moving between the container's children. + if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setIsDraggingFile(false); + }, []); + + // Show a transient error banner (auto-dismissed). Used when an asset can't be + // persisted, e.g. the owner is out of cloud storage. + const showAssetError = useCallback((message: string) => { + setAssetError(message); + if (assetErrorTimer.current) clearTimeout(assetErrorTimer.current); + assetErrorTimer.current = setTimeout(() => setAssetError(null), 4000); + }, []); + + useEffect(() => () => { + if (assetErrorTimer.current) clearTimeout(assetErrorTimer.current); + }, []); + + // Remove cards by id (used to roll back a card whose asset can't be saved). + const removeCards = useCallback( + (ids: Set) => { + const next = cardsRef.current.filter((c) => !ids.has(c.id)); + cardsRef.current = next; // keep the ref current so concurrent removals don't race + setCards(next); + saveCards(next); + }, + [saveCards], + ); + + // Upload the new cards' assets to the cloud in the background, so the cards + // appear instantly (the bytes are already cached locally and render offline). + // If an upload is rejected for quota, roll back that card and explain why. + const syncCreatedAssets = useCallback( + (createdCards: BoardCardData[], pid: string) => { + for (const card of createdCards) { + if (!card.assetId) continue; + const cardId = card.id; + void syncAssetToCloud(pid, card.assetId).catch((err) => { + if (err instanceof CloudQuotaError) { + removeCards(new Set([cardId])); + showAssetError(t("storageLimitReached")); + } else { + console.error("[BoardCanvas] cloud asset upload failed:", err); + } + }); + } + }, + [removeCards, showAssetError, t], + ); + + // Drop image files → store each in IndexedDB (deduped) and drop an image + // card referencing its hash at the cursor. + const handleDrop = useCallback( + async (e: React.DragEvent) => { + e.preventDefault(); + setIsDraggingFile(false); + if (isReadOnly || !projectId) return; + + const files = Array.from(e.dataTransfer.files).filter( + (f) => f.type.startsWith("image/") || f.type.startsWith("audio/"), + ); + if (files.length === 0) return; + + const container = containerRef.current; + if (!container) return; + const rect = container.getBoundingClientRect(); + const dropX = (e.clientX - rect.left - offset.x) / scale; + const dropY = (e.clientY - rect.top - offset.y) / scale; + + const created: BoardCardData[] = []; + for (const file of files) { + const i = created.length; + try { + if (file.type.startsWith("audio/")) { + const { hash } = await importAudioFile(projectId, file); + created.push({ + id: uuidv7(), + type: "audio", + assetId: hash, + title: "", + description: "", + color: randomCardColor(), + x: dropX + i * 24, + y: dropY + i * 24, + width: AUDIO_CARD_WIDTH, + height: AUDIO_CARD_HEIGHT, + }); + continue; + } + const { hash, width, height } = await importImageFile(projectId, file); + const fit = Math.min(1, MAX_IMAGE_CARD_SIZE / Math.max(width, height, 1)); + created.push({ + id: uuidv7(), + type: "image", + assetId: hash, + title: "", + description: "", + color: "transparent", + x: dropX + i * 24, + y: dropY + i * 24, + width: Math.max(60, Math.round(width * fit)), + height: Math.max(60, Math.round(height * fit)), + }); + } catch (err) { + console.error("[BoardCanvas] Failed to import dropped file:", err); + } + } + if (created.length === 0) return; + + const newCards = [...cardsRef.current, ...created]; + setCards(newCards); + saveCards(newCards); + + // Upload to the cloud in the background (cards already show locally). + syncCreatedAssets(created, projectId); + }, + [isReadOnly, projectId, offset, scale, saveCards, syncCreatedAssets], + ); + + // Create a text card at the given canvas-space coords (from the canvas menu). + const handleCreateCard = useCallback( + (x: number, y: number) => { + setSelectedCardIds(new Set()); + + const newCard: BoardCardData = { + id: uuidv7(), + title: "", + description: "", + color: randomCardColor(), + x: isSnapping ? Math.round(x / GRID_SIZE) * GRID_SIZE : x, + y: isSnapping ? Math.round(y / GRID_SIZE) * GRID_SIZE : y, + width: 450, + height: 280, + }; + + const newCards = [...cardsRef.current, newCard]; + setCards(newCards); + saveCards(newCards); + }, + [isSnapping, saveCards], + ); + + // Begin recording; remember where to drop the resulting card. + const handleStartRecording = useCallback( + async (x: number, y: number) => { + recordCoords.current = { x, y }; + try { + await recorder.start(); + } catch (err) { + console.error("[BoardCanvas] Microphone access failed:", err); + } + }, + [recorder], + ); + + // Stop recording, store the clip as an asset, and drop an audio card. + const handleStopRecording = useCallback(async () => { + const blob = await recorder.stop(); + if (!blob || !projectId) return; + try { + const { hash } = await importAudioFile(projectId, blob); + const { x, y } = recordCoords.current; + const newCard: BoardCardData = { + id: uuidv7(), + type: "audio", + assetId: hash, + title: "", + description: "", + color: randomCardColor(), + x, + y, + width: AUDIO_CARD_WIDTH, + height: AUDIO_CARD_HEIGHT, + }; + const newCards = [...cardsRef.current, newCard]; + setCards(newCards); + saveCards(newCards); + + // Upload to the cloud in the background (card already shows locally). + syncCreatedAssets([newCard], projectId); + } catch (err) { + console.error("[BoardCanvas] Failed to store recording:", err); + } + }, [recorder, projectId, saveCards, syncCreatedAssets]); + + // Right-clicking empty canvas opens a menu (create card / record audio). + // Cards and arrows have their own menus, so bail when the click landed on one. + const handleCanvasContextMenu = useCallback( + (e: React.MouseEvent) => { + if (isReadOnly) return; + const target = e.target as HTMLElement; + if ( + target.closest(`.${styles.card}`) || + target.closest(`.${styles.arrow_group}`) || + target.closest("[data-context-menu]") || + target.closest(`.${styles.zoom_controls}`) + ) + return; + + const container = containerRef.current; + if (!container) return; + const rect = container.getBoundingClientRect(); + e.preventDefault(); + const canvasX = (e.clientX - rect.left - offset.x) / scale; + const canvasY = (e.clientY - rect.top - offset.y) / scale; + updateContextMenu({ + position: { x: e.clientX, y: e.clientY }, + content: ( + <> + handleCreateCard(canvasX, canvasY)} + /> + handleStartRecording(canvasX, canvasY)} + disabled={!recorder.isSupported} + title={recorder.isSupported ? undefined : t("audioUnsupported")} + /> + + ), + }); + }, + [isReadOnly, offset, scale, updateContextMenu, t, handleCreateCard, handleStartRecording, recorder.isSupported], + ); + // Update card (with multi-drag support) const handleUpdateCard = useCallback( (updatedCard: BoardCardData) => { @@ -506,11 +764,14 @@ const BoardCanvas = ({ isVisible }: { isVisible: boolean }) => { const dy = updatedCard.y - oldCard.y; // Only apply multi-drag for position changes, not resize if (dx !== 0 || dy !== 0) { - const isResize = updatedCard.width !== oldCard.width || updatedCard.height !== oldCard.height; + const isResize = + updatedCard.width !== oldCard.width || + updatedCard.height !== oldCard.height; if (!isResize) { const newCards = cards.map((c) => { if (c.id === updatedCard.id) return updatedCard; - if (selectedCardIds.has(c.id)) return { ...c, x: c.x + dx, y: c.y + dy }; + if (selectedCardIds.has(c.id)) + return { ...c, x: c.x + dx, y: c.y + dy }; return c; }); setCards(newCards); @@ -538,9 +799,10 @@ const BoardCanvas = ({ isVisible }: { isVisible: boolean }) => { const newArrows = arrows.filter((a) => a.fromCardId !== id && a.toCardId !== id); setArrows(newArrows); saveArrows(newArrows); - setCardContextMenu(null); + // Deleting an image card may orphan its asset — reconcile (debounced). + if (projectId && projectState) scheduleAssetGc(projectId, projectState); }, - [cards, arrows, saveCards, saveArrows], + [cards, arrows, saveCards, saveArrows, projectId, projectState], ); // Change card color @@ -549,7 +811,6 @@ const BoardCanvas = ({ isVisible }: { isVisible: boolean }) => { const newCards = cards.map((c) => (c.id === id ? { ...c, color } : c)); setCards(newCards); saveCards(newCards); - setCardContextMenu(null); }, [cards, saveCards], ); @@ -566,28 +827,25 @@ const BoardCanvas = ({ isVisible }: { isVisible: boolean }) => { const newCards = [...cards, newCard]; setCards(newCards); saveCards(newCards); - setCardContextMenu(null); }, [cards, saveCards], ); - // Context menu for card - const handleCardContextMenu = useCallback((e: React.MouseEvent, card: BoardCardData) => { - setCardContextMenu({ - position: { x: e.clientX, y: e.clientY }, - card, - }); - }, []); - - // Context menu for arrow - const handleArrowContextMenu = useCallback((e: React.MouseEvent, arrow: BoardArrowData) => { - e.preventDefault(); - e.stopPropagation(); - setArrowContextMenu({ - position: { x: e.clientX, y: e.clientY }, - arrow, - }); - }, []); + // Send card to the Outline view + const handleSendToOutline = useCallback( + (card: BoardCardData) => { + repository?.addOutlineItem({ + source: "card", + refDocId: docId, + refId: card.id, + title: card.title, + preview: card.description, + color: card.color, + parentId: null, + }); + }, + [repository, docId], + ); // Delete arrow const handleDeleteArrow = useCallback( @@ -595,32 +853,98 @@ const BoardCanvas = ({ isVisible }: { isVisible: boolean }) => { const newArrows = arrows.filter((a) => a.id !== id); setArrows(newArrows); saveArrows(newArrows); - setArrowContextMenu(null); }, [arrows, saveArrows], ); + // Open the shared context-menu host for a card. + const handleCardContextMenu = useCallback( + (e: React.MouseEvent, card: BoardCardData) => { + updateContextMenu({ + position: { x: e.clientX, y: e.clientY }, + content: ( + <> + {/* Color applies to text + audio notes; image cards have none. */} + {card.type !== "image" && ( + <> + handleChangeCardColor(card.id, color)} + /> + + + )} + handleDuplicateCard(card)} + /> + {(card.type ?? "text") === "text" && ( + handleSendToOutline(card)} + /> + )} + handleDeleteCard(card.id)} + /> + + ), + }); + }, + [updateContextMenu, t, handleChangeCardColor, handleDuplicateCard, handleSendToOutline, handleDeleteCard], + ); + + // Open the shared context-menu host for an arrow. + const handleArrowContextMenu = useCallback( + (e: React.MouseEvent, arrow: BoardArrowData) => { + e.preventDefault(); + e.stopPropagation(); + updateContextMenu({ + position: { x: e.clientX, y: e.clientY }, + content: ( + handleDeleteArrow(arrow.id)} + /> + ), + }); + }, + [updateContextMenu, t, handleDeleteArrow], + ); + // Get connection point position for a card - const getConnectionPoint = useCallback((card: BoardCardData, side: "top" | "right" | "bottom" | "left") => { - const centerX = card.x + card.width / 2; - const centerY = card.y + card.height / 2; - - switch (side) { - case "top": - return { x: centerX, y: card.y }; - case "right": - return { x: card.x + card.width, y: centerY }; - case "bottom": - return { x: centerX, y: card.y + card.height }; - case "left": - return { x: card.x, y: centerY }; - } - }, []); + const getConnectionPoint = useCallback( + (card: BoardCardData, side: "top" | "right" | "bottom" | "left") => { + const centerX = card.x + card.width / 2; + const centerY = card.y + card.height / 2; + + switch (side) { + case "top": + return { x: centerX, y: card.y }; + case "right": + return { x: card.x + card.width, y: centerY }; + case "bottom": + return { x: centerX, y: card.y + card.height }; + case "left": + return { x: card.x, y: centerY }; + } + }, + [], + ); // Calculate best connection points between two cards with perpendicular tangent directions const getArrowPoints = useCallback( (fromCard: BoardCardData, toCard: BoardCardData) => { - const fromCenter = { x: fromCard.x + fromCard.width / 2, y: fromCard.y + fromCard.height / 2 }; + const fromCenter = { + x: fromCard.x + fromCard.width / 2, + y: fromCard.y + fromCard.height / 2, + }; const toCenter = { x: toCard.x + toCard.width / 2, y: toCard.y + toCard.height / 2 }; const dx = toCenter.x - fromCenter.x; @@ -664,10 +988,13 @@ const BoardCanvas = ({ isVisible }: { isVisible: boolean }) => { ); // Handle starting a connection from a card - const handleStartConnection = useCallback((cardId: string, side: string, initialX: number, initialY: number) => { - setConnectingFrom({ cardId, side }); - setConnectingLine({ x: initialX, y: initialY }); - }, []); + const handleStartConnection = useCallback( + (cardId: string, side: string, initialX: number, initialY: number) => { + setConnectingFrom({ cardId, side }); + setConnectingLine({ x: initialX, y: initialY }); + }, + [], + ); // Handle mouse move while connecting const handleConnectionMouseMove = useCallback( @@ -751,10 +1078,13 @@ const BoardCanvas = ({ isVisible }: { isVisible: boolean }) => {
@@ -775,7 +1105,10 @@ const BoardCanvas = ({ isVisible }: { isVisible: boolean }) => { const points = getArrowPoints(fromCard, toCard); // Calculate distance for control point offset (perpendicular to border) - const dist = Math.hypot(points.to.x - points.from.x, points.to.y - points.from.y); + const dist = Math.hypot( + points.to.x - points.from.x, + points.to.y - points.from.y, + ); const controlDist = Math.max(50, dist * 0.4); // Control points extend perpendicular to the borders @@ -791,10 +1124,22 @@ const BoardCanvas = ({ isVisible }: { isVisible: boolean }) => { // Arrowhead points matching original marker shape: M 0 0 L 12 4 L 0 8 L 3 4 Z // Back corners (perpendicular to arrow direction) - const ax1 = points.to.x - arrowLength * Math.cos(angle) + arrowWidth * Math.sin(angle); - const ay1 = points.to.y - arrowLength * Math.sin(angle) - arrowWidth * Math.cos(angle); - const ax2 = points.to.x - arrowLength * Math.cos(angle) - arrowWidth * Math.sin(angle); - const ay2 = points.to.y - arrowLength * Math.sin(angle) + arrowWidth * Math.cos(angle); + const ax1 = + points.to.x - + arrowLength * Math.cos(angle) + + arrowWidth * Math.sin(angle); + const ay1 = + points.to.y - + arrowLength * Math.sin(angle) - + arrowWidth * Math.cos(angle); + const ax2 = + points.to.x - + arrowLength * Math.cos(angle) - + arrowWidth * Math.sin(angle); + const ay2 = + points.to.y - + arrowLength * Math.sin(angle) + + arrowWidth * Math.cos(angle); // Inner notch (25% from back toward tip) const notchDepth = arrowLength * 0.75; const axm = points.to.x - notchDepth * Math.cos(angle); @@ -825,7 +1170,11 @@ const BoardCanvas = ({ isVisible }: { isVisible: boolean }) => { strokeWidth={2.5} /> {/* Arrowhead */} - + ); })} @@ -856,7 +1205,10 @@ const BoardCanvas = ({ isVisible }: { isVisible: boolean }) => { } const fromPoint = getConnectionPoint(fromCard, fromSide); - const dist = Math.hypot(connectingLine.x - fromPoint.x, connectingLine.y - fromPoint.y); + const dist = Math.hypot( + connectingLine.x - fromPoint.x, + connectingLine.y - fromPoint.y, + ); const controlDist = Math.max(30, dist * 0.3); const cx = fromPoint.x + fromDir.x * controlDist; @@ -881,6 +1233,7 @@ const BoardCanvas = ({ isVisible }: { isVisible: boolean }) => { { )}
- {/* Card Context Menu */} - {cardContextMenu && ( -
-
- {DEFAULT_CARD_COLORS.map((color) => ( -
-
handleDuplicateCard(cardContextMenu.card)} - > - -

{t("duplicate")}

-
-
handleDeleteCard(cardContextMenu.card.id)} - > - -

{t("delete")}

-
-
- )} - - {/* Arrow Context Menu */} - {arrowContextMenu && ( -
-
handleDeleteArrow(arrowContextMenu.arrow.id)} - > - -

{t("delete")}

-
+ {/* Transient asset error (e.g. cloud storage limit reached) */} + {assetError &&
{assetError}
} + + {/* Recording indicator */} + {recorder.isRecording && ( +
+ + + {formatRecordingTime(recorder.elapsed)} + +
)}
{Math.round(scale * 100)}%
- -
- {t("hints.pan")} - {t("hints.select")} - {t("hints.create")} - {t("hints.move")} -
); diff --git a/components/board/BoardCard.tsx b/components/board/BoardCard.tsx index d8de0bb4..c2b3d963 100644 --- a/components/board/BoardCard.tsx +++ b/components/board/BoardCard.tsx @@ -3,10 +3,284 @@ import { useRef, useState, useCallback, useEffect } from "react"; import styles from "./BoardCanvas.module.css"; import { useTranslations } from "next-intl"; +import { Play, Pause } from "lucide-react"; import { BoardCardData } from "@src/lib/project/project-state"; +import { useAssetUrl } from "@src/lib/assets/use-asset-url"; + +/** Join truthy class names (false/undefined are skipped). */ +const cx = (...parts: (string | false | undefined)[]) => parts.filter(Boolean).join(" "); + +/** The card kind, defaulting legacy/undefined cards to plain text notes. */ +type CardKind = NonNullable; +const kindOf = (card: BoardCardData): CardKind => card.type ?? "text"; + +/** Smallest height (canvas px) a card can be resized to, by kind. */ +const minHeightFor = (kind: CardKind) => (kind === "audio" ? 76 : 100); + +/** Seconds → `m:ss` (clamped at 0), for the audio timer. */ +function formatTime(seconds: number): string { + const total = Math.max(0, Math.floor(seconds || 0)); + const m = Math.floor(total / 60); + const s = total % 60; + return `${m}:${s.toString().padStart(2, "0")}`; +} + +/** + * Inline color for the card chrome. Images are bare; text and audio notes carry + * a colored header/border like every other card. + */ +function cardColorStyle(card: BoardCardData): React.CSSProperties { + if (kindOf(card) === "image") return {}; + return { borderColor: card.color, backgroundColor: card.color }; +} + +// ── Shared inline-editable title ───────────────────────────────────────────── + +interface TitleEditing { + editing: boolean; + value: string; + onChange: (value: string) => void; + onBlur: () => void; + onKeyDown: (e: React.KeyboardEvent) => void; + onStartEdit: (e: React.MouseEvent) => void; +} + +interface EditableTitleProps extends TitleEditing { + /** Text shown when not editing (e.g. the title or a placeholder hint). */ + display: string; + placeholder: string; + inputClassName: string; + labelClassName: string; + /** Optional tooltip on the read-only label. */ + labelTitle?: string; +} + +const EditableTitle = ({ + editing, + value, + display, + placeholder, + inputClassName, + labelClassName, + labelTitle, + onChange, + onBlur, + onKeyDown, + onStartEdit, +}: EditableTitleProps) => + editing ? ( + onChange(e.target.value)} + onBlur={onBlur} + onKeyDown={onKeyDown} + onClick={(e) => e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + placeholder={placeholder} + autoFocus + /> + ) : ( + + {display} + + ); + +// ── Card body variants ─────────────────────────────────────────────────────── + +const ImageBody = ({ projectId, card }: { projectId: string; card: BoardCardData }) => { + const imageUrl = useAssetUrl(projectId, card.assetId); + if (!imageUrl) return
; + // Blob object URLs can't be optimized by next/image; a plain is correct here. + // eslint-disable-next-line @next/next/no-img-element + return ; +}; + +const AudioBody = ({ + projectId, + card, + title, +}: { + projectId: string; + card: BoardCardData; + title: TitleEditing; +}) => { + const t = useTranslations("board"); + const assetUrl = useAssetUrl(projectId, card.assetId); + const audioRef = useRef(null); + const [isPlaying, setIsPlaying] = useState(false); + const [duration, setDuration] = useState(0); + const [currentTime, setCurrentTime] = useState(0); + // MediaRecorder blobs report duration as Infinity until forced to seek past + // the end; this flag drives the one-shot fix-up below. + const fixDuration = useRef(false); + + const togglePlay = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + const audio = audioRef.current; + if (!audio) return; + if (audio.paused) void audio.play(); + else audio.pause(); + }, []); + + const handleLoadedMetadata = useCallback(() => { + const audio = audioRef.current; + if (!audio) return; + if (isFinite(audio.duration)) { + setDuration(audio.duration); + } else { + fixDuration.current = true; + audio.currentTime = 1e7; // nudge the browser to compute real duration + } + }, []); + + const handleTimeUpdate = useCallback(() => { + const audio = audioRef.current; + if (!audio) return; + if (fixDuration.current) { + fixDuration.current = false; + setDuration(isFinite(audio.duration) ? audio.duration : 0); + audio.currentTime = 0; + setCurrentTime(0); + return; + } + setCurrentTime(audio.currentTime); + }, []); + + const handleSeek = useCallback((e: React.ChangeEvent) => { + const value = Number(e.target.value); + if (audioRef.current) audioRef.current.currentTime = value; + setCurrentTime(value); + }, []); + + return ( + <> +
+ +
+ +
+ + + {formatTime(duration ? duration - currentTime : 0)} + + e.stopPropagation()} + disabled={!assetUrl || !duration} + style={ + { + "--audio-progress": `${duration ? (Math.min(currentTime, duration) / duration) * 100 : 0}%`, + } as React.CSSProperties + } + /> + {assetUrl && ( +
+ + ); +}; + +interface DescriptionEditing { + editing: boolean; + value: string; + onChange: (value: string) => void; + onBlur: () => void; + onKeyDown: (e: React.KeyboardEvent) => void; +} + +const TextBody = ({ + card, + title, + description, + onStartEditDescription, +}: { + card: BoardCardData; + title: TitleEditing; + description: DescriptionEditing; + onStartEditDescription: (e: React.MouseEvent) => void; +}) => { + const t = useTranslations("board"); + return ( + <> +
+ +
+ +
+

+ {card.description} +

+ {description.editing && ( +