From 39f563968b35e9454c5ca929e573eae2743e498e Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Mon, 2 Mar 2026 21:40:17 -0600 Subject: [PATCH 1/8] chore(ci): improve workflow efficiency and automation Skip heavy CI for non-code PRs while keeping required status check, auto-approve+merge dependabot PRs, auto-merge release-please PRs, add conventional commit prefixes for dependabot, and scope CodeQL to security-relevant changes only. --- .github/dependabot.yml | 4 +++ .github/workflows/ci.yml | 34 +++++++++++++++++++-- .github/workflows/dependabot-auto-merge.yml | 8 +++-- .github/workflows/release.yml | 9 ++++++ .github/workflows/security.yml | 7 ++--- 5 files changed, 53 insertions(+), 9 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 18dd29e..d976318 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -7,6 +7,8 @@ updates: day: monday open-pull-requests-limit: 10 labels: [dependencies] + commit-message: + prefix: "chore(deps)" groups: minor-and-patch: update-types: [minor, patch] @@ -18,3 +20,5 @@ updates: day: monday open-pull-requests-limit: 5 labels: [dependencies, ci] + commit-message: + prefix: "ci(deps)" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c697c12..7c5ebec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,8 @@ on: paths-ignore: ['docs/**', '*.md'] pull_request: branches: [main] - paths-ignore: ['docs/**', '*.md'] + # No paths-ignore: always trigger so the required "ci" check is posted. + # The changes job below skips heavy work for non-code PRs. workflow_dispatch: concurrency: @@ -21,7 +22,30 @@ concurrency: permissions: read-all jobs: + changes: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 2 + outputs: + code: ${{ steps.filter.outputs.code }} + steps: + - uses: actions/checkout@v4 + - uses: dorny/paths-filter@v3 + id: filter + with: + filters: | + code: + - 'src/**' + - 'static/**' + - 'package*.json' + - '*.config.js' + - '*.config.ts' + - 'tsconfig.json' + - 'Dockerfile' + lint-and-check: + needs: changes + if: github.event_name != 'pull_request' || needs.changes.outputs.code == 'true' runs-on: ubuntu-latest timeout-minutes: 10 @@ -41,7 +65,7 @@ jobs: run: npm ci - name: Validate commit messages - if: github.event_name == 'pull_request' + if: github.event_name == 'pull_request' && github.actor != 'dependabot[bot]' run: npx commitlint --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }} --verbose - name: Lint (ratcheted) @@ -70,10 +94,14 @@ jobs: ci: runs-on: ubuntu-latest if: always() - needs: [lint-and-check] + needs: [changes, lint-and-check] steps: - name: Check CI status run: | + if [[ "${{ github.event_name }}" == "pull_request" && "${{ needs.changes.outputs.code }}" != "true" ]]; then + echo "No code changes detected, CI auto-passed" + exit 0 + fi if [[ "${{ needs.lint-and-check.result }}" == "failure" || "${{ needs.lint-and-check.result }}" == "cancelled" ]]; then echo "CI failed" exit 1 diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml index a2a7d81..7d81309 100644 --- a/.github/workflows/dependabot-auto-merge.yml +++ b/.github/workflows/dependabot-auto-merge.yml @@ -4,6 +4,8 @@ on: pull_request: paths: - 'package*.json' + - '.github/workflows/**' + - '.github/actions/**' permissions: contents: write @@ -20,9 +22,11 @@ jobs: with: github-token: ${{ secrets.GITHUB_TOKEN }} - - name: Auto-merge patch and minor updates + - name: Approve and auto-merge patch and minor updates if: steps.metadata.outputs.update-type != 'version-update:semver-major' - run: gh pr merge --auto --squash "$PR_URL" + run: | + gh pr review --approve "$PR_URL" + gh pr merge --auto --squash "$PR_URL" env: PR_URL: ${{ github.event.pull_request.html_url }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 822a05d..63c40cb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,3 +27,12 @@ jobs: with: config-file: release-please-config.json manifest-file: .release-please-manifest.json + + - name: Auto-merge release PR + if: steps.rp.outputs.pr + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + PR_NUM="${{ steps.rp.outputs.pr }}" + echo "Enabling auto-merge for release PR #$PR_NUM" + gh pr merge "$PR_NUM" --auto --squash --repo "${{ github.repository }}" diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 2bd9750..e47a68b 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -55,12 +55,11 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 needs: [changes] - # Always run on PRs (branch protection requires CodeQL results even for - # non-code changes like workflow YAML). Skip only on push/schedule when - # no security-relevant files changed. + # Only run when security-relevant files changed (not a required check — + # branch protection only requires "security-status" which handles skips). if: | !inputs.skip_codeql && - (github.event_name == 'pull_request' || needs.changes.outputs.security_relevant == 'true' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') + (needs.changes.outputs.security_relevant == 'true' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') permissions: security-events: write steps: From 75db527a632efc292ce7343747a413aa68f356d4 Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Mon, 2 Mar 2026 21:40:28 -0600 Subject: [PATCH 2/8] feat: extract and store original content creator metadata Add creatorName and creatorUrl columns to clips table, extract from yt-dlp info (uploader/channel), persist through download pipeline, and return in clip API responses. --- .../server/db/migrations/0022_cute_garia.sql | 2 + .../db/migrations/meta/0022_snapshot.json | 1233 +++++++++++++++++ .../server/db/migrations/meta/_journal.json | 7 + src/lib/server/db/schema.ts | 2 + src/lib/server/providers/types.ts | 2 + src/lib/server/providers/ytdlp/index.ts | 10 +- src/lib/server/video/download.ts | 8 +- src/lib/types.ts | 2 + src/routes/api/clips/[id]/+server.ts | 2 + 9 files changed, 1265 insertions(+), 3 deletions(-) create mode 100644 src/lib/server/db/migrations/0022_cute_garia.sql create mode 100644 src/lib/server/db/migrations/meta/0022_snapshot.json diff --git a/src/lib/server/db/migrations/0022_cute_garia.sql b/src/lib/server/db/migrations/0022_cute_garia.sql new file mode 100644 index 0000000..bf04bed --- /dev/null +++ b/src/lib/server/db/migrations/0022_cute_garia.sql @@ -0,0 +1,2 @@ +ALTER TABLE `clips` ADD `creator_name` text;--> statement-breakpoint +ALTER TABLE `clips` ADD `creator_url` text; \ No newline at end of file diff --git a/src/lib/server/db/migrations/meta/0022_snapshot.json b/src/lib/server/db/migrations/meta/0022_snapshot.json new file mode 100644 index 0000000..c1ff54f --- /dev/null +++ b/src/lib/server/db/migrations/meta/0022_snapshot.json @@ -0,0 +1,1233 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "78a942d9-5c77-4032-ac58-a53a5bc9438c", + "prevId": "ad304cb3-15cb-4a07-8380-0390f33d1389", + "tables": { + "clips": { + "name": "clips", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "original_url": { + "name": "original_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "video_path": { + "name": "video_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "thumbnail_path": { + "name": "thumbnail_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration_seconds": { + "name": "duration_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'downloading'" + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'video'" + }, + "audio_path": { + "name": "audio_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "artist": { + "name": "artist", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "album_art": { + "name": "album_art", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "spotify_url": { + "name": "spotify_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "apple_music_url": { + "name": "apple_music_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "youtube_music_url": { + "name": "youtube_music_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "file_size_bytes": { + "name": "file_size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "creator_name": { + "name": "creator_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "creator_url": { + "name": "creator_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "clips_group_url": { + "name": "clips_group_url", + "columns": [ + "group_id", + "original_url" + ], + "isUnique": true + } + }, + "foreignKeys": { + "clips_group_id_groups_id_fk": { + "name": "clips_group_id_groups_id_fk", + "tableFrom": "clips", + "tableTo": "groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "clips_added_by_users_id_fk": { + "name": "clips_added_by_users_id_fk", + "tableFrom": "clips", + "tableTo": "users", + "columnsFrom": [ + "added_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "comment_hearts": { + "name": "comment_hearts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "comment_id": { + "name": "comment_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "comment_hearts_unique": { + "name": "comment_hearts_unique", + "columns": [ + "comment_id", + "user_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "comment_hearts_comment_id_comments_id_fk": { + "name": "comment_hearts_comment_id_comments_id_fk", + "tableFrom": "comment_hearts", + "tableTo": "comments", + "columnsFrom": [ + "comment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "comment_hearts_user_id_users_id_fk": { + "name": "comment_hearts_user_id_users_id_fk", + "tableFrom": "comment_hearts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "comment_views": { + "name": "comment_views", + "columns": { + "clip_id": { + "name": "clip_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "viewed_at": { + "name": "viewed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "comment_views_unique": { + "name": "comment_views_unique", + "columns": [ + "clip_id", + "user_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "comment_views_clip_id_clips_id_fk": { + "name": "comment_views_clip_id_clips_id_fk", + "tableFrom": "comment_views", + "tableTo": "clips", + "columnsFrom": [ + "clip_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "comment_views_user_id_users_id_fk": { + "name": "comment_views_user_id_users_id_fk", + "tableFrom": "comment_views", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "comments": { + "name": "comments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "clip_id": { + "name": "clip_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "gif_url": { + "name": "gif_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "comments_clip_id_clips_id_fk": { + "name": "comments_clip_id_clips_id_fk", + "tableFrom": "comments", + "tableTo": "clips", + "columnsFrom": [ + "clip_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "comments_user_id_users_id_fk": { + "name": "comments_user_id_users_id_fk", + "tableFrom": "comments", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "favorites": { + "name": "favorites", + "columns": { + "clip_id": { + "name": "clip_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "favorites_clip_id_clips_id_fk": { + "name": "favorites_clip_id_clips_id_fk", + "tableFrom": "favorites", + "tableTo": "clips", + "columnsFrom": [ + "clip_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "favorites_user_id_users_id_fk": { + "name": "favorites_user_id_users_id_fk", + "tableFrom": "favorites", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "groups": { + "name": "groups", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "invite_code": { + "name": "invite_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "retention_days": { + "name": "retention_days", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "max_storage_mb": { + "name": "max_storage_mb", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "max_file_size_mb": { + "name": "max_file_size_mb", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 500 + }, + "accent_color": { + "name": "accent_color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'coral'" + }, + "download_provider": { + "name": "download_provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "platform_filter_mode": { + "name": "platform_filter_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'all'" + }, + "platform_filter_list": { + "name": "platform_filter_list", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "shortcut_token": { + "name": "shortcut_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "shortcut_url": { + "name": "shortcut_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "groups_invite_code_unique": { + "name": "groups_invite_code_unique", + "columns": [ + "invite_code" + ], + "isUnique": true + }, + "groups_shortcut_token_unique": { + "name": "groups_shortcut_token_unique", + "columns": [ + "shortcut_token" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notification_preferences": { + "name": "notification_preferences", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "new_adds": { + "name": "new_adds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "reactions": { + "name": "reactions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "comments": { + "name": "comments", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "mentions": { + "name": "mentions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "daily_reminder": { + "name": "daily_reminder", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "notification_preferences_user_id_users_id_fk": { + "name": "notification_preferences_user_id_users_id_fk", + "tableFrom": "notification_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notifications": { + "name": "notifications", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "clip_id": { + "name": "clip_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "emoji": { + "name": "emoji", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "comment_preview": { + "name": "comment_preview", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "read_at": { + "name": "read_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "notifications_user_created": { + "name": "notifications_user_created", + "columns": [ + "user_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "notifications_user_id_users_id_fk": { + "name": "notifications_user_id_users_id_fk", + "tableFrom": "notifications", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "notifications_clip_id_clips_id_fk": { + "name": "notifications_clip_id_clips_id_fk", + "tableFrom": "notifications", + "tableTo": "clips", + "columnsFrom": [ + "clip_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "notifications_actor_id_users_id_fk": { + "name": "notifications_actor_id_users_id_fk", + "tableFrom": "notifications", + "tableTo": "users", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "push_subscriptions": { + "name": "push_subscriptions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keys_p256dh": { + "name": "keys_p256dh", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keys_auth": { + "name": "keys_auth", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "push_subscriptions_user_id_users_id_fk": { + "name": "push_subscriptions_user_id_users_id_fk", + "tableFrom": "push_subscriptions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "reactions": { + "name": "reactions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "clip_id": { + "name": "clip_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "emoji": { + "name": "emoji", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "reactions_unique": { + "name": "reactions_unique", + "columns": [ + "clip_id", + "user_id", + "emoji" + ], + "isUnique": true + } + }, + "foreignKeys": { + "reactions_clip_id_clips_id_fk": { + "name": "reactions_clip_id_clips_id_fk", + "tableFrom": "reactions", + "tableTo": "clips", + "columnsFrom": [ + "clip_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "reactions_user_id_users_id_fk": { + "name": "reactions_user_id_users_id_fk", + "tableFrom": "reactions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "theme_preference": { + "name": "theme_preference", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'system'" + }, + "auto_scroll": { + "name": "auto_scroll", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "muted_by_default": { + "name": "muted_by_default", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "feed_sort_order": { + "name": "feed_sort_order", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'oldest'" + }, + "avatar_path": { + "name": "avatar_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "removed_at": { + "name": "removed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "users_phone_unique": { + "name": "users_phone_unique", + "columns": [ + "phone" + ], + "isUnique": true + } + }, + "foreignKeys": { + "users_group_id_groups_id_fk": { + "name": "users_group_id_groups_id_fk", + "tableFrom": "users", + "tableTo": "groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification_codes": { + "name": "verification_codes", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "verified_at": { + "name": "verified_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "verification_codes_user_id_users_id_fk": { + "name": "verification_codes_user_id_users_id_fk", + "tableFrom": "verification_codes", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "watched": { + "name": "watched", + "columns": { + "clip_id": { + "name": "clip_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "watch_percent": { + "name": "watch_percent", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "watched_at": { + "name": "watched_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "watched_clip_user": { + "name": "watched_clip_user", + "columns": [ + "clip_id", + "user_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "watched_clip_id_clips_id_fk": { + "name": "watched_clip_id_clips_id_fk", + "tableFrom": "watched", + "tableTo": "clips", + "columnsFrom": [ + "clip_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "watched_user_id_users_id_fk": { + "name": "watched_user_id_users_id_fk", + "tableFrom": "watched", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/src/lib/server/db/migrations/meta/_journal.json b/src/lib/server/db/migrations/meta/_journal.json index 2d36c23..7dad5b5 100644 --- a/src/lib/server/db/migrations/meta/_journal.json +++ b/src/lib/server/db/migrations/meta/_journal.json @@ -155,6 +155,13 @@ "when": 1772404817972, "tag": "0021_flaky_stature", "breakpoints": true + }, + { + "idx": 22, + "version": "6", + "when": 1772508242984, + "tag": "0022_cute_garia", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index 67013ad..93da153 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -58,6 +58,8 @@ export const clips = sqliteTable( appleMusicUrl: text('apple_music_url'), youtubeMusicUrl: text('youtube_music_url'), fileSizeBytes: integer('file_size_bytes'), + creatorName: text('creator_name'), + creatorUrl: text('creator_url'), createdAt: integer('created_at', { mode: 'timestamp' }).notNull() }, (table) => [uniqueIndex('clips_group_url').on(table.groupId, table.originalUrl)] diff --git a/src/lib/server/providers/types.ts b/src/lib/server/providers/types.ts index 30e077c..4def3b7 100644 --- a/src/lib/server/providers/types.ts +++ b/src/lib/server/providers/types.ts @@ -3,6 +3,8 @@ export interface VideoDownloadResult { thumbnailPath: string | null; title: string | null; duration: number | null; + creatorName: string | null; + creatorUrl: string | null; } export interface AudioDownloadResult { diff --git a/src/lib/server/providers/ytdlp/index.ts b/src/lib/server/providers/ytdlp/index.ts index c1bfdf4..b30952c 100644 --- a/src/lib/server/providers/ytdlp/index.ts +++ b/src/lib/server/providers/ytdlp/index.ts @@ -143,6 +143,8 @@ export class YtDlpProvider implements DownloadProvider { let title: string | null = null; let duration: number | null = null; + let creatorName: string | null = null; + let creatorUrl: string | null = null; if (infoFile) { try { @@ -150,6 +152,10 @@ export class YtDlpProvider implements DownloadProvider { const info = JSON.parse(await readFile(`${outputDir}/${infoFile}`, 'utf-8')); title = info.title || info.fulltitle || null; duration = typeof info.duration === 'number' ? Math.round(info.duration) : null; + + const rawName = info.uploader || info.channel || info.uploader_id || null; + creatorName = rawName ? String(rawName).replace(/^@/, '') : null; + creatorUrl = info.uploader_url || info.channel_url || null; } catch { // Info file parsing is best-effort } @@ -159,7 +165,9 @@ export class YtDlpProvider implements DownloadProvider { videoPath: `${outputDir}/${videoFile}`, thumbnailPath: thumbFile ? `${outputDir}/${thumbFile}` : null, title, - duration + duration, + creatorName, + creatorUrl }; } diff --git a/src/lib/server/video/download.ts b/src/lib/server/video/download.ts index fbd03af..c86cf53 100644 --- a/src/lib/server/video/download.ts +++ b/src/lib/server/video/download.ts @@ -84,7 +84,9 @@ async function downloadVideoInner(clipId: string, url: string): Promise { .set({ status: 'failed', title: `Exceeds ${sizeMb} MB limit`, - durationSeconds: result.duration + durationSeconds: result.duration, + creatorName: result.creatorName, + creatorUrl: result.creatorUrl }) .where(eq(clips.id, clipId)); // Still notify — clip is viewable via external link @@ -108,7 +110,9 @@ async function downloadVideoInner(clipId: string, url: string): Promise { thumbnailPath: result.thumbnailPath, title, durationSeconds: result.duration, - fileSizeBytes: fileSizeBytes || null + fileSizeBytes: fileSizeBytes || null, + creatorName: result.creatorName, + creatorUrl: result.creatorUrl }) .where(eq(clips.id, clipId)); diff --git a/src/lib/types.ts b/src/lib/types.ts index aa72656..a01a814 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -14,6 +14,8 @@ export interface FeedClip { addedByUsername: string; addedByAvatar: string | null; platform: string; + creatorName: string | null; + creatorUrl: string | null; status: string; contentType: string; durationSeconds: number | null; diff --git a/src/routes/api/clips/[id]/+server.ts b/src/routes/api/clips/[id]/+server.ts index 1974a00..2cc9684 100644 --- a/src/routes/api/clips/[id]/+server.ts +++ b/src/routes/api/clips/[id]/+server.ts @@ -81,6 +81,8 @@ export const GET: RequestHandler = withClipAuth(async ({ params }, { user, clip addedByUsername: uploaderUser?.username || 'Unknown', addedByAvatar: uploaderUser?.avatarPath || null, platform: clip.platform, + creatorName: clip.creatorName, + creatorUrl: clip.creatorUrl, status: clip.status, contentType: clip.contentType, durationSeconds: clip.durationSeconds, From 3247733267fcf2e9289590d18022c0dcf4710e9c Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Mon, 2 Mar 2026 21:40:39 -0600 Subject: [PATCH 3/8] feat: sync favorites with heart reactions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Favoriting a clip now also creates a ❤️ reaction (with notification), and unfavoriting removes the paired reaction and its notification. --- src/routes/api/clips/[id]/favorite/+server.ts | 74 +++++++++++++++++-- 1 file changed, 67 insertions(+), 7 deletions(-) diff --git a/src/routes/api/clips/[id]/favorite/+server.ts b/src/routes/api/clips/[id]/favorite/+server.ts index 3397df8..dc19968 100644 --- a/src/routes/api/clips/[id]/favorite/+server.ts +++ b/src/routes/api/clips/[id]/favorite/+server.ts @@ -1,28 +1,88 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { db } from '$lib/server/db'; -import { favorites } from '$lib/server/db/schema'; +import { favorites, reactions, notifications } from '$lib/server/db/schema'; import { and, eq } from 'drizzle-orm'; -import { withClipAuth } from '$lib/server/api-utils'; +import { v4 as uuid } from 'uuid'; +import { withClipAuth, notifyClipOwner } from '$lib/server/api-utils'; + +export const POST: RequestHandler = withClipAuth(async ({ params }, { user, clip }) => { + const clipId = params.id; + const userId = user.id; -export const POST: RequestHandler = withClipAuth(async ({ params }, { user }) => { // Toggle — check if already favorited const existing = await db.query.favorites.findFirst({ - where: and(eq(favorites.clipId, params.id), eq(favorites.userId, user.id)) + where: and(eq(favorites.clipId, clipId), eq(favorites.userId, userId)) }); if (existing) { await db .delete(favorites) - .where(and(eq(favorites.clipId, params.id), eq(favorites.userId, user.id))); + .where(and(eq(favorites.clipId, clipId), eq(favorites.userId, userId))); + + // Also remove ❤️ reaction when un-favoriting + const heartReaction = await db.query.reactions.findFirst({ + where: and( + eq(reactions.clipId, clipId), + eq(reactions.userId, userId), + eq(reactions.emoji, '❤️') + ) + }); + if (heartReaction) { + await db.delete(reactions).where(eq(reactions.id, heartReaction.id)); + await db + .delete(notifications) + .where( + and( + eq(notifications.clipId, clipId), + eq(notifications.actorId, userId), + eq(notifications.type, 'reaction'), + eq(notifications.emoji, '❤️') + ) + ); + } + return json({ favorited: false }); } await db.insert(favorites).values({ - clipId: params.id, - userId: user.id, + clipId, + userId, createdAt: new Date() }); + // Also create ❤️ reaction if one doesn't already exist + const existingReaction = await db.query.reactions.findFirst({ + where: and( + eq(reactions.clipId, clipId), + eq(reactions.userId, userId), + eq(reactions.emoji, '❤️') + ) + }); + if (!existingReaction) { + await db.insert(reactions).values({ + id: uuid(), + clipId, + userId, + emoji: '❤️', + createdAt: new Date() + }); + + // Notify clip owner (skips self-notification automatically) + await notifyClipOwner({ + recipientId: clip.addedBy, + actorId: userId, + actorUsername: user.username, + actorAvatarPath: user.avatarPath, + clipId, + type: 'reaction', + preferenceKey: 'reactions', + pushTitle: `${user.username} reacted ❤️`, + pushBody: 'on your clip', + pushTag: `reaction-${clipId}`, + emoji: '❤️' + }); + } + return json({ favorited: true }); }); From b82754c205e46c6797ed41be95787d1ae1351b22 Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Mon, 2 Mar 2026 21:40:51 -0600 Subject: [PATCH 4/8] refactor: extract notification and reaction IDs into variables --- src/lib/server/api-utils.ts | 6 ++++-- src/routes/api/clips/[id]/reactions/+server.ts | 4 ++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/lib/server/api-utils.ts b/src/lib/server/api-utils.ts index e91810b..ef41983 100644 --- a/src/lib/server/api-utils.ts +++ b/src/lib/server/api-utils.ts @@ -212,7 +212,8 @@ export async function notifyClipOwner(opts: { const prefs = await db.query.notificationPreferences.findFirst({ where: eq(notificationPreferences.userId, opts.recipientId) }); - if (!prefs || prefs[opts.preferenceKey]) { + const prefEnabled = !prefs || prefs[opts.preferenceKey]; + if (prefEnabled) { const url = opts.type === 'comment' || opts.type === 'reply' ? `/?clip=${opts.clipId}&comments=true` @@ -230,8 +231,9 @@ export async function notifyClipOwner(opts: { }).catch((err) => log.error({ err }, 'push notification failed')); } + const notifId = uuid(); await db.insert(notifications).values({ - id: uuid(), + id: notifId, userId: opts.recipientId, type: opts.type, clipId: opts.clipId, diff --git a/src/routes/api/clips/[id]/reactions/+server.ts b/src/routes/api/clips/[id]/reactions/+server.ts index 1c13d7d..6e0a886 100644 --- a/src/routes/api/clips/[id]/reactions/+server.ts +++ b/src/routes/api/clips/[id]/reactions/+server.ts @@ -63,14 +63,14 @@ export const POST: RequestHandler = withClipAuth(async ({ params, request }, { u if (!sameEmoji) { // Add the new reaction (either fresh or replacing a different emoji) + const reactionId = uuid(); await db.insert(reactions).values({ - id: uuid(), + id: reactionId, clipId, userId, emoji, createdAt: new Date() }); - // Notify clip owner about the new reaction await notifyClipOwner({ recipientId: clip.addedBy, From f4af05372df0bc2e9e2be8c57b759f2dee97d240 Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Mon, 2 Mar 2026 21:41:50 -0600 Subject: [PATCH 5/8] refactor: reel layout above bottom nav and UI polish Position reels within the visible area above bottom nav instead of using full 100dvh with offset calculations. Display original creator name with link on overlay, add portrait video cover-fit, text shadows for readability, swipe-to-dismiss fix for pull-to-refresh conflict, lock to portrait orientation, and compact bottom tab spacing. --- src/lib/components/ActionSidebar.svelte | 4 +-- src/lib/components/ClipOverlay.svelte | 12 +++---- src/lib/components/CommentPrompt.svelte | 2 ++ src/lib/components/FilterBar.svelte | 15 +++++---- src/lib/components/ProgressBar.svelte | 15 ++++----- src/lib/components/ReelItem.svelte | 23 ++++---------- src/lib/components/ReelMusic.svelte | 2 +- src/lib/components/ReelOverlay.svelte | 42 ++++++++++++++++++++----- src/lib/components/ReelVideo.svelte | 10 ++++++ src/lib/components/SkeletonReel.svelte | 8 ++--- src/routes/(app)/+layout.svelte | 16 ++++++---- src/routes/(app)/+page.svelte | 14 ++++++--- src/routes/(app)/favorites/+page.svelte | 20 ++++++------ 13 files changed, 111 insertions(+), 72 deletions(-) diff --git a/src/lib/components/ActionSidebar.svelte b/src/lib/components/ActionSidebar.svelte index 7aebc6f..262b8ec 100644 --- a/src/lib/components/ActionSidebar.svelte +++ b/src/lib/components/ActionSidebar.svelte @@ -187,11 +187,11 @@ .action-sidebar { position: absolute; right: var(--space-lg); - bottom: calc(var(--bottom-nav-height, 64px) + 84px); + bottom: 74px; display: flex; flex-direction: column; align-items: center; - gap: var(--space-lg); + gap: var(--space-sm); z-index: 5; transition: opacity 0.3s ease; } diff --git a/src/lib/components/ClipOverlay.svelte b/src/lib/components/ClipOverlay.svelte index 72ece0b..d127447 100644 --- a/src/lib/components/ClipOverlay.svelte +++ b/src/lib/components/ClipOverlay.svelte @@ -296,14 +296,14 @@ diff --git a/src/lib/components/FilterBar.svelte b/src/lib/components/FilterBar.svelte index 1e8fd1d..5020547 100644 --- a/src/lib/components/FilterBar.svelte +++ b/src/lib/components/FilterBar.svelte @@ -99,12 +99,6 @@ display: flex; justify-content: center; padding: max(var(--space-md), env(safe-area-inset-top)) var(--space-lg) var(--space-sm); - background: linear-gradient( - to bottom, - var(--reel-gradient-soft) 0%, - var(--reel-gradient-faint) 70%, - transparent 100% - ); pointer-events: none; transition: opacity 0.3s ease; } @@ -128,7 +122,7 @@ .filter-tabs button { position: relative; - padding: 8px var(--space-md); + padding: 6px var(--space-md); background: none; color: var(--reel-text-subtle); border: none; @@ -137,6 +131,9 @@ font-size: 0.9375rem; font-weight: 600; cursor: pointer; + text-shadow: + 0 1px 4px rgba(0, 0, 0, 0.7), + 0 2px 12px rgba(0, 0, 0, 0.5); transition: color 0.2s ease; } @@ -160,14 +157,16 @@ line-height: 1; border-radius: var(--radius-full); vertical-align: middle; + box-shadow: 0 1px 6px rgba(0, 0, 0, 0.4); } .tab-indicator { position: absolute; bottom: 0; - height: 3px; + height: 2.5px; background: var(--reel-text); border-radius: var(--radius-full); + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.5); transition: left 0.25s cubic-bezier(0.32, 0.72, 0, 1), width 0.25s cubic-bezier(0.32, 0.72, 0, 1); diff --git a/src/lib/components/ProgressBar.svelte b/src/lib/components/ProgressBar.svelte index 2e5d21c..dc6b188 100644 --- a/src/lib/components/ProgressBar.svelte +++ b/src/lib/components/ProgressBar.svelte @@ -3,6 +3,7 @@ currentTime, duration, isDesktop, + active = true, onseek, onscrubstart, onscrubend, @@ -11,6 +12,7 @@ currentTime: number; duration: number; isDesktop: boolean; + active?: boolean; onseek: (time: number) => void; onscrubstart?: () => void; onscrubend?: () => void; @@ -75,7 +77,7 @@ class="progress-bar" class:desktop={isDesktop} class:scrubbing - class:ui-hidden={uiHidden} + class:ui-hidden={uiHidden || !active} bind:this={barEl} onpointerdown={handlePointerDown} onpointermove={handlePointerMove} @@ -101,16 +103,15 @@ From 9c5264912036561d4378b4fd8b4c3a61179895cf Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Mon, 2 Mar 2026 21:43:03 -0600 Subject: [PATCH 7/8] feat: redesign viewers as floating panel Replace bottom sheet with a floating panel anchored at top-left, add staggered row animations, spinner loading state, and proper empty state with icon. --- src/lib/components/ViewersSheet.svelte | 266 +++++++++++++++++++++---- 1 file changed, 225 insertions(+), 41 deletions(-) diff --git a/src/lib/components/ViewersSheet.svelte b/src/lib/components/ViewersSheet.svelte index 63af649..7ef07b0 100644 --- a/src/lib/components/ViewersSheet.svelte +++ b/src/lib/components/ViewersSheet.svelte @@ -1,6 +1,8 @@ -
- -
- {#if loading} -

Loading...

- {:else if viewers.length === 0} -

No views yet

- {:else} - {#each viewers as viewer (viewer.userId)} -
+
{}} role="presentation">
+ +
+
+ Views{viewers.length > 0 ? ` (${viewers.length})` : ''} + +
+ +
+ {#if loading} +
+ +
+ {:else if viewers.length === 0} +
+
+ +
+

No views yet

+

When others watch this clip, they'll show up here

+
+ {:else} +
+ {#each viewers as viewer, i (viewer.userId)} +
{#if viewer.avatarPath} @@ -70,57 +111,183 @@ class:viewed={viewer.status === 'viewed'} class:skipped={viewer.status === 'skipped'} > - {viewer.status === 'viewed' ? 'Viewed' : 'Skipped'} + Viewed
{/each} - {/if} -
- +
+ {/if} +
From b4f678df0aabb526c3e0d6b55f2811c9600a1ee3 Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Mon, 2 Mar 2026 21:52:09 -0600 Subject: [PATCH 8/8] fix(test): use non-heart emoji in reaction toggle tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Favorites now auto-create ❤️ reactions, so the reaction toggle tests must use a different emoji (😂) to avoid collision. --- src/routes/api/__tests__/clips.test.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/routes/api/__tests__/clips.test.ts b/src/routes/api/__tests__/clips.test.ts index a90ab2c..e7d13f0 100644 --- a/src/routes/api/__tests__/clips.test.ts +++ b/src/routes/api/__tests__/clips.test.ts @@ -616,7 +616,7 @@ describe('POST /api/clips/[id]/reactions', () => { method: 'POST', path: `/api/clips/${data.readyClip.id}/reactions`, params: { id: data.readyClip.id }, - body: { emoji: '❤️' }, + body: { emoji: '😂' }, user: data.member, group: data.group }); @@ -624,9 +624,9 @@ describe('POST /api/clips/[id]/reactions', () => { expect(res.status).toBe(200); const body = await res.json(); expect(body.toggled).toBe(true); - expect(body.reactions['❤️']).toBeDefined(); - expect(body.reactions['❤️'].count).toBe(1); - expect(body.reactions['❤️'].reacted).toBe(true); + expect(body.reactions['😂']).toBeDefined(); + expect(body.reactions['😂'].count).toBe(1); + expect(body.reactions['😂'].reacted).toBe(true); }); it('toggles reaction off on second call', async () => { @@ -634,7 +634,7 @@ describe('POST /api/clips/[id]/reactions', () => { method: 'POST', path: `/api/clips/${data.readyClip.id}/reactions`, params: { id: data.readyClip.id }, - body: { emoji: '❤️' }, + body: { emoji: '😂' }, user: data.member, group: data.group }); @@ -643,8 +643,8 @@ describe('POST /api/clips/[id]/reactions', () => { const body = await res.json(); expect(body.toggled).toBe(false); // Reaction count for this emoji should be 0 or the key absent - if (body.reactions['❤️']) { - expect(body.reactions['❤️'].count).toBe(0); + if (body.reactions['😂']) { + expect(body.reactions['😂'].count).toBe(0); } }); });