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: 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 @@ 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} +
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/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/(app)/+layout.svelte b/src/routes/(app)/+layout.svelte index 3f3c9fd..e8e10b1 100644 --- a/src/routes/(app)/+layout.svelte +++ b/src/routes/(app)/+layout.svelte @@ -65,6 +65,10 @@ document.addEventListener('click', handleFirstInteraction, true); document.addEventListener('touchstart', handleFirstInteraction, true); + // Lock to portrait orientation (works in installed PWA / fullscreen contexts) + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- lock() not in all TS lib defs + (screen.orientation as any)?.lock?.('portrait-primary').catch(() => {}); + // Sync theme-color meta tags with current theme for PWA chrome blending const themeObserver = new MutationObserver(() => syncThemeColor()); themeObserver.observe(document.documentElement, { @@ -304,8 +308,8 @@ backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); border-top: 1px solid color-mix(in srgb, var(--border) 50%, transparent); - padding: var(--space-sm) 0; - padding-bottom: max(var(--space-sm), env(safe-area-inset-bottom)); + padding: var(--space-xs) 0; + padding-bottom: max(var(--space-xs), env(safe-area-inset-bottom)); z-index: 10; transition: opacity 0.3s ease; } @@ -320,8 +324,8 @@ } .bottom-tabs.overlay-mode { - background: linear-gradient(transparent, var(--reel-gradient-heavy)); - border-top: none; + background: var(--reel-bg-elevated); + border-top-color: transparent; backdrop-filter: none; -webkit-backdrop-filter: none; z-index: 50; @@ -332,12 +336,12 @@ display: flex; flex-direction: column; align-items: center; - gap: 3px; + gap: 2px; text-decoration: none; color: var(--text-muted); font-size: 0.625rem; font-family: var(--font-body); - padding: var(--space-xs) 0; + padding: 2px 0; transition: color 0.2s ease; background: none; border: none; diff --git a/src/routes/(app)/+page.svelte b/src/routes/(app)/+page.svelte index 28fd2f5..89c5bd3 100644 --- a/src/routes/(app)/+page.svelte +++ b/src/routes/(app)/+page.svelte @@ -200,6 +200,8 @@ function completeSwipe(goingNext: boolean, newIndex: number) { const vw = window.innerWidth; + // Clear pull-snapping to prevent its CSS transition from overriding the swipe animation + pullSnapping = false; swipeAnimating = true; swipeX = goingNext ? -vw : vw; @@ -345,6 +347,10 @@ isHorizontal = Math.abs(dx) > Math.abs(dy); if (!isHorizontal) return; isHorizontalSwiping = true; + // Reset pull-to-refresh state so its CSS transition doesn't + // compete with the swipe animation on pointerup + pullDistance = 0; + isPullingActive = false; } if (!isHorizontal) return; @@ -896,7 +902,7 @@ animation: spin 0.8s linear infinite; } .reel-scroll { - height: 100dvh; + height: calc(100dvh - var(--bottom-nav-height, 64px)); overflow-y: auto; scroll-snap-type: y mandatory; overscroll-behavior-y: none; @@ -906,7 +912,7 @@ display: none; } .reel-slot { - height: 100dvh; + height: calc(100dvh - var(--bottom-nav-height, 64px)); width: 100%; scroll-snap-align: start; scroll-snap-stop: always; @@ -935,7 +941,7 @@ scroll-snap-align: none; } .reel-empty { - height: 100dvh; + height: calc(100dvh - var(--bottom-nav-height, 64px)); display: flex; flex-direction: column; align-items: center; @@ -1010,7 +1016,7 @@ transition: transform 0.25s ease; } .drop-target { - height: 100dvh; + height: calc(100dvh - var(--bottom-nav-height, 64px)); position: relative; overflow: hidden; } diff --git a/src/routes/(app)/favorites/+page.svelte b/src/routes/(app)/favorites/+page.svelte index f4f8c1a..8af78a4 100644 --- a/src/routes/(app)/favorites/+page.svelte +++ b/src/routes/(app)/favorites/+page.svelte @@ -67,7 +67,7 @@ window.addEventListener('popstate', onReelPopState); await tick(); if (reelContainer) { - reelContainer.scrollTop = index * window.innerHeight; + reelContainer.scrollTop = index * reelContainer.clientHeight; } } @@ -405,11 +405,13 @@ .faves-reel { position: fixed; - inset: 0; + top: 0; + left: 0; + right: 0; + bottom: var(--bottom-nav-height, 64px); z-index: 40; background: var(--bg-primary); overscroll-behavior-x: none; - --bottom-nav-height: calc(env(safe-area-inset-bottom, 0px) + 14px); } .reel-topbar { @@ -458,20 +460,18 @@ } .reel-scroll { - height: 100dvh; + height: 100%; overflow-y: auto; scroll-snap-type: y mandatory; -webkit-overflow-scrolling: touch; overscroll-behavior-y: none; scrollbar-width: none; + &::-webkit-scrollbar { + display: none; + } } - - .reel-scroll::-webkit-scrollbar { - display: none; - } - .reel-slot { - height: 100dvh; + height: 100%; width: 100%; scroll-snap-align: start; scroll-snap-stop: always; 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); } }); }); 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, 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 }); }); 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,