mirror of
https://github.com/iritmaximus/wasab.git
synced 2026-10-12 20:03:31 +03:00
Initial commit
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
coverage
|
||||||
|
test-results
|
||||||
|
playwright-report
|
||||||
|
.git
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: npm
|
||||||
|
- run: npm ci
|
||||||
|
- run: npm run lint
|
||||||
|
- run: npm run fmt:check
|
||||||
|
- run: npm run typecheck
|
||||||
|
- run: npm test -- --coverage
|
||||||
|
- run: npx playwright install --with-deps firefox
|
||||||
|
- run: npm run test:e2e
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
if: failure()
|
||||||
|
with:
|
||||||
|
name: playwright-report
|
||||||
|
path: playwright-report
|
||||||
|
|
||||||
|
# Argo image-updater tracks the :latest digest, see k8s-pumppi/wasab
|
||||||
|
publish:
|
||||||
|
needs: test
|
||||||
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
- uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
- uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
ghcr.io/${{ github.repository_owner }}/wasab:latest
|
||||||
|
ghcr.io/${{ github.repository_owner }}/wasab:${{ github.sha }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
coverage
|
||||||
|
test-results
|
||||||
|
playwright-report
|
||||||
|
*.local
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
||||||
|
"printWidth": 100,
|
||||||
|
"singleQuote": true,
|
||||||
|
"ignorePatterns": ["dist", "coverage", "playwright-report", "test-results", "package-lock.json"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||||
|
"plugins": ["typescript", "unicorn", "oxc", "react", "jsx-a11y", "vitest", "import"],
|
||||||
|
"categories": {
|
||||||
|
"correctness": "error",
|
||||||
|
"suspicious": "warn"
|
||||||
|
},
|
||||||
|
"rules": {
|
||||||
|
"react/react-in-jsx-scope": "off",
|
||||||
|
"react-hooks/rules-of-hooks": "error",
|
||||||
|
"react-hooks/exhaustive-deps": "warn",
|
||||||
|
"import/no-unassigned-import": [
|
||||||
|
"warn",
|
||||||
|
{
|
||||||
|
"allow": ["**/*.css", "@testing-library/jest-dom/vitest", "@fontsource-variable/*"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"ignorePatterns": ["dist", "coverage", "playwright-report", "test-results"]
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
FROM node:24-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# runs as non-root (uid 101) and listens on 8080
|
||||||
|
FROM nginxinc/nginx-unprivileged:alpine
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
EXPOSE 8080
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# wasab
|
||||||
|
|
||||||
|
Home page for [pumppi.dev](https://pumppi.dev): links to every app running on the
|
||||||
|
[k8s-pumppi](https://github.com/iritmaximus/k8s-pumppi) cluster.
|
||||||
|
|
||||||
|
Vite + React + TypeScript, static build served by nginx.
|
||||||
|
|
||||||
|
## Develop
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
| Script | |
|
||||||
|
| --------------------------- | --------------------------------------------------------------------------------- |
|
||||||
|
| `npm run lint` | oxlint |
|
||||||
|
| `npm run fmt` / `fmt:check` | oxfmt |
|
||||||
|
| `npm run typecheck` | `tsc --noEmit` |
|
||||||
|
| `npm test` | Vitest unit/component tests (`-- --coverage` enforces 90%) |
|
||||||
|
| `npm run test:e2e` | Playwright against a production build (run `npx playwright install firefox` once) |
|
||||||
|
|
||||||
|
## Adding an app
|
||||||
|
|
||||||
|
Add an entry to `src/apps.ts`. `access` lists how you get in: `sso` if the ingress uses the
|
||||||
|
Authelia middleware, `login` if the app has its own login (both is fine). Then update the host list
|
||||||
|
in `src/apps.test.ts`, which is there so a removed or mistyped app is noticed.
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
|
||||||
|
Pushing to `main` runs CI and publishes `ghcr.io/iritmaximus/wasab:latest`. Argo CD image-updater
|
||||||
|
picks up the new digest and rolls out `k8s-pumppi/wasab`. The container serves on port 8080 with a
|
||||||
|
`/health` endpoint for probes.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker build -t wasab . && docker run --rm -p 8080:8080 wasab
|
||||||
|
```
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { AxeBuilder } from '@axe-core/playwright';
|
||||||
|
import { test as base, expect } from '@playwright/test';
|
||||||
|
import { apps } from '../src/apps';
|
||||||
|
|
||||||
|
const test = base.extend<{ consoleErrors: string[] }>({
|
||||||
|
consoleErrors: [
|
||||||
|
async ({ page }, use) => {
|
||||||
|
const errors: string[] = [];
|
||||||
|
page.on('console', (msg) => {
|
||||||
|
if (msg.type() === 'error') errors.push(msg.text());
|
||||||
|
});
|
||||||
|
page.on('pageerror', (err) => errors.push(err.message));
|
||||||
|
await use(errors);
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
},
|
||||||
|
{ auto: true },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('has the title and heading', async ({ page }) => {
|
||||||
|
await expect(page).toHaveTitle('pumppi.dev');
|
||||||
|
await expect(page.getByRole('heading', { level: 1 })).toHaveText('pumppi.dev');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('links to every app', async ({ page }) => {
|
||||||
|
await expect(page.getByRole('link')).toHaveCount(apps.length);
|
||||||
|
for (const app of apps) {
|
||||||
|
await expect(page.locator(`a[href="${app.url}"]`)).toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clicking anywhere on a card follows its link', async ({ page }) => {
|
||||||
|
await page.route('https://meal.pumppi.dev/**', (route) =>
|
||||||
|
route.fulfill({ contentType: 'text/html', body: '<title>mealie stub</title>' }),
|
||||||
|
);
|
||||||
|
// the description sits under the link's ::after overlay, so click by coordinates
|
||||||
|
const box = await page.getByText('Recipes and meal plans').boundingBox();
|
||||||
|
if (!box) throw new Error('description not rendered');
|
||||||
|
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
|
||||||
|
await expect(page).toHaveURL('https://meal.pumppi.dev/');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('search filters and Escape resets', async ({ page }) => {
|
||||||
|
const search = page.getByRole('searchbox', { name: 'Search apps' });
|
||||||
|
// the shortcut is registered in an effect that can land after the load event
|
||||||
|
await expect(async () => {
|
||||||
|
await page.keyboard.press('/');
|
||||||
|
await expect(search).toBeFocused({ timeout: 100 });
|
||||||
|
}).toPass();
|
||||||
|
await expect(search).toHaveValue('');
|
||||||
|
|
||||||
|
await search.fill('grafana');
|
||||||
|
await expect(page.getByRole('link')).toHaveCount(1);
|
||||||
|
await expect(page.getByRole('link')).toHaveAttribute('href', 'https://grafana.pumppi.dev');
|
||||||
|
|
||||||
|
await search.fill('zzz');
|
||||||
|
await expect(page.getByRole('status')).toHaveText('No apps match “zzz”.');
|
||||||
|
|
||||||
|
await search.press('Escape');
|
||||||
|
await expect(page.getByRole('link')).toHaveCount(apps.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fits every app on one screen without scrolling', async ({ page }) => {
|
||||||
|
const { scrollHeight, clientHeight } = await page.evaluate(() => ({
|
||||||
|
scrollHeight: document.documentElement.scrollHeight,
|
||||||
|
clientHeight: document.documentElement.clientHeight,
|
||||||
|
}));
|
||||||
|
expect(scrollHeight).toBeLessThanOrEqual(clientHeight);
|
||||||
|
for (const link of await page.getByRole('link').all()) {
|
||||||
|
await expect(link).toBeInViewport({ ratio: 1 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('has no horizontal scroll', async ({ page }) => {
|
||||||
|
const overflow = await page.evaluate(
|
||||||
|
() => document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
||||||
|
);
|
||||||
|
expect(overflow).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const colorScheme of ['light', 'dark'] as const) {
|
||||||
|
test(`has no accessibility violations in ${colorScheme} mode`, async ({ page }) => {
|
||||||
|
await page.emulateMedia({ colorScheme });
|
||||||
|
const results = await new AxeBuilder({ page }).analyze();
|
||||||
|
expect(results.violations).toEqual([]);
|
||||||
|
});
|
||||||
|
}
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="description" content="Apps running on pumppi.dev" />
|
||||||
|
<meta name="color-scheme" content="light dark" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<title>pumppi.dev</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
server {
|
||||||
|
listen 8080;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
|
||||||
|
# k8s readiness/liveness probe
|
||||||
|
location = /health {
|
||||||
|
access_log off;
|
||||||
|
default_type text/plain;
|
||||||
|
return 200 "ok\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
# vite puts content hashes in asset names, so they never change
|
||||||
|
location /assets/ {
|
||||||
|
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
add_header Cache-Control "no-cache";
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+3440
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"name": "wasab",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Home page for pumppi.dev",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc --noEmit && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"lint": "oxlint --deny-warnings",
|
||||||
|
"fmt": "oxfmt",
|
||||||
|
"fmt:check": "oxfmt --check",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"test:e2e": "playwright test"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fontsource-variable/space-grotesk": "^5.3.0",
|
||||||
|
"react": "^19.3.0",
|
||||||
|
"react-dom": "^19.3.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@axe-core/playwright": "^4.13.0",
|
||||||
|
"@playwright/test": "^1.64.0",
|
||||||
|
"@testing-library/jest-dom": "^7.0.1",
|
||||||
|
"@testing-library/react": "^16.3.3",
|
||||||
|
"@testing-library/user-event": "^14.6.7",
|
||||||
|
"@types/node": "^26.6.5",
|
||||||
|
"@types/react": "^19.3.0",
|
||||||
|
"@types/react-dom": "^19.3.0",
|
||||||
|
"@vitejs/plugin-react": "^6.1.2",
|
||||||
|
"@vitest/coverage-v8": "^5.0.3",
|
||||||
|
"jsdom": "^30.1.2",
|
||||||
|
"oxfmt": "^0.72.0",
|
||||||
|
"oxlint": "^1.87.0",
|
||||||
|
"typescript": "^7.0.2",
|
||||||
|
"vite": "^8.3.4",
|
||||||
|
"vitest": "^5.0.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
|
const port = 4173;
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './e2e',
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
retries: process.env.CI ? 1 : 0,
|
||||||
|
reporter: process.env.CI ? 'github' : 'list',
|
||||||
|
use: { baseURL: `http://localhost:${port}` },
|
||||||
|
// Firefox has no mobile emulation, so phones are covered by viewport size only
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: 'firefox',
|
||||||
|
use: { ...devices['Desktop Firefox'], viewport: { width: 1280, height: 720 } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'firefox-laptop',
|
||||||
|
use: { ...devices['Desktop Firefox'], viewport: { width: 1024, height: 640 } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'firefox-phone',
|
||||||
|
use: { ...devices['Desktop Firefox'], viewport: { width: 390, height: 844 } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
webServer: {
|
||||||
|
command: `npm run build && npx vite preview --port ${port} --strictPort`,
|
||||||
|
port,
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="7" fill="#2f6f5e"/><path d="M11 24V8h6.5a5 5 0 0 1 0 10H11" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
After Width: | Height: | Size: 251 B |
@@ -0,0 +1,84 @@
|
|||||||
|
import { render, screen, within } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { App } from './App';
|
||||||
|
import { apps, groups } from './apps';
|
||||||
|
|
||||||
|
function setup() {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<App />);
|
||||||
|
return { user, search: screen.getByRole('searchbox', { name: 'Search apps' }) };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('App', () => {
|
||||||
|
it('shows the site title', () => {
|
||||||
|
setup();
|
||||||
|
expect(screen.getByRole('heading', { level: 1, name: 'pumppi.dev' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('links to every app', () => {
|
||||||
|
setup();
|
||||||
|
for (const app of apps) {
|
||||||
|
expect(screen.getByRole('link', { name: new RegExp(`^${app.name}\\b`) })).toHaveAttribute(
|
||||||
|
'href',
|
||||||
|
app.url,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
expect(screen.getAllByRole('link')).toHaveLength(apps.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('puts each app under its group heading', () => {
|
||||||
|
setup();
|
||||||
|
for (const group of groups) {
|
||||||
|
const section = screen.getByRole('region', { name: group.title });
|
||||||
|
const hrefs = within(section)
|
||||||
|
.getAllByRole('link')
|
||||||
|
.map((link) => link.getAttribute('href'));
|
||||||
|
expect(hrefs).toEqual(apps.filter((app) => app.group === group.id).map((app) => app.url));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters as you type', async () => {
|
||||||
|
const { user, search } = setup();
|
||||||
|
await user.type(search, 'vault');
|
||||||
|
const links = screen.getAllByRole('link');
|
||||||
|
expect(links).toHaveLength(1);
|
||||||
|
expect(links[0]).toHaveAttribute('href', 'https://vault.pumppi.dev');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides groups with no matches', async () => {
|
||||||
|
const { user, search } = setup();
|
||||||
|
await user.type(search, 'grafana');
|
||||||
|
expect(screen.getByRole('region', { name: 'Admin & infra' })).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole('region', { name: 'Apps' })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows an empty state when nothing matches', async () => {
|
||||||
|
const { user, search } = setup();
|
||||||
|
await user.type(search, ' nothing-here ');
|
||||||
|
expect(screen.queryAllByRole('link')).toHaveLength(0);
|
||||||
|
expect(screen.getByRole('status')).toHaveTextContent('No apps match “nothing-here”.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores everything on Escape', async () => {
|
||||||
|
const { user, search } = setup();
|
||||||
|
await user.type(search, 'nothing-here');
|
||||||
|
await user.keyboard('{Escape}');
|
||||||
|
expect(search).toHaveValue('');
|
||||||
|
expect(screen.getAllByRole('link')).toHaveLength(apps.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('focuses search with "/"', async () => {
|
||||||
|
const { user, search } = setup();
|
||||||
|
expect(search).not.toHaveFocus();
|
||||||
|
await user.keyboard('/');
|
||||||
|
expect(search).toHaveFocus();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('explains the badges in the footer', () => {
|
||||||
|
setup();
|
||||||
|
const footer = screen.getByRole('contentinfo');
|
||||||
|
expect(footer).toHaveTextContent('SSO behind Authelia');
|
||||||
|
expect(footer).toHaveTextContent('Login own login');
|
||||||
|
});
|
||||||
|
});
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { apps as allApps, groups } from './apps';
|
||||||
|
import { AppGroup } from './components/AppGroup';
|
||||||
|
import { SearchBox } from './components/SearchBox';
|
||||||
|
import { filterApps } from './filterApps';
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const visible = filterApps(allApps, query);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<header className="header">
|
||||||
|
<h1>pumppi.dev</h1>
|
||||||
|
<p className="tagline">Apps running on the homelab cluster.</p>
|
||||||
|
<SearchBox value={query} onChange={setQuery} />
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
{visible.length === 0 ? (
|
||||||
|
<output className="empty">No apps match “{query.trim()}”.</output>
|
||||||
|
) : (
|
||||||
|
groups.map((group) => {
|
||||||
|
const groupApps = visible.filter((app) => app.group === group.id);
|
||||||
|
return groupApps.length > 0 ? (
|
||||||
|
<AppGroup key={group.id} group={group} apps={groupApps} />
|
||||||
|
) : null;
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
<footer className="footer">
|
||||||
|
<span className="badge badge-sso">SSO</span> behind Authelia ·{' '}
|
||||||
|
<span className="badge badge-login">Login</span> own login
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { apps, groups, hostOf } from './apps';
|
||||||
|
|
||||||
|
describe('app registry', () => {
|
||||||
|
it('has unique ids', () => {
|
||||||
|
const ids = apps.map((app) => app.id);
|
||||||
|
expect(new Set(ids).size).toBe(ids.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has unique urls', () => {
|
||||||
|
const urls = apps.map((app) => app.url);
|
||||||
|
expect(new Set(urls).size).toBe(urls.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(apps)('$id links to an https pumppi.dev subdomain', (app) => {
|
||||||
|
const url = new URL(app.url);
|
||||||
|
expect(url.protocol).toBe('https:');
|
||||||
|
expect(url.hostname).toMatch(/^[a-z0-9-]+\.pumppi\.dev$/);
|
||||||
|
expect(url.pathname).toBe('/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(apps)('$id has a name, description and known group', (app) => {
|
||||||
|
expect(app.name.trim()).not.toBe('');
|
||||||
|
expect(app.description.trim()).not.toBe('');
|
||||||
|
expect(groups.map((g) => g.id)).toContain(app.group);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(apps)('$id lists at least one access kind, without duplicates', (app) => {
|
||||||
|
expect(app.access.length).toBeGreaterThan(0);
|
||||||
|
expect(new Set(app.access).size).toBe(app.access.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has no empty groups', () => {
|
||||||
|
for (const group of groups) {
|
||||||
|
expect(apps.some((app) => app.group === group.id)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes every deployed app with a web UI', () => {
|
||||||
|
expect(apps.map((app) => hostOf(app.url)).toSorted()).toEqual(
|
||||||
|
[
|
||||||
|
'argo.pumppi.dev',
|
||||||
|
'auth.pumppi.dev',
|
||||||
|
'dashboard.pumppi.dev',
|
||||||
|
'git.pumppi.dev',
|
||||||
|
'grafana.pumppi.dev',
|
||||||
|
'meal.pumppi.dev',
|
||||||
|
'vault.pumppi.dev',
|
||||||
|
'whoami.pumppi.dev',
|
||||||
|
'workh.pumppi.dev',
|
||||||
|
].toSorted(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks apps behind Authelia that also keep their own login', () => {
|
||||||
|
const byId = Object.fromEntries(apps.map((app) => [app.id, app.access]));
|
||||||
|
expect(byId.grafana).toEqual(['sso', 'login']);
|
||||||
|
expect(byId.argocd).toEqual(['sso', 'login']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('hostOf', () => {
|
||||||
|
it('returns the host part of a url', () => {
|
||||||
|
expect(hostOf('https://git.pumppi.dev')).toBe('git.pumppi.dev');
|
||||||
|
expect(hostOf('https://git.pumppi.dev:2222/foo')).toBe('git.pumppi.dev:2222');
|
||||||
|
});
|
||||||
|
});
|
||||||
+103
@@ -0,0 +1,103 @@
|
|||||||
|
export type Access = 'sso' | 'login';
|
||||||
|
|
||||||
|
export type AppGroupId = 'apps' | 'admin';
|
||||||
|
|
||||||
|
export interface AppInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
description: string;
|
||||||
|
group: AppGroupId;
|
||||||
|
/** How you get in: Authelia SSO in front, the app's own login, or both. */
|
||||||
|
access: readonly Access[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppGroupInfo {
|
||||||
|
id: AppGroupId;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const groups: readonly AppGroupInfo[] = [
|
||||||
|
{ id: 'apps', title: 'Apps' },
|
||||||
|
{ id: 'admin', title: 'Admin & infra' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Mirrors the deployed apps in k8s-pumppi (README "What runs here").
|
||||||
|
export const apps: readonly AppInfo[] = [
|
||||||
|
{
|
||||||
|
id: 'gitea',
|
||||||
|
name: 'Gitea',
|
||||||
|
url: 'https://git.pumppi.dev',
|
||||||
|
description: 'Git hosting, ssh on :2222',
|
||||||
|
group: 'apps',
|
||||||
|
access: ['login'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'vaultwarden',
|
||||||
|
name: 'Vaultwarden',
|
||||||
|
url: 'https://vault.pumppi.dev',
|
||||||
|
description: 'Password manager',
|
||||||
|
group: 'apps',
|
||||||
|
access: ['login'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mealie',
|
||||||
|
name: 'Mealie',
|
||||||
|
url: 'https://meal.pumppi.dev',
|
||||||
|
description: 'Recipes and meal plans',
|
||||||
|
group: 'apps',
|
||||||
|
access: ['login'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'workh',
|
||||||
|
name: 'workh',
|
||||||
|
url: 'https://workh.pumppi.dev',
|
||||||
|
description: 'Work hour tracking',
|
||||||
|
group: 'apps',
|
||||||
|
access: ['login'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'argocd',
|
||||||
|
name: 'Argo CD',
|
||||||
|
url: 'https://argo.pumppi.dev',
|
||||||
|
description: 'GitOps deployments',
|
||||||
|
group: 'admin',
|
||||||
|
access: ['sso', 'login'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'grafana',
|
||||||
|
name: 'Grafana',
|
||||||
|
url: 'https://grafana.pumppi.dev',
|
||||||
|
description: 'Metrics and logs',
|
||||||
|
group: 'admin',
|
||||||
|
access: ['sso', 'login'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'traefik',
|
||||||
|
name: 'Traefik',
|
||||||
|
url: 'https://dashboard.pumppi.dev',
|
||||||
|
description: 'Ingress dashboard',
|
||||||
|
group: 'admin',
|
||||||
|
access: ['sso'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'authelia',
|
||||||
|
name: 'Authelia',
|
||||||
|
url: 'https://auth.pumppi.dev',
|
||||||
|
description: 'Single sign-on',
|
||||||
|
group: 'admin',
|
||||||
|
access: ['sso'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'whoami',
|
||||||
|
name: 'whoami',
|
||||||
|
url: 'https://whoami.pumppi.dev',
|
||||||
|
description: 'Request echo for testing',
|
||||||
|
group: 'admin',
|
||||||
|
access: ['sso'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function hostOf(url: string): string {
|
||||||
|
return new URL(url).host;
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { render, screen, within } from '@testing-library/react';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { AppInfo } from '../apps';
|
||||||
|
import { AppCard } from './AppCard';
|
||||||
|
|
||||||
|
const base: AppInfo = {
|
||||||
|
id: 'grafana',
|
||||||
|
name: 'Grafana',
|
||||||
|
url: 'https://grafana.pumppi.dev',
|
||||||
|
description: 'Metrics and logs dashboards',
|
||||||
|
group: 'admin',
|
||||||
|
access: ['sso', 'login'],
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderCard(app: AppInfo) {
|
||||||
|
render(
|
||||||
|
<ul>
|
||||||
|
<AppCard app={app} />
|
||||||
|
</ul>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AppCard', () => {
|
||||||
|
it('renders a link to the app named after it', () => {
|
||||||
|
renderCard(base);
|
||||||
|
const link = screen.getByRole('link', { name: /grafana/i });
|
||||||
|
expect(link).toHaveAttribute('href', 'https://grafana.pumppi.dev');
|
||||||
|
expect(link).toHaveAttribute('rel', 'noopener');
|
||||||
|
expect(link).toHaveAccessibleName('Grafana grafana.pumppi.dev');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the description', () => {
|
||||||
|
renderCard(base);
|
||||||
|
expect(screen.getByText('Metrics and logs dashboards')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows both badges for SSO + own login', () => {
|
||||||
|
renderCard(base);
|
||||||
|
const badges = within(screen.getByRole('list', { name: 'Access' })).getAllByRole('listitem');
|
||||||
|
expect(badges.map((b) => b.textContent)).toEqual(['SSO', 'Login']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows only the SSO badge for SSO-only apps', () => {
|
||||||
|
renderCard({ ...base, access: ['sso'] });
|
||||||
|
const badges = within(screen.getByRole('list', { name: 'Access' })).getAllByRole('listitem');
|
||||||
|
expect(badges.map((b) => b.textContent)).toEqual(['SSO']);
|
||||||
|
expect(badges[0]).toHaveAttribute('title', 'Behind Authelia single sign-on');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows only the login badge for apps with their own login', () => {
|
||||||
|
renderCard({ ...base, access: ['login'] });
|
||||||
|
const badges = within(screen.getByRole('list', { name: 'Access' })).getAllByRole('listitem');
|
||||||
|
expect(badges.map((b) => b.textContent)).toEqual(['Login']);
|
||||||
|
expect(badges[0]).toHaveAttribute('title', 'Has its own login');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { hostOf, type Access, type AppInfo } from '../apps';
|
||||||
|
|
||||||
|
const accessLabels: Record<Access, { label: string; title: string }> = {
|
||||||
|
sso: { label: 'SSO', title: 'Behind Authelia single sign-on' },
|
||||||
|
login: { label: 'Login', title: 'Has its own login' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AppCard({ app }: { app: AppInfo }) {
|
||||||
|
return (
|
||||||
|
<li className="card">
|
||||||
|
<a className="card-link" href={app.url} rel="noopener">
|
||||||
|
<span className="card-name">{app.name}</span>{' '}
|
||||||
|
<span className="card-host">{hostOf(app.url)}</span>
|
||||||
|
</a>
|
||||||
|
<p className="card-description">{app.description}</p>
|
||||||
|
<ul className="badges" aria-label="Access">
|
||||||
|
{app.access.map((kind) => (
|
||||||
|
<li key={kind} className={`badge badge-${kind}`} title={accessLabels[kind].title}>
|
||||||
|
{accessLabels[kind].label}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { render, screen, within } from '@testing-library/react';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { apps, groups } from '../apps';
|
||||||
|
import { AppGroup } from './AppGroup';
|
||||||
|
|
||||||
|
describe('AppGroup', () => {
|
||||||
|
it('renders a labelled section with one card per app', () => {
|
||||||
|
const group = groups[0]!;
|
||||||
|
const groupApps = apps.filter((app) => app.group === group.id);
|
||||||
|
render(<AppGroup group={group} apps={groupApps} />);
|
||||||
|
|
||||||
|
const section = screen.getByRole('region', { name: group.title });
|
||||||
|
expect(within(section).getByRole('heading', { level: 2 })).toHaveTextContent(group.title);
|
||||||
|
expect(within(section).getAllByRole('link')).toHaveLength(groupApps.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { AppGroupInfo, AppInfo } from '../apps';
|
||||||
|
import { AppCard } from './AppCard';
|
||||||
|
|
||||||
|
export function AppGroup({ group, apps }: { group: AppGroupInfo; apps: readonly AppInfo[] }) {
|
||||||
|
const headingId = `group-${group.id}`;
|
||||||
|
return (
|
||||||
|
<section className="group" aria-labelledby={headingId}>
|
||||||
|
<h2 id={headingId}>{group.title}</h2>
|
||||||
|
<ul className="cards">
|
||||||
|
{apps.map((app) => (
|
||||||
|
<AppCard key={app.id} app={app} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { SearchBox } from './SearchBox';
|
||||||
|
|
||||||
|
function Controlled({ onChange }: { onChange?: (value: string) => void }) {
|
||||||
|
const [value, setValue] = useState('');
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SearchBox
|
||||||
|
value={value}
|
||||||
|
onChange={(v) => {
|
||||||
|
setValue(v);
|
||||||
|
onChange?.(v);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<textarea aria-label="other field" />
|
||||||
|
<button type="button">elsewhere</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SearchBox', () => {
|
||||||
|
it('is labelled for screen readers', () => {
|
||||||
|
render(<Controlled />);
|
||||||
|
expect(screen.getByRole('searchbox', { name: 'Search apps' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports typed text', async () => {
|
||||||
|
const onChange = vi.fn<(value: string) => void>();
|
||||||
|
render(<Controlled onChange={onChange} />);
|
||||||
|
await userEvent.type(screen.getByRole('searchbox'), 'git');
|
||||||
|
expect(onChange).toHaveBeenLastCalledWith('git');
|
||||||
|
expect(screen.getByRole('searchbox')).toHaveValue('git');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('focuses on "/" without typing the slash', async () => {
|
||||||
|
render(<Controlled />);
|
||||||
|
screen.getByRole('button', { name: 'elsewhere' }).focus();
|
||||||
|
await userEvent.keyboard('/');
|
||||||
|
const input = screen.getByRole('searchbox');
|
||||||
|
expect(input).toHaveFocus();
|
||||||
|
expect(input).toHaveValue('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores "/" with a modifier key', async () => {
|
||||||
|
render(<Controlled />);
|
||||||
|
screen.getByRole('button', { name: 'elsewhere' }).focus();
|
||||||
|
await userEvent.keyboard('{Control>}/{/Control}');
|
||||||
|
expect(screen.getByRole('searchbox')).not.toHaveFocus();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets "/" be typed into other fields', async () => {
|
||||||
|
render(<Controlled />);
|
||||||
|
const other = screen.getByRole('textbox', { name: 'other field' });
|
||||||
|
await userEvent.type(other, 'a/b');
|
||||||
|
expect(other).toHaveValue('a/b');
|
||||||
|
expect(other).toHaveFocus();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets "/" be typed into the search box itself', async () => {
|
||||||
|
render(<Controlled />);
|
||||||
|
await userEvent.type(screen.getByRole('searchbox'), 'a/b');
|
||||||
|
expect(screen.getByRole('searchbox')).toHaveValue('a/b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears on Escape', async () => {
|
||||||
|
render(<Controlled />);
|
||||||
|
const input = screen.getByRole('searchbox');
|
||||||
|
await userEvent.type(input, 'vault');
|
||||||
|
await userEvent.keyboard('{Escape}');
|
||||||
|
expect(input).toHaveValue('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes its key listener on unmount', async () => {
|
||||||
|
const remove = vi.spyOn(window, 'removeEventListener');
|
||||||
|
const { unmount } = render(<Controlled />);
|
||||||
|
unmount();
|
||||||
|
expect(remove).toHaveBeenCalledWith('keydown', expect.any(Function));
|
||||||
|
remove.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTypingTarget(target: EventTarget | null): boolean {
|
||||||
|
return (
|
||||||
|
target instanceof HTMLElement &&
|
||||||
|
(target.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchBox({ value, onChange }: Props) {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// "/" jumps to the search box from anywhere on the page
|
||||||
|
useEffect(() => {
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key !== '/' || event.ctrlKey || event.metaKey || event.altKey) return;
|
||||||
|
if (isTypingTarget(event.target)) return;
|
||||||
|
event.preventDefault();
|
||||||
|
inputRef.current?.focus();
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', onKeyDown);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="search">
|
||||||
|
<label htmlFor="search" className="visually-hidden">
|
||||||
|
Search apps
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
id="search"
|
||||||
|
type="search"
|
||||||
|
placeholder="Search apps…"
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => onChange(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Escape') onChange('');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<kbd aria-hidden="true">/</kbd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { AppInfo } from './apps';
|
||||||
|
import { filterApps } from './filterApps';
|
||||||
|
|
||||||
|
const sample: AppInfo[] = [
|
||||||
|
{
|
||||||
|
id: 'gitea',
|
||||||
|
name: 'Gitea',
|
||||||
|
url: 'https://git.pumppi.dev',
|
||||||
|
description: 'Git hosting',
|
||||||
|
group: 'apps',
|
||||||
|
access: ['login'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'grafana',
|
||||||
|
name: 'Grafana',
|
||||||
|
url: 'https://grafana.pumppi.dev',
|
||||||
|
description: 'Metrics and logs',
|
||||||
|
group: 'admin',
|
||||||
|
access: ['sso', 'login'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mealie',
|
||||||
|
name: 'Mealie',
|
||||||
|
url: 'https://meal.pumppi.dev',
|
||||||
|
description: 'Recipes',
|
||||||
|
group: 'apps',
|
||||||
|
access: ['login'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const ids = (list: AppInfo[]) => list.map((app) => app.id);
|
||||||
|
|
||||||
|
describe('filterApps', () => {
|
||||||
|
it('returns everything for an empty or blank query', () => {
|
||||||
|
expect(ids(filterApps(sample, ''))).toEqual(['gitea', 'grafana', 'mealie']);
|
||||||
|
expect(ids(filterApps(sample, ' '))).toEqual(['gitea', 'grafana', 'mealie']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a copy, not the input array', () => {
|
||||||
|
expect(filterApps(sample, '')).not.toBe(sample);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches names case-insensitively', () => {
|
||||||
|
expect(ids(filterApps(sample, 'GRAF'))).toEqual(['grafana']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches descriptions', () => {
|
||||||
|
expect(ids(filterApps(sample, 'recipes'))).toEqual(['mealie']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches hosts', () => {
|
||||||
|
expect(ids(filterApps(sample, 'meal.pumppi'))).toEqual(['mealie']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('trims surrounding whitespace', () => {
|
||||||
|
expect(ids(filterApps(sample, ' logs '))).toEqual(['grafana']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the original order when several match', () => {
|
||||||
|
expect(ids(filterApps(sample, 'g'))).toEqual(['gitea', 'grafana']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns nothing when no app matches', () => {
|
||||||
|
expect(filterApps(sample, 'kubernetes')).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { hostOf, type AppInfo } from './apps';
|
||||||
|
|
||||||
|
export function filterApps(list: readonly AppInfo[], query: string): AppInfo[] {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
if (!q) return [...list];
|
||||||
|
return list.filter((app) =>
|
||||||
|
[app.name, app.description, hostOf(app.url)].some((field) => field.toLowerCase().includes(q)),
|
||||||
|
);
|
||||||
|
}
|
||||||
+359
@@ -0,0 +1,359 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #f3f5fb;
|
||||||
|
--glow-1: rgb(99 102 241 / 16%);
|
||||||
|
--glow-2: rgb(6 182 212 / 14%);
|
||||||
|
--surface: rgb(255 255 255 / 80%);
|
||||||
|
--surface-solid: #ffffff;
|
||||||
|
--border: #dfe3ef;
|
||||||
|
--text: #111827;
|
||||||
|
--muted: #545d75;
|
||||||
|
--accent: #4f46e5;
|
||||||
|
--accent-2: #0891b2;
|
||||||
|
--host: #0e7490;
|
||||||
|
--gradient: linear-gradient(120deg, #4f46e5, #7c3aed 45%, #0891b2);
|
||||||
|
--sso: #5b21b6;
|
||||||
|
--sso-soft: #ede9fe;
|
||||||
|
--login: #115e59;
|
||||||
|
--login-soft: #ccfbf1;
|
||||||
|
--focus: #4f46e5;
|
||||||
|
--shadow: 0 1px 2px rgb(17 24 39 / 5%), 0 4px 16px -8px rgb(17 24 39 / 10%);
|
||||||
|
--shadow-hover: 0 10px 26px -13px rgb(79 70 229 / 28%);
|
||||||
|
|
||||||
|
color-scheme: light dark;
|
||||||
|
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--bg: #090c16;
|
||||||
|
--glow-1: rgb(99 102 241 / 22%);
|
||||||
|
--glow-2: rgb(34 211 238 / 12%);
|
||||||
|
--surface: rgb(255 255 255 / 4%);
|
||||||
|
--surface-solid: #121729;
|
||||||
|
--border: rgb(255 255 255 / 9%);
|
||||||
|
--text: #e7eaf3;
|
||||||
|
--muted: #9aa3b9;
|
||||||
|
--accent: #a5b4fc;
|
||||||
|
--accent-2: #67e8f9;
|
||||||
|
--host: #67e8f9;
|
||||||
|
--gradient: linear-gradient(120deg, #a5b4fc, #c4b5fd 45%, #67e8f9);
|
||||||
|
--sso: #ddd6fe;
|
||||||
|
--sso-soft: rgb(139 92 246 / 22%);
|
||||||
|
--login: #99f6e4;
|
||||||
|
--login-soft: rgb(20 184 166 / 18%);
|
||||||
|
--focus: #a5b4fc;
|
||||||
|
--shadow: none;
|
||||||
|
--shadow-hover: 0 10px 30px -13px rgb(99 102 241 / 42%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
background:
|
||||||
|
radial-gradient(60rem 30rem at 10% -10%, var(--glow-1), transparent 70%),
|
||||||
|
radial-gradient(50rem 28rem at 95% 0%, var(--glow-2), transparent 70%), var(--bg);
|
||||||
|
background-attachment: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
max-width: 1240px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 32px 16px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
width: fit-content;
|
||||||
|
margin: 0;
|
||||||
|
font-family: 'Space Grotesk Variable', system-ui, sans-serif;
|
||||||
|
font-size: clamp(1.9rem, 5vw, 2.5rem);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--gradient);
|
||||||
|
background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagline {
|
||||||
|
margin: 2px 0 16px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search {
|
||||||
|
position: relative;
|
||||||
|
max-width: 420px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 40px 8px 12px;
|
||||||
|
font: inherit;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--surface-solid);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
transition:
|
||||||
|
border-color 150ms,
|
||||||
|
box-shadow 150ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search input:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--focus);
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--focus) 25%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search kbd {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
right: 12px;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
padding: 0 6px;
|
||||||
|
font:
|
||||||
|
0.8rem ui-monospace,
|
||||||
|
monospace;
|
||||||
|
color: var(--muted);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group {
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group h2 {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.group h2::after {
|
||||||
|
content: '';
|
||||||
|
flex: 1;
|
||||||
|
height: 1px;
|
||||||
|
background: linear-gradient(90deg, var(--border), transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cards {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
transition:
|
||||||
|
transform 220ms ease-out,
|
||||||
|
border-color 220ms ease-out,
|
||||||
|
box-shadow 220ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* gradient accent bar along the top edge, shown on hover */
|
||||||
|
.card::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0 0 auto;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--gradient);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 220ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover,
|
||||||
|
.card:focus-within {
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 33%, var(--border));
|
||||||
|
box-shadow: var(--shadow-hover);
|
||||||
|
transform: translateY(-3px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover::before,
|
||||||
|
.card:focus-within::before {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-link {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* whole card is clickable, the link stays the only interactive element */
|
||||||
|
.card-link::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-link:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:has(.card-link:focus-visible) {
|
||||||
|
outline: 2px solid var(--focus);
|
||||||
|
outline-offset: 2px;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-name {
|
||||||
|
/* leave room for the badges in the top right corner */
|
||||||
|
padding-right: 96px;
|
||||||
|
font-family: 'Space Grotesk Variable', system-ui, sans-serif;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-host {
|
||||||
|
font:
|
||||||
|
0.8rem ui-monospace,
|
||||||
|
monospace;
|
||||||
|
color: var(--host);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-description {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badges {
|
||||||
|
position: absolute;
|
||||||
|
top: 13px;
|
||||||
|
right: 14px;
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 1px 8px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-sso {
|
||||||
|
color: var(--sso);
|
||||||
|
background: var(--sso-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-login {
|
||||||
|
color: var(--login);
|
||||||
|
background: var(--login-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
display: block;
|
||||||
|
margin-top: 24px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
margin-top: 28px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.page {
|
||||||
|
padding-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagline {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group {
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* two narrow columns so everything fits on a phone screen */
|
||||||
|
.cards {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-name {
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-host {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-description {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badges {
|
||||||
|
position: static;
|
||||||
|
order: 1;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.card,
|
||||||
|
.card::before,
|
||||||
|
.search input {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover,
|
||||||
|
.card:focus-within {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.visually-hidden {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip-path: inset(50%);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { StrictMode } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { App } from './App';
|
||||||
|
import '@fontsource-variable/space-grotesk';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import '@testing-library/jest-dom/vitest';
|
||||||
|
import { cleanup } from '@testing-library/react';
|
||||||
|
import { afterEach } from 'vitest';
|
||||||
|
|
||||||
|
afterEach(() => cleanup());
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"types": ["vite/client", "node", "@testing-library/jest-dom/vitest"]
|
||||||
|
},
|
||||||
|
"include": ["src", "e2e", "vite.config.ts", "playwright.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/// <reference types="vitest/config" />
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
setupFiles: ['./src/setupTests.ts'],
|
||||||
|
include: ['src/**/*.test.{ts,tsx}'],
|
||||||
|
coverage: {
|
||||||
|
provider: 'v8',
|
||||||
|
include: ['src/**/*.{ts,tsx}'],
|
||||||
|
exclude: ['src/main.tsx', 'src/setupTests.ts', 'src/**/*.test.{ts,tsx}'],
|
||||||
|
thresholds: { lines: 90, functions: 90, branches: 90, statements: 90 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user