diff --git a/.woodpecker.yml b/.woodpecker.yml
index 128312e..b1e6696 100644
--- a/.woodpecker.yml
+++ b/.woodpecker.yml
@@ -10,6 +10,33 @@ clone:
steps:
# COMPONENT_STEPS_BELOW
+ # Woodpecker CI step for web React app
+ # Add this step to your .woodpecker.yml
+
+ build-web:
+ image: woodpeckerci/plugin-kaniko
+ settings:
+ registry: registry.threesix.ai
+ repo: composed5/web
+ tags:
+ - latest
+ - ${CI_COMMIT_SHA:0:8}
+ context: .
+ dockerfile: apps/web/Dockerfile
+ cache: true
+ skip-tls-verify: true
+ when:
+ branch: main
+ event: push
+
+ deploy-web:
+ image: bitnami/kubectl:latest
+ commands:
+ - kubectl set image deployment/composed5-web web=registry.threesix.ai/composed5/web:${CI_COMMIT_SHA:0:8} -n projects || echo "Deployment not found, skipping"
+ when:
+ branch: main
+ event: push
+
# Woodpecker CI step for api service
# Add this step to your .woodpecker.yml
diff --git a/CLAUDE.md b/CLAUDE.md
index fe2d847..8d16ef9 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -49,4 +49,5 @@ composed5/
| Component | Type | Path |
|-----------|------|------|
| **api** | API service | `services/api/` |
+| **web** | React app | `apps/web/` |
diff --git a/Procfile b/Procfile
index 60fb4f9..856758f 100644
--- a/Procfile
+++ b/Procfile
@@ -1,3 +1,4 @@
# Local development processes
# Components will be added below as they're created
api: cd services/api && make run
+web: cd apps/web && npm run dev
diff --git a/apps/web/.eslintrc.cjs b/apps/web/.eslintrc.cjs
new file mode 100644
index 0000000..4c99537
--- /dev/null
+++ b/apps/web/.eslintrc.cjs
@@ -0,0 +1,18 @@
+module.exports = {
+ root: true,
+ env: { browser: true, es2020: true },
+ extends: [
+ 'eslint:recommended',
+ 'plugin:@typescript-eslint/recommended',
+ 'plugin:react-hooks/recommended',
+ ],
+ ignorePatterns: ['dist', '.eslintrc.cjs'],
+ parser: '@typescript-eslint/parser',
+ plugins: ['react-refresh'],
+ rules: {
+ 'react-refresh/only-export-components': [
+ 'warn',
+ { allowConstantExport: true },
+ ],
+ },
+};
diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile
new file mode 100644
index 0000000..ed047bf
--- /dev/null
+++ b/apps/web/Dockerfile
@@ -0,0 +1,34 @@
+# Build stage - using pnpm for workspace dependency resolution
+FROM node:20-alpine AS build
+
+# Install pnpm
+RUN npm install -g pnpm
+
+WORKDIR /workspace
+
+# Copy workspace configuration files
+COPY pnpm-workspace.yaml package.json pnpm-lock.yaml* ./
+
+# Copy shared packages (required for workspace:* dependencies)
+COPY packages/ ./packages/
+
+# Copy the app component
+COPY apps/web/ ./apps/web/
+
+# Install dependencies using pnpm (resolves workspace:* correctly)
+RUN pnpm install --frozen-lockfile || pnpm install
+
+# Build the app
+WORKDIR /workspace/apps/web
+RUN pnpm build
+
+# Production stage
+FROM nginx:alpine
+
+# Copy built assets
+COPY --from=build /workspace/apps/web/dist /usr/share/nginx/html
+COPY apps/web/nginx.conf /etc/nginx/conf.d/default.conf
+
+EXPOSE 80
+
+CMD ["nginx", "-g", "daemon off;"]
diff --git a/apps/web/component.yaml b/apps/web/component.yaml
new file mode 100644
index 0000000..33a535a
--- /dev/null
+++ b/apps/web/component.yaml
@@ -0,0 +1,6 @@
+name: web
+type: app
+port: 3001
+path: apps/web
+stack: react
+dependencies: []
diff --git a/apps/web/index.html b/apps/web/index.html
new file mode 100644
index 0000000..c59056b
--- /dev/null
+++ b/apps/web/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ web | composed5
+
+
+
+
+
+
diff --git a/apps/web/nginx.conf b/apps/web/nginx.conf
new file mode 100644
index 0000000..2ab46b7
--- /dev/null
+++ b/apps/web/nginx.conf
@@ -0,0 +1,26 @@
+server {
+ listen 80;
+ server_name localhost;
+ root /usr/share/nginx/html;
+ index index.html;
+
+ # Gzip compression
+ gzip on;
+ gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
+
+ # Cache static assets
+ location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
+ expires 1y;
+ add_header Cache-Control "public, immutable";
+ }
+
+ # SPA fallback
+ location / {
+ try_files $uri $uri/ /index.html;
+ }
+
+ # Security headers
+ add_header X-Frame-Options "SAMEORIGIN" always;
+ add_header X-Content-Type-Options "nosniff" always;
+ add_header X-XSS-Protection "1; mode=block" always;
+}
diff --git a/apps/web/package.json b/apps/web/package.json
new file mode 100644
index 0000000..3281817
--- /dev/null
+++ b/apps/web/package.json
@@ -0,0 +1,34 @@
+{
+ "name": "web",
+ "private": true,
+ "version": "0.0.1",
+ "type": "module",
+ "scripts": {
+ "dev": "vite --port 3001",
+ "build": "tsc && vite build",
+ "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
+ "preview": "vite preview --port 3001",
+ "format": "prettier --write src/"
+ },
+ "dependencies": {
+ "@composed5/logger": "workspace:*",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1"
+ },
+ "devDependencies": {
+ "@types/react": "^18.3.3",
+ "@types/react-dom": "^18.3.0",
+ "@typescript-eslint/eslint-plugin": "^7.13.1",
+ "@typescript-eslint/parser": "^7.13.1",
+ "@vitejs/plugin-react": "^4.3.1",
+ "autoprefixer": "^10.4.19",
+ "eslint": "^8.57.0",
+ "eslint-plugin-react-hooks": "^4.6.2",
+ "eslint-plugin-react-refresh": "^0.4.7",
+ "postcss": "^8.4.38",
+ "prettier": "^3.3.2",
+ "tailwindcss": "^3.4.4",
+ "typescript": "^5.5.3",
+ "vite": "^5.4.1"
+ }
+}
diff --git a/apps/web/postcss.config.js b/apps/web/postcss.config.js
new file mode 100644
index 0000000..2aa7205
--- /dev/null
+++ b/apps/web/postcss.config.js
@@ -0,0 +1,6 @@
+export default {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+};
diff --git a/apps/web/public/vite.svg b/apps/web/public/vite.svg
new file mode 100644
index 0000000..6a41099
--- /dev/null
+++ b/apps/web/public/vite.svg
@@ -0,0 +1 @@
+
diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
new file mode 100644
index 0000000..7ffcdf9
--- /dev/null
+++ b/apps/web/src/App.tsx
@@ -0,0 +1,46 @@
+function App() {
+ return (
+
+
+
+
+ web
+
+
+ Welcome to your React app. This is part of the{' '}
+ composed5{' '}
+ monorepo.
+
+
+ Edit this file at{' '}
+
+ apps/web/src/App.tsx
+
+
+
+
+
+
+ );
+}
+
+export default App;
diff --git a/apps/web/src/index.css b/apps/web/src/index.css
new file mode 100644
index 0000000..17df0e7
--- /dev/null
+++ b/apps/web/src/index.css
@@ -0,0 +1,17 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+body {
+ margin: 0;
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
+ 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
+ sans-serif;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+code {
+ font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
+ monospace;
+}
diff --git a/apps/web/src/lib/logger.ts b/apps/web/src/lib/logger.ts
new file mode 100644
index 0000000..33592fd
--- /dev/null
+++ b/apps/web/src/lib/logger.ts
@@ -0,0 +1,11 @@
+import { createLogger, installGlobalHandlers } from '@composed5/logger';
+
+export const logger = createLogger({
+ level: import.meta.env.DEV ? 'debug' : 'info',
+ service: 'web',
+ // Set endpoint to send logs to your backend:
+ // endpoint: '/api/logs',
+});
+
+// Install global error handlers
+installGlobalHandlers(logger);
diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx
new file mode 100644
index 0000000..174dfe8
--- /dev/null
+++ b/apps/web/src/main.tsx
@@ -0,0 +1,11 @@
+import React from 'react';
+import ReactDOM from 'react-dom/client';
+import App from './App.tsx';
+import './index.css';
+import './lib/logger';
+
+ReactDOM.createRoot(document.getElementById('root')!).render(
+
+
+
+);
diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts
new file mode 100644
index 0000000..11f02fe
--- /dev/null
+++ b/apps/web/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/apps/web/tailwind.config.js b/apps/web/tailwind.config.js
new file mode 100644
index 0000000..d21f1cd
--- /dev/null
+++ b/apps/web/tailwind.config.js
@@ -0,0 +1,8 @@
+/** @type {import('tailwindcss').Config} */
+export default {
+ content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
+ theme: {
+ extend: {},
+ },
+ plugins: [],
+};
diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json
new file mode 100644
index 0000000..a7fc6fb
--- /dev/null
+++ b/apps/web/tsconfig.json
@@ -0,0 +1,25 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["src"],
+ "references": [{ "path": "./tsconfig.node.json" }]
+}
diff --git a/apps/web/tsconfig.node.json b/apps/web/tsconfig.node.json
new file mode 100644
index 0000000..97ede7e
--- /dev/null
+++ b/apps/web/tsconfig.node.json
@@ -0,0 +1,11 @@
+{
+ "compilerOptions": {
+ "composite": true,
+ "skipLibCheck": true,
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "allowSyntheticDefaultImports": true,
+ "strict": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts
new file mode 100644
index 0000000..051ab31
--- /dev/null
+++ b/apps/web/vite.config.ts
@@ -0,0 +1,13 @@
+import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react';
+
+// https://vitejs.dev/config/
+export default defineConfig({
+ plugins: [react()],
+ server: {
+ port: 3001,
+ },
+ preview: {
+ port: 3001,
+ },
+});