From 17051a22a7e5d2982ee7bf0bc21132ac44bdb754 Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Mon, 2 Mar 2026 22:50:14 -0600 Subject: [PATCH 01/20] chore: pin actions to SHA hashes and scope workflow permissions Pin all GitHub Actions to commit SHAs across 8 workflows and move write permissions from top-level to job-level. Targets the Pinned-Dependencies and Token-Permissions OpenSSF Scorecard checks. --- .github/workflows/ci.yml | 10 +++--- .github/workflows/dependabot-auto-merge.yml | 9 ++--- .github/workflows/docker-publish.yml | 19 +++++----- .github/workflows/docs.yml | 18 +++++----- .github/workflows/release-pr-checks.yml | 20 +++++------ .github/workflows/release.yml | 9 ++--- .github/workflows/scorecard.yml | 6 ++-- .github/workflows/security.yml | 40 ++++++++++----------- 8 files changed, 67 insertions(+), 64 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7c5ebec..799f30f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,8 +29,8 @@ jobs: outputs: code: ${{ steps.filter.outputs.code }} steps: - - uses: actions/checkout@v4 - - uses: dorny/paths-filter@v3 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 id: filter with: filters: | @@ -51,12 +51,12 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 0 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 24 cache: npm @@ -82,7 +82,7 @@ jobs: - name: Upload coverage if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: coverage-report path: coverage/ diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml index 7d81309..9c89f95 100644 --- a/.github/workflows/dependabot-auto-merge.yml +++ b/.github/workflows/dependabot-auto-merge.yml @@ -7,18 +7,19 @@ on: - '.github/workflows/**' - '.github/actions/**' -permissions: - contents: write - pull-requests: write +permissions: read-all jobs: auto-merge: runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write if: github.actor == 'dependabot[bot]' steps: - name: Fetch Dependabot metadata id: metadata - uses: dependabot/fetch-metadata@v2 + uses: dependabot/fetch-metadata@21025c705c08248db411dc16f3619e6b5f9ea21a # v2.5.0 with: github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 6ebeaed..a2a6c13 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -25,9 +25,7 @@ concurrency: group: docker-publish cancel-in-progress: false -permissions: - contents: read - packages: write +permissions: read-all env: REGISTRY: ghcr.io @@ -37,6 +35,9 @@ jobs: build-and-push: runs-on: ubuntu-latest timeout-minutes: 20 + permissions: + contents: read + packages: write # For workflow_run: only run if the Release workflow succeeded AND a new # release was actually created (check for a tag matching the latest release). # For release/workflow_dispatch: always run. @@ -47,7 +48,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Determine version id: version @@ -83,15 +84,15 @@ jobs: - name: Set up QEMU if: steps.version.outputs.skip != 'true' - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 - name: Set up Docker Buildx if: steps.version.outputs.skip != 'true' - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - name: Log in to GHCR if: steps.version.outputs.skip != 'true' - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} @@ -100,7 +101,7 @@ jobs: - name: Extract metadata if: steps.version.outputs.skip != 'true' id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | @@ -109,7 +110,7 @@ jobs: - name: Build and push if: steps.version.outputs.skip != 'true' - uses: docker/build-push-action@v6 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 with: context: . platforms: linux/amd64,linux/arm64 diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 30fc7c3..f24af1e 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -8,10 +8,7 @@ on: - 'package.json' workflow_dispatch: -permissions: - contents: read - pages: write - id-token: write +permissions: read-all concurrency: group: pages @@ -21,9 +18,9 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 24 cache: npm @@ -34,18 +31,21 @@ jobs: - run: npm run docs:build - - uses: actions/configure-pages@v5 + - uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5.0.0 - - uses: actions/upload-pages-artifact@v3 + - uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3.0.1 with: path: docs/.vitepress/dist deploy: needs: build runs-on: ubuntu-latest + permissions: + pages: write + id-token: write environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} steps: - id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5 diff --git a/.github/workflows/release-pr-checks.yml b/.github/workflows/release-pr-checks.yml index b8c1552..1e6d8d6 100644 --- a/.github/workflows/release-pr-checks.yml +++ b/.github/workflows/release-pr-checks.yml @@ -29,10 +29,7 @@ on: workflows: [Release] types: [completed] -permissions: - contents: read - statuses: write - security-events: write +permissions: read-all jobs: # Gate: only run for release-please PRs. Finds PR details from either trigger. @@ -73,12 +70,12 @@ jobs: timeout-minutes: 10 steps: - name: Checkout PR code - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: ref: ${{ needs.should-run.outputs.head_sha }} - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 24 cache: npm @@ -105,6 +102,8 @@ jobs: runs-on: ubuntu-latest if: always() needs: [should-run, lint-and-check] + permissions: + statuses: write steps: - name: Check CI status run: | @@ -152,23 +151,24 @@ jobs: timeout-minutes: 15 permissions: security-events: write + contents: read steps: - name: Checkout PR code - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: ref: ${{ needs.should-run.outputs.head_sha }} - name: Initialize CodeQL - uses: github/codeql-action/init@v3 + uses: github/codeql-action/init@60168efe1c415ce0f5521ea06d5c2062adbeed1b # v3.28.17 with: languages: javascript-typescript config-file: .github/codeql/codeql-config.yml queries: security-extended - name: Autobuild - uses: github/codeql-action/autobuild@v3 + uses: github/codeql-action/autobuild@60168efe1c415ce0f5521ea06d5c2062adbeed1b # v3.28.17 - name: Perform CodeQL analysis - uses: github/codeql-action/analyze@v3 + uses: github/codeql-action/analyze@60168efe1c415ce0f5521ea06d5c2062adbeed1b # v3.28.17 with: category: '/language:javascript-typescript' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 63c40cb..75769ee 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,20 +10,21 @@ on: # That workflow posts commit statuses to the PR head SHA so branch protection # sees the results. Docker publishing uses docker-publish.yml (also workflow_run). -permissions: - contents: write - pull-requests: write +permissions: read-all jobs: release-please: runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write outputs: pr: ${{ steps.rp.outputs.pr }} release_created: ${{ steps.rp.outputs.release_created }} steps: - name: Run release-please id: rp - uses: googleapis/release-please-action@v4 + uses: googleapis/release-please-action@16a9c90856f42705d54a6fda1823352bdc62cf38 # v4.4.0 with: config-file: release-please-config.json manifest-file: .release-please-manifest.json diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index fc455d7..3659312 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -18,18 +18,18 @@ jobs: id-token: write steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: persist-credentials: false - name: Run Scorecard - uses: ossf/scorecard-action@v2.4.3 + uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 with: results_file: results.sarif results_format: sarif publish_results: true - name: Upload SARIF - uses: github/codeql-action/upload-sarif@v3 + uses: github/codeql-action/upload-sarif@60168efe1c415ce0f5521ea06d5c2062adbeed1b # v3.28.17 with: sarif_file: results.sarif diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 56e8ae4..cdff3ce 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -39,8 +39,8 @@ jobs: outputs: security_relevant: ${{ steps.filter.outputs.security_relevant }} steps: - - uses: actions/checkout@v4 - - uses: dorny/paths-filter@v3 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 id: filter with: filters: | @@ -64,20 +64,20 @@ jobs: security-events: write steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Initialize CodeQL - uses: github/codeql-action/init@v3 + uses: github/codeql-action/init@60168efe1c415ce0f5521ea06d5c2062adbeed1b # v3.28.17 with: languages: javascript-typescript config-file: .github/codeql/codeql-config.yml queries: security-extended - name: Autobuild - uses: github/codeql-action/autobuild@v3 + uses: github/codeql-action/autobuild@60168efe1c415ce0f5521ea06d5c2062adbeed1b # v3.28.17 - name: Perform CodeQL analysis - uses: github/codeql-action/analyze@v3 + uses: github/codeql-action/analyze@60168efe1c415ce0f5521ea06d5c2062adbeed1b # v3.28.17 with: category: '/language:javascript-typescript' @@ -90,7 +90,7 @@ jobs: contents: read steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 0 @@ -109,10 +109,10 @@ jobs: if: needs.changes.outputs.security_relevant == 'true' || github.event_name == 'schedule' steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 24 cache: npm @@ -130,13 +130,13 @@ jobs: if: needs.changes.outputs.security_relevant == 'true' || github.event_name == 'schedule' steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - name: Build Docker image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 with: context: . push: false @@ -146,7 +146,7 @@ jobs: cache-to: type=gha,mode=max - name: Upload image artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: docker-image path: /tmp/scrolly-image.tar @@ -158,13 +158,13 @@ jobs: needs: [build-image] steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: sparse-checkout: .trivyignore sparse-checkout-cone-mode: false - name: Download image artifact - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: docker-image path: /tmp @@ -193,7 +193,7 @@ jobs: image: semgrep/semgrep:1.112.0 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Run Semgrep run: semgrep scan --config=auto --sarif --output=semgrep-results.sarif . @@ -202,7 +202,7 @@ jobs: - name: Upload Semgrep SARIF if: always() - uses: github/codeql-action/upload-sarif@v3 + uses: github/codeql-action/upload-sarif@60168efe1c415ce0f5521ea06d5c2062adbeed1b # v3.28.17 with: sarif_file: semgrep-results.sarif category: semgrep @@ -216,10 +216,10 @@ jobs: github.event.pull_request.head.repo.fork != true steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Download image artifact - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: docker-image path: /tmp @@ -250,7 +250,7 @@ jobs: exit 1 - name: Run OWASP ZAP baseline scan - uses: zaproxy/action-baseline@v0.14.0 + uses: zaproxy/action-baseline@7c4deb10e6261301961c86d65d54a516394f9aed # v0.14.0 with: target: http://localhost:3000 rules_file_name: .zap/rules.tsv From 90e18f8755bec417c04b8c076ef3652f6f7098c7 Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Mon, 2 Mar 2026 22:50:18 -0600 Subject: [PATCH 02/20] docs: enhance security policy with disclosure process and timelines Add structured vulnerability disclosure policy, response timeline table, and reporting guidance. Targets the Security-Policy OpenSSF Scorecard check. --- SECURITY.md | 47 +++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 41 insertions(+), 6 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index 7d3715a..0b74bc5 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -10,9 +10,42 @@ **Please do not report security vulnerabilities through public GitHub issues.** -Instead, please report them via [GitHub Security Advisories](https://github.com/312-dev/scrolly/security/advisories/new). +Instead, please report them via [GitHub Security Advisories](https://github.com/312-dev/scrolly/security/advisories/new). This ensures that sensitive vulnerability information is handled privately and responsibly. -You should receive a response within 48 hours. If the issue is confirmed, a patch will be released as soon as possible. +### What to Include + +When reporting a vulnerability, please provide: + +- A description of the vulnerability and its potential impact +- Detailed steps to reproduce the issue +- Any proof-of-concept code or screenshots +- The affected version(s) and configuration +- Your suggested fix or mitigation, if any + +## Vulnerability Disclosure Policy + +We follow a coordinated vulnerability disclosure process: + +1. **Reporter submits** a vulnerability via [GitHub Security Advisories](https://github.com/312-dev/scrolly/security/advisories/new) +2. **We acknowledge** receipt within 48 hours +3. **We investigate** and validate the report within 7 days +4. **We develop and test** a fix for confirmed vulnerabilities +5. **We release** a patch and publish a security advisory +6. **Public disclosure** occurs after the fix is released + +We ask that reporters refrain from publicly disclosing the vulnerability until a fix has been released. + +## Response Timeline + +| Action | Timeframe | +|--------|-----------| +| Acknowledgement of report | Within 48 hours | +| Initial assessment and validation | Within 7 days | +| Status update to reporter | Every 7 days until resolved | +| Patch development and release | Within 30 days for critical/high severity | +| Public disclosure | After patch release | + +If a vulnerability is accepted, we will work with the reporter to coordinate disclosure timing. If a vulnerability is declined (e.g., out of scope or not reproducible), we will explain our reasoning. ## Security Practices @@ -29,16 +62,18 @@ You should receive a response within 48 hours. If the issue is confirmed, a patc ### Infrastructure - Multi-stage Docker builds with minimal runtime image - Health check endpoint at `/api/health` -- Non-root process execution recommended +- Non-root process execution in containers ### CI/CD Security - **CodeQL SAST** — Static analysis on every PR and push +- **Semgrep** — Additional static analysis with community rules - **Gitleaks** — Secret detection across full git history - **npm audit** — Dependency vulnerability scanning -- **Trivy** — Container image scanning (MEDIUM+ severity) -- **OWASP ZAP** — Dynamic application security testing +- **Trivy** — Container image scanning (HIGH/CRITICAL severity) +- **OWASP ZAP** — Dynamic application security testing (DAST) - **OpenSSF Scorecard** — Supply chain security assessment -- **Dependabot** — Automated dependency updates +- **Dependabot** — Automated dependency updates (npm and GitHub Actions) +- **GitHub Actions pinned by SHA** — All workflow actions pinned to commit hashes ### Self-Hosting Security Checklist - [ ] Generate a strong `SESSION_SECRET` (32+ random bytes) From 61f7eb0ad81c3a63f99bb2bc4f7836178a82dd00 Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Mon, 2 Mar 2026 22:50:22 -0600 Subject: [PATCH 03/20] fix: show correct status label in viewers sheet --- src/lib/components/ViewersSheet.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/components/ViewersSheet.svelte b/src/lib/components/ViewersSheet.svelte index 7ef07b0..44f066c 100644 --- a/src/lib/components/ViewersSheet.svelte +++ b/src/lib/components/ViewersSheet.svelte @@ -111,7 +111,7 @@ class:viewed={viewer.status === 'viewed'} class:skipped={viewer.status === 'skipped'} > - Viewed + {viewer.status === 'skipped' ? 'Skipped' : 'Viewed'} {/each} From cf0776f8e79a03591a02c36f4b2d751497f36bcb Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Mon, 2 Mar 2026 23:01:22 -0600 Subject: [PATCH 04/20] fix: override vulnerable minimatch and cookie transitive deps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add npm overrides to force minimatch>=10.2.3 (fixes CVE-2026-27903 ReDoS) and cookie>=0.7.0 (fixes CVE-2024-47764 OOB chars). Dismiss esbuild alert as tolerable risk — CVE only affects its dev server which we never use. --- package-lock.json | 51 ++++++++++++++++++++++++++++------------------- package.json | 4 ++++ 2 files changed, 35 insertions(+), 20 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1e897d1..5871ebc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -218,6 +218,7 @@ "integrity": "sha512-Nt9hri7nbOo0RipAsGjIssHkpLMHHN/P7QqENywAq5TLsoYDzUyJGny8FEiD/9KJUxtGH8blGpMedilI6kK3rA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@algolia/client-common": "5.49.1", "@algolia/requester-browser-xhr": "5.49.1", @@ -2551,6 +2552,7 @@ "integrity": "sha512-iAIPEahFgDJJyvz8g0jP08KvqnM6JvdW8YfsygZ+pMeMvyM2zssWMltcsotETvjSZ82G3VlitgDtBIvpQSZrTA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", @@ -2593,6 +2595,7 @@ "integrity": "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", "deepmerge": "^4.3.1", @@ -2632,6 +2635,7 @@ "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/node": "*" } @@ -3023,6 +3027,7 @@ "integrity": "sha512-RpV6r/ij22zRRdyBPcxDeKAzH43phWVKEjL2iksqo1Vz3CuBUrgmPpPhALKiRfU7OMCmeeO9vECBMsV0hMTG8Q==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.18.0" } @@ -3109,6 +3114,7 @@ "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", @@ -3709,6 +3715,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3761,6 +3768,7 @@ "integrity": "sha512-X3Pp2aRQhg4xUC6PQtkubn5NpRKuUPQ9FPDQlx36SmpFwwH2N0/tw4c+NXV3nw3PsgeUs+BuWGP0gjz3TvENLQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@algolia/abtesting": "1.15.1", "@algolia/client-abtesting": "5.49.1", @@ -3961,6 +3969,7 @@ "integrity": "sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA==", "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" @@ -4200,6 +4209,7 @@ "integrity": "sha512-opLQzEVriiH1uUQ4Kctsd49bRoFDXGGSC4GUqj7pGyxM3RehRhvTlZJc1FL/Flew2p5uwxa1tUDWKzI4wNM8pg==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@chevrotain/cst-dts-gen": "11.1.2", "@chevrotain/gast": "11.1.2", @@ -4518,13 +4528,17 @@ } }, "node_modules/cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", "dev": true, "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/copy-anything": { @@ -4672,6 +4686,7 @@ "integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10" } @@ -5116,6 +5131,7 @@ "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "dev": true, "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -5795,6 +5811,7 @@ "integrity": "sha512-uYixubwmqJZH+KLVYIVKY1JQt7tysXhtj21WSvjcSmU5SVNzMus1bgLe+pAt816yQ8opKfheVVoPLqvVMGejYw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", @@ -5914,22 +5931,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/eslint-plugin-sonarjs/node_modules/minimatch": { - "version": "10.2.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.1.tgz", - "integrity": "sha512-MClCe8IL5nRRmawL6ib/eT4oLyeKMGCghibcDWK+J0hh0Q8kqSdia6BvbRMVk6mPa6WqUa5uR2oxt6C5jd533A==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.2" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/eslint-plugin-svelte": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-3.15.0.tgz", @@ -6267,6 +6268,7 @@ "integrity": "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "tabbable": "^6.4.0" } @@ -8211,6 +8213,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -8382,6 +8385,7 @@ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -8691,6 +8695,7 @@ "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -9314,6 +9319,7 @@ "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.53.6.tgz", "integrity": "sha512-lP5DGF3oDDI9fhHcSpaBiJEkFLuS16h92DhM1L5K1lFm0WjOmUh1i2sNkBBk8rkxJRpob0dBE75jRfUzGZUOGA==", "license": "MIT", + "peer": true, "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", @@ -9654,6 +9660,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9838,6 +9845,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -10866,6 +10874,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -10926,6 +10935,7 @@ "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", @@ -11059,6 +11069,7 @@ "integrity": "sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.29", "@vue/compiler-sfc": "3.5.29", diff --git a/package.json b/package.json index a70f1f8..2c1da0a 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,10 @@ "uuid": "^13.0.0", "web-push": "^3.6.7" }, + "overrides": { + "minimatch": ">=10.2.3", + "cookie": ">=0.7.0" + }, "devDependencies": { "@commitlint/cli": "^20.4.2", "@commitlint/config-conventional": "^20.4.2", From cbf85e20a8e08d766cce16a2e6368b47b2d23b77 Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Mon, 2 Mar 2026 23:35:22 -0600 Subject: [PATCH 05/20] chore: extract shared safeTimeout utility Centralizes the safeTimeout pattern (auto-tracked setTimeout with cleanup) into $lib/safeTimeout.ts. Components that previously defined this inline can now import createSafeTimeout instead. --- src/lib/safeTimeout.ts | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 src/lib/safeTimeout.ts diff --git a/src/lib/safeTimeout.ts b/src/lib/safeTimeout.ts new file mode 100644 index 0000000..295f290 --- /dev/null +++ b/src/lib/safeTimeout.ts @@ -0,0 +1,27 @@ +/** + * Creates a managed timeout utility that tracks all pending timeouts + * and can clear them on component teardown (via onDestroy). + * + * Usage: + * const { safeTimeout, clearAll } = createSafeTimeout(); + * safeTimeout(() => doSomething(), 300); + * onDestroy(clearAll); + */ +export function createSafeTimeout(): { + safeTimeout: (fn: () => void, ms: number) => ReturnType; + clearAll: () => void; +} { + const timers: ReturnType[] = []; + + function safeTimeout(fn: () => void, ms: number): ReturnType { + const id = setTimeout(fn, ms); + timers.push(id); + return id; + } + + function clearAll(): void { + timers.forEach(clearTimeout); + } + + return { safeTimeout, clearAll }; +} From 105d8857851ae2658d309c67ad1bcc58f52f56ed Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Mon, 2 Mar 2026 23:35:33 -0600 Subject: [PATCH 06/20] refactor: remove playback speed functionality Remove SpeedPill component, playbackSpeed store, and all playbackRate props from ReelVideo, ReelMusic, and ReelIndicators. Feature was broken and unused. --- src/lib/components/ReelIndicators.svelte | 8 --- src/lib/components/ReelMusic.svelte | 9 --- src/lib/components/ReelVideo.svelte | 9 --- src/lib/components/SpeedPill.svelte | 78 ------------------------ src/lib/stores/playbackSpeed.ts | 34 ----------- 5 files changed, 138 deletions(-) delete mode 100644 src/lib/components/SpeedPill.svelte delete mode 100644 src/lib/stores/playbackSpeed.ts diff --git a/src/lib/components/ReelIndicators.svelte b/src/lib/components/ReelIndicators.svelte index 03a53be..482131a 100644 --- a/src/lib/components/ReelIndicators.svelte +++ b/src/lib/components/ReelIndicators.svelte @@ -5,15 +5,11 @@ import PauseIcon from 'phosphor-svelte/lib/PauseIcon'; const { - showSpeedIndicator, - speed, showMuteIndicator, muted, showPlayIndicator, paused }: { - showSpeedIndicator: boolean; - speed: number; showMuteIndicator: boolean; muted: boolean; showPlayIndicator: boolean; @@ -21,10 +17,6 @@ } = $props(); -{#if showSpeedIndicator} -
{speed}x
-{/if} - {#if showMuteIndicator}
{#if muted} diff --git a/src/lib/components/ReelMusic.svelte b/src/lib/components/ReelMusic.svelte index 5764af0..c94f516 100644 --- a/src/lib/components/ReelMusic.svelte +++ b/src/lib/components/ReelMusic.svelte @@ -8,7 +8,6 @@ muted, autoScroll, forceLoop = false, - playbackRate = 1, onretry, onended, audioEl = $bindable(null) @@ -27,7 +26,6 @@ muted: boolean; autoScroll: boolean; forceLoop?: boolean; - playbackRate?: number; onretry: (id: string) => void; onended: () => void; audioEl: HTMLAudioElement | null; @@ -58,13 +56,6 @@ audioEl.muted = muted; } }); - - // Sync playback rate - $effect(() => { - if (audioEl) { - audioEl.playbackRate = playbackRate; - } - });
diff --git a/src/lib/components/ReelVideo.svelte b/src/lib/components/ReelVideo.svelte index fe45fc3..e6a5878 100644 --- a/src/lib/components/ReelVideo.svelte +++ b/src/lib/components/ReelVideo.svelte @@ -8,7 +8,6 @@ muted, autoScroll, forceLoop = false, - playbackRate = 1, onretry, onended, videoEl = $bindable(null) @@ -24,7 +23,6 @@ muted: boolean; autoScroll: boolean; forceLoop?: boolean; - playbackRate?: number; onretry: (id: string) => void; onended: () => void; videoEl: HTMLVideoElement | null; @@ -60,13 +58,6 @@ videoEl.muted = muted; } }); - - // Sync playback rate - $effect(() => { - if (videoEl) { - videoEl.playbackRate = playbackRate; - } - }); {#if clip.status === 'ready' && clip.videoPath} diff --git a/src/lib/components/SpeedPill.svelte b/src/lib/components/SpeedPill.svelte deleted file mode 100644 index 1593c58..0000000 --- a/src/lib/components/SpeedPill.svelte +++ /dev/null @@ -1,78 +0,0 @@ - - - - - diff --git a/src/lib/stores/playbackSpeed.ts b/src/lib/stores/playbackSpeed.ts deleted file mode 100644 index 207fd1f..0000000 --- a/src/lib/stores/playbackSpeed.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { writable } from 'svelte/store'; - -/** Speeds in "tap to cycle" order starting from 1x */ -const SPEED_CYCLE = [1, 1.5, 2, 0.5, 0.75] as const; - -/** Speeds in ascending order for keyboard step up/down */ -const SPEED_LADDER = [0.5, 0.75, 1, 1.5, 2] as const; - -/** Global playback speed — shared across all reels (video + music). */ -export const globalPlaybackSpeed = writable(1); - -/** Cycle to next speed in tap order (1→1.5→2→0.5→0.75→1). */ -export function cycleSpeed(): void { - globalPlaybackSpeed.update((current) => { - const idx = SPEED_CYCLE.indexOf(current as (typeof SPEED_CYCLE)[number]); - return SPEED_CYCLE[(idx + 1) % SPEED_CYCLE.length]; - }); -} - -/** Step up to the next higher speed. */ -export function stepSpeedUp(): void { - globalPlaybackSpeed.update((current) => { - const idx = SPEED_LADDER.indexOf(current as (typeof SPEED_LADDER)[number]); - return idx < SPEED_LADDER.length - 1 ? SPEED_LADDER[idx + 1] : current; - }); -} - -/** Step down to the next lower speed. */ -export function stepSpeedDown(): void { - globalPlaybackSpeed.update((current) => { - const idx = SPEED_LADDER.indexOf(current as (typeof SPEED_LADDER)[number]); - return idx > 0 ? SPEED_LADDER[idx - 1] : current; - }); -} From 7eb4117e970b02791ebafe16059624349d3d2f9c Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Mon, 2 Mar 2026 23:35:46 -0600 Subject: [PATCH 07/20] fix: security hardening and auth improvements - Add Secure flag to session, theme, and accent cookies - Add length guard before timingSafeEqual to prevent crash - Block pre-onboarding API access (empty username check) - Add username validation (length, format, group-scoped uniqueness) - Optimize getUserWithGroup to single JOIN query - Remove unused getUser export --- src/hooks.server.ts | 13 ++++++--- src/lib/server/__tests__/auth.test.ts | 15 +---------- src/lib/server/auth.ts | 30 +++++++++------------ src/routes/api/auth/+server.ts | 39 +++++++++++++++++++++++++-- 4 files changed, 61 insertions(+), 36 deletions(-) diff --git a/src/hooks.server.ts b/src/hooks.server.ts index c46ca98..3eadb8f 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -71,7 +71,7 @@ function setThemeCookies(event: RequestEvent, response: Response): void { if (event.locals.user?.themePreference && !cookies.includes('scrolly_theme=')) { response.headers.append( 'Set-Cookie', - `scrolly_theme=${event.locals.user.themePreference};Path=/;Max-Age=31536000;SameSite=Lax` + `scrolly_theme=${event.locals.user.themePreference};Path=/;Max-Age=31536000;SameSite=Lax;Secure` ); } @@ -80,7 +80,7 @@ function setThemeCookies(event: RequestEvent, response: Response): void { const accentValue = encodeURIComponent(JSON.stringify({ hex: accent.hex, dark: accent.dark })); response.headers.append( 'Set-Cookie', - `scrolly_accent=${accentValue};Path=/;Max-Age=31536000;SameSite=Lax` + `scrolly_accent=${accentValue};Path=/;Max-Age=31536000;SameSite=Lax;Secure` ); } } @@ -98,7 +98,14 @@ export const handle: Handle = async ({ event, resolve }) => { if (userId) { const data = await getUserWithGroup(userId); if (data && !data.user.removedAt) { - event.locals.user = data.user; + // Only expose authenticated user to routes if onboarding is complete. + // Users with empty username haven't finished onboarding and should not + // have access to group data via API routes. The /api/auth onboarding + // actions read the cookie directly via getUserIdFromCookies, so they + // still work without locals.user. + if (data.user.username) { + event.locals.user = data.user; + } event.locals.group = data.group; } } diff --git a/src/lib/server/__tests__/auth.test.ts b/src/lib/server/__tests__/auth.test.ts index 77ab850..77029a9 100644 --- a/src/lib/server/__tests__/auth.test.ts +++ b/src/lib/server/__tests__/auth.test.ts @@ -11,7 +11,7 @@ const { db } = await import('$lib/server/db'); const data = await seed(db as any); // Import after mocks -const { createSessionCookie, getUserIdFromCookies, getUser, getUserWithGroup, validateInviteCode } = +const { createSessionCookie, getUserIdFromCookies, getUserWithGroup, validateInviteCode } = await import('../auth'); describe('createSessionCookie', () => { @@ -72,19 +72,6 @@ describe('getUserIdFromCookies', () => { }); }); -describe('getUser', () => { - it('returns user when exists', async () => { - const user = await getUser(data.host.id); - expect(user).not.toBeNull(); - expect(user!.username).toBe('hostuser'); - }); - - it('returns null when not found', async () => { - const user = await getUser('nonexistent-id'); - expect(user).toBeNull(); - }); -}); - describe('getUserWithGroup', () => { it('returns user and group when both exist', async () => { const result = await getUserWithGroup(data.host.id); diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts index 13cbe04..5173584 100644 --- a/src/lib/server/auth.ts +++ b/src/lib/server/auth.ts @@ -24,13 +24,16 @@ function verify(token: string): string | null { if (lastDot === -1) return null; const payload = token.substring(0, lastDot); const expected = sign(payload); - if (!crypto.timingSafeEqual(Buffer.from(token), Buffer.from(expected))) return null; + const tokenBuf = Buffer.from(token); + const expectedBuf = Buffer.from(expected); + if (tokenBuf.length !== expectedBuf.length) return null; + if (!crypto.timingSafeEqual(tokenBuf, expectedBuf)) return null; return payload; } export function createSessionCookie(userId: string): string { const token = sign(userId); - return `${COOKIE_NAME}=${token}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${MAX_AGE}`; + return `${COOKIE_NAME}=${token}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=${MAX_AGE}`; } export function getUserIdFromCookies(cookieHeader: string | null): string | null { @@ -47,22 +50,15 @@ export function getUserIdFromCookies(cookieHeader: string | null): string | null return verify(token); } -export async function getUser(userId: string) { - const result = await db.query.users.findFirst({ - where: eq(users.id, userId) - }); - return result ?? null; -} - export async function getUserWithGroup(userId: string) { - const user = await db.query.users.findFirst({ - where: eq(users.id, userId) - }); - if (!user) return null; - const group = await db.query.groups.findFirst({ - where: eq(groups.id, user.groupId) - }); - return { user, group: group ?? null }; + const row = db + .select({ user: users, group: groups }) + .from(users) + .innerJoin(groups, eq(users.groupId, groups.id)) + .where(eq(users.id, userId)) + .get(); + if (!row) return null; + return { user: row.user, group: row.group }; } export async function validateInviteCode(code: string) { diff --git a/src/routes/api/auth/+server.ts b/src/routes/api/auth/+server.ts index 9be5936..9dddf4b 100644 --- a/src/routes/api/auth/+server.ts +++ b/src/routes/api/auth/+server.ts @@ -9,7 +9,7 @@ import { import { db } from '$lib/server/db'; import { users, groups, notificationPreferences, verificationCodes } from '$lib/server/db/schema'; import { v4 as uuid } from 'uuid'; -import { eq, and, desc } from 'drizzle-orm'; +import { eq, and, desc, ne, isNull } from 'drizzle-orm'; import { sendVerification, checkVerification } from '$lib/server/sms/verify'; import { dev } from '$app/environment'; import { createLogger } from '$lib/server/logger'; @@ -93,11 +93,46 @@ async function handleVerifyCode(userId: string, body: Record) { } async function handleOnboard(userId: string, body: Record) { - const { username, phone } = body; + const { phone } = body; + const username = typeof body.username === 'string' ? body.username.trim() : ''; if (!username || !phone) { return json({ error: 'Username and phone number required' }, { status: 400 }); } + // Validate username length (1-30 characters) + if (username.length < 1 || username.length > 30) { + return json({ error: 'Username must be 1–30 characters' }, { status: 400 }); + } + + // Validate username format (alphanumeric, underscores, hyphens, periods) + if (!/^[a-zA-Z0-9._-]+$/.test(username)) { + return json( + { error: 'Username can only contain letters, numbers, underscores, hyphens, and periods' }, + { status: 400 } + ); + } + + // Get current user to find their group + const currentUser = await db.query.users.findFirst({ + where: eq(users.id, userId) + }); + if (!currentUser) { + return json({ error: 'User not found' }, { status: 404 }); + } + + // Check username uniqueness within the group (excluding current user, excluding removed members) + const existingUser = await db.query.users.findFirst({ + where: and( + eq(users.groupId, currentUser.groupId), + eq(users.username, username), + ne(users.id, userId), + isNull(users.removedAt) + ) + }); + if (existingUser) { + return json({ error: 'Username is already taken in this group' }, { status: 409 }); + } + if (!/^\+[1-9]\d{1,14}$/.test(phone)) { return json({ error: 'Phone must be in E.164 format (e.g., +1234567890)' }, { status: 400 }); } From 1b139f50e1929ef13fb7d63d8a54352250eebe10 Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Mon, 2 Mar 2026 23:35:55 -0600 Subject: [PATCH 08/20] refactor: narrow type literals and migrate $app/stores - Add Platform, ClipStatus, ContentType literal unions to types.ts - Apply literal types to FeedClip and ClipSummary interfaces - Migrate AddVideo, share, and share/setup from $app/stores to $app/state --- src/lib/components/AddVideo.svelte | 10 ++++---- src/lib/types.ts | 36 ++++++++++++++++++++++++----- src/routes/share/+page.svelte | 10 ++++---- src/routes/share/setup/+page.svelte | 8 +++---- 4 files changed, 44 insertions(+), 20 deletions(-) diff --git a/src/lib/components/AddVideo.svelte b/src/lib/components/AddVideo.svelte index aa1ed33..dc134c5 100644 --- a/src/lib/components/AddVideo.svelte +++ b/src/lib/components/AddVideo.svelte @@ -9,7 +9,7 @@ } from '$lib/url-validation'; import { addToast } from '$lib/stores/toasts'; import { showShortcutNudge } from '$lib/stores/shortcutNudge'; - import { page } from '$app/stores'; + import { page } from '$app/state'; import type { GroupMember } from '$lib/types'; import DownloadSimpleIcon from 'phosphor-svelte/lib/DownloadSimpleIcon'; import ClipboardIcon from 'phosphor-svelte/lib/ClipboardIcon'; @@ -27,11 +27,11 @@ members?: GroupMember[]; } = $props(); - const hasProvider = $derived(!!$page.data.group?.downloadProvider); - const platformFilterMode = $derived(($page.data.group?.platformFilterMode as string) ?? 'all'); + const hasProvider = $derived(!!page.data.group?.downloadProvider); + const platformFilterMode = $derived((page.data.group?.platformFilterMode as string) ?? 'all'); const platformFilterList = $derived( - $page.data.group?.platformFilterList - ? JSON.parse($page.data.group.platformFilterList as string) + page.data.group?.platformFilterList + ? JSON.parse(page.data.group.platformFilterList as string) : null ); let url = $state(''); diff --git a/src/lib/types.ts b/src/lib/types.ts index a01a814..f6a3f36 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1,3 +1,27 @@ +export type ClipStatus = 'downloading' | 'ready' | 'failed' | 'deleted'; +export type ContentType = 'video' | 'music'; +export type Platform = + | 'tiktok' + | 'instagram' + | 'facebook' + | 'youtube' + | 'twitter' + | 'reddit' + | 'streamable' + | 'twitch' + | 'vimeo' + | 'threads' + | 'bluesky' + | 'snapchat' + | 'pinterest' + | 'kick' + | 'dailymotion' + | 'imgur' + | 'soundcloud' + | 'spotify' + | 'apple_music' + | 'youtube_music'; + export interface FeedClip { id: string; originalUrl: string; @@ -13,11 +37,11 @@ export interface FeedClip { addedBy: string; addedByUsername: string; addedByAvatar: string | null; - platform: string; + platform: Platform; creatorName: string | null; creatorUrl: string | null; - status: string; - contentType: string; + status: ClipStatus; + contentType: ContentType; durationSeconds: number | null; watched: boolean; favorited: boolean; @@ -39,12 +63,12 @@ export interface GroupMember { export interface ClipSummary { id: string; title: string | null; - platform: string; - contentType: string; + platform: Platform; + contentType: ContentType; addedBy: string; addedByUsername: string; createdAt: string; sizeMb: number; thumbnailPath: string | null; - status: string; + status: ClipStatus; } diff --git a/src/routes/share/+page.svelte b/src/routes/share/+page.svelte index e6be64e..0794c3b 100644 --- a/src/routes/share/+page.svelte +++ b/src/routes/share/+page.svelte @@ -1,5 +1,5 @@
- +
+ + +

{description}

@@ -56,6 +80,10 @@ gap: var(--space-sm); } + .select-wrapper { + position: relative; + } + select { width: 100%; padding: var(--space-md) var(--space-lg); @@ -68,12 +96,18 @@ cursor: pointer; transition: border-color 0.2s ease; appearance: none; - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%23999' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E"); - background-repeat: no-repeat; - background-position: right var(--space-md) center; padding-right: 2.5rem; } + .select-chevron { + position: absolute; + right: var(--space-md); + top: 50%; + transform: translateY(-50%); + color: var(--text-secondary); + pointer-events: none; + } + select:focus { outline: none; border-color: var(--accent-primary); diff --git a/src/lib/components/settings/SettingRow.svelte b/src/lib/components/settings/SettingRow.svelte new file mode 100644 index 0000000..f292469 --- /dev/null +++ b/src/lib/components/settings/SettingRow.svelte @@ -0,0 +1,74 @@ + + +
+
+ {#if name} + {name} + {/if} + {#if description} + {description} + {/if} +
+ {#if children} + {@render children()} + {/if} +
+ + diff --git a/src/lib/components/settings/Toggle.svelte b/src/lib/components/settings/Toggle.svelte new file mode 100644 index 0000000..af26054 --- /dev/null +++ b/src/lib/components/settings/Toggle.svelte @@ -0,0 +1,57 @@ + + + + + diff --git a/src/routes/(app)/settings/+page.svelte b/src/routes/(app)/settings/+page.svelte index cf328c8..3d4392d 100644 --- a/src/routes/(app)/settings/+page.svelte +++ b/src/routes/(app)/settings/+page.svelte @@ -40,6 +40,8 @@ import GettingStartedChecklist from '$lib/components/settings/GettingStartedChecklist.svelte'; import UsernameEdit from '$lib/components/settings/UsernameEdit.svelte'; import AvatarCropModal from '$lib/components/AvatarCropModal.svelte'; + import Toggle from '$lib/components/settings/Toggle.svelte'; + import SettingRow from '$lib/components/settings/SettingRow.svelte'; import ShortcutGuideSheet from '$lib/components/ShortcutGuideSheet.svelte'; import AppleLogoIcon from 'phosphor-svelte/lib/AppleLogoIcon'; import AndroidLogoIcon from 'phosphor-svelte/lib/AndroidLogoIcon'; @@ -190,6 +192,14 @@ } const checklistMemberCount = $derived($groupMembers.length); + const parsedPlatformFilterList = $derived.by(() => { + if (!group?.platformFilterList) return null; + try { + return JSON.parse(group.platformFilterList); + } catch { + return null; + } + }); function scrollToSection(sectionId: string) { const el = document.getElementById(sectionId); @@ -274,45 +284,27 @@

Playback

-
-
- Start muted - Mute videos and songs by default -
- -
-
-
- Auto-scroll - Advance to next clip when current one ends -
- -
+ label="Toggle start muted" + /> + + + +

Feed Order

-
-
- Choose how clips are sorted in your feed -
-
+
@@ -414,9 +406,7 @@
@@ -687,42 +677,6 @@ color: var(--bg-primary); } - .setting-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--space-md); - padding: var(--space-sm) 0; - border-bottom: 1px solid var(--bg-surface); - } - - .setting-row:first-child { - padding-top: 0; - } - - .setting-row:last-child { - border-bottom: none; - padding-bottom: 0; - } - - .setting-label { - display: flex; - flex-direction: column; - gap: 1px; - min-width: 0; - } - - .setting-name { - font-size: 0.875rem; - font-weight: 500; - color: var(--text-primary); - } - - .setting-desc { - font-size: 0.75rem; - color: var(--text-muted); - } - .share-cta { display: flex; align-items: center; @@ -785,39 +739,6 @@ border-radius: var(--radius-full); } - .toggle { - position: relative; - width: 44px; - height: 26px; - border-radius: 13px; - border: none; - background: var(--border); - cursor: pointer; - flex-shrink: 0; - transition: background 0.2s; - padding: 0; - } - - .toggle.active { - background: var(--accent-primary); - } - - .toggle-thumb { - position: absolute; - top: 2px; - left: 2px; - width: 22px; - height: 22px; - border-radius: var(--radius-full); - background: var(--constant-white); - transition: transform 200ms cubic-bezier(0.34, 1.56, 0.64, 1); - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); - } - - .toggle.active .toggle-thumb { - transform: translateX(18px); - } - .version-footer { text-align: center; color: var(--text-muted); From 00036909d204f60d4f7e81d9d6ce6ea28e11d82c Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Mon, 2 Mar 2026 23:37:34 -0600 Subject: [PATCH 13/20] refactor: modernize sheet and overlay components - Replace drag handle with close button in BaseSheet - Add close buttons to AddVideoModal and ShortcutGuideSheet - Migrate all sheets to shared safeTimeout utility - Wire openSheet/closeSheet in ActivitySheet and ViewersSheet - Remove duplicate header markup from ActivitySheet and ViewersSheet - Remove empty onkeydown handlers from overlay divs - Remove sheet-hidden class from bottom tabs (handled by z-index) - Remove caption editing from ClipOverlay - Fix ShortcutGuideSheet hardcoded color and spelling --- src/lib/components/ActivitySheet.svelte | 74 ++++------------- src/lib/components/AddVideoModal.svelte | 46 ++++++++--- src/lib/components/BaseSheet.svelte | 68 +++++++--------- src/lib/components/ClipOverlay.svelte | 6 -- src/lib/components/CommentsSheet.svelte | 11 +-- src/lib/components/ShortcutGuideSheet.svelte | 40 +++++++-- src/lib/components/ViewersSheet.svelte | 85 +++++--------------- src/routes/(app)/+layout.svelte | 10 +-- 8 files changed, 137 insertions(+), 203 deletions(-) diff --git a/src/lib/components/ActivitySheet.svelte b/src/lib/components/ActivitySheet.svelte index 81cd236..8f9e3e1 100644 --- a/src/lib/components/ActivitySheet.svelte +++ b/src/lib/components/ActivitySheet.svelte @@ -1,10 +1,12 @@ -
{}} role="presentation">
+
-
- Activity - -
-
{#if loading}
@@ -256,55 +253,12 @@ opacity: 1; } - .header { - display: flex; - align-items: center; - justify-content: center; - padding: var(--space-md) var(--space-lg); - border-bottom: 1px solid var(--border); - flex-shrink: 0; - position: relative; - } - - .header-title { - font-family: var(--font-display); - font-weight: 700; - font-size: 1.0625rem; - letter-spacing: -0.01em; - color: var(--text-primary); - } - - .close-btn { - position: absolute; - right: var(--space-lg); - display: flex; - align-items: center; - justify-content: center; - width: 32px; - height: 32px; - border-radius: var(--radius-full); - background: var(--bg-surface); - border: none; - color: var(--text-secondary); - cursor: pointer; - transition: background 0.2s ease; - } - - .close-btn:active { - background: var(--bg-subtle); - } - - .close-btn :global(svg) { - width: 18px; - height: 18px; - } - .content { flex: 1; overflow-y: auto; overscroll-behavior-y: contain; -webkit-overflow-scrolling: touch; - padding: 0 var(--space-sm) var(--space-lg); + padding: var(--space-md) var(--space-sm) var(--space-lg); max-width: 520px; margin: 0 auto; width: 100%; @@ -329,7 +283,7 @@ flex-direction: column; align-items: center; justify-content: center; - padding: var(--space-3xl) var(--space-lg); + padding: var(--space-xl) var(--space-lg); gap: var(--space-sm); } diff --git a/src/lib/components/AddVideoModal.svelte b/src/lib/components/AddVideoModal.svelte index cc9a991..859982b 100644 --- a/src/lib/components/AddVideoModal.svelte +++ b/src/lib/components/AddVideoModal.svelte @@ -1,9 +1,11 @@
- + {#snippet header()} {#if phase === 'form'}
Add to feed +
{/if} {/snippet} @@ -165,14 +164,37 @@ } .add-header { - padding: 0 var(--space-lg) var(--space-sm); + display: flex; + align-items: center; + justify-content: center; + padding: var(--space-md) var(--space-lg); + border-bottom: 1px solid var(--border); + position: relative; } .add-title { font-family: var(--font-display); - font-size: 1.0625rem; - font-weight: 700; + font-size: 0.9375rem; + font-weight: 500; color: var(--text-primary); } + .close-btn { + position: absolute; + right: var(--space-lg); + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: var(--radius-full); + background: var(--bg-surface); + border: none; + color: var(--text-secondary); + cursor: pointer; + transition: background 0.2s ease; + } + .close-btn:active { + background: var(--bg-subtle); + } .sheet-body { padding-bottom: max(var(--space-lg), env(safe-area-inset-bottom)); diff --git a/src/lib/components/BaseSheet.svelte b/src/lib/components/BaseSheet.svelte index 3052d05..9153685 100644 --- a/src/lib/components/BaseSheet.svelte +++ b/src/lib/components/BaseSheet.svelte @@ -3,18 +3,18 @@ import { pushState, beforeNavigate } from '$app/navigation'; import { onDestroy } from 'svelte'; import { openSheet, closeSheet } from '$lib/stores/sheetOpen'; + import { createSafeTimeout } from '$lib/safeTimeout'; + import XIcon from 'phosphor-svelte/lib/XIcon'; let { title = '', sheetId = 'sheet', - showHandle = true, ondismiss, header, children }: { title?: string; sheetId?: string; - showHandle?: boolean; ondismiss: () => void; header?: Snippet; children: Snippet; @@ -22,7 +22,7 @@ let visible = $state(false); let closedViaBack = false; - let timers: ReturnType[] = []; + const { safeTimeout, clearAll } = createSafeTimeout(); let dragZoneEl: HTMLElement | null = $state(null); let dragY = $state(0); @@ -77,12 +77,6 @@ closedViaBack = true; }); - function safeTimeout(fn: () => void, ms: number) { - const id = setTimeout(fn, ms); - timers.push(id); - return id; - } - // Animate in, lock scroll, manage history $effect(() => { openSheet(); @@ -111,7 +105,7 @@ safeTimeout(ondismiss, 300); } - onDestroy(() => timers.forEach(clearTimeout)); + onDestroy(clearAll); @@ -131,25 +125,14 @@ onpointercancel={endDrag} role="presentation" > - {#if showHandle} -
{ - if (e.key === 'Enter' || e.key === ' ') dismiss(); - }} - role="button" - tabindex="-1" - > -
-
- {/if} - {#if header} {@render header()} {:else if title}
{title} +
{/if}
@@ -194,27 +177,36 @@ touch-action: none; } - .base-handle-bar { + .base-header { display: flex; + align-items: center; justify-content: center; - padding: var(--space-md); - cursor: pointer; - } - .base-handle { - width: 36px; - height: 4px; - background: var(--bg-subtle); - border-radius: 2px; - } - - .base-header { - padding: 0 var(--space-lg) var(--space-md); + padding: var(--space-md) var(--space-lg); border-bottom: 1px solid var(--border); + position: relative; } .base-title { font-family: var(--font-display); font-size: 0.9375rem; - font-weight: 600; + font-weight: 500; color: var(--text-primary); } + .base-close-btn { + position: absolute; + right: var(--space-lg); + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: var(--radius-full); + background: var(--bg-surface); + border: none; + color: var(--text-secondary); + cursor: pointer; + transition: background 0.2s ease; + } + .base-close-btn:active { + background: var(--bg-subtle); + } diff --git a/src/lib/components/ClipOverlay.svelte b/src/lib/components/ClipOverlay.svelte index d127447..9517a09 100644 --- a/src/lib/components/ClipOverlay.svelte +++ b/src/lib/components/ClipOverlay.svelte @@ -225,10 +225,6 @@ if (ok && clip) clip = { ...clip, status: 'downloading' }; } - function handleCaptionEdit(id: string, newCaption: string) { - if (clip) clip = { ...clip, title: newCaption }; - } - function handleDelete() { handleDismiss(); } @@ -274,7 +270,6 @@ index={0} {autoScroll} {gifEnabled} - canEditCaption={clip.addedBy === currentUserId && !clip.seenByOthers} seenByOthers={clip.seenByOthers} hideViewBadge={true} onwatched={handleWatched} @@ -282,7 +277,6 @@ onreaction={handleReaction} onretry={handleRetry} onended={() => {}} - oncaptionedit={handleCaptionEdit} ondelete={handleDelete} />
diff --git a/src/lib/components/CommentsSheet.svelte b/src/lib/components/CommentsSheet.svelte index 56658af..66cc883 100644 --- a/src/lib/components/CommentsSheet.svelte +++ b/src/lib/components/CommentsSheet.svelte @@ -2,6 +2,7 @@ import { relativeTime } from '$lib/utils'; import { toast } from '$lib/stores/toasts'; import { onDestroy } from 'svelte'; + import { createSafeTimeout } from '$lib/safeTimeout'; import CommentInput from './CommentInput.svelte'; import CommentRow from './CommentRow.svelte'; import GifPicker from './GifPicker.svelte'; @@ -64,15 +65,9 @@ let heartPopoverId = $state(null); let longPressTimer: ReturnType | null = null; - let timers: ReturnType[] = []; + const { safeTimeout, clearAll } = createSafeTimeout(); - function safeTimeout(fn: () => void, ms: number) { - const id = setTimeout(fn, ms); - timers.push(id); - return id; - } - - onDestroy(() => timers.forEach(clearTimeout)); + onDestroy(clearAll); $effect(() => { loadComments(); diff --git a/src/lib/components/ShortcutGuideSheet.svelte b/src/lib/components/ShortcutGuideSheet.svelte index f7c2dd4..c8de044 100644 --- a/src/lib/components/ShortcutGuideSheet.svelte +++ b/src/lib/components/ShortcutGuideSheet.svelte @@ -4,6 +4,7 @@ import DownloadSimpleIcon from 'phosphor-svelte/lib/DownloadSimpleIcon'; import ExportIcon from 'phosphor-svelte/lib/ExportIcon'; import ListIcon from 'phosphor-svelte/lib/ListIcon'; + import XIcon from 'phosphor-svelte/lib/XIcon'; let { shortcutUrl, @@ -50,6 +51,9 @@ > {/each}
+
{/snippet} @@ -148,7 +152,7 @@

{isMac ? 'Make the shortcut easy to reach whenever you need it:' - : "Pin the shortcut to your favourites so it's always easy to reach:"} + : "Pin the shortcut to your favorites so it's always easy to reach:"}

{#if isMac} @@ -183,7 +187,7 @@ 3 Tap the green + button to add it to - FavouritesFavorites
{/if} @@ -192,7 +196,7 @@ {#if isMac} With Menu Bar enabled, you can run the shortcut from anywhere without opening Safari. {:else} - Once it's in Favourites, it'll appear right at the top of your share sheet for quick + Once it's in Favorites, it'll appear right at the top of your share sheet for quick access. {/if}
@@ -219,18 +223,40 @@ .guide-header { display: flex; align-items: center; - justify-content: space-between; - padding: 0 var(--space-lg) var(--space-md); + justify-content: center; + gap: var(--space-sm); + padding: var(--space-md) var(--space-lg); border-bottom: 1px solid var(--border); + position: relative; } .guide-title { font-family: var(--font-display); font-size: 0.9375rem; - font-weight: 700; + font-weight: 500; color: var(--text-primary); } + .close-btn { + position: absolute; + right: var(--space-lg); + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: var(--radius-full); + background: var(--bg-surface); + border: none; + color: var(--text-secondary); + cursor: pointer; + transition: background 0.2s ease; + } + + .close-btn:active { + background: var(--bg-subtle); + } + .step-dots { display: flex; gap: var(--space-sm); @@ -354,7 +380,7 @@ } .instruction .green { - color: #38a169; + color: var(--success); } .inst-num { diff --git a/src/lib/components/ViewersSheet.svelte b/src/lib/components/ViewersSheet.svelte index 44f066c..2d914a4 100644 --- a/src/lib/components/ViewersSheet.svelte +++ b/src/lib/components/ViewersSheet.svelte @@ -1,7 +1,9 @@ -
{}} role="presentation">
+
-
- Views{viewers.length > 0 ? ` (${viewers.length})` : ''} - -
-
{#if loading}
@@ -137,7 +137,7 @@ .sheet { position: fixed; top: calc(56px + env(safe-area-inset-top)); - left: var(--space-lg); + right: var(--space-lg); width: calc(100vw - 2 * var(--space-lg)); max-width: 400px; max-height: 65vh; @@ -146,7 +146,7 @@ z-index: 100; display: flex; flex-direction: column; - transform-origin: top left; + transform-origin: top right; transform: scale(0.9); opacity: 0; transition: @@ -160,55 +160,12 @@ opacity: 1; } - .header { - display: flex; - align-items: center; - justify-content: center; - padding: var(--space-md) var(--space-lg); - border-bottom: 1px solid var(--border); - flex-shrink: 0; - position: relative; - } - - .header-title { - font-family: var(--font-display); - font-weight: 700; - font-size: 1.0625rem; - letter-spacing: -0.01em; - color: var(--text-primary); - } - - .close-btn { - position: absolute; - right: var(--space-lg); - display: flex; - align-items: center; - justify-content: center; - width: 32px; - height: 32px; - border-radius: var(--radius-full); - background: var(--bg-surface); - border: none; - color: var(--text-secondary); - cursor: pointer; - transition: background 0.2s ease; - } - - .close-btn:active { - background: var(--bg-subtle); - } - - .close-btn :global(svg) { - width: 18px; - height: 18px; - } - .content { flex: 1; overflow-y: auto; overscroll-behavior-y: contain; -webkit-overflow-scrolling: touch; - padding: 0 var(--space-sm) var(--space-lg); + padding: var(--space-sm) var(--space-sm) var(--space-sm); } .viewers-empty { @@ -216,7 +173,7 @@ flex-direction: column; align-items: center; justify-content: center; - padding: var(--space-3xl) var(--space-lg); + padding: var(--space-xl) var(--space-lg); gap: var(--space-sm); } @@ -261,8 +218,8 @@ .viewer-row { display: flex; align-items: center; - gap: var(--space-md); - padding: var(--space-md) var(--space-sm); + gap: var(--space-sm); + padding: var(--space-xs) var(--space-sm); border-radius: var(--radius-sm); animation: viewer-in 250ms cubic-bezier(0.32, 0.72, 0, 1) both; } @@ -279,8 +236,8 @@ } .viewer-avatar { - width: 44px; - height: 44px; + width: 34px; + height: 34px; border-radius: var(--radius-full); overflow: hidden; flex-shrink: 0; @@ -301,7 +258,7 @@ color: var(--text-secondary); font-family: var(--font-display); font-weight: 700; - font-size: 1rem; + font-size: 0.8125rem; } .viewer-info { @@ -311,13 +268,13 @@ .viewer-name { display: block; - font-size: 0.875rem; + font-size: 0.8125rem; font-weight: 600; color: var(--text-primary); } .viewer-time { - font-size: 0.75rem; + font-size: 0.6875rem; color: var(--text-muted); } diff --git a/src/routes/(app)/+layout.svelte b/src/routes/(app)/+layout.svelte index e8e10b1..e5500d2 100644 --- a/src/routes/(app)/+layout.svelte +++ b/src/routes/(app)/+layout.svelte @@ -10,7 +10,6 @@ import { initAudioContext } from '$lib/audio/normalizer'; import { feedUiHidden } from '$lib/stores/uiHidden'; import { fetchGroupMembers } from '$lib/stores/members'; - import { anySheetOpen } from '$lib/stores/sheetOpen'; import ActivitySheet from '$lib/components/ActivitySheet.svelte'; import AddVideoModal from '$lib/components/AddVideoModal.svelte'; import BellIcon from 'phosphor-svelte/lib/BellIcon'; @@ -115,7 +114,7 @@ class:ui-hidden={$feedUiHidden} onclick={() => activitySheetOpen.set(true)} > - + {#if $unreadCount > 0} {$unreadCount > 99 ? '99+' : $unreadCount} {/if} @@ -126,7 +125,7 @@ {pageTitle} {#if !isSettings} + + diff --git a/src/lib/components/FilterBar.svelte b/src/lib/components/FilterBar.svelte index 5020547..d306220 100644 --- a/src/lib/components/FilterBar.svelte +++ b/src/lib/components/FilterBar.svelte @@ -20,7 +20,7 @@ } = $props(); const filters: FeedFilter[] = ['unwatched', 'watched']; - const labels = ['New', 'Seen']; + const labels = ['For Us', 'Replay']; const activeIndex = $derived(filters.indexOf(filter)); let containerEl: HTMLDivElement | undefined = $state(); @@ -150,7 +150,7 @@ padding: 0 5px; margin-left: 4px; background: var(--accent-magenta); - color: #fff; + color: var(--constant-white); font-family: var(--font-body); font-size: 0.6875rem; font-weight: 700; diff --git a/src/lib/components/ReelItem.svelte b/src/lib/components/ReelItem.svelte index 545e6eb..cd38b54 100644 --- a/src/lib/components/ReelItem.svelte +++ b/src/lib/components/ReelItem.svelte @@ -7,6 +7,7 @@ import { globalMuted } from '$lib/stores/mute'; import { connectNormalizer } from '$lib/audio/normalizer'; + import { fetchComments } from '$lib/commentsApi'; import { setupDesktopGestures, setupMobileGestures, @@ -32,6 +33,10 @@ import ViewBadge from './ViewBadge.svelte'; import ViewersSheet from './ViewersSheet.svelte'; import ReelIndicators from './ReelIndicators.svelte'; + import ContributorPill from './ContributorPill.svelte'; + + // Module-level: track last active contributor across all ReelItem instances + let lastActiveContributor = ''; import type { FeedClip } from '$lib/types'; @@ -42,15 +47,14 @@ index, autoScroll, gifEnabled = false, - canEditCaption = false, seenByOthers = false, hideViewBadge = false, + deferWatched = false, onwatched, onfavorited, onreaction, onretry, onended, - oncaptionedit, ondelete }: { clip: FeedClip; @@ -59,15 +63,14 @@ index: number; autoScroll: boolean; gifEnabled?: boolean; - canEditCaption?: boolean; seenByOthers?: boolean; hideViewBadge?: boolean; + deferWatched?: boolean; onwatched: (id: string) => void; onfavorited: (id: string) => void; onreaction: (clipId: string, emoji: string) => Promise; onretry: (id: string) => void; onended: () => void; - oncaptionedit?: (clipId: string, newCaption: string) => void; ondelete?: (clipId: string) => void; } = $props(); @@ -76,8 +79,6 @@ let muted = $derived($globalMuted); let showMuteIndicator = $state(false); let muteIndicatorTimer: ReturnType | null = null; - let showSpeedIndicator = $state(false); - let isDesktop = $state(false); let videoEl: HTMLVideoElement | null = $state(null); let audioEl: HTMLAudioElement | null = $state(null); @@ -111,6 +112,14 @@ let maxPercent = $state(0); let wasActive = $state(false); + // Comment previews for cycling prompt bar + let commentPreviews = $state<{ username: string; text: string }[]>([]); + let commentPreviewsLoaded = $state(false); + + // Contributor pill expand/collapse + let pillExpanded = $state(false); + let pillTimer: ReturnType | null = null; + // Auto-scroll engagement deferral let pendingAutoScroll = $state(false); let postEngagementTimer: ReturnType | null = null; @@ -176,21 +185,61 @@ if (playIndicatorTimer) clearTimeout(playIndicatorTimer); if (scrubberTimerId) clearTimeout(scrubberTimerId); if (postEngagementTimer) clearTimeout(postEngagementTimer); + if (pillTimer) clearTimeout(pillTimer); scrubSeekedCleanup?.(); - sendWatchPercent(clip.id, maxPercent); + if (!deferWatched || hasMarkedWatched) { + sendWatchPercent(clip.id, maxPercent); + } + }); + + // Contributor pill: expand when a different contributor's clip becomes active + $effect(() => { + if (!active) { + if (pillTimer) { + clearTimeout(pillTimer); + pillTimer = null; + } + pillExpanded = false; + return; + } + const contributor = clip.addedByUsername; + if (contributor !== lastActiveContributor) { + lastActiveContributor = contributor; + pillExpanded = true; + pillTimer = setTimeout(() => { + pillExpanded = false; + pillTimer = null; + }, 2500); + } else { + pillExpanded = false; + } + return () => { + if (pillTimer) { + clearTimeout(pillTimer); + pillTimer = null; + } + }; }); $effect(() => { if (active) feedUiHidden.set(uiHidden); }); $effect(() => { - if (!active || clip.watched || hasMarkedWatched) return; + if (!active || clip.watched || hasMarkedWatched || deferWatched) return; const timer = setTimeout(() => { hasMarkedWatched = true; onwatched(clip.id); }, 3000); return () => clearTimeout(timer); }); + // Deferred watch: mark watched when 50% or 10s threshold is met + $effect(() => { + if (!deferWatched || !active || clip.watched || hasMarkedWatched) return; + if ((duration > 0 && currentTime / duration >= 0.5) || currentTime >= 10) { + hasMarkedWatched = true; + onwatched(clip.id); + } + }); let hasMarkedReactionsRead = $state(false); $effect(() => { if (!active || hasMarkedReactionsRead) return; @@ -211,15 +260,38 @@ wasActive = true; } else if (wasActive) { wasActive = false; - sendWatchPercent(clip.id, maxPercent); + if (!deferWatched || hasMarkedWatched) { + sendWatchPercent(clip.id, maxPercent); + } maxPercent = 0; } }); // Send watch percent to server periodically while active $effect(() => { - if (!active) return; + if (!active || (deferWatched && !hasMarkedWatched)) return; return startPeriodicWatchUpdate(clip.id, () => maxPercent); }); + // Fetch comment previews for cycling prompt bar + $effect(() => { + if (!active || localCommentCount === 0 || commentPreviewsLoaded) return; + let cancelled = false; + const timer = setTimeout(async () => { + try { + const { comments } = await fetchComments(clip.id); + if (cancelled) return; + commentPreviews = comments + .filter((c) => !c.parentId && c.text.trim()) + .map((c) => ({ username: c.username, text: c.text })); + commentPreviewsLoaded = true; + } catch { + // silently fail — just show default text + } + }, 500); + return () => { + cancelled = true; + clearTimeout(timer); + }; + }); // Deep-link: open comments sheet when signaled (e.g., from push notification) $effect(() => { const target = $openCommentsSignal; @@ -404,8 +476,39 @@
-
- {#if !hideViewBadge && clip.viewCount > 0} + +
e.stopPropagation()} + ontouchstart={(e) => e.stopPropagation()} + ontouchmove={(e) => e.stopPropagation()} + ontouchend={(e) => e.stopPropagation()} + > + { + if (pillTimer) { + clearTimeout(pillTimer); + pillTimer = null; + } + pillExpanded = !pillExpanded; + }} + /> +
+ + {#if !hideViewBadge && clip.viewCount > 0} + +
e.stopPropagation()} + ontouchstart={(e) => e.stopPropagation()} + ontouchmove={(e) => e.stopPropagation()} + ontouchend={(e) => e.stopPropagation()} + > { @@ -413,8 +516,8 @@ showViewers = true; }} /> - {/if} -
+
+ {/if} {#if clip.contentType === 'music'} {/if} - + {#if duration > 0} {/if} - -
- -
+ -
+ +
e.stopPropagation()} + ontouchstart={(e) => e.stopPropagation()} + ontouchmove={(e) => e.stopPropagation()} + ontouchend={(e) => e.stopPropagation()} + > {#if active} { e.stopPropagation(); commentsAutoFocus = true; @@ -569,17 +667,6 @@ overflow: hidden; background: var(--bg-primary); } - .reel-content-frame { - position: absolute; - inset: 0; - max-width: 480px; - margin: 0 auto; - z-index: 5; - pointer-events: none; - } - .reel-content-frame > :global(*) { - pointer-events: auto; - } .top-left-row { position: absolute; top: max(var(--space-md), env(safe-area-inset-top)); @@ -595,6 +682,20 @@ opacity: 0; pointer-events: none; } + .top-right-row { + position: absolute; + top: max(var(--space-md), env(safe-area-inset-top)); + right: calc(var(--space-lg) + 40px + var(--space-sm)); + z-index: 6; + display: flex; + align-items: center; + min-height: 40px; + transition: opacity 0.3s ease; + } + .top-right-row.ui-hidden { + opacity: 0; + pointer-events: none; + } .bottom-row { position: absolute; bottom: 14px; diff --git a/src/lib/components/ReelOverlay.svelte b/src/lib/components/ReelOverlay.svelte index 6a728f2..6427433 100644 --- a/src/lib/components/ReelOverlay.svelte +++ b/src/lib/components/ReelOverlay.svelte @@ -1,55 +1,46 @@ -
+ +
e.stopPropagation()} + ontouchstart={(e) => e.stopPropagation()} + ontouchmove={(e) => e.stopPropagation()} + ontouchend={(e) => e.stopPropagation()} +>
@@ -103,7 +89,7 @@