diff --git a/docs/api.md b/docs/api.md index 19d33b7..2eba053 100644 --- a/docs/api.md +++ b/docs/api.md @@ -65,15 +65,18 @@ Response: { "user": { ... }, "group": { ... } } | PATCH | `/api/clips/[id]` | Update clip title | | DELETE | `/api/clips/[id]` | Remove clip | | GET | `/api/clips/unwatched-count` | Count of unwatched clips | +| POST | `/api/clips/dismiss` | Dismiss unwatched clips (bulk) | +| DELETE | `/api/clips/dismiss` | Restore dismissed clips | +| GET | `/api/clips/dismissed` | List dismissed clips for user | ### GET /api/clips ``` -Query params: ?filter=unwatched|watched|favorites|uploads&sort=oldest|round-robin&limit=20&offset=0 +Query params: ?filter=unwatched|watched|favorites|uploads&sort=oldest|round-robin|best&limit=20&offset=0 Response: { "clips": [...], "hasMore": true } ``` -Only returns clips with `status: 'ready'`. Default sort is `oldest` (chronological). `round-robin` interleaves clips across members so no single poster dominates the feed. The `watched` filter sorts by most-recently-watched instead. +Only returns clips with `status: 'ready'`. Default sort is `oldest` (chronological). `round-robin` interleaves clips across members so no single poster dominates the feed. `best` ranks clips by engagement (reactions + comments + views) with source view count as tiebreaker and round-robin balance. The `watched` filter sorts by most-recently-watched instead. -Each clip includes: id, originalUrl, title, addedByUsername, addedByAvatar, status, durationSeconds, platform, contentType, creatorName, creatorUrl, createdAt, watched, favorited, reactions, commentCount, unreadCommentCount, viewCount, seenByOthers. +Each clip includes: id, originalUrl, title, addedByUsername, addedByAvatar, status, durationSeconds, platform, contentType, creatorName, creatorUrl, sourceViewCount, createdAt, watched, favorited, reactions, commentCount, unreadCommentCount, viewCount, seenByOthers. ### POST /api/clips ``` @@ -93,7 +96,7 @@ Also accepts `"phones": ["+1234567890"]` (array) for legacy Shortcut backward co ### GET /api/clips/[id] Returns full clip detail with user context, interaction state, and metadata. ``` -Response: { id, originalUrl, videoPath, audioPath, thumbnailPath, title, artist, albumArt, spotifyUrl, appleMusicUrl, youtubeMusicUrl, addedBy, addedByUsername, addedByAvatar, platform, status, contentType, durationSeconds, creatorName, creatorUrl, watched, favorited, reactions, commentCount, unreadCommentCount, viewCount, seenByOthers, createdAt, canEditCaption } +Response: { id, originalUrl, videoPath, audioPath, thumbnailPath, title, artist, albumArt, spotifyUrl, appleMusicUrl, youtubeMusicUrl, addedBy, addedByUsername, addedByAvatar, platform, status, contentType, durationSeconds, creatorName, creatorUrl, sourceViewCount, watched, favorited, reactions, commentCount, unreadCommentCount, viewCount, seenByOthers, createdAt, canEditCaption } ``` ### PATCH /api/clips/[id] @@ -111,6 +114,27 @@ Host can delete any clip. Non-host uploaders can only delete their own clips if Response: { "count": 5 } ``` +### POST /api/clips/dismiss +Bulk-dismiss unwatched clips. Skips already-watched and already-dismissed clips. +``` +Request: { "keepIds": ["clip-1", "clip-2"] } (optional clips to exclude from dismissal) +Response: { "dismissed": 3 } +``` + +### DELETE /api/clips/dismiss +Restore previously dismissed clips. +``` +Request: { "clipIds": ["id1", "id2"] } (specific clips) +Request: { "all": true } (restore all dismissed clips) +Response: { "restored": 2 } +``` + +### GET /api/clips/dismissed +``` +Response: { "clips": [...], "count": 3 } +``` +Returns dismissed clips with thumbnail, platform, uploader info, and dismissal timestamp. + ## Interactions | Method | Path | Description | @@ -185,7 +209,7 @@ Response: { "status": "downloading" } ### POST /api/clips/[id]/refetch Host-only. Refetches metadata (title, creator info) from the source URL via yt-dlp. ``` -Response: { "title": "...", "creatorName": "...", "creatorUrl": "..." } +Response: { "title": "...", "creatorName": "...", "creatorUrl": "...", "sourceViewCount": 12345 } ``` ### POST /api/clips/[id]/trim @@ -224,6 +248,7 @@ Host-only endpoints (unless noted). Requires `createdBy === currentUser`. | PATCH | `/api/group/retention` | Set retention policy | | PATCH | `/api/group/max-file-size` | Set max file size limit | | PATCH | `/api/group/platforms` | Set platform filter | +| PATCH | `/api/group/daily-share-limit` | Set daily share limit per user | | GET | `/api/group/provider` | List download providers | | PATCH | `/api/group/provider` | Set active provider | | POST | `/api/group/provider/install` | Install a provider | @@ -267,6 +292,12 @@ Request: { "mode": "all", "platforms": [] } (mode: "all" | "allow" | "block") Response: { "platformFilterMode": "all", "platformFilterList": null } ``` +### PATCH /api/group/daily-share-limit +``` +Request: { "dailyShareLimit": 5 } (positive integer, or null to remove limit) +Response: { "dailyShareLimit": 5 } +``` + ### GET /api/group/provider ``` Response: { "providers": [{ "id", "name", "installed", "version", ... }] } diff --git a/docs/architecture.md b/docs/architecture.md index dd22f32..41af157 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -72,6 +72,7 @@ scrolly/ │ │ │ │ └── verify.ts # Twilio SMS verification codes │ │ │ ├── auth.ts # Session management, invite code validation │ │ │ ├── push.ts # web-push wrapper, group notifications +│ │ │ ├── share-limit.ts # Daily share limit enforcement utility │ │ │ ├── scheduler.ts # Retention policy enforcement (periodic cleanup) │ │ │ └── download-lock.ts # Prevents duplicate concurrent downloads │ │ ├── components/ @@ -95,6 +96,7 @@ scrolly/ │ │ │ ├── AddVideo.svelte # Add video form │ │ │ ├── AddVideoModal.svelte # Modal wrapper for AddVideo │ │ │ ├── AvatarCropModal.svelte # Profile picture crop UI +│ │ │ ├── CatchUpModal.svelte # Catch-up modal for bulk unwatched clips │ │ │ ├── MeGrid.svelte # Profile clip grid (favorites/uploads) │ │ │ ├── MeReelView.svelte # Profile reel overlay view │ │ │ ├── CommentInput.svelte # Rich comment input with GIF and mention support @@ -115,13 +117,16 @@ scrolly/ │ │ │ ├── PlatformIcon.svelte # Platform logo (TikTok, IG, etc.) │ │ │ ├── InlineError.svelte │ │ │ ├── FilterBar.svelte # Feed filter tabs +│ │ │ ├── ShareLimitDots.svelte # Daily share limit indicator dots │ │ │ ├── ShortcutGuideSheet.svelte # iOS Shortcut setup guide │ │ │ ├── ShortcutUpgradeBanner.svelte # Legacy shortcut upgrade prompt │ │ │ └── settings/ │ │ │ ├── GroupNameEdit.svelte │ │ │ ├── InviteLink.svelte │ │ │ ├── MemberList.svelte +│ │ │ ├── DailyShareLimitPicker.svelte # Daily per-user share limit control │ │ │ ├── RetentionPicker.svelte +│ │ │ ├── SkippedClips.svelte # Dismissed/skipped clips viewer with restore │ │ │ ├── ClipsManager.svelte │ │ │ ├── NotificationSettings.svelte # Push toggle + test button │ │ │ ├── ShortcutManager.svelte # iOS Shortcut config wrapper @@ -142,6 +147,7 @@ scrolly/ │ │ │ ├── sheetOpen.ts # Global any-sheet-open state (blocks scroll/nav) │ │ │ ├── uiHidden.ts # Feed UI hidden state (synced from active reel) │ │ │ ├── homeTap.ts # Double-tap home to scroll to top +│ │ │ ├── catchUpModal.ts # Catch-up modal dismissal state (12-hour cooldown) │ │ │ ├── shortcutNudge.ts # Share shortcut install nudge │ │ │ └── shortcutUpgrade.ts # Shortcut upgrade banner state │ │ ├── types.ts # Shared TypeScript types (Clip, etc.) @@ -163,6 +169,8 @@ scrolly/ │ │ ├── api/ # REST API (see docs/api.md) │ │ │ ├── auth/ │ │ │ ├── clips/ +│ │ │ │ ├── dismiss/+server.ts # Dismiss/restore unwatched clips +│ │ │ │ ├── dismissed/+server.ts # List dismissed clips │ │ │ │ └── [id]/refetch/+server.ts # Host-only metadata refetch │ │ │ │ └── [id]/trim/+server.ts # Music clip trim │ │ │ │ └── [id]/waveform/+server.ts # Waveform data diff --git a/docs/data-model.md b/docs/data-model.md index 6e0d828..8ac2f4c 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -18,6 +18,7 @@ SQLite database via Drizzle ORM. All IDs are UUIDs stored as text. Timestamps ar | download_provider | text | Nullable. Active download provider ID. | | platform_filter_mode | text | Default `'all'`. `'all'` / `'allow'` / `'block'`. | | platform_filter_list | text | Nullable. Comma-separated list of platforms for allow/block filtering. | +| daily_share_limit | integer | Nullable. Max clips per user per calendar day. | | shortcut_token | text | Nullable, unique. Token for iOS Shortcut clip sharing. | | shortcut_url | text | Nullable. URL for iOS Shortcut integration. | | created_by | text | FK → users.id (host/admin) | @@ -66,6 +67,7 @@ SQLite database via Drizzle ORM. All IDs are UUIDs stored as text. Timestamps ar | file_size_bytes | integer | Nullable. File size for storage tracking. | | creator_name | text | Nullable. Original content creator name (from yt-dlp metadata). | | creator_url | text | Nullable. Original content creator profile URL. | +| source_view_count | integer | Nullable. View count from original source (yt-dlp metadata). | | created_at | integer | Unix timestamp | Unique index on `(group_id, original_url)` — prevents duplicate URLs within a group. @@ -148,6 +150,16 @@ Index on `(user_id, created_at)` for efficient feed queries. Unique constraint on `(clip_id, user_id)` — tracks whether a user has seen the comments on a clip. +### dismissed_clips + +| Column | Type | Notes | +|--------|------|-------| +| clip_id | text | Composite PK, FK → clips.id | +| user_id | text | Composite PK, FK → users.id | +| dismissed_at | integer | Unix timestamp when dismissed | + +Unique constraint on `(clip_id, user_id)` — tracks clips dismissed by users in catch-up modal. + ### push_subscriptions | Column | Type | Notes | @@ -198,6 +210,7 @@ comments 1──∞ comment_hearts clips ∞──∞ users (watched) clips ∞──∞ users (favorites) clips ∞──∞ users (comment_views) +clips ∞──∞ users (dismissed_clips) users 1──∞ push_subscriptions users 1──1 notification_preferences users 1──∞ notifications (recipient) @@ -215,3 +228,4 @@ users 1──∞ verification_codes - **Music clips:** The `content_type` field distinguishes video clips from music links. Music clips have additional fields for cross-platform streaming URLs resolved via Odesli. - **Duplicate URL prevention:** A unique index on `(group_id, original_url)` prevents the same link from being shared twice within a group. - **Music clip trim workflow:** Music clips enter `pending_trim` status after download. The user can trim audio via the trim UI or skip trimming. If neither occurs before `trim_deadline`, the clip auto-publishes to `ready` status via the scheduler. +- **Dismissed clips:** The `dismissed_clips` table tracks clips dismissed by users in the catch-up modal. Users can dismiss unwatched clips in bulk, then restore them later from the Skipped Clips viewer in settings.