From b7c28280eb33b98f2ee8d94aa77633950eed7eec Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Mon, 9 Mar 2026 20:31:41 -0500 Subject: [PATCH 01/19] docs: update api, data-model, and architecture for queue feature Add queue management endpoints, clip_queue table, share pacing group columns, queued clip status, and new files to the architecture tree. --- docs/api.md | 60 ++++++++++++++++++++++++++++++++++++++++++++ docs/architecture.md | 13 +++++++++- docs/data-model.md | 22 +++++++++++++++- 3 files changed, 93 insertions(+), 2 deletions(-) diff --git a/docs/api.md b/docs/api.md index d935da2..abc8ac0 100644 --- a/docs/api.md +++ b/docs/api.md @@ -237,6 +237,54 @@ Extends the trim deadline for a music clip in `pending_trim` status. The client Response: { "ok": true } ``` +## Queue Management + +Manage queued clips when share pacing is enabled. + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/queue` | List queued clips for user | +| DELETE | `/api/queue` | Clear entire queue | +| GET | `/api/queue/count` | Queued clip count | +| DELETE | `/api/queue/[id]` | Cancel a queued clip | +| POST | `/api/queue/[id]/move-to-top` | Move entry to top of queue | +| PATCH | `/api/queue/reorder` | Reorder queue entries | + +### GET /api/queue +``` +Response: { "queue": [{ "id", "clipId", "position", "scheduledAt", "sharesIn", "createdAt", "title", "originalUrl", "platform", "contentType", "status", "thumbnailPath" }] } +``` + +### DELETE /api/queue +Clears the entire queue for the user's group and deletes associated clips. +``` +Response: { "cleared": 3 } +``` + +### GET /api/queue/count +``` +Response: { "count": 5 } +``` + +### DELETE /api/queue/[id] +Cancels a single queued clip. Only the uploader can cancel. +``` +Response: { "ok": true } +``` + +### POST /api/queue/[id]/move-to-top +Moves a queue entry to position 0 (next to publish). +``` +Response: { "ok": true } +``` + +### PATCH /api/queue/reorder +Reorders all queue entries and recalculates scheduled publish times. +``` +Request: { "orderedIds": ["entry-id-1", "entry-id-2", "entry-id-3"] } +Response: { "ok": true } +``` + ## Group Management Host-only endpoints (unless noted). Requires `createdBy === currentUser`. @@ -249,6 +297,7 @@ Host-only endpoints (unless noted). Requires `createdBy === currentUser`. | 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 | +| PATCH | `/api/group/share-pacing` | Configure share pacing mode | | GET | `/api/group/provider` | List download providers | | PATCH | `/api/group/provider` | Set active provider | | POST | `/api/group/provider/install` | Install a provider | @@ -298,6 +347,17 @@ Request: { "dailyShareLimit": 5 } (positive integer, or null to remove limit) Response: { "dailyShareLimit": 5 } ``` +### PATCH /api/group/share-pacing +Host-only. Configures queue-based share pacing. When switching away from `queue` mode, all queued clips are flushed to `ready`. +``` +Request: { "sharePacingMode": "queue", "shareBurst": 2, "shareCooldownMinutes": 120, "dailyShareLimit": null } +Response: { "sharePacingMode": "queue", "shareBurst": 2, "shareCooldownMinutes": 120, "dailyShareLimit": null } +``` +- `sharePacingMode`: `"off"` | `"daily_cap"` | `"queue"` +- `shareBurst`: 1–10 (clips per scheduled time slot) +- `shareCooldownMinutes`: 30 | 60 | 120 | 240 | 360 +- `dailyShareLimit`: positive integer or null + ### GET /api/group/provider ``` Response: { "providers": [{ "id", "name", "installed", "version", ... }] } diff --git a/docs/architecture.md b/docs/architecture.md index 47d4500..f114eb2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -72,7 +72,8 @@ 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 +│ │ │ ├── share-limit.ts # Share pacing modes and daily limit enforcement +│ │ │ ├── queue.ts # Clip queue management (enqueue, publish, reorder) │ │ │ ├── scheduler.ts # Retention policy enforcement (periodic cleanup) │ │ │ └── download-lock.ts # Prevents duplicate concurrent downloads │ │ ├── components/ @@ -96,6 +97,7 @@ scrolly/ │ │ │ ├── AddVideo.svelte # Add video form │ │ │ ├── AddVideoModal.svelte # Modal wrapper for AddVideo │ │ │ ├── AvatarCropModal.svelte # Profile picture crop UI +│ │ │ ├── QueueSheet.svelte # Bottom sheet for viewing/reordering queued clips │ │ │ ├── CatchUpModal.svelte # Catch-up modal for bulk unwatched clips │ │ │ ├── MeGrid.svelte # Profile clip grid (favorites/uploads) │ │ │ ├── MeReelView.svelte # Profile reel overlay view @@ -125,6 +127,7 @@ scrolly/ │ │ │ ├── InviteLink.svelte │ │ │ ├── MemberList.svelte │ │ │ ├── DailyShareLimitPicker.svelte # Daily per-user share limit control +│ │ │ ├── SharePacingPicker.svelte # Share pacing mode, burst, and cooldown config │ │ │ ├── RetentionPicker.svelte │ │ │ ├── SkippedClips.svelte # Dismissed/skipped clips viewer with restore │ │ │ ├── ClipsManager.svelte @@ -148,6 +151,8 @@ scrolly/ │ │ │ ├── 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) +│ │ │ ├── queue.ts # Queue count store and fetch function +│ │ │ ├── queueSheet.ts # Queue sheet visibility state │ │ │ ├── shortcutNudge.ts # Share shortcut install nudge │ │ │ └── shortcutUpgrade.ts # Shortcut upgrade banner state │ │ ├── types.ts # Shared TypeScript types (Clip, etc.) @@ -182,7 +187,13 @@ scrolly/ │ │ │ │ └── [id]/waveform/+server.ts # Waveform data │ │ │ │ └── [id]/publish/+server.ts # Publish after trim │ │ │ ├── gifs/ +│ │ │ ├── queue/ +│ │ │ │ ├── +server.ts # GET list / DELETE clear queue +│ │ │ │ ├── count/+server.ts # GET queue count +│ │ │ │ ├── reorder/+server.ts # PATCH reorder entries +│ │ │ │ └── [id]/+server.ts # DELETE cancel entry │ │ │ ├── group/ +│ │ │ │ ├── share-pacing/+server.ts # PATCH configure share pacing │ │ │ ├── notifications/ │ │ │ │ └── [id]/+server.ts # Delete single notification │ │ │ ├── profile/ diff --git a/docs/data-model.md b/docs/data-model.md index 8ac2f4c..7868ede 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -19,6 +19,9 @@ SQLite database via Drizzle ORM. All IDs are UUIDs stored as text. Timestamps ar | 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. | +| share_pacing_mode | text | Default `'off'`. `'off'` / `'daily_cap'` / `'queue'`. | +| share_burst | integer | Default 2. Clips per scheduled time slot in queue mode (1–10). | +| share_cooldown_minutes | integer | Default 120. Minutes between clip groups in queue mode. | | 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) | @@ -55,7 +58,7 @@ SQLite database via Drizzle ORM. All IDs are UUIDs stored as text. Timestamps ar | title | text | User-provided caption, source metadata, or AI-generated. | | duration_seconds | integer | Nullable | | platform | text | `'tiktok'` / `'instagram'` / `'youtube'` / etc. | -| status | text | `'downloading'` / `'pending_trim'` / `'ready'` / `'failed'` / `'deleted'` | +| status | text | `'downloading'` / `'pending_trim'` / `'queued'` / `'ready'` / `'failed'` / `'deleted'` | | content_type | text | `'video'` / `'music'`. Default `'video'`. | | audio_path | text | Nullable. Path to audio file (music clips). | | artist | text | Nullable. Artist name (music clips). | @@ -160,6 +163,20 @@ Unique constraint on `(clip_id, user_id)` — tracks whether a user has seen the Unique constraint on `(clip_id, user_id)` — tracks clips dismissed by users in catch-up modal. +### clip_queue + +| Column | Type | Notes | +|--------|------|-------| +| id | text | PK, UUID | +| clip_id | text | FK → clips.id | +| user_id | text | FK → users.id | +| group_id | text | FK → groups.id | +| position | integer | Order in queue (0-based) | +| scheduled_at | integer | Unix timestamp when clip will be published | +| created_at | integer | Unix timestamp | + +Index on `(user_id, group_id)` for efficient queue lookups. + ### push_subscriptions | Column | Type | Notes | @@ -211,6 +228,9 @@ clips ∞──∞ users (watched) clips ∞──∞ users (favorites) clips ∞──∞ users (comment_views) clips ∞──∞ users (dismissed_clips) +groups 1──∞ clip_queue +users 1──∞ clip_queue +clips 1──∞ clip_queue users 1──∞ push_subscriptions users 1──1 notification_preferences users 1──∞ notifications (recipient) From 3716facc6c41b7e2467fd23b4048d1653ebb8893 Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Tue, 10 Mar 2026 20:10:18 -0500 Subject: [PATCH 02/19] feat: add clout scoring system with tier-based queue pacing Computes rolling engagement score from last 10 matured clips. Tiers (Fresh/Rising/Viral/Iconic) adjust cooldown multiplier, burst size, and queue depth limits per user. --- src/lib/server/clout.ts | 193 ++++++++++++++++++++++++++ src/lib/server/queue.ts | 8 +- src/lib/server/share-limit.ts | 25 +++- src/routes/api/clips/+server.ts | 20 ++- src/routes/api/clips/share/+server.ts | 11 +- src/routes/api/clout/+server.ts | 24 ++++ 6 files changed, 266 insertions(+), 15 deletions(-) create mode 100644 src/lib/server/clout.ts create mode 100644 src/routes/api/clout/+server.ts diff --git a/src/lib/server/clout.ts b/src/lib/server/clout.ts new file mode 100644 index 0000000..647d9e4 --- /dev/null +++ b/src/lib/server/clout.ts @@ -0,0 +1,193 @@ +import { db } from '$lib/server/db'; +import { clips, reactions, favorites, comments } from '$lib/server/db/schema'; +import { eq, and, ne, sql, desc, lte } from 'drizzle-orm'; +import { createLogger } from '$lib/server/logger'; + +const log = createLogger('clout'); + +const MATURITY_HOURS = 48; +const WINDOW_SIZE = 10; + +export const TIERS = { + iconic: { + name: 'Iconic', + minScore: 1.0, + cooldownMultiplier: 0.5, + burst: 5, + queueLimit: null, // uncapped + icon: '/icons/clout/iconic.png' + }, + viral: { + name: 'Viral', + minScore: 0.7, + cooldownMultiplier: 1.0, + burst: 3, + queueLimit: null, + icon: '/icons/clout/viral.png' + }, + rising: { + name: 'Rising', + minScore: 0.4, + cooldownMultiplier: 2.0, + burst: 2, + queueLimit: 10, + icon: '/icons/clout/rising.png' + }, + fresh: { + name: 'Fresh', + minScore: 0, + cooldownMultiplier: 3.0, + burst: 1, + queueLimit: 6, + icon: '/icons/clout/fresh.png' + } +} as const; + +export type TierKey = keyof typeof TIERS; + +export interface TierConfig { + name: string; + minScore: number; + cooldownMultiplier: number; + burst: number; + queueLimit: number | null; + icon: string; +} + +export interface ClipBreakdown { + clipId: string; + score: number; +} + +export interface CloutResult { + score: number; + tier: TierKey; + tierConfig: TierConfig; + breakdown: ClipBreakdown[]; + cooldownMinutes: number; + burstSize: number; + queueLimit: number | null; +} + +/** + * Get the clout tier for a given score. + */ +export function getCloutTier(score: number): { key: TierKey; config: TierConfig } { + if (score >= TIERS.iconic.minScore) return { key: 'iconic', config: TIERS.iconic }; + if (score >= TIERS.viral.minScore) return { key: 'viral', config: TIERS.viral }; + if (score >= TIERS.rising.minScore) return { key: 'rising', config: TIERS.rising }; + return { key: 'fresh', config: TIERS.fresh }; +} + +/** + * Compute clout score for a user in a group. + * + * Scoring per clip (0 / 1 / 2): + * 0 = no reactions or favorites from others + * 1 = at least 1 reaction or favorite, but no comments from others + * 2 = at least 1 reaction/fav AND at least 1 comment from others + * + * Returns the rolling average of the last 10 matured clips (48h+ old). + * Users with <10 matured clips default to Rising tier. + */ +export function getCloutScore( + userId: string, + groupId: string, + baseCooldownMinutes: number +): CloutResult { + const maturityCutoff = new Date(Date.now() - MATURITY_HOURS * 60 * 60 * 1000); + + // Get the user's last WINDOW_SIZE matured clips + const maturedClips = db + .select({ id: clips.id }) + .from(clips) + .where( + and( + eq(clips.addedBy, userId), + eq(clips.groupId, groupId), + eq(clips.status, 'ready'), + lte(clips.createdAt, maturityCutoff) + ) + ) + .orderBy(desc(clips.createdAt)) + .limit(WINDOW_SIZE) + .all(); + + // Not enough matured clips — default to Rising + if (maturedClips.length < WINDOW_SIZE) { + const { key, config } = getCloutTier(TIERS.rising.minScore); + const cooldownMinutes = Math.round(baseCooldownMinutes * config.cooldownMultiplier); + return { + score: -1, // sentinel: not enough data + tier: key, + tierConfig: config, + breakdown: [], + cooldownMinutes, + burstSize: config.burst, + queueLimit: config.queueLimit + }; + } + + const clipIds = maturedClips.map((c) => c.id); + const breakdown: ClipBreakdown[] = []; + + for (const clipId of clipIds) { + // Count reactions from others + const [reactionResult] = db + .select({ count: sql`count(*)` }) + .from(reactions) + .where(and(eq(reactions.clipId, clipId), ne(reactions.userId, userId))) + .all(); + + // Count favorites from others + const [favResult] = db + .select({ count: sql`count(*)` }) + .from(favorites) + .where(and(eq(favorites.clipId, clipId), ne(favorites.userId, userId))) + .all(); + + // Count comments from others + const [commentResult] = db + .select({ count: sql`count(*)` }) + .from(comments) + .where(and(eq(comments.clipId, clipId), ne(comments.userId, userId))) + .all(); + + const hasReactionOrFav = (reactionResult?.count ?? 0) + (favResult?.count ?? 0) > 0; + const hasComment = (commentResult?.count ?? 0) > 0; + + let score: number; + if (hasReactionOrFav && hasComment) { + score = 2; + } else if (hasReactionOrFav) { + score = 1; + } else { + score = 0; + } + + breakdown.push({ clipId, score }); + } + + const totalScore = breakdown.reduce((sum, b) => sum + b.score, 0); + const averageScore = totalScore / breakdown.length; + // Round to 1 decimal place + const score = Math.round(averageScore * 10) / 10; + + const { key, config } = getCloutTier(score); + const cooldownMinutes = Math.round(baseCooldownMinutes * config.cooldownMultiplier); + + log.info( + { userId, score, tier: key, cooldownMinutes, burst: config.burst }, + 'clout score computed' + ); + + return { + score, + tier: key, + tierConfig: config, + breakdown, + cooldownMinutes, + burstSize: config.burst, + queueLimit: config.queueLimit + }; +} diff --git a/src/lib/server/queue.ts b/src/lib/server/queue.ts index 7e7e13c..4779c6f 100644 --- a/src/lib/server/queue.ts +++ b/src/lib/server/queue.ts @@ -7,7 +7,7 @@ import { v4 as uuid } from 'uuid'; const log = createLogger('queue'); -const MAX_QUEUE_DEPTH = 10; +const DEFAULT_QUEUE_DEPTH = 10; /** * Check if a user still has burst slots available (instant shares). @@ -76,7 +76,8 @@ export function enqueueClip( userId: string, groupId: string, cooldownMinutes: number, - burst = 1 + burst = 1, + queueLimit: number | null = DEFAULT_QUEUE_DEPTH ): { id: string; scheduledAt: Date; position: number } | null { // Check queue depth const [countResult] = db @@ -86,7 +87,8 @@ export function enqueueClip( .all(); const currentCount = countResult?.count ?? 0; - if (currentCount >= MAX_QUEUE_DEPTH) { + const effectiveLimit = queueLimit ?? Infinity; + if (currentCount >= effectiveLimit) { return null; // Queue full } diff --git a/src/lib/server/share-limit.ts b/src/lib/server/share-limit.ts index 8fef03a..9886b37 100644 --- a/src/lib/server/share-limit.ts +++ b/src/lib/server/share-limit.ts @@ -3,6 +3,7 @@ import { db } from '$lib/server/db'; import { clips } from '$lib/server/db/schema'; import { eq, and, gte, sql } from 'drizzle-orm'; import { checkBurstAvailable } from '$lib/server/queue'; +import { getCloutScore } from '$lib/server/clout'; /** * Calculate the start of "today" in the user's timezone as a UTC Date. @@ -143,6 +144,13 @@ export type SharePacingResult = scheduledAt?: Date; nextSlotAt?: Date; queueFull?: boolean; + clout?: { + cooldownMinutes: number; + burstSize: number; + queueLimit: number | null; + tier: string; + tierName: string; + }; }; interface GroupPacingConfig { @@ -185,16 +193,19 @@ export function checkSharePacing( }; } case 'queue': { - const burst = checkBurstAvailable( - userId, - groupId, - group.shareBurst, - group.shareCooldownMinutes - ); + const clout = getCloutScore(userId, groupId, group.shareCooldownMinutes); + const burst = checkBurstAvailable(userId, groupId, clout.burstSize, clout.cooldownMinutes); return { mode: 'queue', queued: !burst.available, - nextSlotAt: burst.nextSlotAt ?? undefined + nextSlotAt: burst.nextSlotAt ?? undefined, + clout: { + cooldownMinutes: clout.cooldownMinutes, + burstSize: clout.burstSize, + queueLimit: clout.queueLimit, + tier: clout.tier, + tierName: clout.tierConfig.name + } }; } default: diff --git a/src/routes/api/clips/+server.ts b/src/routes/api/clips/+server.ts index 9d5caff..29388bd 100644 --- a/src/routes/api/clips/+server.ts +++ b/src/routes/api/clips/+server.ts @@ -392,9 +392,23 @@ function tryEnqueue( burst: number ): QueueResult | Response { if (pacing.mode !== 'queue' || !pacing.queued) return { queued: false }; - const entry = enqueueClip(clipId, userId, groupId, cooldownMinutes, burst); - if (!entry) - return json({ error: 'Your queue is full (max 10).', queueFull: true }, { status: 429 }); + // Use clout-adjusted values when available + const effectiveCooldown = pacing.clout?.cooldownMinutes ?? cooldownMinutes; + const effectiveBurst = pacing.clout?.burstSize ?? burst; + const queueLimit = pacing.clout?.queueLimit ?? null; + const entry = enqueueClip(clipId, userId, groupId, effectiveCooldown, effectiveBurst, queueLimit); + if (!entry) { + const tierMsg = pacing.clout ? ` You're at ${pacing.clout.tierName} tier.` : ''; + const limit = pacing.clout?.queueLimit ?? 10; + return json( + { + error: `Queue full (${limit}/${limit}).${tierMsg} Share clips that get reactions to unlock more capacity.`, + queueFull: true, + tier: pacing.clout?.tier + }, + { status: 429 } + ); + } return { queued: true, scheduledAt: entry.scheduledAt, position: entry.position }; } diff --git a/src/routes/api/clips/share/+server.ts b/src/routes/api/clips/share/+server.ts index bf51857..3707dde 100644 --- a/src/routes/api/clips/share/+server.ts +++ b/src/routes/api/clips/share/+server.ts @@ -112,9 +112,16 @@ function tryEnqueueShare( burst: number ): { queued: false } | { queued: true; sharesIn: string } | Response { if (pacing.mode !== 'queue' || !pacing.queued) return { queued: false }; - const entry = enqueueClip(clipId, userId, groupId, cooldownMinutes, burst); + const effectiveCooldown = pacing.clout?.cooldownMinutes ?? cooldownMinutes; + const effectiveBurst = pacing.clout?.burstSize ?? burst; + const queueLimit = pacing.clout?.queueLimit ?? null; + const entry = enqueueClip(clipId, userId, groupId, effectiveCooldown, effectiveBurst, queueLimit); if (!entry) { - return shareResponse(false, '❌ Your queue is full (max 10 items).', 429, { queueFull: true }); + const limit = pacing.clout?.queueLimit ?? 10; + const tierMsg = pacing.clout ? ` (${pacing.clout.tierName} tier)` : ''; + return shareResponse(false, `❌ Queue full${tierMsg} (${limit}/${limit}).`, 429, { + queueFull: true + }); } return { queued: true, diff --git a/src/routes/api/clout/+server.ts b/src/routes/api/clout/+server.ts new file mode 100644 index 0000000..94fd74d --- /dev/null +++ b/src/routes/api/clout/+server.ts @@ -0,0 +1,24 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { withAuth } from '$lib/server/api-utils'; +import { getCloutScore } from '$lib/server/clout'; + +export const GET: RequestHandler = withAuth(async (_event, { user, group }) => { + if (group.sharePacingMode !== 'queue') { + return json({ enabled: false }); + } + + const result = getCloutScore(user.id, user.groupId, group.shareCooldownMinutes); + + return json({ + enabled: true, + score: result.score, + tier: result.tier, + tierName: result.tierConfig.name, + cooldownMinutes: result.cooldownMinutes, + burstSize: result.burstSize, + queueLimit: result.queueLimit, + icon: result.tierConfig.icon, + breakdown: result.breakdown + }); +}); From 1992b7f45941b8702a9bc73e78f4c26e113538e4 Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Tue, 10 Mar 2026 20:14:24 -0500 Subject: [PATCH 03/19] feat: add clout change modal with tips and underperforming clips Shows tier transition when rank changes, with a "How to rank up" button that fetches tips and lists clips with zero engagement. Deferred display avoids conflicts with sheets/dialogs. --- src/lib/components/CloutChangeModal.svelte | 457 +++++++++++++++++++++ src/lib/server/clout.ts | 37 +- src/lib/stores/cloutChange.ts | 12 + src/routes/(app)/+layout.svelte | 32 ++ src/routes/+layout.svelte | 89 +++- src/routes/api/clout/+server.ts | 28 +- 6 files changed, 631 insertions(+), 24 deletions(-) create mode 100644 src/lib/components/CloutChangeModal.svelte create mode 100644 src/lib/stores/cloutChange.ts diff --git a/src/lib/components/CloutChangeModal.svelte b/src/lib/components/CloutChangeModal.svelte new file mode 100644 index 0000000..ecbaf84 --- /dev/null +++ b/src/lib/components/CloutChangeModal.svelte @@ -0,0 +1,457 @@ + + +{#if change} + {@const prev = TIER_INFO[change.previousTier] ?? TIER_INFO.fresh} + {@const next = TIER_INFO[change.newTier] ?? TIER_INFO.fresh} + {@const rankUp = isRankUp()} + + +
+ +
+{/if} + + diff --git a/src/lib/server/clout.ts b/src/lib/server/clout.ts index 647d9e4..f83e6d5 100644 --- a/src/lib/server/clout.ts +++ b/src/lib/server/clout.ts @@ -57,6 +57,10 @@ export interface TierConfig { export interface ClipBreakdown { clipId: string; score: number; + title: string | null; + platform: string; + originalUrl: string; + thumbnailPath: string | null; } export interface CloutResult { @@ -79,6 +83,18 @@ export function getCloutTier(score: number): { key: TierKey; config: TierConfig return { key: 'fresh', config: TIERS.fresh }; } +const TIER_ORDER: TierKey[] = ['fresh', 'rising', 'viral', 'iconic']; + +/** + * Get the next tier above the given one, or null if already at top. + */ +export function getNextTier(currentTier: TierKey): { key: TierKey; config: TierConfig } | null { + const idx = TIER_ORDER.indexOf(currentTier); + if (idx < 0 || idx >= TIER_ORDER.length - 1) return null; + const nextKey = TIER_ORDER[idx + 1]; + return { key: nextKey, config: TIERS[nextKey] }; +} + /** * Compute clout score for a user in a group. * @@ -99,7 +115,13 @@ export function getCloutScore( // Get the user's last WINDOW_SIZE matured clips const maturedClips = db - .select({ id: clips.id }) + .select({ + id: clips.id, + title: clips.title, + platform: clips.platform, + originalUrl: clips.originalUrl, + thumbnailPath: clips.thumbnailPath + }) .from(clips) .where( and( @@ -128,10 +150,10 @@ export function getCloutScore( }; } - const clipIds = maturedClips.map((c) => c.id); const breakdown: ClipBreakdown[] = []; - for (const clipId of clipIds) { + for (const clip of maturedClips) { + const clipId = clip.id; // Count reactions from others const [reactionResult] = db .select({ count: sql`count(*)` }) @@ -165,7 +187,14 @@ export function getCloutScore( score = 0; } - breakdown.push({ clipId, score }); + breakdown.push({ + clipId, + score, + title: clip.title, + platform: clip.platform, + originalUrl: clip.originalUrl, + thumbnailPath: clip.thumbnailPath + }); } const totalScore = breakdown.reduce((sum, b) => sum + b.score, 0); diff --git a/src/lib/stores/cloutChange.ts b/src/lib/stores/cloutChange.ts new file mode 100644 index 0000000..36755bd --- /dev/null +++ b/src/lib/stores/cloutChange.ts @@ -0,0 +1,12 @@ +import { writable } from 'svelte/store'; + +export interface CloutChange { + previousTier: string; + newTier: string; + previousTierName: string; + newTierName: string; + cooldownMinutes: number; + burstSize: number; +} + +export const cloutChange = writable(null); diff --git a/src/routes/(app)/+layout.svelte b/src/routes/(app)/+layout.svelte index b3e7bda..a3f6f3b 100644 --- a/src/routes/(app)/+layout.svelte +++ b/src/routes/(app)/+layout.svelte @@ -12,6 +12,7 @@ import { initAudioContext } from '$lib/audio/normalizer'; import { feedUiHidden } from '$lib/stores/uiHidden'; import { fetchGroupMembers } from '$lib/stores/members'; + import { cloutChange } from '$lib/stores/cloutChange'; import ActivitySheet from '$lib/components/ActivitySheet.svelte'; import QueueSheet from '$lib/components/QueueSheet.svelte'; import AddVideoModal from '$lib/components/AddVideoModal.svelte'; @@ -33,9 +34,40 @@ return ''; }); + const TIER_NAMES: Record = { + fresh: 'Fresh', + rising: 'Rising', + viral: 'Viral', + iconic: 'Iconic' + }; + + async function checkCloutTier() { + try { + const res = await fetch('/api/clout'); + if (!res.ok) return; + const data = await res.json(); + if (!data.enabled) return; + const storedTier = localStorage.getItem('clout-tier'); + localStorage.setItem('clout-tier', data.tier); + if (storedTier && storedTier !== data.tier) { + cloutChange.set({ + previousTier: storedTier, + newTier: data.tier, + previousTierName: TIER_NAMES[storedTier] ?? storedTier, + newTierName: data.tierName, + cooldownMinutes: data.cooldownMinutes, + burstSize: data.burstSize + }); + } + } catch { + // silently fail + } + } + onMount(() => { startPolling(); fetchGroupMembers(); + checkCloutTier(); // Measure actual bottom nav height and expose as CSS variable. // This adapts to the real safe-area insets on each device instead of diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 03d19d2..5265e8a 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -7,6 +7,7 @@ import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'; import InstallBanner from '$lib/components/InstallBanner.svelte'; import SwUpdateToast from '$lib/components/SwUpdateToast.svelte'; + import CloutChangeModal from '$lib/components/CloutChangeModal.svelte'; import { isStandalone, detectStandaloneMode, @@ -16,6 +17,57 @@ const { children } = $props(); + const NOTIFICATION_CLICK_CACHE = 'notification-click'; + const NOTIFICATION_CLICK_KEY = '/__notification_url'; + + /** Shared handler for notification deep-link URLs (used by postMessage and visibilitychange). */ + function handleNotificationUrl(url: string) { + const parsed = new URL(url, window.location.origin); + const clipId = parsed.searchParams.get('clip'); + const comments = parsed.searchParams.get('comments') === 'true'; + + if (clipId && window.location.pathname === '/') { + console.log('[Layout] on feed, opening overlay via signal:', clipId); + clipOverlaySignal.set(clipId); + if (comments) { + setTimeout(() => openCommentsSignal.set(clipId), 150); + } + } else { + console.log('[Layout] navigating to feed with deep link:', url); + // eslint-disable-next-line svelte/no-navigation-without-resolve -- resolve() expects route ID, not URL with query params + goto(url); + } + } + + /** Clear the notification URL stash (called after successful postMessage delivery). */ + async function clearNotificationStash() { + try { + const cache = await caches.open(NOTIFICATION_CLICK_CACHE); + await cache.delete(NOTIFICATION_CLICK_KEY); + } catch { + /* ignore */ + } + } + + /** + * Check for a stashed notification URL from the service worker. + * Covers frozen/backgrounded PWA clients where postMessage was silently lost. + */ + async function checkStashedNotification() { + try { + const cache = await caches.open(NOTIFICATION_CLICK_CACHE); + const res = await cache.match(NOTIFICATION_CLICK_KEY); + if (!res) return; + const { url, ts } = await res.json(); + await cache.delete(NOTIFICATION_CLICK_KEY); + if (Date.now() - ts > 30_000) return; + console.log('[Layout] found stashed notification URL:', url); + handleNotificationUrl(url); + } catch { + /* ignore */ + } + } + onMount(() => { isStandalone.set(detectStandaloneMode()); initInstallPrompt(); @@ -28,35 +80,35 @@ if (accent) updateFavicon(accent); // Listen for push notification clicks forwarded by the service worker. - // Uses postMessage instead of client.navigate() to avoid full-page reloads - // that race with SvelteKit hydration on Android PWA. + // Uses postMessage for smooth in-app navigation when the app is active. function handleSwMessage(event: MessageEvent) { if (event.data?.type !== 'NOTIFICATION_CLICK') return; const url = event.data.url || '/'; console.log('[Layout] NOTIFICATION_CLICK received, url:', url); + clearNotificationStash(); + handleNotificationUrl(url); + } - const parsed = new URL(url, window.location.origin); - const clipId = parsed.searchParams.get('clip'); - const comments = parsed.searchParams.get('comments') === 'true'; - - if (clipId && window.location.pathname === '/') { - // Already on feed — open overlay directly via signal (same as ActivitySheet) - console.log('[Layout] on feed, opening overlay via signal:', clipId); - clipOverlaySignal.set(clipId); - if (comments) { - setTimeout(() => openCommentsSignal.set(clipId), 150); - } - } else { - // On a different page — use SvelteKit client-side navigation - console.log('[Layout] navigating to feed with deep link:', url); - // eslint-disable-next-line svelte/no-navigation-without-resolve -- resolve() expects route ID, not URL with query params - goto(url); + // Fallback for frozen/backgrounded PWA clients: when the page becomes + // visible again, check if the SW stashed a notification URL in the Cache API. + function handleVisibilityChange() { + if (document.visibilityState === 'visible') { + checkStashedNotification(); } } navigator.serviceWorker?.addEventListener('message', handleSwMessage); + document.addEventListener('visibilitychange', handleVisibilityChange); + + // Also check on mount — covers the case where the app was just opened + // from a killed state via openWindow and the stash wasn't cleared yet. + // Delay slightly so the feed page's own deep-link handler (which reads + // ?clip= and clears the stash) has time to run first, avoiding duplicates. + setTimeout(() => checkStashedNotification(), 100); + return () => { navigator.serviceWorker?.removeEventListener('message', handleSwMessage); + document.removeEventListener('visibilitychange', handleVisibilityChange); }; }); @@ -66,6 +118,7 @@ + From d67dd4aeba31ff146cd77d069da4713920feab66 Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Tue, 10 Mar 2026 20:17:29 -0500 Subject: [PATCH 05/19] docs: document clout system in api.md and data-model.md --- docs/api.md | 28 ++++++++++++++++++++++++++++ docs/data-model.md | 1 + 2 files changed, 29 insertions(+) diff --git a/docs/api.md b/docs/api.md index abc8ac0..950b0fc 100644 --- a/docs/api.md +++ b/docs/api.md @@ -237,6 +237,34 @@ Extends the trim deadline for a music clip in `pending_trim` status. The client Response: { "ok": true } ``` +## Clout (Reputation) + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/clout` | Get user's clout score, tier, and breakdown | + +### GET /api/clout +Returns the user's clout score and tier when queue pacing is enabled. Clout is computed from the engagement on the user's last 10 matured clips (48h+ old). Users with fewer than 10 matured clips default to Rising tier. +``` +Response: { + "enabled": true, + "score": 0.8, + "tier": "viral", + "tierName": "Viral", + "cooldownMinutes": 120, + "burstSize": 3, + "queueLimit": null, + "icon": "/icons/clout/viral.png", + "breakdown": [{ "clipId": "...", "score": 2 }, ...], + "nextTier": { "tier": "iconic", "tierName": "Iconic", "minScore": 1.0, "burst": 5, "queueLimit": null, "icon": "..." }, + "underperforming": [{ "clipId": "...", "title": "...", "platform": "tiktok", "originalUrl": "...", "thumbnailPath": "..." }] +} +``` + +**Tiers:** Fresh (<0.4) → Rising (0.4–0.6) → Viral (0.7–0.9) → Iconic (≥1.0). Each tier adjusts cooldown multiplier, burst size, and queue depth limit. + +**Per-clip scoring:** 0 = no reactions/favorites from others, 1 = reaction or favorite but no comment, 2 = reaction/favorite AND comment. Self-interactions excluded. + ## Queue Management Manage queued clips when share pacing is enabled. diff --git a/docs/data-model.md b/docs/data-model.md index 7868ede..8f6304a 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -249,3 +249,4 @@ users 1──∞ verification_codes - **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. +- **Clout (reputation):** Computed on-demand from existing tables — no new schema. A user's clout score is the rolling average of per-clip engagement scores (0/1/2) for their last 10 matured clips (48h+ old). Scores are derived from `reactions`, `favorites`, and `comments` tables, excluding self-interactions. Tiers (Fresh/Rising/Viral/Iconic) determine queue cooldown multiplier, burst size, and queue depth limits. From 41998b102dab6f7b0d10df599c13d6a3820ddd64 Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Tue, 10 Mar 2026 20:19:46 -0500 Subject: [PATCH 06/19] feat: add clout tier icons for queue pacing UI --- static/icons/clout/fresh.png | Bin 0 -> 20169 bytes static/icons/clout/iconic.png | Bin 0 -> 38977 bytes static/icons/clout/rising.png | Bin 0 -> 20108 bytes static/icons/clout/viral.png | Bin 0 -> 21432 bytes 4 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 static/icons/clout/fresh.png create mode 100644 static/icons/clout/iconic.png create mode 100644 static/icons/clout/rising.png create mode 100644 static/icons/clout/viral.png diff --git a/static/icons/clout/fresh.png b/static/icons/clout/fresh.png new file mode 100644 index 0000000000000000000000000000000000000000..92293a712c4ab123c60e6aa318d1fab50a43fff2 GIT binary patch literal 20169 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 literal 0 HcmV?d00001 diff --git a/static/icons/clout/iconic.png b/static/icons/clout/iconic.png new file mode 100644 index 0000000000000000000000000000000000000000..cd33cd903135b530b06a49062cfdaa57fd352f7f GIT binary patch literal 38977 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⋘|JPsO>SXIYYFgCO5#bG| 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 oovHTF>#Ln0ob1Rx*= z_TK#qrGe#InLp+9wU+M9%3cy)jb?Bbtr2%w8o;~A|FmgrDOa@7+@1|Tbw4WN1xf${ zgl#I;6TcpNQR}evH%_(+nzX2%IMi&abR=7ZIeBJqgY(uA{^i6|#dDT!`%WK=36ow? z0uVsyZ5#hwJ%CplH8eW;ev3MD#39S#&~=&yh$^$gO~^)_H=Bp>s`S>e_jwx;@q!{c z0Meb*-r`Rt8ds0zmf?ZqQv4=Pux`gvsW0bLES1OLnoE#caxxf;W}ckX#v!u+&mD0N zy*>kN`VcIGvS24HjRPlor~pu>@MKNXerxG&>h%W|B>(}oY_eF#Brb2Re)FFtma)H< z;+VIDo24;;26Pv-^t@N#lvn*BQln=9QXrT>DzlU4!nB( z%D%l{uhJ`k+*vPd9<&M$^QQ62;?fV?f!Ofrk?!puPm&nY>2Nmw0FYmO8FFV{g2_kk!=d~CA1oul(;OZ# zkDD>!2IuM&zMa}K_BJsQ5if-IQyx*-tz#chkFeh~s(AK99a;qVLfO&N@f)9b5DGQ^ zFObbJNFg5^WK6=c7rzOsZ~PKs*&)&4_|CCY07jkH)Wgyh)cA)FB>*9m-dX$~<*~`7 zt;5*JO->^K$0?j~Aujr}F+jC4_S<9S>`V*p;bG#n)0f-|(UYAmp;!H~w(MEMCEY8vx9Rjz=g- z!XNZ=Sk#(-TWZ; z?B8Tq>o{#t4j>Yl{+eq)3?B8?tpnVO_U|Z`Uy9)it`r?czgQgj ziVn7R1Y`duIvCP_lmJB3O58g=im`WFur3AgKceID+Thw(iVh(V3^Nk>;0X<1{2I|A zc#Z*&0oHFW$Jl#Up&0G8K@mm?K*XJddmbOb*l*4*5}Xhnj~DW*&Hlbe>m^o^oJ2n#97+JlvrfUCrBRH%--2}+LNqOqJLzoEAp{2p zRH___tf*daQeUd8gE z9nSTHZcyhiUKkM_LeDt(b`D%Kg5qy2!^U5Wj)(XUB>-eWquBT#BN#fDf!zdPTy#9$ zVlox}aU$CL0BixT3OLPy57p5;e>ujAjLrfl3!u{g$q6ysrUSTb0$1N@g5S)5j{wMt zj>`$wV|BM!j3JkcPKBI&h9Md7^8l)K3%b#LyT@1{|K3dod^ra8*&!4wqJts+M+pGw z$TAe`06sT`tG{Ky`WV382}Gx$z8viKuwyZWA)S%~UlBLx@jGO3@NX-KeP}7}J}f#I z5*!hEtP!^?LGjQiHr~X+Uj$$SB2q{w3KW^4kPCj}A0ZZiyDiA;mSf}FsqrUH(Fg$P z%5rSn1mM~uxcVHx`Va%V4iOp=2%A`pVSpO{ps)b^rv&!VWhg!&Iv5gMGy*_+vK+=<~ln4ZqqS!ru zTKey6)bJBePy#?)^;{yrU&MfK0-&-oKE(j!vl0#a`m2MnYWRs4C;=dzSdNXG zMlkjchS z6S(?sE#RF1|5E^`h>k1jBnG6Dl>hq+-^;+Pd>oLYauQ~+ZP)<2Kno}Y^jhC~x307RH&C_W6};S%0(vx?Xafb}N;J419_ zQ4muo0T6@l;$XJ}>}$(WoD>}mi8e|Ah;Xw*o?qm+^~)1@`nR)E{JS+vPtX}A-e6MX zO63a0I8c~MOmX0kMj_q9P^2xg#1|+5Aigm>J@7;P6v+)2U4CUX(fYzvytP_04Z2TV zBvI?%7xA_{~f@;f9dyLoHuy$gNAOt zqpcY$X@-FKgb9Wp!(w-+O?~X9|9Gq^Iv_$iK?wi}jQ8FDizE~(Ksx zd-wAiM;*p-{Qb;q(pY2Nq-|1Y5{P1`pDmCK&aju8}Xg^y9fU${mR&XxQyUvY|T3p%>drkyE2*BLJ&8ecasb;}& zQFz7u_O=Ls_H$jFiAb~-E*w9NdRmB z3&1?aC15UVa{2IKyWZltLg*N~W>E^ElU=>0r^)=3n@%d(F}a44)90@4oxWGmE((B# zj71X*hy^lgE^6IQ?chOMZuM{>bh-$D5L&GElg8;vCrCCfG=^-%r9p`UavqXY@dx

2yI|-QPrZtSS3$S$n7zlqcR-= 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 F_#foM-s=DW literal 0 HcmV?d00001 diff --git a/static/icons/clout/viral.png b/static/icons/clout/viral.png new file mode 100644 index 0000000000000000000000000000000000000000..8d81e8da4add70596bf8dced8e52aa4532ffbc64 GIT binary patch literal 21432 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 literal 0 HcmV?d00001 From 3f8f877df56dfc4f133ee2da9a9981381d339bdd Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Tue, 10 Mar 2026 20:21:30 -0500 Subject: [PATCH 07/19] feat: use initials avatar fallback in push notification icons --- src/lib/server/api-utils.ts | 8 ++-- src/lib/server/mentions.ts | 8 ++-- src/lib/server/push.ts | 13 ++++-- src/routes/api/clips/[id]/comments/+server.ts | 8 ++-- .../api/clips/[id]/reactions/+server.ts | 12 +++-- .../avatar/initials/[username]/+server.ts | 46 +++++++++++++++++++ 6 files changed, 78 insertions(+), 17 deletions(-) create mode 100644 src/routes/api/profile/avatar/initials/[username]/+server.ts diff --git a/src/lib/server/api-utils.ts b/src/lib/server/api-utils.ts index ef41983..56d3e46 100644 --- a/src/lib/server/api-utils.ts +++ b/src/lib/server/api-utils.ts @@ -218,10 +218,12 @@ export async function notifyClipOwner(opts: { opts.type === 'comment' || opts.type === 'reply' ? `/?clip=${opts.clipId}&comments=true` : `/?clip=${opts.clipId}`; - const image = - opts.actorAvatarPath && env.ORIGIN + let image: string | undefined; + if (env.ORIGIN) { + image = opts.actorAvatarPath ? `${env.ORIGIN}/api/profile/avatar/${opts.actorAvatarPath}` - : undefined; + : `${env.ORIGIN}/api/profile/avatar/initials/${encodeURIComponent(opts.actorUsername)}`; + } sendNotification(opts.recipientId, { title: opts.pushTitle, body: opts.pushBody, diff --git a/src/lib/server/mentions.ts b/src/lib/server/mentions.ts index 10a435e..5777bc3 100644 --- a/src/lib/server/mentions.ts +++ b/src/lib/server/mentions.ts @@ -70,10 +70,12 @@ export async function notifyMentions(opts: { clip?.thumbnailPath && env.ORIGIN ? `${env.ORIGIN}/api/thumbnails/${basename(clip.thumbnailPath)}` : undefined; - const icon = - opts.actorAvatarPath && env.ORIGIN + let icon: string | undefined; + if (env.ORIGIN) { + icon = opts.actorAvatarPath ? `${env.ORIGIN}/api/profile/avatar/${opts.actorAvatarPath}` - : undefined; + : `${env.ORIGIN}/api/profile/avatar/initials/${encodeURIComponent(opts.actorUsername)}`; + } // Look up all active members in the group const groupMembers = await db.query.users.findMany({ diff --git a/src/lib/server/push.ts b/src/lib/server/push.ts index 3447549..4ea3fd8 100644 --- a/src/lib/server/push.ts +++ b/src/lib/server/push.ts @@ -38,9 +38,14 @@ async function getUnwatchedCount(userId: string, groupId: string): Promise { clip.thumbnailPath && env.ORIGIN ? `${env.ORIGIN}/api/thumbnails/${basename(clip.thumbnailPath)}` : undefined; - const icon = avatarIconUrl(uploader.avatarPath); + const icon = avatarIconUrl(uploader.avatarPath, uploader.username); // Fallback body when no title: use platform name (e.g. "New TikTok") const platformLabels: Record = { diff --git a/src/routes/api/clips/[id]/comments/+server.ts b/src/routes/api/clips/[id]/comments/+server.ts index c7d960c..25a007e 100644 --- a/src/routes/api/clips/[id]/comments/+server.ts +++ b/src/routes/api/clips/[id]/comments/+server.ts @@ -207,10 +207,12 @@ async function dispatchCommentNotification( ): Promise { const type = parentId ? 'reply' : 'comment'; const pushTag = `${type}-${clipId}-${actor.id}`; - const icon = - actor.avatarPath && env.ORIGIN + let icon: string | undefined; + if (env.ORIGIN) { + icon = actor.avatarPath ? `${env.ORIGIN}/api/profile/avatar/${actor.avatarPath}` - : undefined; + : `${env.ORIGIN}/api/profile/avatar/initials/${encodeURIComponent(actor.username)}`; + } const groupMembers = await db.query.users.findMany({ where: and(eq(users.groupId, actor.groupId), isNull(users.removedAt)) diff --git a/src/routes/api/clips/[id]/reactions/+server.ts b/src/routes/api/clips/[id]/reactions/+server.ts index 8bdfbd8..4631e89 100644 --- a/src/routes/api/clips/[id]/reactions/+server.ts +++ b/src/routes/api/clips/[id]/reactions/+server.ts @@ -22,6 +22,13 @@ import { sendNotification } from '$lib/server/push'; import { env } from '$env/dynamic/private'; import { ALLOWED_EMOJIS } from '$lib/server/constants'; +function buildAvatarIconUrl(avatarPath: string | null, username: string): string | undefined { + if (!env.ORIGIN) return undefined; + return avatarPath + ? `${env.ORIGIN}/api/profile/avatar/${avatarPath}` + : `${env.ORIGIN}/api/profile/avatar/initials/${encodeURIComponent(username)}`; +} + export const GET: RequestHandler = withClipAuth(async ({ params }, { user }) => { const clipId = params.id; const userId = user.id; @@ -85,10 +92,7 @@ async function dispatchReactionNotification( const pushTitle = `${actor.username} reacted ${emoji}`; const pushTag = `reaction-${clipId}-${actor.id}`; - const icon = - actor.avatarPath && env.ORIGIN - ? `${env.ORIGIN}/api/profile/avatar/${actor.avatarPath}` - : undefined; + const icon = buildAvatarIconUrl(actor.avatarPath, actor.username); for (const recipient of targets) { const prefs = prefsMap.get(recipient.id); diff --git a/src/routes/api/profile/avatar/initials/[username]/+server.ts b/src/routes/api/profile/avatar/initials/[username]/+server.ts new file mode 100644 index 0000000..d910d1c --- /dev/null +++ b/src/routes/api/profile/avatar/initials/[username]/+server.ts @@ -0,0 +1,46 @@ +import type { RequestHandler } from './$types'; + +/** + * Deterministic color from a username string. + * Picks from a palette that looks good on push notification backgrounds. + */ +const COLORS = [ + '#FF6B35', // coral + '#A855F7', // violet + '#22D3EE', // cyan + '#FB7185', // rose + '#FACC15', // gold + '#34D399', // mint + '#38BDF8', // sky + '#E879F9', // fuchsia + '#F97316', // orange + '#6366F1' // indigo +]; + +function hashColor(username: string): string { + let hash = 0; + for (let i = 0; i < username.length; i++) { + hash = username.charCodeAt(i) + ((hash << 5) - hash); + } + return COLORS[Math.abs(hash) % COLORS.length]; +} + +export const GET: RequestHandler = async ({ params }) => { + const username = params.username; + const initial = username.charAt(0).toUpperCase(); + const bg = hashColor(username); + + const svg = ` + + ${initial} +`; + + return new Response(svg, { + headers: { + 'Content-Type': 'image/svg+xml', + 'Cache-Control': 'public, max-age=604800, immutable' + } + }); +}; From 6217a70357d8477126116c75f98a306329994ca9 Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Tue, 10 Mar 2026 20:21:40 -0500 Subject: [PATCH 08/19] fix: notification tap deep-link on backgrounded mobile PWAs Service worker's postMessage is silently dropped when the PWA client is frozen by the OS. Stash the notification URL in Cache API so the client can pick it up via visibilitychange when the app resumes. Also defer the feed's deep-link overlay open to avoid pushState before router init. --- src/routes/(app)/+page.svelte | 14 +++++- src/service-worker.ts | 88 ++++++++++++++++++++--------------- 2 files changed, 62 insertions(+), 40 deletions(-) diff --git a/src/routes/(app)/+page.svelte b/src/routes/(app)/+page.svelte index 9ca56a5..8268d74 100644 --- a/src/routes/(app)/+page.svelte +++ b/src/routes/(app)/+page.svelte @@ -832,13 +832,23 @@ 'comments:', deepComments ); - overlayOpenComments = deepComments; - clipOverlaySignal.set(deepClipId); // Clean URL without triggering navigation const clean = new URL(window.location.href); clean.searchParams.delete('clip'); clean.searchParams.delete('comments'); history.replaceState(null, '', clean.pathname + clean.search || '/'); + // Clear the notification stash so the layout's visibilitychange handler + // doesn't double-process this same deep-link. + caches + .open('notification-click') + .then((c) => c.delete('/__notification_url')) + .catch(() => {}); + // Defer overlay open until after SvelteKit's router is initialized — + // pushState throws if called before the router is ready (cold start). + setTimeout(() => { + overlayOpenComments = deepComments; + clipOverlaySignal.set(deepClipId); + }, 0); } loadInitialClips(); diff --git a/src/service-worker.ts b/src/service-worker.ts index 88dd729..c6924a5 100644 --- a/src/service-worker.ts +++ b/src/service-worker.ts @@ -8,6 +8,8 @@ import { build, files, version } from '$service-worker'; const sw = self as unknown as ServiceWorkerGlobalScope; const CACHE_NAME = `scrolly-${version}`; const OFFLINE_URL = '/offline'; +const NOTIFICATION_CLICK_CACHE = 'notification-click'; +const NOTIFICATION_CLICK_KEY = '/__notification_url'; // Assets to cache on install (app shell) const ASSETS = [...build, ...files]; @@ -118,44 +120,54 @@ sw.addEventListener('notificationclick', (event) => { console.log('[SW notificationclick] url:', url); event.waitUntil( - Promise.all([ - // Refresh badge with actual unwatched count instead of blindly clearing - fetch('/api/clips/unwatched-count') - .then((res) => (res.ok ? res.json() : null)) - .then((data) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const nav = sw.navigator as any; - if (data && data.count > 0) { - nav.setAppBadge?.(data.count)?.catch?.(() => {}); - } else { - nav.clearAppBadge?.()?.catch?.(() => {}); - } - }) - .catch(() => { - // Offline or fetch failed — clear badge as fallback - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (sw.navigator as any).clearAppBadge?.()?.catch?.(() => {}); - }), - // Focus existing client and send a message to handle navigation - // in-app (avoids full-page reload race conditions from client.navigate), - // or open a new window with the deep-link URL as fallback. - sw.clients.matchAll({ type: 'window', includeUncontrolled: true }).then(async (clients) => { - console.log('[SW notificationclick] found', clients.length, 'client(s)'); - for (const client of clients) { - if (client.url.includes(sw.location.origin) && 'focus' in client) { - console.log('[SW notificationclick] focusing existing client:', client.url); - const focused = await client.focus(); - // Post a message instead of navigate — lets SvelteKit handle - // the deep-link client-side without a full page reload. - console.log('[SW notificationclick] posting NOTIFICATION_CLICK message'); - focused.postMessage({ type: 'NOTIFICATION_CLICK', url }); - return; - } - } - console.log('[SW notificationclick] no existing client, opening new window'); - return sw.clients.openWindow(url); - }) - ]) + // Stash URL in Cache API so the client can pick it up on visibilitychange. + // Covers frozen/backgrounded PWA clients where postMessage is silently lost. + caches + .open(NOTIFICATION_CLICK_CACHE) + .then((cache) => + cache.put(NOTIFICATION_CLICK_KEY, new Response(JSON.stringify({ url, ts: Date.now() }))) + ) + .catch(() => {}) + .then(() => + Promise.all([ + // Refresh badge with actual unwatched count instead of blindly clearing + fetch('/api/clips/unwatched-count') + .then((res) => (res.ok ? res.json() : null)) + .then((data) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const nav = sw.navigator as any; + if (data && data.count > 0) { + nav.setAppBadge?.(data.count)?.catch?.(() => {}); + } else { + nav.clearAppBadge?.()?.catch?.(() => {}); + } + }) + .catch(() => { + // Offline or fetch failed — clear badge as fallback + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (sw.navigator as any).clearAppBadge?.()?.catch?.(() => {}); + }), + // Focus existing client and send a message to handle navigation + // in-app (avoids full-page reload race conditions from client.navigate), + // or open a new window with the deep-link URL as fallback. + sw.clients + .matchAll({ type: 'window', includeUncontrolled: true }) + .then(async (clients) => { + console.log('[SW notificationclick] found', clients.length, 'client(s)'); + for (const client of clients) { + if (client.url.includes(sw.location.origin) && 'focus' in client) { + console.log('[SW notificationclick] focusing existing client:', client.url); + const focused = await client.focus(); + console.log('[SW notificationclick] posting NOTIFICATION_CLICK message'); + focused.postMessage({ type: 'NOTIFICATION_CLICK', url }); + return; + } + } + console.log('[SW notificationclick] no existing client, opening new window'); + return sw.clients.openWindow(url); + }) + ]) + ) ); }); From e3bf9b1988d30a48d1ddace352f924f15c2b6d0e Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Tue, 10 Mar 2026 20:59:41 -0500 Subject: [PATCH 09/19] feat: add cloutEnabled, cloutTier, and cloutChangeShownAt schema columns Add cloutEnabled toggle to groups table and cloutTier/cloutChangeShownAt tracking columns to users table for server-side tier change detection. --- .../db/migrations/0030_mean_bruce_banner.sql | 2 + .../db/migrations/0031_futuristic_shard.sql | 1 + .../db/migrations/meta/0030_snapshot.json | 1482 ++++++++++++++++ .../db/migrations/meta/0031_snapshot.json | 1490 +++++++++++++++++ .../server/db/migrations/meta/_journal.json | 14 + src/lib/server/db/schema.ts | 3 + 6 files changed, 2992 insertions(+) create mode 100644 src/lib/server/db/migrations/0030_mean_bruce_banner.sql create mode 100644 src/lib/server/db/migrations/0031_futuristic_shard.sql create mode 100644 src/lib/server/db/migrations/meta/0030_snapshot.json create mode 100644 src/lib/server/db/migrations/meta/0031_snapshot.json diff --git a/src/lib/server/db/migrations/0030_mean_bruce_banner.sql b/src/lib/server/db/migrations/0030_mean_bruce_banner.sql new file mode 100644 index 0000000..232ddf6 --- /dev/null +++ b/src/lib/server/db/migrations/0030_mean_bruce_banner.sql @@ -0,0 +1,2 @@ +ALTER TABLE `users` ADD `clout_tier` text;--> statement-breakpoint +ALTER TABLE `users` ADD `clout_change_shown_at` integer; \ No newline at end of file diff --git a/src/lib/server/db/migrations/0031_futuristic_shard.sql b/src/lib/server/db/migrations/0031_futuristic_shard.sql new file mode 100644 index 0000000..bb812e9 --- /dev/null +++ b/src/lib/server/db/migrations/0031_futuristic_shard.sql @@ -0,0 +1 @@ +ALTER TABLE `groups` ADD `clout_enabled` integer DEFAULT true NOT NULL; \ No newline at end of file diff --git a/src/lib/server/db/migrations/meta/0030_snapshot.json b/src/lib/server/db/migrations/meta/0030_snapshot.json new file mode 100644 index 0000000..c6f23e3 --- /dev/null +++ b/src/lib/server/db/migrations/meta/0030_snapshot.json @@ -0,0 +1,1482 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "967989f4-845f-42ca-bef2-653bcf815e91", + "prevId": "93f4d71d-17b3-408a-9632-8533a9836edb", + "tables": { + "clip_queue": { + "name": "clip_queue", + "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 + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scheduled_at": { + "name": "scheduled_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "clip_queue_user_group": { + "name": "clip_queue_user_group", + "columns": [ + "user_id", + "group_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "clip_queue_clip_id_clips_id_fk": { + "name": "clip_queue_clip_id_clips_id_fk", + "tableFrom": "clip_queue", + "tableTo": "clips", + "columnsFrom": [ + "clip_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "clip_queue_user_id_users_id_fk": { + "name": "clip_queue_user_id_users_id_fk", + "tableFrom": "clip_queue", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "clip_queue_group_id_groups_id_fk": { + "name": "clip_queue_group_id_groups_id_fk", + "tableFrom": "clip_queue", + "tableTo": "groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "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 + }, + "source_view_count": { + "name": "source_view_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trim_deadline": { + "name": "trim_deadline", + "type": "integer", + "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": {} + }, + "dismissed_clips": { + "name": "dismissed_clips", + "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 + }, + "dismissed_at": { + "name": "dismissed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "dismissed_clips_unique": { + "name": "dismissed_clips_unique", + "columns": [ + "clip_id", + "user_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "dismissed_clips_clip_id_clips_id_fk": { + "name": "dismissed_clips_clip_id_clips_id_fk", + "tableFrom": "dismissed_clips", + "tableTo": "clips", + "columnsFrom": [ + "clip_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "dismissed_clips_user_id_users_id_fk": { + "name": "dismissed_clips_user_id_users_id_fk", + "tableFrom": "dismissed_clips", + "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 + }, + "daily_share_limit": { + "name": "daily_share_limit", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "share_pacing_mode": { + "name": "share_pacing_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'off'" + }, + "share_burst": { + "name": "share_burst", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 2 + }, + "share_cooldown_minutes": { + "name": "share_cooldown_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 120 + }, + "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 + }, + "last_legacy_share_at": { + "name": "last_legacy_share_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "used_new_share_flow": { + "name": "used_new_share_flow", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "clout_tier": { + "name": "clout_tier", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "clout_change_shown_at": { + "name": "clout_change_shown_at", + "type": "integer", + "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/0031_snapshot.json b/src/lib/server/db/migrations/meta/0031_snapshot.json new file mode 100644 index 0000000..e57fdd7 --- /dev/null +++ b/src/lib/server/db/migrations/meta/0031_snapshot.json @@ -0,0 +1,1490 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "10513a08-c2e1-4d2f-99fb-3daf5dd552e4", + "prevId": "967989f4-845f-42ca-bef2-653bcf815e91", + "tables": { + "clip_queue": { + "name": "clip_queue", + "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 + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scheduled_at": { + "name": "scheduled_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "clip_queue_user_group": { + "name": "clip_queue_user_group", + "columns": [ + "user_id", + "group_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "clip_queue_clip_id_clips_id_fk": { + "name": "clip_queue_clip_id_clips_id_fk", + "tableFrom": "clip_queue", + "tableTo": "clips", + "columnsFrom": [ + "clip_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "clip_queue_user_id_users_id_fk": { + "name": "clip_queue_user_id_users_id_fk", + "tableFrom": "clip_queue", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "clip_queue_group_id_groups_id_fk": { + "name": "clip_queue_group_id_groups_id_fk", + "tableFrom": "clip_queue", + "tableTo": "groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "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 + }, + "source_view_count": { + "name": "source_view_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trim_deadline": { + "name": "trim_deadline", + "type": "integer", + "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": {} + }, + "dismissed_clips": { + "name": "dismissed_clips", + "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 + }, + "dismissed_at": { + "name": "dismissed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "dismissed_clips_unique": { + "name": "dismissed_clips_unique", + "columns": [ + "clip_id", + "user_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "dismissed_clips_clip_id_clips_id_fk": { + "name": "dismissed_clips_clip_id_clips_id_fk", + "tableFrom": "dismissed_clips", + "tableTo": "clips", + "columnsFrom": [ + "clip_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "dismissed_clips_user_id_users_id_fk": { + "name": "dismissed_clips_user_id_users_id_fk", + "tableFrom": "dismissed_clips", + "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 + }, + "daily_share_limit": { + "name": "daily_share_limit", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "share_pacing_mode": { + "name": "share_pacing_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'off'" + }, + "share_burst": { + "name": "share_burst", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 2 + }, + "share_cooldown_minutes": { + "name": "share_cooldown_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 120 + }, + "clout_enabled": { + "name": "clout_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "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 + }, + "last_legacy_share_at": { + "name": "last_legacy_share_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "used_new_share_flow": { + "name": "used_new_share_flow", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "clout_tier": { + "name": "clout_tier", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "clout_change_shown_at": { + "name": "clout_change_shown_at", + "type": "integer", + "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 c927dfa..42aea4c 100644 --- a/src/lib/server/db/migrations/meta/_journal.json +++ b/src/lib/server/db/migrations/meta/_journal.json @@ -211,6 +211,20 @@ "when": 1773098368569, "tag": "0029_chunky_sentry", "breakpoints": true + }, + { + "idx": 30, + "version": "6", + "when": 1773193628213, + "tag": "0030_mean_bruce_banner", + "breakpoints": true + }, + { + "idx": 31, + "version": "6", + "when": 1773193972601, + "tag": "0031_futuristic_shard", + "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 54461eb..1cb47db 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -15,6 +15,7 @@ export const groups = sqliteTable('groups', { sharePacingMode: text('share_pacing_mode').notNull().default('off'), shareBurst: integer('share_burst').notNull().default(2), shareCooldownMinutes: integer('share_cooldown_minutes').notNull().default(120), + cloutEnabled: integer('clout_enabled', { mode: 'boolean' }).notNull().default(true), shortcutToken: text('shortcut_token').unique(), shortcutUrl: text('shortcut_url'), createdBy: text('created_by'), @@ -35,6 +36,8 @@ export const users = sqliteTable('users', { avatarPath: text('avatar_path'), lastLegacyShareAt: integer('last_legacy_share_at', { mode: 'timestamp' }), usedNewShareFlow: integer('used_new_share_flow', { mode: 'boolean' }).notNull().default(false), + cloutTier: text('clout_tier'), + cloutChangeShownAt: integer('clout_change_shown_at', { mode: 'timestamp' }), removedAt: integer('removed_at', { mode: 'timestamp' }), createdAt: integer('created_at', { mode: 'timestamp' }).notNull() }); From 78db26cfd7938d8ad1ef3abd4b558e81162db1c9 Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Tue, 10 Mar 2026 20:59:54 -0500 Subject: [PATCH 10/19] feat: add clout toggle and server-driven tier change detection - Conditionally skip clout scoring when cloutEnabled is off in share-limit - Add tier change detection with 3-day cooldown in clout API - Add POST /api/clout endpoint to acknowledge tier change modals - Add dev-only ?tier= query param for testing - Support cloutEnabled in share-pacing PATCH endpoint --- src/lib/server/share-limit.ts | 35 +++++++---- src/routes/api/clout/+server.ts | 61 ++++++++++++++++++-- src/routes/api/group/share-pacing/+server.ts | 7 ++- 3 files changed, 87 insertions(+), 16 deletions(-) diff --git a/src/lib/server/share-limit.ts b/src/lib/server/share-limit.ts index 9886b37..fa27782 100644 --- a/src/lib/server/share-limit.ts +++ b/src/lib/server/share-limit.ts @@ -158,6 +158,7 @@ interface GroupPacingConfig { dailyShareLimit: number | null; shareBurst: number; shareCooldownMinutes: number; + cloutEnabled: boolean; } /** @@ -193,19 +194,33 @@ export function checkSharePacing( }; } case 'queue': { - const clout = getCloutScore(userId, groupId, group.shareCooldownMinutes); - const burst = checkBurstAvailable(userId, groupId, clout.burstSize, clout.cooldownMinutes); + if (group.cloutEnabled) { + const clout = getCloutScore(userId, groupId, group.shareCooldownMinutes); + const burst = checkBurstAvailable(userId, groupId, clout.burstSize, clout.cooldownMinutes); + return { + mode: 'queue', + queued: !burst.available, + nextSlotAt: burst.nextSlotAt ?? undefined, + clout: { + cooldownMinutes: clout.cooldownMinutes, + burstSize: clout.burstSize, + queueLimit: clout.queueLimit, + tier: clout.tier, + tierName: clout.tierConfig.name + } + }; + } + // Clout disabled — use base group settings directly + const burst = checkBurstAvailable( + userId, + groupId, + group.shareBurst, + group.shareCooldownMinutes + ); return { mode: 'queue', queued: !burst.available, - nextSlotAt: burst.nextSlotAt ?? undefined, - clout: { - cooldownMinutes: clout.cooldownMinutes, - burstSize: clout.burstSize, - queueLimit: clout.queueLimit, - tier: clout.tier, - tierName: clout.tierConfig.name - } + nextSlotAt: burst.nextSlotAt ?? undefined }; } default: diff --git a/src/routes/api/clout/+server.ts b/src/routes/api/clout/+server.ts index 7dcfc2b..b6192e0 100644 --- a/src/routes/api/clout/+server.ts +++ b/src/routes/api/clout/+server.ts @@ -1,14 +1,34 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { withAuth } from '$lib/server/api-utils'; -import { getCloutScore, getNextTier } from '$lib/server/clout'; +import { getCloutScore, getNextTier, TIERS } from '$lib/server/clout'; +import { db } from '$lib/server/db'; +import { users } from '$lib/server/db/schema'; +import { eq } from 'drizzle-orm'; +import { dev } from '$app/environment'; -export const GET: RequestHandler = withAuth(async (_event, { user, group }) => { - if (group.sharePacingMode !== 'queue') { +const MODAL_COOLDOWN_MS = 3 * 24 * 60 * 60 * 1000; // 3 days + +export const GET: RequestHandler = withAuth(async (event, { user, group }) => { + if (group.sharePacingMode !== 'queue' || !group.cloutEnabled) { return json({ enabled: false }); } const result = getCloutScore(user.id, user.groupId, group.shareCooldownMinutes); + + // DEV ONLY: ?tier=rising to force a specific tier for testing + if (dev) { + const forceTier = event.url.searchParams.get('tier'); + if (forceTier && forceTier in TIERS) { + const config = TIERS[forceTier as keyof typeof TIERS]; + result.tier = forceTier as keyof typeof TIERS; + result.tierConfig = config; + result.cooldownMinutes = Math.round(group.shareCooldownMinutes * config.cooldownMultiplier); + result.burstSize = config.burst; + result.queueLimit = config.queueLimit; + } + } + const nextTier = getNextTier(result.tier); // Low-scoring clips (score 0) that drag the average down @@ -22,6 +42,24 @@ export const GET: RequestHandler = withAuth(async (_event, { user, group }) => { thumbnailPath: b.thumbnailPath })); + // Determine if a tier change modal should be shown. + // cloutTier tracks the last tier the user was NOTIFIED about (updated on POST ack). + // This means if a change is suppressed by cooldown, it's preserved until the + // cooldown expires — then the modal shows the accumulated change. + const lastAckedTier = user.cloutTier; + const lastShownAt = user.cloutChangeShownAt; + let tierChanged = false; + + if (!lastAckedTier) { + // First time — seed the tier silently (no modal on first load) + db.update(users).set({ cloutTier: result.tier }).where(eq(users.id, user.id)).run(); + } else if (lastAckedTier !== result.tier) { + const cooldownElapsed = !lastShownAt || Date.now() - lastShownAt.getTime() >= MODAL_COOLDOWN_MS; + tierChanged = cooldownElapsed; + // Don't update cloutTier here — only on POST ack, so the change persists + // until the user actually sees the modal + } + return json({ enabled: true, score: result.score, @@ -43,6 +81,21 @@ export const GET: RequestHandler = withAuth(async (_event, { user, group }) => { icon: nextTier.config.icon } : null, - underperforming + underperforming, + lastTier: lastAckedTier ?? null, + tierChanged }); }); + +/** Acknowledge that the tier change modal was shown. Updates both the + * last-shown timestamp (cooldown) and the acked tier so future checks + * compare against the tier the user was actually notified about. */ +export const POST: RequestHandler = withAuth(async (_event, { user, group }) => { + // Recompute current tier so we store the accurate value + const result = getCloutScore(user.id, user.groupId, group.shareCooldownMinutes); + db.update(users) + .set({ cloutTier: result.tier, cloutChangeShownAt: new Date() }) + .where(eq(users.id, user.id)) + .run(); + return json({ ok: true }); +}); diff --git a/src/routes/api/group/share-pacing/+server.ts b/src/routes/api/group/share-pacing/+server.ts index 85648b8..5b3e005 100644 --- a/src/routes/api/group/share-pacing/+server.ts +++ b/src/routes/api/group/share-pacing/+server.ts @@ -14,6 +14,7 @@ interface PacingBody { shareBurst?: number; shareCooldownMinutes?: number; dailyShareLimit?: number | null; + cloutEnabled?: boolean; } function validatePacingBody(body: PacingBody): string | null { @@ -53,13 +54,14 @@ export const PATCH: RequestHandler = withHost(async ({ request }, { group }) => const error = validatePacingBody(body); if (error) return badRequest(error); - const { sharePacingMode, shareBurst, shareCooldownMinutes, dailyShareLimit } = body; + const { sharePacingMode, shareBurst, shareCooldownMinutes, dailyShareLimit, cloutEnabled } = body; const updates: Record = {}; if (sharePacingMode !== undefined) updates.sharePacingMode = sharePacingMode; if (shareBurst !== undefined) updates.shareBurst = shareBurst; if (shareCooldownMinutes !== undefined) updates.shareCooldownMinutes = shareCooldownMinutes; if (dailyShareLimit !== undefined) updates.dailyShareLimit = dailyShareLimit ?? null; + if (cloutEnabled !== undefined) updates.cloutEnabled = cloutEnabled; if (Object.keys(updates).length === 0) return badRequest('No fields to update.'); @@ -75,6 +77,7 @@ export const PATCH: RequestHandler = withHost(async ({ request }, { group }) => shareBurst: (updates.shareBurst as number) ?? group.shareBurst, shareCooldownMinutes: (updates.shareCooldownMinutes as number) ?? group.shareCooldownMinutes, dailyShareLimit: - dailyShareLimit !== undefined ? (dailyShareLimit ?? null) : group.dailyShareLimit + dailyShareLimit !== undefined ? (dailyShareLimit ?? null) : group.dailyShareLimit, + cloutEnabled: cloutEnabled !== undefined ? cloutEnabled : group.cloutEnabled }); }); From 08457cf2cd89ecfcd3ace4c7f2987fc79d81800c Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Tue, 10 Mar 2026 21:03:32 -0500 Subject: [PATCH 11/19] refactor: extract CloutTipsView and enhance CloutChangeModal Extract tips/breakdown view into CloutTipsView component to stay under max-lines limit. Add tier cycling animation, drag-to-dismiss gesture, and close button to CloutChangeModal. --- src/lib/components/CloutChangeModal.svelte | 662 ++++++++++++--------- src/lib/components/CloutTipsView.svelte | 486 +++++++++++++++ 2 files changed, 861 insertions(+), 287 deletions(-) create mode 100644 src/lib/components/CloutTipsView.svelte diff --git a/src/lib/components/CloutChangeModal.svelte b/src/lib/components/CloutChangeModal.svelte index ecbaf84..47e8cda 100644 --- a/src/lib/components/CloutChangeModal.svelte +++ b/src/lib/components/CloutChangeModal.svelte @@ -2,8 +2,8 @@ import { cloutChange } from '$lib/stores/cloutChange'; import { anySheetOpen } from '$lib/stores/sheetOpen'; import { confirmState } from '$lib/stores/confirm'; - import { basename } from '$lib/utils'; - import PlatformIcon from './PlatformIcon.svelte'; + import XIcon from 'phosphor-svelte/lib/XIcon'; + import CloutTipsView from './CloutTipsView.svelte'; const TIER_INFO: Record = { fresh: { label: 'Fresh', icon: '/icons/clout/fresh.png' }, @@ -14,14 +14,6 @@ const TIER_ORDER = ['fresh', 'rising', 'viral', 'iconic']; - interface Underperformer { - clipId: string; - title: string | null; - platform: string; - originalUrl: string; - thumbnailPath: string | null; - } - interface NextTierInfo { tier: string; tierName: string; @@ -31,6 +23,14 @@ icon: string; } + interface Underperformer { + clipId: string; + title: string | null; + platform: string; + originalUrl: string; + thumbnailPath: string | null; + } + let visible = $state(false); let change = $state(null); let showTips = $state(false); @@ -40,7 +40,60 @@ breakdown: { clipId: string; score: number }[]; } | null>(null); let tipsLoading = $state(false); - let autoTimer: ReturnType | undefined; + + // Tier cycling animation state + let cycleIndex = $state(0); + let cycling = $state(false); + let cycleTimers: ReturnType[] = []; + + const displayTier = $derived.by(() => { + if (!change) return TIER_INFO.fresh; + if (!cycling) return TIER_INFO[change.newTier] ?? TIER_INFO.fresh; + const startIdx = TIER_ORDER.indexOf(change.previousTier); + const tierKey = TIER_ORDER[startIdx + cycleIndex] ?? change.newTier; + return TIER_INFO[tierKey] ?? TIER_INFO.fresh; + }); + + const animationDone = $derived(!cycling); + + function startCycleAnimation() { + if (!change) return; + const startIdx = TIER_ORDER.indexOf(change.previousTier); + const endIdx = TIER_ORDER.indexOf(change.newTier); + const steps = Math.abs(endIdx - startIdx); + if (steps <= 0) return; + + cycling = true; + cycleIndex = 0; + cycleTimers = []; + + for (let i = 1; i <= steps; i++) { + const timer = setTimeout(() => { + const dir = + TIER_ORDER.indexOf(change!.newTier) > TIER_ORDER.indexOf(change!.previousTier) ? 1 : -1; + cycleIndex = dir * i; + if (i === steps) { + cycling = false; + } + }, i * 400); + cycleTimers.push(timer); + } + } + + function clearCycleTimers() { + cycleTimers.forEach(clearTimeout); + cycleTimers = []; + } + + // Drag-to-dismiss state + let dragZoneEl: HTMLElement | null = $state(null); + let dragY = $state(0); + let dragging = $state(false); + let dragTracking = false; + let dragStartY = 0; + let dragStartX = 0; + const DRAG_COMMIT = 6; + const DRAG_DISMISS = 120; // Deferred display: wait until no sheets or confirm dialogs are open $effect(() => { @@ -54,24 +107,62 @@ tipsData = null; requestAnimationFrame(() => { visible = true; + // Start cycling after sheet slides up + setTimeout(() => startCycleAnimation(), 350); }); - autoTimer = setTimeout(dismiss, 5000); } }); function dismiss() { - if (autoTimer) clearTimeout(autoTimer); visible = false; + clearCycleTimers(); + cycling = false; setTimeout(() => { change = null; showTips = false; tipsData = null; cloutChange.set(null); - }, 200); + }, 300); + } + + function startDrag(e: PointerEvent) { + dragTracking = true; + dragStartY = e.clientY; + dragStartX = e.clientX; + dragY = 0; + } + + function moveDrag(e: PointerEvent) { + if (!dragTracking) return; + const dy = e.clientY - dragStartY; + const dx = e.clientX - dragStartX; + + if (!dragging) { + if (dy > DRAG_COMMIT && dy > Math.abs(dx)) { + dragging = true; + dragZoneEl?.setPointerCapture(e.pointerId); + } else if (Math.abs(dx) > DRAG_COMMIT || dy < -DRAG_COMMIT) { + dragTracking = false; + } + return; + } + dragY = Math.max(0, dy); + } + + function endDrag() { + if (!dragTracking) return; + dragTracking = false; + if (!dragging) return; + dragging = false; + if (dragY > DRAG_DISMISS) { + dragY = 0; + dismiss(); + } else { + dragY = 0; + } } async function openTips() { - if (autoTimer) clearTimeout(autoTimer); if (tipsData) { showTips = true; return; @@ -98,7 +189,12 @@ showTips = false; } - const isRankUp = $derived(() => { + const isSameRank = $derived.by(() => { + if (!change) return false; + return change.previousTier === change.newTier; + }); + + const isRankUp = $derived.by(() => { if (!change) return false; return TIER_ORDER.indexOf(change.newTier) > TIER_ORDER.indexOf(change.previousTier); }); @@ -107,114 +203,121 @@ if (minutes < 60) return `${minutes}min`; return `${Math.round(minutes / 60)}h`; } - - function clipTitle(clip: Underperformer): string { - if (clip.title) return clip.title; - try { - return new URL(clip.originalUrl).hostname; - } catch { - return clip.originalUrl; - } - } - - // How many score-0 clips need to become score-1+ to reach next tier - const neededUpgrades = $derived(() => { - if (!tipsData?.nextTier || !tipsData.breakdown.length) return 0; - const total = tipsData.breakdown.reduce((s, b) => s + b.score, 0); - const count = tipsData.breakdown.length; - const targetAvg = tipsData.nextTier.minScore; - const needed = Math.ceil(targetAvg * count) - total; - return Math.max(0, needed); - }); {#if change} - {@const prev = TIER_INFO[change.previousTier] ?? TIER_INFO.fresh} - {@const next = TIER_INFO[change.newTier] ?? TIER_INFO.fresh} - {@const rankUp = isRankUp()} -

- From 561f9d889dece6a9a0bd189b7729ed115c43515f Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Tue, 10 Mar 2026 21:03:49 -0500 Subject: [PATCH 13/19] feat: show clout tier badge on profile page Display clout tier icon next to username on profile page. Tapping the badge opens the CloutChangeModal for viewing current rank details. --- src/routes/(app)/me/+page.svelte | 62 +++++++++++++++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/src/routes/(app)/me/+page.svelte b/src/routes/(app)/me/+page.svelte index 8b8c173..fb4faf8 100644 --- a/src/routes/(app)/me/+page.svelte +++ b/src/routes/(app)/me/+page.svelte @@ -17,6 +17,7 @@ import HeartIcon from 'phosphor-svelte/lib/HeartIcon'; import UploadSimpleIcon from 'phosphor-svelte/lib/UploadSimpleIcon'; import CameraIcon from 'phosphor-svelte/lib/CameraIcon'; + import { cloutChange } from '$lib/stores/cloutChange'; const user = $derived(page.data.user); const group = $derived(page.data.group); @@ -25,6 +26,14 @@ const gifEnabled = $derived(!!page.data.gifEnabled); let stats = $state<{ uploads: number; saves: number; minutesWatched: number } | null>(null); + let clout = $state<{ + enabled: boolean; + tier?: string; + tierName?: string; + icon?: string; + cooldownMinutes?: number; + burstSize?: number; + } | null>(null); async function loadStats() { try { @@ -35,6 +44,15 @@ } } + async function loadClout() { + try { + const res = await fetch('/api/clout'); + if (res.ok) clout = await res.json(); + } catch { + /* non-critical */ + } + } + function formatWatchTime(minutes: number | null | undefined): string { if (minutes === null || minutes === undefined) return '--'; if (minutes < 1) return '<1m'; @@ -42,6 +60,18 @@ return `${(minutes / 60).toFixed(1)}h`; } + function showCloutModal() { + if (!clout?.enabled || !clout.tier || !clout.tierName) return; + cloutChange.set({ + previousTier: clout.tier, + newTier: clout.tier, + previousTierName: clout.tierName, + newTierName: clout.tierName, + cooldownMinutes: clout.cooldownMinutes ?? 0, + burstSize: clout.burstSize ?? 1 + }); + } + // Avatar state let avatarCropImage = $state(null); let avatarOverride = $state(undefined); @@ -152,6 +182,7 @@ onMount(() => { loadFaves(); loadStats(); + loadClout(); }); @@ -182,7 +213,14 @@ {#if avatarPath} {/if} - @{user?.username} +
+ {user?.username} + {#if clout?.enabled && clout.tier && clout.icon} + + {/if} +
@@ -328,6 +366,11 @@ cursor: pointer; padding: 0; } + .name-row { + display: flex; + align-items: center; + gap: var(--space-sm); + } .profile-username { font-family: var(--font-display); font-size: 1.25rem; @@ -335,6 +378,23 @@ color: var(--text-primary); letter-spacing: -0.02em; } + .rank-badge { + display: flex; + align-items: center; + justify-content: center; + padding: 0; + background: none; + border: none; + cursor: pointer; + } + .rank-badge:active { + transform: scale(0.93); + } + .rank-icon { + width: 22px; + height: 22px; + object-fit: contain; + } .stats-row { display: flex; align-items: center; From 5c2aa383509919740226f992b0005399f8d5ee39 Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Tue, 10 Mar 2026 21:03:56 -0500 Subject: [PATCH 14/19] feat: use server-driven tier change detection for clout modal Replace localStorage-based tier tracking with server-side tier change detection. Acknowledge tier changes via POST /api/clout to sync across devices and enforce 3-day cooldown. --- src/routes/(app)/+layout.svelte | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/routes/(app)/+layout.svelte b/src/routes/(app)/+layout.svelte index a3f6f3b..1cc8f6c 100644 --- a/src/routes/(app)/+layout.svelte +++ b/src/routes/(app)/+layout.svelte @@ -47,17 +47,19 @@ if (!res.ok) return; const data = await res.json(); if (!data.enabled) return; - const storedTier = localStorage.getItem('clout-tier'); - localStorage.setItem('clout-tier', data.tier); - if (storedTier && storedTier !== data.tier) { + + // Server tells us if a tier change modal should be shown (3-day cooldown) + if (data.tierChanged && data.lastTier) { cloutChange.set({ - previousTier: storedTier, + previousTier: data.lastTier, newTier: data.tier, - previousTierName: TIER_NAMES[storedTier] ?? storedTier, + previousTierName: TIER_NAMES[data.lastTier] ?? data.lastTier, newTierName: data.tierName, cooldownMinutes: data.cooldownMinutes, burstSize: data.burstSize }); + // Acknowledge so it won't show again on other devices + fetch('/api/clout', { method: 'POST' }).catch(() => {}); } } catch { // silently fail From 8af075a8c1c882dd05f4fa17c635ae2f0ba01f40 Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Tue, 10 Mar 2026 21:04:02 -0500 Subject: [PATCH 15/19] docs: add clout tier icon attributions to CREDITS.md --- CREDITS.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CREDITS.md b/CREDITS.md index 55ccd45..73fe238 100644 --- a/CREDITS.md +++ b/CREDITS.md @@ -8,6 +8,15 @@ Brand icons (TikTok, Instagram, Facebook, Spotify, Apple Music, YouTube) sourced from [Simple Icons](https://simpleicons.org) — released under [CC0 1.0 Universal](https://creativecommons.org/publicdomain/zero/1.0/). +## Clout Tier Icons + +Icons from [Flaticon](https://www.flaticon.com), used under the [Flaticon License](https://www.flaticon.com/legal): + +- **Fresh** — [Toy Blocks](https://www.flaticon.com/free-icon/toy-blocks_10527515) by Freepik +- **Rising** — [Development](https://www.flaticon.com/free-icon/development_3657083) by Freepik +- **Viral** — [Trending](https://www.flaticon.com/free-icon/trending_18302932) by Freepik +- **Iconic** — [Trophy](https://www.flaticon.com/free-icon/trophy_2641497) by Freepik + ## Fonts Loaded via [Google Fonts](https://fonts.google.com), licensed under the [SIL Open Font License 1.1](https://opensource.org/licenses/OFL-1.1): From b73c25ab7fb479a9e78fa73761b703420792f53b Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Tue, 10 Mar 2026 21:08:16 -0500 Subject: [PATCH 16/19] refactor: extract QueueCloutBanner from QueueSheet Move clout banner template and styles into a dedicated component to keep QueueSheet under max-lines limit. --- src/lib/components/QueueCloutBanner.svelte | 110 +++++++++++++++++++++ src/lib/components/QueueSheet.svelte | 109 +------------------- 2 files changed, 114 insertions(+), 105 deletions(-) create mode 100644 src/lib/components/QueueCloutBanner.svelte diff --git a/src/lib/components/QueueCloutBanner.svelte b/src/lib/components/QueueCloutBanner.svelte new file mode 100644 index 0000000..04d029e --- /dev/null +++ b/src/lib/components/QueueCloutBanner.svelte @@ -0,0 +1,110 @@ + + +
+
+ {clout.tierName} +
+ {clout.tierName} + + {formatCooldown(clout.cooldownMinutes)} between clips · {clout.burstSize} per burst + +
+
+ {#if clout.breakdown.length > 0} +
+ {#each clout.breakdown as entry (entry.clipId)} + 0} class:full={entry.score === 2}> + {/each} + + {clout.breakdown.filter((b) => b.score > 0).length}/{clout.breakdown.length} got reactions + +
+ {:else if clout.score === -1} + Share more clips to build your score + {/if} +
+ + diff --git a/src/lib/components/QueueSheet.svelte b/src/lib/components/QueueSheet.svelte index 54a1f93..0555245 100644 --- a/src/lib/components/QueueSheet.svelte +++ b/src/lib/components/QueueSheet.svelte @@ -5,6 +5,7 @@ import { toast } from '$lib/stores/toasts'; import { fetchQueueCount } from '$lib/stores/queue'; import { basename } from '$lib/utils'; + import QueueCloutBanner from './QueueCloutBanner.svelte'; import ClockIcon from 'phosphor-svelte/lib/ClockIcon'; import TrashIcon from 'phosphor-svelte/lib/TrashIcon'; import QueueIcon from 'phosphor-svelte/lib/QueueIcon'; @@ -13,19 +14,8 @@ const { ondismiss }: { ondismiss: () => void } = $props(); - interface CloutData { - enabled: boolean; - tier: string; - tierName: string; - cooldownMinutes: number; - burstSize: number; - queueLimit: number | null; - icon: string; - score: number; - breakdown: { clipId: string; score: number }[]; - } - - let clout = $state(null); + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- API response passed through to QueueCloutBanner + let clout = $state(null); interface QueueItem { id: string; @@ -83,12 +73,6 @@ } } - function formatCooldown(minutes: number): string { - if (minutes < 60) return `${minutes}min`; - const h = Math.round(minutes / 60); - return `${h}h`; - } - function handleDragStart(e: PointerEvent, index: number) { e.stopPropagation(); e.preventDefault(); @@ -227,29 +211,7 @@ {/snippet} {#if clout} -
-
- {clout.tierName} -
- {clout.tierName} - - {formatCooldown(clout.cooldownMinutes)} between clips · {clout.burstSize} per burst - -
-
- {#if clout.breakdown.length > 0} -
- {#each clout.breakdown as entry (entry.clipId)} - 0} class:full={entry.score === 2}> - {/each} - - {clout.breakdown.filter((b) => b.score > 0).length}/{clout.breakdown.length} got reactions - -
- {:else if clout.score === -1} - Share more clips to build your score - {/if} -
+ {/if}
@@ -516,67 +478,4 @@ .action-btn.danger { color: var(--error); } - - .clout-banner { - padding: var(--space-md) var(--space-lg); - border-bottom: 1px solid var(--border); - background: var(--bg-elevated); - } - .clout-top { - display: flex; - align-items: center; - gap: var(--space-sm); - } - .clout-icon { - width: 32px; - height: 32px; - object-fit: contain; - } - .clout-info { - display: flex; - flex-direction: column; - gap: 1px; - } - .clout-tier-name { - font-family: var(--font-display); - font-size: 0.875rem; - font-weight: 700; - color: var(--text-primary); - } - .clout-stats { - font-size: 0.6875rem; - color: var(--text-secondary); - } - .clout-dots { - display: flex; - align-items: center; - gap: 4px; - margin-top: var(--space-sm); - } - .dot { - width: 8px; - height: 8px; - border-radius: var(--radius-full); - background: var(--bg-subtle); - flex-shrink: 0; - } - .dot.filled { - background: var(--accent-primary); - opacity: 0.6; - } - .dot.full { - background: var(--accent-primary); - opacity: 1; - } - .dot-label { - font-size: 0.6875rem; - color: var(--text-muted); - margin-left: var(--space-xs); - } - .clout-new-user { - display: block; - font-size: 0.75rem; - color: var(--text-muted); - margin-top: var(--space-xs); - } From 11346e71279c1c6434de5a5eea1b1e06e8b57d45 Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Tue, 10 Mar 2026 21:17:16 -0500 Subject: [PATCH 17/19] fix: override tar to >=7.5.11 for CVE-2026-31802 --- package-lock.json | 23 +++++++++++++++++++++++ package.json | 3 ++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index e353382..26d842a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -218,6 +218,7 @@ "integrity": "sha512-Nt9hri7nbOo0RipAsGjIssHkpLMHHN/P7QqENywAq5TLsoYDzUyJGny8FEiD/9KJUxtGH8blGpMedilI6kK3rA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@algolia/client-common": "5.49.1", "@algolia/requester-browser-xhr": "5.49.1", @@ -2551,6 +2552,7 @@ "integrity": "sha512-iAIPEahFgDJJyvz8g0jP08KvqnM6JvdW8YfsygZ+pMeMvyM2zssWMltcsotETvjSZ82G3VlitgDtBIvpQSZrTA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", @@ -2593,6 +2595,7 @@ "integrity": "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", "deepmerge": "^4.3.1", @@ -2632,6 +2635,7 @@ "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/node": "*" } @@ -3023,6 +3027,7 @@ "integrity": "sha512-RpV6r/ij22zRRdyBPcxDeKAzH43phWVKEjL2iksqo1Vz3CuBUrgmPpPhALKiRfU7OMCmeeO9vECBMsV0hMTG8Q==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.18.0" } @@ -3109,6 +3114,7 @@ "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", @@ -3709,6 +3715,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3761,6 +3768,7 @@ "integrity": "sha512-X3Pp2aRQhg4xUC6PQtkubn5NpRKuUPQ9FPDQlx36SmpFwwH2N0/tw4c+NXV3nw3PsgeUs+BuWGP0gjz3TvENLQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@algolia/abtesting": "1.15.1", "@algolia/client-abtesting": "5.49.1", @@ -3961,6 +3969,7 @@ "integrity": "sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA==", "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" @@ -4200,6 +4209,7 @@ "integrity": "sha512-opLQzEVriiH1uUQ4Kctsd49bRoFDXGGSC4GUqj7pGyxM3RehRhvTlZJc1FL/Flew2p5uwxa1tUDWKzI4wNM8pg==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@chevrotain/cst-dts-gen": "11.1.2", "@chevrotain/gast": "11.1.2", @@ -4676,6 +4686,7 @@ "integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10" } @@ -5120,6 +5131,7 @@ "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "dev": true, "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -5802,6 +5814,7 @@ "integrity": "sha512-uYixubwmqJZH+KLVYIVKY1JQt7tysXhtj21WSvjcSmU5SVNzMus1bgLe+pAt816yQ8opKfheVVoPLqvVMGejYw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", @@ -6258,6 +6271,7 @@ "integrity": "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "tabbable": "^6.4.0" } @@ -8202,6 +8216,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -8373,6 +8388,7 @@ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -8682,6 +8698,7 @@ "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -9305,6 +9322,7 @@ "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.53.6.tgz", "integrity": "sha512-lP5DGF3oDDI9fhHcSpaBiJEkFLuS16h92DhM1L5K1lFm0WjOmUh1i2sNkBBk8rkxJRpob0dBE75jRfUzGZUOGA==", "license": "MIT", + "peer": true, "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", @@ -9645,6 +9663,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9829,6 +9848,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -10857,6 +10877,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -10917,6 +10938,7 @@ "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", @@ -11050,6 +11072,7 @@ "integrity": "sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.29", "@vue/compiler-sfc": "3.5.29", diff --git a/package.json b/package.json index 2b7ad11..d4f42e6 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,8 @@ }, "overrides": { "minimatch": ">=10.2.3", - "cookie": ">=0.7.0" + "cookie": ">=0.7.0", + "tar": ">=7.5.11" }, "devDependencies": { "@commitlint/cli": "^20.4.2", From 7d19396361f270212f64f99d614f03753d067f1d Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Tue, 10 Mar 2026 21:22:13 -0500 Subject: [PATCH 18/19] fix: patch npm's bundled tar to 7.5.11 in Docker image (CVE-2026-31802) Update the vulnerable tar package inside npm's own node_modules in the Node.js base image. The override in package.json was ineffective since tar ships with npm itself, not as a project dependency. --- Dockerfile | 3 ++- package.json | 3 +-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 12e9e5a..10b1246 100644 --- a/Dockerfile +++ b/Dockerfile @@ -32,7 +32,8 @@ RUN apt-get update && \ ca-certificates && \ apt-get clean && \ rm -rf /var/lib/apt/lists/* && \ - groupadd -r scrolly && useradd -r -g scrolly -m scrolly + groupadd -r scrolly && useradd -r -g scrolly -m scrolly && \ + cd /usr/local/lib/node_modules/npm && npm install tar@7.5.11 --no-save WORKDIR /app diff --git a/package.json b/package.json index d4f42e6..2b7ad11 100644 --- a/package.json +++ b/package.json @@ -49,8 +49,7 @@ }, "overrides": { "minimatch": ">=10.2.3", - "cookie": ">=0.7.0", - "tar": ">=7.5.11" + "cookie": ">=0.7.0" }, "devDependencies": { "@commitlint/cli": "^20.4.2", From 97398fb40b69684eccb4ef4c03f1e05993ed61eb Mon Sep 17 00:00:00 2001 From: Grayson Adams <51373669+GraysonCAdams@users.noreply.github.com> Date: Tue, 10 Mar 2026 21:25:26 -0500 Subject: [PATCH 19/19] fix: add CVE-2026-31802 to trivyignore for npm's bundled tar Revert Dockerfile tar patch that broke the image build. Instead, suppress the upstream npm tar CVE in trivyignore until the Node.js base image is updated. --- .trivyignore | 3 +++ Dockerfile | 3 +-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.trivyignore b/.trivyignore index ef9b391..c87e1df 100644 --- a/.trivyignore +++ b/.trivyignore @@ -13,3 +13,6 @@ GHSA-qffp-2rhf-9h96 # tar hardlink path traversal via drive-relative linkpath (npm bundled) CVE-2026-29786 + +# tar node-tar <7.5.11 vulnerability (npm bundled) +CVE-2026-31802 diff --git a/Dockerfile b/Dockerfile index 10b1246..12e9e5a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -32,8 +32,7 @@ RUN apt-get update && \ ca-certificates && \ apt-get clean && \ rm -rf /var/lib/apt/lists/* && \ - groupadd -r scrolly && useradd -r -g scrolly -m scrolly && \ - cd /usr/local/lib/node_modules/npm && npm install tar@7.5.11 --no-save + groupadd -r scrolly && useradd -r -g scrolly -m scrolly WORKDIR /app