diff --git a/packages/web/src/components/CloudflareStreamPlayer.tsx b/packages/web/src/components/CloudflareStreamPlayer.tsx index 1f423d4a..2d096dc4 100644 --- a/packages/web/src/components/CloudflareStreamPlayer.tsx +++ b/packages/web/src/components/CloudflareStreamPlayer.tsx @@ -78,7 +78,6 @@ export function CloudflareStreamPlayer({ responsive={false} height="100%" width="100%" - onCanPlay={handleReady} onPlaying={handleReady} /> diff --git a/packages/web/src/lib/jazz/schema.ts b/packages/web/src/lib/jazz/schema.ts index 1bbc8d08..69387919 100644 --- a/packages/web/src/lib/jazz/schema.ts +++ b/packages/web/src/lib/jazz/schema.ts @@ -15,6 +15,24 @@ export type Presence = z.infer */ export const PresenceFeed = co.feed(Presence) +/** + * Paid comment entry - a message attached to a verified payment + */ +export const PaidComment = z.object({ + message: z.string(), + sender: z.string().nullable(), + usdAmount: z.number(), + solAmount: z.number(), + signature: z.string(), + createdAt: z.number(), +}) +export type PaidComment = z.infer + +/** + * Feed of paid comment entries + */ +export const PaidCommentFeed = co.feed(PaidComment) + /** * Container for a stream's presence feed - enables upsertUnique */ @@ -22,6 +40,13 @@ export const StreamPresenceContainer = co.map({ presenceFeed: PresenceFeed, }) +/** + * Container for a stream's paid comment feed - enables upsertUnique + */ +export const StreamPaidCommentsContainer = co.map({ + commentFeed: PaidCommentFeed, +}) + /** * Account profile - minimal, just for Jazz to work */ @@ -33,12 +58,29 @@ export const ViewerProfile = co onCreate: (newGroup) => newGroup.makePublic(), }) +/** + * A saved URL entry + */ +export const SavedUrl = z.object({ + url: z.string(), + title: z.string().nullable(), + createdAt: z.number(), +}) +export type SavedUrl = z.infer + +/** + * List of saved URLs + */ +export const SavedUrlList = co.list(SavedUrl) + /** * Viewer account root - stores any viewer-specific data */ export const ViewerRoot = co.map({ /** Placeholder field */ version: z.number(), + /** User's saved URLs */ + savedUrls: SavedUrlList, }) /** @@ -58,6 +100,7 @@ export const ViewerAccount = co if (!account.$jazz.has("root")) { account.$jazz.set("root", { version: 1, + savedUrls: SavedUrlList.create([]), }) } }) diff --git a/packages/web/src/routeTree.gen.ts b/packages/web/src/routeTree.gen.ts index a0cf7691..fc93977a 100644 --- a/packages/web/src/routeTree.gen.ts +++ b/packages/web/src/routeTree.gen.ts @@ -10,6 +10,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as UsersRouteImport } from './routes/users' +import { Route as UrlsRouteImport } from './routes/urls' import { Route as SettingsRouteImport } from './routes/settings' import { Route as SessionsRouteImport } from './routes/sessions' import { Route as MarketplaceRouteImport } from './routes/marketplace' @@ -27,6 +28,7 @@ import { Route as CanvasCanvasIdRouteImport } from './routes/canvas.$canvasId' import { Route as ArchiveArchiveIdRouteImport } from './routes/archive.$archiveId' import { Route as ApiUsersRouteImport } from './routes/api/users' import { Route as ApiUsageEventsRouteImport } from './routes/api/usage-events' +import { Route as ApiStreamReplaysRouteImport } from './routes/api/stream-replays' import { Route as ApiStreamRouteImport } from './routes/api/stream' import { Route as ApiProfileRouteImport } from './routes/api/profile' import { Route as ApiContextItemsRouteImport } from './routes/api/context-items' @@ -43,6 +45,8 @@ import { Route as ApiUsageEventsCreateRouteImport } from './routes/api/usage-eve import { Route as ApiStripeWebhooksRouteImport } from './routes/api/stripe/webhooks' import { Route as ApiStripeCheckoutRouteImport } from './routes/api/stripe/checkout' import { Route as ApiStreamsUsernameRouteImport } from './routes/api/streams.$username' +import { Route as ApiStreamReplaysReplayIdRouteImport } from './routes/api/stream-replays.$replayId' +import { Route as ApiSpotifyNowPlayingRouteImport } from './routes/api/spotify.now-playing' import { Route as ApiFlowgladSplatRouteImport } from './routes/api/flowglad/$' import { Route as ApiChatMutationsRouteImport } from './routes/api/chat/mutations' import { Route as ApiChatGuestRouteImport } from './routes/api/chat/guest' @@ -57,6 +61,7 @@ import { Route as DemoStartSsrSpaModeRouteImport } from './routes/demo/start.ssr import { Route as DemoStartSsrFullSsrRouteImport } from './routes/demo/start.ssr.full-ssr' import { Route as DemoStartSsrDataOnlyRouteImport } from './routes/demo/start.ssr.data-only' import { Route as ApiStreamsUsernameViewersRouteImport } from './routes/api/streams.$username.viewers' +import { Route as ApiStreamsUsernameReplaysRouteImport } from './routes/api/streams.$username.replays' import { Route as ApiCanvasImagesImageIdRouteImport } from './routes/api/canvas.images.$imageId' import { Route as ApiCanvasImagesImageIdGenerateRouteImport } from './routes/api/canvas.images.$imageId.generate' @@ -65,6 +70,11 @@ const UsersRoute = UsersRouteImport.update({ path: '/users', getParentRoute: () => rootRouteImport, } as any) +const UrlsRoute = UrlsRouteImport.update({ + id: '/urls', + path: '/urls', + getParentRoute: () => rootRouteImport, +} as any) const SettingsRoute = SettingsRouteImport.update({ id: '/settings', path: '/settings', @@ -150,6 +160,11 @@ const ApiUsageEventsRoute = ApiUsageEventsRouteImport.update({ path: '/api/usage-events', getParentRoute: () => rootRouteImport, } as any) +const ApiStreamReplaysRoute = ApiStreamReplaysRouteImport.update({ + id: '/api/stream-replays', + path: '/api/stream-replays', + getParentRoute: () => rootRouteImport, +} as any) const ApiStreamRoute = ApiStreamRouteImport.update({ id: '/api/stream', path: '/api/stream', @@ -230,6 +245,17 @@ const ApiStreamsUsernameRoute = ApiStreamsUsernameRouteImport.update({ path: '/api/streams/$username', getParentRoute: () => rootRouteImport, } as any) +const ApiStreamReplaysReplayIdRoute = + ApiStreamReplaysReplayIdRouteImport.update({ + id: '/$replayId', + path: '/$replayId', + getParentRoute: () => ApiStreamReplaysRoute, + } as any) +const ApiSpotifyNowPlayingRoute = ApiSpotifyNowPlayingRouteImport.update({ + id: '/api/spotify/now-playing', + path: '/api/spotify/now-playing', + getParentRoute: () => rootRouteImport, +} as any) const ApiFlowgladSplatRoute = ApiFlowgladSplatRouteImport.update({ id: '/api/flowglad/$', path: '/api/flowglad/$', @@ -302,6 +328,12 @@ const ApiStreamsUsernameViewersRoute = path: '/viewers', getParentRoute: () => ApiStreamsUsernameRoute, } as any) +const ApiStreamsUsernameReplaysRoute = + ApiStreamsUsernameReplaysRouteImport.update({ + id: '/replays', + path: '/replays', + getParentRoute: () => ApiStreamsUsernameRoute, + } as any) const ApiCanvasImagesImageIdRoute = ApiCanvasImagesImageIdRouteImport.update({ id: '/$imageId', path: '/$imageId', @@ -326,6 +358,7 @@ export interface FileRoutesByFullPath { '/marketplace': typeof MarketplaceRoute '/sessions': typeof SessionsRoute '/settings': typeof SettingsRoute + '/urls': typeof UrlsRoute '/users': typeof UsersRoute '/api/archives': typeof ApiArchivesRouteWithChildren '/api/browser-sessions': typeof ApiBrowserSessionsRouteWithChildren @@ -335,6 +368,7 @@ export interface FileRoutesByFullPath { '/api/context-items': typeof ApiContextItemsRoute '/api/profile': typeof ApiProfileRoute '/api/stream': typeof ApiStreamRoute + '/api/stream-replays': typeof ApiStreamReplaysRouteWithChildren '/api/usage-events': typeof ApiUsageEventsRouteWithChildren '/api/users': typeof ApiUsersRouteWithChildren '/archive/$archiveId': typeof ArchiveArchiveIdRoute @@ -350,6 +384,8 @@ export interface FileRoutesByFullPath { '/api/chat/guest': typeof ApiChatGuestRoute '/api/chat/mutations': typeof ApiChatMutationsRoute '/api/flowglad/$': typeof ApiFlowgladSplatRoute + '/api/spotify/now-playing': typeof ApiSpotifyNowPlayingRoute + '/api/stream-replays/$replayId': typeof ApiStreamReplaysReplayIdRoute '/api/streams/$username': typeof ApiStreamsUsernameRouteWithChildren '/api/stripe/checkout': typeof ApiStripeCheckoutRoute '/api/stripe/webhooks': typeof ApiStripeWebhooksRoute @@ -359,6 +395,7 @@ export interface FileRoutesByFullPath { '/demo/start/api-request': typeof DemoStartApiRequestRoute '/demo/start/server-funcs': typeof DemoStartServerFuncsRoute '/api/canvas/images/$imageId': typeof ApiCanvasImagesImageIdRouteWithChildren + '/api/streams/$username/replays': typeof ApiStreamsUsernameReplaysRoute '/api/streams/$username/viewers': typeof ApiStreamsUsernameViewersRoute '/demo/start/ssr/data-only': typeof DemoStartSsrDataOnlyRoute '/demo/start/ssr/full-ssr': typeof DemoStartSsrFullSsrRoute @@ -377,6 +414,7 @@ export interface FileRoutesByTo { '/marketplace': typeof MarketplaceRoute '/sessions': typeof SessionsRoute '/settings': typeof SettingsRoute + '/urls': typeof UrlsRoute '/users': typeof UsersRoute '/api/archives': typeof ApiArchivesRouteWithChildren '/api/browser-sessions': typeof ApiBrowserSessionsRouteWithChildren @@ -386,6 +424,7 @@ export interface FileRoutesByTo { '/api/context-items': typeof ApiContextItemsRoute '/api/profile': typeof ApiProfileRoute '/api/stream': typeof ApiStreamRoute + '/api/stream-replays': typeof ApiStreamReplaysRouteWithChildren '/api/usage-events': typeof ApiUsageEventsRouteWithChildren '/api/users': typeof ApiUsersRouteWithChildren '/archive/$archiveId': typeof ArchiveArchiveIdRoute @@ -401,6 +440,8 @@ export interface FileRoutesByTo { '/api/chat/guest': typeof ApiChatGuestRoute '/api/chat/mutations': typeof ApiChatMutationsRoute '/api/flowglad/$': typeof ApiFlowgladSplatRoute + '/api/spotify/now-playing': typeof ApiSpotifyNowPlayingRoute + '/api/stream-replays/$replayId': typeof ApiStreamReplaysReplayIdRoute '/api/streams/$username': typeof ApiStreamsUsernameRouteWithChildren '/api/stripe/checkout': typeof ApiStripeCheckoutRoute '/api/stripe/webhooks': typeof ApiStripeWebhooksRoute @@ -410,6 +451,7 @@ export interface FileRoutesByTo { '/demo/start/api-request': typeof DemoStartApiRequestRoute '/demo/start/server-funcs': typeof DemoStartServerFuncsRoute '/api/canvas/images/$imageId': typeof ApiCanvasImagesImageIdRouteWithChildren + '/api/streams/$username/replays': typeof ApiStreamsUsernameReplaysRoute '/api/streams/$username/viewers': typeof ApiStreamsUsernameViewersRoute '/demo/start/ssr/data-only': typeof DemoStartSsrDataOnlyRoute '/demo/start/ssr/full-ssr': typeof DemoStartSsrFullSsrRoute @@ -430,6 +472,7 @@ export interface FileRoutesById { '/marketplace': typeof MarketplaceRoute '/sessions': typeof SessionsRoute '/settings': typeof SettingsRoute + '/urls': typeof UrlsRoute '/users': typeof UsersRoute '/api/archives': typeof ApiArchivesRouteWithChildren '/api/browser-sessions': typeof ApiBrowserSessionsRouteWithChildren @@ -439,6 +482,7 @@ export interface FileRoutesById { '/api/context-items': typeof ApiContextItemsRoute '/api/profile': typeof ApiProfileRoute '/api/stream': typeof ApiStreamRoute + '/api/stream-replays': typeof ApiStreamReplaysRouteWithChildren '/api/usage-events': typeof ApiUsageEventsRouteWithChildren '/api/users': typeof ApiUsersRouteWithChildren '/archive/$archiveId': typeof ArchiveArchiveIdRoute @@ -454,6 +498,8 @@ export interface FileRoutesById { '/api/chat/guest': typeof ApiChatGuestRoute '/api/chat/mutations': typeof ApiChatMutationsRoute '/api/flowglad/$': typeof ApiFlowgladSplatRoute + '/api/spotify/now-playing': typeof ApiSpotifyNowPlayingRoute + '/api/stream-replays/$replayId': typeof ApiStreamReplaysReplayIdRoute '/api/streams/$username': typeof ApiStreamsUsernameRouteWithChildren '/api/stripe/checkout': typeof ApiStripeCheckoutRoute '/api/stripe/webhooks': typeof ApiStripeWebhooksRoute @@ -463,6 +509,7 @@ export interface FileRoutesById { '/demo/start/api-request': typeof DemoStartApiRequestRoute '/demo/start/server-funcs': typeof DemoStartServerFuncsRoute '/api/canvas/images/$imageId': typeof ApiCanvasImagesImageIdRouteWithChildren + '/api/streams/$username/replays': typeof ApiStreamsUsernameReplaysRoute '/api/streams/$username/viewers': typeof ApiStreamsUsernameViewersRoute '/demo/start/ssr/data-only': typeof DemoStartSsrDataOnlyRoute '/demo/start/ssr/full-ssr': typeof DemoStartSsrFullSsrRoute @@ -484,6 +531,7 @@ export interface FileRouteTypes { | '/marketplace' | '/sessions' | '/settings' + | '/urls' | '/users' | '/api/archives' | '/api/browser-sessions' @@ -493,6 +541,7 @@ export interface FileRouteTypes { | '/api/context-items' | '/api/profile' | '/api/stream' + | '/api/stream-replays' | '/api/usage-events' | '/api/users' | '/archive/$archiveId' @@ -508,6 +557,8 @@ export interface FileRouteTypes { | '/api/chat/guest' | '/api/chat/mutations' | '/api/flowglad/$' + | '/api/spotify/now-playing' + | '/api/stream-replays/$replayId' | '/api/streams/$username' | '/api/stripe/checkout' | '/api/stripe/webhooks' @@ -517,6 +568,7 @@ export interface FileRouteTypes { | '/demo/start/api-request' | '/demo/start/server-funcs' | '/api/canvas/images/$imageId' + | '/api/streams/$username/replays' | '/api/streams/$username/viewers' | '/demo/start/ssr/data-only' | '/demo/start/ssr/full-ssr' @@ -535,6 +587,7 @@ export interface FileRouteTypes { | '/marketplace' | '/sessions' | '/settings' + | '/urls' | '/users' | '/api/archives' | '/api/browser-sessions' @@ -544,6 +597,7 @@ export interface FileRouteTypes { | '/api/context-items' | '/api/profile' | '/api/stream' + | '/api/stream-replays' | '/api/usage-events' | '/api/users' | '/archive/$archiveId' @@ -559,6 +613,8 @@ export interface FileRouteTypes { | '/api/chat/guest' | '/api/chat/mutations' | '/api/flowglad/$' + | '/api/spotify/now-playing' + | '/api/stream-replays/$replayId' | '/api/streams/$username' | '/api/stripe/checkout' | '/api/stripe/webhooks' @@ -568,6 +624,7 @@ export interface FileRouteTypes { | '/demo/start/api-request' | '/demo/start/server-funcs' | '/api/canvas/images/$imageId' + | '/api/streams/$username/replays' | '/api/streams/$username/viewers' | '/demo/start/ssr/data-only' | '/demo/start/ssr/full-ssr' @@ -587,6 +644,7 @@ export interface FileRouteTypes { | '/marketplace' | '/sessions' | '/settings' + | '/urls' | '/users' | '/api/archives' | '/api/browser-sessions' @@ -596,6 +654,7 @@ export interface FileRouteTypes { | '/api/context-items' | '/api/profile' | '/api/stream' + | '/api/stream-replays' | '/api/usage-events' | '/api/users' | '/archive/$archiveId' @@ -611,6 +670,8 @@ export interface FileRouteTypes { | '/api/chat/guest' | '/api/chat/mutations' | '/api/flowglad/$' + | '/api/spotify/now-playing' + | '/api/stream-replays/$replayId' | '/api/streams/$username' | '/api/stripe/checkout' | '/api/stripe/webhooks' @@ -620,6 +681,7 @@ export interface FileRouteTypes { | '/demo/start/api-request' | '/demo/start/server-funcs' | '/api/canvas/images/$imageId' + | '/api/streams/$username/replays' | '/api/streams/$username/viewers' | '/demo/start/ssr/data-only' | '/demo/start/ssr/full-ssr' @@ -640,6 +702,7 @@ export interface RootRouteChildren { MarketplaceRoute: typeof MarketplaceRoute SessionsRoute: typeof SessionsRoute SettingsRoute: typeof SettingsRoute + UrlsRoute: typeof UrlsRoute UsersRoute: typeof UsersRoute ApiArchivesRoute: typeof ApiArchivesRouteWithChildren ApiBrowserSessionsRoute: typeof ApiBrowserSessionsRouteWithChildren @@ -649,6 +712,7 @@ export interface RootRouteChildren { ApiContextItemsRoute: typeof ApiContextItemsRoute ApiProfileRoute: typeof ApiProfileRoute ApiStreamRoute: typeof ApiStreamRoute + ApiStreamReplaysRoute: typeof ApiStreamReplaysRouteWithChildren ApiUsageEventsRoute: typeof ApiUsageEventsRouteWithChildren ApiUsersRoute: typeof ApiUsersRouteWithChildren I1focusDemoRoute: typeof I1focusDemoRoute @@ -657,6 +721,7 @@ export interface RootRouteChildren { ApiChatGuestRoute: typeof ApiChatGuestRoute ApiChatMutationsRoute: typeof ApiChatMutationsRoute ApiFlowgladSplatRoute: typeof ApiFlowgladSplatRoute + ApiSpotifyNowPlayingRoute: typeof ApiSpotifyNowPlayingRoute ApiStreamsUsernameRoute: typeof ApiStreamsUsernameRouteWithChildren ApiStripeCheckoutRoute: typeof ApiStripeCheckoutRoute ApiStripeWebhooksRoute: typeof ApiStripeWebhooksRoute @@ -678,6 +743,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof UsersRouteImport parentRoute: typeof rootRouteImport } + '/urls': { + id: '/urls' + path: '/urls' + fullPath: '/urls' + preLoaderRoute: typeof UrlsRouteImport + parentRoute: typeof rootRouteImport + } '/settings': { id: '/settings' path: '/settings' @@ -797,6 +869,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiUsageEventsRouteImport parentRoute: typeof rootRouteImport } + '/api/stream-replays': { + id: '/api/stream-replays' + path: '/api/stream-replays' + fullPath: '/api/stream-replays' + preLoaderRoute: typeof ApiStreamReplaysRouteImport + parentRoute: typeof rootRouteImport + } '/api/stream': { id: '/api/stream' path: '/api/stream' @@ -909,6 +988,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiStreamsUsernameRouteImport parentRoute: typeof rootRouteImport } + '/api/stream-replays/$replayId': { + id: '/api/stream-replays/$replayId' + path: '/$replayId' + fullPath: '/api/stream-replays/$replayId' + preLoaderRoute: typeof ApiStreamReplaysReplayIdRouteImport + parentRoute: typeof ApiStreamReplaysRoute + } + '/api/spotify/now-playing': { + id: '/api/spotify/now-playing' + path: '/api/spotify/now-playing' + fullPath: '/api/spotify/now-playing' + preLoaderRoute: typeof ApiSpotifyNowPlayingRouteImport + parentRoute: typeof rootRouteImport + } '/api/flowglad/$': { id: '/api/flowglad/$' path: '/api/flowglad/$' @@ -1007,6 +1100,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiStreamsUsernameViewersRouteImport parentRoute: typeof ApiStreamsUsernameRoute } + '/api/streams/$username/replays': { + id: '/api/streams/$username/replays' + path: '/replays' + fullPath: '/api/streams/$username/replays' + preLoaderRoute: typeof ApiStreamsUsernameReplaysRouteImport + parentRoute: typeof ApiStreamsUsernameRoute + } '/api/canvas/images/$imageId': { id: '/api/canvas/images/$imageId' path: '/$imageId' @@ -1111,6 +1211,17 @@ const ApiCanvasRouteWithChildren = ApiCanvasRoute._addFileChildren( ApiCanvasRouteChildren, ) +interface ApiStreamReplaysRouteChildren { + ApiStreamReplaysReplayIdRoute: typeof ApiStreamReplaysReplayIdRoute +} + +const ApiStreamReplaysRouteChildren: ApiStreamReplaysRouteChildren = { + ApiStreamReplaysReplayIdRoute: ApiStreamReplaysReplayIdRoute, +} + +const ApiStreamReplaysRouteWithChildren = + ApiStreamReplaysRoute._addFileChildren(ApiStreamReplaysRouteChildren) + interface ApiUsageEventsRouteChildren { ApiUsageEventsCreateRoute: typeof ApiUsageEventsCreateRoute } @@ -1136,10 +1247,12 @@ const ApiUsersRouteWithChildren = ApiUsersRoute._addFileChildren( ) interface ApiStreamsUsernameRouteChildren { + ApiStreamsUsernameReplaysRoute: typeof ApiStreamsUsernameReplaysRoute ApiStreamsUsernameViewersRoute: typeof ApiStreamsUsernameViewersRoute } const ApiStreamsUsernameRouteChildren: ApiStreamsUsernameRouteChildren = { + ApiStreamsUsernameReplaysRoute: ApiStreamsUsernameReplaysRoute, ApiStreamsUsernameViewersRoute: ApiStreamsUsernameViewersRoute, } @@ -1158,6 +1271,7 @@ const rootRouteChildren: RootRouteChildren = { MarketplaceRoute: MarketplaceRoute, SessionsRoute: SessionsRoute, SettingsRoute: SettingsRoute, + UrlsRoute: UrlsRoute, UsersRoute: UsersRoute, ApiArchivesRoute: ApiArchivesRouteWithChildren, ApiBrowserSessionsRoute: ApiBrowserSessionsRouteWithChildren, @@ -1167,6 +1281,7 @@ const rootRouteChildren: RootRouteChildren = { ApiContextItemsRoute: ApiContextItemsRoute, ApiProfileRoute: ApiProfileRoute, ApiStreamRoute: ApiStreamRoute, + ApiStreamReplaysRoute: ApiStreamReplaysRouteWithChildren, ApiUsageEventsRoute: ApiUsageEventsRouteWithChildren, ApiUsersRoute: ApiUsersRouteWithChildren, I1focusDemoRoute: I1focusDemoRoute, @@ -1175,6 +1290,7 @@ const rootRouteChildren: RootRouteChildren = { ApiChatGuestRoute: ApiChatGuestRoute, ApiChatMutationsRoute: ApiChatMutationsRoute, ApiFlowgladSplatRoute: ApiFlowgladSplatRoute, + ApiSpotifyNowPlayingRoute: ApiSpotifyNowPlayingRoute, ApiStreamsUsernameRoute: ApiStreamsUsernameRouteWithChildren, ApiStripeCheckoutRoute: ApiStripeCheckoutRoute, ApiStripeWebhooksRoute: ApiStripeWebhooksRoute, diff --git a/packages/web/src/routes/$username.tsx b/packages/web/src/routes/$username.tsx index 4eee4247..4cd996ed 100644 --- a/packages/web/src/routes/$username.tsx +++ b/packages/web/src/routes/$username.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react" +import { useEffect, useRef, useState } from "react" import { createFileRoute } from "@tanstack/react-router" import { getStreamByUsername, type StreamPageData } from "@/lib/stream/db" import { VideoPlayer } from "@/components/VideoPlayer" @@ -21,6 +21,7 @@ export const Route = createFileRoute("/$username")({ // Cloudflare Stream HLS URL const HLS_URL = "https://customer-xctsztqzu046isdc.cloudflarestream.com/bb7858eafc85de6c92963f3817477b5d/manifest/video.m3u8" const NIKIV_PLAYBACK = resolveStreamPlayback({ hlsUrl: HLS_URL, webrtcUrl: null }) +const READY_PULSE_MS = 1200 // Hardcoded user for nikiv const NIKIV_DATA: StreamPageData = { @@ -58,6 +59,8 @@ function StreamPage() { const [nowPlayingLoading, setNowPlayingLoading] = useState(false) const [nowPlayingError, setNowPlayingError] = useState(false) const [streamLive, setStreamLive] = useState(false) + const [showReadyPulse, setShowReadyPulse] = useState(false) + const readyPulseTimeoutRef = useRef | null>(null) useEffect(() => { let isActive = true @@ -147,6 +150,31 @@ function StreamPage() { } }, [username]) + useEffect(() => { + if (readyPulseTimeoutRef.current) { + clearTimeout(readyPulseTimeoutRef.current) + readyPulseTimeoutRef.current = null + } + + if (!streamReady) { + setShowReadyPulse(false) + return + } + + setShowReadyPulse(true) + readyPulseTimeoutRef.current = setTimeout(() => { + setShowReadyPulse(false) + readyPulseTimeoutRef.current = null + }, READY_PULSE_MS) + + return () => { + if (readyPulseTimeoutRef.current) { + clearTimeout(readyPulseTimeoutRef.current) + readyPulseTimeoutRef.current = null + } + } + }, [streamReady]) + const stream = data?.stream ?? null const playback = stream?.playback ?? null const fallbackPlayback = stream?.hls_url @@ -350,13 +378,13 @@ function StreamPage() { }} /> {!streamReady && ( -
-
-
🔴
-

- Connecting to stream... -

-
+
+
🟡
+
+ )} + {showReadyPulse && ( +
+
🔴
)}
@@ -369,27 +397,29 @@ function StreamPage() { onReady={() => setStreamReady(true)} /> {!streamReady && ( -
-
-
🔴
-

- Connecting to stream... -

-
+
+
🟡
+
+ )} + {showReadyPulse && ( +
+
🔴
)}
) : ( - +
+ + {showReadyPulse && ( +
+
🔴
+
+ )} +
) ) : isActuallyLive && activePlayback ? (
-
-
🔴
-

- Connecting to stream... -

-
+
🟡
) : (
diff --git a/packages/web/src/routes/urls.tsx b/packages/web/src/routes/urls.tsx new file mode 100644 index 00000000..839445fa --- /dev/null +++ b/packages/web/src/routes/urls.tsx @@ -0,0 +1,191 @@ +import { useState, type FormEvent } from "react" +import { createFileRoute } from "@tanstack/react-router" +import { authClient } from "@/lib/auth-client" +import { useAccount } from "jazz-tools/react" +import { ViewerAccount, type SavedUrl } from "@/lib/jazz/schema" +import { Link2, Plus, Trash2, ExternalLink } from "lucide-react" + +export const Route = createFileRoute("/urls")({ + component: UrlsPage, + ssr: false, +}) + +function UrlsPage() { + const { data: session, isPending: authPending } = authClient.useSession() + const me = useAccount(ViewerAccount) + + const [newUrl, setNewUrl] = useState("") + const [newTitle, setNewTitle] = useState("") + const [isAdding, setIsAdding] = useState(false) + + if (authPending) { + return ( +
+

Loading...

+
+ ) + } + + if (!session?.user) { + return ( +
+
+

Please sign in to save URLs

+ + Sign in + +
+
+ ) + } + + const root = me.$isLoaded ? me.root : null + const urlList = root?.$isLoaded ? root.savedUrls : null + + if (!me.$isLoaded || !root?.$isLoaded) { + return ( +
+

Loading Jazz...

+
+ ) + } + + const savedUrls: SavedUrl[] = urlList?.$isLoaded ? [...urlList] : [] + + const handleAddUrl = (e: FormEvent) => { + e.preventDefault() + if (!newUrl.trim() || !root?.savedUrls?.$isLoaded) return + + root.savedUrls.$jazz.push({ + url: newUrl.trim(), + title: newTitle.trim() || null, + createdAt: Date.now(), + }) + + setNewUrl("") + setNewTitle("") + setIsAdding(false) + } + + const handleDeleteUrl = (index: number) => { + if (!root?.savedUrls?.$isLoaded) return + root.savedUrls.$jazz.splice(index, 1) + } + + return ( +
+
+
+
+ +

Saved URLs

+
+ +
+ + {isAdding && ( +
+
+ + setNewUrl(e.target.value)} + placeholder="https://example.com" + className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-teal-500" + /> +
+
+ + setNewTitle(e.target.value)} + placeholder="My favorite site" + className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-teal-500" + /> +
+
+ + +
+
+ )} + + {savedUrls.length === 0 ? ( +
+ +

No saved URLs yet

+

Click "Add URL" to save your first link

+
+ ) : ( +
+ {savedUrls.map((item, index) => ( +
+
+

+ {item.title || item.url} +

+ {item.title && ( +

+ {item.url} +

+ )} +
+
+ + + + +
+
+ ))} +
+ )} +
+
+ ) +} diff --git a/packages/worker/package.json b/packages/worker/package.json index aafca0aa..04e95df6 100644 --- a/packages/worker/package.json +++ b/packages/worker/package.json @@ -13,7 +13,11 @@ "wrangler": "^4.53.0" }, "dependencies": { - "hono": "^4.10.4" + "drizzle-orm": "^0.45.0", + "drizzle-zod": "^0.8.3", + "hono": "^4.10.4", + "postgres": "^3.4.7", + "zod": "^4.1.13" }, "scripts": { "dev": "wrangler dev", diff --git a/packages/worker/readme.md b/packages/worker/readme.md index 152a6093..bc41ce87 100644 --- a/packages/worker/readme.md +++ b/packages/worker/readme.md @@ -81,6 +81,50 @@ Returns a greeting message. } ``` +## Admin API (write access) + +These endpoints write directly to Postgres for pragmatic data ingestion. + +Authentication: +- If `ADMIN_API_KEY` is set, include `Authorization: Bearer `. +- If `ADMIN_API_KEY` is not set, requests are allowed (useful for local dev). + +### Canvas + +- `POST /api/v1/admin/canvas` +- `PATCH /api/v1/admin/canvas/:canvasId` +- `POST /api/v1/admin/canvas/:canvasId/images` +- `PATCH /api/v1/admin/canvas/images/:imageId` +- `DELETE /api/v1/admin/canvas/images/:imageId` + +### Chat + +- `POST /api/v1/admin/chat/threads` +- `PATCH /api/v1/admin/chat/threads/:threadId` +- `POST /api/v1/admin/chat/messages` + +### Context Items + +- `POST /api/v1/admin/context-items` +- `PATCH /api/v1/admin/context-items/:itemId` +- `POST /api/v1/admin/context-items/:itemId/link` +- `DELETE /api/v1/admin/context-items/:itemId` + +### Browser Sessions + +- `POST /api/v1/admin/browser-sessions` +- `PATCH /api/v1/admin/browser-sessions/:sessionId` +- `DELETE /api/v1/admin/browser-sessions/:sessionId` + +### Example (create chat thread) + +```bash +curl -X POST "http://localhost:8787/api/v1/admin/chat/threads" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $ADMIN_API_KEY" \ + -d '{"title":"Research notes","userId":"user_123"}' +``` + ## RPC Methods The `WorkerRpc` class provides the following methods for service bindings: @@ -165,6 +209,11 @@ pnpm dev The worker will be available at http://localhost:8787 +### Database configuration + +Set `DATABASE_URL` locally or configure the `HYPERDRIVE` binding in `wrangler.jsonc`. +In production, use `wrangler secret put ADMIN_API_KEY` to secure the admin endpoints. + ### Run Tests ```bash diff --git a/packages/worker/src/db.ts b/packages/worker/src/db.ts new file mode 100644 index 00000000..cbad2345 --- /dev/null +++ b/packages/worker/src/db.ts @@ -0,0 +1,34 @@ +import postgres from "postgres" +import { drizzle } from "drizzle-orm/postgres-js" +import * as schema from "../../web/src/db/schema" + +export type Hyperdrive = { + connectionString: string +} + +export type WorkerEnv = { + DATABASE_URL?: string + HYPERDRIVE?: Hyperdrive +} + +const getConnectionString = (env?: WorkerEnv): string => { + if (env?.DATABASE_URL) { + return env.DATABASE_URL + } + + if (env?.HYPERDRIVE?.connectionString) { + return env.HYPERDRIVE.connectionString + } + + if (process.env.DATABASE_URL) { + return process.env.DATABASE_URL + } + + throw new Error("No database connection available. Set DATABASE_URL or HYPERDRIVE.") +} + +export const getDb = (env?: WorkerEnv) => { + const connectionString = getConnectionString(env) + const sql = postgres(connectionString, { prepare: false }) + return drizzle(sql, { schema, casing: "snake_case" }) +} diff --git a/packages/worker/src/index.ts b/packages/worker/src/index.ts index cf2e4ebf..9aa268d9 100644 --- a/packages/worker/src/index.ts +++ b/packages/worker/src/index.ts @@ -1,11 +1,111 @@ -import { Hono } from "hono" +import { Hono, type Context, type MiddlewareHandler } from "hono" import { cors } from "hono/cors" +import { eq } from "drizzle-orm" +import { + browser_session_tabs, + browser_sessions, + canvas, + canvas_images, + chat_messages, + chat_threads, + context_items, + thread_context_items, +} from "../../web/src/db/schema" +import { getDb, type Hyperdrive } from "./db" + +type Env = { + ADMIN_API_KEY?: string + DATABASE_URL?: string + HYPERDRIVE?: Hyperdrive +} // Create a new Hono app -const app = new Hono() +type AppEnv = { Bindings: Env } +const app = new Hono() // Enable CORS for all routes -app.use("/*", cors()) +app.use( + "/*", + cors({ + origin: "*", + allowHeaders: ["Authorization", "Content-Type"], + allowMethods: ["GET", "POST", "PATCH", "DELETE", "OPTIONS"], + }), +) + +const requireAdmin: MiddlewareHandler = async (c, next) => { + if (c.req.method === "OPTIONS") { + return next() + } + + const apiKey = c.env.ADMIN_API_KEY + if (!apiKey) { + return next() + } + + const authHeader = c.req.header("Authorization") + if (!authHeader || !authHeader.startsWith("Bearer ")) { + return c.json({ error: "Missing Authorization header" }, 401) + } + + const providedKey = authHeader.slice(7) + if (providedKey !== apiKey) { + return c.json({ error: "Invalid API key" }, 401) + } + + return next() +} + +app.use("/api/v1/admin/*", requireAdmin) + +const parseBody = async (c: Context) => { + return (await c.req.json().catch(() => ({}))) as Record +} + +const parseInteger = (value: unknown) => { + const numberValue = + typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN + return Number.isInteger(numberValue) ? numberValue : null +} + +const parseDate = (value: unknown) => { + if (typeof value !== "string" && typeof value !== "number") { + return null + } + const date = new Date(value) + return Number.isNaN(date.getTime()) ? null : date +} + +const parsePosition = (value: unknown) => { + if ( + value && + typeof value === "object" && + "x" in value && + "y" in value && + typeof (value as { x: unknown }).x === "number" && + typeof (value as { y: unknown }).y === "number" + ) { + return { x: (value as { x: number }).x, y: (value as { y: number }).y } + } + return null +} + +const parseSize = (value: unknown) => { + if ( + value && + typeof value === "object" && + "width" in value && + "height" in value && + typeof (value as { width: unknown }).width === "number" && + typeof (value as { height: unknown }).height === "number" + ) { + return { + width: (value as { width: number }).width, + height: (value as { height: number }).height, + } + } + return null +} // Health check endpoint app.get("/health", (c) => { @@ -19,6 +119,7 @@ app.get("/", (c) => { endpoints: { health: "/health", api: "/api/v1", + admin: "/api/v1/admin", }, }) }) @@ -29,6 +130,634 @@ app.get("/api/v1/hello", (c) => { return c.json({ message: `Hello, ${name}!` }) }) +// Canvas endpoints +app.post("/api/v1/admin/canvas", async (c) => { + const body = await parseBody(c) + const ownerId = typeof body.ownerId === "string" ? body.ownerId.trim() : "" + if (!ownerId) { + return c.json({ error: "ownerId required" }, 400) + } + + const values: typeof canvas.$inferInsert = { + owner_id: ownerId, + } + + if (typeof body.name === "string" && body.name.trim()) { + values.name = body.name.trim() + } + if (typeof body.width === "number" && Number.isFinite(body.width)) { + values.width = body.width + } + if (typeof body.height === "number" && Number.isFinite(body.height)) { + values.height = body.height + } + if (typeof body.defaultModel === "string") { + values.default_model = body.defaultModel + } + if (typeof body.defaultStyle === "string") { + values.default_style = body.defaultStyle + } + if (body.backgroundPrompt === null) { + values.background_prompt = null + } else if (typeof body.backgroundPrompt === "string") { + values.background_prompt = body.backgroundPrompt + } + + try { + const database = getDb(c.env) + const [record] = await database.insert(canvas).values(values).returning() + return c.json({ canvas: record }, 201) + } catch (error) { + console.error("[worker] create canvas failed", error) + return c.json({ error: "Failed to create canvas" }, 500) + } +}) + +app.patch("/api/v1/admin/canvas/:canvasId", async (c) => { + const canvasId = c.req.param("canvasId") + if (!canvasId) { + return c.json({ error: "canvasId required" }, 400) + } + + const body = await parseBody(c) + const updates: Partial = { + updated_at: new Date(), + } + + if (typeof body.name === "string") { + updates.name = body.name + } + if (typeof body.width === "number" && Number.isFinite(body.width)) { + updates.width = body.width + } + if (typeof body.height === "number" && Number.isFinite(body.height)) { + updates.height = body.height + } + if (typeof body.defaultModel === "string") { + updates.default_model = body.defaultModel + } + if (typeof body.defaultStyle === "string") { + updates.default_style = body.defaultStyle + } + if (body.backgroundPrompt === null) { + updates.background_prompt = null + } else if (typeof body.backgroundPrompt === "string") { + updates.background_prompt = body.backgroundPrompt + } + + if (Object.keys(updates).length <= 1) { + return c.json({ error: "No updates provided" }, 400) + } + + try { + const database = getDb(c.env) + const [record] = await database + .update(canvas) + .set(updates) + .where(eq(canvas.id, canvasId)) + .returning() + if (!record) { + return c.json({ error: "Canvas not found" }, 404) + } + return c.json({ canvas: record }) + } catch (error) { + console.error("[worker] update canvas failed", error) + return c.json({ error: "Failed to update canvas" }, 500) + } +}) + +app.post("/api/v1/admin/canvas/:canvasId/images", async (c) => { + const canvasId = c.req.param("canvasId") + if (!canvasId) { + return c.json({ error: "canvasId required" }, 400) + } + + const body = await parseBody(c) + const position = parsePosition(body.position) + const size = parseSize(body.size) + + const values: typeof canvas_images.$inferInsert = { + canvas_id: canvasId, + } + + if (typeof body.name === "string") values.name = body.name + if (typeof body.prompt === "string") values.prompt = body.prompt + if (typeof body.modelId === "string") values.model_id = body.modelId + if (typeof body.modelUsed === "string") values.model_used = body.modelUsed + if (typeof body.styleId === "string") values.style_id = body.styleId + if (typeof body.rotation === "number" && Number.isFinite(body.rotation)) { + values.rotation = body.rotation + } + if (position) values.position = position + if (size) { + values.width = size.width + values.height = size.height + } + if (body.metadata !== undefined) { + values.metadata = + body.metadata && typeof body.metadata === "object" ? body.metadata : null + } + if (body.branchParentId === null) { + values.branch_parent_id = null + } else if (typeof body.branchParentId === "string") { + values.branch_parent_id = body.branchParentId + } + if (body.contentBase64 === null) { + values.content_base64 = null + } else if (typeof body.contentBase64 === "string") { + values.content_base64 = body.contentBase64 + } else if (typeof body.content_base64 === "string") { + values.content_base64 = body.content_base64 + } + if (body.imageUrl === null) { + values.image_url = null + } else if (typeof body.imageUrl === "string") { + values.image_url = body.imageUrl + } + + try { + const database = getDb(c.env) + const [image] = await database.insert(canvas_images).values(values).returning() + return c.json({ image }, 201) + } catch (error) { + console.error("[worker] create canvas image failed", error) + return c.json({ error: "Failed to create canvas image" }, 500) + } +}) + +app.patch("/api/v1/admin/canvas/images/:imageId", async (c) => { + const imageId = c.req.param("imageId") + if (!imageId) { + return c.json({ error: "imageId required" }, 400) + } + + const body = await parseBody(c) + const updates: Partial = { + updated_at: new Date(), + } + const position = parsePosition(body.position) + const size = parseSize(body.size) + + if (typeof body.name === "string") updates.name = body.name + if (typeof body.prompt === "string") updates.prompt = body.prompt + if (typeof body.modelId === "string") updates.model_id = body.modelId + if (typeof body.modelUsed === "string") updates.model_used = body.modelUsed + if (typeof body.styleId === "string") updates.style_id = body.styleId + if (typeof body.rotation === "number" && Number.isFinite(body.rotation)) { + updates.rotation = body.rotation + } + if (position) updates.position = position + if (size) { + updates.width = size.width + updates.height = size.height + } + if (body.metadata !== undefined) { + updates.metadata = + body.metadata && typeof body.metadata === "object" ? body.metadata : null + } + if (body.branchParentId === null) { + updates.branch_parent_id = null + } else if (typeof body.branchParentId === "string") { + updates.branch_parent_id = body.branchParentId + } + if (body.contentBase64 === null) { + updates.content_base64 = null + } else if (typeof body.contentBase64 === "string") { + updates.content_base64 = body.contentBase64 + } else if (typeof body.content_base64 === "string") { + updates.content_base64 = body.content_base64 + } + if (body.imageUrl === null) { + updates.image_url = null + } else if (typeof body.imageUrl === "string") { + updates.image_url = body.imageUrl + } + + if (Object.keys(updates).length <= 1) { + return c.json({ error: "No updates provided" }, 400) + } + + try { + const database = getDb(c.env) + const [image] = await database + .update(canvas_images) + .set(updates) + .where(eq(canvas_images.id, imageId)) + .returning() + if (!image) { + return c.json({ error: "Image not found" }, 404) + } + return c.json({ image }) + } catch (error) { + console.error("[worker] update canvas image failed", error) + return c.json({ error: "Failed to update canvas image" }, 500) + } +}) + +app.delete("/api/v1/admin/canvas/images/:imageId", async (c) => { + const imageId = c.req.param("imageId") + if (!imageId) { + return c.json({ error: "imageId required" }, 400) + } + + try { + const database = getDb(c.env) + const [deleted] = await database + .delete(canvas_images) + .where(eq(canvas_images.id, imageId)) + .returning() + if (!deleted) { + return c.json({ error: "Image not found" }, 404) + } + return c.json({ id: imageId }) + } catch (error) { + console.error("[worker] delete canvas image failed", error) + return c.json({ error: "Failed to delete canvas image" }, 500) + } +}) + +// Chat endpoints +app.post("/api/v1/admin/chat/threads", async (c) => { + const body = await parseBody(c) + const title = + typeof body.title === "string" && body.title.trim() ? body.title.trim() : "New chat" + const userId = typeof body.userId === "string" ? body.userId : null + + try { + const database = getDb(c.env) + const [thread] = await database + .insert(chat_threads) + .values({ title, user_id: userId }) + .returning() + return c.json({ thread }, 201) + } catch (error) { + console.error("[worker] create thread failed", error) + return c.json({ error: "Failed to create thread" }, 500) + } +}) + +app.patch("/api/v1/admin/chat/threads/:threadId", async (c) => { + const threadId = parseInteger(c.req.param("threadId")) + if (!threadId) { + return c.json({ error: "threadId required" }, 400) + } + + const body = await parseBody(c) + const title = typeof body.title === "string" ? body.title.trim() : "" + if (!title) { + return c.json({ error: "title required" }, 400) + } + + try { + const database = getDb(c.env) + const [thread] = await database + .update(chat_threads) + .set({ title }) + .where(eq(chat_threads.id, threadId)) + .returning() + if (!thread) { + return c.json({ error: "Thread not found" }, 404) + } + return c.json({ thread }) + } catch (error) { + console.error("[worker] update thread failed", error) + return c.json({ error: "Failed to update thread" }, 500) + } +}) + +app.post("/api/v1/admin/chat/messages", async (c) => { + const body = await parseBody(c) + const threadId = parseInteger(body.threadId) + const role = typeof body.role === "string" ? body.role.trim() : "" + const content = typeof body.content === "string" ? body.content.trim() : "" + const createdAt = parseDate(body.createdAt) + + if (!threadId || !role || !content) { + return c.json({ error: "threadId, role, and content are required" }, 400) + } + + try { + const database = getDb(c.env) + const values: typeof chat_messages.$inferInsert = { + thread_id: threadId, + role, + content, + } + if (createdAt) { + values.created_at = createdAt + } + const [message] = await database + .insert(chat_messages) + .values(values) + .returning() + return c.json({ message }, 201) + } catch (error) { + console.error("[worker] add message failed", error) + return c.json({ error: "Failed to add message" }, 500) + } +}) + +// Context items endpoints +app.post("/api/v1/admin/context-items", async (c) => { + const body = await parseBody(c) + const userId = typeof body.userId === "string" ? body.userId.trim() : "" + const type = typeof body.type === "string" ? body.type.trim() : "" + const url = typeof body.url === "string" ? body.url.trim() : null + const name = + typeof body.name === "string" && body.name.trim() + ? body.name.trim() + : url + ? (() => { + try { + const parsed = new URL(url) + return `${parsed.hostname}${parsed.pathname}` + } catch { + return url + } + })() + : "Untitled context" + const threadId = parseInteger(body.threadId) + const parentId = parseInteger(body.parentId) + + if (!userId) { + return c.json({ error: "userId required" }, 400) + } + if (type !== "url" && type !== "file") { + return c.json({ error: "type must be 'url' or 'file'" }, 400) + } + + const values: typeof context_items.$inferInsert = { + user_id: userId, + type, + name, + } + + if (url) values.url = url + if (body.content === null) { + values.content = null + } else if (typeof body.content === "string") { + values.content = body.content + } + if (typeof body.refreshing === "boolean") { + values.refreshing = body.refreshing + } + if (parentId) { + values.parent_id = parentId + } + + try { + const database = getDb(c.env) + const [item] = await database.insert(context_items).values(values).returning() + + if (threadId) { + await database.insert(thread_context_items).values({ + thread_id: threadId, + context_item_id: item.id, + }) + } + + return c.json({ item }, 201) + } catch (error) { + console.error("[worker] create context item failed", error) + return c.json({ error: "Failed to create context item" }, 500) + } +}) + +app.patch("/api/v1/admin/context-items/:itemId", async (c) => { + const itemId = parseInteger(c.req.param("itemId")) + if (!itemId) { + return c.json({ error: "itemId required" }, 400) + } + + const body = await parseBody(c) + const updates: Partial = { + updated_at: new Date(), + } + const parentId = parseInteger(body.parentId) + + if (typeof body.name === "string") updates.name = body.name + if (typeof body.type === "string") { + const nextType = body.type.trim() + if (nextType !== "url" && nextType !== "file") { + return c.json({ error: "type must be 'url' or 'file'" }, 400) + } + updates.type = nextType + } + if (body.url === null) { + updates.url = null + } else if (typeof body.url === "string") { + updates.url = body.url + } + if (body.content === null) { + updates.content = null + } else if (typeof body.content === "string") { + updates.content = body.content + } + if (typeof body.refreshing === "boolean") { + updates.refreshing = body.refreshing + } + if (body.parentId === null) { + updates.parent_id = null + } else if (parentId) { + updates.parent_id = parentId + } + + if (Object.keys(updates).length <= 1) { + return c.json({ error: "No updates provided" }, 400) + } + + try { + const database = getDb(c.env) + const [item] = await database + .update(context_items) + .set(updates) + .where(eq(context_items.id, itemId)) + .returning() + if (!item) { + return c.json({ error: "Context item not found" }, 404) + } + return c.json({ item }) + } catch (error) { + console.error("[worker] update context item failed", error) + return c.json({ error: "Failed to update context item" }, 500) + } +}) + +app.post("/api/v1/admin/context-items/:itemId/link", async (c) => { + const itemId = parseInteger(c.req.param("itemId")) + if (!itemId) { + return c.json({ error: "itemId required" }, 400) + } + + const body = await parseBody(c) + const threadId = parseInteger(body.threadId) + if (!threadId) { + return c.json({ error: "threadId required" }, 400) + } + + try { + const database = getDb(c.env) + await database.insert(thread_context_items).values({ + thread_id: threadId, + context_item_id: itemId, + }) + return c.json({ success: true }) + } catch (error) { + console.error("[worker] link context item failed", error) + return c.json({ error: "Failed to link context item" }, 500) + } +}) + +app.delete("/api/v1/admin/context-items/:itemId", async (c) => { + const itemId = parseInteger(c.req.param("itemId")) + if (!itemId) { + return c.json({ error: "itemId required" }, 400) + } + + try { + const database = getDb(c.env) + const [item] = await database + .delete(context_items) + .where(eq(context_items.id, itemId)) + .returning() + if (!item) { + return c.json({ error: "Context item not found" }, 404) + } + return c.json({ id: itemId }) + } catch (error) { + console.error("[worker] delete context item failed", error) + return c.json({ error: "Failed to delete context item" }, 500) + } +}) + +// Browser session endpoints +app.post("/api/v1/admin/browser-sessions", async (c) => { + const body = await parseBody(c) + const userId = typeof body.userId === "string" ? body.userId.trim() : "" + const name = typeof body.name === "string" ? body.name.trim() : "" + const browser = typeof body.browser === "string" ? body.browser.trim() : "safari" + const capturedAt = parseDate(body.capturedAt) + const isFavorite = typeof body.isFavorite === "boolean" ? body.isFavorite : undefined + const tabs = Array.isArray(body.tabs) ? body.tabs : [] + + if (!userId || !name) { + return c.json({ error: "userId and name are required" }, 400) + } + + const tabValues = tabs + .map((tab) => { + if (!tab || typeof tab !== "object") { + return null + } + const title = typeof (tab as { title?: unknown }).title === "string" + ? (tab as { title: string }).title + : "" + const url = typeof (tab as { url?: unknown }).url === "string" + ? (tab as { url: string }).url + : "" + if (!url) { + return null + } + const faviconUrl = + typeof (tab as { faviconUrl?: unknown }).faviconUrl === "string" + ? (tab as { faviconUrl: string }).faviconUrl + : typeof (tab as { favicon_url?: unknown }).favicon_url === "string" + ? (tab as { favicon_url: string }).favicon_url + : null + return { title, url, favicon_url: faviconUrl } + }) + .filter((tab): tab is { title: string; url: string; favicon_url: string | null } => + Boolean(tab), + ) + + try { + const database = getDb(c.env) + const [session] = await database + .insert(browser_sessions) + .values({ + user_id: userId, + name, + browser, + tab_count: tabValues.length, + is_favorite: isFavorite ?? false, + captured_at: capturedAt ?? new Date(), + }) + .returning() + + if (tabValues.length > 0) { + await database.insert(browser_session_tabs).values( + tabValues.map((tab, index) => ({ + session_id: session.id, + title: tab.title, + url: tab.url, + position: index, + favicon_url: tab.favicon_url ?? null, + })), + ) + } + + return c.json({ session }, 201) + } catch (error) { + console.error("[worker] create browser session failed", error) + return c.json({ error: "Failed to create browser session" }, 500) + } +}) + +app.patch("/api/v1/admin/browser-sessions/:sessionId", async (c) => { + const sessionId = c.req.param("sessionId") + if (!sessionId) { + return c.json({ error: "sessionId required" }, 400) + } + + const body = await parseBody(c) + const updates: Partial = {} + + if (typeof body.name === "string") updates.name = body.name + if (typeof body.isFavorite === "boolean") updates.is_favorite = body.isFavorite + + if (Object.keys(updates).length === 0) { + return c.json({ error: "No updates provided" }, 400) + } + + try { + const database = getDb(c.env) + const [session] = await database + .update(browser_sessions) + .set(updates) + .where(eq(browser_sessions.id, sessionId)) + .returning() + if (!session) { + return c.json({ error: "Session not found" }, 404) + } + return c.json({ session }) + } catch (error) { + console.error("[worker] update browser session failed", error) + return c.json({ error: "Failed to update browser session" }, 500) + } +}) + +app.delete("/api/v1/admin/browser-sessions/:sessionId", async (c) => { + const sessionId = c.req.param("sessionId") + if (!sessionId) { + return c.json({ error: "sessionId required" }, 400) + } + + try { + const database = getDb(c.env) + const [session] = await database + .delete(browser_sessions) + .where(eq(browser_sessions.id, sessionId)) + .returning() + if (!session) { + return c.json({ error: "Session not found" }, 404) + } + return c.json({ id: sessionId }) + } catch (error) { + console.error("[worker] delete browser session failed", error) + return c.json({ error: "Failed to delete browser session" }, 500) + } +}) + // Export the Hono app as default (handles HTTP requests) export default app diff --git a/packages/worker/wrangler.jsonc b/packages/worker/wrangler.jsonc index e3e82e56..07a8245f 100644 --- a/packages/worker/wrangler.jsonc +++ b/packages/worker/wrangler.jsonc @@ -7,6 +7,9 @@ "name": "fullstack-monorepo-template-worker", "main": "src/index.ts", "compatibility_date": "2025-09-06", + "compatibility_flags": [ + "nodejs_compat" + ], /** * Bindings * Bindings allow your Worker to interact with resources on the Cloudflare Developer Platform, including @@ -32,4 +35,15 @@ * https://developers.cloudflare.com/workers/wrangler/configuration/#service-bindings */ // "services": [{ "binding": "MY_SERVICE", "service": "my-service" }] + /** + * Hyperdrive (PostgreSQL connection pooling) + * https://developers.cloudflare.com/hyperdrive/ + */ + "hyperdrive": [ + { + "binding": "HYPERDRIVE", + "id": "ab1f77b46587473ca6d42489678e34fd", + "localConnectionString": "postgresql://postgres:password@db.localtest.me:5433/electric" + } + ] }