Merge pull request #1 from SamuelSJames/claude/wonderful-faraday-qxym5t

Claude/wonderful faraday qxym5t
This commit is contained in:
Samuel James 2026-06-18 16:01:10 -04:00 committed by GitHub
commit 106a7d9911
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
49 changed files with 5486 additions and 405 deletions

6
.dockerignore Normal file
View file

@ -0,0 +1,6 @@
node_modules
dist
.git
.github
pics
*.md

39
.github/workflows/deploy.yml vendored Normal file
View file

@ -0,0 +1,39 @@
name: Deploy to racknerd1
on:
push:
branches: [main]
workflow_dispatch: {}
env:
DEPLOY_PATH: /opt/archnest
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Copy repo to racknerd1
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ secrets.RACKNERD_HOST }}
username: ${{ secrets.RACKNERD_USER }}
key: ${{ secrets.RACKNERD_SSH_KEY }}
port: ${{ secrets.RACKNERD_PORT || 22 }}
source: "."
target: ${{ env.DEPLOY_PATH }}
rm: false
- name: Build and restart container
uses: appleboy/ssh-action@v1.2.0
with:
host: ${{ secrets.RACKNERD_HOST }}
username: ${{ secrets.RACKNERD_USER }}
key: ${{ secrets.RACKNERD_SSH_KEY }}
port: ${{ secrets.RACKNERD_PORT || 22 }}
script: |
cd ${{ env.DEPLOY_PATH }}
docker compose up -d --build
docker image prune -f

9
.gitignore vendored
View file

@ -12,6 +12,15 @@ dist
dist-ssr dist-ssr
*.local *.local
# Backend data/secrets
backend/data
backend/.env
*.db
*.db-journal
*.db-wal
*.db-shm
*.tsbuildinfo
# Editor directories and files # Editor directories and files
.vscode/* .vscode/*
!.vscode/extensions.json !.vscode/extensions.json

11
Dockerfile Normal file
View file

@ -0,0 +1,11 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 8080

View file

@ -1,73 +1,39 @@
# React + TypeScript + Vite # ArchNest
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. A self-hosted ops dashboard — infrastructure monitoring, a bookmark hub for your homelab/cloud links, an embedded terminal, and system settings, all in one place.
Currently, two official plugins are available: Built with React 19 + TypeScript + Vite, styled with Tailwind CSS v4, charts via Recharts, icons via Lucide React.
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) ## Pages
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler | Page | Route | Status |
|------|-------|--------|
| Glance | `/` | Done — main ops dashboard (system status, resource overview, alerts, network traffic) |
| Infrastructure | `/infrastructure` | Done — resource distribution, node status grid, cost/trend breakdown. "Network" sub-tab planned as a future addition. |
| BookNest | `/booknest` | Done — categorized bookmark hub with quick access, favorites, link health, and category breakdown |
| Terminal | `/terminal` | Pending — will be based on a fork of the (archived) Termix project, not yet merged in |
| Settings | `/settings` | Done — Profile (incl. avatar upload), Appearance, Integrations, Notifications, Data & Backup, About |
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). See `archnest-blueprint.md` for the original per-page design spec and `design-decisions.md` for the visual/UX conventions and lessons learned while building each page — read that file before making layout changes, it documents *why* things are built the way they are (hero banner layering, card blend techniques, icon library gotchas, etc.).
## Expanding the ESLint configuration ## Development
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: ```bash
npm install
```js npm run dev
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
``` ```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: Type-check with `npx tsc --noEmit` before committing — Vite/the browser surface some runtime errors (e.g. missing icon exports) that the type-checker won't catch, so also smoke-test pages in a browser.
```js ## Tech Stack
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([ - React 19 + Vite + TypeScript
globalIgnores(['dist']), - React Router for routing
{ - Tailwind CSS v4
files: ['**/*.{ts,tsx}'], - Recharts (donuts, line/area charts)
extends: [ - Lucide React (icons)
// Other configs... - Deploy target: Docker on racknerd1 → NPM proxy at archnest.snsnetlabs.com
// Enable lint rules for React
reactX.configs['recommended-typescript'], ## Deployment
// Enable lint rules for React DOM
reactDom.configs.recommended, This project is deployed via Docker on `racknerd1`, proxied through Nginx Proxy Manager at `archnest.snsnetlabs.com`.
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

5
backend/.env.example Normal file
View file

@ -0,0 +1,5 @@
PORT=4000
ARCHNEST_DB_PATH=./data/archnest.db
ARCHNEST_JWT_SECRET=change-me-to-a-long-random-string
ARCHNEST_SECRET_KEY=change-me-to-another-long-random-string
ARCHNEST_CORS_ORIGIN=http://localhost:5173

15
backend/Dockerfile Normal file
View file

@ -0,0 +1,15 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install --omit=dev=false
COPY . .
RUN npm run build
FROM node:22-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json* ./
RUN npm install --omit=dev
COPY --from=build /app/dist ./dist
EXPOSE 4000
CMD ["node", "dist/server.js"]

1865
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

27
backend/package.json Normal file
View file

@ -0,0 +1,27 @@
{
"name": "archnest-backend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc -b",
"start": "node dist/server.js"
},
"dependencies": {
"@fastify/cors": "^10.0.1",
"@fastify/jwt": "^9.0.4",
"bcryptjs": "^2.4.3",
"better-sqlite3": "^11.8.1",
"dotenv": "^16.6.1",
"fastify": "^5.2.1",
"zod": "^3.24.1"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/better-sqlite3": "^7.6.12",
"@types/node": "^22.10.5",
"tsx": "^4.19.2",
"typescript": "^5.7.3"
}
}

25
backend/src/db/crypto.ts Normal file
View file

@ -0,0 +1,25 @@
import { createCipheriv, createDecipheriv, randomBytes, createHash } from 'node:crypto'
const rawKey = process.env.ARCHNEST_SECRET_KEY
if (!rawKey) {
throw new Error('ARCHNEST_SECRET_KEY env var is required to encrypt integration secrets')
}
const key = createHash('sha256').update(rawKey).digest()
export function encryptSecret(plaintext: string): string {
const iv = randomBytes(12)
const cipher = createCipheriv('aes-256-gcm', key, iv)
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
const authTag = cipher.getAuthTag()
return Buffer.concat([iv, authTag, encrypted]).toString('base64')
}
export function decryptSecret(payload: string): string {
const buf = Buffer.from(payload, 'base64')
const iv = buf.subarray(0, 12)
const authTag = buf.subarray(12, 28)
const encrypted = buf.subarray(28)
const decipher = createDecipheriv('aes-256-gcm', key, iv)
decipher.setAuthTag(authTag)
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8')
}

72
backend/src/db/index.ts Normal file
View file

@ -0,0 +1,72 @@
import Database from 'better-sqlite3'
import { mkdirSync } from 'node:fs'
import { dirname } from 'node:path'
const DB_PATH = process.env.ARCHNEST_DB_PATH ?? './data/archnest.db'
mkdirSync(dirname(DB_PATH), { recursive: true })
export const db = new Database(DB_PATH)
db.pragma('journal_mode = WAL')
db.pragma('foreign_keys = ON')
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
display_name TEXT,
email TEXT,
avatar_data_url TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS integrations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL,
name TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
status TEXT NOT NULL DEFAULT 'unknown',
config_json TEXT NOT NULL DEFAULT '{}',
last_checked_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS secrets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
integration_id INTEGER NOT NULL REFERENCES integrations(id) ON DELETE CASCADE,
key TEXT NOT NULL,
value_encrypted TEXT NOT NULL,
UNIQUE(integration_id, key)
);
CREATE TABLE IF NOT EXISTS bookmark_categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
icon TEXT,
sort_order INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS bookmarks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
category_id INTEGER REFERENCES bookmark_categories(id) ON DELETE SET NULL,
title TEXT NOT NULL,
url TEXT NOT NULL,
icon TEXT,
favorite INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'unknown',
last_checked_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL,
title TEXT NOT NULL,
source TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`)
export function logEvent(type: string, title: string, source?: string | null) {
db.prepare('INSERT INTO events (type, title, source) VALUES (?, ?, ?)').run(type, title, source ?? null)
}

View file

@ -0,0 +1,28 @@
import type { IntegrationAdapter, Resource } from './types.js'
export const docker: IntegrationAdapter = {
async testConnection(config) {
const baseUrl = config.baseUrl?.replace(/\/$/, '')
if (!baseUrl) return { ok: false, message: 'Missing baseUrl' }
try {
const res = await fetch(`${baseUrl}/version`)
if (!res.ok) return { ok: false, message: `HTTP ${res.status}` }
return { ok: true, message: 'Connected' }
} catch (err) {
return { ok: false, message: err instanceof Error ? err.message : 'Connection failed' }
}
},
async listResources(config): Promise<Resource[]> {
const baseUrl = config.baseUrl?.replace(/\/$/, '')
if (!baseUrl) return []
const res = await fetch(`${baseUrl}/containers/json?all=true`)
if (!res.ok) return []
const containers = (await res.json()) as { Names: string[]; State: string }[]
return containers.map((c) => ({
name: c.Names[0]?.replace(/^\//, '') ?? 'unknown',
status: c.State === 'running' ? 'healthy' : c.State === 'restarting' ? 'warning' : 'critical',
detail: c.State,
}))
},
}

View file

@ -0,0 +1,19 @@
import type { IntegrationAdapter, IntegrationType } from './types.js'
import { uptimeKuma } from './uptimeKuma.js'
import { docker } from './docker.js'
const notImplemented: IntegrationAdapter = {
async testConnection() {
return { ok: false, message: 'Test connection not yet implemented for this integration type' }
},
}
export const adapterRegistry: Record<IntegrationType, IntegrationAdapter> = {
uptime_kuma: uptimeKuma,
docker,
proxmox: notImplemented,
netbird: notImplemented,
cloudflare: notImplemented,
aws: notImplemented,
weather: notImplemented,
}

View file

@ -0,0 +1,28 @@
export type IntegrationType =
| 'proxmox'
| 'docker'
| 'netbird'
| 'cloudflare'
| 'aws'
| 'uptime_kuma'
| 'weather'
export interface IntegrationConfig {
[key: string]: string
}
export interface TestResult {
ok: boolean
message: string
}
export interface Resource {
name: string
status: 'healthy' | 'warning' | 'critical' | 'unknown'
detail?: string
}
export interface IntegrationAdapter {
testConnection(config: IntegrationConfig, secrets: Record<string, string>): Promise<TestResult>
listResources?(config: IntegrationConfig, secrets: Record<string, string>): Promise<Resource[]>
}

View file

@ -0,0 +1,15 @@
import type { IntegrationAdapter } from './types.js'
export const uptimeKuma: IntegrationAdapter = {
async testConnection(config) {
const baseUrl = config.baseUrl?.replace(/\/$/, '')
if (!baseUrl) return { ok: false, message: 'Missing baseUrl' }
try {
const res = await fetch(`${baseUrl}/api/status-page/heartbeat/default`)
if (!res.ok) return { ok: false, message: `HTTP ${res.status}` }
return { ok: true, message: 'Connected' }
} catch (err) {
return { ok: false, message: err instanceof Error ? err.message : 'Connection failed' }
}
},
}

View file

@ -0,0 +1,60 @@
import type { FastifyInstance } from 'fastify'
import bcrypt from 'bcryptjs'
import { z } from 'zod'
import { db, logEvent } from '../db/index.js'
const credentialsSchema = z.object({
username: z.string().min(3).max(64),
password: z.string().min(8).max(256),
})
export async function authRoutes(app: FastifyInstance) {
app.get('/api/system/setup-status', async () => {
const row = db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }
return { needsSetup: row.count === 0 }
})
app.post('/api/setup', async (req, reply) => {
const existing = db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }
if (existing.count > 0) {
return reply.code(409).send({ error: 'Setup already completed' })
}
const parsed = credentialsSchema.safeParse(req.body)
if (!parsed.success) {
return reply.code(400).send({ error: parsed.error.issues[0]?.message ?? 'Invalid input' })
}
const { username, password } = parsed.data
const passwordHash = await bcrypt.hash(password, 12)
const result = db
.prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)')
.run(username, passwordHash)
const token = app.jwt.sign({ sub: Number(result.lastInsertRowid), username })
logEvent('account_created', `Account created for ${username}`)
return { token }
})
app.post('/api/auth/login', async (req, reply) => {
const parsed = credentialsSchema.safeParse(req.body)
if (!parsed.success) {
return reply.code(400).send({ error: 'Invalid input' })
}
const { username, password } = parsed.data
const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username) as
| { id: number; username: string; password_hash: string }
| undefined
if (!user || !(await bcrypt.compare(password, user.password_hash))) {
return reply.code(401).send({ error: 'Invalid username or password' })
}
const token = app.jwt.sign({ sub: user.id, username: user.username })
logEvent('user_login', `${user.username} logged in`)
return { token }
})
app.get('/api/auth/me', { onRequest: [app.authenticate] }, async (req) => {
const payload = req.user as { sub: number; username: string }
const user = db
.prepare('SELECT id, username, display_name, email, avatar_data_url FROM users WHERE id = ?')
.get(payload.sub)
return { user }
})
}

View file

@ -0,0 +1,81 @@
import type { FastifyInstance } from 'fastify'
import { z } from 'zod'
import { db, logEvent } from '../db/index.js'
const bookmarkSchema = z.object({
categoryId: z.number().int().nullable().optional(),
title: z.string().min(1).max(128),
url: z.string().url(),
icon: z.string().optional(),
favorite: z.boolean().optional(),
})
const categorySchema = z.object({
name: z.string().min(1).max(64),
icon: z.string().optional(),
sortOrder: z.number().int().optional(),
})
export async function bookmarkRoutes(app: FastifyInstance) {
app.addHook('onRequest', app.authenticate)
app.get('/api/bookmarks/categories', async () => {
const categories = db.prepare('SELECT * FROM bookmark_categories ORDER BY sort_order').all()
return { categories }
})
app.post('/api/bookmarks/categories', async (req, reply) => {
const parsed = categorySchema.safeParse(req.body)
if (!parsed.success) return reply.code(400).send({ error: 'Invalid input' })
const { name, icon, sortOrder } = parsed.data
const result = db
.prepare('INSERT INTO bookmark_categories (name, icon, sort_order) VALUES (?, ?, ?)')
.run(name, icon ?? null, sortOrder ?? 0)
return reply.code(201).send({ id: result.lastInsertRowid })
})
app.get('/api/bookmarks', async () => {
const bookmarks = db.prepare('SELECT * FROM bookmarks ORDER BY created_at DESC').all()
return { bookmarks }
})
app.post('/api/bookmarks', async (req, reply) => {
const parsed = bookmarkSchema.safeParse(req.body)
if (!parsed.success) return reply.code(400).send({ error: 'Invalid input' })
const { categoryId, title, url, icon, favorite } = parsed.data
const result = db
.prepare(
'INSERT INTO bookmarks (category_id, title, url, icon, favorite) VALUES (?, ?, ?, ?, ?)'
)
.run(categoryId ?? null, title, url, icon ?? null, favorite ? 1 : 0)
logEvent('bookmark_created', `Bookmark added: ${title}`)
return reply.code(201).send({ id: result.lastInsertRowid })
})
app.put('/api/bookmarks/:id', async (req, reply) => {
const id = Number((req.params as { id: string }).id)
const parsed = bookmarkSchema.partial().safeParse(req.body)
if (!parsed.success) return reply.code(400).send({ error: 'Invalid input' })
const existing = db.prepare('SELECT * FROM bookmarks WHERE id = ?').get(id) as
| { category_id: number | null; title: string; url: string; icon: string | null; favorite: number }
| undefined
if (!existing) return reply.code(404).send({ error: 'Not found' })
const categoryId = parsed.data.categoryId ?? existing.category_id
const title = parsed.data.title ?? existing.title
const url = parsed.data.url ?? existing.url
const icon = parsed.data.icon ?? existing.icon
const favorite = parsed.data.favorite ?? !!existing.favorite
db.prepare(
'UPDATE bookmarks SET category_id = ?, title = ?, url = ?, icon = ?, favorite = ? WHERE id = ?'
).run(categoryId, title, url, icon, favorite ? 1 : 0, id)
return { ok: true }
})
app.delete('/api/bookmarks/:id', async (req, reply) => {
const id = Number((req.params as { id: string }).id)
const existing = db.prepare('SELECT title FROM bookmarks WHERE id = ?').get(id) as { title: string } | undefined
db.prepare('DELETE FROM bookmarks WHERE id = ?').run(id)
if (existing) logEvent('bookmark_deleted', `Bookmark removed: ${existing.title}`)
return reply.code(204).send()
})
}

View file

@ -0,0 +1,13 @@
import type { FastifyInstance } from 'fastify'
import { db } from '../db/index.js'
export async function eventRoutes(app: FastifyInstance) {
app.addHook('onRequest', app.authenticate)
app.get('/api/events', async (req) => {
const query = req.query as { limit?: string }
const limit = Math.min(Number(query.limit) || 20, 100)
const events = db.prepare('SELECT * FROM events ORDER BY created_at DESC, id DESC LIMIT ?').all(limit)
return { events }
})
}

View file

@ -0,0 +1,163 @@
import type { FastifyInstance } from 'fastify'
import { z } from 'zod'
import { db, logEvent } from '../db/index.js'
import { encryptSecret, decryptSecret } from '../db/crypto.js'
import { adapterRegistry } from '../integrations/registry.js'
import type { IntegrationType, Resource } from '../integrations/types.js'
const integrationTypes = [
'proxmox',
'docker',
'netbird',
'cloudflare',
'aws',
'uptime_kuma',
'weather',
] as const
const createSchema = z.object({
type: z.enum(integrationTypes),
name: z.string().min(1).max(128),
config: z.record(z.string(), z.string()).default({}),
secrets: z.record(z.string(), z.string()).default({}),
})
interface IntegrationRow {
id: number
type: string
name: string
enabled: number
status: string
config_json: string
last_checked_at: string | null
created_at: string
}
function serialize(row: IntegrationRow) {
return {
id: row.id,
type: row.type,
name: row.name,
enabled: !!row.enabled,
status: row.status,
config: JSON.parse(row.config_json),
lastCheckedAt: row.last_checked_at,
createdAt: row.created_at,
}
}
function loadSecrets(integrationId: number): Record<string, string> {
const rows = db
.prepare('SELECT key, value_encrypted FROM secrets WHERE integration_id = ?')
.all(integrationId) as { key: string; value_encrypted: string }[]
const out: Record<string, string> = {}
for (const row of rows) out[row.key] = decryptSecret(row.value_encrypted)
return out
}
export async function integrationRoutes(app: FastifyInstance) {
app.addHook('onRequest', app.authenticate)
app.get('/api/integrations', async () => {
const rows = db.prepare('SELECT * FROM integrations ORDER BY created_at').all() as IntegrationRow[]
return { integrations: rows.map(serialize) }
})
app.post('/api/integrations', async (req, reply) => {
const parsed = createSchema.safeParse(req.body)
if (!parsed.success) {
return reply.code(400).send({ error: parsed.error.issues[0]?.message ?? 'Invalid input' })
}
const { type, name, config, secrets } = parsed.data
const result = db
.prepare('INSERT INTO integrations (type, name, config_json) VALUES (?, ?, ?)')
.run(type, name, JSON.stringify(config))
const integrationId = Number(result.lastInsertRowid)
const insertSecret = db.prepare(
'INSERT INTO secrets (integration_id, key, value_encrypted) VALUES (?, ?, ?)'
)
for (const [key, value] of Object.entries(secrets)) {
insertSecret.run(integrationId, key, encryptSecret(value))
}
const row = db.prepare('SELECT * FROM integrations WHERE id = ?').get(integrationId) as IntegrationRow
logEvent('integration_created', `${name} integration added`, type)
return reply.code(201).send({ integration: serialize(row) })
})
app.put('/api/integrations/:id', async (req, reply) => {
const id = Number((req.params as { id: string }).id)
const parsed = createSchema.partial().safeParse(req.body)
if (!parsed.success) {
return reply.code(400).send({ error: parsed.error.issues[0]?.message ?? 'Invalid input' })
}
const existing = db.prepare('SELECT * FROM integrations WHERE id = ?').get(id) as
| IntegrationRow
| undefined
if (!existing) return reply.code(404).send({ error: 'Not found' })
const name = parsed.data.name ?? existing.name
const config = parsed.data.config ?? JSON.parse(existing.config_json)
db.prepare('UPDATE integrations SET name = ?, config_json = ? WHERE id = ?').run(
name,
JSON.stringify(config),
id
)
if (parsed.data.secrets) {
const upsert = db.prepare(
`INSERT INTO secrets (integration_id, key, value_encrypted) VALUES (?, ?, ?)
ON CONFLICT(integration_id, key) DO UPDATE SET value_encrypted = excluded.value_encrypted`
)
for (const [key, value] of Object.entries(parsed.data.secrets)) {
upsert.run(id, key, encryptSecret(value))
}
}
const row = db.prepare('SELECT * FROM integrations WHERE id = ?').get(id) as IntegrationRow
return { integration: serialize(row) }
})
app.delete('/api/integrations/:id', async (req, reply) => {
const id = Number((req.params as { id: string }).id)
const existing = db.prepare('SELECT * FROM integrations WHERE id = ?').get(id) as IntegrationRow | undefined
db.prepare('DELETE FROM integrations WHERE id = ?').run(id)
if (existing) logEvent('integration_deleted', `${existing.name} integration removed`, existing.type)
return reply.code(204).send()
})
app.post('/api/integrations/:id/test', async (req, reply) => {
const id = Number((req.params as { id: string }).id)
const row = db.prepare('SELECT * FROM integrations WHERE id = ?').get(id) as
| IntegrationRow
| undefined
if (!row) return reply.code(404).send({ error: 'Not found' })
const adapter = adapterRegistry[row.type as IntegrationType]
const config = JSON.parse(row.config_json)
const secrets = loadSecrets(id)
const result = await adapter.testConnection(config, secrets)
const status = result.ok ? 'connected' : 'error'
db.prepare("UPDATE integrations SET status = ?, last_checked_at = datetime('now') WHERE id = ?").run(
status,
id
)
logEvent('integration_tested', `${row.name} test ${result.ok ? 'succeeded' : 'failed'}`, row.type)
return result
})
app.get('/api/integrations/resources', async () => {
const rows = db.prepare("SELECT * FROM integrations WHERE enabled = 1 AND status = 'connected'").all() as IntegrationRow[]
const resources: (Resource & { integration: string })[] = []
for (const row of rows) {
const adapter = adapterRegistry[row.type as IntegrationType]
if (!adapter.listResources) continue
const config = JSON.parse(row.config_json)
const secrets = loadSecrets(row.id)
try {
const found = await adapter.listResources(config, secrets)
for (const r of found) resources.push({ ...r, integration: row.name })
} catch {
// adapter unreachable — skip, connection test already surfaces this
}
}
return { resources }
})
}

39
backend/src/server.ts Normal file
View file

@ -0,0 +1,39 @@
import 'dotenv/config'
import Fastify from 'fastify'
import cors from '@fastify/cors'
import jwt from '@fastify/jwt'
import { authRoutes } from './routes/auth.js'
import { integrationRoutes } from './routes/integrations.js'
import { bookmarkRoutes } from './routes/bookmarks.js'
import { eventRoutes } from './routes/events.js'
const JWT_SECRET = process.env.ARCHNEST_JWT_SECRET
if (!JWT_SECRET) {
throw new Error('ARCHNEST_JWT_SECRET env var is required')
}
const app = Fastify({ logger: true })
await app.register(cors, { origin: process.env.ARCHNEST_CORS_ORIGIN ?? true })
await app.register(jwt, { secret: JWT_SECRET })
app.decorate('authenticate', async function (req, reply) {
try {
await req.jwtVerify()
} catch {
reply.code(401).send({ error: 'Unauthorized' })
}
})
await app.register(authRoutes)
await app.register(integrationRoutes)
await app.register(bookmarkRoutes)
await app.register(eventRoutes)
app.get('/api/health', async () => ({ ok: true }))
const port = Number(process.env.PORT ?? 4000)
app.listen({ port, host: '0.0.0.0' }).catch((err) => {
app.log.error(err)
process.exit(1)
})

14
backend/src/types.d.ts vendored Normal file
View file

@ -0,0 +1,14 @@
import '@fastify/jwt'
declare module 'fastify' {
interface FastifyInstance {
authenticate: (req: import('fastify').FastifyRequest, reply: import('fastify').FastifyReply) => Promise<void>
}
}
declare module '@fastify/jwt' {
interface FastifyJWT {
payload: { sub: number; username: string }
user: { sub: number; username: string }
}
}

15
backend/tsconfig.json Normal file
View file

@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"declaration": false
},
"include": ["src"]
}

View file

@ -8,8 +8,8 @@
## Global Rules (Apply to Every Page) ## Global Rules (Apply to Every Page)
### Sidebar ### Sidebar
- **Expanded width**: 100px (not 80px — needs room for labels) - **Expanded width**: 200px (matches mockup proportions — needs room for labels)
- **Collapsed width**: 60px (icon only) - **Collapsed width**: 64px (icon only)
- **User can manually collapse/expand** via toggle button (not just responsive) - **User can manually collapse/expand** via toggle button (not just responsive)
- **Main content margin-left** must match sidebar width exactly - **Main content margin-left** must match sidebar width exactly
@ -80,3 +80,146 @@
- Bottom row: 2 columns (65/35) - Bottom row: 2 columns (65/35)
- Network Traffic card has its own background image at low opacity - Network Traffic card has its own background image at low opacity
- User avatar dropdown has: Profile, Appearance, Security, Help & Support, Sign Out - User avatar dropdown has: Profile, Appearance, Security, Help & Support, Sign Out
### Infrastructure Page
- Hero banner rendered at the **App.tsx layout level** (not per-page), so it can extend
behind the sticky TopBar — conditional via `showHero = location.pathname === '/infrastructure'`.
TopBar and the search input are transparent on hero routes so the banner shows through.
- Hero image: `object-position: center 5%` to reveal the full arch + sky; faded out via
`linear-gradient` mask (vertical) + a `radial-gradient(ellipse 70% 100% at center, ...)`
overlay (sides/corners) — borderless, blends into page background.
- Sub-tabs trimmed to **Overview only** (Compute → Tags are future work, not built yet).
- Status cards: `rgba(10,10,12,0.5)` background (more transparent than other pages),
content centered (`justify-content/alignItems: center`) with a fixed row height (110px)
so there's breathing room instead of empty space below left-aligned content.
- Middle row (`grid-cols-[1fr_1.6fr]`): **Resource Distribution** (donut) and **Node Status**
(server tile grid). Both use the `/blank-kpi-bg.png` background art with a `cardDim`
(semi-transparent dark overlay) + `cardVignette` (radial-gradient `closest-side` blend)
combo — keeps the background pattern visible but subdued, with borders blended rather
than hard-edged. Card titles are rendered as our own text, NOT baked into the image
(baked-in labels got covered by the dim overlay).
- **Node Status** card: originally a world-map-style region dot plot, replaced with a
4-column tile grid (one tile per server, colored status dot + name) — a world map
didn't make sense for a small/single-site infra. Reuse this "small-scale" reasoning
for any future map-like cards.
- Bottom row (`grid-cols-[1.4fr_1fr_1fr]`): Resource Trend / Cost Breakdown / Recent
Activity — **left plain/regular**, no dim/vignette blending (explicit user preference,
only the middle row gets the hero-style blend). Resource Trend uses the
`/archnest-network-traffic-bg.png` background (plain, no dim/vignette) with 4 trend
lines: blue `#3B82F6` (compute), orange `#E67E22` (storage), green `#2ECC71` (database),
brown `#8B5E3C` (network).
- `cardVignette` radial-gradient must use the `closest-side` keyword (not a fixed `%`)
— otherwise straight edges of the card don't reach full opacity and a hard border line
remains visible (only corners fade correctly with a fixed percentage).
### BookNest Page
- Hero banner reused at the App.tsx layout level (`showHero` now includes `/booknest`),
with page-specific tuning via small lookup maps in `App.tsx` keyed on `location.pathname`:
`heroPaddingTop` (how far content sits below the hero top) and `heroObjectPosition`
(horizontal/vertical crop of the arch image) — `70px` / `54% 8%` for BookNest vs.
`72px` / `center 5%` for Infrastructure. Extend these maps rather than hardcoding a
single value when a future page needs different hero framing.
- **Large hero title + subtitle**: unlike other pages, BookNest's TopBar title is NOT the
small 18px uppercase label — it's rendered at 28px with a subtitle line ("Your Digital
Library") underneath, driven by a new `pageSubtitles` map in `TopBar.tsx`. When a page
has a subtitle, the header height grows from 56px → 72px (`TopBar.tsx`), and `App.tsx`'s
`topBarHeight` lookup keeps the content section's `calc(100vh - Npx)` in sync — both
must be updated together or the layout will clip/gap.
- **Stats row lives directly under the hero subtitle** (Links / Categories / Favorites),
not in its own separate bar — matches the blueprint's hero-header block grouping.
- **"Quick Access" section label** added above the 5 quick-access category cards (gold,
same `sectionTitle` style) — the row is intentionally pulled up via a small negative
hero-padding tune so it slightly overlaps the bottom edge of the hero, like the blueprint.
- **"+ Add Bookmark" button**: same gold-fill button style as Infrastructure's "+ Add
Resource", placed inline next to the "Quick Access" label rather than the page-stats row.
- **Right sidebar spans both grid rows** (`gridRow: '1 / span 2'` in a `gridTemplateRows:
'auto 1fr'` grid) so the Favorites card can rise up near the hero while the main column's
page-stats row stays in row 1 of column 1 only — keeps the two from overlapping/clipping.
Negative margins were tried first and discarded: content pushed above the scroll
container's natural top edge gets clipped by `overflow-y-auto`, so prefer reshaping the
grid/flow over negative-margin hacks when something needs to "reach upward."
- **Sidebar cards stretch to match the main column's full height** so the last card's
(Category Breakdown) bottom border lines up with the bottom of the bookmark groups grid:
sidebar wrapper is `display:flex; flex-direction:column; height:100%` (grid's default
`align-items: stretch` already gives it the matching height), and each card uses
`flex: 1 0 auto` (Favorites gets `flex: 1.4 0 auto` to read as visibly taller, per
explicit request) so they share the leftover vertical space instead of all packing tight
at content height.
- lucide-react gotcha (see Global Rules candidate): the installed version does **not**
export brand/wordmark icons (`Github`, `Gitlab`, `Linkedin`, `Youtube`) even though
TypeScript's type declarations list them — `tsc --noEmit` stays clean while the page
renders blank with a runtime `SyntaxError` only visible in the Vite dev log. Verify
icon names against `Object.keys(require('lucide-react'))` before importing anything
brand-flavored; substitutes used here: `GitBranch`/`GitFork` (GitHub/GitLab/Gitea),
`SquarePlay` (YouTube), `Briefcase` (LinkedIn Learning).
### Settings Page
- No mockup image existed for this page — built directly from the blueprint's Page 6
spec rather than iterating against a screenshot.
- Layout: fixed-width (200px) left nav listing the 6 sections (Profile, Appearance,
Integrations, Notifications, Data & Backup, About) + a scrollable content panel on
the right showing the active section. No hero banner (not in the blueprint spec for
this page, and a settings page doesn't need one).
- Active section is local component state (a string id) mapped through a
`sectionComponents` record to the corresponding section-renderer function — simplest
approach for a page with no routing/deep-linking requirement.
- Shared style helpers (`cardBase`, `sectionTitle`, `labelStyle`, `inputStyle`) plus two
small reusable components defined in the same file: `Toggle` (on/off pill switch) and
`GoldButton` (gold-filled primary / danger-outline variant) — kept local to
`Settings.tsx` rather than extracted, since no other page needs them yet.
- **Integrations** cards mask secret fields (API keys/tokens) behind dots with an eye
icon to reveal/hide, plus a "Test Connection" button per card — matches the blueprint's
explicit "masked secrets with eye toggle" instruction.
- **Avatar upload** (Profile section): the avatar circle is clickable and opens a hidden
`<input type="file" accept="image/*">` via a `useRef` + `.click()` call rather than a
visible file input — keeps the round avatar as the only visible control. On change,
`FileReader.readAsDataURL` converts the selected image to a base64 data URL stored in
component state, which becomes the circle's `backgroundImage` (cover-fit), replacing
the "AO" initials fallback. A hover-only camera-icon overlay (Tailwind `group` /
`group-hover:opacity-100`) signals the circle is clickable without cluttering the
default state. This is a frontend-only preview (no backend upload endpoint exists yet).
---
## Backend (added once frontend reached "good enough" state)
- New `backend/` package (separate `package.json`/`tsconfig.json`, own `node_modules`,
own Dockerfile) — Fastify + TypeScript, `better-sqlite3` for storage, deployed as a
second container alongside the existing frontend container (`docker-compose.yml` now
has `archnest` + `archnest-backend`, with a named volume for the SQLite file).
- Auth: single-user, JWT-based (`@fastify/jwt`). `POST /api/setup` creates the one user
and only succeeds while the `users` table is empty — this is what powers the
first-run enrollment page. `GET /api/system/setup-status` tells the frontend whether
to show enrollment/login or the normal app.
- Integration credentials are split across two tables: `integrations` (type, name,
status, non-secret `config_json`) and `secrets` (per-key AES-256-GCM-encrypted
values, key derived from the `ARCHNEST_SECRET_KEY` env var) — keeps secrets out of any
generic "list integrations" query/response by construction, not by remembering to
redact a field.
- Each integration type has an adapter module under `backend/src/integrations/`
exporting `testConnection(config, secrets)`; `registry.ts` maps `IntegrationType`
adapter. Only `uptime_kuma` and `docker` are real so far (simple HTTP health checks);
the rest return a "not yet implemented" result until built out — this lets the
Integrations UI and `POST /api/integrations/:id/test` endpoint work end-to-end for
every type without blocking on every adapter being finished.
- Vite dev server proxies `/api``http://localhost:4000` (`vite.config.ts`) so the
frontend can call relative `/api/...` paths in both dev and prod (prod routes `/api`
to the backend container via NPM).
- Next steps (not yet done): build the enrollment/login frontend pages, strip the mock
arrays out of Glance/Infrastructure/BookNest/Settings and replace with calls to this
API, add bookmark category seeding.
## Future Integration Notes
### Live Provider Data (AWS, Linode, etc.)
- All KPI/status card data (resource counts, health, pricing, budgets, cost breakdowns,
utilization, regions/map data) is currently mocked/static.
- The Infrastructure page (and likely Glance) should eventually integrate with real
cloud provider APIs — AWS, Linode, or any other VPC/cloud provider — via user-supplied
API keys, to pull live data such as:
- Resource inventory/counts and health status
- Pricing and budget/cost data (replacing the static Cost Breakdown numbers)
- Resource utilization metrics
- Region/datacenter info for the Infrastructure Map
- Design the data layer so it's provider-agnostic (a common interface/adapter per
provider) since users may connect more than one provider's API key.

29
docker-compose.yml Normal file
View file

@ -0,0 +1,29 @@
services:
archnest:
build: .
image: archnest:latest
container_name: archnest
restart: unless-stopped
ports:
- "8080:8080"
depends_on:
- archnest-backend
archnest-backend:
build: ./backend
image: archnest-backend:latest
container_name: archnest-backend
restart: unless-stopped
environment:
- PORT=4000
- ARCHNEST_DB_PATH=/data/archnest.db
- ARCHNEST_JWT_SECRET=${ARCHNEST_JWT_SECRET}
- ARCHNEST_SECRET_KEY=${ARCHNEST_SECRET_KEY}
- ARCHNEST_CORS_ORIGIN=${ARCHNEST_CORS_ORIGIN:-https://archnest.snsnetlabs.com}
volumes:
- archnest-data:/data
ports:
- "4000:4000"
volumes:
archnest-data:

10
nginx.conf Normal file
View file

@ -0,0 +1,10 @@
server {
listen 8080;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}

133
package-lock.json generated
View file

@ -12,6 +12,7 @@
"lucide-react": "^1.17.0", "lucide-react": "^1.17.0",
"react": "^19.2.6", "react": "^19.2.6",
"react-dom": "^19.2.6", "react-dom": "^19.2.6",
"react-router-dom": "^7.18.0",
"recharts": "^3.8.1", "recharts": "^3.8.1",
"tailwindcss": "^4.3.0" "tailwindcss": "^4.3.0"
}, },
@ -61,7 +62,6 @@
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.29.7", "@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7", "@babel/generator": "^7.29.7",
@ -271,10 +271,31 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@emnapi/core": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": { "node_modules/@emnapi/wasi-threads": {
"version": "1.2.1", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
@ -793,6 +814,37 @@
"node": "^20.19.0 || >=22.12.0" "node": "^20.19.0 || >=22.12.0"
} }
}, },
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.1",
"tslib": "^2.4.0"
}
},
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": { "node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz",
@ -1200,7 +1252,6 @@
"integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~7.18.0" "undici-types": "~7.18.0"
} }
@ -1211,7 +1262,6 @@
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
} }
@ -1277,7 +1327,6 @@
"integrity": "sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w==", "integrity": "sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.61.0", "@typescript-eslint/scope-manager": "8.61.0",
"@typescript-eslint/types": "8.61.0", "@typescript-eslint/types": "8.61.0",
@ -1508,7 +1557,6 @@
"integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@ -1599,7 +1647,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.10.12", "baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782", "caniuse-lite": "^1.0.30001782",
@ -1651,6 +1698,19 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@ -1893,7 +1953,6 @@
"integrity": "sha512-AyIKhnOBuOAdueD7RB3xB+YeAWScb9jHsJBgH2Hcde8InP5JYhqrRR6iTMHyTEwgENK54Cp44e4v8BwNhsuHuw==", "integrity": "sha512-AyIKhnOBuOAdueD7RB3xB+YeAWScb9jHsJBgH2Hcde8InP5JYhqrRR6iTMHyTEwgENK54Cp44e4v8BwNhsuHuw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.2", "@eslint-community/regexpp": "^4.12.2",
@ -2826,7 +2885,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@ -2887,7 +2945,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
"integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@ -2897,7 +2954,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
"integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"scheduler": "^0.27.0" "scheduler": "^0.27.0"
}, },
@ -2917,7 +2973,6 @@
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",
"integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/use-sync-external-store": "^0.0.6", "@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0" "use-sync-external-store": "^1.4.0"
@ -2936,6 +2991,44 @@
} }
} }
}, },
"node_modules/react-router": {
"version": "7.18.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.0.tgz",
"integrity": "sha512-pTTGt8J+ji1NOmYnjzT+bAJy/1zD+Jp4ziO6cL7T3ZLvXKtusO7BpFqlRXitqpcPVqllsIXFHRMt+2/k3Xn6HQ==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"version": "7.18.0",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.0.tgz",
"integrity": "sha512-Fi0yY6kgtKae/Th2xibdWK0KSdYZ4B53Gyf6wRtomOKWgpNm7H7+DyfDhncdz9FKbpS+1jmDhg3F4WoGJ+yFOA==",
"license": "MIT",
"dependencies": {
"react-router": "7.18.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/recharts": { "node_modules/recharts": {
"version": "3.8.1", "version": "3.8.1",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz", "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz",
@ -2970,8 +3063,7 @@
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/redux-thunk": { "node_modules/redux-thunk": {
"version": "3.1.0", "version": "3.1.0",
@ -3037,6 +3129,12 @@
"semver": "bin/semver.js" "semver": "bin/semver.js"
} }
}, },
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/shebang-command": { "node_modules/shebang-command": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@ -3149,7 +3247,6 @@
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@ -3266,7 +3363,6 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz",
"integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"lightningcss": "^1.32.0", "lightningcss": "^1.32.0",
"picomatch": "^4.0.4", "picomatch": "^4.0.4",
@ -3391,7 +3487,6 @@
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }

View file

@ -14,6 +14,7 @@
"lucide-react": "^1.17.0", "lucide-react": "^1.17.0",
"react": "^19.2.6", "react": "^19.2.6",
"react-dom": "^19.2.6", "react-dom": "^19.2.6",
"react-router-dom": "^7.18.0",
"recharts": "^3.8.1", "recharts": "^3.8.1",
"tailwindcss": "^4.3.0" "tailwindcss": "^4.3.0"
}, },

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

BIN
public/blank-kpi-bg.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

BIN
public/infra-map-kpi.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

BIN
public/network-kpi-bg.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

View file

@ -1,13 +1,39 @@
import { useState } from 'react' import { useState } from 'react'
import { Routes, Route, useLocation } from 'react-router-dom'
import Sidebar from './components/Sidebar' import Sidebar from './components/Sidebar'
import TopBar from './components/TopBar' import TopBar from './components/TopBar'
import StatusCards from './components/StatusCards' import Glance from './pages/Glance'
import MiddleRow from './components/MiddleRow' import Infrastructure from './pages/Infrastructure'
import BottomRow from './components/BottomRow' import BookNest from './pages/BookNest'
import Settings from './pages/Settings'
import Login from './pages/Login'
import Enrollment from './pages/Enrollment'
import { useAuth } from './lib/AuthContext'
function App() { function App() {
const { status } = useAuth()
if (status === 'loading') {
return (
<div className="flex h-screen w-screen items-center justify-center bg-page">
<p style={{ color: '#7A7D85', fontSize: '13px' }}>Loading</p>
</div>
)
}
if (status === 'needs-setup' || status === 'enrolling') return <Enrollment />
if (status === 'logged-out') return <Login />
return <Dashboard />
}
function Dashboard() {
const [sidebarCollapsed, setSidebarCollapsed] = useState(false) const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
const sidebarWidth = sidebarCollapsed ? 60 : 140 const sidebarWidth = sidebarCollapsed ? 64 : 200
const location = useLocation()
const showHero = location.pathname === '/infrastructure' || location.pathname === '/booknest'
const heroPaddingTop = location.pathname === '/booknest' ? '70px' : '72px'
const heroObjectPosition = location.pathname === '/booknest' ? '54% 8%' : 'center 5%'
const topBarHeight = location.pathname === '/booknest' ? 72 : 56
return ( return (
<div className="min-h-screen w-screen overflow-hidden bg-page"> <div className="min-h-screen w-screen overflow-hidden bg-page">
@ -17,50 +43,45 @@ function App() {
/> />
<main <main
className="h-screen overflow-hidden" className="relative h-screen overflow-hidden"
style={{ marginLeft: `${sidebarWidth}px`, width: `calc(100vw - ${sidebarWidth}px)` }} style={{ marginLeft: `${sidebarWidth}px`, width: `calc(100vw - ${sidebarWidth}px)` }}
> >
<TopBar /> {showHero && (
<div className="pointer-events-none absolute left-0 right-0 top-0" style={{ height: '300px', zIndex: 0 }}>
<img
src="/archnest-hero-banner.png"
alt=""
className="absolute inset-0 h-full w-full"
style={{
objectFit: 'cover',
objectPosition: heroObjectPosition,
maskImage: 'linear-gradient(to bottom, black 0%, black 55%, transparent 100%)',
WebkitMaskImage: 'linear-gradient(to bottom, black 0%, black 55%, transparent 100%)',
}}
/>
<div
className="absolute inset-0"
style={{
background: 'radial-gradient(ellipse 70% 100% at center, transparent 40%, var(--color-page) 100%)',
}}
/>
</div>
)}
<div className="relative" style={{ zIndex: 1 }}>
<TopBar />
</div>
<section <section
className="w-full overflow-y-auto" className="relative flex w-full flex-col overflow-hidden"
style={{ height: 'calc(100vh - 56px)', scrollbarWidth: 'none', padding: '16px 24px 32px 24px' }} style={{ height: `calc(100vh - ${topBarHeight}px)`, scrollbarWidth: 'none', padding: showHero ? `${heroPaddingTop} 24px 24px 24px` : '16px 24px 24px 24px', gap: '20px', zIndex: 1 }}
> >
<div className="flex w-full max-w-none flex-col gap-0"> <Routes>
{/* Hero + KPI overlap — KPI bottom aligns with banner bottom */} <Route path="/" element={<Glance />} />
<div className="relative"> <Route path="/infrastructure" element={<Infrastructure />} />
<div className="w-full overflow-hidden" style={{ borderRadius: '12px 12px 0 0' }}> <Route path="/booknest" element={<BookNest />} />
<img <Route path="/settings" element={<Settings />} />
src="/archnest-hero-banner.png" </Routes>
alt="ArchNest Banner"
className="w-full"
style={{ display: 'block' }}
onError={(e) => {
const target = e.currentTarget
target.style.display = 'none'
target.parentElement!.classList.add('bg-card')
target.parentElement!.style.height = '260px'
}}
/>
</div>
{/* KPI cards positioned so their bottom edge aligns with banner bottom */}
<div className="absolute bottom-0 left-0 right-0 z-10 px-4">
<StatusCards />
</div>
</div>
{/* 24px breathing room between KPI row and middle row */}
<div style={{ height: '24px' }} />
{/* Middle Row */}
<MiddleRow />
{/* Gap */}
<div style={{ height: '24px' }} />
{/* Bottom Row */}
<BottomRow />
</div>
</section> </section>
</main> </main>
</div> </div>

View file

@ -1,17 +1,13 @@
import { AreaChart, Area, ResponsiveContainer } from 'recharts' import { useEffect, useState } from 'react'
import { ServerCog, DatabaseBackup, Rocket, FileText } from 'lucide-react' import { useNavigate } from 'react-router-dom'
import { Plug, ServerCog, BookMarked, Settings as SettingsIcon } from 'lucide-react'
const trafficData = Array.from({ length: 48 }, (_, i) => ({ import { api, type Integration } from '../lib/api'
time: i,
incoming: 800 + Math.sin(i / 6) * 300 + Math.random() * 150,
outgoing: 700 + Math.cos(i / 8) * 250 + Math.random() * 100,
}))
const shortcuts = [ const shortcuts = [
{ icon: ServerCog, label: 'Add Server' }, { icon: ServerCog, label: 'Add Integration', to: '/settings' },
{ icon: DatabaseBackup, label: 'Create Backup' }, { icon: BookMarked, label: 'Add Bookmark', to: '/booknest' },
{ icon: Rocket, label: 'Deploy App' }, { icon: Plug, label: 'Infrastructure', to: '/infrastructure' },
{ icon: FileText, label: 'View Logs' }, { icon: SettingsIcon, label: 'Settings', to: '/settings' },
] ]
const cardBase: React.CSSProperties = { const cardBase: React.CSSProperties = {
@ -25,56 +21,48 @@ const cardBase: React.CSSProperties = {
overflow: 'hidden', overflow: 'hidden',
} }
const statusColor: Record<string, string> = {
connected: '#2ECC71',
error: '#E74C3C',
unknown: '#7A7D85',
}
export default function BottomRow() { export default function BottomRow() {
const [integrations, setIntegrations] = useState<Integration[] | null>(null)
const navigate = useNavigate()
useEffect(() => {
api.listIntegrations().then(({ integrations }) => setIntegrations(integrations))
}, [])
return ( return (
<div className="grid w-full grid-cols-[1.8fr_1fr] gap-6"> <div className="grid w-full grid-cols-[1.8fr_1fr] gap-6">
{/* Network Traffic */} {/* Connected Integrations */}
<div style={cardBase} className="hover:!border-gold/15"> <div style={cardBase} className="hover:!border-gold/15">
{/* Background image at very low opacity */}
<div style={{ position: 'absolute', inset: 0, opacity: 0.12, backgroundImage: 'url(/archnest-network-traffic-bg.png)', backgroundSize: 'cover', backgroundPosition: 'center', pointerEvents: 'none' }} />
{/* Gold top edge */}
<div style={{ position: 'absolute', top: 0, left: '5%', right: '5%', height: '1px', background: 'linear-gradient(90deg, transparent, rgba(200,164,52,0.15), transparent)', pointerEvents: 'none' }} /> <div style={{ position: 'absolute', top: 0, left: '5%', right: '5%', height: '1px', background: 'linear-gradient(90deg, transparent, rgba(200,164,52,0.15), transparent)', pointerEvents: 'none' }} />
<div className="relative z-10"> <div className="relative z-10">
<h3 style={{ fontSize: '11px', textTransform: 'uppercase', letterSpacing: '1.5px', color: '#7A7D85', fontWeight: 500, marginBottom: '16px' }}> <h3 style={{ fontSize: '11px', textTransform: 'uppercase', letterSpacing: '1.5px', color: '#7A7D85', fontWeight: 500, marginBottom: '16px' }}>
Network Traffic Connected Integrations
</h3> </h3>
<div className="flex items-end gap-6"> {integrations === null ? (
<div style={{ flex: 1, height: '100px' }}> <p style={{ fontSize: '12px', color: '#7A7D85' }}>Loading</p>
<ResponsiveContainer width="100%" height="100%"> ) : integrations.length === 0 ? (
<AreaChart data={trafficData} margin={{ top: 0, right: 0, left: 0, bottom: 0 }}> <p style={{ fontSize: '12px', color: '#7A7D85' }}>No integrations added yet add one in Settings.</p>
<defs> ) : (
<linearGradient id="trafficGold" x1="0" y1="0" x2="0" y2="1"> <div className="grid grid-cols-3 gap-3">
<stop offset="0%" stopColor="#C8A434" stopOpacity={0.25} /> {integrations.map((i) => (
<stop offset="100%" stopColor="#C8A434" stopOpacity={0.02} /> <div key={i.id} className="flex items-center gap-2.5" style={{ padding: '8px 10px', borderRadius: '8px', backgroundColor: 'rgba(255,255,255,0.02)' }}>
</linearGradient> <span style={{ width: '7px', height: '7px', borderRadius: '50%', backgroundColor: statusColor[i.status] ?? '#7A7D85', flexShrink: 0 }} />
<linearGradient id="trafficAmber" x1="0" y1="0" x2="0" y2="1"> <span style={{ fontSize: '13px', color: '#E8E6E0', flex: 1, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{i.name}</span>
<stop offset="0%" stopColor="#E67E22" stopOpacity={0.15} /> </div>
<stop offset="100%" stopColor="#E67E22" stopOpacity={0.02} /> ))}
</linearGradient>
</defs>
<Area type="monotone" dataKey="incoming" stroke="#C8A434" strokeWidth={1.5} fill="url(#trafficGold)" dot={false} isAnimationActive={true} animationDuration={1200} />
<Area type="monotone" dataKey="outgoing" stroke="rgba(230,126,34,0.6)" strokeWidth={1} fill="url(#trafficAmber)" dot={false} isAnimationActive={true} animationDuration={1200} />
</AreaChart>
</ResponsiveContainer>
</div> </div>
<div className="flex flex-col gap-3 flex-shrink-0"> )}
<div>
<p style={{ fontSize: '11px', color: '#7A7D85' }}>Incoming</p>
<p style={{ fontSize: '18px', fontWeight: 700, color: '#E8E6E0' }}>1.23 Gbps</p>
<p style={{ fontSize: '11px', color: '#E74C3C' }}> 12.4%</p>
</div>
<div>
<p style={{ fontSize: '11px', color: '#7A7D85' }}>Outgoing</p>
<p style={{ fontSize: '18px', fontWeight: 700, color: '#E8E6E0' }}>1.08 Gbps</p>
<p style={{ fontSize: '11px', color: '#2ECC71' }}> 8.7%</p>
</div>
</div>
</div>
</div> </div>
</div> </div>
{/* Shortcuts — miniature control panels */} {/* Shortcuts */}
<div style={cardBase} className="hover:!border-gold/15"> <div style={cardBase} className="hover:!border-gold/15">
<div style={{ position: 'absolute', inset: 0, opacity: 0.02, backgroundImage: 'radial-gradient(circle, #C8A434 1px, transparent 1px)', backgroundSize: '24px 24px', pointerEvents: 'none' }} /> <div style={{ position: 'absolute', inset: 0, opacity: 0.02, backgroundImage: 'radial-gradient(circle, #C8A434 1px, transparent 1px)', backgroundSize: '24px 24px', pointerEvents: 'none' }} />
@ -82,19 +70,20 @@ export default function BottomRow() {
<h3 style={{ fontSize: '11px', textTransform: 'uppercase', letterSpacing: '1.5px', color: '#7A7D85', fontWeight: 500, marginBottom: '16px' }}> <h3 style={{ fontSize: '11px', textTransform: 'uppercase', letterSpacing: '1.5px', color: '#7A7D85', fontWeight: 500, marginBottom: '16px' }}>
Shortcuts Shortcuts
</h3> </h3>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-4 gap-3">
{shortcuts.map((item) => { {shortcuts.map((item) => {
const Icon = item.icon const Icon = item.icon
return ( return (
<button <button
key={item.label} key={item.label}
onClick={() => navigate(item.to)}
className="flex flex-col items-center gap-2 cursor-pointer bg-transparent transition-all duration-200 group/btn" className="flex flex-col items-center gap-2 cursor-pointer bg-transparent transition-all duration-200 group/btn"
style={{ padding: '16px 12px', borderRadius: '10px', border: '1px solid rgba(200,164,52,0.08)', boxShadow: '0 0 12px rgba(200,164,52,0.02)' }} style={{ padding: '16px 8px', borderRadius: '10px', border: '1px solid rgba(200,164,52,0.08)', boxShadow: '0 0 12px rgba(200,164,52,0.02)' }}
onMouseEnter={(e) => { e.currentTarget.style.borderColor = 'rgba(200,164,52,0.2)'; e.currentTarget.style.boxShadow = '0 0 16px rgba(200,164,52,0.06)' }} onMouseEnter={(e) => { e.currentTarget.style.borderColor = 'rgba(200,164,52,0.2)'; e.currentTarget.style.boxShadow = '0 0 16px rgba(200,164,52,0.06)' }}
onMouseLeave={(e) => { e.currentTarget.style.borderColor = 'rgba(200,164,52,0.08)'; e.currentTarget.style.boxShadow = '0 0 12px rgba(200,164,52,0.02)' }} onMouseLeave={(e) => { e.currentTarget.style.borderColor = 'rgba(200,164,52,0.08)'; e.currentTarget.style.boxShadow = '0 0 12px rgba(200,164,52,0.02)' }}
> >
<Icon size={20} style={{ color: '#7A7D85', transition: 'color 0.2s' }} /> <Icon size={20} style={{ color: '#C8A434', transition: 'color 0.2s' }} />
<span style={{ fontSize: '10px', color: '#7A7D85' }}>{item.label}</span> <span style={{ fontSize: '11px', color: '#C9CCD1', whiteSpace: 'nowrap' }}>{item.label}</span>
</button> </button>
) )
})} })}

View file

@ -1,27 +1,6 @@
import { X, CircleCheck, Shield, Play, Settings, User } from 'lucide-react' import { useEffect, useState } from 'react'
import { CircleCheck, AlertTriangle, Plug, Bookmark as BookmarkIcon, LogIn } from 'lucide-react'
const resources = [ import { api, type Event, type Resource, type Integration } from '../lib/api'
{ label: 'Compute', current: 18, max: 24, unit: '' },
{ label: 'Storage', current: 12.4, max: 20, unit: ' TB' },
{ label: 'Database', current: 8, max: 12, unit: '' },
{ label: 'Network', current: 98.7, max: 100, unit: '%' },
{ label: 'Containers', current: 32, max: 40, unit: '' },
]
const activities = [
{ icon: CircleCheck, title: 'Backup completed', source: 'Database Cluster 01', time: '2m ago' },
{ icon: Shield, title: 'Security scan completed', source: 'Web Frontend', time: '8m ago' },
{ icon: Play, title: 'Instance launched', source: 'App Server 03', time: '15m ago' },
{ icon: Settings, title: 'Configuration updated', source: 'Load Balancer', time: '22m ago' },
{ icon: User, title: 'User login detected', source: 'admin@archnest.io', time: '35m ago' },
]
const alerts = [
{ severity: 'high', title: 'High CPU Usage', source: 'App Server 02', time: '2m ago' },
{ severity: 'medium', title: 'Disk Space Low', source: 'Database Cluster 01', time: '15m ago' },
{ severity: 'medium', title: 'Unauthorized Login Attempt', source: 'Web Frontend', time: '32m ago' },
{ severity: 'medium', title: 'SSL Certificate Expiring', source: 'api.archnest.io', time: '1h ago' },
]
const cardBase: React.CSSProperties = { const cardBase: React.CSSProperties = {
backgroundColor: 'rgba(10, 10, 12, 0.92)', backgroundColor: 'rgba(10, 10, 12, 0.92)',
@ -32,111 +11,148 @@ const cardBase: React.CSSProperties = {
transition: 'border-color 0.2s ease', transition: 'border-color 0.2s ease',
position: 'relative', position: 'relative',
overflow: 'hidden', overflow: 'hidden',
height: '100%',
display: 'flex',
flexDirection: 'column',
} }
function getBarColor(percentage: number) { const statusColor: Record<string, string> = {
if (percentage >= 90) return '#E74C3C' healthy: '#C8A434',
if (percentage >= 70) return '#E67E22' warning: '#E67E22',
return '#C8A434' critical: '#E74C3C',
unknown: '#7A7D85',
}
const eventIcons: Record<string, typeof CircleCheck> = {
integration_created: Plug,
integration_tested: CircleCheck,
integration_deleted: Plug,
bookmark_created: BookmarkIcon,
bookmark_deleted: BookmarkIcon,
user_login: LogIn,
account_created: LogIn,
}
function timeAgo(iso: string) {
const diffMs = Date.now() - new Date(iso.replace(' ', 'T') + 'Z').getTime()
const mins = Math.floor(diffMs / 60000)
if (mins < 1) return 'just now'
if (mins < 60) return `${mins}m ago`
const hours = Math.floor(mins / 60)
if (hours < 24) return `${hours}h ago`
return `${Math.floor(hours / 24)}d ago`
} }
export default function MiddleRow() { export default function MiddleRow() {
const [resources, setResources] = useState<Resource[] | null>(null)
const [events, setEvents] = useState<Event[] | null>(null)
const [integrations, setIntegrations] = useState<Integration[] | null>(null)
useEffect(() => {
api.listResources().then(({ resources }) => setResources(resources))
api.listEvents(5).then(({ events }) => setEvents(events))
api.listIntegrations().then(({ integrations }) => setIntegrations(integrations))
}, [])
const erroredIntegrations = integrations?.filter((i) => i.status === 'error') ?? []
const problemResources = resources?.filter((r) => r.status === 'warning' || r.status === 'critical') ?? []
return ( return (
<div className="grid w-full grid-cols-[1fr_1.4fr_1fr] gap-6"> <div className="grid h-full w-full grid-cols-[1fr_1.4fr_1fr] gap-6">
{/* Resource Overview */} {/* Resource Overview */}
<div style={cardBase} className="hover:!border-gold/15 group"> <div style={cardBase} className="hover:!border-gold/15 group">
{/* Subtle hex pattern overlay */}
<div style={{ position: 'absolute', inset: 0, opacity: 0.03, backgroundImage: 'radial-gradient(circle, #C8A434 1px, transparent 1px)', backgroundSize: '20px 20px', pointerEvents: 'none' }} /> <div style={{ position: 'absolute', inset: 0, opacity: 0.03, backgroundImage: 'radial-gradient(circle, #C8A434 1px, transparent 1px)', backgroundSize: '20px 20px', pointerEvents: 'none' }} />
{/* Gold top edge lighting */}
<div style={{ position: 'absolute', top: 0, left: '10%', right: '10%', height: '1px', background: 'linear-gradient(90deg, transparent, rgba(200,164,52,0.15), transparent)', pointerEvents: 'none' }} /> <div style={{ position: 'absolute', top: 0, left: '10%', right: '10%', height: '1px', background: 'linear-gradient(90deg, transparent, rgba(200,164,52,0.15), transparent)', pointerEvents: 'none' }} />
<div className="relative z-10"> <div className="relative z-10 flex flex-1 flex-col">
<div className="flex items-center justify-between mb-4"> <h3 style={{ fontSize: '11px', textTransform: 'uppercase', letterSpacing: '1.5px', color: '#7A7D85', fontWeight: 500, marginBottom: '16px' }}>
<h3 style={{ fontSize: '11px', textTransform: 'uppercase', letterSpacing: '1.5px', color: '#7A7D85', fontWeight: 500 }}> Resource Overview
Resource Overview </h3>
</h3> {resources === null ? (
<button className="bg-transparent border-none cursor-pointer p-1" style={{ color: '#7A7D85' }}> <p style={{ fontSize: '12px', color: '#7A7D85' }}>Loading</p>
<X size={14} /> ) : resources.length === 0 ? (
</button> <p style={{ fontSize: '12px', color: '#7A7D85' }}>Connect an integration in Settings to see live resources here.</p>
</div> ) : (
<div className="flex flex-col gap-4"> <div className="flex flex-1 flex-col justify-around gap-3" style={{ overflowY: 'auto' }}>
{resources.map((res) => { {resources.slice(0, 6).map((r, i) => (
const percentage = res.unit === '%' ? res.current : (res.current / res.max) * 100 <div key={i} className="flex items-center gap-2.5">
const displayValue = res.unit === '%' ? `${res.current}%` : `${res.current} / ${res.max}${res.unit}` <span style={{ width: '7px', height: '7px', borderRadius: '50%', backgroundColor: statusColor[r.status], flexShrink: 0 }} />
return ( <span style={{ fontSize: '13px', color: '#E8E6E0', flex: 1, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{r.name}</span>
<div key={res.label} className="flex items-center gap-3"> <span style={{ fontSize: '10px', color: '#7A7D85', flexShrink: 0 }}>{r.integration}</span>
<span style={{ fontSize: '13px', color: '#E8E6E0', width: '90px' }}>{res.label}</span>
<div style={{ flex: 1, height: '6px', backgroundColor: 'rgba(30,32,37,0.8)', borderRadius: '3px', overflow: 'hidden' }}>
<div style={{ height: '100%', width: `${percentage}%`, backgroundColor: getBarColor(percentage), borderRadius: '3px', transition: 'width 0.8s ease' }} />
</div>
<span style={{ fontSize: '12px', color: '#7A7D85', width: '80px', textAlign: 'right' }}>{displayValue}</span>
</div> </div>
) ))}
})} </div>
</div> )}
</div> </div>
</div> </div>
{/* Recent Activity — visually dominant */} {/* Recent Activity */}
<div style={cardBase} className="hover:!border-gold/15 group"> <div style={cardBase} className="hover:!border-gold/15 group">
{/* City grid texture */}
<div style={{ position: 'absolute', inset: 0, opacity: 0.02, backgroundImage: 'linear-gradient(rgba(200,164,52,0.3) 1px, transparent 1px), linear-gradient(90deg, rgba(200,164,52,0.3) 1px, transparent 1px)', backgroundSize: '40px 40px', pointerEvents: 'none' }} /> <div style={{ position: 'absolute', inset: 0, opacity: 0.02, backgroundImage: 'linear-gradient(rgba(200,164,52,0.3) 1px, transparent 1px), linear-gradient(90deg, rgba(200,164,52,0.3) 1px, transparent 1px)', backgroundSize: '40px 40px', pointerEvents: 'none' }} />
{/* Gold top edge */}
<div style={{ position: 'absolute', top: 0, left: '5%', right: '5%', height: '1px', background: 'linear-gradient(90deg, transparent, rgba(200,164,52,0.2), transparent)', pointerEvents: 'none' }} /> <div style={{ position: 'absolute', top: 0, left: '5%', right: '5%', height: '1px', background: 'linear-gradient(90deg, transparent, rgba(200,164,52,0.2), transparent)', pointerEvents: 'none' }} />
<div className="relative z-10"> <div className="relative z-10 flex flex-1 flex-col">
<div className="flex items-center justify-between mb-4"> <h3 style={{ fontSize: '11px', textTransform: 'uppercase', letterSpacing: '1.5px', color: '#7A7D85', fontWeight: 500, marginBottom: '16px' }}>
<h3 style={{ fontSize: '11px', textTransform: 'uppercase', letterSpacing: '1.5px', color: '#7A7D85', fontWeight: 500 }}> Recent Activity
Recent Activity </h3>
</h3> {events === null ? (
<button className="bg-transparent border-none cursor-pointer p-1" style={{ color: '#7A7D85' }}> <p style={{ fontSize: '12px', color: '#7A7D85' }}>Loading</p>
<X size={14} /> ) : events.length === 0 ? (
</button> <p style={{ fontSize: '12px', color: '#7A7D85' }}>No activity yet.</p>
</div> ) : (
<div className="flex flex-col gap-3"> <div className="flex flex-1 flex-col justify-around gap-3">
{activities.map((item, i) => { {events.map((item) => {
const Icon = item.icon const Icon = eventIcons[item.type] ?? CircleCheck
return ( return (
<div key={i} className="flex items-start gap-3"> <div key={item.id} className="flex items-start gap-3">
<div style={{ width: '28px', height: '28px', borderRadius: '6px', backgroundColor: 'rgba(200,164,52,0.06)', border: '1px solid rgba(200,164,52,0.08)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, boxShadow: '0 0 8px rgba(200,164,52,0.04)' }}> <div style={{ width: '28px', height: '28px', borderRadius: '6px', backgroundColor: 'rgba(200,164,52,0.06)', border: '1px solid rgba(200,164,52,0.08)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, boxShadow: '0 0 8px rgba(200,164,52,0.04)' }}>
<Icon size={13} style={{ color: '#C8A434' }} /> <Icon size={13} style={{ color: '#C8A434' }} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<p style={{ fontSize: '13px', color: '#E8E6E0', fontWeight: 500, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{item.title}</p>
{item.source && <p style={{ fontSize: '11px', color: '#7A7D85', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{item.source}</p>}
</div>
<span style={{ fontSize: '11px', color: '#7A7D85', flexShrink: 0 }}>{timeAgo(item.created_at)}</span>
</div> </div>
<div style={{ flex: 1, minWidth: 0 }}> )
<p style={{ fontSize: '13px', color: '#E8E6E0', fontWeight: 500, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{item.title}</p> })}
<p style={{ fontSize: '11px', color: '#7A7D85', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{item.source}</p> </div>
</div> )}
<span style={{ fontSize: '11px', color: '#7A7D85', flexShrink: 0 }}>{item.time}</span>
</div>
)
})}
</div>
</div> </div>
</div> </div>
{/* Top Alerts */} {/* Top Alerts */}
<div style={cardBase} className="hover:!border-gold/15 group"> <div style={cardBase} className="hover:!border-gold/15 group">
{/* Amber edge lighting */}
<div style={{ position: 'absolute', top: 0, left: '10%', right: '10%', height: '1px', background: 'linear-gradient(90deg, transparent, rgba(231,126,34,0.15), transparent)', pointerEvents: 'none' }} /> <div style={{ position: 'absolute', top: 0, left: '10%', right: '10%', height: '1px', background: 'linear-gradient(90deg, transparent, rgba(231,126,34,0.15), transparent)', pointerEvents: 'none' }} />
<div className="relative z-10"> <div className="relative z-10 flex flex-1 flex-col">
<div className="flex items-center justify-between mb-4"> <h3 style={{ fontSize: '11px', textTransform: 'uppercase', letterSpacing: '1.5px', color: '#7A7D85', fontWeight: 500, marginBottom: '16px' }}>
<h3 style={{ fontSize: '11px', textTransform: 'uppercase', letterSpacing: '1.5px', color: '#7A7D85', fontWeight: 500 }}> Top Alerts
Top Alerts </h3>
</h3> {erroredIntegrations.length === 0 && problemResources.length === 0 ? (
<a href="#" style={{ fontSize: '11px', color: '#C8A434', textDecoration: 'none' }}>View all</a> <p style={{ fontSize: '12px', color: '#7A7D85' }}>No alerts everything connected is healthy.</p>
</div> ) : (
<div className="flex flex-col gap-3"> <div className="flex flex-1 flex-col justify-around gap-3">
{alerts.map((alert, i) => ( {erroredIntegrations.map((i) => (
<div key={i} className="flex items-start gap-3"> <div key={`int-${i.id}`} className="flex items-start gap-3">
<div style={{ width: '8px', height: '8px', borderRadius: '50%', flexShrink: 0, marginTop: '5px', backgroundColor: alert.severity === 'high' ? '#E74C3C' : '#E67E22', boxShadow: alert.severity === 'high' ? '0 0 6px rgba(231,76,60,0.3)' : '0 0 6px rgba(230,126,34,0.2)' }} /> <AlertTriangle size={14} style={{ color: '#E74C3C', flexShrink: 0, marginTop: '2px' }} />
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<p style={{ fontSize: '13px', color: '#E8E6E0', fontWeight: 500, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{alert.title}</p> <p style={{ fontSize: '13px', color: '#E8E6E0', fontWeight: 500 }}>Connection failing</p>
<p style={{ fontSize: '11px', color: '#7A7D85', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{alert.source}</p> <p style={{ fontSize: '11px', color: '#7A7D85' }}>{i.name}</p>
</div>
</div> </div>
<span style={{ fontSize: '11px', color: '#7A7D85', flexShrink: 0 }}>{alert.time}</span> ))}
</div> {problemResources.map((r, idx) => (
))} <div key={`res-${idx}`} className="flex items-start gap-3">
</div> <AlertTriangle size={14} style={{ color: r.status === 'critical' ? '#E74C3C' : '#E67E22', flexShrink: 0, marginTop: '2px' }} />
<div style={{ flex: 1, minWidth: 0 }}>
<p style={{ fontSize: '13px', color: '#E8E6E0', fontWeight: 500 }}>{r.name}</p>
<p style={{ fontSize: '11px', color: '#7A7D85' }}>{r.integration} · {r.detail ?? r.status}</p>
</div>
</div>
))}
</div>
)}
</div> </div>
</div> </div>
</div> </div>

View file

@ -1,7 +1,7 @@
import { useLocation, Link } from 'react-router-dom'
import { import {
LayoutGrid, LayoutGrid,
Server, Server,
Globe,
Bookmark, Bookmark,
Terminal, Terminal,
Settings, Settings,
@ -15,81 +15,96 @@ interface SidebarProps {
} }
const navItems = [ const navItems = [
{ icon: LayoutGrid, label: 'Glance', route: '/', active: true }, { icon: LayoutGrid, label: 'Glance', route: '/' },
{ icon: Server, label: 'Infrastructure', route: '/infrastructure', active: false }, { icon: Server, label: 'Infrastructure', route: '/infrastructure' },
{ icon: Globe, label: 'Network', route: '/network', active: false }, { icon: Bookmark, label: 'BookNest', route: '/booknest' },
{ icon: Bookmark, label: 'BookNest', route: '/booknest', active: false }, { icon: Terminal, label: 'Terminal', route: '/terminal' },
{ icon: Terminal, label: 'Terminal', route: '/terminal', active: false }, { icon: Settings, label: 'Settings', route: '/settings' },
{ icon: Settings, label: 'Settings', route: '/settings', active: false },
] ]
export default function Sidebar({ collapsed, onToggle }: SidebarProps) { export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
const width = collapsed ? 60 : 140 const width = collapsed ? 64 : 200
const location = useLocation()
return ( return (
<aside <aside
className="fixed left-0 top-0 z-50 h-screen overflow-hidden flex flex-col items-center py-5" className="fixed left-0 top-0 z-50 h-screen overflow-hidden flex flex-col py-6"
style={{ width: `${width}px`, backgroundColor: '#0A0B0D' }} style={{ width: `${width}px`, backgroundColor: '#0A0B0D' }}
> >
{/* Logo — larger, aligned with top bar */} {/* Collapse Toggle — floating on the sidebar/content edge, vertically centered */}
<div className="flex flex-col items-center mb-6" style={{ paddingTop: '8px' }}>
<img
src="/archnest-logo.png"
alt="ArchNest"
className="mb-1.5"
style={{
width: collapsed ? '28px' : '44px',
height: collapsed ? '28px' : '44px',
filter: 'drop-shadow(0 0 8px rgba(200,164,52,0.5))',
}}
/>
{!collapsed && (
<span style={{ fontSize: '9px', fontWeight: 700, letterSpacing: '2.5px', color: '#C8A434', textTransform: 'uppercase' }}>
ArchNest
</span>
)}
</div>
{/* Nav Items */}
<nav className="flex-1 flex flex-col justify-center gap-6 w-full">
{navItems.map((item) => {
const Icon = item.icon
return (
<a
key={item.label}
href={item.route}
className="relative flex w-full flex-col items-center justify-center gap-1.5 px-2 py-2 text-center no-underline transition-all duration-200"
style={{ color: item.active ? '#C8A434' : '#7A7D85' }}
title={collapsed ? item.label : undefined}
onMouseEnter={(e) => { if (!item.active) e.currentTarget.style.backgroundColor = 'rgba(200,164,52,0.05)'; e.currentTarget.style.color = '#C8A434' }}
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent'; if (!item.active) e.currentTarget.style.color = '#7A7D85' }}
>
{item.active && (
<div
className="absolute left-0 top-1/2 -translate-y-1/2 rounded-r"
style={{ width: '3px', height: '26px', backgroundColor: '#C8A434', boxShadow: '0 0 6px rgba(200,164,52,0.5)' }}
/>
)}
<Icon className="h-5 w-5 shrink-0" strokeWidth={item.active ? 2 : 1.5} />
{!collapsed && (
<span className="max-w-[90px] truncate leading-tight font-medium" style={{ fontSize: '10px' }}>
{item.label}
</span>
)}
</a>
)
})}
</nav>
{/* Collapse Toggle */}
<button <button
onClick={onToggle} onClick={onToggle}
className="p-1.5 rounded cursor-pointer bg-transparent transition-colors" className="fixed top-1/2 z-50 flex items-center justify-center rounded-full cursor-pointer transition-colors"
style={{ border: '1px solid #1E2025', color: '#7A7D85', marginBottom: '12px' }} style={{
left: `${width - 11}px`,
transform: 'translateY(-50%)',
width: '22px',
height: '22px',
border: '1px solid rgba(200,164,52,0.25)',
backgroundColor: '#15161A',
color: '#7A7D85',
boxShadow: '0 0 8px rgba(0,0,0,0.4)',
}}
onMouseEnter={(e) => { e.currentTarget.style.color = '#C8A434'; e.currentTarget.style.borderColor = 'rgba(200,164,52,0.5)' }}
onMouseLeave={(e) => { e.currentTarget.style.color = '#7A7D85'; e.currentTarget.style.borderColor = 'rgba(200,164,52,0.25)' }}
> >
{collapsed ? <ChevronRight size={12} /> : <ChevronLeft size={12} />} {collapsed ? <ChevronRight size={12} /> : <ChevronLeft size={12} />}
</button> </button>
{/* Logo prominent, centered at top. Blend mode hides the baked-in
dark background of the source PNG so only the gold arc shows. */}
<div className="flex flex-col items-center mb-10" style={{ paddingTop: '4px' }}>
<img
src="/archnest-logo-clean.png"
alt="ArchNest"
style={{
width: collapsed ? '56px' : '168px',
height: 'auto',
filter: 'drop-shadow(0 0 10px rgba(200,164,52,0.3))',
}}
/>
</div>
{/* Nav Items */}
<nav className="flex-1 flex flex-col gap-2 w-full" style={{ padding: collapsed ? '0 8px' : '0 12px' }}>
{navItems.map((item) => {
const Icon = item.icon
const active = location.pathname === item.route
return (
<Link
key={item.label}
to={item.route}
className={`relative flex items-center no-underline transition-all duration-200 ${collapsed ? 'justify-center' : ''}`}
style={{
color: active ? '#C8A434' : '#7A7D85',
gap: collapsed ? '0' : '12px',
padding: collapsed ? '12px 0' : '12px 14px',
borderRadius: '10px',
backgroundColor: active ? 'rgba(200,164,52,0.1)' : 'transparent',
border: active ? '1px solid rgba(200,164,52,0.18)' : '1px solid transparent',
boxShadow: active ? '0 0 14px rgba(200,164,52,0.06)' : 'none',
}}
title={collapsed ? item.label : undefined}
onMouseEnter={(e) => { if (!active) { e.currentTarget.style.backgroundColor = 'rgba(200,164,52,0.05)'; e.currentTarget.style.color = '#C8A434' } }}
onMouseLeave={(e) => { if (!active) { e.currentTarget.style.backgroundColor = 'transparent'; e.currentTarget.style.color = '#7A7D85' } }}
>
{active && (
<div
className="absolute left-0 top-1/2 -translate-y-1/2"
style={{ width: '3px', height: '22px', backgroundColor: '#C8A434', borderRadius: '0 3px 3px 0', boxShadow: '0 0 6px rgba(200,164,52,0.5)' }}
/>
)}
<Icon className="h-5 w-5 shrink-0" strokeWidth={active ? 2 : 1.5} />
{!collapsed && (
<span className="truncate leading-tight font-medium" style={{ fontSize: '13px' }}>
{item.label}
</span>
)}
</Link>
)
})}
</nav>
{/* System Status — rounded block, fits inside nav with breathing room */} {/* System Status — rounded block, fits inside nav with breathing room */}
<div style={{ width: '100%', padding: '0 12px 16px 12px' }}> <div style={{ width: '100%', padding: '0 12px 16px 12px' }}>
<div <div

View file

@ -1,103 +1,108 @@
import { Server, Shield, Network } from 'lucide-react' import { useEffect, useState } from 'react'
import SparklineChart from './SparklineChart' import { Server, Plug, BookMarked } from 'lucide-react'
import ProgressRing from './ProgressRing' import ProgressRing from './ProgressRing'
import { api, type Integration, type Resource, type Bookmark } from '../lib/api'
const cardStyle: React.CSSProperties = { const cardStyle: React.CSSProperties = {
backgroundColor: 'rgba(10, 10, 12, 0.92)', backgroundColor: 'rgba(10, 10, 12, 0.55)',
backdropFilter: 'blur(14px)', backdropFilter: 'blur(10px)',
border: '1px solid rgba(200, 164, 52, 0.08)', border: '1px solid rgba(200, 164, 52, 0.1)',
borderRadius: '12px', borderRadius: '12px',
padding: '16px', padding: '16px',
boxShadow: '0 0 20px rgba(200, 164, 52, 0.03)', boxShadow: '0 0 20px rgba(200, 164, 52, 0.03)',
transition: 'border-color 0.2s ease', transition: 'border-color 0.2s ease',
} }
const labelStyle: React.CSSProperties = {
fontSize: '10px',
textTransform: 'uppercase',
letterSpacing: '1.5px',
color: '#7A7D85',
fontWeight: 500,
marginBottom: '8px',
}
export default function StatusCards() { export default function StatusCards() {
const [integrations, setIntegrations] = useState<Integration[] | null>(null)
const [resources, setResources] = useState<Resource[] | null>(null)
const [bookmarks, setBookmarks] = useState<Bookmark[] | null>(null)
useEffect(() => {
api.listIntegrations().then(({ integrations }) => setIntegrations(integrations))
api.listResources().then(({ resources }) => setResources(resources))
api.listBookmarks().then(({ bookmarks }) => setBookmarks(bookmarks))
}, [])
const connected = integrations?.filter((i) => i.status === 'connected').length ?? 0
const errored = integrations?.filter((i) => i.status === 'error').length ?? 0
const total = integrations?.length ?? 0
const healthy = resources?.filter((r) => r.status === 'healthy').length ?? 0
const warning = resources?.filter((r) => r.status === 'warning').length ?? 0
const critical = resources?.filter((r) => r.status === 'critical').length ?? 0
const resourceTotal = resources?.length ?? 0
const favorites = bookmarks?.filter((b) => b.favorite).length ?? 0
const systemLabel = errored > 0 ? 'Issues Detected' : total === 0 ? 'Not Configured' : 'All Systems Operational'
const systemPercent = total === 0 ? 0 : Math.round((connected / total) * 100)
return ( return (
<div className="grid w-full grid-cols-4 gap-5"> <div className="grid w-full grid-cols-4 gap-5">
{/* System Status */} {/* System Status */}
<div style={cardStyle} className="hover:!border-gold/20"> <div style={cardStyle} className="hover:!border-gold/20">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div> <div>
<h3 style={{ fontSize: '10px', textTransform: 'uppercase', letterSpacing: '1.5px', color: '#7A7D85', fontWeight: 500, marginBottom: '8px' }}> <h3 style={labelStyle}>System Status</h3>
System Status <p style={{ fontSize: '16px', fontWeight: 700, color: errored > 0 ? '#E74C3C' : '#2ECC71', lineHeight: 1.3 }}>{systemLabel}</p>
</h3> <p style={{ fontSize: '10px', color: '#7A7D85', marginTop: '4px' }}>{connected} of {total} integrations connected</p>
<p style={{ fontSize: '13px', fontWeight: 700, color: '#E8E6E0', lineHeight: 1.2 }}>All Systems</p>
<p style={{ fontSize: '13px', fontWeight: 700, color: '#C8A434', fontStyle: 'italic', lineHeight: 1.2 }}>Operational</p>
</div> </div>
<ProgressRing percentage={100} size={44} strokeWidth={3} /> <ProgressRing percentage={systemPercent} size={44} strokeWidth={3} />
</div>
<div style={{ marginTop: '8px', paddingTop: '8px', borderTop: '1px solid rgba(200,164,52,0.06)' }}>
<SparklineChart data={[100, 100, 99, 100, 100, 100, 98, 100, 100, 100, 100, 100]} color="#C8A434" height={20} />
<p style={{ fontSize: '9px', color: '#7A7D85', marginTop: '4px' }}>Last checked: 2m ago</p>
</div> </div>
</div> </div>
{/* Infrastructure */} {/* Infrastructure */}
<div style={cardStyle} className="hover:!border-gold/20"> <div style={cardStyle} className="hover:!border-gold/20">
<h3 style={{ fontSize: '10px', textTransform: 'uppercase', letterSpacing: '1.5px', color: '#7A7D85', fontWeight: 500, marginBottom: '8px' }}> <h3 style={labelStyle}>Infrastructure</h3>
Infrastructure
</h3>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Server size={16} style={{ color: '#C8A434' }} /> <Server size={16} style={{ color: '#C8A434' }} />
<span style={{ fontSize: '24px', fontWeight: 700, color: '#E8E6E0', lineHeight: 1 }}>24</span> <span style={{ fontSize: '24px', fontWeight: 700, color: '#E8E6E0', lineHeight: 1 }}>{resourceTotal}</span>
</div> </div>
<p style={{ fontSize: '10px', color: '#7A7D85', marginTop: '4px' }}>Total Resources</p> <p style={{ fontSize: '10px', color: '#7A7D85', marginTop: '4px' }}>Resources from connected integrations</p>
<div style={{ marginTop: '8px', paddingTop: '8px', borderTop: '1px solid rgba(200,164,52,0.06)', fontSize: '9px' }} className="flex items-center gap-2 flex-wrap"> <div style={{ marginTop: '8px', paddingTop: '8px', borderTop: '1px solid rgba(200,164,52,0.06)', fontSize: '9px' }} className="flex items-center gap-2 flex-wrap">
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<span style={{ width: '6px', height: '6px', borderRadius: '50%', backgroundColor: '#2ECC71' }} /> <span style={{ width: '6px', height: '6px', borderRadius: '50%', backgroundColor: '#2ECC71' }} />
<span style={{ color: '#7A7D85' }}>24 Running</span> <span style={{ color: '#7A7D85' }}>{healthy} Healthy</span>
</span> </span>
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<span style={{ width: '6px', height: '6px', borderRadius: '50%', backgroundColor: '#E67E22' }} /> <span style={{ width: '6px', height: '6px', borderRadius: '50%', backgroundColor: '#E67E22' }} />
<span style={{ color: '#7A7D85' }}>0 Warning</span> <span style={{ color: '#7A7D85' }}>{warning} Warning</span>
</span> </span>
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<span style={{ width: '6px', height: '6px', borderRadius: '50%', backgroundColor: '#E74C3C' }} /> <span style={{ width: '6px', height: '6px', borderRadius: '50%', backgroundColor: '#E74C3C' }} />
<span style={{ color: '#7A7D85' }}>0 Critical</span> <span style={{ color: '#7A7D85' }}>{critical} Critical</span>
</span> </span>
</div> </div>
</div> </div>
{/* Security */} {/* Integrations */}
<div style={cardStyle} className="hover:!border-gold/20"> <div style={cardStyle} className="hover:!border-gold/20">
<h3 style={{ fontSize: '10px', textTransform: 'uppercase', letterSpacing: '1.5px', color: '#7A7D85', fontWeight: 500, marginBottom: '8px' }}> <h3 style={labelStyle}>Integrations</h3>
Security
</h3>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Shield size={16} style={{ color: '#C8A434' }} /> <Plug size={16} style={{ color: '#C8A434' }} />
<span style={{ fontSize: '24px', fontWeight: 700, color: '#E8E6E0', lineHeight: 1 }}>2</span> <span style={{ fontSize: '24px', fontWeight: 700, color: '#E8E6E0', lineHeight: 1 }}>{connected}/{total}</span>
</div>
<p style={{ fontSize: '10px', color: '#7A7D85', marginTop: '4px' }}>Active Alerts</p>
<div style={{ marginTop: '8px', paddingTop: '8px', borderTop: '1px solid rgba(200,164,52,0.06)', fontSize: '9px' }} className="flex items-center gap-2 flex-wrap">
<span className="flex items-center gap-1">
<span style={{ width: '6px', height: '6px', borderRadius: '50%', backgroundColor: '#2ECC71' }} />
<span style={{ color: '#7A7D85' }}>2 Low</span>
</span>
<span className="flex items-center gap-1">
<span style={{ width: '6px', height: '6px', borderRadius: '50%', backgroundColor: '#E67E22' }} />
<span style={{ color: '#7A7D85' }}>0 Medium</span>
</span>
<span className="flex items-center gap-1">
<span style={{ width: '6px', height: '6px', borderRadius: '50%', backgroundColor: '#E74C3C' }} />
<span style={{ color: '#7A7D85' }}>0 High</span>
</span>
</div> </div>
<p style={{ fontSize: '10px', color: '#7A7D85', marginTop: '4px' }}>Connected services</p>
</div> </div>
{/* Network */} {/* Bookmarks */}
<div style={cardStyle} className="hover:!border-gold/20"> <div style={cardStyle} className="hover:!border-gold/20">
<h3 style={{ fontSize: '10px', textTransform: 'uppercase', letterSpacing: '1.5px', color: '#7A7D85', fontWeight: 500, marginBottom: '8px' }}> <h3 style={labelStyle}>Bookmarks</h3>
Network
</h3>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Network size={16} style={{ color: '#C8A434' }} /> <BookMarked size={16} style={{ color: '#C8A434' }} />
<span style={{ fontSize: '24px', fontWeight: 700, color: '#E8E6E0', lineHeight: 1 }}>98.7%</span> <span style={{ fontSize: '24px', fontWeight: 700, color: '#E8E6E0', lineHeight: 1 }}>{bookmarks?.length ?? 0}</span>
</div>
<p style={{ fontSize: '10px', color: '#7A7D85', marginTop: '4px' }}>Network Uptime</p>
<div style={{ marginTop: '8px', paddingTop: '8px', borderTop: '1px solid rgba(200,164,52,0.06)' }}>
<SparklineChart data={[99, 98, 99, 100, 99, 98, 99, 100, 99, 99, 98, 99, 100, 99, 98, 99, 100, 99, 99, 98, 99, 100, 99, 98]} color="#C8A434" height={24} filled />
</div> </div>
<p style={{ fontSize: '10px', color: '#7A7D85', marginTop: '4px' }}>{favorites} favorited</p>
</div> </div>
</div> </div>
) )

View file

@ -1,9 +1,27 @@
import { useState, useRef, useEffect } from 'react' import { useState, useRef, useEffect } from 'react'
import { useLocation } from 'react-router-dom'
import { Search, Bell, ChevronDown, User, Palette, LogOut, Shield, HelpCircle } from 'lucide-react' import { Search, Bell, ChevronDown, User, Palette, LogOut, Shield, HelpCircle } from 'lucide-react'
import { useAuth } from '../lib/AuthContext'
const pageTitles: Record<string, string> = {
'/': 'Glance',
'/infrastructure': 'Infrastructure',
'/booknest': 'BookNest',
'/terminal': 'Terminal',
'/settings': 'Settings',
}
const pageSubtitles: Record<string, string> = {
'/booknest': 'Your Digital Library',
}
export default function TopBar() { export default function TopBar() {
const { logout } = useAuth()
const [userMenuOpen, setUserMenuOpen] = useState(false) const [userMenuOpen, setUserMenuOpen] = useState(false)
const menuRef = useRef<HTMLDivElement>(null) const menuRef = useRef<HTMLDivElement>(null)
const location = useLocation()
const title = pageTitles[location.pathname] ?? 'Glance'
const subtitle = pageSubtitles[location.pathname]
useEffect(() => { useEffect(() => {
function handleClick(e: MouseEvent) { function handleClick(e: MouseEvent) {
@ -16,11 +34,21 @@ export default function TopBar() {
}, []) }, [])
return ( return (
<header className="h-14 flex items-center px-6 bg-page sticky top-0 z-40"> <header className="flex items-center px-6 sticky top-0 z-40" style={{ height: subtitle ? '72px' : '56px' }}>
{/* Page Title — pushed away from sidebar edge */} {/* Page Title — pushed away from sidebar edge */}
<h1 className="text-[18px] font-bold uppercase tracking-wide" style={{ color: '#C8A434', marginLeft: '20px' }}> <div style={{ marginLeft: '20px' }}>
Glance <h1
</h1> className="font-bold uppercase tracking-wide"
style={{ color: '#C8A434', fontSize: subtitle ? '28px' : '18px' }}
>
{title}
</h1>
{subtitle && (
<p className="text-[13px]" style={{ color: '#A8A6A0', marginTop: '2px' }}>
{subtitle}
</p>
)}
</div>
{/* Center section — Search bar */} {/* Center section — Search bar */}
<div className="flex-1 flex justify-center"> <div className="flex-1 flex justify-center">
@ -29,8 +57,8 @@ export default function TopBar() {
<input <input
type="text" type="text"
placeholder="Search resources..." placeholder="Search resources..."
className="w-[300px] h-8 rounded-full bg-card border border-border text-[12px] text-text-primary placeholder:text-text-secondary focus:outline-none focus:border-gold transition-colors" className="w-[300px] h-8 rounded-full border border-border text-[12px] text-text-primary placeholder:text-text-secondary focus:outline-none focus:border-gold transition-colors"
style={{ paddingLeft: '36px', paddingRight: '16px' }} style={{ paddingLeft: '36px', paddingRight: '16px', backgroundColor: 'rgba(255,255,255,0.04)', backdropFilter: 'blur(6px)' }}
/> />
</div> </div>
</div> </div>
@ -87,10 +115,13 @@ export default function TopBar() {
</a> </a>
</div> </div>
<div className="border-t border-border py-1"> <div className="border-t border-border py-1">
<a href="#" className="flex items-center gap-2.5 px-3 py-2 text-[12px] text-danger hover:bg-page transition-colors no-underline"> <button
onClick={logout}
className="flex w-full items-center gap-2.5 px-3 py-2 text-[12px] text-danger hover:bg-page transition-colors cursor-pointer border-none bg-transparent text-left"
>
<LogOut size={14} /> <LogOut size={14} />
<span>Sign Out</span> <span>Sign Out</span>
</a> </button>
</div> </div>
</div> </div>
)} )}

83
src/lib/AuthContext.tsx Normal file
View file

@ -0,0 +1,83 @@
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
import { api, getToken, setToken } from './api'
type AuthStatus = 'loading' | 'needs-setup' | 'enrolling' | 'logged-out' | 'logged-in'
interface AuthUser {
id: number
username: string
display_name: string | null
email: string | null
avatar_data_url: string | null
}
interface AuthContextValue {
status: AuthStatus
user: AuthUser | null
login: (username: string, password: string) => Promise<void>
completeSetup: (username: string, password: string) => Promise<void>
finishEnrollment: () => Promise<void>
logout: () => void
}
const AuthContext = createContext<AuthContextValue | null>(null)
export function AuthProvider({ children }: { children: ReactNode }) {
const [status, setStatus] = useState<AuthStatus>('loading')
const [user, setUser] = useState<AuthUser | null>(null)
async function refresh() {
if (getToken()) {
try {
const { user } = await api.me()
setUser(user)
setStatus('logged-in')
return
} catch {
setToken(null)
}
}
const { needsSetup } = await api.getSetupStatus()
setStatus(needsSetup ? 'needs-setup' : 'logged-out')
}
useEffect(() => {
refresh()
}, [])
async function login(username: string, password: string) {
const { token } = await api.login(username, password)
setToken(token)
await refresh()
}
async function completeSetup(username: string, password: string) {
const { token } = await api.setup(username, password)
setToken(token)
const { user } = await api.me()
setUser(user)
setStatus('enrolling')
}
async function finishEnrollment() {
await refresh()
}
function logout() {
setToken(null)
setUser(null)
setStatus('logged-out')
}
return (
<AuthContext.Provider value={{ status, user, login, completeSetup, finishEnrollment, logout }}>
{children}
</AuthContext.Provider>
)
}
export function useAuth() {
const ctx = useContext(AuthContext)
if (!ctx) throw new Error('useAuth must be used within AuthProvider')
return ctx
}

118
src/lib/api.ts Normal file
View file

@ -0,0 +1,118 @@
const TOKEN_KEY = 'archnest_token'
export function getToken(): string | null {
return localStorage.getItem(TOKEN_KEY)
}
export function setToken(token: string | null) {
if (token) localStorage.setItem(TOKEN_KEY, token)
else localStorage.removeItem(TOKEN_KEY)
}
export class ApiError extends Error {
status: number
constructor(status: number, message: string) {
super(message)
this.status = status
}
}
export async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
const token = getToken()
const headers: Record<string, string> = {
...(options.body ? { 'Content-Type': 'application/json' } : {}),
...(options.headers as Record<string, string> | undefined),
}
if (token) headers.Authorization = `Bearer ${token}`
const res = await fetch(`/api${path}`, { ...options, headers })
if (!res.ok) {
let message = res.statusText
try {
const body = await res.json()
message = body.error ?? message
} catch {
// ignore non-JSON error bodies
}
throw new ApiError(res.status, message)
}
if (res.status === 204) return undefined as T
return res.json() as Promise<T>
}
export const api = {
getSetupStatus: () => apiFetch<{ needsSetup: boolean }>('/system/setup-status'),
setup: (username: string, password: string) =>
apiFetch<{ token: string }>('/setup', { method: 'POST', body: JSON.stringify({ username, password }) }),
login: (username: string, password: string) =>
apiFetch<{ token: string }>('/auth/login', { method: 'POST', body: JSON.stringify({ username, password }) }),
me: () => apiFetch<{ user: { id: number; username: string; display_name: string | null; email: string | null; avatar_data_url: string | null } }>('/auth/me'),
listIntegrations: () => apiFetch<{ integrations: Integration[] }>('/integrations'),
createIntegration: (data: { type: string; name: string; config?: Record<string, string>; secrets?: Record<string, string> }) =>
apiFetch<{ integration: Integration }>('/integrations', { method: 'POST', body: JSON.stringify(data) }),
updateIntegration: (id: number, data: Partial<{ name: string; config: Record<string, string>; secrets: Record<string, string> }>) =>
apiFetch<{ integration: Integration }>(`/integrations/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
deleteIntegration: (id: number) => apiFetch<void>(`/integrations/${id}`, { method: 'DELETE' }),
testIntegration: (id: number) => apiFetch<{ ok: boolean; message: string }>(`/integrations/${id}/test`, { method: 'POST' }),
listBookmarks: () => apiFetch<{ bookmarks: Bookmark[] }>('/bookmarks'),
listBookmarkCategories: () => apiFetch<{ categories: BookmarkCategory[] }>('/bookmarks/categories'),
createBookmarkCategory: (data: { name: string; icon?: string; sortOrder?: number }) =>
apiFetch<{ id: number }>('/bookmarks/categories', { method: 'POST', body: JSON.stringify(data) }),
createBookmark: (data: { categoryId?: number | null; title: string; url: string; icon?: string; favorite?: boolean }) =>
apiFetch<{ id: number }>('/bookmarks', { method: 'POST', body: JSON.stringify(data) }),
updateBookmark: (id: number, data: Partial<{ categoryId: number | null; title: string; url: string; icon: string; favorite: boolean }>) =>
apiFetch<{ ok: boolean }>(`/bookmarks/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
deleteBookmark: (id: number) => apiFetch<void>(`/bookmarks/${id}`, { method: 'DELETE' }),
listEvents: (limit = 20) => apiFetch<{ events: Event[] }>(`/events?limit=${limit}`),
listResources: () => apiFetch<{ resources: Resource[] }>('/integrations/resources'),
}
export interface Integration {
id: number
type: string
name: string
enabled: boolean
status: string
config: Record<string, string>
lastCheckedAt: string | null
createdAt: string
}
export interface Bookmark {
id: number
category_id: number | null
title: string
url: string
icon: string | null
favorite: number
status: string
last_checked_at: string | null
created_at: string
}
export interface BookmarkCategory {
id: number
name: string
icon: string | null
sort_order: number
}
export interface Event {
id: number
type: string
title: string
source: string | null
created_at: string
}
export interface Resource {
name: string
status: 'healthy' | 'warning' | 'critical' | 'unknown'
detail?: string
integration: string
}

View file

@ -1,10 +1,16 @@
import { StrictMode } from 'react' import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import './index.css' import './index.css'
import App from './App.tsx' import App from './App.tsx'
import { AuthProvider } from './lib/AuthContext'
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<App /> <BrowserRouter>
<AuthProvider>
<App />
</AuthProvider>
</BrowserRouter>
</StrictMode>, </StrictMode>,
) )

552
src/pages/BookNest.tsx Normal file
View file

@ -0,0 +1,552 @@
import { useEffect, useMemo, useState } from 'react'
import { PieChart, Pie, Cell, ResponsiveContainer } from 'recharts'
import {
Link2,
FolderOpen,
Star,
Plus,
Server,
Bot,
Cloud,
Network,
GitBranch,
GitFork,
Box,
Terminal as TerminalIcon,
Database,
Shield,
Workflow,
FileCode,
Router,
Wifi,
BookOpen,
GraduationCap,
SquarePlay,
Briefcase,
Wallet,
CreditCard,
PiggyBank,
TrendingUp,
Calendar,
Mail,
Image,
HardDrive,
FileText,
Plane,
Sparkles,
MessageSquare,
Zap,
Globe2,
Container,
X,
type LucideIcon,
} from 'lucide-react'
import { api, ApiError, type Bookmark, type BookmarkCategory } from '../lib/api'
const ICONS: Record<string, LucideIcon> = {
server: Server,
bot: Bot,
cloud: Cloud,
network: Network,
gitbranch: GitBranch,
gitfork: GitFork,
box: Box,
terminal: TerminalIcon,
database: Database,
shield: Shield,
workflow: Workflow,
filecode: FileCode,
router: Router,
wifi: Wifi,
bookopen: BookOpen,
graduationcap: GraduationCap,
squareplay: SquarePlay,
briefcase: Briefcase,
wallet: Wallet,
creditcard: CreditCard,
piggybank: PiggyBank,
trendingup: TrendingUp,
calendar: Calendar,
mail: Mail,
image: Image,
harddrive: HardDrive,
filetext: FileText,
plane: Plane,
sparkles: Sparkles,
messagesquare: MessageSquare,
zap: Zap,
globe2: Globe2,
container: Container,
link2: Link2,
}
function resolveIcon(name: string | null | undefined): LucideIcon {
if (!name) return Link2
return ICONS[name.toLowerCase()] ?? Link2
}
const statusColors: Record<string, string> = {
online: '#2ECC71',
warning: '#E67E22',
offline: '#E74C3C',
unknown: '#7A7D85',
}
const categoryPalette = ['#C8A434', '#3B82F6', '#2ECC71', '#E67E22', '#7A7D85', '#8B5E3C', '#9B59B6', '#E74C3C']
const cardBase: React.CSSProperties = {
backgroundColor: 'rgba(10, 10, 12, 0.92)',
border: '1px solid rgba(200, 164, 52, 0.08)',
borderRadius: '12px',
padding: '18px',
boxShadow: '0 0 20px rgba(200, 164, 52, 0.03)',
transition: 'border-color 0.2s ease',
position: 'relative',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
}
const sectionTitle: React.CSSProperties = {
fontSize: '11px',
textTransform: 'uppercase',
letterSpacing: '1.5px',
color: '#7A7D85',
fontWeight: 500,
marginBottom: '14px',
}
const inputStyle: React.CSSProperties = {
backgroundColor: 'rgba(255,255,255,0.03)',
border: '1px solid rgba(200,164,52,0.12)',
borderRadius: '8px',
padding: '9px 12px',
fontSize: '13px',
color: '#E8E6E0',
width: '100%',
outline: 'none',
}
function Donut({ data, centerLabel }: { data: { name: string; value: number; color: string }[]; centerLabel?: string }) {
const hasData = data.some((d) => d.value > 0)
return (
<div className="flex items-center gap-3">
<div className="relative" style={{ width: '88px', height: '88px', flexShrink: 0 }}>
{hasData && (
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie data={data} dataKey="value" innerRadius={28} outerRadius={42} paddingAngle={2} isAnimationActive animationDuration={1000}>
{data.map((entry) => (
<Cell key={entry.name} fill={entry.color} stroke="none" />
))}
</Pie>
</PieChart>
</ResponsiveContainer>
)}
{centerLabel && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<span style={{ fontSize: '14px', fontWeight: 700, color: '#E8E6E0' }}>{centerLabel}</span>
</div>
)}
</div>
<div className="flex flex-col gap-1.5">
{data.map((entry) => (
<div key={entry.name} className="flex items-center gap-1.5">
<span style={{ width: '7px', height: '7px', borderRadius: '50%', backgroundColor: entry.color, flexShrink: 0 }} />
<span style={{ fontSize: '11px', color: '#E8E6E0' }}>{entry.name}</span>
<span style={{ fontSize: '10px', color: '#7A7D85' }}>{entry.value}</span>
</div>
))}
</div>
</div>
)
}
function LinkRow({ bookmark, onToggleFavorite }: { bookmark: Bookmark; onToggleFavorite: () => void }) {
const Icon = resolveIcon(bookmark.icon)
return (
<div className="flex items-center justify-between group" style={{ padding: '6px 0' }}>
<a
href={bookmark.url}
target="_blank"
rel="noreferrer"
className="flex items-center gap-2.5 cursor-pointer"
style={{ minWidth: 0, textDecoration: 'none' }}
>
<Icon size={16} style={{ color: '#C8A434', flexShrink: 0 }} />
<span style={{ fontSize: '13px', color: '#E8E6E0', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{bookmark.title}</span>
</a>
<button
onClick={onToggleFavorite}
className="cursor-pointer bg-transparent border-none p-0 flex items-center"
style={{ flexShrink: 0 }}
>
<Star size={14} fill={bookmark.favorite ? '#C8A434' : 'none'} style={{ color: bookmark.favorite ? '#C8A434' : '#4A4D55' }} />
</button>
</div>
)
}
function AddBookmarkModal({
categories,
onClose,
onCreated,
onCategoryCreated,
}: {
categories: BookmarkCategory[]
onClose: () => void
onCreated: (bookmark: Bookmark) => void
onCategoryCreated: (category: BookmarkCategory) => void
}) {
const [title, setTitle] = useState('')
const [url, setUrl] = useState('')
const [icon, setIcon] = useState('link2')
const [categoryId, setCategoryId] = useState<number | 'new' | ''>('')
const [newCategoryName, setNewCategoryName] = useState('')
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError('')
if (!title.trim() || !url.trim()) {
setError('Title and URL are required')
return
}
setBusy(true)
try {
let resolvedCategoryId: number | null = null
if (categoryId === 'new') {
if (!newCategoryName.trim()) {
setError('Enter a name for the new category')
setBusy(false)
return
}
const { id } = await api.createBookmarkCategory({ name: newCategoryName.trim() })
resolvedCategoryId = id
onCategoryCreated({ id, name: newCategoryName.trim(), icon: null, sort_order: 0 })
} else if (categoryId !== '') {
resolvedCategoryId = categoryId
}
const { id } = await api.createBookmark({
title: title.trim(),
url: url.trim(),
icon,
categoryId: resolvedCategoryId,
})
onCreated({
id,
category_id: resolvedCategoryId,
title: title.trim(),
url: url.trim(),
icon,
favorite: 0,
status: 'unknown',
last_checked_at: null,
created_at: new Date().toISOString(),
})
onClose()
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Failed to create bookmark')
} finally {
setBusy(false)
}
}
return (
<div
className="fixed inset-0 flex items-center justify-center"
style={{ backgroundColor: 'rgba(0,0,0,0.6)', zIndex: 50 }}
onClick={onClose}
>
<form
onClick={(e) => e.stopPropagation()}
onSubmit={handleSubmit}
style={{ ...cardBase, width: '420px', padding: '24px', gap: '14px' }}
>
<div className="flex items-center justify-between" style={{ marginBottom: '4px' }}>
<h3 style={{ fontSize: '14px', color: '#E8E6E0', fontWeight: 600 }}>Add Bookmark</h3>
<button type="button" onClick={onClose} className="cursor-pointer bg-transparent border-none p-0">
<X size={16} style={{ color: '#7A7D85' }} />
</button>
</div>
<div className="flex flex-col gap-1.5">
<label style={{ fontSize: '11px', color: '#7A7D85' }}>Title</label>
<input style={inputStyle} value={title} onChange={(e) => setTitle(e.target.value)} placeholder="e.g. Proxmox" />
</div>
<div className="flex flex-col gap-1.5">
<label style={{ fontSize: '11px', color: '#7A7D85' }}>URL</label>
<input style={inputStyle} value={url} onChange={(e) => setUrl(e.target.value)} placeholder="https://..." />
</div>
<div className="flex flex-col gap-1.5">
<label style={{ fontSize: '11px', color: '#7A7D85' }}>Icon</label>
<select style={inputStyle} value={icon} onChange={(e) => setIcon(e.target.value)}>
{Object.keys(ICONS).map((key) => (
<option key={key} value={key}>
{key}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1.5">
<label style={{ fontSize: '11px', color: '#7A7D85' }}>Category</label>
<select
style={inputStyle}
value={categoryId}
onChange={(e) => setCategoryId(e.target.value === 'new' ? 'new' : e.target.value === '' ? '' : Number(e.target.value))}
>
<option value="">Uncategorized</option>
{categories.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
<option value="new">+ New category</option>
</select>
</div>
{categoryId === 'new' && (
<div className="flex flex-col gap-1.5">
<label style={{ fontSize: '11px', color: '#7A7D85' }}>New category name</label>
<input style={inputStyle} value={newCategoryName} onChange={(e) => setNewCategoryName(e.target.value)} placeholder="e.g. Monitoring" />
</div>
)}
{error && <p style={{ fontSize: '12px', color: '#E74C3C' }}>{error}</p>}
<button
type="submit"
disabled={busy}
className="cursor-pointer transition-colors"
style={{
fontSize: '12px',
fontWeight: 600,
color: '#0A0B0D',
backgroundColor: '#C8A434',
border: 'none',
borderRadius: '8px',
padding: '10px 14px',
opacity: busy ? 0.6 : 1,
marginTop: '4px',
}}
>
{busy ? 'Saving…' : 'Add Bookmark'}
</button>
</form>
</div>
)
}
export default function BookNest() {
const [bookmarks, setBookmarks] = useState<Bookmark[] | null>(null)
const [categories, setCategories] = useState<BookmarkCategory[]>([])
const [showAddModal, setShowAddModal] = useState(false)
useEffect(() => {
Promise.all([api.listBookmarks(), api.listBookmarkCategories()]).then(([b, c]) => {
setBookmarks(b.bookmarks)
setCategories(c.categories)
})
}, [])
async function toggleFavorite(bookmark: Bookmark) {
const next = bookmark.favorite ? false : true
setBookmarks((prev) => prev?.map((b) => (b.id === bookmark.id ? { ...b, favorite: next ? 1 : 0 } : b)) ?? prev)
try {
await api.updateBookmark(bookmark.id, { favorite: next })
} catch {
setBookmarks((prev) => prev?.map((b) => (b.id === bookmark.id ? { ...b, favorite: bookmark.favorite } : b)) ?? prev)
}
}
const groups = useMemo(() => {
if (!bookmarks) return []
const byCategory = new Map<number | null, Bookmark[]>()
for (const b of bookmarks) {
const key = b.category_id
if (!byCategory.has(key)) byCategory.set(key, [])
byCategory.get(key)!.push(b)
}
const named = categories
.map((c) => ({ title: c.name, links: byCategory.get(c.id) ?? [] }))
.filter((g) => g.links.length > 0)
const uncategorized = byCategory.get(null) ?? []
return uncategorized.length > 0 ? [...named, { title: 'Uncategorized', links: uncategorized }] : named
}, [bookmarks, categories])
const favorites = useMemo(() => (bookmarks ?? []).filter((b) => b.favorite), [bookmarks])
const recentlyAdded = useMemo(
() => [...(bookmarks ?? [])].sort((a, b) => (a.created_at < b.created_at ? 1 : -1)).slice(0, 5),
[bookmarks]
)
const quickAccess = useMemo(
() =>
[...groups]
.sort((a, b) => b.links.length - a.links.length)
.slice(0, 5)
.map((g) => ({ label: g.title, icons: g.links.slice(0, 5).map((l) => resolveIcon(l.icon)), count: g.links.length })),
[groups]
)
const linkHealthData = useMemo(() => {
const counts: Record<string, number> = { online: 0, warning: 0, offline: 0, unknown: 0 }
for (const b of bookmarks ?? []) counts[b.status in counts ? b.status : 'unknown']++
return Object.entries(counts)
.filter(([, value]) => value > 0)
.map(([name, value]) => ({ name: name.charAt(0).toUpperCase() + name.slice(1), value, color: statusColors[name] }))
}, [bookmarks])
const categoryBreakdownData = useMemo(
() => groups.map((g, i) => ({ name: g.title, value: g.links.length, color: categoryPalette[i % categoryPalette.length] })),
[groups]
)
if (!bookmarks) {
return (
<div style={cardBase}>
<p style={{ fontSize: '13px', color: '#7A7D85' }}>Loading bookmarks</p>
</div>
)
}
return (
<div className="flex h-full w-full flex-col overflow-y-auto" style={{ scrollbarWidth: 'none' }}>
{showAddModal && (
<AddBookmarkModal
categories={categories}
onClose={() => setShowAddModal(false)}
onCreated={(b) => setBookmarks((prev) => [b, ...(prev ?? [])])}
onCategoryCreated={(c) => setCategories((prev) => [...prev, c])}
/>
)}
<div className="grid w-full gap-5" style={{ gridTemplateColumns: '3fr 1fr', gridTemplateRows: 'auto 1fr' }}>
{/* Page stats — sits directly under the hero title/subtitle, like the blueprint */}
<div className="flex items-center shrink-0" style={{ gridColumn: 1, gridRow: 1, marginTop: '8px' }}>
<div className="flex items-center gap-5" style={{ fontSize: '12px', color: '#7A7D85' }}>
<span className="flex items-center gap-1.5"><Link2 size={13} style={{ color: '#C8A434' }} /> <strong style={{ color: '#E8E6E0' }}>{bookmarks.length}</strong> Links</span>
<span className="flex items-center gap-1.5"><FolderOpen size={13} style={{ color: '#C8A434' }} /> <strong style={{ color: '#E8E6E0' }}>{categories.length}</strong> Categories</span>
<span className="flex items-center gap-1.5"><Star size={13} style={{ color: '#C8A434' }} /> <strong style={{ color: '#E8E6E0' }}>{favorites.length}</strong> Favorites</span>
</div>
</div>
{/* Main column */}
<div className="flex flex-col gap-5" style={{ gridColumn: 1, gridRow: 2, marginTop: '14px' }}>
{/* Quick Access */}
<div className="flex items-center justify-between">
<h3 style={{ ...sectionTitle, marginBottom: 0, color: '#C8A434' }}>Quick Access</h3>
<button
onClick={() => setShowAddModal(true)}
className="flex items-center gap-2 cursor-pointer transition-colors whitespace-nowrap"
style={{
fontSize: '12px',
fontWeight: 600,
color: '#0A0B0D',
backgroundColor: '#C8A434',
border: 'none',
borderRadius: '8px',
padding: '8px 14px',
boxShadow: '0 0 14px rgba(200,164,52,0.2)',
}}
>
<Plus size={14} />
Add Bookmark
</button>
</div>
{quickAccess.length > 0 ? (
<div className="grid grid-cols-5 gap-4">
{quickAccess.map((qa) => (
<div key={qa.label} style={{ ...cardBase, padding: '14px' }} className="hover:!border-gold/20">
<span style={{ fontSize: '11px', color: '#E8E6E0', fontWeight: 600, marginBottom: '10px' }}>{qa.label}</span>
<div className="flex items-center gap-1.5" style={{ marginBottom: '8px' }}>
{qa.icons.map((Icon, i) => (
<div key={i} style={{ width: '22px', height: '22px', borderRadius: '6px', backgroundColor: 'rgba(200,164,52,0.08)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Icon size={12} style={{ color: '#C8A434' }} />
</div>
))}
</div>
<span style={{ fontSize: '10px', color: '#7A7D85' }}>{qa.count} links</span>
</div>
))}
</div>
) : (
<div style={cardBase}>
<p style={{ fontSize: '13px', color: '#7A7D85' }}>No bookmarks yet add your first one to get started.</p>
</div>
)}
{/* Bookmark groups grid */}
{groups.length > 0 && (
<div className="grid grid-cols-4 gap-4">
{groups.map((group) => (
<div key={group.title} style={cardBase} className="hover:!border-gold/15">
<h3 style={sectionTitle}>{group.title}</h3>
<div className="flex flex-col" style={{ gap: '2px' }}>
{group.links.map((link) => (
<LinkRow key={link.id} bookmark={link} onToggleFavorite={() => toggleFavorite(link)} />
))}
</div>
</div>
))}
</div>
)}
</div>
{/* Right sidebar — spans both rows so Favorites reaches up near the hero, and stretches to match the main column's full height */}
<div className="flex h-full flex-col gap-5" style={{ gridColumn: 2, gridRow: '1 / span 2' }}>
<div style={{ ...cardBase, padding: '22px', flex: '1.4 0 auto' }}>
<h3 style={{ ...sectionTitle, fontSize: '12px', marginBottom: '18px' }}>Favorites</h3>
<div className="flex flex-col" style={{ gap: '4px' }}>
{favorites.length > 0 ? (
favorites.map((f) => {
const Icon = resolveIcon(f.icon)
return (
<a key={f.id} href={f.url} target="_blank" rel="noreferrer" className="flex items-center gap-3" style={{ padding: '8px 0', textDecoration: 'none' }}>
<Icon size={17} style={{ color: '#C8A434' }} />
<span style={{ fontSize: '13px', color: '#E8E6E0' }}>{f.title}</span>
</a>
)
})
) : (
<p style={{ fontSize: '12px', color: '#7A7D85' }}>No favorites yet</p>
)}
</div>
</div>
<div style={{ ...cardBase, flex: '1 0 auto' }}>
<h3 style={sectionTitle}>Recently Added</h3>
<div className="flex flex-col" style={{ gap: '8px' }}>
{recentlyAdded.length > 0 ? (
recentlyAdded.map((r) => (
<div key={r.id} className="flex items-center justify-between">
<span style={{ fontSize: '12px', color: '#E8E6E0' }}>{r.title}</span>
<span style={{ fontSize: '10px', color: '#7A7D85' }}>{new Date(r.created_at).toLocaleDateString()}</span>
</div>
))
) : (
<p style={{ fontSize: '12px', color: '#7A7D85' }}>Nothing yet</p>
)}
</div>
</div>
<div style={{ ...cardBase, flex: '1 0 auto' }}>
<h3 style={sectionTitle}>Link Health</h3>
<Donut data={linkHealthData} centerLabel={String(bookmarks.length)} />
</div>
<div className="justify-center" style={{ ...cardBase, flex: '1 0 auto', display: 'flex' }}>
<div className="flex flex-col" style={{ width: '100%' }}>
<h3 style={sectionTitle}>Category Breakdown</h3>
<Donut data={categoryBreakdownData} />
</div>
</div>
</div>
</div>
</div>
)
}

275
src/pages/Enrollment.tsx Normal file
View file

@ -0,0 +1,275 @@
import { useState } from 'react'
import { Server, Container, Network, Cloud, CloudCog, Activity, CloudSun, Check, ArrowRight } from 'lucide-react'
import { useAuth } from '../lib/AuthContext'
import { api, ApiError } from '../lib/api'
const cardBase: React.CSSProperties = {
backgroundColor: 'rgba(10, 10, 12, 0.92)',
border: '1px solid rgba(200, 164, 52, 0.12)',
borderRadius: '14px',
padding: '32px',
}
const fieldLabel: React.CSSProperties = {
fontSize: '11px',
color: '#7A7D85',
marginBottom: '6px',
display: 'block',
}
const fieldInput: React.CSSProperties = {
width: '100%',
height: '36px',
borderRadius: '8px',
border: '1px solid rgba(200,164,52,0.12)',
backgroundColor: 'rgba(255,255,255,0.03)',
color: '#E8E6E0',
fontSize: '13px',
padding: '0 12px',
outline: 'none',
}
const goldButton: React.CSSProperties = {
height: '38px',
borderRadius: '8px',
border: 'none',
fontSize: '13px',
fontWeight: 600,
color: '#0A0B0D',
backgroundColor: '#C8A434',
boxShadow: '0 0 14px rgba(200,164,52,0.2)',
padding: '0 20px',
}
const integrationOptions = [
{ type: 'proxmox', name: 'Proxmox', icon: Server },
{ type: 'docker', name: 'Docker', icon: Container },
{ type: 'netbird', name: 'NetBird', icon: Network },
{ type: 'cloudflare', name: 'Cloudflare', icon: Cloud },
{ type: 'aws', name: 'AWS', icon: CloudCog },
{ type: 'uptime_kuma', name: 'Uptime Kuma', icon: Activity },
{ type: 'weather', name: 'Weather API', icon: CloudSun },
] as const
export default function Enrollment() {
const { status, completeSetup, finishEnrollment } = useAuth()
const step = status === 'enrolling' ? 'connect' : 'account'
return (
<div className="flex h-screen w-screen items-center justify-center bg-page">
<div style={{ width: step === 'account' ? '380px' : '640px' }}>
{step === 'account' ? (
<AccountStep completeSetup={completeSetup} />
) : (
<ConnectStep onFinish={finishEnrollment} />
)}
</div>
</div>
)
}
function AccountStep({
completeSetup,
}: {
completeSetup: (username: string, password: string) => Promise<void>
}) {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [confirm, setConfirm] = useState('')
const [error, setError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError(null)
if (password !== confirm) {
setError('Passwords do not match')
return
}
if (password.length < 8) {
setError('Password must be at least 8 characters')
return
}
setSubmitting(true)
try {
await completeSetup(username, password)
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Setup failed')
} finally {
setSubmitting(false)
}
}
return (
<form onSubmit={handleSubmit} style={cardBase}>
<h1
style={{
fontSize: '20px',
fontWeight: 700,
letterSpacing: '1px',
textTransform: 'uppercase',
color: '#C8A434',
marginBottom: '4px',
}}
>
Welcome to ArchNest
</h1>
<p style={{ fontSize: '12px', color: '#7A7D85', marginBottom: '24px' }}>
Create your admin account to get started
</p>
<label style={fieldLabel}>Username</label>
<input style={fieldInput} value={username} onChange={(e) => setUsername(e.target.value)} autoFocus required />
<label style={{ ...fieldLabel, marginTop: '14px' }}>Password</label>
<input style={fieldInput} type="password" value={password} onChange={(e) => setPassword(e.target.value)} required />
<label style={{ ...fieldLabel, marginTop: '14px' }}>Confirm Password</label>
<input style={fieldInput} type="password" value={confirm} onChange={(e) => setConfirm(e.target.value)} required />
{error && <p style={{ fontSize: '12px', color: '#E74C3C', marginTop: '12px' }}>{error}</p>}
<button type="submit" disabled={submitting} style={{ ...goldButton, width: '100%', marginTop: '22px', opacity: submitting ? 0.6 : 1 }}>
{submitting ? 'Creating account…' : 'Create Account'}
</button>
</form>
)
}
function ConnectStep({ onFinish }: { onFinish: () => Promise<void> }) {
const [connected, setConnected] = useState<Set<string>>(new Set())
const [active, setActive] = useState<(typeof integrationOptions)[number] | null>(null)
return (
<div style={cardBase}>
<h1
style={{
fontSize: '18px',
fontWeight: 700,
letterSpacing: '1px',
textTransform: 'uppercase',
color: '#C8A434',
marginBottom: '4px',
}}
>
Connect Your Services
</h1>
<p style={{ fontSize: '12px', color: '#7A7D85', marginBottom: '24px' }}>
Add the integrations you want ArchNest to monitor. You can also do this later from Settings.
</p>
{active ? (
<ConnectForm
option={active}
onConnected={() => {
setConnected((prev) => new Set(prev).add(active.type))
setActive(null)
}}
onCancel={() => setActive(null)}
/>
) : (
<div className="grid grid-cols-3 gap-3">
{integrationOptions.map((opt) => {
const Icon = opt.icon
const isDone = connected.has(opt.type)
return (
<button
key={opt.type}
onClick={() => setActive(opt)}
className="flex flex-col items-center gap-2 cursor-pointer transition-colors"
style={{
border: `1px solid ${isDone ? 'rgba(46,204,113,0.4)' : 'rgba(200,164,52,0.12)'}`,
borderRadius: '10px',
padding: '18px 8px',
backgroundColor: 'rgba(255,255,255,0.02)',
}}
>
{isDone ? <Check size={20} color="#2ECC71" /> : <Icon size={20} color="#C8A434" />}
<span style={{ fontSize: '11px', color: '#E8E6E0' }}>{opt.name}</span>
</button>
)
})}
</div>
)}
{!active && (
<div className="flex justify-end" style={{ marginTop: '24px' }}>
<button
onClick={() => onFinish()}
className="cursor-pointer"
style={{ ...goldButton, display: 'inline-flex', alignItems: 'center', gap: '8px' }}
>
{connected.size > 0 ? 'Finish' : 'Skip for now'} <ArrowRight size={14} />
</button>
</div>
)}
</div>
)
}
function ConnectForm({
option,
onConnected,
onCancel,
}: {
option: (typeof integrationOptions)[number]
onConnected: () => void
onCancel: () => void
}) {
const [baseUrl, setBaseUrl] = useState('')
const [apiKey, setApiKey] = useState('')
const [error, setError] = useState<string | null>(null)
const [testResult, setTestResult] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError(null)
setTestResult(null)
setSubmitting(true)
try {
const { integration } = await api.createIntegration({
type: option.type,
name: option.name,
config: baseUrl ? { baseUrl } : {},
secrets: apiKey ? { apiKey } : {},
})
const result = await api.testIntegration(integration.id)
setTestResult(result.message)
if (result.ok) onConnected()
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Failed to add integration')
} finally {
setSubmitting(false)
}
}
return (
<form onSubmit={handleSubmit}>
<p style={{ fontSize: '13px', color: '#E8E6E0', marginBottom: '16px', fontWeight: 600 }}>{option.name}</p>
<label style={fieldLabel}>Base URL</label>
<input style={fieldInput} value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} placeholder="https://..." />
<label style={{ ...fieldLabel, marginTop: '14px' }}>API Key (optional)</label>
<input style={fieldInput} type="password" value={apiKey} onChange={(e) => setApiKey(e.target.value)} />
{error && <p style={{ fontSize: '12px', color: '#E74C3C', marginTop: '12px' }}>{error}</p>}
{testResult && <p style={{ fontSize: '12px', color: '#7A7D85', marginTop: '12px' }}>{testResult}</p>}
<div className="flex gap-3" style={{ marginTop: '20px' }}>
<button type="submit" disabled={submitting} style={{ ...goldButton, opacity: submitting ? 0.6 : 1 }}>
{submitting ? 'Connecting…' : 'Connect'}
</button>
<button
type="button"
onClick={onCancel}
className="cursor-pointer"
style={{ height: '38px', borderRadius: '8px', border: '1px solid rgba(255,255,255,0.12)', background: 'transparent', color: '#7A7D85', fontSize: '13px', padding: '0 16px' }}
>
Cancel
</button>
</div>
</form>
)
}

51
src/pages/Glance.tsx Normal file
View file

@ -0,0 +1,51 @@
import StatusCards from '../components/StatusCards'
import MiddleRow from '../components/MiddleRow'
import BottomRow from '../components/BottomRow'
export default function Glance() {
return (
<>
{/* Hero + KPI overlap — KPI bottom aligns with banner bottom */}
<div className="relative w-full shrink-0 overflow-hidden" style={{ height: '240px' }}>
<img
src="/archnest-hero-banner.png"
alt="ArchNest Banner"
className="absolute inset-0 h-full w-full"
style={{
objectFit: 'cover',
objectPosition: 'center 35%',
maskImage: 'linear-gradient(to bottom, black 0%, black 45%, transparent 95%)',
WebkitMaskImage: 'linear-gradient(to bottom, black 0%, black 45%, transparent 95%)',
}}
onError={(e) => {
const target = e.currentTarget
target.style.display = 'none'
target.parentElement!.classList.add('bg-card')
}}
/>
{/* Side vignette so the rectangular image blends into the page edges */}
<div
className="pointer-events-none absolute inset-0"
style={{
background:
'radial-gradient(ellipse 75% 100% at center, transparent 55%, var(--color-page) 100%)',
}}
/>
{/* KPI cards positioned so their bottom edge aligns with banner bottom */}
<div className="absolute bottom-0 left-0 right-0 z-10 px-4">
<StatusCards />
</div>
</div>
{/* Middle Row — stretches to fill available vertical space */}
<div className="min-h-0 flex-1">
<MiddleRow />
</div>
{/* Bottom Row — anchored to the bottom */}
<div className="shrink-0">
<BottomRow />
</div>
</>
)
}

View file

@ -0,0 +1,350 @@
import { useEffect, useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { PieChart, Pie, Cell, ResponsiveContainer } from 'recharts'
import { Plus, Server, Activity, AlertTriangle, CircleCheck } from 'lucide-react'
import { api, type Resource, type Integration, type Event } from '../lib/api'
const subTabs = ['Overview']
const futureSubTabs = ['Network']
const cardBase: React.CSSProperties = {
backgroundColor: 'rgba(10, 10, 12, 0.92)',
border: '1px solid rgba(200, 164, 52, 0.08)',
borderRadius: '12px',
padding: '20px',
boxShadow: '0 0 20px rgba(200, 164, 52, 0.03)',
transition: 'border-color 0.2s ease',
position: 'relative',
overflow: 'hidden',
height: '100%',
display: 'flex',
flexDirection: 'column',
}
const sectionTitle: React.CSSProperties = {
fontSize: '11px',
textTransform: 'uppercase',
letterSpacing: '1.5px',
color: '#7A7D85',
fontWeight: 500,
marginBottom: '16px',
}
function framedCard(bgUrl: string): React.CSSProperties {
return {
backgroundImage: `url(${bgUrl})`,
backgroundSize: '100% 100%',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
position: 'relative',
overflow: 'hidden',
height: '100%',
display: 'flex',
flexDirection: 'column',
padding: '20px 20px 64px 20px',
}
}
const cardVignette: React.CSSProperties = {
position: 'absolute',
inset: 0,
pointerEvents: 'none',
background: 'radial-gradient(ellipse closest-side at center, transparent 70%, var(--color-page) 100%)',
}
const cardDim: React.CSSProperties = {
position: 'absolute',
inset: 0,
pointerEvents: 'none',
backgroundColor: 'rgba(8, 8, 10, 0.45)',
}
const nodeStatusColor: Record<string, string> = {
healthy: '#2ECC71',
warning: '#E67E22',
critical: '#E74C3C',
unknown: '#7A7D85',
}
const integrationPalette = ['#C8A434', '#E67E22', '#2ECC71', '#7A7D85', '#3B82F6', '#8B5E3C']
function Donut({ data, centerLabel }: { data: { name: string; value: number; color: string }[]; centerLabel?: string }) {
const hasData = data.some((d) => d.value > 0)
return (
<div className="flex flex-1 items-center gap-4">
<div className="relative" style={{ width: '120px', height: '120px', flexShrink: 0 }}>
{hasData && (
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie data={data} dataKey="value" innerRadius={38} outerRadius={56} paddingAngle={2} isAnimationActive animationDuration={1000}>
{data.map((entry) => (
<Cell key={entry.name} fill={entry.color} stroke="none" />
))}
</Pie>
</PieChart>
</ResponsiveContainer>
)}
{centerLabel && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<span style={{ fontSize: '18px', fontWeight: 700, color: '#E8E6E0' }}>{centerLabel}</span>
</div>
)}
</div>
<div className="flex flex-col gap-2">
{data.map((entry) => (
<div key={entry.name} className="flex items-center gap-2">
<span style={{ width: '8px', height: '8px', borderRadius: '50%', backgroundColor: entry.color, flexShrink: 0 }} />
<span style={{ fontSize: '12px', color: '#E8E6E0', width: '90px' }}>{entry.name}</span>
<span style={{ fontSize: '12px', color: '#7A7D85' }}>{entry.value}</span>
</div>
))}
</div>
</div>
)
}
export default function Infrastructure() {
const [activeTab, setActiveTab] = useState('Overview')
const [resources, setResources] = useState<Resource[] | null>(null)
const [integrations, setIntegrations] = useState<Integration[] | null>(null)
const [events, setEvents] = useState<Event[] | null>(null)
const navigate = useNavigate()
useEffect(() => {
api.listResources().then(({ resources }) => setResources(resources))
api.listIntegrations().then(({ integrations }) => setIntegrations(integrations))
api.listEvents(4).then(({ events }) => setEvents(events))
}, [])
const healthy = resources?.filter((r) => r.status === 'healthy').length ?? 0
const warning = resources?.filter((r) => r.status === 'warning').length ?? 0
const critical = resources?.filter((r) => r.status === 'critical').length ?? 0
const total = resources?.length ?? 0
const statusCards = [
{ label: 'Total Resources', value: String(total), icon: Server, sub: `${integrations?.filter((i) => i.status === 'connected').length ?? 0} integrations connected` },
{ label: 'Healthy', value: String(healthy), icon: Activity, sub: total ? `${Math.round((healthy / total) * 100)}%` : '—', color: '#2ECC71' },
{ label: 'Warnings', value: String(warning), icon: AlertTriangle, sub: warning ? 'Needs attention' : 'None', color: '#E67E22' },
{ label: 'Critical', value: String(critical), icon: AlertTriangle, sub: critical ? 'Action required' : 'None', color: '#E74C3C' },
]
const distributionData = useMemo(() => {
if (!resources) return []
const byIntegration = new Map<string, number>()
for (const r of resources) byIntegration.set(r.integration, (byIntegration.get(r.integration) ?? 0) + 1)
return Array.from(byIntegration.entries()).map(([name, value], i) => ({ name, value, color: integrationPalette[i % integrationPalette.length] }))
}, [resources])
return (
<>
{/* Sub-tabs + Add Resource — hero banner is rendered at the layout level behind this */}
<div className="flex items-center justify-between shrink-0">
<div className="flex items-center gap-1 overflow-x-auto" style={{ scrollbarWidth: 'none' }}>
{subTabs.map((tab) => {
const active = tab === activeTab
return (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className="cursor-pointer bg-transparent border-none transition-colors whitespace-nowrap"
style={{
fontSize: '12px',
fontWeight: 500,
padding: '8px 14px',
borderRadius: '8px',
color: active ? '#C8A434' : '#7A7D85',
backgroundColor: active ? 'rgba(200,164,52,0.1)' : 'transparent',
}}
>
{tab}
</button>
)
})}
{futureSubTabs.map((tab) => (
<button
key={tab}
disabled
title="Coming soon"
className="cursor-not-allowed bg-transparent border-none whitespace-nowrap"
style={{
fontSize: '12px',
fontWeight: 500,
padding: '8px 14px',
borderRadius: '8px',
color: '#4A4D55',
}}
>
{tab}
</button>
))}
</div>
<button
onClick={() => navigate('/settings')}
className="flex items-center gap-2 cursor-pointer transition-colors whitespace-nowrap"
style={{
fontSize: '12px',
fontWeight: 600,
color: '#0A0B0D',
backgroundColor: '#C8A434',
border: 'none',
borderRadius: '8px',
padding: '9px 16px',
boxShadow: '0 0 14px rgba(200,164,52,0.2)',
}}
>
<Plus size={14} />
Add Resource
</button>
</div>
{/* Status Cards */}
<div className="grid w-full grid-cols-4 gap-5 shrink-0" style={{ marginTop: '8px', height: '110px' }}>
{statusCards.map((card) => {
const Icon = card.icon
return (
<div
key={card.label}
style={{ ...cardBase, backgroundColor: 'rgba(10, 10, 12, 0.5)', padding: '18px', justifyContent: 'center', alignItems: 'center', gap: '8px' }}
className="hover:!border-gold/20"
>
<h3 style={{ fontSize: '10px', textTransform: 'uppercase', letterSpacing: '1.5px', color: '#7A7D85', fontWeight: 500, textAlign: 'center' }}>
{card.label}
</h3>
<div className="flex items-center gap-2">
<Icon size={18} style={{ color: card.color ?? '#C8A434' }} />
<span style={{ fontSize: '26px', fontWeight: 700, color: '#E8E6E0', lineHeight: 1 }}>{card.value}</span>
</div>
<p style={{ fontSize: '10px', color: card.color ?? '#7A7D85', textAlign: 'center' }}>{card.sub}</p>
</div>
)
})}
</div>
{/* Middle Row */}
<div className="min-h-0 flex-1">
<div className="grid h-full w-full grid-cols-[1fr_1.6fr] gap-6">
{/* Resource Distribution */}
<div style={framedCard('/blank-kpi-bg.png')}>
<div style={cardDim} />
<div style={cardVignette} />
<div className="relative z-10 flex flex-1 flex-col">
<h3 style={sectionTitle}>Resource Distribution</h3>
<div className="flex flex-1 flex-col items-center justify-center">
{distributionData.length > 0 ? (
<Donut data={distributionData} />
) : (
<p style={{ fontSize: '12px', color: '#7A7D85', textAlign: 'center' }}>Connect an integration in Settings to see resource distribution.</p>
)}
</div>
</div>
</div>
{/* Node Status — expanded */}
<div style={framedCard('/blank-kpi-bg.png')}>
<div style={cardDim} />
<div style={cardVignette} />
<div className="relative z-10 flex flex-1 flex-col">
<h3 style={sectionTitle}>Node Status</h3>
{resources && resources.length > 0 ? (
<div className="grid flex-1 grid-cols-4 gap-3 content-center">
{resources.map((node, i) => (
<div
key={i}
title={`${node.name}: ${node.detail ?? node.status}`}
style={{
backgroundColor: 'rgba(10, 10, 12, 0.55)',
border: `1px solid ${nodeStatusColor[node.status]}33`,
borderRadius: '8px',
padding: '10px 12px',
display: 'flex',
flexDirection: 'column',
gap: '6px',
}}
>
<div className="flex items-center gap-1.5">
<span style={{ width: '7px', height: '7px', borderRadius: '50%', backgroundColor: nodeStatusColor[node.status], boxShadow: `0 0 6px ${nodeStatusColor[node.status]}` }} />
<Server size={12} style={{ color: '#7A7D85' }} />
</div>
<span style={{ fontSize: '11px', color: '#E8E6E0', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{node.name}</span>
</div>
))}
</div>
) : (
<div className="flex flex-1 items-center justify-center">
<p style={{ fontSize: '12px', color: '#7A7D85', textAlign: 'center' }}>No resources reported yet. Connect Docker (or another supported integration) in Settings to populate this view.</p>
</div>
)}
</div>
</div>
</div>
</div>
{/* Bottom Row */}
<div className="shrink-0">
<div className="grid w-full grid-cols-2 gap-6">
{/* Integration Health */}
<div style={{ ...cardBase, height: 'auto' }} className="hover:!border-gold/15">
<div className="relative z-10 flex flex-col">
<h3 style={sectionTitle}>Integration Health</h3>
{integrations && integrations.length > 0 ? (
<div className="flex flex-col gap-2">
{integrations.map((i) => (
<div key={i.id} className="flex items-center justify-between">
<span className="flex items-center gap-2" style={{ fontSize: '12px', color: '#E8E6E0' }}>
<CircleCheck size={13} style={{ color: i.status === 'connected' ? '#2ECC71' : i.status === 'error' ? '#E74C3C' : '#7A7D85' }} />
{i.name}
</span>
<span style={{ fontSize: '11px', color: '#7A7D85' }}>{i.status}</span>
</div>
))}
</div>
) : (
<p style={{ fontSize: '12px', color: '#7A7D85' }}>No integrations added yet.</p>
)}
</div>
</div>
{/* Recent Activity */}
<div style={{ ...cardBase, height: 'auto' }} className="hover:!border-gold/15">
<div className="relative z-10">
<h3 style={sectionTitle}>Recent Activity</h3>
{events && events.length > 0 ? (
<div className="flex flex-col gap-3">
{events.map((item) => (
<div key={item.id} className="flex items-start justify-between gap-3">
<div style={{ flex: 1, minWidth: 0 }}>
<p style={{ fontSize: '12px', color: '#E8E6E0', fontWeight: 500, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{item.title}</p>
</div>
</div>
))}
</div>
) : (
<p style={{ fontSize: '12px', color: '#7A7D85' }}>No activity yet.</p>
)}
</div>
</div>
</div>
</div>
{/* Footer stats bar */}
<div
className="shrink-0 flex items-center justify-center gap-3"
style={{
fontSize: '11px',
color: '#7A7D85',
padding: '8px 0',
borderTop: '1px solid rgba(200,164,52,0.08)',
}}
>
<span>{total} Resources</span><span style={{ color: 'rgba(200,164,52,0.3)' }}>|</span>
<span>{integrations?.filter((i) => i.status === 'connected').length ?? 0} Integrations Connected</span>
{critical > 0 && (
<>
<span style={{ color: 'rgba(200,164,52,0.3)' }}>|</span>
<span style={{ color: '#E74C3C' }}>{critical} Critical</span>
</>
)}
</div>
</>
)
}

115
src/pages/Login.tsx Normal file
View file

@ -0,0 +1,115 @@
import { useState } from 'react'
import { useAuth } from '../lib/AuthContext'
import { ApiError } from '../lib/api'
export default function Login() {
const { login } = useAuth()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError(null)
setSubmitting(true)
try {
await login(username, password)
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Login failed')
} finally {
setSubmitting(false)
}
}
return (
<div className="flex h-screen w-screen items-center justify-center bg-page">
<form
onSubmit={handleSubmit}
style={{
width: '380px',
backgroundColor: 'rgba(10, 10, 12, 0.92)',
border: '1px solid rgba(200, 164, 52, 0.12)',
borderRadius: '14px',
padding: '32px',
}}
>
<h1
style={{
fontSize: '20px',
fontWeight: 700,
letterSpacing: '1px',
textTransform: 'uppercase',
color: '#C8A434',
marginBottom: '4px',
}}
>
ArchNest
</h1>
<p style={{ fontSize: '12px', color: '#7A7D85', marginBottom: '24px' }}>Sign in to your dashboard</p>
<label style={fieldLabel}>Username</label>
<input
style={fieldInput}
value={username}
onChange={(e) => setUsername(e.target.value)}
autoFocus
required
/>
<label style={{ ...fieldLabel, marginTop: '14px' }}>Password</label>
<input
style={fieldInput}
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
{error && (
<p style={{ fontSize: '12px', color: '#E74C3C', marginTop: '12px' }}>{error}</p>
)}
<button
type="submit"
disabled={submitting}
className="cursor-pointer transition-opacity"
style={{
width: '100%',
marginTop: '22px',
height: '38px',
borderRadius: '8px',
border: 'none',
fontSize: '13px',
fontWeight: 600,
color: '#0A0B0D',
backgroundColor: '#C8A434',
boxShadow: '0 0 14px rgba(200,164,52,0.2)',
opacity: submitting ? 0.6 : 1,
}}
>
{submitting ? 'Signing in…' : 'Sign In'}
</button>
</form>
</div>
)
}
const fieldLabel: React.CSSProperties = {
fontSize: '11px',
color: '#7A7D85',
marginBottom: '6px',
display: 'block',
}
const fieldInput: React.CSSProperties = {
width: '100%',
height: '36px',
borderRadius: '8px',
border: '1px solid rgba(200,164,52,0.12)',
backgroundColor: 'rgba(255,255,255,0.03)',
color: '#E8E6E0',
fontSize: '13px',
padding: '0 12px',
outline: 'none',
}

653
src/pages/Settings.tsx Normal file
View file

@ -0,0 +1,653 @@
import { useEffect, useRef, useState } from 'react'
import { api, ApiError, type Integration } from '../lib/api'
import {
User,
Palette,
Plug,
Bell,
Database,
Info,
Eye,
EyeOff,
Check,
Download,
Upload,
Trash2,
RotateCcw,
Camera,
} from 'lucide-react'
const navSections = [
{ id: 'profile', label: 'Profile', icon: User },
{ id: 'appearance', label: 'Appearance', icon: Palette },
{ id: 'integrations', label: 'Integrations', icon: Plug },
{ id: 'notifications', label: 'Notifications', icon: Bell },
{ id: 'data', label: 'Data & Backup', icon: Database },
{ id: 'about', label: 'About', icon: Info },
]
const accentColors = [
{ name: 'Gold', color: '#C8A434' },
{ name: 'Teal', color: '#2DD4BF' },
{ name: 'Purple', color: '#A855F7' },
{ name: 'Blue', color: '#3B82F6' },
{ name: 'Green', color: '#2ECC71' },
{ name: 'Red', color: '#E74C3C' },
]
type FieldDef = { key: string; label: string; secret?: boolean }
const integrationTypeDefs: { type: string; name: string; fields: FieldDef[] }[] = [
{ type: 'proxmox', name: 'Proxmox', fields: [{ key: 'baseUrl', label: 'Host URL' }, { key: 'apiKey', label: 'API Token', secret: true }] },
{ type: 'docker', name: 'Docker', fields: [{ key: 'baseUrl', label: 'Socket / Remote URL' }] },
{ type: 'netbird', name: 'NetBird', fields: [{ key: 'apiKey', label: 'API Key', secret: true }] },
{ type: 'cloudflare', name: 'Cloudflare', fields: [{ key: 'apiKey', label: 'API Token', secret: true }, { key: 'zoneId', label: 'Zone ID' }] },
{ type: 'aws', name: 'AWS', fields: [{ key: 'accessKey', label: 'Access Key' }, { key: 'secretKey', label: 'Secret', secret: true }, { key: 'region', label: 'Region' }] },
{ type: 'uptime_kuma', name: 'Uptime Kuma', fields: [{ key: 'baseUrl', label: 'URL' }, { key: 'apiKey', label: 'API Key', secret: true }] },
{ type: 'weather', name: 'Weather API', fields: [{ key: 'location', label: 'Location' }, { key: 'units', label: 'Units' }] },
]
const cardBase: React.CSSProperties = {
backgroundColor: 'rgba(10, 10, 12, 0.92)',
border: '1px solid rgba(200, 164, 52, 0.08)',
borderRadius: '12px',
padding: '22px',
position: 'relative',
}
const sectionTitle: React.CSSProperties = {
fontSize: '11px',
textTransform: 'uppercase',
letterSpacing: '1.5px',
color: '#7A7D85',
fontWeight: 500,
marginBottom: '16px',
}
const labelStyle: React.CSSProperties = {
fontSize: '11px',
color: '#7A7D85',
marginBottom: '6px',
display: 'block',
}
const inputStyle: React.CSSProperties = {
width: '100%',
height: '34px',
borderRadius: '8px',
border: '1px solid rgba(200,164,52,0.12)',
backgroundColor: 'rgba(255,255,255,0.03)',
color: '#E8E6E0',
fontSize: '12px',
padding: '0 12px',
outline: 'none',
}
function Toggle({ on, onClick }: { on: boolean; onClick: () => void }) {
return (
<button
onClick={onClick}
className="cursor-pointer border-none"
style={{
width: '38px',
height: '20px',
borderRadius: '10px',
backgroundColor: on ? '#C8A434' : 'rgba(255,255,255,0.08)',
position: 'relative',
transition: 'background-color 0.2s ease',
flexShrink: 0,
}}
>
<span
style={{
position: 'absolute',
top: '2px',
left: on ? '20px' : '2px',
width: '16px',
height: '16px',
borderRadius: '50%',
backgroundColor: '#0A0B0D',
transition: 'left 0.2s ease',
}}
/>
</button>
)
}
function GoldButton({ children, danger }: { children: React.ReactNode; danger?: boolean }) {
return (
<button
className="flex items-center gap-2 cursor-pointer transition-colors whitespace-nowrap"
style={{
fontSize: '12px',
fontWeight: 600,
color: danger ? '#E74C3C' : '#0A0B0D',
backgroundColor: danger ? 'transparent' : '#C8A434',
border: danger ? '1px solid rgba(231,76,60,0.4)' : 'none',
borderRadius: '8px',
padding: '9px 16px',
boxShadow: danger ? 'none' : '0 0 14px rgba(200,164,52,0.2)',
}}
>
{children}
</button>
)
}
function ProfileSection() {
const [avatar, setAvatar] = useState<string | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
function handleAvatarChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = () => setAvatar(reader.result as string)
reader.readAsDataURL(file)
}
return (
<div style={cardBase}>
<h3 style={sectionTitle}>Profile</h3>
<div className="flex items-center gap-4" style={{ marginBottom: '24px' }}>
<div
onClick={() => fileInputRef.current?.click()}
className="relative rounded-full border-2 flex items-center justify-center font-bold cursor-pointer group"
style={{
width: '64px',
height: '64px',
borderColor: '#C8A434',
color: '#C8A434',
fontSize: '20px',
backgroundColor: 'rgba(200,164,52,0.08)',
backgroundImage: avatar ? `url(${avatar})` : undefined,
backgroundSize: 'cover',
backgroundPosition: 'center',
overflow: 'hidden',
}}
title="Upload photo"
>
{!avatar && 'AO'}
<div
className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
style={{ backgroundColor: 'rgba(0,0,0,0.55)' }}
>
<Camera size={18} color="#E8E6E0" />
</div>
</div>
<input ref={fileInputRef} type="file" accept="image/*" onChange={handleAvatarChange} className="hidden" />
<div>
<div className="flex items-center gap-2">
<span style={{ fontSize: '15px', color: '#E8E6E0', fontWeight: 600 }}>ArchNest Ops</span>
<span style={{ fontSize: '10px', color: '#C8A434', border: '1px solid rgba(200,164,52,0.3)', borderRadius: '6px', padding: '2px 8px' }}>
Administrator
</span>
</div>
<span style={{ fontSize: '12px', color: '#7A7D85' }}>admin@archnest.io</span>
</div>
</div>
<div className="grid grid-cols-2 gap-4" style={{ marginBottom: '20px' }}>
<div>
<label style={labelStyle}>Display Name</label>
<input style={inputStyle} defaultValue="ArchNest Ops" />
</div>
<div>
<label style={labelStyle}>Email</label>
<input style={inputStyle} defaultValue="admin@archnest.io" />
</div>
<div>
<label style={labelStyle}>Role</label>
<input style={inputStyle} defaultValue="Administrator" disabled />
</div>
<div>
<label style={labelStyle}>Timezone</label>
<input style={inputStyle} defaultValue="America/New_York (EST)" />
</div>
</div>
<GoldButton>
<Check size={14} />
Save Changes
</GoldButton>
</div>
)
}
function AppearanceSection() {
const [theme, setTheme] = useState<'dark' | 'light'>('dark')
const [accent, setAccent] = useState('Gold')
const [fontSize, setFontSize] = useState(13)
const [radius, setRadius] = useState(12)
const [sidebarExpanded, setSidebarExpanded] = useState(true)
const [animations, setAnimations] = useState(true)
return (
<div style={cardBase}>
<h3 style={sectionTitle}>Appearance</h3>
<div className="flex items-center justify-between" style={{ marginBottom: '20px' }}>
<span style={{ fontSize: '13px', color: '#E8E6E0' }}>Theme</span>
<div className="flex items-center gap-1" style={{ backgroundColor: 'rgba(255,255,255,0.03)', borderRadius: '8px', padding: '3px' }}>
{(['dark', 'light'] as const).map((t) => (
<button
key={t}
onClick={() => setTheme(t)}
className="cursor-pointer border-none capitalize"
style={{
fontSize: '11px',
padding: '6px 14px',
borderRadius: '6px',
color: theme === t ? '#0A0B0D' : '#7A7D85',
backgroundColor: theme === t ? '#C8A434' : 'transparent',
fontWeight: 600,
}}
>
{t}
</button>
))}
</div>
</div>
<div style={{ marginBottom: '20px' }}>
<span style={{ fontSize: '13px', color: '#E8E6E0', marginBottom: '10px', display: 'block' }}>Accent Color</span>
<div className="flex items-center gap-3">
{accentColors.map((a) => (
<button
key={a.name}
onClick={() => setAccent(a.name)}
title={a.name}
className="cursor-pointer border-none rounded-full flex items-center justify-center"
style={{
width: '28px',
height: '28px',
backgroundColor: a.color,
outline: accent === a.name ? `2px solid ${a.color}` : 'none',
outlineOffset: '3px',
}}
>
{accent === a.name && <Check size={14} color="#0A0B0D" />}
</button>
))}
</div>
</div>
<div style={{ marginBottom: '20px' }}>
<div className="flex items-center justify-between" style={{ marginBottom: '8px' }}>
<span style={{ fontSize: '13px', color: '#E8E6E0' }}>Font Size</span>
<span style={{ fontSize: '11px', color: '#C8A434' }}>{fontSize}px</span>
</div>
<input
type="range"
min={12}
max={16}
value={fontSize}
onChange={(e) => setFontSize(Number(e.target.value))}
className="w-full"
style={{ accentColor: '#C8A434' }}
/>
</div>
<div style={{ marginBottom: '20px' }}>
<div className="flex items-center justify-between" style={{ marginBottom: '8px' }}>
<span style={{ fontSize: '13px', color: '#E8E6E0' }}>Card Border Radius</span>
<span style={{ fontSize: '11px', color: '#C8A434' }}>{radius}px</span>
</div>
<input
type="range"
min={4}
max={16}
value={radius}
onChange={(e) => setRadius(Number(e.target.value))}
className="w-full"
style={{ accentColor: '#C8A434' }}
/>
</div>
<div className="flex items-center justify-between" style={{ marginBottom: '16px' }}>
<span style={{ fontSize: '13px', color: '#E8E6E0' }}>Sidebar Expanded by Default</span>
<Toggle on={sidebarExpanded} onClick={() => setSidebarExpanded((v) => !v)} />
</div>
<div className="flex items-center justify-between">
<span style={{ fontSize: '13px', color: '#E8E6E0' }}>Animations</span>
<Toggle on={animations} onClick={() => setAnimations((v) => !v)} />
</div>
</div>
)
}
function IntegrationsSection() {
const [integrations, setIntegrations] = useState<Integration[] | null>(null)
const [revealed, setRevealed] = useState<Set<string>>(new Set())
const [drafts, setDrafts] = useState<Record<string, Record<string, string>>>({})
const [statusMsg, setStatusMsg] = useState<Record<string, string>>({})
const [busy, setBusy] = useState<Set<string>>(new Set())
useEffect(() => {
api.listIntegrations().then(({ integrations }) => setIntegrations(integrations))
}, [])
function toggleReveal(key: string) {
setRevealed((prev) => {
const next = new Set(prev)
if (next.has(key)) next.delete(key)
else next.add(key)
return next
})
}
function setBusyFlag(type: string, value: boolean) {
setBusy((prev) => {
const next = new Set(prev)
if (value) next.add(type)
else next.delete(type)
return next
})
}
function setDraftField(type: string, fieldKey: string, value: string) {
setDrafts((prev) => ({ ...prev, [type]: { ...prev[type], [fieldKey]: value } }))
}
async function handleSave(def: (typeof integrationTypeDefs)[number], existing: Integration | undefined) {
setBusyFlag(def.type, true)
setStatusMsg((prev) => ({ ...prev, [def.type]: '' }))
try {
const draft = drafts[def.type] ?? {}
const config: Record<string, string> = {}
const secrets: Record<string, string> = {}
for (const f of def.fields) {
const value = draft[f.key]
if (value === undefined) continue
if (f.secret) secrets[f.key] = value
else config[f.key] = value
}
let integration: Integration
if (existing) {
;({ integration } = await api.updateIntegration(existing.id, { config, secrets }))
} else {
;({ integration } = await api.createIntegration({ type: def.type, name: def.name, config, secrets }))
}
setIntegrations((prev) => {
const others = (prev ?? []).filter((i) => i.id !== integration.id)
return [...others, integration]
})
setStatusMsg((prev) => ({ ...prev, [def.type]: 'Saved' }))
} catch (err) {
setStatusMsg((prev) => ({ ...prev, [def.type]: err instanceof ApiError ? err.message : 'Save failed' }))
} finally {
setBusyFlag(def.type, false)
}
}
async function handleTest(def: (typeof integrationTypeDefs)[number], existing: Integration | undefined) {
if (!existing) {
setStatusMsg((prev) => ({ ...prev, [def.type]: 'Save the integration before testing' }))
return
}
setBusyFlag(def.type, true)
try {
const result = await api.testIntegration(existing.id)
setStatusMsg((prev) => ({ ...prev, [def.type]: result.message }))
const { integrations } = await api.listIntegrations()
setIntegrations(integrations)
} catch (err) {
setStatusMsg((prev) => ({ ...prev, [def.type]: err instanceof ApiError ? err.message : 'Test failed' }))
} finally {
setBusyFlag(def.type, false)
}
}
if (!integrations) {
return (
<div style={cardBase}>
<p style={{ fontSize: '12px', color: '#7A7D85' }}>Loading integrations</p>
</div>
)
}
return (
<div className="flex flex-col gap-4">
{integrationTypeDefs.map((def) => {
const existing = integrations.find((i) => i.type === def.type)
const online = existing?.status === 'connected'
const draft = drafts[def.type] ?? {}
return (
<div key={def.type} style={cardBase}>
<div className="flex items-center justify-between" style={{ marginBottom: '16px' }}>
<div className="flex items-center gap-2.5">
<span
style={{
width: '8px',
height: '8px',
borderRadius: '50%',
backgroundColor: online ? '#2ECC71' : '#4A4D55',
boxShadow: online ? '0 0 6px rgba(46,204,113,0.6)' : 'none',
}}
/>
<span style={{ fontSize: '13px', color: '#E8E6E0', fontWeight: 600 }}>{def.name}</span>
</div>
<div className="flex items-center gap-2">
{statusMsg[def.type] && (
<span style={{ fontSize: '11px', color: '#7A7D85' }}>{statusMsg[def.type]}</span>
)}
<button
onClick={() => handleSave(def, existing)}
disabled={busy.has(def.type)}
className="cursor-pointer border-none"
style={{
fontSize: '11px',
fontWeight: 600,
color: '#0A0B0D',
backgroundColor: '#C8A434',
borderRadius: '6px',
padding: '6px 12px',
opacity: busy.has(def.type) ? 0.6 : 1,
}}
>
Save
</button>
<button
onClick={() => handleTest(def, existing)}
disabled={busy.has(def.type)}
className="cursor-pointer border-none"
style={{
fontSize: '11px',
fontWeight: 600,
color: '#C8A434',
backgroundColor: 'rgba(200,164,52,0.08)',
border: '1px solid rgba(200,164,52,0.2)',
borderRadius: '6px',
padding: '6px 12px',
opacity: busy.has(def.type) ? 0.6 : 1,
}}
>
Test Connection
</button>
</div>
</div>
<div className="grid grid-cols-3 gap-4">
{def.fields.map((f) => {
const key = `${def.type}-${f.key}`
const isRevealed = revealed.has(key)
const savedValue = f.secret ? '' : existing?.config[f.key] ?? ''
const value = draft[f.key] ?? savedValue
return (
<div key={key}>
<label style={labelStyle}>{f.label}</label>
<div className="relative">
<input
style={inputStyle}
type={f.secret && !isRevealed ? 'password' : 'text'}
value={value}
onChange={(e) => setDraftField(def.type, f.key, e.target.value)}
placeholder={f.secret && existing ? '••••••••••••' : 'Not configured'}
/>
{f.secret && (
<button
onClick={() => toggleReveal(key)}
className="absolute cursor-pointer border-none bg-transparent"
style={{ right: '8px', top: '50%', transform: 'translateY(-50%)', color: '#7A7D85' }}
>
{isRevealed ? <EyeOff size={13} /> : <Eye size={13} />}
</button>
)}
</div>
</div>
)
})}
</div>
</div>
)
})}
</div>
)
}
function NotificationsSection() {
const [enabled, setEnabled] = useState(true)
const [email, setEmail] = useState(true)
const [push, setPush] = useState(false)
const [sound, setSound] = useState(true)
return (
<div style={cardBase}>
<h3 style={sectionTitle}>Notifications</h3>
<div className="flex items-center justify-between" style={{ marginBottom: '20px' }}>
<span style={{ fontSize: '13px', color: '#E8E6E0' }}>Enable Notifications</span>
<Toggle on={enabled} onClick={() => setEnabled((v) => !v)} />
</div>
<div style={{ marginBottom: '20px' }}>
<label style={labelStyle}>Alert Threshold</label>
<select style={{ ...inputStyle, width: '220px' }} defaultValue="all">
<option value="all">All</option>
<option value="critical">Critical Only</option>
<option value="warning">Warning & Above</option>
</select>
</div>
<div className="flex items-center justify-between" style={{ marginBottom: '16px' }}>
<span style={{ fontSize: '13px', color: '#E8E6E0' }}>Email Notifications</span>
<Toggle on={email} onClick={() => setEmail((v) => !v)} />
</div>
<div className="flex items-center justify-between" style={{ marginBottom: '16px' }}>
<span style={{ fontSize: '13px', color: '#E8E6E0' }}>Browser Push</span>
<Toggle on={push} onClick={() => setPush((v) => !v)} />
</div>
<div className="flex items-center justify-between" style={{ marginBottom: '8px' }}>
<span style={{ fontSize: '13px', color: '#E8E6E0' }}>Sound</span>
<Toggle on={sound} onClick={() => setSound((v) => !v)} />
</div>
{sound && (
<input type="range" min={0} max={100} defaultValue={70} className="w-full" style={{ accentColor: '#C8A434' }} />
)}
</div>
)
}
function DataBackupSection() {
return (
<div style={cardBase}>
<h3 style={sectionTitle}>Data & Backup</h3>
<div className="flex flex-col gap-3" style={{ maxWidth: '320px' }}>
<div className="flex items-center justify-between">
<span style={{ fontSize: '13px', color: '#E8E6E0' }}>Export Bookmarks (JSON)</span>
<GoldButton><Download size={13} /> Export</GoldButton>
</div>
<div className="flex items-center justify-between">
<span style={{ fontSize: '13px', color: '#E8E6E0' }}>Import Bookmarks (JSON)</span>
<GoldButton><Upload size={13} /> Import</GoldButton>
</div>
<div className="flex items-center justify-between">
<span style={{ fontSize: '13px', color: '#E8E6E0' }}>Export Settings</span>
<GoldButton><Download size={13} /> Export</GoldButton>
</div>
<div className="border-t" style={{ borderColor: 'rgba(231,76,60,0.15)', margin: '8px 0' }} />
<div className="flex items-center justify-between">
<span style={{ fontSize: '13px', color: '#E8E6E0' }}>Clear Cache</span>
<GoldButton danger><Trash2 size={13} /> Clear</GoldButton>
</div>
<div className="flex items-center justify-between">
<span style={{ fontSize: '13px', color: '#E8E6E0' }}>Reset to Defaults</span>
<GoldButton danger><RotateCcw size={13} /> Reset</GoldButton>
</div>
</div>
</div>
)
}
function AboutSection() {
const rows: [string, string][] = [
['App', 'ArchNest Dashboard v1.0.0'],
['Author', 'Samuel James'],
['Repo', 'github.com/SamuelSJames/archnest'],
['Stack', 'React 19, Vite, TypeScript'],
['License', 'MIT'],
]
return (
<div style={cardBase}>
<h3 style={sectionTitle}>About</h3>
<div className="flex flex-col gap-3">
{rows.map(([label, value]) => (
<div key={label} className="flex items-center justify-between">
<span style={{ fontSize: '12px', color: '#7A7D85' }}>{label}</span>
<span style={{ fontSize: '12px', color: '#E8E6E0' }}>{value}</span>
</div>
))}
</div>
</div>
)
}
const sectionComponents: Record<string, () => JSX.Element> = {
profile: ProfileSection,
appearance: AppearanceSection,
integrations: IntegrationsSection,
notifications: NotificationsSection,
data: DataBackupSection,
about: AboutSection,
}
export default function Settings() {
const [active, setActive] = useState('profile')
const ActiveSection = sectionComponents[active]
return (
<div className="flex h-full w-full gap-5">
{/* Settings nav */}
<div className="flex flex-col gap-1 shrink-0" style={{ width: '200px' }}>
{navSections.map((s) => {
const Icon = s.icon
const isActive = active === s.id
return (
<button
key={s.id}
onClick={() => setActive(s.id)}
className="flex items-center gap-2.5 cursor-pointer border-none bg-transparent transition-colors"
style={{
fontSize: '13px',
fontWeight: 500,
padding: '10px 14px',
borderRadius: '8px',
color: isActive ? '#C8A434' : '#7A7D85',
backgroundColor: isActive ? 'rgba(200,164,52,0.1)' : 'transparent',
}}
>
<Icon size={15} />
{s.label}
</button>
)
})}
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto" style={{ scrollbarWidth: 'none' }}>
<ActiveSection />
</div>
</div>
)
}

View file

@ -4,4 +4,12 @@ import tailwindcss from '@tailwindcss/vite'
export default defineConfig({ export default defineConfig({
plugins: [react(), tailwindcss()], plugins: [react(), tailwindcss()],
server: {
proxy: {
'/api': {
target: 'http://localhost:4000',
changeOrigin: true,
},
},
},
}) })