diff --git a/.vscode/settings.json b/.vscode/settings.json index 9a4dbec5..15a1f6e9 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -24,4 +24,7 @@ }, "javascript.preferences.importModuleSpecifier": "non-relative", "typescript.preferences.importModuleSpecifier": "non-relative", + "[typescriptreact]": { + "editor.defaultFormatter": "dbaeumer.vscode-eslint" + }, } \ No newline at end of file diff --git a/apps/juxtaposition-ui/nodemon.json b/apps/juxtaposition-ui/nodemon.json index e993974b..cabe691d 100644 --- a/apps/juxtaposition-ui/nodemon.json +++ b/apps/juxtaposition-ui/nodemon.json @@ -2,7 +2,7 @@ "watch": [ "src/" ], - "ext": "js,ts,ejs,json,css", + "ext": "js,ts,ejs,tsx,jsx,json,css", "env": { "NODE_ENV": "development" } diff --git a/apps/juxtaposition-ui/package.json b/apps/juxtaposition-ui/package.json index 3d6a8b50..863970b2 100644 --- a/apps/juxtaposition-ui/package.json +++ b/apps/juxtaposition-ui/package.json @@ -19,6 +19,7 @@ "@neato/config": "^3.0.0", "@pretendonetwork/grpc": "^2.0.1", "@repo/grpc-client": "^0.0.0", + "classnames": "^2.5.1", "colors": "^1.4.0", "connect-redis": "^8.0.1", "cookie-parser": "^1.4.7", @@ -46,14 +47,19 @@ "pjax": "^0.2.8", "pngjs": "^7.0.0", "prom-client": "^15.1.3", + "react": "^19.1.0", + "react-dom": "^19.1.0", "redis": "^4.7.0", "sharp": "^0.33.5", "tga": "^1.0.7", "tsx": "^4.19.3" }, "devDependencies": { - "@pretendonetwork/eslint-config": "^0.0.8", + "@pretendonetwork/eslint-config": "^0.0.11", + "@types/hashmap": "^2.3.4", "@types/node": "^22.13.8", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", "browserslist": "^4.24.5", "browserslist-to-esbuild": "^2.1.1", "ejs-lint": "^2.0.1", diff --git a/apps/juxtaposition-ui/src/middleware/jsx.ts b/apps/juxtaposition-ui/src/middleware/jsx.ts new file mode 100644 index 00000000..07eae7e9 --- /dev/null +++ b/apps/juxtaposition-ui/src/middleware/jsx.ts @@ -0,0 +1,37 @@ +import { renderToStaticMarkup } from 'react-dom/server'; +import type { RequestHandler } from 'express'; + +const htmlDoctype = ''; + +/** + * Render JSX as static markup. Only static! No state or event handlers are supported. + */ +export const jsxRenderer: RequestHandler = (request, response, next) => { + response.jsx = (el, addDoctype): typeof response => { + const prefix = addDoctype ? htmlDoctype + '\n' : ''; + response.send(prefix + renderToStaticMarkup(el)); + return response; + }; + + response.jsxForDirectory = (opt): typeof response => { + const disabledFor = opt.disableDoctypeFor ?? []; + const directory = request.directory; + if (directory === 'ctr' && opt.ctr) { + response.jsx(opt.ctr, !disabledFor.includes('ctr')); + return response; + } + + if (directory === 'portal' && opt.portal) { + response.jsx(opt.portal, !disabledFor.includes('portal')); + return response; + } + + if (directory === 'web' && opt.web) { + response.jsx(opt.web, !disabledFor.includes('web')); + return response; + } + + throw new Error('Invalid directory to render JSX for'); + }; + next(); +}; diff --git a/apps/juxtaposition-ui/src/server.js b/apps/juxtaposition-ui/src/server.js index c294d98e..97bc6146 100644 --- a/apps/juxtaposition-ui/src/server.js +++ b/apps/juxtaposition-ui/src/server.js @@ -10,6 +10,7 @@ const { redisClient } = require('@/redisCache'); const juxt_web = require('@/services/juxt-web'); const { healthzRouter } = require('@/services/healthz'); const { config } = require('@/config'); +const { jsxRenderer } = require('@/middleware/jsx'); process.title = 'Pretendo - Juxt-Web'; process.on('SIGTERM', () => { @@ -49,6 +50,7 @@ app.get('/ip', (request, response) => response.send(request.ip)); // Create router logger.info('Setting up Middleware'); +app.use(jsxRenderer); app.use(loggerHttp); app.use(express.json()); app.use(healthzRouter); diff --git a/apps/juxtaposition-ui/src/services/juxt-web/routes/console/messages.js b/apps/juxtaposition-ui/src/services/juxt-web/routes/console/messages.jsx similarity index 91% rename from apps/juxtaposition-ui/src/services/juxt-web/routes/console/messages.js rename to apps/juxtaposition-ui/src/services/juxt-web/routes/console/messages.jsx index c9f3f603..0619eb2b 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/routes/console/messages.js +++ b/apps/juxtaposition-ui/src/services/juxt-web/routes/console/messages.jsx @@ -1,3 +1,8 @@ +/* eslint-disable import/no-unresolved -- eslint config is broken */ +import { buildContext } from '@/services/juxt-web/views/context'; +import { CtrMessagesView } from '@/services/juxt-web/views/ctr/messages'; +import { PortalMessagesView } from '@/services/juxt-web/views/portal/messages'; +import { WebMessagesView } from '@/services/juxt-web/views/web/messages'; const crypto = require('crypto'); const express = require('express'); const moment = require('moment'); @@ -11,11 +16,11 @@ const router = express.Router(); router.get('/', async function (req, res) { const conversations = await database.getConversations(req.pid); - const usersMap = await util.getUserHash(); - res.render(req.directory + '/messages.ejs', { - moment: moment, - conversations: conversations, - usersMap: usersMap + res.jsxForDirectory({ + web: , + portal: , + ctr: , + disableDoctypeFor: ['ctr'] }); }); diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/common.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/common.tsx new file mode 100644 index 00000000..5db087d7 --- /dev/null +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/common.tsx @@ -0,0 +1,9 @@ +import type { ReactNode } from 'react'; + +export function InlineScript(props: { src: string }): ReactNode { + return + + + ); +} + +export type HtmlProps = { + children?: ReactNode; + head?: ReactNode; + title: string; +}; + +export function PortalRoot(props: HtmlProps): ReactNode { + return ( + + + + {props.title} + {props.head} + + {props.children} + + ); +} + +export function PortalPageBody(props: { children?: ReactNode }): ReactNode { + return
{props.children}
; +} diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/web/messages.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/web/messages.tsx new file mode 100644 index 00000000..bbf4f7b6 --- /dev/null +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/web/messages.tsx @@ -0,0 +1,99 @@ +import moment from 'moment'; +import { WebNavBar } from '@/services/juxt-web/views/web/navbar'; +import { WebRoot } from '@/services/juxt-web/views/web/root'; +import type { ConversationSchema } from '@/models/conversation'; +import type { RenderContext } from '@/services/juxt-web/views/context'; +import type { InferSchemaType } from 'mongoose'; +import type { ReactNode } from 'react'; + +export type ConversationModel = InferSchemaType; +export type ConversationUserModel = ConversationModel['users'][number]; + +export type MessagesViewProps = { + ctx: RenderContext; + conversations: ConversationModel[]; +}; + +export function WebMessagesView(props: MessagesViewProps): ReactNode { + return ( + +

+ {props.ctx.lang.global.messages} +

+ +
+
+ {props.conversations.length === 0 + ? ( +
  • +

    {props.ctx.lang.messages.coming_soon}

    +
  • + ) + : ( + + )} +
    +
    + ); +} diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/web/navbar.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/web/navbar.tsx new file mode 100644 index 00000000..34ced762 --- /dev/null +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/web/navbar.tsx @@ -0,0 +1,222 @@ +import { InlineStyle } from '@/services/juxt-web/views/common'; +import type { RenderContext } from '@/services/juxt-web/views/context'; +import type { ReactNode } from 'react'; + +export type NavBarProps = { + ctx: RenderContext; + selection: number; +}; + +export function WebNavBar(props: NavBarProps): ReactNode { + const selectedClasses = (id: number): string => + id === props.selection ? 'selected' : ''; + + // TODO replace SVG icons with better methods for inline SVG (raw imports / Icon component) + return ( + + ); +} diff --git a/apps/juxtaposition-ui/src/services/juxt-web/views/web/root.tsx b/apps/juxtaposition-ui/src/services/juxt-web/views/web/root.tsx new file mode 100644 index 00000000..12d3de11 --- /dev/null +++ b/apps/juxtaposition-ui/src/services/juxt-web/views/web/root.tsx @@ -0,0 +1,49 @@ +import { InlineScript } from '@/services/juxt-web/views/common'; +import type { ReactNode } from 'react'; + +function DefaultHead(): ReactNode { + return ( + <> + + +