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/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): 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. 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/src/lib/components/CloutChangeModal.svelte b/src/lib/components/CloutChangeModal.svelte new file mode 100644 index 0000000..47e8cda --- /dev/null +++ b/src/lib/components/CloutChangeModal.svelte @@ -0,0 +1,545 @@ + + +{#if change} + + +
+ +
0 ? `translateY(${dragY}px)` : undefined} + onclick={(e) => e.stopPropagation()} + > + + +
+ {#if !showTips} + {@const final = TIER_INFO[change.newTier] ?? TIER_INFO.fresh} +
+
+ {#key displayTier.icon} + {displayTier.label} + {/key} +
+

{final.label}

+

+ {#if isSameRank} + Your rank + {:else if isRankUp} + You ranked up! + {:else} + Your rank changed + {/if} +

+

+ {#if isSameRank && change.newTier === 'iconic'} + Your clips hit different. Maximum speed unlocked. + {:else if isSameRank && change.newTier === 'viral'} + The group loves your taste. Keep it coming. + {:else if isSameRank && change.newTier === 'rising'} + You're building momentum. Keep sharing hits. + {:else if isSameRank} + Share clips the group engages with to climb up. + {:else if change.newTier === 'iconic'} + Your clips hit different. Maximum speed unlocked. + {:else if change.newTier === 'viral' && isRankUp} + The group loves your taste. Keep it coming. + {:else if change.newTier === 'viral'} + Still solid — get more comments to climb back. + {:else if change.newTier === 'rising' && isRankUp} + You're building momentum. Nice picks. + {:else if change.newTier === 'rising'} + Share clips the group reacts to and you'll bounce back. + {:else if isRankUp} + Your clips are starting to land. + {:else} + Share clips the group engages with to climb back up. + {/if} +

+
+ {formatCooldown(change.cooldownMinutes)} between clips + · + {change.burstSize} per burst +
+
+ + {#if !isRankUp} + + {/if} + {:else if tipsData} + + {/if} +
+
+{/if} + + diff --git a/src/lib/components/CloutTipsView.svelte b/src/lib/components/CloutTipsView.svelte new file mode 100644 index 0000000..845dee3 --- /dev/null +++ b/src/lib/components/CloutTipsView.svelte @@ -0,0 +1,486 @@ + + +
+ {#if nextTier} + {@const currentIdx = TIER_ORDER.indexOf(currentTier)} + {@const nextIdx = TIER_ORDER.indexOf(nextTier.tier)} +
+
+ {#each TIER_ORDER as tier, i (tier)} + {@const isCurrent = i === currentIdx} + {@const isNext = i === nextIdx} + {@const isPast = i < currentIdx} +
nextIdx} + > + {TIER_INFO[tier]?.label + {TIER_INFO[tier]?.label} +
+ {#if i < TIER_ORDER.length - 1} +
+ {/if} + {/each} +
+ {#if neededUpgrades > 0} +

+ {neededUpgrades} clip{neededUpgrades === 1 ? '' : 's'} need better engagement to reach + {nextTier.tierName} +

+ {/if} +
+ +
+ +
+
+ 0 + No reactions or favorites +
+
+ 1 + Got a reaction or favorite +
+
+ 2 + Reaction/fave and a comment +
+
+
+ +
+ +
+
+ {nextTier.burst} + per burst +
+
+
+ {nextTier.queueLimit ?? '∞'} + {nextTier.queueLimit ? 'max queued' : 'queue depth'} +
+
+
+ {:else} + {@const currentTierInfo = TIER_INFO[currentTier]} +
+
+ {currentTierInfo?.label +
+

You're at the top

+

Maximum speed unlocked. Keep sharing clips the group loves.

+
+ {/if} + + {#if underperforming.length > 0} +
+ + +
+ {/if} +
+ + 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 97c678d..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,6 +14,9 @@ const { ondismiss }: { ondismiss: () => void } = $props(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- API response passed through to QueueCloutBanner + let clout = $state(null); + interface QueueItem { id: string; clipId: string; @@ -40,6 +44,7 @@ $effect(() => { loadQueue(); + loadClout(); }); async function loadQueue() { @@ -56,6 +61,18 @@ loading = false; } + async function loadClout() { + try { + const res = await fetch('/api/clout'); + if (res.ok) { + const data = await res.json(); + if (data.enabled) clout = data; + } + } catch { + // silently fail + } + } + function handleDragStart(e: PointerEvent, index: number) { e.stopPropagation(); e.preventDefault(); @@ -145,8 +162,9 @@ title: 'How the queue works', message: 'Your first few clips go straight to the feed. ' + - 'After that, extras land here and get shared on a timer — ' + - 'so your group gets a steady stream instead of everything at once.\n\n' + + 'After that, extras land here and get shared on a timer.\n\n' + + 'Your queue speed is based on how your clips land with the group — ' + + 'more reactions and comments means faster sharing.\n\n' + 'Drag to reorder, or tap the trash icon to remove.', confirmLabel: 'Got it', cancelLabel: 'Close' @@ -192,6 +210,10 @@ {/snippet} + {#if clout} + + {/if} +
{#if loading}
diff --git a/src/lib/components/settings/SharePacingPicker.svelte b/src/lib/components/settings/SharePacingPicker.svelte index cb0cb73..76e8d26 100644 --- a/src/lib/components/settings/SharePacingPicker.svelte +++ b/src/lib/components/settings/SharePacingPicker.svelte @@ -5,18 +5,21 @@ currentMode, currentBurst, currentCooldown, - currentDailyLimit + currentDailyLimit, + currentCloutEnabled }: { currentMode: string; currentBurst: number; currentCooldown: number; currentDailyLimit: number | null; + currentCloutEnabled: boolean; } = $props(); let mode = $state(currentMode); let burst = $state(currentBurst); let cooldown = $state(currentCooldown); let dailyLimit = $state(currentDailyLimit === null ? '' : String(currentDailyLimit)); + let cloutEnabled = $state(currentCloutEnabled); let saving = $state(false); const cooldownOptions = [ @@ -73,6 +76,11 @@ if (parsed !== null && (isNaN(parsed) || parsed < 1)) return; save({ dailyShareLimit: parsed }); } + + function toggleClout() { + cloutEnabled = !cloutEnabled; + save({ cloutEnabled }); + }
@@ -172,6 +180,23 @@ {/each}
+
+
+ Reputation adjustments + Adjust pacing based on engagement +
+ +
{/if} @@ -363,4 +388,54 @@ opacity: 0.5; cursor: not-allowed; } + + .toggle-label-group { + display: flex; + flex-direction: column; + gap: 2px; + } + + .toggle-hint { + font-size: 0.6875rem; + font-weight: 400; + color: var(--text-muted); + } + + .toggle { + position: relative; + width: 44px; + height: 26px; + border-radius: 13px; + border: none; + background: var(--border); + cursor: pointer; + padding: 0; + flex-shrink: 0; + transition: background 0.2s ease; + } + + .toggle.on { + background: var(--accent-primary); + } + + .toggle:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .toggle-thumb { + position: absolute; + top: 2px; + left: 2px; + width: 22px; + height: 22px; + border-radius: 50%; + background: var(--constant-white, #fff); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); + transition: transform 0.2s ease; + } + + .toggle.on .toggle-thumb { + transform: translateX(18px); + } 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/clout.ts b/src/lib/server/clout.ts new file mode 100644 index 0000000..f83e6d5 --- /dev/null +++ b/src/lib/server/clout.ts @@ -0,0 +1,222 @@ +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; + title: string | null; + platform: string; + originalUrl: string; + thumbnailPath: string | null; +} + +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 }; +} + +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. + * + * 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, + title: clips.title, + platform: clips.platform, + originalUrl: clips.originalUrl, + thumbnailPath: clips.thumbnailPath + }) + .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 breakdown: ClipBreakdown[] = []; + + for (const clip of maturedClips) { + const clipId = clip.id; + // 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, + title: clip.title, + platform: clip.platform, + originalUrl: clip.originalUrl, + thumbnailPath: clip.thumbnailPath + }); + } + + 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/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() }); 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/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..fa27782 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 { @@ -150,6 +158,7 @@ interface GroupPacingConfig { dailyShareLimit: number | null; shareBurst: number; shareCooldownMinutes: number; + cloutEnabled: boolean; } /** @@ -185,6 +194,23 @@ export function checkSharePacing( }; } case 'queue': { + 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, 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..1cc8f6c 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,42 @@ 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; + + // Server tells us if a tier change modal should be shown (3-day cooldown) + if (data.tierChanged && data.lastTier) { + cloutChange.set({ + previousTier: data.lastTier, + newTier: data.tier, + 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 + } + } + 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/(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/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; diff --git a/src/routes/(app)/settings/+page.svelte b/src/routes/(app)/settings/+page.svelte index c033342..74fd464 100644 --- a/src/routes/(app)/settings/+page.svelte +++ b/src/routes/(app)/settings/+page.svelte @@ -423,6 +423,7 @@ currentBurst={group.shareBurst} currentCooldown={group.shareCooldownMinutes} currentDailyLimit={group.dailyShareLimit} + currentCloutEnabled={group.cloutEnabled} />
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 @@ +