diff --git a/apps/web/package.json b/apps/web/package.json index fcdcb602..1f5d41a4 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -33,6 +33,7 @@ "@repo/db": "workspace:*", "@repo/env": "workspace:*", "@repo/ui": "workspace:*", + "@rsbuild/core": "catalog:", "@tanstack/react-form-start": "catalog:", "@tanstack/react-query": "catalog:", "@tanstack/react-router": "catalog:", diff --git a/apps/web/src/lib/csp.ts b/apps/web/src/lib/csp.ts new file mode 100644 index 00000000..0d7dc9e8 --- /dev/null +++ b/apps/web/src/lib/csp.ts @@ -0,0 +1,52 @@ +export type CspDirectives = Record; + +export function buildBaseCsp(opts: { + nonce: string; + isDev: boolean; + pathname: string; + reportUrl: string; +}): CspDirectives { + const { nonce, isDev, pathname, reportUrl } = opts; + const needsScannerCompatEval = /^\/admin\/walls\/[^/]+\/devices\/?$/.test(pathname); + const styleSrcElem = ["'self'", "'unsafe-inline'"]; + + return { + 'upgrade-insecure-requests': [], + 'default-src': ["'none'"], + 'base-uri': ["'self'"], + 'object-src': ["'none'"], + 'form-action': ["'self'"], + 'connect-src': ["'self'", 'ws:', 'wss:', 'https:', ...(isDev ? ['http:'] : [])], + 'manifest-src': ["'self'"], + 'frame-src': ["'self'", 'https:', ...(isDev ? ['http:'] : [])], + 'img-src': ["'self'", 'data:', 'blob:', 'https:'], + 'media-src': ["'self'", 'data:', 'blob:', 'https:', ...(isDev ? ['http:'] : [])], + 'font-src': ["'self'", 'data:', 'https:', ...(isDev ? ['http:'] : [])], + 'worker-src': ["'self'", 'blob:'], + 'script-src': [ + "'strict-dynamic'", + `'nonce-${nonce}'`, + // Required by modern engines for WebAssembly compilation without opening + // JS eval permissions. + "'wasm-unsafe-eval'", + // Compatibility fallback for engines that still gate WASM compile behind + // 'unsafe-eval' (kept narrowly scoped to scanner route in production). + ...(isDev || needsScannerCompatEval ? ["'unsafe-eval'"] : []) + ], + 'style-src': styleSrcElem, + 'style-src-elem': styleSrcElem, + 'style-src-attr': ["'unsafe-inline'"], + 'report-uri': [reportUrl], + 'report-to': ['csp-endpoint'] + }; +} + +export function modifyCsp(base: CspDirectives, overrides: CspDirectives): CspDirectives { + return { ...base, ...overrides }; +} + +export function serializeCsp(directives: CspDirectives): string { + return Object.entries(directives) + .map(([name, values]) => (values.length ? `${name} ${values.join(' ')}` : name)) + .join('; '); +} diff --git a/apps/web/src/lib/portalHttp.ts b/apps/web/src/lib/portalHttp.ts new file mode 100644 index 00000000..cbd02ec3 --- /dev/null +++ b/apps/web/src/lib/portalHttp.ts @@ -0,0 +1,30 @@ +export function getCorsHeaders(request: Request) { + const origin = request.headers.get('origin'); + return { + 'Access-Control-Allow-Origin': origin ?? '*', + 'Access-Control-Allow-Methods': 'POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Authorization, Content-Type', + 'Access-Control-Max-Age': '86400', + Vary: 'Origin' + } as const; +} + +export function json(request: Request, status: number, payload: unknown) { + return new Response(JSON.stringify(payload), { + status, + headers: { + 'Content-Type': 'application/json', + ...getCorsHeaders(request) + } + }); +} + +export function getBearerToken(request: Request): string | null { + const auth = request.headers.get('authorization'); + if (auth && auth.toLowerCase().startsWith('bearer ')) { + return auth.slice(7).trim(); + } + const url = new URL(request.url); + const fallback = url.searchParams.get('_gem_t'); + return fallback && fallback.trim().length > 0 ? fallback.trim() : null; +} diff --git a/apps/web/src/lib/serverAssetUtils.ts b/apps/web/src/lib/serverAssetUtils.ts index ab40f38a..6a0a8ef8 100644 --- a/apps/web/src/lib/serverAssetUtils.ts +++ b/apps/web/src/lib/serverAssetUtils.ts @@ -5,7 +5,7 @@ import sharp from 'sharp'; import { ASSET_DIR } from './serverVariables'; -const VARIANT_WIDTHS = [50, 200, 800, 1600]; +const VARIANT_WIDTHS = [50, 200, 800, 1600, 2400, 3200]; /** Compute a blurhash from an image file on disk */ export async function computeBlurhash(imagePath: string): Promise { diff --git a/apps/web/src/lib/serverVariables.ts b/apps/web/src/lib/serverVariables.ts index 185ad518..348d6aea 100644 --- a/apps/web/src/lib/serverVariables.ts +++ b/apps/web/src/lib/serverVariables.ts @@ -7,3 +7,4 @@ export const APP_DATA_DIR = env.APP_DATA_DIR; export const UPLOAD_DIR = env.UPLOAD_DIR || join(APP_DATA_DIR, 'uploads'); export const TMP_DIR = env.TMP_DIR || join(APP_DATA_DIR, 'tmp'); export const ASSET_DIR = env.ASSET_DIR || join(APP_DATA_DIR, 'assets'); +export const CONTROLLER_DIR = env.CONTROLLER_DIR || join(APP_DATA_DIR, 'controllers'); diff --git a/apps/web/src/lib/wallEngine.ts b/apps/web/src/lib/wallEngine.ts index 1bbadfc5..384a67b1 100644 --- a/apps/web/src/lib/wallEngine.ts +++ b/apps/web/src/lib/wallEngine.ts @@ -116,6 +116,10 @@ export class WallEngine { return () => this.layoutCallbacks.delete(callback); } + public onReady(callback: () => void) { + return this.bus.onReady(callback); + } + public registerLayer(layer: LayerWithWallComponentState, el: HTMLElement) { let layerPtr = this.layers.get(layer.numericId); if (!layerPtr) { diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 30cf3208..74fea553 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -44,6 +44,7 @@ import { Route as GuestLoginRouteImport } from './routes/_guest/login' import { Route as GuestBootstrapRouteImport } from './routes/_guest/bootstrap' import { Route as AdminWallsIndexRouteImport } from './routes/admin/walls/index' import { Route as AuthQuarryIndexRouteImport } from './routes/_auth/quarry/index' +import { Route as ApiWallMediaCookieRouteImport } from './routes/api/wall/media-cookie' import { Route as ApiUploadsSplatRouteImport } from './routes/api/uploads/$' import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$' import { Route as ApiAssetsUriRouteImport } from './routes/api/assets/$uri' @@ -51,15 +52,19 @@ import { Route as AdminWallsWallIdRouteRouteImport } from './routes/admin/walls/ import { Route as AuthQuarryProjectsRouteRouteImport } from './routes/_auth/quarry/projects/route' import { Route as AuthQuarryEditorRouteRouteImport } from './routes/_auth/quarry/editor/route' import { Route as AdminWallsWallIdIndexRouteImport } from './routes/admin/walls/$wallId/index' +import { Route as ApiPortalV1SlidesRouteImport } from './routes/api/portal/v1/slides' import { Route as ApiPortalV1RebootRouteImport } from './routes/api/portal/v1/reboot' +import { Route as ApiPortalV1BindRouteImport } from './routes/api/portal/v1/bind' import { Route as AdminWallsWallIdDevicesRouteImport } from './routes/admin/walls/$wallId/devices' import { Route as AuthQuarryProjectsNewRouteImport } from './routes/_auth/quarry/projects/new' import { Route as AuthQuarryProjectsProjectIdRouteRouteImport } from './routes/_auth/quarry/projects/$projectId/route' import { Route as AuthQuarryProjectsProjectIdIndexRouteImport } from './routes/_auth/quarry/projects/$projectId/index' import { Route as AuthQuarryEditorProjectIdIndexRouteImport } from './routes/_auth/quarry/editor/$projectId/index' +import { Route as ApiPortalV1ControllersProjectIdRouteImport } from './routes/api/portal/v1/controllers/$projectId' import { Route as AuthQuarryViewProjectIdCommitIdRouteImport } from './routes/_auth/quarry/view/$projectId/$commitId' import { Route as AuthQuarryProjectsProjectIdPermissionsRouteImport } from './routes/_auth/quarry/projects/$projectId/permissions' import { Route as AuthQuarryProjectsProjectIdHistoryRouteImport } from './routes/_auth/quarry/projects/$projectId/history' +import { Route as AuthQuarryProjectsProjectIdController_editorRouteImport } from './routes/_auth/quarry/projects/$projectId/controller_editor' import { Route as AuthQuarryProjectsProjectIdCommitsRouteImport } from './routes/_auth/quarry/projects/$projectId/commits' import { Route as AuthQuarryProjectsProjectIdAssetsRouteImport } from './routes/_auth/quarry/projects/$projectId/assets' import { Route as AuthQuarryEditorProjectIdSlideIdRouteImport } from './routes/_auth/quarry/editor/$projectId/$slideId' @@ -238,6 +243,11 @@ const AuthQuarryIndexRoute = AuthQuarryIndexRouteImport.update({ path: '/quarry/', getParentRoute: () => AuthRouteRoute, } as any) +const ApiWallMediaCookieRoute = ApiWallMediaCookieRouteImport.update({ + id: '/api/wall/media-cookie', + path: '/api/wall/media-cookie', + getParentRoute: () => rootRouteImport, +} as any) const ApiUploadsSplatRoute = ApiUploadsSplatRouteImport.update({ id: '/api/uploads/$', path: '/api/uploads/$', @@ -273,11 +283,21 @@ const AdminWallsWallIdIndexRoute = AdminWallsWallIdIndexRouteImport.update({ path: '/', getParentRoute: () => AdminWallsWallIdRouteRoute, } as any) +const ApiPortalV1SlidesRoute = ApiPortalV1SlidesRouteImport.update({ + id: '/api/portal/v1/slides', + path: '/api/portal/v1/slides', + getParentRoute: () => rootRouteImport, +} as any) const ApiPortalV1RebootRoute = ApiPortalV1RebootRouteImport.update({ id: '/api/portal/v1/reboot', path: '/api/portal/v1/reboot', getParentRoute: () => rootRouteImport, } as any) +const ApiPortalV1BindRoute = ApiPortalV1BindRouteImport.update({ + id: '/api/portal/v1/bind', + path: '/api/portal/v1/bind', + getParentRoute: () => rootRouteImport, +} as any) const AdminWallsWallIdDevicesRoute = AdminWallsWallIdDevicesRouteImport.update({ id: '/devices', path: '/devices', @@ -306,6 +326,12 @@ const AuthQuarryEditorProjectIdIndexRoute = path: '/$projectId/', getParentRoute: () => AuthQuarryEditorRouteRoute, } as any) +const ApiPortalV1ControllersProjectIdRoute = + ApiPortalV1ControllersProjectIdRouteImport.update({ + id: '/api/portal/v1/controllers/$projectId', + path: '/api/portal/v1/controllers/$projectId', + getParentRoute: () => rootRouteImport, + } as any) const AuthQuarryViewProjectIdCommitIdRoute = AuthQuarryViewProjectIdCommitIdRouteImport.update({ id: '/quarry/view/$projectId/$commitId', @@ -324,6 +350,12 @@ const AuthQuarryProjectsProjectIdHistoryRoute = path: '/history', getParentRoute: () => AuthQuarryProjectsProjectIdRouteRoute, } as any) +const AuthQuarryProjectsProjectIdController_editorRoute = + AuthQuarryProjectsProjectIdController_editorRouteImport.update({ + id: '/controller_editor', + path: '/controller_editor', + getParentRoute: () => AuthQuarryProjectsProjectIdRouteRoute, + } as any) const AuthQuarryProjectsProjectIdCommitsRoute = AuthQuarryProjectsProjectIdCommitsRouteImport.update({ id: '/commits', @@ -387,19 +419,24 @@ export interface FileRoutesByFullPath { '/api/assets/$uri': typeof ApiAssetsUriRoute '/api/auth/$': typeof ApiAuthSplatRoute '/api/uploads/$': typeof ApiUploadsSplatRoute + '/api/wall/media-cookie': typeof ApiWallMediaCookieRoute '/quarry/': typeof AuthQuarryIndexRoute '/admin/walls/': typeof AdminWallsIndexRoute '/quarry/projects/$projectId': typeof AuthQuarryProjectsProjectIdRouteRouteWithChildren '/quarry/projects/new': typeof AuthQuarryProjectsNewRoute '/admin/walls/$wallId/devices': typeof AdminWallsWallIdDevicesRoute + '/api/portal/v1/bind': typeof ApiPortalV1BindRoute '/api/portal/v1/reboot': typeof ApiPortalV1RebootRoute + '/api/portal/v1/slides': typeof ApiPortalV1SlidesRoute '/admin/walls/$wallId/': typeof AdminWallsWallIdIndexRoute '/quarry/editor/$projectId/$slideId': typeof AuthQuarryEditorProjectIdSlideIdRoute '/quarry/projects/$projectId/assets': typeof AuthQuarryProjectsProjectIdAssetsRoute '/quarry/projects/$projectId/commits': typeof AuthQuarryProjectsProjectIdCommitsRoute + '/quarry/projects/$projectId/controller_editor': typeof AuthQuarryProjectsProjectIdController_editorRoute '/quarry/projects/$projectId/history': typeof AuthQuarryProjectsProjectIdHistoryRoute '/quarry/projects/$projectId/permissions': typeof AuthQuarryProjectsProjectIdPermissionsRoute '/quarry/view/$projectId/$commitId': typeof AuthQuarryViewProjectIdCommitIdRoute + '/api/portal/v1/controllers/$projectId': typeof ApiPortalV1ControllersProjectIdRoute '/quarry/editor/$projectId/': typeof AuthQuarryEditorProjectIdIndexRoute '/quarry/projects/$projectId/': typeof AuthQuarryProjectsProjectIdIndexRoute '/quarry/editor/$projectId/$commitId/$slideId': typeof AuthQuarryEditorProjectIdCommitIdSlideIdRoute @@ -440,18 +477,23 @@ export interface FileRoutesByTo { '/api/assets/$uri': typeof ApiAssetsUriRoute '/api/auth/$': typeof ApiAuthSplatRoute '/api/uploads/$': typeof ApiUploadsSplatRoute + '/api/wall/media-cookie': typeof ApiWallMediaCookieRoute '/quarry': typeof AuthQuarryIndexRoute '/admin/walls': typeof AdminWallsIndexRoute '/quarry/projects/new': typeof AuthQuarryProjectsNewRoute '/admin/walls/$wallId/devices': typeof AdminWallsWallIdDevicesRoute + '/api/portal/v1/bind': typeof ApiPortalV1BindRoute '/api/portal/v1/reboot': typeof ApiPortalV1RebootRoute + '/api/portal/v1/slides': typeof ApiPortalV1SlidesRoute '/admin/walls/$wallId': typeof AdminWallsWallIdIndexRoute '/quarry/editor/$projectId/$slideId': typeof AuthQuarryEditorProjectIdSlideIdRoute '/quarry/projects/$projectId/assets': typeof AuthQuarryProjectsProjectIdAssetsRoute '/quarry/projects/$projectId/commits': typeof AuthQuarryProjectsProjectIdCommitsRoute + '/quarry/projects/$projectId/controller_editor': typeof AuthQuarryProjectsProjectIdController_editorRoute '/quarry/projects/$projectId/history': typeof AuthQuarryProjectsProjectIdHistoryRoute '/quarry/projects/$projectId/permissions': typeof AuthQuarryProjectsProjectIdPermissionsRoute '/quarry/view/$projectId/$commitId': typeof AuthQuarryViewProjectIdCommitIdRoute + '/api/portal/v1/controllers/$projectId': typeof ApiPortalV1ControllersProjectIdRoute '/quarry/editor/$projectId': typeof AuthQuarryEditorProjectIdIndexRoute '/quarry/projects/$projectId': typeof AuthQuarryProjectsProjectIdIndexRoute '/quarry/editor/$projectId/$commitId/$slideId': typeof AuthQuarryEditorProjectIdCommitIdSlideIdRoute @@ -497,19 +539,24 @@ export interface FileRoutesById { '/api/assets/$uri': typeof ApiAssetsUriRoute '/api/auth/$': typeof ApiAuthSplatRoute '/api/uploads/$': typeof ApiUploadsSplatRoute + '/api/wall/media-cookie': typeof ApiWallMediaCookieRoute '/_auth/quarry/': typeof AuthQuarryIndexRoute '/admin/walls/': typeof AdminWallsIndexRoute '/_auth/quarry/projects/$projectId': typeof AuthQuarryProjectsProjectIdRouteRouteWithChildren '/_auth/quarry/projects/new': typeof AuthQuarryProjectsNewRoute '/admin/walls/$wallId/devices': typeof AdminWallsWallIdDevicesRoute + '/api/portal/v1/bind': typeof ApiPortalV1BindRoute '/api/portal/v1/reboot': typeof ApiPortalV1RebootRoute + '/api/portal/v1/slides': typeof ApiPortalV1SlidesRoute '/admin/walls/$wallId/': typeof AdminWallsWallIdIndexRoute '/_auth/quarry/editor/$projectId/$slideId': typeof AuthQuarryEditorProjectIdSlideIdRoute '/_auth/quarry/projects/$projectId/assets': typeof AuthQuarryProjectsProjectIdAssetsRoute '/_auth/quarry/projects/$projectId/commits': typeof AuthQuarryProjectsProjectIdCommitsRoute + '/_auth/quarry/projects/$projectId/controller_editor': typeof AuthQuarryProjectsProjectIdController_editorRoute '/_auth/quarry/projects/$projectId/history': typeof AuthQuarryProjectsProjectIdHistoryRoute '/_auth/quarry/projects/$projectId/permissions': typeof AuthQuarryProjectsProjectIdPermissionsRoute '/_auth/quarry/view/$projectId/$commitId': typeof AuthQuarryViewProjectIdCommitIdRoute + '/api/portal/v1/controllers/$projectId': typeof ApiPortalV1ControllersProjectIdRoute '/_auth/quarry/editor/$projectId/': typeof AuthQuarryEditorProjectIdIndexRoute '/_auth/quarry/projects/$projectId/': typeof AuthQuarryProjectsProjectIdIndexRoute '/_auth/quarry/editor/$projectId/$commitId/$slideId': typeof AuthQuarryEditorProjectIdCommitIdSlideIdRoute @@ -554,19 +601,24 @@ export interface FileRouteTypes { | '/api/assets/$uri' | '/api/auth/$' | '/api/uploads/$' + | '/api/wall/media-cookie' | '/quarry/' | '/admin/walls/' | '/quarry/projects/$projectId' | '/quarry/projects/new' | '/admin/walls/$wallId/devices' + | '/api/portal/v1/bind' | '/api/portal/v1/reboot' + | '/api/portal/v1/slides' | '/admin/walls/$wallId/' | '/quarry/editor/$projectId/$slideId' | '/quarry/projects/$projectId/assets' | '/quarry/projects/$projectId/commits' + | '/quarry/projects/$projectId/controller_editor' | '/quarry/projects/$projectId/history' | '/quarry/projects/$projectId/permissions' | '/quarry/view/$projectId/$commitId' + | '/api/portal/v1/controllers/$projectId' | '/quarry/editor/$projectId/' | '/quarry/projects/$projectId/' | '/quarry/editor/$projectId/$commitId/$slideId' @@ -607,18 +659,23 @@ export interface FileRouteTypes { | '/api/assets/$uri' | '/api/auth/$' | '/api/uploads/$' + | '/api/wall/media-cookie' | '/quarry' | '/admin/walls' | '/quarry/projects/new' | '/admin/walls/$wallId/devices' + | '/api/portal/v1/bind' | '/api/portal/v1/reboot' + | '/api/portal/v1/slides' | '/admin/walls/$wallId' | '/quarry/editor/$projectId/$slideId' | '/quarry/projects/$projectId/assets' | '/quarry/projects/$projectId/commits' + | '/quarry/projects/$projectId/controller_editor' | '/quarry/projects/$projectId/history' | '/quarry/projects/$projectId/permissions' | '/quarry/view/$projectId/$commitId' + | '/api/portal/v1/controllers/$projectId' | '/quarry/editor/$projectId' | '/quarry/projects/$projectId' | '/quarry/editor/$projectId/$commitId/$slideId' @@ -663,19 +720,24 @@ export interface FileRouteTypes { | '/api/assets/$uri' | '/api/auth/$' | '/api/uploads/$' + | '/api/wall/media-cookie' | '/_auth/quarry/' | '/admin/walls/' | '/_auth/quarry/projects/$projectId' | '/_auth/quarry/projects/new' | '/admin/walls/$wallId/devices' + | '/api/portal/v1/bind' | '/api/portal/v1/reboot' + | '/api/portal/v1/slides' | '/admin/walls/$wallId/' | '/_auth/quarry/editor/$projectId/$slideId' | '/_auth/quarry/projects/$projectId/assets' | '/_auth/quarry/projects/$projectId/commits' + | '/_auth/quarry/projects/$projectId/controller_editor' | '/_auth/quarry/projects/$projectId/history' | '/_auth/quarry/projects/$projectId/permissions' | '/_auth/quarry/view/$projectId/$commitId' + | '/api/portal/v1/controllers/$projectId' | '/_auth/quarry/editor/$projectId/' | '/_auth/quarry/projects/$projectId/' | '/_auth/quarry/editor/$projectId/$commitId/$slideId' @@ -708,7 +770,11 @@ export interface RootRouteChildren { ApiAssetsUriRoute: typeof ApiAssetsUriRoute ApiAuthSplatRoute: typeof ApiAuthSplatRoute ApiUploadsSplatRoute: typeof ApiUploadsSplatRoute + ApiWallMediaCookieRoute: typeof ApiWallMediaCookieRoute + ApiPortalV1BindRoute: typeof ApiPortalV1BindRoute ApiPortalV1RebootRoute: typeof ApiPortalV1RebootRoute + ApiPortalV1SlidesRoute: typeof ApiPortalV1SlidesRoute + ApiPortalV1ControllersProjectIdRoute: typeof ApiPortalV1ControllersProjectIdRoute } declare module '@tanstack/react-router' { @@ -958,6 +1024,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthQuarryIndexRouteImport parentRoute: typeof AuthRouteRoute } + '/api/wall/media-cookie': { + id: '/api/wall/media-cookie' + path: '/api/wall/media-cookie' + fullPath: '/api/wall/media-cookie' + preLoaderRoute: typeof ApiWallMediaCookieRouteImport + parentRoute: typeof rootRouteImport + } '/api/uploads/$': { id: '/api/uploads/$' path: '/api/uploads/$' @@ -1007,6 +1080,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AdminWallsWallIdIndexRouteImport parentRoute: typeof AdminWallsWallIdRouteRoute } + '/api/portal/v1/slides': { + id: '/api/portal/v1/slides' + path: '/api/portal/v1/slides' + fullPath: '/api/portal/v1/slides' + preLoaderRoute: typeof ApiPortalV1SlidesRouteImport + parentRoute: typeof rootRouteImport + } '/api/portal/v1/reboot': { id: '/api/portal/v1/reboot' path: '/api/portal/v1/reboot' @@ -1014,6 +1094,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiPortalV1RebootRouteImport parentRoute: typeof rootRouteImport } + '/api/portal/v1/bind': { + id: '/api/portal/v1/bind' + path: '/api/portal/v1/bind' + fullPath: '/api/portal/v1/bind' + preLoaderRoute: typeof ApiPortalV1BindRouteImport + parentRoute: typeof rootRouteImport + } '/admin/walls/$wallId/devices': { id: '/admin/walls/$wallId/devices' path: '/devices' @@ -1049,6 +1136,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthQuarryEditorProjectIdIndexRouteImport parentRoute: typeof AuthQuarryEditorRouteRoute } + '/api/portal/v1/controllers/$projectId': { + id: '/api/portal/v1/controllers/$projectId' + path: '/api/portal/v1/controllers/$projectId' + fullPath: '/api/portal/v1/controllers/$projectId' + preLoaderRoute: typeof ApiPortalV1ControllersProjectIdRouteImport + parentRoute: typeof rootRouteImport + } '/_auth/quarry/view/$projectId/$commitId': { id: '/_auth/quarry/view/$projectId/$commitId' path: '/quarry/view/$projectId/$commitId' @@ -1070,6 +1164,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthQuarryProjectsProjectIdHistoryRouteImport parentRoute: typeof AuthQuarryProjectsProjectIdRouteRoute } + '/_auth/quarry/projects/$projectId/controller_editor': { + id: '/_auth/quarry/projects/$projectId/controller_editor' + path: '/controller_editor' + fullPath: '/quarry/projects/$projectId/controller_editor' + preLoaderRoute: typeof AuthQuarryProjectsProjectIdController_editorRouteImport + parentRoute: typeof AuthQuarryProjectsProjectIdRouteRoute + } '/_auth/quarry/projects/$projectId/commits': { id: '/_auth/quarry/projects/$projectId/commits' path: '/commits' @@ -1122,6 +1223,7 @@ const AuthQuarryEditorRouteRouteWithChildren = interface AuthQuarryProjectsProjectIdRouteRouteChildren { AuthQuarryProjectsProjectIdAssetsRoute: typeof AuthQuarryProjectsProjectIdAssetsRoute AuthQuarryProjectsProjectIdCommitsRoute: typeof AuthQuarryProjectsProjectIdCommitsRoute + AuthQuarryProjectsProjectIdController_editorRoute: typeof AuthQuarryProjectsProjectIdController_editorRoute AuthQuarryProjectsProjectIdHistoryRoute: typeof AuthQuarryProjectsProjectIdHistoryRoute AuthQuarryProjectsProjectIdPermissionsRoute: typeof AuthQuarryProjectsProjectIdPermissionsRoute AuthQuarryProjectsProjectIdIndexRoute: typeof AuthQuarryProjectsProjectIdIndexRoute @@ -1133,6 +1235,8 @@ const AuthQuarryProjectsProjectIdRouteRouteChildren: AuthQuarryProjectsProjectId AuthQuarryProjectsProjectIdAssetsRoute, AuthQuarryProjectsProjectIdCommitsRoute: AuthQuarryProjectsProjectIdCommitsRoute, + AuthQuarryProjectsProjectIdController_editorRoute: + AuthQuarryProjectsProjectIdController_editorRoute, AuthQuarryProjectsProjectIdHistoryRoute: AuthQuarryProjectsProjectIdHistoryRoute, AuthQuarryProjectsProjectIdPermissionsRoute: @@ -1267,7 +1371,11 @@ const rootRouteChildren: RootRouteChildren = { ApiAssetsUriRoute: ApiAssetsUriRoute, ApiAuthSplatRoute: ApiAuthSplatRoute, ApiUploadsSplatRoute: ApiUploadsSplatRoute, + ApiWallMediaCookieRoute: ApiWallMediaCookieRoute, + ApiPortalV1BindRoute: ApiPortalV1BindRoute, ApiPortalV1RebootRoute: ApiPortalV1RebootRoute, + ApiPortalV1SlidesRoute: ApiPortalV1SlidesRoute, + ApiPortalV1ControllersProjectIdRoute: ApiPortalV1ControllersProjectIdRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/apps/web/src/routes/_auth/quarry/projects/$projectId/controller_editor.tsx b/apps/web/src/routes/_auth/quarry/projects/$projectId/controller_editor.tsx new file mode 100644 index 00000000..39b1dfdb --- /dev/null +++ b/apps/web/src/routes/_auth/quarry/projects/$projectId/controller_editor.tsx @@ -0,0 +1,55 @@ +import { authQueryOptions } from '@repo/auth/tanstack/queries'; +import { Button } from '@repo/ui/components/button'; +import { useMutation, useQueryClient, useSuspenseQuery } from '@tanstack/react-query'; +import { createFileRoute } from '@tanstack/react-router'; +import { useState } from 'react'; +import { toast } from 'sonner'; + +import { + $getCustomControllerHtml, + $upsertCustomControllerHtml +} from '~/server/customController.fns'; + +export const Route = createFileRoute('/_auth/quarry/projects/$projectId/controller_editor')({ + component: ControllerEditor, + loader: async ({ context }) => { + const user = await context.queryClient.ensureQueryData(authQueryOptions()); + if (user?.role !== 'admin') { + throw new Response('Unauthorized', { status: 401 }); + } + } +}); + +function ControllerEditor() { + const { projectId } = Route.useParams(); + const queryClient = useQueryClient(); + + const { data: initialHtml } = useSuspenseQuery({ + queryKey: ['controllerHtml', projectId], + queryFn: () => $getCustomControllerHtml({ data: { projectId } }) + }); + + const [html, setHtml] = useState(initialHtml ?? ''); + + const mutation = useMutation({ + mutationFn: () => $upsertCustomControllerHtml({ data: { projectId, html } }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['controllerHtml', projectId] }); + toast.success('Controller HTML saved successfully'); + }, + onError: (e) => toast.error(e.message) + }); + + return ( +
+ +