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/database.js b/apps/juxtaposition-ui/src/database.js index 5b1b933d..beb9c305 100644 --- a/apps/juxtaposition-ui/src/database.js +++ b/apps/juxtaposition-ui/src/database.js @@ -8,6 +8,7 @@ const { NOTIFICATION } = require('@/models/notifications'); const { POST } = require('@/models/post'); const { SETTINGS } = require('@/models/settings'); const { REPORT } = require('@/models/report'); +const { LOGS } = require('@/models/logs'); const { logger } = require('@/logger'); const { config } = require('@/config'); @@ -506,6 +507,11 @@ async function getReportById(id) { return REPORT.findById(id); } +async function getLogsForTarget(targetPID, offset, limit) { + verifyConnected(); + return LOGS.find({ target: targetPID }).sort({ timestamp: -1 }).skip(offset).limit(limit); +} + module.exports = { connect, getCommunities, @@ -563,5 +569,6 @@ module.exports = { getReportsByOffender, getReportsByPost, getDuplicateReports, - getReportById + getReportById, + getLogsForTarget }; 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/models/logs.js b/apps/juxtaposition-ui/src/models/logs.js new file mode 100644 index 00000000..c814cc95 --- /dev/null +++ b/apps/juxtaposition-ui/src/models/logs.js @@ -0,0 +1,51 @@ +const { Schema, model } = require('mongoose'); + +const actionEnum = [ + 'REMOVE_POST', + 'IGNORE_REPORT', + 'LIMIT_POSTING', + 'TEMP_BAN', + 'PERMA_BAN', + 'UNBAN', + 'UPDATE_USER', + 'MAKE_COMMUNITY', + 'UPDATE_COMMUNITY', + 'DELETE_COMMUNITY' +]; + +const auditLogSchema = new Schema({ + actor: { + type: Number, + required: true + }, + action: { + type: String, + enum: actionEnum, + required: true + }, + target: { + type: String, + required: true + }, + context: { + type: String, + required: true + }, + timestamp: { + type: Date, + default: Date.now, + required: true + }, + changed_fields: { + type: [String], + default: [], + required: true + } +}); + +const LOGS = model('LOGS', auditLogSchema); + +module.exports = { + auditLogSchema, + LOGS +}; 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/admin/admin.js b/apps/juxtaposition-ui/src/services/juxt-web/routes/admin/admin.js index fdc5af77..50cee82d 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/routes/admin/admin.js +++ b/apps/juxtaposition-ui/src/services/juxt-web/routes/admin/admin.js @@ -108,6 +108,8 @@ router.get('/accounts/:pid', async function (req, res) { const removedPosts = await POST.find({ pid: req.params.pid, removed: true }).sort({ removed_at: -1 }).limit(10); + const auditLog = await database.getLogsForTarget(req.params.pid, 0, 20); + res.render(req.directory + '/moderate_user.ejs', { moment: moment, userSettings, @@ -123,7 +125,9 @@ router.get('/accounts/:pid', async function (req, res) { userMap, communityMap, postsMap, - reasonMap + reasonMap, + + auditLog }); }); @@ -133,6 +137,19 @@ router.post('/accounts/:pid', async (req, res) => { } const { pid } = req.params; + const oldUserSettings = await database.getUserSettings(pid); + + if (!oldUserSettings) { + res.json({ + error: true + }); + return; + } + + if (req.body.ban_lift_date == '') { + req.body.ban_lift_date = null; + } + await SETTINGS.findOneAndUpdate({ pid: pid }, { account_status: req.body.account_status, ban_lift_date: req.body.ban_lift_date, @@ -153,6 +170,55 @@ router.post('/accounts/:pid', async (req, res) => { link: '/titles/2551084080/new' }); } + + let action = 'UPDATE_USER'; + const changes = []; + const fields = []; + + if (oldUserSettings.account_status !== req.body.account_status) { + const oldStatus = getAccountStatus(oldUserSettings.account_status); + const newStatus = getAccountStatus(req.body.account_status); + + switch (req.body.account_status) { + case 0: + action = 'UNBAN'; + break; + case 1: + action = 'LIMIT_POSTING'; + break; + case 2: + action = 'TEMP_BAN'; + break; + case 3: + action = 'PERMA_BAN'; + break; + default: + action = 'PERMA_BAN'; + break; + } + fields.push('account_status'); + changes.push(`Account Status changed from "${oldStatus}" to "${newStatus}"`); + } + + if (oldUserSettings.ban_lift_date !== req.body.ban_lift_date) { + fields.push('ban_lift_date'); + changes.push(`User Ban Lift Date changed from "${oldUserSettings.ban_lift_date}" to "${req.body.ban_lift_date}"`); + } + + if (oldUserSettings.ban_reason !== req.body.ban_reason) { + fields.push('ban_reason'); + changes.push(`Ban reason changed from "${oldUserSettings.ban_reason}" to "${req.body.ban_reason}"`); + } + + if (changes.length > 0) { + await util.createLogEntry( + req.pid, + action, + pid, + changes.join('\n'), + fields + ); + } }); router.delete('/:reportID', async function (req, res) { @@ -172,14 +238,23 @@ router.delete('/:reportID', async function (req, res) { await post.removePost(reason, req.pid); await report.resolve(req.pid, reason); + const postType = post.parent ? 'comment' : 'post'; + await util.newNotification({ pid: post.pid, type: 'notice', - text: `Your post "${post.id}" has been removed for the following reason: "${reason}"`, + text: `Your ${postType} "${post.id}" has been removed for the following reason: "${reason}"`, image: '/images/bandwidthalert.png', link: '/titles/2551084080/new' }); + await util.createLogEntry( + req.pid, + 'REMOVE_POST', + post.id, + `Post ${post.id} removed for: "${reason}"` + ); + return res.sendStatus(200); }); @@ -195,6 +270,13 @@ router.put('/:reportID', async function (req, res) { await report.resolve(req.pid, req.query.reason); + await util.createLogEntry( + req.pid, + 'IGNORE_REPORT', + report.id, + `Report ${report.id} ignored for: "${req.query.reason}"` + ); + return res.sendStatus(200); }); @@ -259,6 +341,9 @@ router.post('/communities/new', upload.fields([{ name: 'browserIcon', maxCount: return res.sendStatus(422); } + req.body.has_shop_page = req.body.has_shop_page === 'on' ? 1 : 0; + req.body.is_recommended = req.body.is_recommended === 'on' ? 1 : 0; + const document = { platform_id: req.body.platform, name: req.body.name, @@ -284,6 +369,42 @@ router.post('/communities/new', upload.fields([{ name: 'browserIcon', maxCount: res.redirect(`/admin/communities/${communityID}`); util.updateCommunityHash(document); + + const communityType = getCommunityType(document.type); + const communityPlatform = getCommunityPlatform(document.platform_id); + const changes = []; + + changes.push(`Name set to "${document.name}"`); + changes.push(`Description set to "${document.description}"`); + changes.push(`Platform ID set to "${communityPlatform}"`); + changes.push(`Type set to "${communityType}"`); + changes.push(`Title IDs set to "${document.title_id.join(', ')}"`); + changes.push(`Parent set to "${document.parent}"`); + changes.push(`App data set to "${document.app_data}"`); + changes.push(`Is Recommended set to "${document.is_recommended}"`); + changes.push(`Has Shop Page set to "${document.has_shop_page}"`); + + const fields = [ + 'name', + 'description', + 'platform_id', + 'type', + 'title_id', + 'browserIcon', + 'CTRbrowserHeader', + 'WiiUbrowserHeader', + 'parent', + 'app_data', + 'is_recommended', + 'has_shop_page' + ]; + await util.createLogEntry( + req.pid, + 'MAKE_COMMUNITY', + communityID, + changes.join('\n'), + fields + ); }); router.get('/communities/:community_id', async function (req, res) { @@ -315,6 +436,12 @@ router.post('/communities/:id', upload.fields([{ name: 'browserIcon', maxCount: const communityID = req.params.id; let tgaIcon; + const oldCommunity = await COMMUNITY.findOne({ olive_community_id: communityID }).exec(); + + if (!oldCommunity) { + return res.redirect('/404'); + } + // browser icon if (req.files.browserIcon) { const icon128 = await util.resizeImage(req.files.browserIcon[0].buffer.toString('base64'), 128, 128); @@ -346,6 +473,9 @@ router.post('/communities/:id', upload.fields([{ name: 'browserIcon', maxCount: } } + req.body.has_shop_page = req.body.has_shop_page === 'on' ? 1 : 0; + req.body.is_recommended = req.body.is_recommended === 'on' ? 1 : 0; + const document = { type: req.body.type, has_shop_page: req.body.has_shop_page, @@ -363,6 +493,73 @@ router.post('/communities/:id', upload.fields([{ name: 'browserIcon', maxCount: res.redirect(`/admin/communities/${communityID}`); util.updateCommunityHash(document); + + // determine the changes made to the community + const changes = []; + const fields = []; + + if (oldCommunity.name !== document.name) { + fields.push('name'); + changes.push(`Name changed from "${oldCommunity.name}" to "${document.name}"`); + } + if (oldCommunity.description !== document.description) { + fields.push('description'); + changes.push(`Description changed from "${oldCommunity.description}" to "${document.description}"`); + } + if (oldCommunity.platform_id !== parseInt(document.platform_id)) { + const oldCommunityPlatform = getCommunityPlatform(oldCommunity.platform_id); + const newCommunityPlatform = getCommunityPlatform(document.platform_id); + fields.push('platform_id'); + changes.push(`Platform ID changed from "${oldCommunityPlatform}" to "${newCommunityPlatform}"`); + } + if (oldCommunity.type !== parseInt(document.type)) { + const oldCommunityType = getCommunityType(oldCommunity.type); + const newCommunityType = getCommunityType(document.type); + fields.push('type'); + changes.push(`Type changed from "${oldCommunityType}" to "${newCommunityType}"`); + } + if (oldCommunity.title_id.toString() !== document.title_id.toString()) { + fields.push('title_id'); + changes.push(`Title IDs changed from "${oldCommunity.title_id.join(', ')}" to "${document.title_id.join(', ')}"`); + } + if (req.files.browserIcon) { + fields.push('browserIcon'); + changes.push('Icon changed'); + } + if (req.files.CTRbrowserHeader) { + fields.push('CTRbrowserHeader'); + changes.push('3DS Banner changed'); + } + if (req.files.WiiUbrowserHeader) { + fields.push('WiiUbrowserHeader'); + changes.push('Wii U Banner changed'); + } + if (oldCommunity.parent !== document.parent) { + fields.push('parent'); + changes.push(`Parent changed from "${oldCommunity.parent}" to "${document.parent}"`); + } + if (oldCommunity.app_data !== document.app_data) { + fields.push('app_data'); + changes.push(`App data changed from "${oldCommunity.app_data}" to "${document.app_data}"`); + } + if (oldCommunity.is_recommended !== document.is_recommended) { + fields.push('is_recommended'); + changes.push(`Is Recommended changed from "${oldCommunity.is_recommended}" to "${document.is_recommended}"`); + } + if (oldCommunity.has_shop_page !== document.has_shop_page) { + fields.push('has_shop_page'); + changes.push(`Has Shop Page changed from "${oldCommunity.has_shop_page}" to "${document.has_shop_page}"`); + } + + if (changes.length > 0) { + await util.createLogEntry( + req.pid, + 'UPDATE_COMMUNITY', + oldCommunity.olive_community_id, + changes.join('\n'), + fields + ); + } }); router.delete('/communities/:id', async (req, res) => { @@ -377,6 +574,13 @@ router.delete('/communities/:id', async (req, res) => { res.json({ error: false }); + + await util.createLogEntry( + req.pid, + 'DELETE_COMMUNITY', + id, + `Community ${id} deleted` + ); }); async function generateCommunityUID(length) { @@ -386,4 +590,49 @@ async function generateCommunityUID(length) { return id; } +function getAccountStatus(status) { + switch (status) { + case 0: + return 'Normal'; + case 1: + return 'Limited from Posting'; + case 2: + return 'Temporary Ban'; + case 3: + return 'Permanent Ban'; + default: + return `Unknown (${status})`; + } +} + +function getCommunityType(type) { + type = Number(type); + switch (type) { + case 0: + return 'Main'; + case 1: + return 'Sub'; + case 2: + return 'Announcement'; + case 3: + return 'Private'; + default: + return `Unknown (${type})`; + } +} + +function getCommunityPlatform(platform_id) { + platform_id = Number(platform_id); + switch (platform_id) { + case 0: + return 'Wii U'; + case 1: + return '3DS'; + case 2: + return 'Both'; + default: + return `Unknown (${platform_id})`; + } +} + module.exports = router; diff --git a/apps/juxtaposition-ui/src/services/juxt-web/routes/console/feed.js b/apps/juxtaposition-ui/src/services/juxt-web/routes/console/feed.js index 2a1f060b..f55acb3c 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/routes/console/feed.js +++ b/apps/juxtaposition-ui/src/services/juxt-web/routes/console/feed.js @@ -37,8 +37,7 @@ router.get('/', async function (req, res) { communityMap: communityMap, bundle, tab: 0, - template: 'posts_list', - moderator: req.moderator + template: 'posts_list' }); }); @@ -131,9 +130,7 @@ router.get('/all/more', async function (req, res) { open: true, communityMap, userContent, - lang: req.lang, - link: `/feed/more?offset=${offset + posts.length}&pjax=true`, - moderator: req.moderator + link: `/feed/all/more?offset=${offset + posts.length}&pjax=true` }; if (posts.length > 0) { @@ -148,45 +145,4 @@ router.get('/all/more', async function (req, res) { } }); -router.get('/all/more', async function (req, res) { - let offset = parseInt(req.query.offset); - const userContent = await database.getUserContent(req.pid); - const communityMap = await util.getCommunityHash(); - if (!offset) { - offset = 0; - } - - const posts = await POST.find({ - parent: null, - message_to_pid: null, - removed: false - }).skip(offset).limit(config.postLimit).sort({ created_at: -1 }); - - const bundle = { - posts, - numPosts: posts.length, - open: true, - communityMap, - userContent, - lang: req.lang, - link: `/feed/all/more?offset=${offset + posts.length}&pjax=true`, - moderator: req.moderator - }; - - if (posts.length > 0) { - res.render(req.directory + '/partials/posts_list.ejs', { - communityMap: communityMap, - moment: moment, - database: database, - bundle, - cdnURL: config.cdnDomain, - lang: req.lang, - pid: req.pid, - moderator: req.moderator - }); - } else { - res.sendStatus(204); - } -}); - module.exports = router; 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/routes/console/posts.js b/apps/juxtaposition-ui/src/services/juxt-web/routes/console/posts.js index e705a414..18fc31c3 100644 --- a/apps/juxtaposition-ui/src/services/juxt-web/routes/console/posts.js +++ b/apps/juxtaposition-ui/src/services/juxt-web/routes/console/posts.js @@ -145,7 +145,14 @@ router.delete('/:post_id', async function (req, res) { return res.sendStatus(401); } if (res.locals.moderator && req.pid !== post.pid) { - await post.removePost(req.query.reason ? req.query.reason : 'Removed by moderator', req.pid); + const reason = req.query.reason ? req.query.reason : 'Removed by moderator'; + await post.removePost(reason, req.pid); + await util.createLogEntry( + req.pid, + 'REMOVE_POST', + post.pid, + `Post ${post.id} removed for: "${reason}"` + ); } else { await post.removePost('User requested removal', req.pid); } 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 ( + <> + + +