diff --git a/.dockerignore b/.dockerignore index aec4d8b..0ba9e74 100644 --- a/.dockerignore +++ b/.dockerignore @@ -7,4 +7,4 @@ node_modules dist *.log .env* -.DS_Store \ No newline at end of file +.DS_Store diff --git a/.vscode/settings.json b/.vscode/settings.json index 5ea1ee2..1a321b9 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,4 @@ { - "typescript.experimental.useTsgo": false, "editor.defaultFormatter": "esbenp.prettier-vscode", "editor.formatOnSave": true, "workbench.colorCustomizations": { @@ -11,21 +10,6 @@ "editor.codeActionsOnSave": { "source.fixAll": "explicit" }, - "[typescript]": { - "editor.defaultFormatter": "esbenp.prettier-vscode" - }, - "[javascript]": { - "editor.defaultFormatter": "esbenp.prettier-vscode" - }, - "[svelte]": { - "editor.defaultFormatter": "esbenp.prettier-vscode" - }, - "[json]": { - "editor.defaultFormatter": "esbenp.prettier-vscode" - }, - "[jsonc]": { - "editor.defaultFormatter": "esbenp.prettier-vscode" - }, "typescript.tsdk": "node_modules/typescript/lib", "typescript.enablePromptUseWorkspaceTsdk": true, "files.watcherExclude": { @@ -35,4 +19,4 @@ "**/.svelte-kit/**": true, "**/.turbo/**": true } -} +} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 031d315..9604ee5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,3 +37,13 @@ The Effect Solutions CLI provides curated best practices and patterns for Effect - `effect-solutions search ` - Search topics by keyword **Local Effect Source:** The Effect repository is cloned to `~/.local/share/effect-solutions/effect` for reference. Use this to explore APIs, find usage examples, and understand implementation details when the documentation isn't enough. + +## btca + +Trigger: user says "use btca" (for codebase/docs questions). + +Run: + +- btca ask -t -q "" + +Available : svelte, tailwindcss diff --git a/apps/bg/package.json b/apps/bg/package.json index 13b5140..ef143cb 100644 --- a/apps/bg/package.json +++ b/apps/bg/package.json @@ -30,8 +30,5 @@ "devDependencies": { "@types/bun": "latest", "prettier": "^3.7.4" - }, - "peerDependencies": { - "typescript": "^5.9.3" } } diff --git a/apps/helpers/.gitignore b/apps/helpers/.gitignore deleted file mode 100644 index a14702c..0000000 --- a/apps/helpers/.gitignore +++ /dev/null @@ -1,34 +0,0 @@ -# dependencies (bun install) -node_modules - -# output -out -dist -*.tgz - -# code coverage -coverage -*.lcov - -# logs -logs -_.log -report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json - -# dotenv environment variable files -.env -.env.development.local -.env.test.local -.env.production.local -.env.local - -# caches -.eslintcache -.cache -*.tsbuildinfo - -# IntelliJ based IDEs -.idea - -# Finder (MacOS) folder config -.DS_Store diff --git a/apps/helpers/README.md b/apps/helpers/README.md deleted file mode 100644 index e780bb0..0000000 --- a/apps/helpers/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# local-helpers - -To install dependencies: - -```bash -bun install -``` - -To run: - -```bash -bun run index.ts -``` - -This project was created using `bun init` in bun v1.3.0. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime. diff --git a/apps/helpers/package.json b/apps/helpers/package.json deleted file mode 100644 index f0b794d..0000000 --- a/apps/helpers/package.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "@r8y/helpers", - "private": true, - "scripts": { - "wipe": "bun src/wipe.ts" - }, - "dependencies": { - "@r8y/channel-sync": "*", - "@r8y/db": "*" - }, - "devDependencies": { - "@types/bun": "latest", - "prettier": "^3.6.2" - }, - "peerDependencies": { - "typescript": "^5" - } -} diff --git a/apps/helpers/src/db/index.ts b/apps/helpers/src/db/index.ts deleted file mode 100644 index 5f82399..0000000 --- a/apps/helpers/src/db/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { getDrizzleInstance } from '@r8y/db'; - -export const dbClient = getDrizzleInstance(Bun.env.MYSQL_URL!); diff --git a/apps/helpers/src/wipe.ts b/apps/helpers/src/wipe.ts deleted file mode 100644 index 8b9b348..0000000 --- a/apps/helpers/src/wipe.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { DB_SCHEMA } from '@r8y/db'; -import { dbClient } from './db'; - -const main = async () => { - const prompt = 'This will wipe DB tables. Type "yes" to continue: '; - const input = await new Promise((res) => { - process.stdout.write(prompt); - let data = ''; - process.stdin.on('data', (chunk) => { - data += chunk.toString(); - if (data.endsWith('\n')) { - res(data.trim()); - } - }); - }); - if (input !== 'yes') { - console.log('Aborted.'); - process.exit(0); - } - - console.log('Wiping DB tables...'); - await dbClient.delete(DB_SCHEMA.sponsorToVideos); - console.log('Wiped sponsorToVideos'); - await dbClient.delete(DB_SCHEMA.sponsors); - console.log('Wiped sponsors'); - await dbClient.delete(DB_SCHEMA.videos); - console.log('Wiped videos'); - await dbClient.delete(DB_SCHEMA.comments); - console.log('Wiped comments'); - await dbClient.delete(DB_SCHEMA.notifications); - console.log('Wiped notifications'); - console.log('DB tables wiped successfully'); -}; - -main(); diff --git a/apps/helpers/tsconfig.json b/apps/helpers/tsconfig.json deleted file mode 100644 index bfa0fea..0000000 --- a/apps/helpers/tsconfig.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "compilerOptions": { - // Environment setup & latest features - "lib": ["ESNext"], - "target": "ESNext", - "module": "Preserve", - "moduleDetection": "force", - "jsx": "react-jsx", - "allowJs": true, - - // Bundler mode - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "verbatimModuleSyntax": true, - "noEmit": true, - - // Best practices - "strict": true, - "skipLibCheck": true, - "noFallthroughCasesInSwitch": true, - "noUncheckedIndexedAccess": true, - "noImplicitOverride": true, - - // Some stricter flags (disabled by default) - "noUnusedLocals": false, - "noUnusedParameters": false, - "noPropertyAccessFromIndexSignature": false - } -} diff --git a/apps/web/package.json b/apps/web/package.json index 88cc667..dafa29c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -22,12 +22,12 @@ "@r8y/db": "*", "effect": "^3.19.10", "mode-watcher": "^1.1.0", - "runed": "^0.36.0", + "runed": "^0.37.0", "zod": "^4.1.13" }, "devDependencies": { "@internationalized/date": "^3.10.0", - "@lucide/svelte": "^0.544.0", + "@lucide/svelte": "^0.561.0", "@sveltejs/kit": "^2.49.2", "@sveltejs/vite-plugin-svelte": "^6.2.1", "@tailwindcss/vite": "^4.1.17", @@ -44,7 +44,6 @@ "tailwind-variants": "^3.2.2", "tailwindcss": "^4.1.17", "tw-animate-css": "^1.4.0", - "typescript": "^5.9.3", "vite": "^7.2.7", "vite-plugin-devtools-json": "^1.0.0" } diff --git a/apps/web/src/lib/assets/favicon.svg b/apps/web/src/lib/assets/favicon.svg index cc5dc66..9aca32e 100644 --- a/apps/web/src/lib/assets/favicon.svg +++ b/apps/web/src/lib/assets/favicon.svg @@ -1 +1 @@ -svelte-logo \ No newline at end of file + diff --git a/apps/web/src/lib/components/AppBreadcrumb.svelte b/apps/web/src/lib/components/AppBreadcrumb.svelte new file mode 100644 index 0000000..069c866 --- /dev/null +++ b/apps/web/src/lib/components/AppBreadcrumb.svelte @@ -0,0 +1,45 @@ + + + + + + Channels + + {#each items as item} + + + {#if item.type === 'link'} + {item.label} + {:else if item.type === 'page'} + {item.label} + {:else if item.type === 'channel'} + + {#snippet pending()} + Loading... + {/snippet} + {#snippet failed()} + Error + {/snippet} + {#if item.isLink} + + {(await remoteGetChannel(item.channelId)).name} + + {:else} + {(await remoteGetChannel(item.channelId)).name} + {/if} + + {/if} + + {/each} + + diff --git a/apps/web/src/lib/components/ChannelHeader.svelte b/apps/web/src/lib/components/ChannelHeader.svelte deleted file mode 100644 index d98682e..0000000 --- a/apps/web/src/lib/components/ChannelHeader.svelte +++ /dev/null @@ -1,43 +0,0 @@ - - -
- - - {selectedChannel?.name ?? 'Select Channel'} - - - - - Switch Channel - {#each channels as channel} - - - {channel.name} - {#if channelId === channel.ytChannelId} - - {/if} - - - {/each} - - - - - -
diff --git a/apps/web/src/lib/components/ChannelLastSevenVids.svelte b/apps/web/src/lib/components/ChannelLastSevenVids.svelte index 6a6b396..4c76381 100644 --- a/apps/web/src/lib/components/ChannelLastSevenVids.svelte +++ b/apps/web/src/lib/components/ChannelLastSevenVids.svelte @@ -1,5 +1,4 @@ -{#if channels.length === 0} -
-
-
-{:else} -
- {#each channels as channel} - -
-
-
-

- {channel.name} -

-

- {channel.ytChannelId} -

+ {:else} +
+ {#each channels as channel} + +
+
+
+

+ {channel.name} +

+

+ {channel.ytChannelId} +

+
-
-
+ {/if} + diff --git a/apps/web/src/lib/components/GlobalAppCommand.svelte b/apps/web/src/lib/components/GlobalAppCommand.svelte index e28526c..f4b1931 100644 --- a/apps/web/src/lib/components/GlobalAppCommand.svelte +++ b/apps/web/src/lib/components/GlobalAppCommand.svelte @@ -12,19 +12,12 @@ channelId: z.string().default('') }); + const asdf = ''; + const params = useSearchParams(searchParamsSchema); const channelId = $derived(params.channelId); - const searchResults = $derived( - await remoteSearchVideosAndSponsors({ - channelId: channelId, - searchQuery: value - }) - ); - - const results = $derived(searchResults.results); - function handleKeydown(e: KeyboardEvent) { if (e.key === 'k' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); @@ -43,14 +36,14 @@ {#snippet Results()} - {#each results as item} + {#each await remoteSearchVideosAndSponsors({ channelId: channelId, searchQuery: value }) as item} {#if item.type === 'sponsor'} - >; - channelId: string; - } = $props(); + type Comment = { + ytVideoId: string; + ytCommentId: string; + text: string; + videoTitle: string; + author: string; + likeCount: number; + publishedAt: Date | string; + }; + + type SponsorData = { + sponsorMentionComments: Comment[]; + }; + + const { sponsorData, channelId }: { sponsorData: SponsorData; channelId: string } = $props(); const getYouTubeCommentUrl = (videoId: string, commentId: string) => { return `https://www.youtube.com/watch?v=${videoId}&lc=${commentId}`; }; - type Comment = (typeof sponsorData.sponsorMentionComments)[number]; - const columns: ColumnDef[] = [ { accessorKey: 'text', @@ -203,7 +207,7 @@ {#each table.getRowModel().rows as row (row.id)} {#each row.getVisibleCells() as cell (cell.id)} - + {/each} diff --git a/apps/web/src/lib/components/SponsorStats.svelte b/apps/web/src/lib/components/SponsorStats.svelte index 55e31ae..214f03a 100644 --- a/apps/web/src/lib/components/SponsorStats.svelte +++ b/apps/web/src/lib/components/SponsorStats.svelte @@ -2,13 +2,17 @@ import { formatNumber, formatDate } from '$lib/utils'; import { Eye, Video, Calendar, TrendingUp } from '@lucide/svelte'; - const { - sponsorData - }: { - sponsorData: Awaited< - ReturnType - >; - } = $props(); + type SponsorData = { + sponsor: { sponsorId: string; name: string; sponsorKey: string }; + stats: { + totalViews: number; + totalAds: number; + avgViewsPerVideo: number; + lastPublishDate: Date | string | number | null; + }; + }; + + const { sponsorData }: { sponsorData: SponsorData } = $props();
diff --git a/apps/web/src/lib/components/SponsorVideosTable.svelte b/apps/web/src/lib/components/SponsorVideosTable.svelte index 1a5791a..b44f497 100644 --- a/apps/web/src/lib/components/SponsorVideosTable.svelte +++ b/apps/web/src/lib/components/SponsorVideosTable.svelte @@ -18,17 +18,20 @@ } from '@tanstack/table-core'; import { formatNumber, formatDate } from '$lib/utils'; - const { - sponsorData, - channelId - }: { - sponsorData: Awaited< - ReturnType - >; - channelId: string; - } = $props(); + type VideoType = { + ytVideoId: string; + title: string; + thumbnailUrl: string; + viewCount: number; + likeCount: number; + publishedAt: Date | string; + }; - type VideoType = (typeof sponsorData.videos)[number]; + type SponsorData = { + videos: VideoType[]; + }; + + const { sponsorData, channelId }: { sponsorData: SponsorData; channelId: string } = $props(); const columns: ColumnDef[] = [ { diff --git a/apps/web/src/lib/components/VideoCommentsTable.svelte b/apps/web/src/lib/components/VideoCommentsTable.svelte index f343f00..c4330d5 100644 --- a/apps/web/src/lib/components/VideoCommentsTable.svelte +++ b/apps/web/src/lib/components/VideoCommentsTable.svelte @@ -23,13 +23,25 @@ } from '@tanstack/table-core'; import { formatNumber, formatDate } from '$lib/utils'; - const { - videoData - }: { - videoData: Awaited< - ReturnType - >; - } = $props(); + type Comment = { + ytCommentId: string; + author: string; + text: string; + likeCount: number; + replyCount: number; + publishedAt: Date; + isQuestion: boolean; + isSponsorMention: boolean; + isEditingMistake: boolean; + isPositiveComment: boolean; + }; + + type VideoData = { + video: { ytVideoId: string }; + comments: Comment[]; + }; + + const { videoData }: { videoData: VideoData } = $props(); let showQuestions = $state(false); let showSponsorMentions = $state(false); @@ -49,9 +61,7 @@ }); }); - type Comments = ColumnDef<(typeof videoData.comments)[number]>[]; - - const columns: Comments = [ + const columns: ColumnDef[] = [ { accessorKey: 'author', header: 'Author', diff --git a/apps/web/src/lib/components/VideoNotificationsTable.svelte b/apps/web/src/lib/components/VideoNotificationsTable.svelte index 906b0da..999a455 100644 --- a/apps/web/src/lib/components/VideoNotificationsTable.svelte +++ b/apps/web/src/lib/components/VideoNotificationsTable.svelte @@ -4,13 +4,18 @@ import { formatRelativeTime } from '$lib/utils'; import { Bell } from '@lucide/svelte'; - const { - videoData - }: { - videoData: Awaited< - ReturnType - >; - } = $props(); + type Notification = { + type: string; + success: boolean; + message: string; + createdAt: Date; + }; + + type VideoData = { + notifications?: Notification[]; + }; + + const { videoData }: { videoData: VideoData } = $props(); const getNotificationTypeLabel = (type: string) => { return type diff --git a/apps/web/src/lib/remote/auth.remote.ts b/apps/web/src/lib/remote/auth.remote.ts index f94fcbe..a2019ac 100644 --- a/apps/web/src/lib/remote/auth.remote.ts +++ b/apps/web/src/lib/remote/auth.remote.ts @@ -1,8 +1,7 @@ -import { command, getRequestEvent } from '$app/server'; -import z from 'zod'; -import { Effect } from 'effect'; -import { remoteRunner } from './helpers'; +import { command, form, getRequestEvent, query } from '$app/server'; import { AuthService } from '$lib/services/auth'; +import { Effect, Schema } from 'effect'; +import { remoteRunner } from './helpers'; export const remoteSignOut = command(async () => { return await remoteRunner( @@ -14,30 +13,30 @@ export const remoteSignOut = command(async () => { ); }); -export const remoteSignIn = command( - z.object({ - authPassword: z.string() - }), - async ({ authPassword }) => { - return await remoteRunner( - Effect.gen(function* () { - const auth = yield* AuthService; - const event = yield* Effect.sync(() => getRequestEvent()); - return yield* auth.signIn({ - event, - authPassword - }); - }) - ); - } -); +const signInSchema = Schema.Struct({ + authPassword: Schema.String +}).pipe(Schema.standardSchemaV1); -export const remoteCheckAuth = command(async () => { +export const remoteSignIn = form(signInSchema, async (formData) => { return await remoteRunner( Effect.gen(function* () { + const auth = yield* AuthService; const event = yield* Effect.sync(() => getRequestEvent()); + return yield* auth.signIn({ event, authPassword: formData.authPassword }); + }) + ); +}); + +export const remoteCheckAuth = query(async () => { + return await remoteRunner( + Effect.gen(function* () { const auth = yield* AuthService; - return yield* auth.checkAuth(event); + + const event = yield* Effect.sync(() => getRequestEvent()); + + const res = yield* auth.checkAuth(event); + + return res; }) ); }); diff --git a/apps/web/src/lib/remote/channels.remote.ts b/apps/web/src/lib/remote/channels.remote.ts index bea7d1c..3001406 100644 --- a/apps/web/src/lib/remote/channels.remote.ts +++ b/apps/web/src/lib/remote/channels.remote.ts @@ -1,84 +1,99 @@ import { form, query } from '$app/server'; -import { Effect } from 'effect'; +import { Effect, Schema } from 'effect'; import z from 'zod'; import { authedRemoteRunner } from './helpers'; -export const remoteGetAllChannels = query(async () => { - return authedRemoteRunner(({ db }) => db.getAllChannels()); +const createChannelSchema = Schema.Struct({ + channelName: Schema.String.pipe(Schema.nonEmptyString()), + ytChannelId: Schema.String.pipe(Schema.nonEmptyString()), + findSponsorPrompt: Schema.String +}).pipe(Schema.standardSchemaV1); + +export const remoteCreateChannel = form(createChannelSchema, async (data) => { + await authedRemoteRunner(({ db }) => + Effect.gen(function* () { + yield* db.createChannel(data); + }) + ); + return { success: true, ytChannelId: data.ytChannelId }; }); -export const remoteCreateChannel = form( +export const remoteSearchVideosAndSponsors = query( z.object({ - channelName: z.string(), - findSponsorPrompt: z.string(), - ytChannelId: z.string() + searchQuery: z.string(), + channelId: z.string() }), - async (data) => { - return authedRemoteRunner(({ db }) => - Effect.gen(function* () { - yield* db.createChannel(data); - return { success: true }; - }) - ); + async (args) => { + const { results } = await authedRemoteRunner(({ db }) => db.searchVideosAndSponsors(args)); + + return results; } ); -export const remoteGetChannelsWithStats = query(async () => { - return authedRemoteRunner(({ db }) => db.getChannelsWithStats()); -}); - -export const remoteGetLast7VideosByViews = query(z.string(), async (ytChannelId) => { - return authedRemoteRunner(({ db }) => db.getLast7VideosByViews(ytChannelId)); +export const remoteGetAllChannels = query(async () => { + const results = await authedRemoteRunner(({ db }) => db.getChannelsWithStats()); + return results; }); -export const remoteGetChannelDetails = query(z.string(), async (ytChannelId) => { - return authedRemoteRunner(({ db }) => db.getChannel(ytChannelId)); -}); +export const remoteGetChannel = query( + Schema.String.pipe(Schema.standardSchemaV1), + async (channelId) => { + return await authedRemoteRunner(({ db }) => db.getChannel(channelId)); + } +); -export const remoteGetChannelVideos = query(z.string(), async (ytChannelId) => { - return authedRemoteRunner(({ db }) => - db.getChannelVideos({ - ytChannelId, - limit: 20 - }) - ); -}); +export const remoteGet2025Videos = query( + Schema.String.pipe(Schema.standardSchemaV1), + async (channelId) => { + return await authedRemoteRunner(({ db }) => db.getChannelVideos2025(channelId)); + } +); -export const remoteGetChannelNotifications = query(z.string(), async (ytChannelId) => { - return authedRemoteRunner(({ db }) => db.getChannelNotifications(ytChannelId)); -}); +export const remoteGet2025Sponsors = query( + Schema.String.pipe(Schema.standardSchemaV1), + async (channelId) => { + return await authedRemoteRunner(({ db }) => db.getChannelSponsors2025(channelId)); + } +); -export const remoteGetChannelSponsors = query(z.string(), async (ytChannelId) => { - return authedRemoteRunner(({ db }) => db.getChannelSponsors(ytChannelId)); -}); +export const remoteGetVideoDetails = query( + Schema.String.pipe(Schema.standardSchemaV1), + async (videoId) => { + return await authedRemoteRunner(({ db }) => db.getVideoDetails(videoId)); + } +); -export const remoteGetSponsorDetails = query(z.string(), async (sponsorId) => { - return authedRemoteRunner(({ db }) => db.getSponsorDetails(sponsorId)); -}); +export const remoteGetSponsorDetails = query( + Schema.String.pipe(Schema.standardSchemaV1), + async (sponsorId) => { + return await authedRemoteRunner(({ db }) => db.getSponsorDetails(sponsorId)); + } +); -export const remoteSearchVideosAndSponsors = query( - z.object({ - searchQuery: z.string(), - channelId: z.string() - }), - async (args) => { - return authedRemoteRunner(({ db }) => db.searchVideosAndSponsors(args)); +export const remoteGetChannelVideos = query( + Schema.String.pipe(Schema.standardSchemaV1), + async (channelId) => { + return await authedRemoteRunner(({ db }) => db.getChannelVideos({ ytChannelId: channelId, limit: 20 })); } ); -export const remoteGetVideoDetails = query(z.string(), async (ytVideoId) => { - return authedRemoteRunner(({ db }) => db.getVideoDetails(ytVideoId)); -}); +export const remoteGetChannelNotifications = query( + Schema.String.pipe(Schema.standardSchemaV1), + async (channelId) => { + return await authedRemoteRunner(({ db }) => db.getChannelNotifications(channelId)); + } +); -export const remoteGetChannel2025Data = query(z.string(), async (ytChannelId) => { - return authedRemoteRunner(({ db }) => - Effect.gen(function* () { - const [videos, sponsors] = yield* Effect.all( - [db.getChannelVideos2025(ytChannelId), db.getChannelSponsors2025(ytChannelId)], - { concurrency: 'unbounded' } - ); +export const remoteGetLast7Videos = query( + Schema.String.pipe(Schema.standardSchemaV1), + async (channelId) => { + return await authedRemoteRunner(({ db }) => db.getLast7VideosByViews(channelId)); + } +); - return { videos, sponsors }; - }) - ); -}); +export const remoteGetChannelSponsors = query( + Schema.String.pipe(Schema.standardSchemaV1), + async (channelId) => { + return await authedRemoteRunner(({ db }) => db.getChannelSponsors(channelId)); + } +); diff --git a/apps/web/src/lib/server/runner.ts b/apps/web/src/lib/server/runner.ts new file mode 100644 index 0000000..97c7792 --- /dev/null +++ b/apps/web/src/lib/server/runner.ts @@ -0,0 +1,122 @@ +import { NodeContext } from '@effect/platform-node'; +import { DbError, DbService } from '$lib/services/db'; +import { error, type RequestEvent } from '@sveltejs/kit'; +import { Effect, Cause, ManagedRuntime, Layer } from 'effect'; +import { AuthError, AuthService } from '$lib/services/auth'; +import { TaggedError } from 'effect/Data'; + +export class AppError extends TaggedError('AppError') { + status: number; + body: App.Error; + constructor(body: App.Error, status = 500) { + super(); + this.message = body.message; + this.cause = body.cause; + this.body = body; + this.status = status; + } +} + +const appLayer = Layer.mergeAll(NodeContext.layer, DbService.Default, AuthService.Default); + +const runtime = ManagedRuntime.make(appLayer); + +const shutdown = async () => { + console.log('sveltekit:shutdown'); + await runtime.dispose(); + process.exit(0); +}; + +process.on('SIGTERM', shutdown); +process.on('SIGINT', shutdown); + +export const runner = async ( + effect: Effect.Effect +) => { + const result = await effect.pipe( + Effect.catchTag('DbError', (err) => + Effect.fail( + new AppError({ + type: 'db', + message: err.message, + cause: err.cause + }) + ) + ), + Effect.catchTag('AuthError', (err) => + Effect.fail( + new AppError( + { + type: 'auth', + message: err.message, + cause: err.cause + }, + 401 + ) + ) + ), + Effect.matchCause({ + onSuccess: (res): { _type: 'success'; value: A } => ({ + _type: 'success', + value: res + }), + onFailure: (cause): { _type: 'failure'; value: AppError } => { + console.error(cause.toString()); + + const failures = Array.from(Cause.failures(cause)); + + if (failures.length > 0) { + failures.forEach((failure) => { + console.error(failure.toString()); + console.error('CAUSE', failure.cause); + }); + const first = failures[0]; + if (first) { + return { + _type: 'failure', + value: first + }; + } + } + + return { + _type: 'failure', + value: new AppError( + { + type: 'unknown', + message: 'An unexpected error occurred', + cause: cause.toString() + }, + 500 + ) + }; + } + }), + runtime.runPromise + ); + + if (result._type === 'failure') { + return error(result.value.status, result.value.body); + } + + return result.value; +}; + +export const authedRunner = async ( + event: RequestEvent, + fn: (ctx: { + auth: AuthService; + db: DbService; + }) => Effect.Effect +) => { + return runner( + Effect.gen(function* () { + const auth = yield* AuthService; + const db = yield* DbService; + + yield* auth.checkAuthAndFail(event); + + return yield* fn({ auth, db }); + }) + ); +}; diff --git a/apps/web/src/lib/services/db.ts b/apps/web/src/lib/services/db.ts index bd48d7b..f1751be 100644 --- a/apps/web/src/lib/services/db.ts +++ b/apps/web/src/lib/services/db.ts @@ -13,7 +13,7 @@ import { count, sum, max, - like + ilike } from '@r8y/db'; import { Array, Effect, pipe } from 'effect'; import { TaggedError } from 'effect/Data'; @@ -31,10 +31,10 @@ export class DbError extends TaggedError('DbError') { const generateId = () => randomBytes(16).toString('hex'); const dbService = Effect.gen(function* () { - const dbUrl = yield* Effect.sync(() => env.MYSQL_URL); + const dbUrl = yield* Effect.sync(() => env.DATABASE_URL); if (!dbUrl) { - return yield* Effect.die('MYSQL_URL is not set...'); + return yield* Effect.die('DATABASE_URL is not set...'); } const drizzle = yield* Effect.acquireRelease( @@ -957,8 +957,8 @@ const dbService = Effect.gen(function* () { .where( and( or( - like(DB_SCHEMA.videos.title, `%${searchQuery}%`), - like(DB_SCHEMA.videos.ytVideoId, `%${searchQuery}%`) + ilike(DB_SCHEMA.videos.title, `%${searchQuery}%`), + ilike(DB_SCHEMA.videos.ytVideoId, `%${searchQuery}%`) ), eq(DB_SCHEMA.videos.ytChannelId, channelId) ) @@ -986,8 +986,8 @@ const dbService = Effect.gen(function* () { .from(DB_SCHEMA.channels) .where( or( - like(DB_SCHEMA.channels.name, `%${searchQuery}%`), - like(DB_SCHEMA.channels.ytChannelId, `%${searchQuery}%`) + ilike(DB_SCHEMA.channels.name, `%${searchQuery}%`), + ilike(DB_SCHEMA.channels.ytChannelId, `%${searchQuery}%`) ) ) .limit(4), @@ -1014,8 +1014,8 @@ const dbService = Effect.gen(function* () { .where( and( or( - like(DB_SCHEMA.sponsors.sponsorKey, `%${searchQuery}%`), - like(DB_SCHEMA.sponsors.name, `%${searchQuery}%`) + ilike(DB_SCHEMA.sponsors.sponsorKey, `%${searchQuery}%`), + ilike(DB_SCHEMA.sponsors.name, `%${searchQuery}%`) ), eq(DB_SCHEMA.sponsors.ytChannelId, channelId) ) @@ -1130,9 +1130,7 @@ const dbService = Effect.gen(function* () { return sponsors.map((s) => { const videoCount = Number(s.videoCount) || 0; const totalViews = Number(s.totalViews) || 0; - const lastPublishedAt = s.lastVideoPublishedAt - ? new Date(s.lastVideoPublishedAt) - : null; + const lastPublishedAt = s.lastVideoPublishedAt ? new Date(s.lastVideoPublishedAt) : null; const daysAgo = lastPublishedAt ? Math.floor((Date.now() - lastPublishedAt.getTime()) / (1000 * 60 * 60 * 24)) : null; diff --git a/apps/web/src/lib/stores/AuthStore.svelte.ts b/apps/web/src/lib/stores/AuthStore.svelte.ts index 4e9323b..46da252 100644 --- a/apps/web/src/lib/stores/AuthStore.svelte.ts +++ b/apps/web/src/lib/stores/AuthStore.svelte.ts @@ -1,51 +1,31 @@ +import { remoteCheckAuth, remoteSignOut } from '$lib/remote/auth.remote'; import { createContext, onMount } from 'svelte'; -import { remoteCheckAuth, remoteSignIn, remoteSignOut } from '../remote/auth.remote'; class AuthStore { isAuthenticated = $state(false); isLoading = $state(true); + signOut = async () => { + this.isLoading = true; + await remoteSignOut(); + this.isAuthenticated = false; + this.isLoading = false; + }; + constructor() { onMount(async () => { - const isAuthenticated = await remoteCheckAuth(); - console.log('result', isAuthenticated); - - this.isAuthenticated = isAuthenticated; + this.isAuthenticated = await remoteCheckAuth(); this.isLoading = false; }); } - - handleSignIn = async (authPassword: string) => { - this.isLoading = true; - const result = await remoteSignIn({ authPassword }); - if (result.success) { - this.isAuthenticated = true; - } - this.isLoading = false; - return result.success; - }; - - handleSignOut = async () => { - this.isLoading = true; - const result = await remoteSignOut(); - if (result.success) { - this.isAuthenticated = false; - } - this.isLoading = false; - }; } -const [internalGetAuthStore, internalSetAuthStore] = createContext(); - -const getAuthStore = () => { - const authStore = internalGetAuthStore(); - if (!authStore) throw new Error('AuthStore not found'); - return authStore; -}; +const [internalGetStore, internalSetStore] = createContext(); -const setAuthStore = () => { - const authStore = new AuthStore(); - return internalSetAuthStore(authStore); +export const getAuthStore = () => { + const store = internalGetStore(); + if (!store) throw new Error('AuthStore not found'); + return store; }; -export { getAuthStore, setAuthStore }; +export const setAuthStore = () => internalSetStore(new AuthStore()); diff --git a/apps/web/src/lib/stores/ChannelStore.svelte.ts b/apps/web/src/lib/stores/ChannelStore.svelte.ts new file mode 100644 index 0000000..ebf42ab --- /dev/null +++ b/apps/web/src/lib/stores/ChannelStore.svelte.ts @@ -0,0 +1,21 @@ +import type { DB_SELECT_MODELS } from '@r8y/db'; +import { createContext } from 'svelte'; + +class ChannelStore { + private channels = new Map(); + + constructor() {} +} + +const [internalGetChannelStore, internalSetChannelStore] = createContext(); + +export const getChannelStore = () => { + const store = internalGetChannelStore(); + if (!store) throw new Error('Channel store not found'); + return store; +}; + +export const setChannelStore = () => { + const store = new ChannelStore(); + internalSetChannelStore(store); +}; diff --git a/apps/web/src/routes/+layout.svelte b/apps/web/src/routes/+layout.svelte index 037c7dd..36c009e 100644 --- a/apps/web/src/routes/+layout.svelte +++ b/apps/web/src/routes/+layout.svelte @@ -1,21 +1,29 @@ - My SvelteKit App + r8y - YT Tracking + - -
- {@render children?.()} -
+{#if authStore.isLoading} + +{:else} +
+ {@render children?.()} +
+{/if} diff --git a/apps/web/src/routes/+page.svelte b/apps/web/src/routes/+page.svelte index 4e352ea..09ea006 100644 --- a/apps/web/src/routes/+page.svelte +++ b/apps/web/src/routes/+page.svelte @@ -1,37 +1,19 @@ - - r8y - YT Analytics & Sponsor Tracking - - - -{#if authStore.isLoading} - -{:else if authStore.isAuthenticated} +{#if authStore.isAuthenticated}
@@ -48,18 +30,45 @@

r8y 3.0

Sign in to access your dashboard

-
+ { + isSubmitting = true; + error = null; + try { + await submit(); + if (remoteSignIn.result?.success) { + await goto('/app'); + } else { + error = 'Invalid password'; + } + } catch { + error = 'Sign in failed'; + } finally { + isSubmitting = false; + } + })} + >
- + {#if error} +

{error}

+ {/if} +
diff --git a/apps/web/src/routes/app/+layout.svelte b/apps/web/src/routes/app/+layout.svelte index 27b67a0..e96a306 100644 --- a/apps/web/src/routes/app/+layout.svelte +++ b/apps/web/src/routes/app/+layout.svelte @@ -3,20 +3,25 @@ import { toggleMode } from 'mode-watcher'; import { mode } from 'mode-watcher'; import { Moon, Sun } from '@lucide/svelte'; - import { getAuthStore } from '$lib/stores/AuthStore.svelte'; - import RootLoader from '$lib/components/RootLoader.svelte'; - import AppError from '$lib/components/AppError.svelte'; import GlobalAppCommand from '$lib/components/GlobalAppCommand.svelte'; + import { setAuthStore } from '$lib/stores/AuthStore.svelte'; const { children } = $props(); - const authStore = getAuthStore(); + const authStore = setAuthStore(); -
- {#if authStore.isAuthenticated} +{#if !authStore.isAuthenticated} +
+
+

Not Authenticated

+ +
+
+{:else} +
- {:else} - - {/if} -
- - {#snippet pending()} - - {/snippet} - {#snippet failed(err, retryFn)} - - {/snippet} +
{@render children()} - -
-
+ +
+{/if} diff --git a/apps/web/src/routes/app/+layout.ts b/apps/web/src/routes/app/+layout.ts new file mode 100644 index 0000000..a3d1578 --- /dev/null +++ b/apps/web/src/routes/app/+layout.ts @@ -0,0 +1 @@ +export const ssr = false; diff --git a/apps/web/src/routes/app/+page.svelte b/apps/web/src/routes/app/+page.svelte index 89bfe87..e83925d 100644 --- a/apps/web/src/routes/app/+page.svelte +++ b/apps/web/src/routes/app/+page.svelte @@ -24,8 +24,8 @@
-

Channels

-

Manage and view statistics for your channels

+

Channels

+

Manage and view statistics for your channels

- - {#snippet pending()} -
- {#each Array(3) as _} -
-
-
-
-
-
-
-
-
- {/each} -
- {/snippet} - -
+
diff --git a/apps/web/src/routes/app/channel/create/+page.svelte b/apps/web/src/routes/app/channel/create/+page.svelte index 8319d8e..ac1c077 100644 --- a/apps/web/src/routes/app/channel/create/+page.svelte +++ b/apps/web/src/routes/app/channel/create/+page.svelte @@ -8,8 +8,7 @@ import { remoteCreateChannel } from '$lib/remote/channels.remote'; let isCreating = $state(false); - - const { channelName, findSponsorPrompt, ytChannelId } = remoteCreateChannel.fields; + let error = $state(null); @@ -22,38 +21,45 @@
{ + class="flex w-96 flex-col gap-6" + {...remoteCreateChannel.enhance(async ({ submit }) => { + isCreating = true; + error = null; try { - isCreating = true; await submit(); - form.reset(); - await goto(`/app/view/channel?channelId=${data.ytChannelId}`); - } catch (error) { - console.error(error); + if (remoteCreateChannel.result?.success) { + await goto(`/app/view/channel?channelId=${remoteCreateChannel.result.ytChannelId}`); + } else { + error = 'Failed to create channel'; + } + } catch { + error = 'Failed to create channel'; } finally { isCreating = false; } })} - class="flex w-96 flex-col gap-6" >

Create Channel

+ {#if error} +

{error}

+ {/if}