Date: Mon, 27 Apr 2026 07:47:32 +0100
Subject: [PATCH 04/12] feat: Custom controller editor + portal bind/slides
APIs
---
apps/web/src/lib/serverVariables.ts | 1 +
.../projects/$projectId/controller_editor.tsx | 51 ++++++
.../quarry/projects/$projectId/route.tsx | 28 +++-
apps/web/src/routes/api/portal/v1/bind.ts | 158 ++++++++++++++++++
.../api/portal/v1/controllers/$projectId.ts | 58 +++++++
apps/web/src/routes/api/portal/v1/slides.ts | 124 ++++++++++++++
apps/web/src/server/bus/bus.binding.ts | 2 +-
apps/web/src/server/customController.fns.ts | 74 ++++++++
packages/env/src/index.ts | 2 +
9 files changed, 491 insertions(+), 7 deletions(-)
create mode 100644 apps/web/src/routes/_auth/quarry/projects/$projectId/controller_editor.tsx
create mode 100644 apps/web/src/routes/api/portal/v1/bind.ts
create mode 100644 apps/web/src/routes/api/portal/v1/controllers/$projectId.ts
create mode 100644 apps/web/src/routes/api/portal/v1/slides.ts
create mode 100644 apps/web/src/server/customController.fns.ts
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/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..e686cc74
--- /dev/null
+++ b/apps/web/src/routes/_auth/quarry/projects/$projectId/controller_editor.tsx
@@ -0,0 +1,51 @@
+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 (
+
+
+
+ );
+}
diff --git a/apps/web/src/routes/_auth/quarry/projects/$projectId/route.tsx b/apps/web/src/routes/_auth/quarry/projects/$projectId/route.tsx
index cc213f1a..67c1a173 100644
--- a/apps/web/src/routes/_auth/quarry/projects/$projectId/route.tsx
+++ b/apps/web/src/routes/_auth/quarry/projects/$projectId/route.tsx
@@ -6,8 +6,10 @@ import {
GitBranchIcon,
ImageIcon,
PencilSimpleIcon,
- UsersIcon
+ UsersIcon,
+ CodeIcon
} from '@phosphor-icons/react';
+import { authQueryOptions } from '@repo/auth/tanstack/queries';
import { Badge } from '@repo/ui/components/badge';
import { Button } from '@repo/ui/components/button';
import { Tabs, TabsList, TabsTrigger } from '@repo/ui/components/tabs';
@@ -47,7 +49,14 @@ export const Route = createFileRoute('/_auth/quarry/projects/$projectId')({
})
});
-const TAB_ORDER = { info: 0, permissions: 1, commits: 2, history: 3, assets: 4 } as const;
+const TAB_ORDER = {
+ info: 0,
+ permissions: 1,
+ commits: 2,
+ history: 3,
+ assets: 4,
+ controller: 5
+} as const;
type TabKey = keyof typeof TAB_ORDER;
const ALL_TABS: { key: TabKey; label: string; to: string; icon: any }[] = [
@@ -55,7 +64,8 @@ const ALL_TABS: { key: TabKey; label: string; to: string; icon: any }[] = [
{ key: 'permissions', label: 'Permissions', to: './permissions', icon: UsersIcon },
{ key: 'commits', label: 'Commits', to: './commits', icon: GitBranchIcon },
{ key: 'history', label: 'History', to: './history', icon: ClockIcon },
- { key: 'assets', label: 'Assets', to: './assets', icon: ImageIcon }
+ { key: 'assets', label: 'Assets', to: './assets', icon: ImageIcon },
+ { key: 'controller', label: 'Controller', to: './controller_editor', icon: CodeIcon }
];
const CUSTOM_RENDER_HIDDEN_TABS: ReadonlySet = new Set(['commits', 'assets']);
@@ -80,6 +90,10 @@ const TAB_SUBHEADERS: Record =
assets: {
title: 'Project Media',
description: 'Manage the media assets associated with this project.'
+ },
+ controller: {
+ title: 'Controller Editor',
+ description: 'Edit the custom controller for this project.'
}
};
@@ -103,19 +117,21 @@ function getTabFromPath(pathname: string): TabKey {
if (pathname.endsWith('/commits')) return 'commits';
if (pathname.endsWith('/history')) return 'history';
if (pathname.endsWith('/assets')) return 'assets';
+ if (pathname.endsWith('/controller_editor')) return 'controller';
return 'info';
}
function ProjectLayout() {
const { projectId } = Route.useParams();
const { data: project } = useSuspenseQuery(projectQueryOptions(projectId));
+ const { data: user } = useSuspenseQuery(authQueryOptions());
const location = useLocation();
const navigate = useNavigate();
const currentTab = getTabFromPath(location.pathname);
const hasCustomRender = !!project.customRenderUrl;
- const tabs = hasCustomRender
- ? ALL_TABS.filter((t) => !CUSTOM_RENDER_HIDDEN_TABS.has(t.key))
- : ALL_TABS;
+ const tabs = (
+ hasCustomRender ? ALL_TABS.filter((t) => !CUSTOM_RENDER_HIDDEN_TABS.has(t.key)) : ALL_TABS
+ ).filter((t) => t.key !== 'controller' || user?.role === 'admin');
const queryClient = useQueryClient();
const publishCustomRender = useMutation({
diff --git a/apps/web/src/routes/api/portal/v1/bind.ts b/apps/web/src/routes/api/portal/v1/bind.ts
new file mode 100644
index 00000000..ac3fc404
--- /dev/null
+++ b/apps/web/src/routes/api/portal/v1/bind.ts
@@ -0,0 +1,158 @@
+// call the bus function that calls
+// another api in v1 to get slides of the project, pass pid
+// search token, with token get the slides, create button to bind the slides.
+// serving the file from a predictable url with pid cid
+// new server fn code editor for admins only $getControlPanelHtml({ projectId, commitId })
+// upsert server functions for code editor with admin middleware $upsertControlPanelHtml({ projectId, commitId, html })
+// new tab: code editor