diff --git a/apps/web/src/lib/components/AppBreadcrumb.svelte b/apps/web/src/lib/components/AppBreadcrumb.svelte index 069c866..83e96b9 100644 --- a/apps/web/src/lib/components/AppBreadcrumb.svelte +++ b/apps/web/src/lib/components/AppBreadcrumb.svelte @@ -31,7 +31,7 @@ Error {/snippet} {#if item.isLink} - + {(await remoteGetChannel(item.channelId)).name} {:else} diff --git a/apps/web/src/lib/components/ChannelSponsorMentions.svelte b/apps/web/src/lib/components/ChannelSponsorMentions.svelte new file mode 100644 index 0000000..6bba793 --- /dev/null +++ b/apps/web/src/lib/components/ChannelSponsorMentions.svelte @@ -0,0 +1,251 @@ + + +
+
+
+

Recent Sponsor Mentions

+ {mentions.length} +
+
+ {#if mentions.length === 0} +
+
+ +
+

No sponsor mentions found in comments

+
+ {:else} +
+
+ + + {#key sorting} + {#each table.getHeaderGroups() as headerGroup (headerGroup.id)} + + {#each headerGroup.headers as header (header.id)} + + {#if !header.isPlaceholder} + + {/if} + + {/each} + + {/each} + {/key} + + + {#each table.getRowModel().rows as row (row.id)} + + {#each row.getVisibleCells() as cell (cell.id)} + + + + {/each} + + {/each} + + +
+
+ {/if} +
diff --git a/apps/web/src/lib/components/ChannelVideos.svelte b/apps/web/src/lib/components/ChannelVideos.svelte index 3f8b783..0967e62 100644 --- a/apps/web/src/lib/components/ChannelVideos.svelte +++ b/apps/web/src/lib/components/ChannelVideos.svelte @@ -9,6 +9,7 @@ import DataTableColumnHeader from '$lib/components/ui/data-table/data-table-column-header.svelte'; import * as Table from '$lib/components/ui/table/index.js'; import { Badge } from '$lib/components/ui/badge/index.js'; + import { Button } from '$lib/components/ui/button/index.js'; import { getCoreRowModel, getSortedRowModel, @@ -16,7 +17,7 @@ type SortingState } from '@tanstack/table-core'; import { formatNumber, formatDate } from '$lib/utils'; - import { Video } from '@lucide/svelte'; + import { Video, ChevronLeft, ChevronRight } from '@lucide/svelte'; import { remoteGetChannelVideos } from '$lib/remote/channels.remote'; type VideoType = { @@ -34,7 +35,13 @@ const { channelId }: { channelId: string } = $props(); - const videos = $derived(await remoteGetChannelVideos(channelId)); + let currentPage = $state(1); + const pageSize = 20; + + const result = $derived(await remoteGetChannelVideos({ channelId, page: currentPage, pageSize })); + const videos = $derived(result.videos); + const totalCount = $derived(result.totalCount); + const totalPages = $derived(Math.ceil(totalCount / pageSize)); const columns: ColumnDef[] = [ { @@ -168,61 +175,102 @@ getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel() }); + + const goToPreviousPage = () => { + if (currentPage > 1) currentPage--; + }; + + const goToNextPage = () => { + if (currentPage < totalPages) currentPage++; + };
-

Recent Videos

- {videos.length} +

Recent Videos

+ {totalCount}
{#if videos.length === 0}
-
-
{:else} -
-
- - - {#key sorting} - {#each table.getHeaderGroups() as headerGroup (headerGroup.id)} - - {#each headerGroup.headers as header (header.id)} - - {#if !header.isPlaceholder} - - {/if} - - {/each} - - {/each} - {/key} - - - {#each table.getRowModel().rows as row (row.id)} - - {#each row.getVisibleCells() as cell (cell.id)} - - - +
+ + + {#key sorting} + {#each table.getHeaderGroups() as headerGroup (headerGroup.id)} + + {#each headerGroup.headers as header (header.id)} + + {#if !header.isPlaceholder} + + {/if} + {/each} {/each} - - -
+ {/key} + + + {#each table.getRowModel().rows as row (row.id)} + + {#each row.getVisibleCells() as cell (cell.id)} + + + + {/each} + + {/each} + +
+ + {#if totalPages > 1} +
+

+ Showing {(currentPage - 1) * pageSize + 1} to {Math.min( + currentPage * pageSize, + totalCount + )} + of {totalCount} videos +

+
+ + + Page {currentPage} of {totalPages} + + +
+
+ {/if} {/if}
diff --git a/apps/web/src/lib/components/ChannelsList.svelte b/apps/web/src/lib/components/ChannelsList.svelte index f833020..3bb80a0 100644 --- a/apps/web/src/lib/components/ChannelsList.svelte +++ b/apps/web/src/lib/components/ChannelsList.svelte @@ -33,7 +33,7 @@
{#each channels as channel}
diff --git a/apps/web/src/lib/components/GlobalAppCommand.svelte b/apps/web/src/lib/components/GlobalAppCommand.svelte index f4b1931..d616594 100644 --- a/apps/web/src/lib/components/GlobalAppCommand.svelte +++ b/apps/web/src/lib/components/GlobalAppCommand.svelte @@ -53,7 +53,7 @@ {:else if item.type === 'channel'} CHANNEL: {item.data.name} diff --git a/apps/web/src/lib/remote/channels.remote.ts b/apps/web/src/lib/remote/channels.remote.ts index 3001406..00ce1da 100644 --- a/apps/web/src/lib/remote/channels.remote.ts +++ b/apps/web/src/lib/remote/channels.remote.ts @@ -71,9 +71,16 @@ export const remoteGetSponsorDetails = query( ); export const remoteGetChannelVideos = query( - Schema.String.pipe(Schema.standardSchemaV1), - async (channelId) => { - return await authedRemoteRunner(({ db }) => db.getChannelVideos({ ytChannelId: channelId, limit: 20 })); + z.object({ + channelId: z.string(), + page: z.number().default(1), + pageSize: z.number().default(20) + }), + async ({ channelId, page, pageSize }) => { + const offset = (page - 1) * pageSize; + return await authedRemoteRunner(({ db }) => + db.getChannelVideos({ ytChannelId: channelId, limit: pageSize, offset }) + ); } ); @@ -97,3 +104,10 @@ export const remoteGetChannelSponsors = query( return await authedRemoteRunner(({ db }) => db.getChannelSponsors(channelId)); } ); + +export const remoteGetChannelSponsorMentions = query( + Schema.String.pipe(Schema.standardSchemaV1), + async (channelId) => { + return await authedRemoteRunner(({ db }) => db.getChannelSponsorMentions(channelId)); + } +); diff --git a/apps/web/src/lib/services/db.ts b/apps/web/src/lib/services/db.ts index f1751be..70c4ca3 100644 --- a/apps/web/src/lib/services/db.ts +++ b/apps/web/src/lib/services/db.ts @@ -738,9 +738,9 @@ const dbService = Effect.gen(function* () { }; }), - getChannelVideos: (args: { ytChannelId: string; limit: number }) => + getChannelVideos: (args: { ytChannelId: string; limit: number; offset: number }) => Effect.gen(function* () { - const { ytChannelId, limit } = args; + const { ytChannelId, limit, offset } = args; const videos = yield* Effect.tryPromise({ try: () => drizzle @@ -750,6 +750,7 @@ const dbService = Effect.gen(function* () { }) .from(DB_SCHEMA.videos) .limit(limit) + .offset(offset) .leftJoin( DB_SCHEMA.sponsorToVideos, eq(DB_SCHEMA.sponsorToVideos.ytVideoId, DB_SCHEMA.videos.ytVideoId) @@ -766,10 +767,25 @@ const dbService = Effect.gen(function* () { }) }); - return videos.map((v) => ({ - ...v.video, - sponsor: v.sponsor || null - })); + const totalCount = yield* Effect.tryPromise({ + try: () => + drizzle + .select({ count: count() }) + .from(DB_SCHEMA.videos) + .where(eq(DB_SCHEMA.videos.ytChannelId, ytChannelId)), + catch: (err) => + new DbError('Failed to count channel videos', { + cause: err + }) + }).pipe(Effect.map((res) => Number(res[0]?.count ?? 0))); + + return { + videos: videos.map((v) => ({ + ...v.video, + sponsor: v.sponsor || null + })), + totalCount + }; }), getChannelNotifications: (ytChannelId: string) => @@ -1146,6 +1162,52 @@ const dbService = Effect.gen(function* () { }); }), + getChannelSponsorMentions: (ytChannelId: string) => + Effect.gen(function* () { + const mentions = yield* Effect.tryPromise({ + try: () => + drizzle + .select({ + comment: DB_SCHEMA.comments, + videoTitle: DB_SCHEMA.videos.title, + sponsorName: DB_SCHEMA.sponsors.name, + sponsorId: DB_SCHEMA.sponsors.sponsorId + }) + .from(DB_SCHEMA.comments) + .innerJoin( + DB_SCHEMA.videos, + eq(DB_SCHEMA.videos.ytVideoId, DB_SCHEMA.comments.ytVideoId) + ) + .leftJoin( + DB_SCHEMA.sponsorToVideos, + eq(DB_SCHEMA.sponsorToVideos.ytVideoId, DB_SCHEMA.videos.ytVideoId) + ) + .leftJoin( + DB_SCHEMA.sponsors, + eq(DB_SCHEMA.sponsors.sponsorId, DB_SCHEMA.sponsorToVideos.sponsorId) + ) + .where( + and( + eq(DB_SCHEMA.videos.ytChannelId, ytChannelId), + eq(DB_SCHEMA.comments.isSponsorMention, true) + ) + ) + .orderBy(desc(DB_SCHEMA.comments.publishedAt)) + .limit(40), + catch: (err) => + new DbError('Failed to get channel sponsor mentions', { + cause: err + }) + }); + + return mentions.map((m) => ({ + ...m.comment, + videoTitle: m.videoTitle, + sponsorName: m.sponsorName, + sponsorId: m.sponsorId + })); + }), + getVideoDetails: (ytVideoId: string) => Effect.gen(function* () { const video = yield* Effect.tryPromise({ diff --git a/apps/web/src/routes/app/channel/create/+page.svelte b/apps/web/src/routes/app/channel/create/+page.svelte index ac1c077..0223954 100644 --- a/apps/web/src/routes/app/channel/create/+page.svelte +++ b/apps/web/src/routes/app/channel/create/+page.svelte @@ -28,7 +28,7 @@ try { await submit(); if (remoteCreateChannel.result?.success) { - await goto(`/app/view/channel?channelId=${remoteCreateChannel.result.ytChannelId}`); + await goto(`/app/view/channel/overview?channelId=${remoteCreateChannel.result.ytChannelId}`); } else { error = 'Failed to create channel'; } diff --git a/apps/web/src/routes/app/view/channel/+layout.svelte b/apps/web/src/routes/app/view/channel/+layout.svelte new file mode 100644 index 0000000..45fdff9 --- /dev/null +++ b/apps/web/src/routes/app/view/channel/+layout.svelte @@ -0,0 +1,72 @@ + + + + Channel - r8y 3.0 + + + + diff --git a/apps/web/src/routes/app/view/channel/+page.svelte b/apps/web/src/routes/app/view/channel/+page.svelte index 0d57a2f..4247a41 100644 --- a/apps/web/src/routes/app/view/channel/+page.svelte +++ b/apps/web/src/routes/app/view/channel/+page.svelte @@ -1,93 +1,17 @@ - - Channel - r8y 3.0 - - - -
- {#if !channelId} -

Please select a channel

- {:else} -
- - -
- - - (tab = 'details')}>Overview - (tab = 'recent')}>Last 7 Days - (tab = 'sponsors')}>Sponsors - - - -
-
- - {#if tab === 'details'} -
- - {#snippet pending()} -
loading videos...
- {/snippet} - {#snippet failed(e, r)} -
error loading videos: {(e as Error).message}
- {/snippet} - -
- - {#snippet pending()} -
loading notifications...
- {/snippet} - {#snippet failed(e, r)} -
error loading notifications: {(e as Error).message}
- {/snippet} - -
-
- {:else if tab === 'recent'} - - {#snippet pending()} -
loading videos...
- {/snippet} - {#snippet failed(e, r)} -
error loading videos: {(e as Error).message}
- {/snippet} - -
- {:else if tab === 'sponsors'} - - {#snippet pending()} -
loading sponsors...
- {/snippet} - {#snippet failed(e, r)} -
error loading sponsors: {(e as Error).message}
- {/snippet} - -
- {/if} - {/if} +
+

Redirecting...

diff --git a/apps/web/src/routes/app/view/channel/last7days/+page.svelte b/apps/web/src/routes/app/view/channel/last7days/+page.svelte new file mode 100644 index 0000000..726fb93 --- /dev/null +++ b/apps/web/src/routes/app/view/channel/last7days/+page.svelte @@ -0,0 +1,25 @@ + + +{#if channelId} + + {#snippet pending()} +
loading videos...
+ {/snippet} + {#snippet failed(e)} +
error loading videos: {(e as Error).message}
+ {/snippet} + +
+{/if} + diff --git a/apps/web/src/routes/app/view/channel/overview/+page.svelte b/apps/web/src/routes/app/view/channel/overview/+page.svelte new file mode 100644 index 0000000..b72fa09 --- /dev/null +++ b/apps/web/src/routes/app/view/channel/overview/+page.svelte @@ -0,0 +1,49 @@ + + +{#if channelId} +
+ + {#snippet pending()} +
loading videos...
+ {/snippet} + {#snippet failed(e)} +
error loading videos: {(e as Error).message}
+ {/snippet} + +
+ + + {#snippet pending()} +
loading sponsor mentions...
+ {/snippet} + {#snippet failed(e)} +
error loading sponsor mentions: {(e as Error).message}
+ {/snippet} + +
+ + + {#snippet pending()} +
loading notifications...
+ {/snippet} + {#snippet failed(e)} +
error loading notifications: {(e as Error).message}
+ {/snippet} + +
+
+{/if} + diff --git a/apps/web/src/routes/app/view/channel/sponsors/+page.svelte b/apps/web/src/routes/app/view/channel/sponsors/+page.svelte new file mode 100644 index 0000000..ff518f1 --- /dev/null +++ b/apps/web/src/routes/app/view/channel/sponsors/+page.svelte @@ -0,0 +1,25 @@ + + +{#if channelId} + + {#snippet pending()} +
loading sponsors...
+ {/snippet} + {#snippet failed(e)} +
error loading sponsors: {(e as Error).message}
+ {/snippet} + +
+{/if} +