Loading...
+
{}} role="presentation">
+
+
+
+
+
+ {#if loading}
+
+
+
+ {:else if viewers.length === 0}
+
+
+
+
+
No views yet
+
When others watch this clip, they'll show up here
+
+ {:else}
+
+ {#each viewers as viewer, i (viewer.userId)}
+
{#if viewer.avatarPath}

@@ -70,57 +111,183 @@
class:viewed={viewer.status === 'viewed'}
class:skipped={viewer.status === 'skipped'}
>
- {viewer.status === 'viewed' ? 'Viewed' : 'Skipped'}
+ Viewed
{/each}
- {/if}
-
-
+
+ {/if}
+
diff --git a/src/lib/server/api-utils.ts b/src/lib/server/api-utils.ts
index e91810b..ef41983 100644
--- a/src/lib/server/api-utils.ts
+++ b/src/lib/server/api-utils.ts
@@ -212,7 +212,8 @@ export async function notifyClipOwner(opts: {
const prefs = await db.query.notificationPreferences.findFirst({
where: eq(notificationPreferences.userId, opts.recipientId)
});
- if (!prefs || prefs[opts.preferenceKey]) {
+ const prefEnabled = !prefs || prefs[opts.preferenceKey];
+ if (prefEnabled) {
const url =
opts.type === 'comment' || opts.type === 'reply'
? `/?clip=${opts.clipId}&comments=true`
@@ -230,8 +231,9 @@ export async function notifyClipOwner(opts: {
}).catch((err) => log.error({ err }, 'push notification failed'));
}
+ const notifId = uuid();
await db.insert(notifications).values({
- id: uuid(),
+ id: notifId,
userId: opts.recipientId,
type: opts.type,
clipId: opts.clipId,
diff --git a/src/lib/server/db/migrations/0022_cute_garia.sql b/src/lib/server/db/migrations/0022_cute_garia.sql
new file mode 100644
index 0000000..bf04bed
--- /dev/null
+++ b/src/lib/server/db/migrations/0022_cute_garia.sql
@@ -0,0 +1,2 @@
+ALTER TABLE `clips` ADD `creator_name` text;--> statement-breakpoint
+ALTER TABLE `clips` ADD `creator_url` text;
\ No newline at end of file
diff --git a/src/lib/server/db/migrations/meta/0022_snapshot.json b/src/lib/server/db/migrations/meta/0022_snapshot.json
new file mode 100644
index 0000000..c1ff54f
--- /dev/null
+++ b/src/lib/server/db/migrations/meta/0022_snapshot.json
@@ -0,0 +1,1233 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "78a942d9-5c77-4032-ac58-a53a5bc9438c",
+ "prevId": "ad304cb3-15cb-4a07-8380-0390f33d1389",
+ "tables": {
+ "clips": {
+ "name": "clips",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "group_id": {
+ "name": "group_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "added_by": {
+ "name": "added_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "original_url": {
+ "name": "original_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "video_path": {
+ "name": "video_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "thumbnail_path": {
+ "name": "thumbnail_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "duration_seconds": {
+ "name": "duration_seconds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "platform": {
+ "name": "platform",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'downloading'"
+ },
+ "content_type": {
+ "name": "content_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'video'"
+ },
+ "audio_path": {
+ "name": "audio_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "artist": {
+ "name": "artist",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "album_art": {
+ "name": "album_art",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "spotify_url": {
+ "name": "spotify_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "apple_music_url": {
+ "name": "apple_music_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "youtube_music_url": {
+ "name": "youtube_music_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "file_size_bytes": {
+ "name": "file_size_bytes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "creator_name": {
+ "name": "creator_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "creator_url": {
+ "name": "creator_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "clips_group_url": {
+ "name": "clips_group_url",
+ "columns": [
+ "group_id",
+ "original_url"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "clips_group_id_groups_id_fk": {
+ "name": "clips_group_id_groups_id_fk",
+ "tableFrom": "clips",
+ "tableTo": "groups",
+ "columnsFrom": [
+ "group_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "clips_added_by_users_id_fk": {
+ "name": "clips_added_by_users_id_fk",
+ "tableFrom": "clips",
+ "tableTo": "users",
+ "columnsFrom": [
+ "added_by"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "comment_hearts": {
+ "name": "comment_hearts",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "comment_id": {
+ "name": "comment_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "comment_hearts_unique": {
+ "name": "comment_hearts_unique",
+ "columns": [
+ "comment_id",
+ "user_id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "comment_hearts_comment_id_comments_id_fk": {
+ "name": "comment_hearts_comment_id_comments_id_fk",
+ "tableFrom": "comment_hearts",
+ "tableTo": "comments",
+ "columnsFrom": [
+ "comment_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "comment_hearts_user_id_users_id_fk": {
+ "name": "comment_hearts_user_id_users_id_fk",
+ "tableFrom": "comment_hearts",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "comment_views": {
+ "name": "comment_views",
+ "columns": {
+ "clip_id": {
+ "name": "clip_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "viewed_at": {
+ "name": "viewed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "comment_views_unique": {
+ "name": "comment_views_unique",
+ "columns": [
+ "clip_id",
+ "user_id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "comment_views_clip_id_clips_id_fk": {
+ "name": "comment_views_clip_id_clips_id_fk",
+ "tableFrom": "comment_views",
+ "tableTo": "clips",
+ "columnsFrom": [
+ "clip_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "comment_views_user_id_users_id_fk": {
+ "name": "comment_views_user_id_users_id_fk",
+ "tableFrom": "comment_views",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "comments": {
+ "name": "comments",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "clip_id": {
+ "name": "clip_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "parent_id": {
+ "name": "parent_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "text": {
+ "name": "text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "gif_url": {
+ "name": "gif_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "comments_clip_id_clips_id_fk": {
+ "name": "comments_clip_id_clips_id_fk",
+ "tableFrom": "comments",
+ "tableTo": "clips",
+ "columnsFrom": [
+ "clip_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "comments_user_id_users_id_fk": {
+ "name": "comments_user_id_users_id_fk",
+ "tableFrom": "comments",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "favorites": {
+ "name": "favorites",
+ "columns": {
+ "clip_id": {
+ "name": "clip_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "favorites_clip_id_clips_id_fk": {
+ "name": "favorites_clip_id_clips_id_fk",
+ "tableFrom": "favorites",
+ "tableTo": "clips",
+ "columnsFrom": [
+ "clip_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "favorites_user_id_users_id_fk": {
+ "name": "favorites_user_id_users_id_fk",
+ "tableFrom": "favorites",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "groups": {
+ "name": "groups",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "invite_code": {
+ "name": "invite_code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "retention_days": {
+ "name": "retention_days",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "max_storage_mb": {
+ "name": "max_storage_mb",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "max_file_size_mb": {
+ "name": "max_file_size_mb",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": 500
+ },
+ "accent_color": {
+ "name": "accent_color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'coral'"
+ },
+ "download_provider": {
+ "name": "download_provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "platform_filter_mode": {
+ "name": "platform_filter_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'all'"
+ },
+ "platform_filter_list": {
+ "name": "platform_filter_list",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "shortcut_token": {
+ "name": "shortcut_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "shortcut_url": {
+ "name": "shortcut_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "groups_invite_code_unique": {
+ "name": "groups_invite_code_unique",
+ "columns": [
+ "invite_code"
+ ],
+ "isUnique": true
+ },
+ "groups_shortcut_token_unique": {
+ "name": "groups_shortcut_token_unique",
+ "columns": [
+ "shortcut_token"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "notification_preferences": {
+ "name": "notification_preferences",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "new_adds": {
+ "name": "new_adds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "reactions": {
+ "name": "reactions",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "comments": {
+ "name": "comments",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "mentions": {
+ "name": "mentions",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "daily_reminder": {
+ "name": "daily_reminder",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "notification_preferences_user_id_users_id_fk": {
+ "name": "notification_preferences_user_id_users_id_fk",
+ "tableFrom": "notification_preferences",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "notifications": {
+ "name": "notifications",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "clip_id": {
+ "name": "clip_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "actor_id": {
+ "name": "actor_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "emoji": {
+ "name": "emoji",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "comment_preview": {
+ "name": "comment_preview",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "read_at": {
+ "name": "read_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "notifications_user_created": {
+ "name": "notifications_user_created",
+ "columns": [
+ "user_id",
+ "created_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "notifications_user_id_users_id_fk": {
+ "name": "notifications_user_id_users_id_fk",
+ "tableFrom": "notifications",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "notifications_clip_id_clips_id_fk": {
+ "name": "notifications_clip_id_clips_id_fk",
+ "tableFrom": "notifications",
+ "tableTo": "clips",
+ "columnsFrom": [
+ "clip_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "notifications_actor_id_users_id_fk": {
+ "name": "notifications_actor_id_users_id_fk",
+ "tableFrom": "notifications",
+ "tableTo": "users",
+ "columnsFrom": [
+ "actor_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "push_subscriptions": {
+ "name": "push_subscriptions",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "endpoint": {
+ "name": "endpoint",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "keys_p256dh": {
+ "name": "keys_p256dh",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "keys_auth": {
+ "name": "keys_auth",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "push_subscriptions_user_id_users_id_fk": {
+ "name": "push_subscriptions_user_id_users_id_fk",
+ "tableFrom": "push_subscriptions",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "reactions": {
+ "name": "reactions",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "clip_id": {
+ "name": "clip_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "emoji": {
+ "name": "emoji",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "reactions_unique": {
+ "name": "reactions_unique",
+ "columns": [
+ "clip_id",
+ "user_id",
+ "emoji"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "reactions_clip_id_clips_id_fk": {
+ "name": "reactions_clip_id_clips_id_fk",
+ "tableFrom": "reactions",
+ "tableTo": "clips",
+ "columnsFrom": [
+ "clip_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "reactions_user_id_users_id_fk": {
+ "name": "reactions_user_id_users_id_fk",
+ "tableFrom": "reactions",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "users": {
+ "name": "users",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "username": {
+ "name": "username",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "phone": {
+ "name": "phone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "group_id": {
+ "name": "group_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "theme_preference": {
+ "name": "theme_preference",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'system'"
+ },
+ "auto_scroll": {
+ "name": "auto_scroll",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "muted_by_default": {
+ "name": "muted_by_default",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "feed_sort_order": {
+ "name": "feed_sort_order",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'oldest'"
+ },
+ "avatar_path": {
+ "name": "avatar_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "removed_at": {
+ "name": "removed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "users_phone_unique": {
+ "name": "users_phone_unique",
+ "columns": [
+ "phone"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "users_group_id_groups_id_fk": {
+ "name": "users_group_id_groups_id_fk",
+ "tableFrom": "users",
+ "tableTo": "groups",
+ "columnsFrom": [
+ "group_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "verification_codes": {
+ "name": "verification_codes",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "phone": {
+ "name": "phone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "code": {
+ "name": "code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "attempts": {
+ "name": "attempts",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "verified_at": {
+ "name": "verified_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "verification_codes_user_id_users_id_fk": {
+ "name": "verification_codes_user_id_users_id_fk",
+ "tableFrom": "verification_codes",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "watched": {
+ "name": "watched",
+ "columns": {
+ "clip_id": {
+ "name": "clip_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "watch_percent": {
+ "name": "watch_percent",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "watched_at": {
+ "name": "watched_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "watched_clip_user": {
+ "name": "watched_clip_user",
+ "columns": [
+ "clip_id",
+ "user_id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "watched_clip_id_clips_id_fk": {
+ "name": "watched_clip_id_clips_id_fk",
+ "tableFrom": "watched",
+ "tableTo": "clips",
+ "columnsFrom": [
+ "clip_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "watched_user_id_users_id_fk": {
+ "name": "watched_user_id_users_id_fk",
+ "tableFrom": "watched",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ }
+ },
+ "views": {},
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "indexes": {}
+ }
+}
\ No newline at end of file
diff --git a/src/lib/server/db/migrations/meta/_journal.json b/src/lib/server/db/migrations/meta/_journal.json
index 2d36c23..7dad5b5 100644
--- a/src/lib/server/db/migrations/meta/_journal.json
+++ b/src/lib/server/db/migrations/meta/_journal.json
@@ -155,6 +155,13 @@
"when": 1772404817972,
"tag": "0021_flaky_stature",
"breakpoints": true
+ },
+ {
+ "idx": 22,
+ "version": "6",
+ "when": 1772508242984,
+ "tag": "0022_cute_garia",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts
index 67013ad..93da153 100644
--- a/src/lib/server/db/schema.ts
+++ b/src/lib/server/db/schema.ts
@@ -58,6 +58,8 @@ export const clips = sqliteTable(
appleMusicUrl: text('apple_music_url'),
youtubeMusicUrl: text('youtube_music_url'),
fileSizeBytes: integer('file_size_bytes'),
+ creatorName: text('creator_name'),
+ creatorUrl: text('creator_url'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull()
},
(table) => [uniqueIndex('clips_group_url').on(table.groupId, table.originalUrl)]
diff --git a/src/lib/server/providers/types.ts b/src/lib/server/providers/types.ts
index 30e077c..4def3b7 100644
--- a/src/lib/server/providers/types.ts
+++ b/src/lib/server/providers/types.ts
@@ -3,6 +3,8 @@ export interface VideoDownloadResult {
thumbnailPath: string | null;
title: string | null;
duration: number | null;
+ creatorName: string | null;
+ creatorUrl: string | null;
}
export interface AudioDownloadResult {
diff --git a/src/lib/server/providers/ytdlp/index.ts b/src/lib/server/providers/ytdlp/index.ts
index c1bfdf4..b30952c 100644
--- a/src/lib/server/providers/ytdlp/index.ts
+++ b/src/lib/server/providers/ytdlp/index.ts
@@ -143,6 +143,8 @@ export class YtDlpProvider implements DownloadProvider {
let title: string | null = null;
let duration: number | null = null;
+ let creatorName: string | null = null;
+ let creatorUrl: string | null = null;
if (infoFile) {
try {
@@ -150,6 +152,10 @@ export class YtDlpProvider implements DownloadProvider {
const info = JSON.parse(await readFile(`${outputDir}/${infoFile}`, 'utf-8'));
title = info.title || info.fulltitle || null;
duration = typeof info.duration === 'number' ? Math.round(info.duration) : null;
+
+ const rawName = info.uploader || info.channel || info.uploader_id || null;
+ creatorName = rawName ? String(rawName).replace(/^@/, '') : null;
+ creatorUrl = info.uploader_url || info.channel_url || null;
} catch {
// Info file parsing is best-effort
}
@@ -159,7 +165,9 @@ export class YtDlpProvider implements DownloadProvider {
videoPath: `${outputDir}/${videoFile}`,
thumbnailPath: thumbFile ? `${outputDir}/${thumbFile}` : null,
title,
- duration
+ duration,
+ creatorName,
+ creatorUrl
};
}
diff --git a/src/lib/server/video/download.ts b/src/lib/server/video/download.ts
index fbd03af..c86cf53 100644
--- a/src/lib/server/video/download.ts
+++ b/src/lib/server/video/download.ts
@@ -84,7 +84,9 @@ async function downloadVideoInner(clipId: string, url: string): Promise
{
.set({
status: 'failed',
title: `Exceeds ${sizeMb} MB limit`,
- durationSeconds: result.duration
+ durationSeconds: result.duration,
+ creatorName: result.creatorName,
+ creatorUrl: result.creatorUrl
})
.where(eq(clips.id, clipId));
// Still notify — clip is viewable via external link
@@ -108,7 +110,9 @@ async function downloadVideoInner(clipId: string, url: string): Promise {
thumbnailPath: result.thumbnailPath,
title,
durationSeconds: result.duration,
- fileSizeBytes: fileSizeBytes || null
+ fileSizeBytes: fileSizeBytes || null,
+ creatorName: result.creatorName,
+ creatorUrl: result.creatorUrl
})
.where(eq(clips.id, clipId));
diff --git a/src/lib/types.ts b/src/lib/types.ts
index aa72656..a01a814 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -14,6 +14,8 @@ export interface FeedClip {
addedByUsername: string;
addedByAvatar: string | null;
platform: string;
+ creatorName: string | null;
+ creatorUrl: string | null;
status: string;
contentType: string;
durationSeconds: number | null;
diff --git a/src/routes/(app)/+layout.svelte b/src/routes/(app)/+layout.svelte
index 3f3c9fd..e8e10b1 100644
--- a/src/routes/(app)/+layout.svelte
+++ b/src/routes/(app)/+layout.svelte
@@ -65,6 +65,10 @@
document.addEventListener('click', handleFirstInteraction, true);
document.addEventListener('touchstart', handleFirstInteraction, true);
+ // Lock to portrait orientation (works in installed PWA / fullscreen contexts)
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- lock() not in all TS lib defs
+ (screen.orientation as any)?.lock?.('portrait-primary').catch(() => {});
+
// Sync theme-color meta tags with current theme for PWA chrome blending
const themeObserver = new MutationObserver(() => syncThemeColor());
themeObserver.observe(document.documentElement, {
@@ -304,8 +308,8 @@
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-top: 1px solid color-mix(in srgb, var(--border) 50%, transparent);
- padding: var(--space-sm) 0;
- padding-bottom: max(var(--space-sm), env(safe-area-inset-bottom));
+ padding: var(--space-xs) 0;
+ padding-bottom: max(var(--space-xs), env(safe-area-inset-bottom));
z-index: 10;
transition: opacity 0.3s ease;
}
@@ -320,8 +324,8 @@
}
.bottom-tabs.overlay-mode {
- background: linear-gradient(transparent, var(--reel-gradient-heavy));
- border-top: none;
+ background: var(--reel-bg-elevated);
+ border-top-color: transparent;
backdrop-filter: none;
-webkit-backdrop-filter: none;
z-index: 50;
@@ -332,12 +336,12 @@
display: flex;
flex-direction: column;
align-items: center;
- gap: 3px;
+ gap: 2px;
text-decoration: none;
color: var(--text-muted);
font-size: 0.625rem;
font-family: var(--font-body);
- padding: var(--space-xs) 0;
+ padding: 2px 0;
transition: color 0.2s ease;
background: none;
border: none;
diff --git a/src/routes/(app)/+page.svelte b/src/routes/(app)/+page.svelte
index 28fd2f5..89c5bd3 100644
--- a/src/routes/(app)/+page.svelte
+++ b/src/routes/(app)/+page.svelte
@@ -200,6 +200,8 @@
function completeSwipe(goingNext: boolean, newIndex: number) {
const vw = window.innerWidth;
+ // Clear pull-snapping to prevent its CSS transition from overriding the swipe animation
+ pullSnapping = false;
swipeAnimating = true;
swipeX = goingNext ? -vw : vw;
@@ -345,6 +347,10 @@
isHorizontal = Math.abs(dx) > Math.abs(dy);
if (!isHorizontal) return;
isHorizontalSwiping = true;
+ // Reset pull-to-refresh state so its CSS transition doesn't
+ // compete with the swipe animation on pointerup
+ pullDistance = 0;
+ isPullingActive = false;
}
if (!isHorizontal) return;
@@ -896,7 +902,7 @@
animation: spin 0.8s linear infinite;
}
.reel-scroll {
- height: 100dvh;
+ height: calc(100dvh - var(--bottom-nav-height, 64px));
overflow-y: auto;
scroll-snap-type: y mandatory;
overscroll-behavior-y: none;
@@ -906,7 +912,7 @@
display: none;
}
.reel-slot {
- height: 100dvh;
+ height: calc(100dvh - var(--bottom-nav-height, 64px));
width: 100%;
scroll-snap-align: start;
scroll-snap-stop: always;
@@ -935,7 +941,7 @@
scroll-snap-align: none;
}
.reel-empty {
- height: 100dvh;
+ height: calc(100dvh - var(--bottom-nav-height, 64px));
display: flex;
flex-direction: column;
align-items: center;
@@ -1010,7 +1016,7 @@
transition: transform 0.25s ease;
}
.drop-target {
- height: 100dvh;
+ height: calc(100dvh - var(--bottom-nav-height, 64px));
position: relative;
overflow: hidden;
}
diff --git a/src/routes/(app)/favorites/+page.svelte b/src/routes/(app)/favorites/+page.svelte
index f4f8c1a..8af78a4 100644
--- a/src/routes/(app)/favorites/+page.svelte
+++ b/src/routes/(app)/favorites/+page.svelte
@@ -67,7 +67,7 @@
window.addEventListener('popstate', onReelPopState);
await tick();
if (reelContainer) {
- reelContainer.scrollTop = index * window.innerHeight;
+ reelContainer.scrollTop = index * reelContainer.clientHeight;
}
}
@@ -405,11 +405,13 @@
.faves-reel {
position: fixed;
- inset: 0;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: var(--bottom-nav-height, 64px);
z-index: 40;
background: var(--bg-primary);
overscroll-behavior-x: none;
- --bottom-nav-height: calc(env(safe-area-inset-bottom, 0px) + 14px);
}
.reel-topbar {
@@ -458,20 +460,18 @@
}
.reel-scroll {
- height: 100dvh;
+ height: 100%;
overflow-y: auto;
scroll-snap-type: y mandatory;
-webkit-overflow-scrolling: touch;
overscroll-behavior-y: none;
scrollbar-width: none;
+ &::-webkit-scrollbar {
+ display: none;
+ }
}
-
- .reel-scroll::-webkit-scrollbar {
- display: none;
- }
-
.reel-slot {
- height: 100dvh;
+ height: 100%;
width: 100%;
scroll-snap-align: start;
scroll-snap-stop: always;
diff --git a/src/routes/api/__tests__/clips.test.ts b/src/routes/api/__tests__/clips.test.ts
index a90ab2c..e7d13f0 100644
--- a/src/routes/api/__tests__/clips.test.ts
+++ b/src/routes/api/__tests__/clips.test.ts
@@ -616,7 +616,7 @@ describe('POST /api/clips/[id]/reactions', () => {
method: 'POST',
path: `/api/clips/${data.readyClip.id}/reactions`,
params: { id: data.readyClip.id },
- body: { emoji: '❤️' },
+ body: { emoji: '😂' },
user: data.member,
group: data.group
});
@@ -624,9 +624,9 @@ describe('POST /api/clips/[id]/reactions', () => {
expect(res.status).toBe(200);
const body = await res.json();
expect(body.toggled).toBe(true);
- expect(body.reactions['❤️']).toBeDefined();
- expect(body.reactions['❤️'].count).toBe(1);
- expect(body.reactions['❤️'].reacted).toBe(true);
+ expect(body.reactions['😂']).toBeDefined();
+ expect(body.reactions['😂'].count).toBe(1);
+ expect(body.reactions['😂'].reacted).toBe(true);
});
it('toggles reaction off on second call', async () => {
@@ -634,7 +634,7 @@ describe('POST /api/clips/[id]/reactions', () => {
method: 'POST',
path: `/api/clips/${data.readyClip.id}/reactions`,
params: { id: data.readyClip.id },
- body: { emoji: '❤️' },
+ body: { emoji: '😂' },
user: data.member,
group: data.group
});
@@ -643,8 +643,8 @@ describe('POST /api/clips/[id]/reactions', () => {
const body = await res.json();
expect(body.toggled).toBe(false);
// Reaction count for this emoji should be 0 or the key absent
- if (body.reactions['❤️']) {
- expect(body.reactions['❤️'].count).toBe(0);
+ if (body.reactions['😂']) {
+ expect(body.reactions['😂'].count).toBe(0);
}
});
});
diff --git a/src/routes/api/clips/[id]/+server.ts b/src/routes/api/clips/[id]/+server.ts
index 1974a00..2cc9684 100644
--- a/src/routes/api/clips/[id]/+server.ts
+++ b/src/routes/api/clips/[id]/+server.ts
@@ -81,6 +81,8 @@ export const GET: RequestHandler = withClipAuth(async ({ params }, { user, clip
addedByUsername: uploaderUser?.username || 'Unknown',
addedByAvatar: uploaderUser?.avatarPath || null,
platform: clip.platform,
+ creatorName: clip.creatorName,
+ creatorUrl: clip.creatorUrl,
status: clip.status,
contentType: clip.contentType,
durationSeconds: clip.durationSeconds,
diff --git a/src/routes/api/clips/[id]/favorite/+server.ts b/src/routes/api/clips/[id]/favorite/+server.ts
index 3397df8..dc19968 100644
--- a/src/routes/api/clips/[id]/favorite/+server.ts
+++ b/src/routes/api/clips/[id]/favorite/+server.ts
@@ -1,28 +1,88 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { db } from '$lib/server/db';
-import { favorites } from '$lib/server/db/schema';
+import { favorites, reactions, notifications } from '$lib/server/db/schema';
import { and, eq } from 'drizzle-orm';
-import { withClipAuth } from '$lib/server/api-utils';
+import { v4 as uuid } from 'uuid';
+import { withClipAuth, notifyClipOwner } from '$lib/server/api-utils';
+
+export const POST: RequestHandler = withClipAuth(async ({ params }, { user, clip }) => {
+ const clipId = params.id;
+ const userId = user.id;
-export const POST: RequestHandler = withClipAuth(async ({ params }, { user }) => {
// Toggle — check if already favorited
const existing = await db.query.favorites.findFirst({
- where: and(eq(favorites.clipId, params.id), eq(favorites.userId, user.id))
+ where: and(eq(favorites.clipId, clipId), eq(favorites.userId, userId))
});
if (existing) {
await db
.delete(favorites)
- .where(and(eq(favorites.clipId, params.id), eq(favorites.userId, user.id)));
+ .where(and(eq(favorites.clipId, clipId), eq(favorites.userId, userId)));
+
+ // Also remove ❤️ reaction when un-favoriting
+ const heartReaction = await db.query.reactions.findFirst({
+ where: and(
+ eq(reactions.clipId, clipId),
+ eq(reactions.userId, userId),
+ eq(reactions.emoji, '❤️')
+ )
+ });
+ if (heartReaction) {
+ await db.delete(reactions).where(eq(reactions.id, heartReaction.id));
+ await db
+ .delete(notifications)
+ .where(
+ and(
+ eq(notifications.clipId, clipId),
+ eq(notifications.actorId, userId),
+ eq(notifications.type, 'reaction'),
+ eq(notifications.emoji, '❤️')
+ )
+ );
+ }
+
return json({ favorited: false });
}
await db.insert(favorites).values({
- clipId: params.id,
- userId: user.id,
+ clipId,
+ userId,
createdAt: new Date()
});
+ // Also create ❤️ reaction if one doesn't already exist
+ const existingReaction = await db.query.reactions.findFirst({
+ where: and(
+ eq(reactions.clipId, clipId),
+ eq(reactions.userId, userId),
+ eq(reactions.emoji, '❤️')
+ )
+ });
+ if (!existingReaction) {
+ await db.insert(reactions).values({
+ id: uuid(),
+ clipId,
+ userId,
+ emoji: '❤️',
+ createdAt: new Date()
+ });
+
+ // Notify clip owner (skips self-notification automatically)
+ await notifyClipOwner({
+ recipientId: clip.addedBy,
+ actorId: userId,
+ actorUsername: user.username,
+ actorAvatarPath: user.avatarPath,
+ clipId,
+ type: 'reaction',
+ preferenceKey: 'reactions',
+ pushTitle: `${user.username} reacted ❤️`,
+ pushBody: 'on your clip',
+ pushTag: `reaction-${clipId}`,
+ emoji: '❤️'
+ });
+ }
+
return json({ favorited: true });
});
diff --git a/src/routes/api/clips/[id]/reactions/+server.ts b/src/routes/api/clips/[id]/reactions/+server.ts
index 1c13d7d..6e0a886 100644
--- a/src/routes/api/clips/[id]/reactions/+server.ts
+++ b/src/routes/api/clips/[id]/reactions/+server.ts
@@ -63,14 +63,14 @@ export const POST: RequestHandler = withClipAuth(async ({ params, request }, { u
if (!sameEmoji) {
// Add the new reaction (either fresh or replacing a different emoji)
+ const reactionId = uuid();
await db.insert(reactions).values({
- id: uuid(),
+ id: reactionId,
clipId,
userId,
emoji,
createdAt: new Date()
});
-
// Notify clip owner about the new reaction
await notifyClipOwner({
recipientId: clip.addedBy,