mirror of
https://github.com/linsa-io/linsa.git
synced 2026-01-11 11:50:25 +01:00
Add URL management schema, route, and related updates for URLs feature
This commit is contained in:
@@ -78,7 +78,6 @@ export function CloudflareStreamPlayer({
|
||||
responsive={false}
|
||||
height="100%"
|
||||
width="100%"
|
||||
onCanPlay={handleReady}
|
||||
onPlaying={handleReady}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -15,6 +15,24 @@ export type Presence = z.infer<typeof Presence>
|
||||
*/
|
||||
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<typeof PaidComment>
|
||||
|
||||
/**
|
||||
* 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<typeof SavedUrl>
|
||||
|
||||
/**
|
||||
* 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([]),
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<ReturnType<typeof setTimeout> | 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 && (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-white">
|
||||
<div className="text-center">
|
||||
<div className="animate-pulse text-4xl">🔴</div>
|
||||
<p className="mt-4 text-xl text-neutral-400">
|
||||
Connecting to stream...
|
||||
</p>
|
||||
</div>
|
||||
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center bg-black/70">
|
||||
<div className="animate-pulse text-4xl">🟡</div>
|
||||
</div>
|
||||
)}
|
||||
{showReadyPulse && (
|
||||
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center">
|
||||
<div className="animate-pulse text-4xl">🔴</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -369,27 +397,29 @@ function StreamPage() {
|
||||
onReady={() => setStreamReady(true)}
|
||||
/>
|
||||
{!streamReady && (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-white">
|
||||
<div className="text-center">
|
||||
<div className="animate-pulse text-4xl">🔴</div>
|
||||
<p className="mt-4 text-xl text-neutral-400">
|
||||
Connecting to stream...
|
||||
</p>
|
||||
</div>
|
||||
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center bg-black/70">
|
||||
<div className="animate-pulse text-4xl">🟡</div>
|
||||
</div>
|
||||
)}
|
||||
{showReadyPulse && (
|
||||
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center">
|
||||
<div className="animate-pulse text-4xl">🔴</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<VideoPlayer src={activePlayback.url} muted={false} />
|
||||
<div className="relative h-full w-full">
|
||||
<VideoPlayer src={activePlayback.url} muted={false} />
|
||||
{showReadyPulse && (
|
||||
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center">
|
||||
<div className="animate-pulse text-4xl">🔴</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
) : isActuallyLive && activePlayback ? (
|
||||
<div className="flex h-full w-full items-center justify-center text-white">
|
||||
<div className="text-center">
|
||||
<div className="animate-pulse text-4xl">🔴</div>
|
||||
<p className="mt-4 text-xl text-neutral-400">
|
||||
Connecting to stream...
|
||||
</p>
|
||||
</div>
|
||||
<div className="animate-pulse text-4xl">🟡</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-white">
|
||||
|
||||
191
packages/web/src/routes/urls.tsx
Normal file
191
packages/web/src/routes/urls.tsx
Normal file
@@ -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 (
|
||||
<div className="min-h-screen text-white grid place-items-center">
|
||||
<p className="text-slate-400">Loading...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!session?.user) {
|
||||
return (
|
||||
<div className="min-h-screen text-white grid place-items-center">
|
||||
<div className="text-center space-y-4">
|
||||
<p className="text-slate-400">Please sign in to save URLs</p>
|
||||
<a
|
||||
href="/auth"
|
||||
className="inline-block px-4 py-2 rounded-lg text-sm font-semibold text-white bg-teal-600 hover:bg-teal-500 transition-colors"
|
||||
>
|
||||
Sign in
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const root = me.$isLoaded ? me.root : null
|
||||
const urlList = root?.$isLoaded ? root.savedUrls : null
|
||||
|
||||
if (!me.$isLoaded || !root?.$isLoaded) {
|
||||
return (
|
||||
<div className="min-h-screen text-white grid place-items-center">
|
||||
<p className="text-slate-400">Loading Jazz...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="min-h-screen text-white">
|
||||
<div className="max-w-2xl mx-auto px-4 py-10">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link2 className="w-6 h-6 text-teal-400" />
|
||||
<h1 className="text-2xl font-semibold">Saved URLs</h1>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsAdding(true)}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium text-white bg-teal-600 hover:bg-teal-500 transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add URL
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isAdding && (
|
||||
<form
|
||||
onSubmit={handleAddUrl}
|
||||
className="mb-6 p-4 bg-[#0c0f18] border border-white/10 rounded-xl space-y-4"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-white/70">URL</label>
|
||||
<input
|
||||
type="url"
|
||||
required
|
||||
value={newUrl}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-white/70">Title (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newTitle}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsAdding(false)
|
||||
setNewUrl("")
|
||||
setNewTitle("")
|
||||
}}
|
||||
className="px-4 py-2 rounded-lg text-sm text-slate-200 bg-white/5 hover:bg-white/10 border border-white/10 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 rounded-lg text-sm font-semibold text-white bg-teal-600 hover:bg-teal-500 transition-colors"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{savedUrls.length === 0 ? (
|
||||
<div className="text-center py-12 text-slate-400">
|
||||
<Link2 className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
||||
<p>No saved URLs yet</p>
|
||||
<p className="text-sm mt-1">Click "Add URL" to save your first link</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{savedUrls.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center gap-3 p-4 bg-[#0c0f18] border border-white/5 rounded-xl hover:border-white/10 transition-colors group"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-white truncate">
|
||||
{item.title || item.url}
|
||||
</p>
|
||||
{item.title && (
|
||||
<p className="text-xs text-white/50 truncate mt-1">
|
||||
{item.url}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<a
|
||||
href={item.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-2 rounded-lg text-white/70 hover:text-white hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDeleteUrl(index)}
|
||||
className="p-2 rounded-lg text-rose-400 hover:text-rose-300 hover:bg-rose-500/10 transition-colors"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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 <ADMIN_API_KEY>`.
|
||||
- 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
|
||||
|
||||
34
packages/worker/src/db.ts
Normal file
34
packages/worker/src/db.ts
Normal file
@@ -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" })
|
||||
}
|
||||
@@ -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<AppEnv>()
|
||||
|
||||
// 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<AppEnv> = 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<AppEnv>) => {
|
||||
return (await c.req.json().catch(() => ({}))) as Record<string, unknown>
|
||||
}
|
||||
|
||||
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<typeof canvas.$inferInsert> = {
|
||||
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<typeof canvas_images.$inferInsert> = {
|
||||
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<typeof context_items.$inferInsert> = {
|
||||
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<typeof browser_sessions.$inferInsert> = {}
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user