Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions web/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
FROM node:18-alpine AS web-build
WORKDIR /app
COPY package*.json ./
RUN npm ci --silent
COPY . .
RUN npm run build

FROM node:18-alpine AS web-runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=web-build /app/ .
EXPOSE 3000
CMD ["npm", "run", "start"]
24 changes: 24 additions & 0 deletions web/components/Editor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import dynamic from 'next/dynamic'
import React from 'react'

const Monaco = dynamic(() => import('@monaco-editor/react'), { ssr: false })

type Props = {
value: string
language?: string
onChange?: (v: string | undefined) => void
height?: string | number
}

export default function Editor({ value, language = 'yaml', onChange, height = '60vh' }: Props) {
return (
// @ts-ignore - dynamic import types
<Monaco
value={value}
language={language}
height={height}
onChange={onChange}
options={{ automaticLayout: true, tabSize: 2 }}
/>
)
}
15 changes: 15 additions & 0 deletions web/lib/ws.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export function connectWS(room: string, onMessage: (m: string) => void) {
const backend = process.env.NEXT_PUBLIC_BACKEND_URL || process.env.BACKEND_URL || 'http://localhost:8080'
const url = backend.replace(/^http/, 'ws') + `/ws?room=${encodeURIComponent(room)}`
try {
const ws = new WebSocket(url)
ws.addEventListener('message', (ev) => onMessage(ev.data))
ws.addEventListener('open', () => onMessage('[ws] connected'))
ws.addEventListener('close', () => onMessage('[ws] disconnected'))
ws.addEventListener('error', () => onMessage('[ws] error'))
return ws
} catch (err) {
onMessage('[ws] connection failed')
return null
}
}
6 changes: 6 additions & 0 deletions web/next.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
}

module.exports = nextConfig
19 changes: 19 additions & 0 deletions web/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"name": "nexus-web",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start -p $PORT"
},
"dependencies": {
"next": "14.0.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"@monaco-editor/react": "^5.0.1"
},
"devDependencies": {
"typescript": "5.5.0"
}
}
6 changes: 6 additions & 0 deletions web/pages/_app.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import '../styles/globals.css'
import type { AppProps } from 'next/app'

export default function App({ Component, pageProps }: AppProps) {
return <Component {...pageProps} />
}
83 changes: 83 additions & 0 deletions web/pages/collections/[name].tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import React, { useEffect, useState } from 'react'
import { useRouter } from 'next/router'
import { connectWS } from '../../lib/ws'
import Editor from '../../components/Editor'

export default function CollectionPage() {
const router = useRouter()
const name = Array.isArray(router.query.name) ? router.query.name[0] : router.query.name || ''

const [content, setContent] = useState('')
const [status, setStatus] = useState<string | null>(null)
const [wsLog, setWsLog] = useState<string[]>([])
const [ws, setWs] = useState<WebSocket | null>(null)

useEffect(() => {
if (!name) return
const backend = process.env.NEXT_PUBLIC_BACKEND_URL || process.env.BACKEND_URL || 'http://localhost:8080'
fetch(`${backend}/api/collections/get?name=${encodeURIComponent(name)}`)
.then((r) => r.json())
.then((data) => {
setContent(JSON.stringify(data, null, 2))
})
.catch((e) => setStatus(String(e)))

const socket = connectWS(name, (m) => setWsLog((s) => [...s, m]))
setWs(socket)
return () => {
try {
socket?.close()
} catch {}
}
}, [name])

function save() {
const backend = process.env.NEXT_PUBLIC_BACKEND_URL || process.env.BACKEND_URL || 'http://localhost:8080'
fetch(`${backend}/api/collections/save`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, content }),
})
.then((r) => r.json())
.then(() => setStatus('saved'))
.catch((e) => setStatus(String(e)))
}

function run() {
const backend = process.env.NEXT_PUBLIC_BACKEND_URL || process.env.BACKEND_URL || 'http://localhost:8080'
setStatus('running')
fetch(`${backend}/api/run`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
})
.then((r) => r.json())
.then((data) => setStatus(JSON.stringify(data)))
.catch((e) => setStatus(String(e)))
}

return (
<main style={{ padding: 24 }}>
<h1>Collection: {name}</h1>
<div style={{ marginBottom: 8 }}>
<button onClick={save} style={{ marginRight: 8 }}>Save</button>
<button onClick={run}>Run</button>
</div>

<Editor value={content} onChange={(v) => setContent(v ?? '')} height="60vh" />

<div style={{ marginTop: 12 }}>
<strong>Status:</strong> {status}
</div>

<div style={{ marginTop: 12 }}>
<strong>WS log:</strong>
<div style={{ maxHeight: 200, overflow: 'auto', border: '1px solid #eee', padding: 8 }}>
{wsLog.map((l, i) => (
<div key={i}>{l}</div>
))}
</div>
</div>
</main>
)
}
31 changes: 31 additions & 0 deletions web/pages/collections/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import React from 'react'
import Link from 'next/link'
import type { GetServerSideProps } from 'next'

type Props = { collections: string[] }

export default function Collections({ collections }: Props) {
return (
<main style={{ padding: 24 }}>
<h1>Collections</h1>
<ul>
{collections.map((c) => (
<li key={c}>
<Link href={`/collections/${encodeURIComponent(c)}`}>{c}</Link>
</li>
))}
</ul>
</main>
)
}

export const getServerSideProps: GetServerSideProps<Props> = async () => {
const backend = process.env.BACKEND_URL || 'http://localhost:8080'
try {
const res = await fetch(`${backend}/api/collections`)
const data = await res.json()
return { props: { collections: data.collections || [] } }
} catch (err) {
return { props: { collections: [] } }
}
}
37 changes: 37 additions & 0 deletions web/pages/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import React from 'react'
import type { GetServerSideProps } from 'next'

type Props = {
collections: string[]
error?: string
}

export default function Home({ collections, error }: Props) {
if (error) return <div>Error: {error}</div>

return (
<main style={{ padding: 24, fontFamily: 'Inter, system-ui' }}>
<h1>Nexus — Collections</h1>
<p>Collections stored in repository</p>
<ul>
{collections.map((c) => (
<li key={c}>{c}</li>
))}
</ul>
</main>
)
}

export const getServerSideProps: GetServerSideProps<Props> = async (context) => {
const backend = process.env.BACKEND_URL || 'http://localhost:8080'
try {
const res = await fetch(`${backend}/api/collections`)
if (!res.ok) {
return { props: { collections: [], error: `backend responded ${res.status}` } }
}
const data = await res.json()
return { props: { collections: data.collections || [] } }
} catch (err: any) {
return { props: { collections: [], error: err.message } }
}
}
7 changes: 7 additions & 0 deletions web/styles/globals.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
html, body, #__next {
height: 100%;
}
body {
margin: 0;
font-family: Inter, system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial;
}
19 changes: 19 additions & 0 deletions web/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve"
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}
Loading