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