aria-grid-demo: live examples of @jambotron/aria-grid with Jambotron light/dark and Synthwave themes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
.git
|
||||
.angular
|
||||
.vscode
|
||||
node_modules
|
||||
dist
|
||||
*.log
|
||||
@@ -0,0 +1,17 @@
|
||||
# Editor configuration, see https://editorconfig.org
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.ts]
|
||||
quote_type = single
|
||||
ij_typescript_use_double_quotes = false
|
||||
|
||||
[*.md]
|
||||
max_line_length = off
|
||||
trim_trailing_whitespace = false
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
|
||||
|
||||
# Compiled output
|
||||
/dist
|
||||
/tmp
|
||||
/out-tsc
|
||||
/bazel-out
|
||||
|
||||
# Node
|
||||
/node_modules
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
|
||||
# IDEs and editors
|
||||
.idea/
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# Visual Studio Code
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
!.vscode/mcp.json
|
||||
.history/*
|
||||
|
||||
# Miscellaneous
|
||||
/.angular/cache
|
||||
.sass-cache/
|
||||
/connect.lock
|
||||
/coverage
|
||||
/libpeerconnection.log
|
||||
testem.log
|
||||
/typings
|
||||
__screenshots__/
|
||||
|
||||
# System files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -0,0 +1 @@
|
||||
@jambotron:registry=https://gitea.jambotron.com/api/packages/jambotron_gang/npm/
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"printWidth": 100,
|
||||
"singleQuote": true,
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.html",
|
||||
"options": {
|
||||
"parser": "angular"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
{
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
|
||||
"recommendations": ["angular.ng-template"]
|
||||
}
|
||||
Vendored
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "ng serve",
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "npm: start",
|
||||
"url": "http://localhost:4200/"
|
||||
},
|
||||
{
|
||||
"name": "ng test",
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "npm: test",
|
||||
"url": "http://localhost:9876/debug.html"
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+42
@@ -0,0 +1,42 @@
|
||||
{
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "start",
|
||||
"isBackground": true,
|
||||
"problemMatcher": {
|
||||
"owner": "typescript",
|
||||
"pattern": "$tsc",
|
||||
"background": {
|
||||
"activeOnStart": true,
|
||||
"beginsPattern": {
|
||||
"regexp": "Changes detected"
|
||||
},
|
||||
"endsPattern": {
|
||||
"regexp": "bundle generation (complete|failed)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "test",
|
||||
"isBackground": true,
|
||||
"problemMatcher": {
|
||||
"owner": "typescript",
|
||||
"pattern": "$tsc",
|
||||
"background": {
|
||||
"activeOnStart": true,
|
||||
"beginsPattern": {
|
||||
"regexp": "Changes detected"
|
||||
},
|
||||
"endsPattern": {
|
||||
"regexp": "bundle generation (complete|failed)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
# 1. Build the Angular app. Debian images, not Alpine: Alpine binaries die with "Bus error" on some Jambotron VMs.
|
||||
FROM node:22-bookworm-slim AS build
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json .npmrc ./
|
||||
RUN npm ci --no-audit --no-fund
|
||||
COPY . .
|
||||
RUN npx ng build --configuration=production
|
||||
|
||||
# 2. Serve the static files
|
||||
FROM nginx:1.27
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/dist/aria-grid-demo/browser /usr/share/nginx/html
|
||||
EXPOSE 80
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 CMD curl -fsS http://localhost/ >/dev/null || exit 1
|
||||
@@ -0,0 +1,30 @@
|
||||
# aria-grid-demo
|
||||
|
||||
Live examples of [`@jambotron/aria-grid`](https://gitea.jambotron.com/jambotron_gang/-/packages/npm/@jambotron%2Faria-grid) —
|
||||
an Angular data grid on Angular Aria. Three examples and a theme switch:
|
||||
|
||||
1. **Columns, sorting, paging** — cell templates, a column without sorting, filter and the empty state.
|
||||
2. **Selection and bulk actions** — `JtGridBulkService`: an action without confirmation, with a required reason,
|
||||
with a dangerous confirmation; "locked" rows fail on purpose to show "done N, failed M".
|
||||
3. **Row actions and highlight** — icons in the row, the "⋮" menu, dangerous and disabled actions, the open row highlighted.
|
||||
|
||||
Themes: **Jambotron light**, **Jambotron dark** (the tokens of jambotron.com) and **Synthwave** (the colors of
|
||||
reactive-mail-server's web mail). A theme is one attribute — `<html data-theme="…">`; everything is in `src/styles.scss`.
|
||||
The grid language switch (English / Russian) shows `provideJtGridLabels()`.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
npm ci # @jambotron scope comes from the Gitea npm registry (.npmrc), no token needed
|
||||
npx ng serve # http://localhost:4200
|
||||
```
|
||||
|
||||
## Deploy on Jambotron
|
||||
|
||||
Upload the repository (ZIP) as a project, pick `docker-compose.yml` in the deploy wizard, give the `web` service a domain,
|
||||
port `80`. The image builds the app (`node:22-bookworm-slim`) and serves it with nginx (`nginx:1.27`, Debian);
|
||||
any unknown path falls back to `index.html`.
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
@@ -0,0 +1,97 @@
|
||||
{
|
||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||
"version": 1,
|
||||
"cli": {
|
||||
"packageManager": "npm"
|
||||
},
|
||||
"newProjectRoot": "projects",
|
||||
"projects": {
|
||||
"aria-grid-demo": {
|
||||
"projectType": "application",
|
||||
"schematics": {
|
||||
"@schematics/angular:component": {
|
||||
"style": "scss",
|
||||
"skipTests": true
|
||||
},
|
||||
"@schematics/angular:class": {
|
||||
"skipTests": true
|
||||
},
|
||||
"@schematics/angular:directive": {
|
||||
"skipTests": true
|
||||
},
|
||||
"@schematics/angular:guard": {
|
||||
"skipTests": true
|
||||
},
|
||||
"@schematics/angular:interceptor": {
|
||||
"skipTests": true
|
||||
},
|
||||
"@schematics/angular:pipe": {
|
||||
"skipTests": true
|
||||
},
|
||||
"@schematics/angular:resolver": {
|
||||
"skipTests": true
|
||||
},
|
||||
"@schematics/angular:service": {
|
||||
"skipTests": true
|
||||
}
|
||||
},
|
||||
"root": "",
|
||||
"sourceRoot": "src",
|
||||
"prefix": "app",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular/build:application",
|
||||
"options": {
|
||||
"browser": "src/main.ts",
|
||||
"tsConfig": "tsconfig.app.json",
|
||||
"inlineStyleLanguage": "scss",
|
||||
"assets": [
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "public"
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
"src/styles.scss"
|
||||
]
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "1MB",
|
||||
"maximumError": "2MB"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "4kB",
|
||||
"maximumError": "8kB"
|
||||
}
|
||||
],
|
||||
"outputHashing": "all"
|
||||
},
|
||||
"development": {
|
||||
"optimization": false,
|
||||
"extractLicenses": false,
|
||||
"sourceMap": true
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "production"
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular/build:dev-server",
|
||||
"configurations": {
|
||||
"production": {
|
||||
"buildTarget": "aria-grid-demo:build:production"
|
||||
},
|
||||
"development": {
|
||||
"buildTarget": "aria-grid-demo:build:development"
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
# Deploy on Jambotron: pick this file in the deploy wizard, give the "web" service a domain, port 80.
|
||||
name: aria-grid-demo
|
||||
|
||||
services:
|
||||
web:
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
expose:
|
||||
- "80"
|
||||
mem_limit: 64m
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
server {
|
||||
listen 80;
|
||||
root /usr/share/nginx/html;
|
||||
|
||||
# Hashed bundles never change — cache for a year; index.html — always fresh
|
||||
location ~* \.(js|css|woff2?|ico|png|svg)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location / {
|
||||
add_header Cache-Control "no-cache";
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
gzip on;
|
||||
gzip_types text/css application/javascript image/svg+xml;
|
||||
}
|
||||
Generated
+6088
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "aria-grid-demo",
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"start": "ng serve",
|
||||
"build": "ng build",
|
||||
"watch": "ng build --watch --configuration development",
|
||||
"test": "ng test"
|
||||
},
|
||||
"private": true,
|
||||
"packageManager": "npm@10.9.9",
|
||||
"dependencies": {
|
||||
"@angular/aria": "^22.2.6",
|
||||
"@angular/cdk": "^22.2.6",
|
||||
"@angular/common": "^22.2.0",
|
||||
"@angular/compiler": "^22.2.0",
|
||||
"@angular/core": "^22.2.0",
|
||||
"@angular/forms": "^22.2.0",
|
||||
"@angular/material": "^22.2.6",
|
||||
"@angular/platform-browser": "^22.2.0",
|
||||
"@angular/router": "^22.2.0",
|
||||
"@jambotron/aria-grid": "^0.1.0",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular/build": "^22.2.4",
|
||||
"@angular/cli": "^22.2.4",
|
||||
"@angular/compiler-cli": "^22.2.0",
|
||||
"prettier": "^3.8.1",
|
||||
"typescript": "~6.0.2"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,11 @@
|
||||
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
|
||||
import { provideJtGridLabels } from '@jambotron/aria-grid';
|
||||
import { lang, RU_LABELS } from './i18n/labels';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideBrowserGlobalErrorListeners(),
|
||||
// Grid texts: a function, so they follow the language switch. English needs nothing — it is the default.
|
||||
provideJtGridLabels(() => (lang() === 'ru' ? RU_LABELS : {}))
|
||||
]
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
<header class="top">
|
||||
<div class="brand">
|
||||
<span class="brand-mark" aria-hidden="true">▦</span>
|
||||
<div>
|
||||
<h1>@jambotron/aria-grid</h1>
|
||||
<p>Angular data grid on Angular Aria — live examples</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="switches">
|
||||
<!-- jt-select comes with the grid package: the same combobox the grid uses for the page size -->
|
||||
<jt-select label="Theme" [options]="themes" [value]="theme()" (valueChange)="$event && theme.set($event)" />
|
||||
<jt-select label="Grid language" [options]="langs" [value]="lang()" (valueChange)="$event && lang.set($event)" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="panel" aria-labelledby="ex1">
|
||||
<h2 id="ex1"><span>1</span> Columns, sorting, paging</h2>
|
||||
<p class="lead">
|
||||
Click a header to sort: ascending → descending → original order. "Owner" has no <code>sort</code>, so it is not a button.
|
||||
Type in the filter — the page and the sort stay, an empty result shows <code>emptyMessage</code>.
|
||||
</p>
|
||||
<demo-basic-example />
|
||||
</section>
|
||||
|
||||
<section class="panel" aria-labelledby="ex2">
|
||||
<h2 id="ex2"><span>2</span> Selection and bulk actions</h2>
|
||||
<p class="lead">
|
||||
"Restart" runs at once, "Stop" asks for a reason, "Delete" asks for confirmation. Servers marked
|
||||
<span class="chip chip--locked">locked</span> fail on purpose: the rest still run, and the result says "done N, failed M".
|
||||
The selection survives sorting and paging.
|
||||
</p>
|
||||
<demo-bulk-example />
|
||||
</section>
|
||||
|
||||
<section class="panel" aria-labelledby="ex3">
|
||||
<h2 id="ex3"><span>3</span> Row actions and highlight</h2>
|
||||
<p class="lead">
|
||||
Two icons in the row, the rest in the "⋮" menu, "Delete" last in red. On locked servers "Rename" and "Delete" are disabled.
|
||||
"Open" highlights the row while its details are shown.
|
||||
</p>
|
||||
<demo-row-actions-example />
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="foot">
|
||||
Deployed on <a href="https://jambotron.com" target="_blank" rel="noopener">jambotron.com</a> ·
|
||||
source <a href="https://gitea.jambotron.com/jambotron_gang/aria-grid-demo" target="_blank" rel="noopener">aria-grid-demo</a>
|
||||
</footer>
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||
import { JtSelectComponent, JtSelectOption } from '@jambotron/aria-grid';
|
||||
import { BasicExampleComponent } from './examples/basic-example';
|
||||
import { BulkExampleComponent } from './examples/bulk-example';
|
||||
import { RowActionsExampleComponent } from './examples/row-actions-example';
|
||||
import { Lang, lang } from './i18n/labels';
|
||||
import { THEMES, ThemeService } from './theme/theme.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
imports: [JtSelectComponent, BasicExampleComponent, BulkExampleComponent, RowActionsExampleComponent],
|
||||
templateUrl: './app.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class App {
|
||||
protected readonly theme = inject(ThemeService).theme;
|
||||
protected readonly themes = THEMES;
|
||||
protected readonly lang = lang;
|
||||
protected readonly langs: JtSelectOption<Lang>[] = [
|
||||
{ value: 'en', label: 'English' },
|
||||
{ value: 'ru', label: 'Русский' }
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { delay, Observable, of, throwError } from 'rxjs';
|
||||
|
||||
export type ServerStatus = 'running' | 'stopped' | 'error';
|
||||
|
||||
export interface Server {
|
||||
id: number;
|
||||
name: string;
|
||||
region: string;
|
||||
status: ServerStatus;
|
||||
cpu: number; // load, %
|
||||
memoryGb: number;
|
||||
created: Date;
|
||||
owner: string;
|
||||
locked: boolean; // protected server: every action on it fails (shows "failed N" in bulk actions)
|
||||
}
|
||||
|
||||
const REGIONS = ['eu-central', 'eu-north', 'us-east', 'us-west', 'ap-south'];
|
||||
const ROLES = ['web', 'api', 'db', 'cache', 'queue', 'mail', 'worker', 'search'];
|
||||
const OWNERS = ['alice', 'bob', 'carol', 'dave', 'erin', 'frank'];
|
||||
const STATUSES: ServerStatus[] = ['running', 'running', 'running', 'stopped', 'error'];
|
||||
|
||||
// Same list on every load: a small seeded generator instead of Math.random()
|
||||
function seeded(seed: number): () => number {
|
||||
return () => {
|
||||
seed = (seed * 1664525 + 1013904223) % 4294967296;
|
||||
return seed / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
export function makeServers(count = 137): Server[] {
|
||||
const random = seeded(42);
|
||||
const pick = <T>(list: T[]) => list[Math.floor(random() * list.length)];
|
||||
const start = Date.UTC(2025, 0, 1);
|
||||
return Array.from({ length: count }, (_, i) => {
|
||||
const status = pick(STATUSES);
|
||||
return {
|
||||
id: i + 1,
|
||||
name: `${pick(ROLES)}-${String(i + 1).padStart(3, '0')}`,
|
||||
region: pick(REGIONS),
|
||||
status,
|
||||
cpu: status === 'running' ? Math.round(random() * 100) : 0,
|
||||
memoryGb: pick([1, 2, 4, 8, 16, 32]),
|
||||
created: new Date(start + Math.floor(random() * 640) * 86_400_000),
|
||||
owner: pick(OWNERS),
|
||||
locked: random() < 0.08
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Pretend backend: answers after a short delay; a locked server always fails. */
|
||||
export function fakeCall(server: Server): Observable<void> {
|
||||
return server.locked
|
||||
? throwError(() => new Error(`${server.name} is locked`)).pipe(delay(120))
|
||||
: of(undefined).pipe(delay(120));
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, signal } from '@angular/core';
|
||||
import { DatePipe } from '@angular/common';
|
||||
import { JtGridCellDirective, JtGridColumn, JtGridComponent } from '@jambotron/aria-grid';
|
||||
import { makeServers, Server } from '../data/servers';
|
||||
import { text } from '../i18n/labels';
|
||||
import { CodeComponent } from './code';
|
||||
|
||||
/** Example 1: columns, cell templates, sorting, paging, filtering and the empty state. */
|
||||
@Component({
|
||||
selector: 'demo-basic-example',
|
||||
imports: [JtGridComponent, JtGridCellDirective, DatePipe, CodeComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<label class="filter">
|
||||
<span>Filter</span>
|
||||
<input type="search" placeholder="name, region or owner" [value]="filter()" (input)="filter.set($any($event.target).value)" />
|
||||
</label>
|
||||
|
||||
<jt-grid [rows]="rows()" [columns]="columns()" [trackBy]="byId" [label]="t().servers"
|
||||
[initialSort]="{ key: 'name', direction: 'asc' }" [pageSize]="10" [emptyMessage]="t().nothing">
|
||||
<ng-template jtGridCell="status" let-row>
|
||||
<span class="chip chip--{{ row.status }}">{{ statusText(row.status) }}</span>
|
||||
</ng-template>
|
||||
<ng-template jtGridCell="cpu" let-row>
|
||||
<span class="meter" [style.--value]="row.cpu"><span>{{ row.cpu }}%</span></span>
|
||||
</ng-template>
|
||||
<ng-template jtGridCell="memoryGb" let-row>{{ row.memoryGb }} GB</ng-template>
|
||||
<ng-template jtGridCell="created" let-row>{{ row.created | date: 'mediumDate' }}</ng-template>
|
||||
</jt-grid>
|
||||
|
||||
<demo-code [code]="code" />
|
||||
`
|
||||
})
|
||||
export class BasicExampleComponent {
|
||||
protected readonly t = text;
|
||||
protected readonly statusText = (s: Server['status']) => this.t()[s];
|
||||
private readonly all = makeServers();
|
||||
protected readonly filter = signal('');
|
||||
|
||||
// rows is a plain input: filter the array yourself, the grid keeps the page and the sort
|
||||
protected readonly rows = computed(() => {
|
||||
const q = this.filter().trim().toLowerCase();
|
||||
return q ? this.all.filter(s => [s.name, s.region, s.owner].some(v => v.includes(q))) : this.all;
|
||||
});
|
||||
|
||||
// computed — so the column titles follow the language switch
|
||||
protected readonly columns = computed<JtGridColumn<Server>[]>(() => {
|
||||
const t = this.t();
|
||||
return [
|
||||
{ key: 'name', label: t.name, sort: s => s.name },
|
||||
{ key: 'region', label: t.region, sort: s => s.region },
|
||||
{ key: 'status', label: t.status, sort: s => s.status },
|
||||
{ key: 'cpu', label: t.cpu, sort: s => s.cpu, align: 'end' },
|
||||
{ key: 'memoryGb', label: t.memory, sort: s => s.memoryGb, align: 'end' },
|
||||
{ key: 'created', label: t.created, sort: s => s.created },
|
||||
{ key: 'owner', label: t.owner } // no sort — the header is not a button
|
||||
];
|
||||
});
|
||||
|
||||
protected readonly byId = (s: Server) => s.id;
|
||||
|
||||
protected readonly code = `columns: JtGridColumn<Server>[] = [
|
||||
{ key: 'name', label: 'Name', sort: s => s.name },
|
||||
{ key: 'status', label: 'Status', sort: s => s.status },
|
||||
{ key: 'cpu', label: 'CPU', sort: s => s.cpu, align: 'end' },
|
||||
{ key: 'created', label: 'Created', sort: s => s.created },
|
||||
{ key: 'owner', label: 'Owner' } // not sortable
|
||||
];
|
||||
byId = (s: Server) => s.id;
|
||||
|
||||
<jt-grid [rows]="rows()" [columns]="columns" [trackBy]="byId" label="Servers"
|
||||
[initialSort]="{ key: 'name', direction: 'asc' }" [pageSize]="10"
|
||||
emptyMessage="No servers match the filter.">
|
||||
<ng-template jtGridCell="status" let-row>
|
||||
<span class="chip chip--{{ row.status }}">{{ row.status }}</span>
|
||||
</ng-template>
|
||||
<ng-template jtGridCell="created" let-row>{{ row.created | date }}</ng-template>
|
||||
<!-- no template for "name" and "owner": the grid prints row[key] -->
|
||||
</jt-grid>`;
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
|
||||
import {
|
||||
JtGridBulkEvent, JtGridBulkResult, JtGridBulkService, JtGridCellDirective, JtGridColumn, JtGridComponent, RowAction
|
||||
} from '@jambotron/aria-grid';
|
||||
import { fakeCall, makeServers, Server } from '../data/servers';
|
||||
import { text } from '../i18n/labels';
|
||||
import { CodeComponent } from './code';
|
||||
|
||||
/** Example 2: row selection, "select all", bulk actions through JtGridBulkService (confirm, reason, partial failure). */
|
||||
@Component({
|
||||
selector: 'demo-bulk-example',
|
||||
imports: [JtGridComponent, JtGridCellDirective, CodeComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<jt-grid [rows]="servers()" [columns]="columns()" [trackBy]="byId" [label]="t().servers" [pageSize]="10"
|
||||
[bulkActions]="bulk()" (bulkAction)="onBulk($event)" [rowSubject]="nameOf">
|
||||
<ng-template jtGridCell="status" let-row>
|
||||
<span class="chip chip--{{ row.status }}">{{ statusText(row.status) }}</span>
|
||||
@if (row.locked) {
|
||||
<span class="chip chip--locked">{{ t().locked }}</span>
|
||||
}
|
||||
</ng-template>
|
||||
</jt-grid>
|
||||
|
||||
<p class="log" aria-live="polite">{{ log() }}</p>
|
||||
<button type="button" class="link" (click)="reset()">Reset the list</button>
|
||||
|
||||
<demo-code [code]="code" />
|
||||
`
|
||||
})
|
||||
export class BulkExampleComponent {
|
||||
private readonly bulkService = inject(JtGridBulkService);
|
||||
protected readonly t = text;
|
||||
protected readonly statusText = (s: Server['status']) => this.t()[s];
|
||||
protected readonly servers = signal(makeServers(48));
|
||||
protected readonly log = signal('Select rows (or the header checkbox for all of them) and pick an action above the table.');
|
||||
|
||||
protected readonly columns = computed<JtGridColumn<Server>[]>(() => {
|
||||
const t = this.t();
|
||||
return [
|
||||
{ key: 'name', label: t.name, sort: s => s.name },
|
||||
{ key: 'status', label: t.status, sort: s => s.status },
|
||||
{ key: 'owner', label: t.owner, sort: s => s.owner }
|
||||
];
|
||||
});
|
||||
|
||||
// Non-empty bulkActions = the checkbox column appears
|
||||
protected readonly bulk = computed<RowAction[]>(() => {
|
||||
const t = this.t();
|
||||
return [
|
||||
{ value: 'restart', label: t.restart, icon: 'restart_alt' },
|
||||
{ value: 'stop', label: t.stop, icon: 'stop_circle' },
|
||||
{ value: 'delete', label: t.delete, icon: 'delete', danger: true }
|
||||
];
|
||||
});
|
||||
|
||||
protected readonly byId = (s: Server) => s.id;
|
||||
protected readonly nameOf = (s: Server) => s.name;
|
||||
|
||||
protected onBulk(event: JtGridBulkEvent<Server>): void {
|
||||
const t = this.t();
|
||||
switch (event.action) {
|
||||
case 'restart':
|
||||
// No confirm — a safe action runs at once
|
||||
this.bulkService.run({ rows: event.rows, label: t.restart, run: fakeCall })
|
||||
.subscribe(result => this.done('restart', result, () => 'running'));
|
||||
break;
|
||||
case 'stop':
|
||||
// reason: true — one required reason for all rows, passed to run() as the second argument
|
||||
this.bulkService.run({ rows: event.rows, label: t.stop, confirm: t.reasonStop, reason: true, run: (s, _reason) => fakeCall(s) })
|
||||
.subscribe(result => this.done('stop', result, () => 'stopped'));
|
||||
break;
|
||||
case 'delete':
|
||||
this.bulkService.run({ rows: event.rows, label: t.delete, confirm: t.confirmDelete, danger: true, run: fakeCall })
|
||||
.subscribe(result => {
|
||||
const gone = new Set(result.done.map(s => s.id));
|
||||
this.servers.update(list => list.filter(s => !gone.has(s.id)));
|
||||
this.report('delete', result);
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
protected reset(): void {
|
||||
this.servers.set(makeServers(48));
|
||||
this.log.set('List restored.');
|
||||
}
|
||||
|
||||
private done(action: string, result: JtGridBulkResult<Server>, status: () => Server['status']): void {
|
||||
const ok = new Set(result.done.map(s => s.id));
|
||||
this.servers.update(list => list.map(s => ok.has(s.id) ? { ...s, status: status(), cpu: 0 } : s));
|
||||
this.report(action, result);
|
||||
}
|
||||
|
||||
private report(action: string, result: JtGridBulkResult<Server>): void {
|
||||
const failed = result.failed.map(s => s.name).join(', ');
|
||||
this.log.set(`${action}: done ${result.done.length}` + (failed ? `, failed ${result.failed.length} (locked: ${failed})` : ''));
|
||||
}
|
||||
|
||||
protected readonly code = `bulk: RowAction[] = [
|
||||
{ value: 'restart', label: 'Restart', icon: 'restart_alt' },
|
||||
{ value: 'stop', label: 'Stop', icon: 'stop_circle' },
|
||||
{ value: 'delete', label: 'Delete', icon: 'delete', danger: true }
|
||||
];
|
||||
|
||||
<jt-grid [rows]="servers()" [columns]="columns" [trackBy]="byId"
|
||||
[bulkActions]="bulk" (bulkAction)="onBulk($event)" [rowSubject]="nameOf" />
|
||||
|
||||
private readonly bulkService = inject(JtGridBulkService);
|
||||
|
||||
onBulk(e: JtGridBulkEvent<Server>) {
|
||||
if (e.action === 'delete') {
|
||||
this.bulkService.run({
|
||||
rows: e.rows, label: 'Delete', danger: true,
|
||||
confirm: 'Delete the selected servers? This cannot be undone.',
|
||||
run: s => this.api.delete(s.id) // Observable; one row failing does not stop the rest
|
||||
}).subscribe(({ done, failed }) => this.reload()); // cancelled -> no emission at all
|
||||
}
|
||||
if (e.action === 'stop') {
|
||||
this.bulkService.run({
|
||||
rows: e.rows, label: 'Stop', reason: true,
|
||||
confirm: 'Why are these servers being stopped?',
|
||||
run: (s, reason) => this.api.stop(s.id, reason)
|
||||
}).subscribe(() => this.reload());
|
||||
}
|
||||
}`;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
|
||||
|
||||
/** The example's source under a "Show code" toggle. */
|
||||
@Component({
|
||||
selector: 'demo-code',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<details class="code">
|
||||
<summary>Show code</summary>
|
||||
<pre><code>{{ code() }}</code></pre>
|
||||
</details>
|
||||
`
|
||||
})
|
||||
export class CodeComponent {
|
||||
readonly code = input.required<string>();
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, signal } from '@angular/core';
|
||||
import { DatePipe } from '@angular/common';
|
||||
import { JtGridCellDirective, JtGridColumn, JtGridComponent, JtGridRowEvent, RowAction } from '@jambotron/aria-grid';
|
||||
import { makeServers, Server } from '../data/servers';
|
||||
import { text } from '../i18n/labels';
|
||||
import { CodeComponent } from './code';
|
||||
|
||||
/** Example 3: row actions (icons in the row, the rest in "⋮", dangerous ones last), disabled actions, row highlight. */
|
||||
@Component({
|
||||
selector: 'demo-row-actions-example',
|
||||
imports: [JtGridComponent, JtGridCellDirective, DatePipe, CodeComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<jt-grid [rows]="servers()" [columns]="columns()" [trackBy]="byId" [label]="t().servers" [pageSize]="10"
|
||||
[rowActions]="actionsOf" (rowAction)="onRow($event)" [rowSubject]="nameOf" [highlight]="isOpen">
|
||||
<ng-template jtGridCell="status" let-row>
|
||||
<span class="chip chip--{{ row.status }}">{{ statusText(row.status) }}</span>
|
||||
@if (row.locked) {
|
||||
<span class="chip chip--locked">{{ t().locked }}</span>
|
||||
}
|
||||
</ng-template>
|
||||
</jt-grid>
|
||||
|
||||
@if (opened(); as s) {
|
||||
<aside class="details" aria-live="polite">
|
||||
<strong>{{ s.name }}</strong> · {{ s.region }} · {{ s.memoryGb }} GB · {{ statusText(s.status) }} ·
|
||||
{{ s.owner }} · {{ s.created | date: 'mediumDate' }}
|
||||
<button type="button" class="link" (click)="openedId.set(null)">Close</button>
|
||||
</aside>
|
||||
}
|
||||
<p class="log" aria-live="polite">{{ log() }}</p>
|
||||
|
||||
<demo-code [code]="code" />
|
||||
`
|
||||
})
|
||||
export class RowActionsExampleComponent {
|
||||
protected readonly t = text;
|
||||
protected readonly statusText = (s: Server['status']) => this.t()[s];
|
||||
protected readonly servers = signal(makeServers(30));
|
||||
protected readonly openedId = signal<number | null>(null);
|
||||
protected readonly opened = computed(() => this.servers().find(s => s.id === this.openedId()) ?? null);
|
||||
protected readonly log = signal('Use the icons in a row or its "⋮" menu. Arrow keys move between cells, Enter opens the menu.');
|
||||
|
||||
protected readonly columns = computed<JtGridColumn<Server>[]>(() => {
|
||||
const t = this.t();
|
||||
return [
|
||||
{ key: 'name', label: t.name, sort: s => s.name },
|
||||
{ key: 'region', label: t.region, sort: s => s.region },
|
||||
{ key: 'status', label: t.status, sort: s => s.status }
|
||||
];
|
||||
});
|
||||
|
||||
// Called for every row: the set of actions may depend on the row's state.
|
||||
// Arrow functions — so "this" works when the grid calls them.
|
||||
protected readonly actionsOf = (s: Server): RowAction[] => {
|
||||
const t = this.t();
|
||||
return [
|
||||
{ value: 'open', label: t.open, icon: 'open_in_new', inline: true },
|
||||
{ value: 'copy', label: t.copy, icon: 'content_copy', inline: true },
|
||||
s.status === 'running'
|
||||
? { value: 'stop', label: t.stop, icon: 'stop_circle' }
|
||||
: { value: 'start', label: t.start, icon: 'play_circle' },
|
||||
{ value: 'rename', label: t.rename, icon: 'edit', disabled: s.locked },
|
||||
{ value: 'delete', label: t.delete, icon: 'delete', danger: true, disabled: s.locked }
|
||||
];
|
||||
};
|
||||
|
||||
protected readonly byId = (s: Server) => s.id;
|
||||
protected readonly nameOf = (s: Server) => s.name;
|
||||
protected readonly isOpen = (s: Server) => s.id === this.openedId();
|
||||
|
||||
protected onRow({ action, row }: JtGridRowEvent<Server>): void {
|
||||
switch (action) {
|
||||
case 'open':
|
||||
this.openedId.set(row.id);
|
||||
break;
|
||||
case 'copy':
|
||||
navigator.clipboard?.writeText(row.name).catch(() => undefined);
|
||||
break;
|
||||
case 'start':
|
||||
case 'stop':
|
||||
this.servers.update(list => list.map(s => s.id === row.id ? { ...s, status: action === 'start' ? 'running' : 'stopped' } : s));
|
||||
break;
|
||||
case 'rename': {
|
||||
const name = prompt('New name', row.name)?.trim();
|
||||
if (name) {
|
||||
this.servers.update(list => list.map(s => s.id === row.id ? { ...s, name } : s));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'delete':
|
||||
// Row actions do not ask by themselves — confirm dangerous ones on the page
|
||||
if (confirm(`Delete ${row.name}?`)) {
|
||||
this.servers.update(list => list.filter(s => s.id !== row.id));
|
||||
}
|
||||
break;
|
||||
}
|
||||
this.log.set(`${action}: ${row.name}`);
|
||||
}
|
||||
|
||||
protected readonly code = `actionsOf = (s: Server): RowAction[] => [
|
||||
{ value: 'open', label: 'Open', icon: 'open_in_new', inline: true }, // icon in the row
|
||||
{ value: 'copy', label: 'Copy name', icon: 'content_copy', inline: true }, // (two at most)
|
||||
s.status === 'running'
|
||||
? { value: 'stop', label: 'Stop', icon: 'stop_circle' } // in the "⋮" menu
|
||||
: { value: 'start', label: 'Start', icon: 'play_circle' },
|
||||
{ value: 'rename', label: 'Rename', icon: 'edit', disabled: s.locked },
|
||||
{ value: 'delete', label: 'Delete', icon: 'delete', danger: true, disabled: s.locked } // last, red
|
||||
];
|
||||
isOpen = (s: Server) => s.id === this.openedId();
|
||||
|
||||
<jt-grid [rows]="servers()" [columns]="columns" [trackBy]="byId"
|
||||
[rowActions]="actionsOf" (rowAction)="onRow($event)"
|
||||
[rowSubject]="nameOf" [highlight]="isOpen" />
|
||||
|
||||
onRow({ action, row }: JtGridRowEvent<Server>) {
|
||||
if (action === 'open') this.openedId.set(row.id);
|
||||
if (action === 'delete' && confirm('Delete ' + row.name + '?')) this.api.delete(row.id);
|
||||
}`;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { JtGridLabels } from '@jambotron/aria-grid';
|
||||
|
||||
export type Lang = 'en' | 'ru';
|
||||
|
||||
/** Current language of the grid texts. A plain signal: provideJtGridLabels() reads it on every change detection. */
|
||||
export const lang = signal<Lang>('en');
|
||||
|
||||
// English is the grid's default, so only Russian has to be written out
|
||||
export const RU_LABELS: JtGridLabels = {
|
||||
actions: 'Действия',
|
||||
moreActions: 'Ещё действия',
|
||||
selectAll: 'Выбрать все',
|
||||
selectRow: 'Выбрать',
|
||||
selected: 'Выбрано',
|
||||
bulkActions: 'Действия с выбранными',
|
||||
clearSelection: 'Снять выбор',
|
||||
pages: 'Страницы',
|
||||
of: 'из',
|
||||
pageSize: 'Строк на странице',
|
||||
first: 'Первая страница',
|
||||
previous: 'Предыдущая страница',
|
||||
next: 'Следующая страница',
|
||||
last: 'Последняя страница',
|
||||
done: 'выполнено',
|
||||
failed: 'с ошибкой',
|
||||
cancel: 'Отмена',
|
||||
reason: 'Причина'
|
||||
};
|
||||
|
||||
/** Texts of the demo's own columns and actions, so the switch changes the whole grid, not only its built-in parts. */
|
||||
export const TEXT = {
|
||||
en: {
|
||||
name: 'Name', region: 'Region', status: 'Status', cpu: 'CPU', memory: 'Memory', created: 'Created', owner: 'Owner',
|
||||
running: 'running', stopped: 'stopped', error: 'error', locked: 'locked',
|
||||
open: 'Open', copy: 'Copy name', start: 'Start', stop: 'Stop', restart: 'Restart', rename: 'Rename', delete: 'Delete',
|
||||
servers: 'Servers', nothing: 'No servers match the filter.',
|
||||
confirmDelete: 'Delete the selected servers? This cannot be undone.',
|
||||
reasonStop: 'Why are these servers being stopped? The reason goes to their owners.'
|
||||
},
|
||||
ru: {
|
||||
name: 'Имя', region: 'Регион', status: 'Статус', cpu: 'CPU', memory: 'Память', created: 'Создан', owner: 'Владелец',
|
||||
running: 'работает', stopped: 'остановлен', error: 'ошибка', locked: 'защищён',
|
||||
open: 'Открыть', copy: 'Копировать имя', start: 'Запустить', stop: 'Остановить', restart: 'Перезапустить',
|
||||
rename: 'Переименовать', delete: 'Удалить',
|
||||
servers: 'Серверы', nothing: 'Под фильтр ничего не подходит.',
|
||||
confirmDelete: 'Удалить выбранные серверы? Отменить нельзя.',
|
||||
reasonStop: 'Почему серверы останавливаются? Причину получат владельцы.'
|
||||
}
|
||||
} as const;
|
||||
|
||||
export type Text = { readonly [K in keyof (typeof TEXT)['en']]: string };
|
||||
export const text = (): Text => TEXT[lang()];
|
||||
@@ -0,0 +1,42 @@
|
||||
import { DOCUMENT, effect, inject, Injectable, signal } from '@angular/core';
|
||||
|
||||
export type Theme = 'jambotron-light' | 'jambotron-dark' | 'synthwave';
|
||||
|
||||
export const THEMES: { value: Theme; label: string }[] = [
|
||||
{ value: 'jambotron-light', label: 'Jambotron light' },
|
||||
{ value: 'jambotron-dark', label: 'Jambotron dark' },
|
||||
{ value: 'synthwave', label: 'Synthwave (reactive-mail-server)' }
|
||||
];
|
||||
|
||||
const KEY = 'aria-grid-demo.theme';
|
||||
|
||||
/** The theme is one attribute on <html>: data-theme="…". All colors of the page and the grid hang on it (styles.scss). */
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ThemeService {
|
||||
private readonly document = inject(DOCUMENT);
|
||||
readonly theme = signal<Theme>(this.initial());
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const theme = this.theme();
|
||||
this.document.documentElement.dataset['theme'] = theme;
|
||||
try {
|
||||
localStorage.setItem(KEY, theme);
|
||||
} catch {
|
||||
// private window — the choice just is not remembered
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private initial(): Theme {
|
||||
try {
|
||||
const saved = localStorage.getItem(KEY) as Theme | null;
|
||||
if (saved && THEMES.some(t => t.value === saved)) {
|
||||
return saved;
|
||||
}
|
||||
} catch {
|
||||
// no storage — fall through to the system setting
|
||||
}
|
||||
return this.document.defaultView?.matchMedia('(prefers-color-scheme: dark)').matches ? 'jambotron-dark' : 'jambotron-light';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<!doctype html>
|
||||
<html lang="en" data-theme="jambotron-light">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>@jambotron/aria-grid — demo</title>
|
||||
<base href="/">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="description" content="Live examples of @jambotron/aria-grid, an Angular data grid on Angular Aria: sorting, paging, selection, bulk and row actions, themes.">
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@500;700&family=Rajdhani:wght@400;500;600&family=Roboto:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
|
||||
<!-- theme before Angular starts: no flash of the wrong colors -->
|
||||
<script>
|
||||
try {
|
||||
var t = localStorage.getItem('aria-grid-demo.theme');
|
||||
if (!t) t = matchMedia('(prefers-color-scheme: dark)').matches ? 'jambotron-dark' : 'jambotron-light';
|
||||
document.documentElement.dataset.theme = t;
|
||||
} catch (e) {}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<app-root></app-root>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,6 @@
|
||||
import { bootstrapApplication } from '@angular/platform-browser';
|
||||
import { appConfig } from './app/app.config';
|
||||
import { App } from './app/app';
|
||||
|
||||
bootstrapApplication(App, appConfig)
|
||||
.catch((err) => console.error(err));
|
||||
+417
@@ -0,0 +1,417 @@
|
||||
// Three themes on one attribute: <html data-theme="jambotron-light | jambotron-dark | synthwave">.
|
||||
// The grid is painted only by --jt-* variables (its defaults sit on :where(:root), so any value below wins);
|
||||
// Material (checkboxes, buttons, dialog, snack bar of the grid) takes its colors from the same variables.
|
||||
@use '@angular/material' as mat;
|
||||
@use '@jambotron/aria-grid/styles/aria-grid';
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------------
|
||||
// Jambotron light — the tokens of jambotron.com (_tokens.scss), the part the grid and this page use
|
||||
[data-theme='jambotron-light'] {
|
||||
color-scheme: light;
|
||||
--app-bg: radial-gradient(1200px 600px at 10% -10%, #d7f6fb 0%, transparent 60%),
|
||||
radial-gradient(900px 500px at 110% 10%, #fde9c4 0%, transparent 55%), #eef2f7;
|
||||
--app-panel: #e7edf3c2;
|
||||
--app-panel-border: rgba(10, 16, 48, 0.12);
|
||||
--app-surface: #f3f6fa;
|
||||
--app-code-bg: rgba(10, 16, 48, 0.05);
|
||||
--app-font: Roboto, 'Helvetica Neue', sans-serif;
|
||||
--app-font-display: Orbitron, Roboto, sans-serif;
|
||||
--app-title: #0b6e80;
|
||||
|
||||
--jt-accent: #14c8e6;
|
||||
--jt-accent-text: #074b5a;
|
||||
--jt-on-accent: #050a24;
|
||||
--jt-on-accent-text: #ffffff;
|
||||
--jt-danger: #ef4444;
|
||||
--jt-danger-text: #8a1414;
|
||||
--jt-success: #22c55e;
|
||||
--jt-success-text: #0c4f26;
|
||||
--jt-warning: #ff9a2e;
|
||||
--jt-warning-text: #6b3a00;
|
||||
--jt-text: #141a33;
|
||||
--jt-text-muted: #39425f;
|
||||
--jt-text-disabled: rgba(20, 26, 51, 0.45);
|
||||
--jt-divider: rgba(10, 16, 48, 0.12);
|
||||
--jt-field-bg: rgba(255, 255, 255, 0.55);
|
||||
--jt-field-border: rgba(10, 16, 48, 0.28);
|
||||
--jt-focus-ring: 0 0 0 3px rgba(20, 200, 230, 0.45);
|
||||
--jt-glow-accent: 0 0 10px rgba(20, 200, 230, 0.35);
|
||||
--jt-popup-bg: #eef2f7e6;
|
||||
--jt-popup-border: rgba(10, 16, 48, 0.12);
|
||||
--jt-shadow-lg: 0 16px 48px rgba(10, 16, 48, 0.2);
|
||||
}
|
||||
|
||||
// Jambotron dark — night sky, neon cyan
|
||||
[data-theme='jambotron-dark'] {
|
||||
color-scheme: dark;
|
||||
--app-bg: radial-gradient(1200px 700px at 15% -10%, #1c2a6b 0%, transparent 60%),
|
||||
radial-gradient(900px 600px at 110% 0%, #3b1d5e 0%, transparent 55%), #050a24;
|
||||
--app-panel: rgba(10, 16, 44, 0.72);
|
||||
--app-panel-border: rgba(62, 230, 255, 0.22);
|
||||
--app-surface: #0c1234;
|
||||
--app-code-bg: rgba(5, 10, 36, 0.6);
|
||||
--app-font: Roboto, 'Helvetica Neue', sans-serif;
|
||||
--app-font-display: Orbitron, Roboto, sans-serif;
|
||||
--app-title: #7ff0ff;
|
||||
|
||||
--jt-accent: #3ee6ff;
|
||||
--jt-accent-text: #7ff0ff;
|
||||
--jt-on-accent: #050a24;
|
||||
--jt-on-accent-text: #050a24;
|
||||
--jt-danger: #f87171;
|
||||
--jt-danger-text: #fecaca;
|
||||
--jt-success: #34d399;
|
||||
--jt-success-text: #86efac;
|
||||
--jt-warning: #ff9a2e;
|
||||
--jt-warning-text: #ffd98a;
|
||||
--jt-text: #eef2ff;
|
||||
--jt-text-muted: #ccd4ec;
|
||||
--jt-text-disabled: rgba(238, 242, 255, 0.45);
|
||||
--jt-divider: rgba(195, 204, 230, 0.16);
|
||||
--jt-field-bg: rgba(5, 10, 36, 0.55);
|
||||
--jt-field-border: rgba(195, 204, 230, 0.3);
|
||||
--jt-focus-ring: 0 0 0 3px rgba(62, 230, 255, 0.5);
|
||||
--jt-glow-accent: 0 0 14px rgba(62, 230, 255, 0.55);
|
||||
--jt-popup-bg: rgba(8, 12, 36, 0.95);
|
||||
--jt-popup-border: rgba(62, 230, 255, 0.22);
|
||||
--jt-shadow-lg: 0 16px 48px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
// Synthwave — the colors of reactive-mail-server's web mail (frontend/src/material.scss there)
|
||||
[data-theme='synthwave'] {
|
||||
color-scheme: dark;
|
||||
--app-bg: linear-gradient(180deg, transparent 55%, rgba(255, 0, 127, 0.18) 100%),
|
||||
repeating-linear-gradient(90deg, rgba(255, 0, 127, 0.12) 0 1px, transparent 1px 64px),
|
||||
repeating-linear-gradient(0deg, rgba(0, 240, 255, 0.08) 0 1px, transparent 1px 64px), #0d0221;
|
||||
--app-panel: rgba(15, 12, 35, 0.78);
|
||||
--app-panel-border: rgba(255, 0, 127, 0.4);
|
||||
--app-surface: #140a2e;
|
||||
--app-code-bg: rgba(2, 6, 23, 0.7);
|
||||
--app-font: Rajdhani, sans-serif;
|
||||
--app-font-display: Orbitron, sans-serif;
|
||||
--app-title: #ff007f;
|
||||
|
||||
--jt-accent: #00f0ff;
|
||||
--jt-accent-text: #00f0ff;
|
||||
--jt-on-accent: #0d0221;
|
||||
--jt-on-accent-text: #0d0221;
|
||||
--jt-danger: #ff4d6d;
|
||||
--jt-danger-text: #ff8fa3;
|
||||
--jt-success: #00ff88;
|
||||
--jt-success-text: #7dffbf;
|
||||
--jt-warning: #ffbe0b;
|
||||
--jt-warning-text: #ffd76a;
|
||||
--jt-text: #e2e8f0;
|
||||
--jt-text-muted: #94a3b8;
|
||||
--jt-text-disabled: rgba(226, 232, 240, 0.4);
|
||||
--jt-divider: rgba(0, 240, 255, 0.14);
|
||||
--jt-field-bg: rgba(2, 6, 23, 0.6);
|
||||
--jt-field-border: rgba(0, 240, 255, 0.35);
|
||||
--jt-focus-ring: 0 0 0 3px #ffbe0b;
|
||||
--jt-glow-accent: 0 0 12px rgba(0, 240, 255, 0.6);
|
||||
--jt-popup-bg: rgba(10, 5, 24, 0.97);
|
||||
--jt-popup-border: rgba(255, 0, 127, 0.45);
|
||||
--jt-shadow-lg: 0 16px 48px rgba(0, 0, 0, 0.6), 0 0 20px rgba(255, 0, 127, 0.25);
|
||||
--jt-radius-sm: 8px;
|
||||
--jt-radius-md: 12px;
|
||||
|
||||
.jt-table {
|
||||
font-size: 0.95rem;
|
||||
|
||||
th {
|
||||
font-family: Orbitron, sans-serif;
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.08em;
|
||||
color: #00f0ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------------
|
||||
// Material: light or dark by color-scheme (set by the theme above), roles mapped to the --jt-* variables
|
||||
html {
|
||||
@include mat.theme(
|
||||
(
|
||||
color: (primary: mat.$cyan-palette, tertiary: mat.$violet-palette, theme-type: color-scheme),
|
||||
typography: (plain-family: var(--app-font), brand-family: var(--app-font-display)),
|
||||
density: 0,
|
||||
),
|
||||
(
|
||||
primary: var(--jt-accent-text),
|
||||
on-primary: var(--jt-on-accent-text),
|
||||
error: var(--jt-danger-text),
|
||||
on-error: var(--jt-on-accent-text),
|
||||
background: var(--app-surface),
|
||||
on-background: var(--jt-text),
|
||||
surface: var(--app-surface),
|
||||
on-surface: var(--jt-text),
|
||||
on-surface-variant: var(--jt-text-muted),
|
||||
surface-container: var(--app-surface),
|
||||
surface-container-high: var(--app-surface),
|
||||
surface-container-highest: var(--app-surface),
|
||||
outline: var(--jt-field-border),
|
||||
outline-variant: var(--jt-divider),
|
||||
inverse-surface: var(--jt-text),
|
||||
inverse-on-surface: var(--app-surface),
|
||||
)
|
||||
);
|
||||
@include mat.checkbox-overrides((
|
||||
selected-icon-color: var(--jt-accent),
|
||||
selected-hover-icon-color: var(--jt-accent),
|
||||
selected-focus-icon-color: var(--jt-accent),
|
||||
selected-checkmark-color: var(--jt-on-accent),
|
||||
));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------------
|
||||
// Page
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--app-bg);
|
||||
background-attachment: fixed;
|
||||
color: var(--jt-text);
|
||||
font-family: var(--app-font);
|
||||
line-height: 1.5;
|
||||
transition: background var(--jt-duration-base) var(--jt-easing), color var(--jt-duration-base) var(--jt-easing);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--jt-accent-text);
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: 'JetBrains Mono', Consolas, monospace;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.top {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
max-width: 1180px;
|
||||
margin: 0 auto;
|
||||
padding: 28px 16px 8px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-family: var(--app-font-display);
|
||||
font-size: clamp(1.2rem, 3vw, 1.7rem);
|
||||
color: var(--app-title);
|
||||
text-shadow: var(--jt-glow-accent);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 2px 0 0;
|
||||
color: var(--jt-text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: var(--jt-radius-md);
|
||||
background: var(--jt-accent);
|
||||
color: var(--jt-on-accent);
|
||||
font-size: 1.6rem;
|
||||
box-shadow: var(--jt-glow-accent);
|
||||
}
|
||||
|
||||
.switches {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
|
||||
jt-select {
|
||||
min-width: 220px;
|
||||
}
|
||||
}
|
||||
|
||||
main {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
max-width: 1180px;
|
||||
margin: 0 auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
min-width: 0;
|
||||
padding: 20px;
|
||||
border: 1px solid var(--app-panel-border);
|
||||
border-radius: var(--jt-radius-md);
|
||||
background: var(--app-panel);
|
||||
backdrop-filter: blur(8px);
|
||||
|
||||
h2 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 0 0 6px;
|
||||
font-family: var(--app-font-display);
|
||||
font-size: 1.05rem;
|
||||
letter-spacing: 0.02em;
|
||||
|
||||
span {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
background: var(--jt-accent);
|
||||
color: var(--jt-on-accent);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lead {
|
||||
margin: 0 0 16px;
|
||||
color: var(--jt-text-muted);
|
||||
max-width: 85ch;
|
||||
}
|
||||
|
||||
.filter {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--jt-text-muted);
|
||||
|
||||
input {
|
||||
width: min(320px, 80vw);
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--jt-field-border);
|
||||
border-radius: var(--jt-radius-sm);
|
||||
background: var(--jt-field-bg);
|
||||
color: var(--jt-text);
|
||||
font: inherit;
|
||||
font-size: 0.95rem;
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--jt-focus-ring);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-block;
|
||||
margin-right: 4px;
|
||||
padding: 1px 8px;
|
||||
border-radius: var(--jt-radius-pill);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
border: 1px solid currentColor;
|
||||
}
|
||||
|
||||
.chip--running {
|
||||
color: var(--jt-success-text);
|
||||
}
|
||||
|
||||
.chip--stopped {
|
||||
color: var(--jt-text-muted);
|
||||
}
|
||||
|
||||
.chip--error {
|
||||
color: var(--jt-danger-text);
|
||||
}
|
||||
|
||||
.chip--locked {
|
||||
color: var(--jt-warning-text);
|
||||
}
|
||||
|
||||
// CPU load bar
|
||||
.meter {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
width: 90px;
|
||||
height: 18px;
|
||||
padding: 0 6px;
|
||||
border-radius: var(--jt-radius-sm);
|
||||
background: linear-gradient(90deg, color-mix(in srgb, var(--jt-accent) 35%, transparent) calc(var(--value) * 1%), var(--jt-divider) 0);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.log {
|
||||
min-height: 1.5em;
|
||||
margin: 12px 0 4px;
|
||||
color: var(--jt-text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.details {
|
||||
margin-top: 12px;
|
||||
padding: 10px 14px;
|
||||
border-left: 3px solid var(--jt-accent);
|
||||
border-radius: var(--jt-radius-sm);
|
||||
background: color-mix(in srgb, var(--jt-accent) 10%, transparent);
|
||||
}
|
||||
|
||||
.link {
|
||||
margin-left: 8px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--jt-accent-text);
|
||||
font: inherit;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.code {
|
||||
margin-top: 12px;
|
||||
|
||||
summary {
|
||||
cursor: pointer;
|
||||
color: var(--jt-accent-text);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
pre {
|
||||
overflow-x: auto;
|
||||
margin: 8px 0 0;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--jt-divider);
|
||||
border-radius: var(--jt-radius-sm);
|
||||
background: var(--app-code-bg);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
}
|
||||
|
||||
// The grid's own empty state uses this class
|
||||
.jt-muted {
|
||||
color: var(--jt-text-muted);
|
||||
}
|
||||
|
||||
.foot {
|
||||
max-width: 1180px;
|
||||
margin: 0 auto;
|
||||
padding: 8px 16px 32px;
|
||||
color: var(--jt-text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": []
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"src/**/*.spec.ts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||
{
|
||||
"compileOnSave": false,
|
||||
"compilerOptions": {
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"skipLibCheck": true,
|
||||
"isolatedModules": true,
|
||||
"experimentalDecorators": true,
|
||||
"importHelpers": true,
|
||||
"target": "ES2022",
|
||||
"module": "preserve"
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
"enableI18nLegacyMessageIdFormat": false,
|
||||
"strictInjectionParameters": true,
|
||||
"strictInputAccessModifiers": true
|
||||
},
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": [
|
||||
"vitest/globals"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.d.ts",
|
||||
"src/**/*.spec.ts"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user