change upstream
@@ -0,0 +1,30 @@
|
|||||||
|
# Schema fidelity
|
||||||
|
|
||||||
|
Stay schema-driven: the server's JSON schema is the single source of truth for what forms, fields, filters, and columns exist. This is why upstream (stalwartlabs/webui) rejects PRs that hardcode exceptions instead of fixing the schema/server — see [SCHEMA_DEVIATIONS.md](../../SCHEMA_DEVIATIONS.md) for the full rationale and the tracked list.
|
||||||
|
|
||||||
|
## When this applies
|
||||||
|
|
||||||
|
- Adding or changing anything in `DynamicList.tsx`, `FieldWidget.tsx`, `DynamicForm.tsx`, `layout.ts`, `schemaResolver.ts`, or any `src/lib/*Columns.ts` / `*Filters.ts` file.
|
||||||
|
- Adding a new list column, filter, form field, or navigation entry.
|
||||||
|
- Any change driven by a specific object/view name (`viewName === 'x:...'`, `objectName === 'x:...'`).
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- **NEVER** add a hardcoded `viewName === '...'` / `objectName === '...'` branch, a synthetic/computed column, or a client-side filter/sort workaround without checking first whether the schema already supports it.
|
||||||
|
- Widget-level special cases for object types the schema itself designates (e.g. `x:OtpAuth`, `x:Expression`, `x:Rate`) are fine — that pattern already exists upstream and just renders real schema data with a dedicated widget.
|
||||||
|
- **CRITICAL**: if something genuinely cannot be done through the schema because the server (`stalwartlabs/stalwart`) or upstream webui doesn't support it yet, it must become a tracked deviation, not a silent workaround:
|
||||||
|
1. Add an entry to `SCHEMA_DEVIATIONS.md` (id, file location, what it does, why it's needed, the ideal server-side fix, status `🟡 Workaround` or `🔵 Upstream tracked`).
|
||||||
|
2. Tag the code with `// SCHEMA-DEVIATION: <id>` pointing at that entry.
|
||||||
|
3. Prefer filing (or pointing the user to file) an issue against `stalwartlabs/stalwart` or `stalwartlabs/webui` for the ideal fix.
|
||||||
|
- **CRITICAL**: `src/types/schema.ts` must **never** be edited for a deviation, not even to add an extra optional field — it must stay alignable with upstream's copy of the file. If a deviation needs to carry extra data on an official schema shape, declare the augmented type as an intersection (`OfficialType & { extra?: ... }`) in the deviation's own module, or in `src/lib/schemaDeviationTypes.ts`, and import it only where the deviation is used.
|
||||||
|
- Never remove a `SCHEMA-DEVIATION` tag or its `SCHEMA_DEVIATIONS.md` entry without confirming the underlying server/schema capability actually landed.
|
||||||
|
- Pure client-side/presentational logic (theming, dark mode, responsive layout, sidebar UX, appearance settings) is not a schema concern — no deviation tracking needed for those.
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// SCHEMA-DEVIATION: log-client-filters (see SCHEMA_DEVIATIONS.md)
|
||||||
|
//
|
||||||
|
// The Stalwart JMAP backend rejects `level`/`event` as filter conditions on
|
||||||
|
// `x:Log/query` (`unsupportedFilter`) ... [why + ideal fix]
|
||||||
|
```
|
||||||
@@ -23,8 +23,27 @@ dist-ssr
|
|||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
.ignore
|
.ignore
|
||||||
scripts/
|
scripts/*
|
||||||
|
!scripts/dev-token.ps1
|
||||||
|
!scripts/dev-token.sh
|
||||||
|
!scripts/dev-server-init.ps1
|
||||||
|
!scripts/dev-server-init.sh
|
||||||
|
!scripts/dev-seed.ps1
|
||||||
|
!scripts/dev-seed.sh
|
||||||
*.md
|
*.md
|
||||||
!README.md
|
!README.md
|
||||||
!CHANGELOG.md
|
!CHANGELOG.md
|
||||||
|
!AGENTS.md
|
||||||
|
!CLAUDE.md
|
||||||
|
!SCHEMA_DEVIATIONS.md
|
||||||
|
!DEVELOPMENT.md
|
||||||
|
!.agents/rules/*.md
|
||||||
/SPEC-*
|
/SPEC-*
|
||||||
|
# Tool-generated artifacts during agent sessions
|
||||||
|
.playwright-mcp/
|
||||||
|
outputs/
|
||||||
|
.vite_*.log
|
||||||
|
.tmp_*
|
||||||
|
*.py
|
||||||
|
webui.zip
|
||||||
|
release_body.md
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Stalwart WebUI Fork
|
||||||
|
|
||||||
|
Community fork of [stalwartlabs/webui](https://github.com/stalwartlabs/webui), a schema-driven admin panel for [Stalwart](https://stalw.art). The server's JSON schema (fetched from `/api/schema`) is the single source of truth for forms, fields, filters, columns, and navigation.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- React 19 + Vite + TypeScript, Zustand stores, JMAP (RFC 8620) for all data operations.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
- `npm run dev:server` - Start a local disposable Stalwart test server (Docker); `npm run dev:server:down` to stop it
|
||||||
|
- `bash scripts/dev-server-init.sh` (or `pwsh ./scripts/dev-server-init.ps1`) - One-time setup of that server (bootstrap, dev admin account, 3h default token lifetime); idempotent
|
||||||
|
- `bash scripts/dev-seed.sh` (or `pwsh ./scripts/dev-seed.ps1`) - Optional: seed sample Users/Groups/Mailing Lists/Roles for testing; idempotent
|
||||||
|
- `bash scripts/dev-token.sh [duration_seconds]` (or `pwsh ./scripts/dev-token.ps1 [-DurationSeconds N]`) - Get a dev access token from that server, 3h by default
|
||||||
|
- `npm run dev` - Dev server (proxies `/api` and `/jmap` to the test server)
|
||||||
|
- `npm run typecheck` - `tsc --noEmit`
|
||||||
|
- `npm run lint` - ESLint
|
||||||
|
- `npm run test` - Vitest (`npm run test:watch` to watch)
|
||||||
|
- `npm run build` - `tsc -b && vite build`
|
||||||
|
|
||||||
|
Full local dev workflow, including how to run it end-to-end without a browser: [DEVELOPMENT.md](DEVELOPMENT.md).
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
The detailed rules live in `.agents/rules/`. Read the relevant file before acting:
|
||||||
|
|
||||||
|
- **Schema fidelity** - [.agents/rules/schema-fidelity.md](.agents/rules/schema-fidelity.md) - Stay schema-driven; how to handle cases the schema can't cover yet
|
||||||
|
|
||||||
|
## Universal Rules
|
||||||
|
|
||||||
|
- This applies to every AI coding agent working in this repo (Claude Code, Codex, Kimi, or any other) — not just one tool.
|
||||||
|
- Never hardcode object types, field names, filters, or columns as a shortcut. Read `.agents/rules/schema-fidelity.md` before adding anything that touches lists, forms, or navigation.
|
||||||
|
- **CRITICAL**: `src/types/schema.ts` mirrors the official server/webui schema contract and must never be edited to accommodate a deviation, not even to add an optional field. Deviation-only type augmentations go in `src/lib/schemaDeviationTypes.ts` (or the deviation's own module) as an intersection with the official type — see `.agents/rules/schema-fidelity.md`.
|
||||||
|
- Any client-side workaround for something the official schema doesn't support yet must be documented in `SCHEMA_DEVIATIONS.md` and tagged `// SCHEMA-DEVIATION: <id>` in code. Never add one silently.
|
||||||
@@ -2,26 +2,124 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/).
|
All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/).
|
||||||
|
|
||||||
## [1.0.7] - 2026-07-30
|
## [1.1.3] - 2026-08-01
|
||||||
|
|
||||||
### Added
|
|
||||||
- `Ctrl+K` / `Cmd+K` command palette for global search (credits @LinkPhoenix).
|
|
||||||
- Calendar date picker for date and time fields (credits @LinkPhoenix).
|
|
||||||
- Dynamic document titles per page (credits @LinkPhoenix).
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- Code-split the admin shell and heavy feature pages to speed up the initial load (credits @LinkPhoenix).
|
|
||||||
- Center forms horizontally on wide screens (credits @LinkPhoenix).
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- Redirect URLs without a view to the first accessible page of their section (credits @LinkPhoenix).
|
- Column sort headers now cycle unsorted → ascending → descending → unsorted, so a third click clears the sort and restores the list's default order (previously stuck alternating between ascending and descending).
|
||||||
- Sidebar groups auto-open and scroll the active item into view after navigation (credits @LinkPhoenix).
|
|
||||||
- Keep the sidebar section synced with the URL on full page loads (credits @LinkPhoenix).
|
## [1.1.2] - 2026-08-01
|
||||||
- Date and time fields no longer shift values by the UTC offset when editing (credits @LinkPhoenix).
|
|
||||||
- Clip the table header background inside the rounded card border (credits @LinkPhoenix).
|
### Added
|
||||||
- Keep the selected account across page reloads (#17).
|
- Brand-accurate "Stalwart" color theme (red/pink accent from the official site tokens). The previous theme named "Stalwart" was only the neutral black/white look and is now labeled "Default".
|
||||||
- Refresh open views when switching accounts (#17).
|
- Changelog page in the header user dropdown, rendering this repository's `CHANGELOG.md` so release notes stay in sync with every release.
|
||||||
- Custom logos no longer flash the default logo while loading.
|
- Usage/Quota column on the Groups list (same synthetic column as Accounts); unlimited quota renders as ∞ instead of the word "Unlimited".
|
||||||
|
- Aliases count column on Accounts, Groups, Mailing Lists, and Domains (resolved from each object's real `aliases` property).
|
||||||
|
- Enabled Permissions and Disabled Permissions count columns on the Roles list.
|
||||||
|
- Client-side column sorting on Accounts, Groups, Mailing Lists, Roles, and Domains for the most useful columns (Email/Name/Description, Usage/Quota, Aliases, permission counts, Domain Name, Enabled). The backend rejects sort on these properties (`unsupportedSort`), so lists fetch then sort/paginate locally when a sortable header is clicked — same approach as mailbox hierarchy sort.
|
||||||
|
- `scripts/dev-seed` (`.sh` / `.ps1`) to populate the local test server with sample Users, Groups, Mailing Lists, and Roles after `dev-server-init` (idempotent; documented in DEVELOPMENT.md).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Default color theme is now the new Stalwart theme (previously Ocean since 1.0.9); existing saved preferences are unaffected.
|
||||||
|
- Client-side sort is driven by a shared `clientSortable` column flag instead of hardcoded view/column maps, so any list can opt in without touching `DynamicList` branching.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Sidebar: expanding a collapsible group (e.g. Directory) and then navigating to an unrelated top-level link (e.g. Cluster) now collapses that group instead of leaving it open.
|
||||||
|
- Vite respects the `$PORT` environment variable so preview/dev tooling that assigns a free port can bind the same port Vite actually listens on.
|
||||||
|
|
||||||
|
## [1.1.1] - 2026-08-01
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Mobile sidebar no longer snaps shut when switching between Management/Settings/Account — it now stays open so you can pick a page in the new section, and only closes once you actually navigate to one.
|
||||||
|
- Favicon replaced with upstream's original: this fork's had drifted to a mis-cropped export (visible padding around the logo, lower color depth) despite showing the same logo and color.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- The "Active WebUI" card (Settings > Web Applications) now also shows the resource URL the active WebUI was installed from, alongside its description and version.
|
||||||
|
|
||||||
|
## [1.1.0] - 2026-07-30
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Mobile admin shell and lists: wide tables no longer expand/clip the page. The shared ScrollArea constrains content width, tables scroll horizontally inside their card, and list Create / pagination actions stay visible in the mobile viewport.
|
||||||
|
- Form and detail layouts wrap more cleanly on narrow screens (action bars, label/value rows).
|
||||||
|
- Date/time picker in dark mode: replaced the native time input (invisible clock icon + always-light popup) with themed hour/minute selects and a visible Clock icon; set `color-scheme` on light/dark themes so remaining native date controls follow the UI.
|
||||||
|
- Empty date/time fields open prefilled with the current date and time so the calendar and hour/minute selects start on a useful default.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Negative account disk-usage values (stale Stalwart quota counters) are shown in red with an info tooltip that explains how to recalculate usage via Tasks (Perform account maintenance operations → Recalculate storage quota usage, or store-wide Reset all user quotas).
|
||||||
|
|
||||||
|
## [1.0.9] - 2026-07-30
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Level and Event filters on the Log Entries list, applied client-side since the backend doesn't support filtering on these properties yet. Event uses a searchable combobox given its ~600 possible values.
|
||||||
|
- Rate-limited manual Refresh button on the Log Entries list (one click per 5 seconds) ([webui#8](https://github.com/stalwartlabs/webui/issues/8)).
|
||||||
|
- Role and Usage/Quota columns on the Accounts list, replacing Created At ([webui#12](https://github.com/stalwartlabs/webui/issues/12)).
|
||||||
|
- Mailbox hierarchy: mailboxes are now indented under their parent instead of shown as a flat list ([webui#16](https://github.com/stalwartlabs/webui/issues/16)).
|
||||||
|
- Three additional color themes — Rose, Amber, Teal — alongside Stalwart/Ocean/Forest/Violet.
|
||||||
|
- "Remember last visited page" per section (localStorage), so switching sections returns to where you left off.
|
||||||
|
- Username and email shown directly in the TopBar user menu trigger.
|
||||||
|
- "Active WebUI" info card and column in the Web Applications list, showing which web app is currently serving the admin UI.
|
||||||
|
- Backend/provider icons across the variant selectors (DNS providers, storage/directory backends, Redis/Valkey).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Default color theme is now Ocean with square corners (previously Stalwart with rounded corners); existing saved preferences are unaffected.
|
||||||
|
- Active account is preserved across page reloads instead of resetting to the primary account, and switching accounts fully remounts the current view (and clears cached display-name/list lookups) so account-scoped data refreshes immediately instead of requiring a tab switch first ([webui#17](https://github.com/stalwartlabs/webui/issues/17); reviewed against upstream's own fix for the same issue in [stalwartlabs/webui@189e270](https://github.com/stalwartlabs/webui/commit/189e270785a6953a99d11c958fd52daa94e1f5c7) and aligned with it).
|
||||||
|
- WebUI label renamed to "Stalwart WebUI Fork" to distinguish this fork from upstream.
|
||||||
|
- Appearance settings moved from the sidebar to the header user dropdown.
|
||||||
|
- Dynamic page titles prefixed with "Stalwart |".
|
||||||
|
- x:Application list layout aligned with Domains (Description first, Enabled second).
|
||||||
|
- Updated Vite to 8.2.0, `@vitejs/plugin-react` to 6.0.5, and `lucide-react` to 1.28.0.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Custom logos no longer flash the default Stalwart logo while loading. Loading is encapsulated in `logoCache` (shared fetch + AbortController + blob URL revoke), keeping `uiStore` free of logo state while still caching across TopBar/Login remounts.
|
||||||
|
- Icon/label alignment in backend select triggers.
|
||||||
|
- Web Applications list shows an Enabled column again.
|
||||||
|
- Appearance Corners preview: only the Rounded choice forces rounded radius on its card and sample; Square stays sharp even when the global theme is square.
|
||||||
|
- Removed the experimental PWA/service worker: it precached `index.html` with `<base href="/">`, which broke Stalwart's mount-path rewrite (`/admin`, `/account`) and produced a blank UI. Aligns with upstream webui, which does not ship a service worker.
|
||||||
|
- iOS home-screen Web App support without a service worker: `apple-touch-icon` plus `apple-mobile-web-app-title` set to "Stalwart".
|
||||||
|
|
||||||
|
### Known limitations (confirmed backend-side, not fixable from this fork)
|
||||||
|
- Level/Event filters on Log Entries are client-side only (see Added above) because the backend's JMAP query engine rejects `level`/`event` as filter conditions ([webui#15](https://github.com/stalwartlabs/webui/issues/15)).
|
||||||
|
- Text filters across the admin API (Spam Rules & Scores, Accounts, Domains, ...) only support exact match, not partial/glob/wildcard search — confirmed systemic across the whole `x:` filter engine, requires a server-side change.
|
||||||
|
|
||||||
|
## [1.0.8] - 2026-07-29
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Appearance settings page (linked at the bottom of the sidebar) with light/dark mode, four selectable color themes (Stalwart, Ocean, Forest, Violet) and a rounded/square corners option that applies globally across all themes.
|
||||||
|
- Light/dark toggle on the login pages.
|
||||||
|
- Dynamic document titles per page, mirroring the sidebar navigation labels.
|
||||||
|
- Square corners toggle for a border-radius-free interface.
|
||||||
|
- Full-width sidebar hover with a separated footer in square mode.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- The light/dark toggle is a single-click button again, in the top bar and on the login pages.
|
||||||
|
- The logout menu item is marked as destructive.
|
||||||
|
- Updated react-router-dom to 7.18.2 and migrated the date picker to `@daypicker/react` 10 (the new react-day-picker package name).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Section URLs without a view (e.g. /admin) redirect to the first accessible page instead of the "Select a view" empty state.
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- The Web Applications list no longer shows a "Version" column or an "Update" button, because Stalwart does not expose the installed version of each web application and `/latest/` GitHub URLs hide it.
|
||||||
|
|
||||||
|
## [1.0.7] - 2026-07-29
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Ctrl+K / Cmd+K command palette to search pages, form sections and fields across the admin panel, with an ESC hint badge instead of the close cross.
|
||||||
|
- WebUI version and update action with a confirmation modal in the Web Applications list.
|
||||||
|
- Calendar date picker with time input replacing native datetime inputs in all schema-driven forms.
|
||||||
|
- Shared styled ScrollArea used app-wide, including the sidebar and the command palette.
|
||||||
|
- Development proxy forwarding API and JMAP requests to a local Stalwart server.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Renamed the package to `stalwart-webui-fork` to identify the community fork.
|
||||||
|
- Sidebar behaves as an animated accordion: expanding a section collapses the others at the same level, with a softer hover.
|
||||||
|
- Main content is horizontally centered.
|
||||||
|
- Form fields now use a background color distinct from card surfaces.
|
||||||
|
- Feature pages and the admin shell are code-split with React.lazy to reduce the initial bundle size.
|
||||||
|
- Switches show green when enabled and red when disabled.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Table header background is clipped inside the rounded border, removing the square corner visible behind the radius.
|
||||||
|
- Sidebar section stays synced with the URL on programmatic navigation and full page loads.
|
||||||
|
|
||||||
## [1.0.6] - 2026-07-28
|
## [1.0.6] - 2026-07-28
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
Read [AGENTS.md](AGENTS.md) first — it is the source of truth for this repo's rules (tech stack, commands, and the schema-fidelity rule in `.agents/rules/`). It applies to every AI agent working here, Claude Code included.
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
# Development
|
||||||
|
|
||||||
|
How to run a local Stalwart test server and develop this WebUI against it.
|
||||||
|
This file is meant to be read by humans and AI coding agents alike — see
|
||||||
|
[AGENTS.md](AGENTS.md) for the rules that also apply while doing this.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Node.js 18+
|
||||||
|
- Docker (with the `docker compose` CLI plugin)
|
||||||
|
|
||||||
|
## 1. Start a local test server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev:server
|
||||||
|
```
|
||||||
|
|
||||||
|
This runs `docker compose up -d`, which starts a disposable Stalwart
|
||||||
|
instance ([`docker-compose.yml`](docker-compose.yml)) with:
|
||||||
|
|
||||||
|
- The management/JMAP HTTP API on `http://localhost:8080` (the only port
|
||||||
|
the WebUI dev proxy needs — see `server.proxy` in
|
||||||
|
[`vite.config.ts`](vite.config.ts)).
|
||||||
|
- Mail protocol ports (SMTP/IMAP/POP3/ManageSieve) exposed too, only
|
||||||
|
needed if you're testing actual mail flows, not just admin UI screens.
|
||||||
|
- A fixed admin account baked in via `STALWART_RECOVERY_ADMIN`:
|
||||||
|
`admin@example.org` / `c8321iEscHDy0GWV`. **Disposable dev credentials
|
||||||
|
only — never reuse them for anything real.**
|
||||||
|
- Named Docker volumes (`stalwart-etc`, `stalwart-data`) so data survives
|
||||||
|
a restart. Data persists until you tear the volumes down.
|
||||||
|
|
||||||
|
Useful companions:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev:server:logs # tail the container's logs
|
||||||
|
npm run dev:server:down # stop it (add `-v` via `docker compose down -v` to also wipe data)
|
||||||
|
```
|
||||||
|
|
||||||
|
The server takes a couple of seconds to come up; `docker compose logs stalwart`
|
||||||
|
will show `Network listener started ... localPort = 8080` once it's ready.
|
||||||
|
|
||||||
|
## 2. Initialize the server (first time only)
|
||||||
|
|
||||||
|
The container starts in Stalwart's bootstrap mode, which only allows
|
||||||
|
signing in as the break-glass `STALWART_RECOVERY_ADMIN` account — real
|
||||||
|
accounts and most settings aren't usable yet. Run once per fresh volume:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Windows / PowerShell
|
||||||
|
pwsh ./scripts/dev-server-init.ps1
|
||||||
|
|
||||||
|
# Linux / macOS / any POSIX shell (including most AI agent sandboxes)
|
||||||
|
bash ./scripts/dev-server-init.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
This completes the bootstrap wizard (default domain `example.org`, no TLS
|
||||||
|
certificate request — safe for local/offline use), creates a real
|
||||||
|
`devadmin@example.org` admin account, sets the server's default OAuth
|
||||||
|
access token lifetime to 3 hours, and points the `x:Application` record
|
||||||
|
serving `/admin`/`/account` at this fork's description and release URL
|
||||||
|
(cosmetic — otherwise Settings > Web Applications' "Active WebUI" card
|
||||||
|
shows Stalwart's default "Stalwart Web Interface" / upstream release URL,
|
||||||
|
even though what's actually running is this fork served by Vite). It's
|
||||||
|
idempotent — safe to re-run, it no-ops once the server is already
|
||||||
|
bootstrapped. You only need to re-run it after `docker compose down -v`
|
||||||
|
(which wipes the volumes).
|
||||||
|
|
||||||
|
## 3. Seed sample data (optional)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Windows / PowerShell
|
||||||
|
pwsh ./scripts/dev-seed.ps1
|
||||||
|
|
||||||
|
# Linux / macOS / any POSIX shell (including most AI agent sandboxes)
|
||||||
|
bash ./scripts/dev-seed.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Populates Users, Groups, Mailing Lists, and Roles with sample data so
|
||||||
|
every admin panel screen has something to test against:
|
||||||
|
|
||||||
|
- **Users**: `alice@example.org` / `AlicePass123!` (User role, in
|
||||||
|
engineering, 1 alias), `bob@example.org` / `BobPass123!` (User role, in
|
||||||
|
engineering + marketing), `carol@example.org` / `CarolPass123!` (Admin
|
||||||
|
role, in marketing) — alongside the `devadmin`/`admin` accounts from
|
||||||
|
step 2.
|
||||||
|
- **Groups**: `engineering`, `marketing`.
|
||||||
|
- **Mailing lists**: `newsletter@example.org`, `support@example.org`.
|
||||||
|
- **Roles**: `Support Agent`, `Read-only Auditor` (custom, alongside the
|
||||||
|
built-in System Administrator/Tenant Administrator/Group/User roles).
|
||||||
|
|
||||||
|
Idempotent — does nothing if `alice` already exists. Only needed once per
|
||||||
|
fresh volume, same as step 2.
|
||||||
|
|
||||||
|
## 4. Get an access token
|
||||||
|
|
||||||
|
For local development it's simpler to skip interactive login and use a
|
||||||
|
bearer token directly via `VITE_ACCESS_TOKEN` (see `.env.development`).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Windows / PowerShell
|
||||||
|
pwsh ./scripts/dev-token.ps1 # 3 hour token (server default)
|
||||||
|
pwsh ./scripts/dev-token.ps1 -DurationSeconds 1800 # custom duration (30 min)
|
||||||
|
|
||||||
|
# Linux / macOS / any POSIX shell (including most AI agent sandboxes)
|
||||||
|
bash ./scripts/dev-token.sh # 3 hour token
|
||||||
|
bash ./scripts/dev-token.sh 1800 # custom duration (30 min)
|
||||||
|
```
|
||||||
|
|
||||||
|
Both scripts authenticate as the `devadmin` account created in step 2 and
|
||||||
|
create a Stalwart API key with the requested expiry (default 3 hours,
|
||||||
|
overridable per invocation — this is a genuine per-request duration, not
|
||||||
|
a global setting), then write its secret to `.env.development.local`
|
||||||
|
(gitignored, never committed) as `VITE_ACCESS_TOKEN`. Re-run the script
|
||||||
|
and restart `npm run dev` once the token expires (the UI starts returning
|
||||||
|
401s).
|
||||||
|
|
||||||
|
The `STALWART_RECOVERY_ADMIN` account is intentionally not used here: it's
|
||||||
|
a break-glass credential and its tokens always expire in a fixed 1 hour
|
||||||
|
regardless of server configuration, so it can't honor a custom duration.
|
||||||
|
|
||||||
|
## 5. Run the WebUI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install # first time only
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `http://localhost:5173`. You should land directly in the admin panel
|
||||||
|
(no login screen) since `VITE_ACCESS_TOKEN` is set.
|
||||||
|
|
||||||
|
## 6. Verify your change
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run typecheck
|
||||||
|
npm run lint
|
||||||
|
npm test
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
For UI changes, actually look at the running app (browser or a browser
|
||||||
|
automation tool) — passing typecheck/lint/tests proves the code compiles
|
||||||
|
and existing behavior didn't regress, it doesn't prove the new UI works.
|
||||||
|
|
||||||
|
## Resetting the test server
|
||||||
|
|
||||||
|
To start from a completely clean server (e.g. to re-test first-run
|
||||||
|
behavior):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev:server:down
|
||||||
|
docker compose down -v # also removes the stalwart-etc/stalwart-data volumes
|
||||||
|
npm run dev:server
|
||||||
|
bash ./scripts/dev-server-init.sh # re-run: fresh volume needs bootstrapping again
|
||||||
|
bash ./scripts/dev-seed.sh # optional: re-seed sample data too
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- **UI shows a login screen instead of the admin panel, or the page fails
|
||||||
|
to load data**: `npm run dev` only reads `.env.development.local` at
|
||||||
|
startup. If you regenerate a token, or restart the Docker container,
|
||||||
|
while `npm run dev` is already running, stop it (Ctrl+C) and start it
|
||||||
|
again — it won't pick up the new token or reconnect on its own.
|
||||||
|
- **`Failed to fetch` / connection refused in the browser console**: the
|
||||||
|
test server isn't running or isn't ready yet. Check with
|
||||||
|
`docker ps --filter name=stalwart-webui-dev` and
|
||||||
|
`npm run dev:server:logs`; wait for `Network listener started ...
|
||||||
|
localPort = 8080` before retrying.
|
||||||
|
- **401s after everything was working**: your token expired. Re-run
|
||||||
|
`scripts/dev-token.sh` (or `.ps1`) and restart `npm run dev`.
|
||||||
|
- **`scripts/dev-server-init.sh` fails with connection errors**: the
|
||||||
|
container needs a few seconds after `npm run dev:server` before it
|
||||||
|
accepts requests — the script retries for ~30s, but if your machine is
|
||||||
|
slow, just re-run it.
|
||||||
|
|
||||||
|
## Notes for AI agents
|
||||||
|
|
||||||
|
- This whole workflow (steps 1–5) is scriptable end-to-end without a
|
||||||
|
browser: `npm run dev:server`, then `bash scripts/dev-server-init.sh`
|
||||||
|
and `bash scripts/dev-seed.sh` (first time only), then
|
||||||
|
`bash scripts/dev-token.sh`, then the app is reachable at
|
||||||
|
`http://localhost:5173` with `VITE_ACCESS_TOKEN` already set. Verify
|
||||||
|
backend connectivity directly with `curl`, e.g.
|
||||||
|
`curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/jmap/session`.
|
||||||
|
- Read [AGENTS.md](AGENTS.md) before touching anything under `src/` —
|
||||||
|
the schema-fidelity rule applies to all development, local test server
|
||||||
|
or not.
|
||||||
|
- Don't commit `.env.development.local` (it holds a live token) or leave
|
||||||
|
the dev container running unexpectedly — `npm run dev:server:down` when
|
||||||
|
you're done.
|
||||||
@@ -8,6 +8,10 @@
|
|||||||
Web-based User Interface for Stalwart 🛡️
|
Web-based User Interface for Stalwart 🛡️
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
Community fork of <a href="https://github.com/stalwartlabs/webui">stalwartlabs/webui</a> with UI improvements and fixes.
|
||||||
|
</p>
|
||||||
|
|
||||||
<br>
|
<br>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -28,36 +32,93 @@
|
|||||||
<a href="https://matrix.to/#/#stalwart:matrix.org"><img src="https://img.shields.io/matrix/stalwartmail%3Amatrix.org?label=Join%20Matrix&logo=matrix&style=flat-square" alt="Matrix"></a>
|
<a href="https://matrix.to/#/#stalwart:matrix.org"><img src="https://img.shields.io/matrix/stalwartmail%3Amatrix.org?label=Join%20Matrix&logo=matrix&style=flat-square" alt="Matrix"></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
## About this fork
|
||||||
|
|
||||||
|
This is a community fork of [stalwartlabs/webui](https://github.com/stalwartlabs/webui) maintained by [LinkPhoenix](https://github.com/LinkPhoenix), focused on UI/UX improvements: mobile-friendly layouts, dark mode polish, additional color themes, a command palette, a calendar date/time picker, and several list/form refinements. Several of these have already been contributed back and shipped in official Stalwart WebUI releases.
|
||||||
|
|
||||||
|
**Stalwart WebUI** is a schema-driven single-page application for administering [Stalwart](https://stalw.art). After authentication the panel fetches a JSON schema from the server and dynamically generates all forms, lists, navigation, and layouts from that schema — the schema is the single source of truth, not the UI code.
|
||||||
|
|
||||||
|
This fork tries to stay aligned with that philosophy: any AI agent or contributor working on it follows the rules in [AGENTS.md](AGENTS.md), and the small number of deliberate exceptions where the UI does something the official schema doesn't (yet) support are tracked, with the ideal server-side fix for each, in [SCHEMA_DEVIATIONS.md](SCHEMA_DEVIATIONS.md).
|
||||||
|
|
||||||
|
See [CHANGELOG.md](CHANGELOG.md) for the full list of changes in this fork.
|
||||||
|
|
||||||
|
Official Stalwart repositories:
|
||||||
|
|
||||||
|
- [stalwartlabs/stalwart](https://github.com/stalwartlabs/stalwart) — the mail server itself.
|
||||||
|
- [stalwartlabs/webui](https://github.com/stalwartlabs/webui) — the official admin WebUI this project forks.
|
||||||
|
- [stalwartlabs/cli](https://github.com/stalwartlabs/cli) — `stalwart-cli`, used below to point a server at a WebUI build.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
**Stalwart WebUI** is schema-driven single-page application for administering [Stalwart](https://stalw.art). After authentication the panel fetches a JSON schema from the server and dynamically generates all forms, lists, navigation, and layouts from that schema. Nothing is hardcoded.
|
Key features (shared with upstream):
|
||||||
|
|
||||||
Key features:
|
|
||||||
|
|
||||||
- **Schema-driven UI**: All forms, lists, and navigation are generated from a JSON schema fetched from `/api/schema` after login. No object types, field names, or layouts are hardcoded.
|
- **Schema-driven UI**: All forms, lists, and navigation are generated from a JSON schema fetched from `/api/schema` after login. No object types, field names, or layouts are hardcoded.
|
||||||
- **JMAP protocol**: All data operations (queries, creates, updates, deletes, blob uploads) use JMAP (RFC 8620) with method chaining and result references.
|
- **JMAP protocol**: All data operations (queries, creates, updates, deletes, blob uploads) use JMAP (RFC 8620) with method chaining and result references.
|
||||||
- **Permission-aware**: Every button, link, field, and section respects the user's permissions. Elements the user cannot access are hidden.
|
- **Permission-aware**: Every button, link, field, and section respects the user's permissions. Elements the user cannot access are hidden.
|
||||||
|
|
||||||
|
Additions in this fork:
|
||||||
|
|
||||||
|
- **Usable on mobile**: admin lists, forms, and the sidebar work on narrow viewports instead of assuming desktop.
|
||||||
|
- **Selectable color themes** (Stalwart, Ocean, Forest, Violet, Rose, Amber, Teal) with a light/dark toggle and a square/rounded corners option.
|
||||||
|
- **`Ctrl+K` / `Cmd+K` command palette** to search pages, form sections, and fields across the admin panel.
|
||||||
|
- **Calendar date/time picker** replacing native date inputs, themed for dark mode.
|
||||||
|
- **Accounts list**: Role and Usage/Quota columns, with a highlight and recalculate hint for stale negative disk-usage values.
|
||||||
|
- **Mailboxes list**: shown as an indented hierarchy instead of a flat list.
|
||||||
|
- **Log Entries**: client-side Level/Event filters and a rate-limited manual refresh button.
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
<img src="./img/demo.gif">
|
<img src="./img/demo.gif">
|
||||||
|
|
||||||
## Get Started
|
## Get Started
|
||||||
|
|
||||||
Stalwart WebUI is included with Stalwart Mail Server, to install Stalwart Mail Server on your server by following the instructions for your platform:
|
Stalwart WebUI ships as part of Stalwart Mail Server. To install Stalwart Mail Server on your server, follow the instructions for your platform:
|
||||||
|
|
||||||
- [Linux / MacOS](https://stalw.art/docs/install/linux)
|
- [Linux / MacOS](https://stalw.art/docs/install/linux)
|
||||||
- [Windows](https://stalw.art/docs/install/windows)
|
- [Windows](https://stalw.art/docs/install/windows)
|
||||||
- [Docker](https://stalw.art/docs/install/docker)
|
- [Docker](https://stalw.art/docs/install/docker)
|
||||||
|
|
||||||
All documentation is available at [stalw.art/docs/get-started](https://stalw.art/docs/get-started).
|
All documentation is available at [stalw.art/docs/get-started](https://stalw.art/docs/get-started). Note that a standard Stalwart install ships the **official** WebUI; see [Switching your server to this fork's UI](#switching-your-server-to-this-forks-ui) below to point your server at this fork instead.
|
||||||
|
|
||||||
|
## Switching your server to this fork's UI
|
||||||
|
|
||||||
|
Stalwart serves its admin UI as a managed `WEBAPP` application, downloaded from a URL you control — switching to this fork (or back to upstream) is a server-side config change, no rebuild or redeploy of Stalwart itself required. This is done with [`stalwart-cli`](https://github.com/stalwartlabs/cli).
|
||||||
|
|
||||||
|
On your server:
|
||||||
|
|
||||||
|
```
|
||||||
|
export STALWART_URL=https://subdomain.domain.com
|
||||||
|
export STALWART_USER='user@domain.com'
|
||||||
|
export STALWART_PASSWORD='Password'
|
||||||
|
```
|
||||||
|
|
||||||
|
Find the id of your `WEBAPP` application:
|
||||||
|
|
||||||
|
```
|
||||||
|
stalwart-cli query Application
|
||||||
|
```
|
||||||
|
|
||||||
|
Point it at this fork's latest release instead of upstream's:
|
||||||
|
|
||||||
|
```
|
||||||
|
stalwart-cli update Application ID WEBAPP \
|
||||||
|
--field https://github.com/LinkPhoenix/stalwart-webui-fork/releases/latest/download/webui.zip
|
||||||
|
```
|
||||||
|
|
||||||
|
Then trigger the update:
|
||||||
|
|
||||||
|
```
|
||||||
|
stalwart-cli create Action/UpdateApps
|
||||||
|
```
|
||||||
|
|
||||||
|
Every tagged release of this fork publishes a `webui.zip` build via CI (see [`.github/workflows/build.yml`](.github/workflows/build.yml)), so pointing at `releases/latest/download/webui.zip` always fetches the newest tested build. To go back to the official UI, repeat the `update` step with `https://github.com/stalwartlabs/webui/releases/latest/download/webui.zip`.
|
||||||
|
|
||||||
## Getting started
|
## Getting started
|
||||||
|
|
||||||
Prerequisites:
|
Prerequisites:
|
||||||
|
|
||||||
- Node.js 18 or later
|
- Node.js 18 or later
|
||||||
- A running Stalwart instance (for JMAP API calls)
|
- A running Stalwart instance (for JMAP API calls) — see [DEVELOPMENT.md](DEVELOPMENT.md) for how to spin up a disposable local test server with Docker, no manual Stalwart setup required.
|
||||||
|
|
||||||
Install dependencies:
|
Install dependencies:
|
||||||
|
|
||||||
@@ -85,12 +146,14 @@ VITE_OAUTH_SCOPES=
|
|||||||
|
|
||||||
### Bypassing OAuth for development
|
### Bypassing OAuth for development
|
||||||
|
|
||||||
Set `VITE_ACCESS_TOKEN` to a valid bearer token to skip the login page and go straight to the admin panel. You can obtain a token from the Stalwart server's token endpoint or use an API key:
|
Set `VITE_ACCESS_TOKEN` to a valid bearer token to skip the login page and go straight to the admin panel:
|
||||||
|
|
||||||
```
|
```
|
||||||
VITE_ACCESS_TOKEN=your-bearer-token-here
|
VITE_ACCESS_TOKEN=your-bearer-token-here
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Against the local test server from [DEVELOPMENT.md](DEVELOPMENT.md), `scripts/dev-token.ps1` / `scripts/dev-token.sh` fetch one for you automatically.
|
||||||
|
|
||||||
### Running the dev server
|
### Running the dev server
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -130,10 +193,11 @@ npm run preview
|
|||||||
|
|
||||||
## Support
|
## Support
|
||||||
|
|
||||||
If you are having problems running Stalwart Mail Server, you found a bug or just have a question,
|
For bugs or questions about **this fork's UI changes**, please open an issue on [this repository](https://github.com/LinkPhoenix/stalwart-webui-fork).
|
||||||
do not hesitate to reach us on [Github Discussions](https://github.com/stalwartlabs/mail-server/discussions),
|
|
||||||
|
For anything related to Stalwart Mail Server itself, do not hesitate to reach the upstream team on [Github Discussions](https://github.com/stalwartlabs/mail-server/discussions),
|
||||||
[Reddit](https://www.reddit.com/r/stalwartlabs), [Discord](https://discord.gg/aVQr3jF8jd) or [Matrix](https://matrix.to/#/#stalwart:matrix.org).
|
[Reddit](https://www.reddit.com/r/stalwartlabs), [Discord](https://discord.gg/aVQr3jF8jd) or [Matrix](https://matrix.to/#/#stalwart:matrix.org).
|
||||||
Additionally you may purchase a subscription to obtain priority support from Stalwart Labs LLC
|
Additionally you may purchase a subscription to obtain priority support from Stalwart Labs LLC.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
@@ -144,6 +208,8 @@ This project is dual-licensed under the **GNU Affero General Public License v3.0
|
|||||||
|
|
||||||
Each file in this project contains a license notice at the top, indicating the applicable license(s). The license notice follows the [REUSE guidelines](https://reuse.software/) to ensure clarity and consistency. The full text of each license is available in the [LICENSES](./LICENSES/) directory.
|
Each file in this project contains a license notice at the top, indicating the applicable license(s). The license notice follows the [REUSE guidelines](https://reuse.software/) to ensure clarity and consistency. The full text of each license is available in the [LICENSES](./LICENSES/) directory.
|
||||||
|
|
||||||
|
As a fork, all changes made here — including new files added by this fork — remain under the same dual license as the upstream project; this is reflected in the SPDX license notice at the top of every source file.
|
||||||
|
|
||||||
## Copyright
|
## Copyright
|
||||||
|
|
||||||
Copyright (C) 2024, Stalwart Labs LLC
|
Copyright (C) 2024, Stalwart Labs LLC
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# Schema deviations
|
||||||
|
|
||||||
|
Stalwart WebUI is schema-driven: the server's JSON schema is the single
|
||||||
|
source of truth for what forms, fields, filters, and columns exist. This
|
||||||
|
fork tries to stay aligned with that philosophy (see
|
||||||
|
[stalwartlabs/webui#discussion](https://github.com/stalwartlabs/webui) and
|
||||||
|
the maintainer's note on why exceptions belong server-side, not in the UI).
|
||||||
|
|
||||||
|
Everything in this file is a deliberate exception: a place where the UI
|
||||||
|
does something the schema doesn't (yet) describe, because the equivalent
|
||||||
|
server-side capability doesn't exist in [stalwartlabs/stalwart](https://github.com/stalwartlabs/stalwart)
|
||||||
|
or [stalwartlabs/webui](https://github.com/stalwartlabs/webui). Each entry
|
||||||
|
is tagged in code with `// SCHEMA-DEVIATION: <id>` so they're greppable
|
||||||
|
(`grep -rn "SCHEMA-DEVIATION" src/`).
|
||||||
|
|
||||||
|
The goal is **not** to remove these — they're real functionality this fork
|
||||||
|
wants to keep — but to track them separately from schema-driven code, so
|
||||||
|
it's always clear which is which, and so each one can be dropped the day
|
||||||
|
the server (or upstream webui) grows the equivalent native capability.
|
||||||
|
|
||||||
|
## `src/types/schema.ts` is never touched for a deviation
|
||||||
|
|
||||||
|
`src/types/schema.ts` mirrors the server's schema contract exactly and
|
||||||
|
must stay aligned with the official webui/server types — it is **never**
|
||||||
|
edited to accommodate a deviation, even to add an extra optional field.
|
||||||
|
|
||||||
|
If a deviation needs to carry extra data on an otherwise-official schema
|
||||||
|
shape (e.g. a flag consumed only by the deviation's own code), the
|
||||||
|
augmented type lives in the deviation's own module or in
|
||||||
|
[`src/lib/schemaDeviationTypes.ts`](src/lib/schemaDeviationTypes.ts), as
|
||||||
|
an intersection with the official type (`OfficialType & { extra?: ... }`),
|
||||||
|
and is imported only where the deviation is actually used. `schema.ts`
|
||||||
|
itself stays byte-for-byte alignable with upstream's version of the file.
|
||||||
|
|
||||||
|
## Status legend
|
||||||
|
|
||||||
|
- 🟡 **Workaround** — client-only, would be removed if the server supported it natively.
|
||||||
|
- 🔵 **Upstream tracked** — an issue has been filed upstream; link included.
|
||||||
|
|
||||||
|
## Deviations
|
||||||
|
|
||||||
|
### `log-client-filters` 🟡
|
||||||
|
|
||||||
|
- **Where**: [`src/lib/logFilters.ts`](src/lib/logFilters.ts), type augmentation in [`src/lib/schemaDeviationTypes.ts`](src/lib/schemaDeviationTypes.ts)
|
||||||
|
- **What**: injects `level` and `event` as filterable columns on the `x:Log` list, applied entirely client-side (`clientOnly` flag consumed by `DynamicList`). The `clientOnly` flag is declared as `ClientOnlyFilterEnum` (an intersection type), not on the official `FilterEnum` in `schema.ts`.
|
||||||
|
- **Why**: Stalwart's JMAP `x:Log/query` returns `unsupportedFilter` for both properties today, even though they're returned per row.
|
||||||
|
- **Ideal fix**: `stalwartlabs/stalwart` accepts `level`/`event` as real query filters; the schema then advertises them normally and `logFilters.ts` + the `ClientOnlyFilterEnum` augmentation are deleted.
|
||||||
|
|
||||||
|
### `account-quota-usage-column` 🟡
|
||||||
|
|
||||||
|
- **Where**: [`src/lib/accountColumns.ts`](src/lib/accountColumns.ts)
|
||||||
|
- **What**: adds a synthetic `quotaUsage` column to the `x:Account/User` and `x:Account/Group` lists — not a real schema property, `DynamicList` resolves it from the `usedDiskQuota` + `quotas.maxDiskQuota` pair and formats it specially. Also re-adds `roles` on the Users list only (a real property, just not in the list's default columns).
|
||||||
|
- **Why**: neither the Accounts nor the Groups list schema exposes usage/role as list columns, only as detail-view fields, even though both object types have real `usedDiskQuota`/`quotas` fields.
|
||||||
|
- **Ideal fix**: the server's `x:Account/User` and `x:Account/Group` list schemas include `roles` (Users) and a computed usage/quota column natively; this file is deleted.
|
||||||
|
|
||||||
|
### `mailbox-client-hierarchy-sort` 🟡
|
||||||
|
|
||||||
|
- **Where**: [`src/components/lists/DynamicList.tsx`](src/components/lists/DynamicList.tsx) — `sortMailboxesByHierarchy` and the `isMailboxList` branch
|
||||||
|
- **What**: for the `Mailbox` list, fetches the *entire* result set (bypassing normal server pagination) and sorts it client-side so each parent mailbox is immediately followed by its children, with indentation depth tracked in React state.
|
||||||
|
- **Why**: the server returns mailboxes in whatever order the query produces, not grouped by parent/child, and a mailbox's parent can land on a different page than the mailbox itself, so hierarchy can't be reconstructed one page at a time.
|
||||||
|
- **Ideal fix**: the server offers a native tree/hierarchical ordering (or a `sort` that groups by ancestry) for `Mailbox/query`; the client-side full-fetch-and-sort is deleted in favor of normal paginated queries.
|
||||||
|
|
||||||
|
### `webapp-enabled-column-fallback` 🟡
|
||||||
|
|
||||||
|
- **Where**: [`src/components/lists/DynamicList.tsx`](src/components/lists/DynamicList.tsx) — `displayColumns` in the `isWebApplications` branch
|
||||||
|
- **What**: reordering the `x:Application` list's real schema columns (Description first, Enabled second) is not a deviation, but if the schema's `list.columns` doesn't include an `enabled` column at all, a fallback column definition with a hardcoded label is fabricated client-side so the toggle still renders.
|
||||||
|
- **Why**: the `x:Application` list schema is not guaranteed to expose `enabled` as a list column, even though it's a real object property (fetched separately via `properties.push('enabled')`).
|
||||||
|
- **Ideal fix**: the server's `x:Application` list schema always includes `enabled` as a real column; the fallback branch is deleted (only the reordering logic remains, which is not a deviation).
|
||||||
|
|
||||||
|
### `account-alias-count-column` 🟡
|
||||||
|
|
||||||
|
- **Where**: [`src/lib/accountColumns.ts`](src/lib/accountColumns.ts), [`src/lib/mailingListColumns.ts`](src/lib/mailingListColumns.ts), [`src/lib/domainColumns.ts`](src/lib/domainColumns.ts), resolved generically via `COUNT_COLUMN_SOURCES` in [`src/components/lists/DynamicList.tsx`](src/components/lists/DynamicList.tsx)
|
||||||
|
- **What**: adds a synthetic `aliasCount` column to the `x:Account/User`, `x:Account/Group`, `x:MailingList`, and `x:Domain` lists — not a real schema property; `DynamicList` resolves it from the real `aliases` property (an objectList on Accounts/Groups/Mailing Lists, a `set` of domain names on Domains — same id-keyed wire format either way) and renders its entry count.
|
||||||
|
- **Why**: none of these lists' schemas expose alias count as a column, only the full `aliases` list on the detail view.
|
||||||
|
- **Ideal fix**: the server's list schemas include a computed alias-count column natively; these column definitions are deleted.
|
||||||
|
|
||||||
|
### `account-client-sort` 🟡
|
||||||
|
|
||||||
|
- **Where**: table-level mechanism in [`src/components/lists/DynamicList.tsx`](src/components/lists/DynamicList.tsx) (`clientSortableColumns`, `getClientSortValue`, the `fetchData` branch triggered by `clientSortField`) reading a `clientSortable` flag set per-column via the shared `clientSortable()` helper in [`src/lib/schemaDeviationTypes.ts`](src/lib/schemaDeviationTypes.ts) (type: `ClientSortableColumn`), used by [`accountColumns.ts`](src/lib/accountColumns.ts), [`mailingListColumns.ts`](src/lib/mailingListColumns.ts), [`roleColumns.ts`](src/lib/roleColumns.ts), and [`domainColumns.ts`](src/lib/domainColumns.ts)
|
||||||
|
- **What**: any column tagged `clientSortable` in the schema gets fetch-all-then-sort-in-memory on click (bypassing server pagination, same mechanism as `mailbox-client-hierarchy-sort`), instead of sending a JMAP `sort` to the server. The mechanism itself is generic and not tied to any specific list. Currently tagged: Email Address/Full Name/Usage/Aliases on `x:Account/User` and `x:Account/Group`; Email Address/Description/Aliases on `x:MailingList`; Description/Enabled Permissions/Disabled Permissions on `x:Role`; Domain Name/Enabled/Aliases on `x:Domain`.
|
||||||
|
- **Why**: none of these lists' schemas declare any sortable property at all (`list.sort` is absent on all four) — confirmed against a live server by trying `sort` on every displayed real column: all return `unsupportedSort`, **except** `x:Domain/query` with `sort: [{"property":"name",...}]`, which the server actually accepts despite the schema not declaring it. Rather than add a second "trust an undeclared sort" pathway for that one case, Domain Name is routed through the same client-sort mechanism as everything else, for consistency; it's marginally less efficient (fetch-all instead of a paginated server sort) but domain lists are typically small.
|
||||||
|
- **Ideal fix**: the server's query methods accept `sort` on these properties and the schema declares them in each list's `list.sort`; each `with*Columns` helper stops tagging its columns `clientSortable` and they fall through to the normal server-paginated `sortableFields` path already used elsewhere. The generic mechanism itself only goes away once nothing tags any column `clientSortable` anymore.
|
||||||
|
|
||||||
|
### `role-permission-count-columns` 🟡
|
||||||
|
|
||||||
|
- **Where**: [`src/lib/roleColumns.ts`](src/lib/roleColumns.ts); resolved generically by the same `COUNT_COLUMN_SOURCES` table in [`src/components/lists/DynamicList.tsx`](src/components/lists/DynamicList.tsx) used by `account-alias-count-column`
|
||||||
|
- **What**: adds synthetic `enabledPermissionCount`/`disabledPermissionCount` columns to the `x:Role` list — not real schema properties; resolved from the real `enabledPermissions`/`disabledPermissions` set properties and rendered as entry counts.
|
||||||
|
- **Why**: the Roles list schema only exposes Description as a column; seeing how broad or restrictive a role is requires opening it and counting permissions by hand.
|
||||||
|
- **Ideal fix**: the server's `x:Role` list schema includes computed enabled/disabled permission count columns natively; this column definition is deleted.
|
||||||
|
|
||||||
|
## Not a deviation (for reference)
|
||||||
|
|
||||||
|
A few other `viewName === '...'` / `objectName === '...'` checks exist in
|
||||||
|
`DynamicList.tsx`, `MainContent.tsx`, `Sidebar.tsx`, `layout.ts`, and
|
||||||
|
`FieldWidget.tsx` (e.g. `x:OtpAuth`, `x:Expression`, `x:Rate`, `x:Action`,
|
||||||
|
`x:Trace`, `CustomComponent/Dashboard` and other `CustomComponent/*`
|
||||||
|
pages, the `x:Application` column reordering itself, the active-WebApp
|
||||||
|
info card). These are **not** tracked here: they render real schema data
|
||||||
|
with a custom widget or extra display, the same pattern already used
|
||||||
|
upstream for special object types — they don't fabricate data or bypass
|
||||||
|
the server's filtering/pagination. Verified against `upstream/main` for
|
||||||
|
each: the object/view names above already drive special-cased rendering
|
||||||
|
there too, except `x:Application`/`Mailbox`/`x:Log`/`x:Account/User`
|
||||||
|
which are fork-only and covered by the entries above (or explicitly
|
||||||
|
noted as presentation-only, e.g. the Web Applications column reorder).
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
services:
|
||||||
|
stalwart:
|
||||||
|
image: stalwartlabs/stalwart:latest
|
||||||
|
container_name: stalwart-webui-dev
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
# HTTP management/JMAP API — the only port the WebUI dev proxy needs
|
||||||
|
# (see server.proxy in vite.config.ts).
|
||||||
|
- "8080:8080"
|
||||||
|
# Mail protocols, only needed if you're testing actual mail flows
|
||||||
|
# (sending/receiving, not just admin UI screens).
|
||||||
|
- "25:25"
|
||||||
|
- "587:587"
|
||||||
|
- "465:465"
|
||||||
|
- "143:143"
|
||||||
|
- "993:993"
|
||||||
|
- "110:110"
|
||||||
|
- "995:995"
|
||||||
|
- "4190:4190"
|
||||||
|
volumes:
|
||||||
|
- stalwart-etc:/etc/stalwart
|
||||||
|
- stalwart-data:/var/lib/stalwart
|
||||||
|
environment:
|
||||||
|
# Disposable local dev credentials — do not reuse for anything real.
|
||||||
|
# Break-glass admin, used only by scripts/dev-server-init.sh (its
|
||||||
|
# tokens always expire in 1h regardless of server config, so it's
|
||||||
|
# not used for day-to-day dev tokens — see scripts/dev-token.sh).
|
||||||
|
STALWART_RECOVERY_ADMIN: "admin@example.org:c8321iEscHDy0GWV"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
stalwart-etc:
|
||||||
|
stalwart-data:
|
||||||
@@ -6,7 +6,9 @@
|
|||||||
<base href="/" />
|
<base href="/" />
|
||||||
<link rel="icon" type="image/svg+xml" href="favicon.png" />
|
<link rel="icon" type="image/svg+xml" href="favicon.png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Portal</title>
|
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)" />
|
||||||
|
<meta name="theme-color" content="#09090b" media="(prefers-color-scheme: dark)" />
|
||||||
|
<title>Stalwart WebUI</title>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "stalwart-webui",
|
"name": "stalwart-webui-fork",
|
||||||
"version": "1.0.7",
|
"version": "1.1.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "stalwart-webui",
|
"name": "stalwart-webui-fork",
|
||||||
"version": "1.0.7",
|
"version": "1.1.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@daypicker/react": "^10.0.1",
|
"@daypicker/react": "^10.0.1",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.23",
|
"@radix-ui/react-alert-dialog": "^1.1.23",
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"i18next": "^26.3.6",
|
"i18next": "^26.3.6",
|
||||||
"lucide-react": "^1.28.0",
|
"lucide-react": "1.28.0",
|
||||||
"otpauth": "^9.5.1",
|
"otpauth": "^9.5.1",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
@@ -46,7 +46,7 @@
|
|||||||
"@types/node": "^26.1.2",
|
"@types/node": "^26.1.2",
|
||||||
"@types/react": "^19.2.17",
|
"@types/react": "^19.2.17",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.5",
|
"@vitejs/plugin-react": "6.0.5",
|
||||||
"eslint": "^10.8.0",
|
"eslint": "^10.8.0",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
"tailwindcss": "^4.3.3",
|
"tailwindcss": "^4.3.3",
|
||||||
"typescript": "~6.0.3",
|
"typescript": "~6.0.3",
|
||||||
"typescript-eslint": "^8.65.0",
|
"typescript-eslint": "^8.65.0",
|
||||||
"vite": "^8.2.0",
|
"vite": "8.2.0",
|
||||||
"vitest": "^4.1.10"
|
"vitest": "^4.1.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -644,9 +644,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@napi-rs/wasm-runtime": {
|
"node_modules/@napi-rs/wasm-runtime": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz",
|
||||||
"integrity": "sha512-KjZdi8Q1wh89gsVmghvbrMgWl6ZWmRmHV6wjB7/g4Zf0dyO+hH3neZUtuDNPO00qq5YE5RITVWvrIZKRaAmzGQ==",
|
"integrity": "sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
@@ -2492,6 +2492,92 @@
|
|||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
|
"version": "8.65.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz",
|
||||||
|
"integrity": "sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@eslint-community/regexpp": "^4.12.2",
|
||||||
|
"@typescript-eslint/scope-manager": "8.65.0",
|
||||||
|
"@typescript-eslint/type-utils": "8.65.0",
|
||||||
|
"@typescript-eslint/utils": "8.65.0",
|
||||||
|
"@typescript-eslint/visitor-keys": "8.65.0",
|
||||||
|
"ignore": "^7.0.5",
|
||||||
|
"natural-compare": "^1.4.0",
|
||||||
|
"ts-api-utils": "^2.5.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/typescript-eslint"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@typescript-eslint/parser": "^8.65.0",
|
||||||
|
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||||
|
"typescript": ">=4.8.4 <6.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
|
||||||
|
"version": "7.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz",
|
||||||
|
"integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript-eslint/parser": {
|
||||||
|
"version": "8.65.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.65.0.tgz",
|
||||||
|
"integrity": "sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@typescript-eslint/scope-manager": "8.65.0",
|
||||||
|
"@typescript-eslint/types": "8.65.0",
|
||||||
|
"@typescript-eslint/typescript-estree": "8.65.0",
|
||||||
|
"@typescript-eslint/visitor-keys": "8.65.0",
|
||||||
|
"debug": "^4.4.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/typescript-eslint"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||||
|
"typescript": ">=4.8.4 <6.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript-eslint/project-service": {
|
||||||
|
"version": "8.65.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.65.0.tgz",
|
||||||
|
"integrity": "sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@typescript-eslint/tsconfig-utils": "^8.65.0",
|
||||||
|
"@typescript-eslint/types": "^8.65.0",
|
||||||
|
"debug": "^4.4.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/typescript-eslint"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": ">=4.8.4 <6.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@typescript-eslint/scope-manager": {
|
"node_modules/@typescript-eslint/scope-manager": {
|
||||||
"version": "8.65.0",
|
"version": "8.65.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.65.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.65.0.tgz",
|
||||||
@@ -2510,6 +2596,48 @@
|
|||||||
"url": "https://opencollective.com/typescript-eslint"
|
"url": "https://opencollective.com/typescript-eslint"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@typescript-eslint/tsconfig-utils": {
|
||||||
|
"version": "8.65.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.65.0.tgz",
|
||||||
|
"integrity": "sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/typescript-eslint"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": ">=4.8.4 <6.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript-eslint/type-utils": {
|
||||||
|
"version": "8.65.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.65.0.tgz",
|
||||||
|
"integrity": "sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@typescript-eslint/types": "8.65.0",
|
||||||
|
"@typescript-eslint/typescript-estree": "8.65.0",
|
||||||
|
"@typescript-eslint/utils": "8.65.0",
|
||||||
|
"debug": "^4.4.3",
|
||||||
|
"ts-api-utils": "^2.5.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/typescript-eslint"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||||
|
"typescript": ">=4.8.4 <6.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@typescript-eslint/types": {
|
"node_modules/@typescript-eslint/types": {
|
||||||
"version": "8.65.0",
|
"version": "8.65.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.65.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.65.0.tgz",
|
||||||
@@ -2524,6 +2652,71 @@
|
|||||||
"url": "https://opencollective.com/typescript-eslint"
|
"url": "https://opencollective.com/typescript-eslint"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@typescript-eslint/typescript-estree": {
|
||||||
|
"version": "8.65.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.65.0.tgz",
|
||||||
|
"integrity": "sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@typescript-eslint/project-service": "8.65.0",
|
||||||
|
"@typescript-eslint/tsconfig-utils": "8.65.0",
|
||||||
|
"@typescript-eslint/types": "8.65.0",
|
||||||
|
"@typescript-eslint/visitor-keys": "8.65.0",
|
||||||
|
"debug": "^4.4.3",
|
||||||
|
"minimatch": "^10.2.2",
|
||||||
|
"semver": "^7.7.3",
|
||||||
|
"tinyglobby": "^0.2.15",
|
||||||
|
"ts-api-utils": "^2.5.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/typescript-eslint"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": ">=4.8.4 <6.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
|
||||||
|
"version": "7.8.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||||
|
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"bin": {
|
||||||
|
"semver": "bin/semver.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript-eslint/utils": {
|
||||||
|
"version": "8.65.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.65.0.tgz",
|
||||||
|
"integrity": "sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@eslint-community/eslint-utils": "^4.9.1",
|
||||||
|
"@typescript-eslint/scope-manager": "8.65.0",
|
||||||
|
"@typescript-eslint/types": "8.65.0",
|
||||||
|
"@typescript-eslint/typescript-estree": "8.65.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/typescript-eslint"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||||
|
"typescript": ">=4.8.4 <6.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@typescript-eslint/visitor-keys": {
|
"node_modules/@typescript-eslint/visitor-keys": {
|
||||||
"version": "8.65.0",
|
"version": "8.65.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.65.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.65.0.tgz",
|
||||||
@@ -6067,199 +6260,6 @@
|
|||||||
"typescript": ">=4.8.4 <6.1.0"
|
"typescript": ">=4.8.4 <6.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/typescript-eslint/node_modules/@typescript-eslint/eslint-plugin": {
|
|
||||||
"version": "8.65.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz",
|
|
||||||
"integrity": "sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@eslint-community/regexpp": "^4.12.2",
|
|
||||||
"@typescript-eslint/scope-manager": "8.65.0",
|
|
||||||
"@typescript-eslint/type-utils": "8.65.0",
|
|
||||||
"@typescript-eslint/utils": "8.65.0",
|
|
||||||
"@typescript-eslint/visitor-keys": "8.65.0",
|
|
||||||
"ignore": "^7.0.5",
|
|
||||||
"natural-compare": "^1.4.0",
|
|
||||||
"ts-api-utils": "^2.5.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/typescript-eslint"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@typescript-eslint/parser": "^8.65.0",
|
|
||||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
|
||||||
"typescript": ">=4.8.4 <6.1.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/typescript-eslint/node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/type-utils": {
|
|
||||||
"version": "8.65.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.65.0.tgz",
|
|
||||||
"integrity": "sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@typescript-eslint/types": "8.65.0",
|
|
||||||
"@typescript-eslint/typescript-estree": "8.65.0",
|
|
||||||
"@typescript-eslint/utils": "8.65.0",
|
|
||||||
"debug": "^4.4.3",
|
|
||||||
"ts-api-utils": "^2.5.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/typescript-eslint"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
|
||||||
"typescript": ">=4.8.4 <6.1.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/typescript-eslint/node_modules/@typescript-eslint/parser": {
|
|
||||||
"version": "8.65.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.65.0.tgz",
|
|
||||||
"integrity": "sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@typescript-eslint/scope-manager": "8.65.0",
|
|
||||||
"@typescript-eslint/types": "8.65.0",
|
|
||||||
"@typescript-eslint/typescript-estree": "8.65.0",
|
|
||||||
"@typescript-eslint/visitor-keys": "8.65.0",
|
|
||||||
"debug": "^4.4.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/typescript-eslint"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
|
||||||
"typescript": ">=4.8.4 <6.1.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/typescript-eslint/node_modules/@typescript-eslint/typescript-estree": {
|
|
||||||
"version": "8.65.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.65.0.tgz",
|
|
||||||
"integrity": "sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@typescript-eslint/project-service": "8.65.0",
|
|
||||||
"@typescript-eslint/tsconfig-utils": "8.65.0",
|
|
||||||
"@typescript-eslint/types": "8.65.0",
|
|
||||||
"@typescript-eslint/visitor-keys": "8.65.0",
|
|
||||||
"debug": "^4.4.3",
|
|
||||||
"minimatch": "^10.2.2",
|
|
||||||
"semver": "^7.7.3",
|
|
||||||
"tinyglobby": "^0.2.15",
|
|
||||||
"ts-api-utils": "^2.5.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/typescript-eslint"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"typescript": ">=4.8.4 <6.1.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/typescript-eslint/node_modules/@typescript-eslint/typescript-estree/node_modules/@typescript-eslint/project-service": {
|
|
||||||
"version": "8.65.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.65.0.tgz",
|
|
||||||
"integrity": "sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@typescript-eslint/tsconfig-utils": "^8.65.0",
|
|
||||||
"@typescript-eslint/types": "^8.65.0",
|
|
||||||
"debug": "^4.4.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/typescript-eslint"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"typescript": ">=4.8.4 <6.1.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/typescript-eslint/node_modules/@typescript-eslint/typescript-estree/node_modules/@typescript-eslint/tsconfig-utils": {
|
|
||||||
"version": "8.65.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.65.0.tgz",
|
|
||||||
"integrity": "sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/typescript-eslint"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"typescript": ">=4.8.4 <6.1.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/typescript-eslint/node_modules/@typescript-eslint/utils": {
|
|
||||||
"version": "8.65.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.65.0.tgz",
|
|
||||||
"integrity": "sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@eslint-community/eslint-utils": "^4.9.1",
|
|
||||||
"@typescript-eslint/scope-manager": "8.65.0",
|
|
||||||
"@typescript-eslint/types": "8.65.0",
|
|
||||||
"@typescript-eslint/typescript-estree": "8.65.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/typescript-eslint"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
|
||||||
"typescript": ">=4.8.4 <6.1.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/typescript-eslint/node_modules/ignore": {
|
|
||||||
"version": "7.0.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz",
|
|
||||||
"integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/typescript-eslint/node_modules/semver": {
|
|
||||||
"version": "7.8.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
|
||||||
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
|
||||||
"bin": {
|
|
||||||
"semver": "bin/semver.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "8.3.0",
|
"version": "8.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "stalwart-webui",
|
"name": "stalwart-webui-fork",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.7",
|
"version": "1.1.3",
|
||||||
"description": "Stalwart WebUI",
|
"description": "Stalwart WebUI Fork",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
"dev:server": "docker compose up -d",
|
||||||
|
"dev:server:down": "docker compose down",
|
||||||
|
"dev:server:logs": "docker compose logs -f stalwart",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"typecheck": "tsc -p tsconfig.app.json --noEmit",
|
"typecheck": "tsc -p tsconfig.app.json --noEmit",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
@@ -36,7 +39,7 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"i18next": "^26.3.6",
|
"i18next": "^26.3.6",
|
||||||
"lucide-react": "^1.28.0",
|
"lucide-react": "1.28.0",
|
||||||
"otpauth": "^9.5.1",
|
"otpauth": "^9.5.1",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
@@ -54,7 +57,7 @@
|
|||||||
"@types/node": "^26.1.2",
|
"@types/node": "^26.1.2",
|
||||||
"@types/react": "^19.2.17",
|
"@types/react": "^19.2.17",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.5",
|
"@vitejs/plugin-react": "6.0.5",
|
||||||
"eslint": "^10.8.0",
|
"eslint": "^10.8.0",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
@@ -65,7 +68,7 @@
|
|||||||
"tailwindcss": "^4.3.3",
|
"tailwindcss": "^4.3.3",
|
||||||
"typescript": "~6.0.3",
|
"typescript": "~6.0.3",
|
||||||
"typescript-eslint": "^8.65.0",
|
"typescript-eslint": "^8.65.0",
|
||||||
"vite": "^8.2.0",
|
"vite": "8.2.0",
|
||||||
"vitest": "^4.1.10"
|
"vitest": "^4.1.10"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"><path d="M307 346c-20.6 1-18.5-9.8-6.4-26.3 28.2-37.7 81.5-90.4 83.3-127.6 3.2-48.7-45.7-64.5-96-64-35.1.4-71.3 10.5-96 19.3C106.7 177.5 53.2 225.7 19.5 279c-35.7 52.9-24 105.8 51.5 105.1 58-.6 95.5-18.4 134.8-38.4.2 0-108.5 31-148.4 8.1-.1 0-.1 0-.2-.1-4.1-2.3-9.1-5.6-9.8-14.6-1.4-18.9 31-38.5 48.6-44.8v-32.7c12.9 5 27 7.8 41.7 7.8 28.2 0 54-10.2 74.1-27 .8 2.8 1.2 5.9 1 9.5h7.9c1-8.4-3.7-15.1-3.7-15.1-7.1-11.3-19.5-11.1-19.5-11.1s6.7 2.9 11.3 9.9c-18.4 15.4-42 24.7-67.9 24.7-11 0-21.6-1.7-31.6-4.8L135 230l-7.1-18.6c51.9-18.2 95.6-32.1 166.6-44.4l-15.9-13.3 8.3-5.1c42.9 12.1 70.9 21 69.4 43.7-.7 3.7-2 8.1-4.3 13-12.5 24.7-49.6 65.9-64.6 83.1-9.9 11.4-19.6 22.6-26.6 33.3-7.1 10.7-11.5 20.7-11.8 30.3.9 74.8 220.4-34.9 262.9-64-62.7 27-130.3 53-204.9 58" style="fill:#f60"/></svg>
|
||||||
|
After Width: | Height: | Size: 871 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" id="Layer_1" x="0" y="0" version="1.1" viewBox="0.02 102.6 511.9 306.4"><style>.st1{fill-rule:evenodd;clip-rule:evenodd;fill:#f90}</style><path d="M144.3 214.1c0 6.3.7 11.4 1.9 15.2 1.4 3.7 3.1 7.8 5.4 12.3.9 1.4 1.2 2.7 1.2 3.9 0 1.7-1 3.4-3.2 5.1l-10.7 7.2c-1.5 1-3.1 1.5-4.4 1.5-1.7 0-3.4-.9-5.1-2.4-2.4-2.6-4.4-5.3-6.1-8-1.7-2.9-3.4-6.1-5.3-10-13.3 15.7-30 23.5-50.1 23.5-14.3 0-25.7-4.1-34.1-12.3-8.3-8.2-12.6-19.1-12.6-32.7 0-14.5 5.1-26.2 15.5-35.1S60.8 169 78.4 169c5.8 0 11.7.5 18.1 1.4s12.8 2.2 19.6 3.7v-12.4c0-12.9-2.7-22-8-27.2-5.4-5.3-14.6-7.8-27.8-7.8-6 0-12.1.7-18.4 2.2s-12.4 3.4-18.4 5.8c-2.7 1.2-4.8 1.9-6 2.2s-2 .5-2.7.5c-2.4 0-3.6-1.7-3.6-5.3v-8.3c0-2.7.3-4.8 1.2-6s2.4-2.4 4.8-3.6c6-3.1 13.1-5.6 21.5-7.7 8.3-2.2 17.2-3.2 26.6-3.2 20.3 0 35.1 4.6 44.6 13.8 9.4 9.2 14.1 23.2 14.1 41.9v55.2h.3zM75.2 240c5.6 0 11.4-1 17.5-3.1 6.1-2 11.6-5.8 16.2-10.9 2.7-3.2 4.8-6.8 5.8-10.9s1.7-9 1.7-14.8v-7.2c-4.9-1.2-10.2-2.2-15.7-2.9-5.4-.7-10.7-1-16-1-11.4 0-19.8 2.2-25.4 6.8S51 207.1 51 215.6c0 8 2 14 6.3 18.1 4.1 4.2 10 6.3 17.9 6.3m136.7 18.4c-3.1 0-5.1-.5-6.5-1.7-1.4-1-2.6-3.4-3.6-6.6l-40-131.6c-1-3.4-1.5-5.6-1.5-6.8 0-2.7 1.4-4.3 4.1-4.3h16.7c3.2 0 5.4.5 6.6 1.7 1.4 1 2.4 3.4 3.4 6.6l28.6 112.7 26.6-112.7c.9-3.4 1.9-5.6 3.2-6.6 1.4-1 3.7-1.7 6.8-1.7H270c3.2 0 5.4.5 6.8 1.7 1.4 1 2.6 3.4 3.2 6.6l26.9 114.1 29.5-114.1c1-3.4 2.2-5.6 3.4-6.6 1.4-1 3.6-1.7 6.6-1.7h15.8c2.7 0 4.3 1.4 4.3 4.3 0 .9-.2 1.7-.3 2.7-.2 1-.5 2.4-1.2 4.3l-41 131.6q-1.5 5.1-3.6 6.6c-1.4 1-3.6 1.7-6.5 1.7h-14.6c-3.2 0-5.4-.5-6.8-1.7s-2.6-3.4-3.2-6.8l-26.4-109.8L236.7 250c-.9 3.4-1.9 5.6-3.2 6.8-1.4 1.2-3.7 1.7-6.8 1.7h-14.8zm218.8 4.6c-8.9 0-17.7-1-26.2-3.1-8.5-2-15.2-4.3-19.6-6.8-2.7-1.5-4.6-3.2-5.3-4.8s-1-3.2-1-4.8v-8.7c0-3.6 1.4-5.3 3.9-5.3 1 0 2 .2 3.1.5 1 .3 2.6 1 4.3 1.7 5.8 2.6 12.1 4.6 18.7 6 6.8 1.4 13.5 2 20.3 2 10.7 0 19.1-1.9 24.9-5.6s8.9-9.2 8.9-16.2c0-4.8-1.5-8.7-4.6-11.9s-8.9-6.1-17.2-8.9l-24.7-7.7c-12.4-3.9-21.6-9.7-27.2-17.4-5.6-7.5-8.5-15.8-8.5-24.7 0-7.2 1.5-13.5 4.6-18.9s7.2-10.2 12.3-14c5.1-3.9 10.9-6.8 17.7-8.9 6.8-2 14-2.9 21.5-2.9 3.7 0 7.7.2 11.4.7 3.9.5 7.5 1.2 11.1 1.9 3.4.9 6.6 1.7 9.7 2.7s5.4 2 7.2 3.1c2.4 1.4 4.1 2.7 5.1 4.3 1 1.4 1.5 3.2 1.5 5.6v8c0 3.6-1.4 5.4-3.9 5.4-1.4 0-3.6-.7-6.5-2q-14.55-6.6-32.7-6.6c-9.7 0-17.4 1.5-22.6 4.8s-8 8.2-8 15.2c0 4.8 1.7 8.9 5.1 12.1s9.7 6.5 18.7 9.4l24.2 7.7c12.3 3.9 21.1 9.4 26.4 16.3s7.8 15 7.8 23.8c0 7.3-1.5 14-4.4 19.8-3.1 5.8-7.2 10.9-12.4 15-5.3 4.3-11.6 7.3-18.9 9.5-8 2.5-16 3.7-24.7 3.7" style="fill:#fff"/><path fill="#fff" d="M462.9 345.7c-56 41.4-137.4 63.3-207.4 63.3-98.1 0-186.5-36.3-253.2-96.6-5.3-4.8-.5-11.2 5.8-7.5 72.2 41.9 161.3 67.3 253.4 67.3 62.2 0 130.4-12.9 193.3-39.5 9.3-4.2 17.3 6.2 8.1 13" class="st1"/><path fill="#fff" d="M486.2 319.2c-7.2-9.2-47.3-4.4-65.6-2.2-5.4.7-6.3-4.1-1.4-7.7 32-22.5 84.6-16 90.8-8.5 6.1 7.7-1.7 60.3-31.7 85.5-4.6 3.9-9 1.9-7-3.2 6.9-16.9 22.1-54.9 14.9-63.9" class="st1"/></svg>
|
||||||
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 17 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>BaiduCloud</title><path d="M21.715 5.61l-3.983 2.31a.903.903 0 01-.896 0L12.44 5.384a.903.903 0 00-.897 0L7.156 7.92a.903.903 0 01-.896 0L2.276 5.617 12.002 0l9.713 5.61z" fill="#5BCA87"></path><path d="M18.641 9.467a.89.89 0 00-.438.77v5.072a.896.896 0 01-.445.77l-4.428 2.51a.884.884 0 00-.445.777v4.607l4.429-2.536 5.31-3.047V7.157l-3.983 2.31z" fill="#EC5D3E"></path><path d="M10.98 18.941a.936.936 0 00-.305-.352l-4.429-2.516a.903.903 0 01-.431-.764v-5.078a.89.89 0 00-.452-.757l-.451-.26L1.38 7.158V18.39l5.311 3.047L11.126 24v-4.608a.881.881 0 00-.146-.45z" fill="#2464F5"></path></svg>
|
||||||
|
After Width: | Height: | Size: 717 B |
@@ -0,0 +1,28 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 39 43" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||||
|
<g id="Layer_2">
|
||||||
|
<g id="Layer_1-2">
|
||||||
|
<path d="M21,6.85L30.87,12.21L21.75,0C20.278,1.976 19.99,4.603 21,6.85Z" style="fill:url(#_Linear1);fill-rule:nonzero;"/>
|
||||||
|
<path d="M16.54,26.73C17.774,26.73 18.79,27.746 18.79,28.98C18.79,30.214 17.774,31.23 16.54,31.23C15.313,31.23 14.301,30.227 14.29,29C14.29,28.993 14.29,28.987 14.29,28.98C14.29,27.746 15.306,26.73 16.54,26.73C16.54,26.73 16.54,26.73 16.54,26.73Z" style="fill:url(#_Linear2);fill-rule:nonzero;"/>
|
||||||
|
<path d="M9.67,1.79L37.31,16.79C37.77,17.014 38.064,17.483 38.064,17.995C38.064,18.507 37.77,18.976 37.31,19.2C35.22,20.458 32.93,21.351 30.54,21.84L24.79,33.64C24.79,33.64 22.97,37.78 17.96,36.19C20.06,34.09 22.6,32.19 22.6,28.96C22.6,25.63 19.86,22.89 16.53,22.89C13.2,22.89 10.46,25.63 10.46,28.96C10.46,33.18 14.62,34.96 16.93,37.89C17.966,39.357 17.828,41.369 16.6,42.68C13.73,39.84 8.18,35.05 5.9,31.91C4.654,30.327 3.968,28.374 3.95,26.36C4.174,21.978 7.134,18.183 11.33,16.9C12.589,16.533 13.9,16.374 15.21,16.43C17.037,16.568 18.812,17.104 20.41,18C22.86,19.44 24.05,19.06 25.74,17.64C26.74,16.82 27.83,14.15 26.14,13.53C25.587,13.35 25.023,13.209 24.45,13.11C21.31,12.5 15.82,11.92 13.8,10.77C10.59,9 8.43,5.35 9.67,1.79Z" style="fill:url(#_Linear3);fill-rule:nonzero;"/>
|
||||||
|
<path d="M22.55,28.99C23.83,22.26 17,15.84 11.76,16.8L12.11,16.72C11.83,16.78 11.56,16.85 11.3,16.93C7.104,18.213 4.144,22.008 3.92,26.39C3.952,28.41 4.656,30.364 5.92,31.94C8.2,35.08 13.75,39.87 16.62,42.71C17.848,41.399 17.986,39.387 16.95,37.92C14.59,35 10.43,33.21 10.43,29C10.43,25.67 13.17,22.93 16.5,22.93C19.83,22.93 22.57,25.67 22.57,29L22.55,28.99Z" style="fill:url(#_Linear4);fill-rule:nonzero;"/>
|
||||||
|
<path d="M9.67,1.79L30.67,13.23L31.27,13.56C31.77,13.95 32.27,14.73 31.62,16.17C30.62,18.32 26.62,20.4 22.01,18.77C23.45,19.19 24.43,18.71 25.69,17.65C26.69,16.83 27.78,14.16 26.09,13.54C25.537,13.36 24.973,13.219 24.4,13.12C21.26,12.51 15.77,11.93 13.75,10.78C10.59,9 8.43,5.35 9.67,1.79Z" style="fill:url(#_Linear5);fill-rule:nonzero;"/>
|
||||||
|
<path d="M9.67,1.79C11.84,9.79 25.05,10.45 31.67,13.79L9.67,1.79Z" style="fill:url(#_Linear6);fill-rule:nonzero;"/>
|
||||||
|
<path d="M16.9,37.92C14.59,35 10.43,33.21 10.43,29C10.442,25.961 12.734,23.375 15.75,23C10.956,23.016 7.016,26.956 7,31.75C6.999,32.341 7.059,32.931 7.18,33.51C9.09,35.67 11.85,38.22 14.18,40.38C15.09,41.23 15.93,42.03 16.62,42.71C17.194,42.045 17.544,41.215 17.62,40.34C17.675,39.474 17.419,38.616 16.9,37.92Z" style="fill:url(#_Linear7);fill-rule:nonzero;"/>
|
||||||
|
<path d="M22.52,29.71C22.552,29.471 22.568,29.231 22.57,28.99C23.83,22.26 17,15.84 11.76,16.8C12.879,16.53 14.03,16.415 15.18,16.46C22.05,16.74 23.97,24.08 22.52,29.71Z" style="fill:url(#_Linear8);fill-rule:nonzero;"/>
|
||||||
|
<path d="M2.26,14.84C3.502,14.845 4.52,15.868 4.52,17.11L4.52,19.37L2.26,19.37C1.02,19.37 0,18.35 0,17.11C-0,15.868 1.018,14.845 2.26,14.84Z" style="fill:url(#_Linear9);fill-rule:nonzero;"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(19.64,0,0,19.64,16.85,6.11)"><stop offset="0" style="stop-color:rgb(251,170,25);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(239,62,35);stop-opacity:1"/></linearGradient>
|
||||||
|
<linearGradient id="_Linear2" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(1.54,-4.21,4.21,1.54,15.77,31.08)"><stop offset="0" style="stop-color:rgb(247,141,30);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(243,113,33);stop-opacity:1"/></linearGradient>
|
||||||
|
<linearGradient id="_Linear3" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(23.93,-25.65,25.65,23.93,3.56,32.53)"><stop offset="0" style="stop-color:rgb(254,190,45);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(240,78,35);stop-opacity:1"/></linearGradient>
|
||||||
|
<linearGradient id="_Linear4" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(-13.24,20.38,-20.38,-13.24,20.47,17.54)"><stop offset="0" style="stop-color:rgb(234,68,37);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(253,187,39);stop-opacity:1"/></linearGradient>
|
||||||
|
<linearGradient id="_Linear5" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(41.8,0,0,41.8,0.24,10.57)"><stop offset="0" style="stop-color:rgb(244,121,32);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(233,56,37);stop-opacity:1"/></linearGradient>
|
||||||
|
<linearGradient id="_Linear6" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(85.05,0,0,85.05,-21.84,7.78)"><stop offset="0" style="stop-color:rgb(253,202,11);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(245,132,31);stop-opacity:1"/></linearGradient>
|
||||||
|
<linearGradient id="_Linear7" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(8.85,30.87,-30.87,8.85,8.54,18.07)"><stop offset="0" style="stop-color:rgb(231,60,37);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(250,162,27);stop-opacity:1"/></linearGradient>
|
||||||
|
<linearGradient id="_Linear8" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(126.25,0,0,126.25,-51.37,23.08)"><stop offset="0" style="stop-color:rgb(253,186,18);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(247,146,30);stop-opacity:1"/></linearGradient>
|
||||||
|
<linearGradient id="_Linear9" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(21.3796,3.8052,-3.8052,21.3796,0.3616,90.5816)"><stop offset="0" style="stop-color:rgb(254,190,45);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(240,78,35);stop-opacity:1"/></linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 6.1 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="#F38020" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Cloudflare</title><path d="M16.5088 16.8447c.1475-.5068.0908-.9707-.1553-1.3154-.2246-.3164-.6045-.499-1.0615-.5205l-8.6592-.1123a.1559.1559 0 0 1-.1333-.0713c-.0283-.042-.0351-.0986-.021-.1553.0278-.084.1123-.1484.2036-.1562l8.7359-.1123c1.0351-.0489 2.1601-.8868 2.5537-1.9136l.499-1.3013c.0215-.0561.0293-.1128.0147-.168-.5625-2.5463-2.835-4.4453-5.5499-4.4453-2.5039 0-4.6284 1.6177-5.3876 3.8614-.4927-.3658-1.1187-.5625-1.794-.499-1.2026.119-2.1665 1.083-2.2861 2.2856-.0283.31-.0069.6128.0635.894C1.5683 13.171 0 14.7754 0 16.752c0 .1748.0142.3515.0352.5273.0141.083.0844.1475.1689.1475h15.9814c.0909 0 .1758-.0645.2032-.1553l.12-.4268zm2.7568-5.5634c-.0771 0-.1611 0-.2383.0112-.0566 0-.1054.0415-.127.0976l-.3378 1.1744c-.1475.5068-.0918.9707.1543 1.3164.2256.3164.6055.498 1.0625.5195l1.8437.1133c.0557 0 .1055.0263.1329.0703.0283.043.0351.1074.0214.1562-.0283.084-.1132.1485-.204.1553l-1.921.1123c-1.041.0488-2.1582.8867-2.5527 1.914l-.1406.3585c-.0283.0713.0215.1416.0986.1416h6.5977c.0771 0 .1474-.0489.169-.126.1122-.4082.1757-.837.1757-1.2803 0-2.6025-2.125-4.727-4.7344-4.727"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" data-name="Layer 1" viewBox="72.79 132 358.38 240.01"><path d="M162.5 191.1h67.8l-10.5 40.2a25.38 25.38 0 0 1-9 13.5 24.32 24.32 0 0 1-15.3 5.1H164a30.53 30.53 0 0 0-19 6.3 33 33 0 0 0-11.55 17.1 31.9 31.9 0 0 0-.45 15.3 33.1 33.1 0 0 0 5.85 12.75 30.3 30.3 0 0 0 10.8 8.85 31.74 31.74 0 0 0 14.4 3.3h19.2a10.8 10.8 0 0 1 8.85 4.35 10.4 10.4 0 0 1 1.9 9.75L182 372h-21a84.8 84.8 0 0 1-39.75-9.45A89.8 89.8 0 0 1 91.1 337.5 88.4 88.4 0 0 1 74.75 302a87.5 87.5 0 0 1 1-40.95l1.2-4.5a88.7 88.7 0 0 1 31.65-47.25 89.9 89.9 0 0 1 25-13.35 87 87 0 0 1 28.9-4.85M196.7 372l59.1-221.4a25.38 25.38 0 0 1 9-13.5 24.32 24.32 0 0 1 15.3-5.1h62.7a84.8 84.8 0 0 1 39.75 9.45 89.21 89.21 0 0 1 46.65 60.6A83.8 83.8 0 0 1 428 243l-1.2 4.5a89.9 89.9 0 0 1-12 26.55 87.65 87.65 0 0 1-73.2 39.15h-54.3l10.8-40.5a25.38 25.38 0 0 1 9-13.2 24.32 24.32 0 0 1 15.3-5.1h17.4a31.56 31.56 0 0 0 30.6-23.7 29.4 29.4 0 0 0 .45-14.7 33.1 33.1 0 0 0-5.85-12.75 31.76 31.76 0 0 0-10.8-9 30.6 30.6 0 0 0-14.4-3.45h-33.6l-43.8 162.9a25.38 25.38 0 0 1-9 13.2 23.88 23.88 0 0 1-15 5.1Z" style="fill:#ff6c2c"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" id="Layer_1" x="0" y="0" version="1.1" viewBox="0 0 354 354"><style>.st1{fill-rule:evenodd;clip-rule:evenodd;fill:#0080ff}</style><g id="XMLID_690_"><g id="XMLID_691_"><g id="XMLID_44_"><g id="XMLID_48_"><path id="XMLID_49_" d="M177 354v-68.6c72.7 0 129.1-72.1 101.2-148.5-10.2-28.1-32.9-50.8-61.2-61C140.5 48.1 68.4 104.3 68.4 177H0C0 61.2 112-29.2 233.4 8.8c53 16.7 95.3 58.8 111.8 111.8C383.2 242 292.8 354 177 354" style="fill:#0080ff"/></g><path id="XMLID_47_" d="M177.2 285.8h-68.4v-68.5h68.4z" class="st1"/><path id="XMLID_46_" d="M108.8 338.1H56.4v-52.3h52.4z" class="st1"/><path id="XMLID_45_" d="M56.4 285.8h-44v-44h44z" class="st1"/></g></g></g></svg>
|
||||||
|
After Width: | Height: | Size: 723 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg viewBox="0 0 480 480" xmlns="http://www.w3.org/2000/svg" fill-rule="evenodd" clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.414"><path fill="none" d="M0 0h480v480H0z"/><clipPath id="a"><path d="M0 0h480v480H0z"/></clipPath><g clip-path="url(#a)"><circle cx="240" cy="240" r="240" fill="#1a5ec6"/><path d="M295.77 96.601c-7.908 0-15.815.416-23.306 2.08v77.408c-10.82-4.162-23.305-6.243-39.952-6.243-59.096 0-107.372 40.369-107.372 105.291 0 66.587 44.114 103.21 109.037 103.21 32.045 0 64.922-8.74 85.314-19.56V98.681c-7.907-1.664-16.23-2.08-23.721-2.08zm-62.426 240.545c-35.79 0-60.344-24.138-60.344-62.841 0-38.288 26.218-62.842 61.177-62.842 14.149 0 27.051 1.665 38.287 8.74v110.701c-12.485 4.577-25.386 6.658-39.12 6.242z" fill="#fff" fill-rule="nonzero"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 793 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-auto w-[194px] false" alt="The DreamHost Logo" enable-background="new 0 0 471.3 89.3" viewBox="17.99 14.6 60.81 60.2"><g fill="#071c26"><path d="m77.3 60c-4.9 2.7-10.5 4.2-16.5 4.1-17.8-.4-31.8-14.7-31.5-32.1.1-5.9 1.9-11.4 4.9-16-9.5 5.3-16 15.2-16.2 26.7-.4 17.3 13.7 31.7 31.4 32.1 11.8.2 22.1-5.7 27.9-14.8"/></g><path d="m51.3 14.6c-4.8 0-9.3 1.2-13.3 3.4-2.5 4.1-3.9 8.8-4.1 13.6-.3 15.1 12 27.6 27.3 27.9 4.6 0 9.8-1.1 13.8-3.3 2.4-4.1 3.8-8.9 3.8-14 .2-15.2-12.2-27.6-27.5-27.6z" fill="#0073ec"/></svg>
|
||||||
|
After Width: | Height: | Size: 559 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" id="Layer_1" x="0" y="0" version="1.1" viewBox="-0.01 27.45 512.09 457.21"><style>.st0{fill:#040502}</style><path d="M226.8 213.8c-15.5 0-51 7.5-63.8-.1-9.4-5.6-15.6-18.7-23.2-26.5-14.1-14.4-30.6-26.3-48.7-35.1-8.1-4-19.6-10.8-28.9-7.7-17.5 5.8-30.6 34.2-38.6 49.4-31.6 59.6-29.4 136.1-5.7 198 17.1 44.7 47.5 59.6 91.1 74.1 64.5 21.5 143.6 25.7 209.1 6.4 40.2-11.9 69.3-19.2 90.5-58.3 20.3-37.5 30.3-84.3 24.2-126.8-2.6-18.1-19.6-50-16.5-66.6 2.5-13.2 29.8-8.9 39.7-12.6 29.2-11.1 73.4-64.3 49-98-12.6-17.4-53.4 3.4-70.3-7.4-9.9-6.3-15.3-24.1-22.7-33.3-16.5-20.7-39.3-33.4-64.9-39.1C276.2 14.3 211 69.5 207 140.4c-1.5 27 9.5 49.4 19.8 73.4" class="st0"/><path d="M304.7 54.7c-56.8 10.7-83.6 74.7-64.8 125.7 5 13.5 40.5 44.4 26.3 56.5-6.8 5.8-19.9 3.6-28.2 3.6-20.1-.2-39.9-1.8-60.1.6-11.1 1.3-23.6 5.8-32.5-3.4-18.3-18.7-28.6-40.4-52-54.9-6.8-4.2-17.9-12.5-26-7.5-14.3 8.9-23.7 34.5-29.4 49.6-17 44.8-14.2 97.2-.8 142.4 4.7 15.7 11 36.7 23.3 48.1 15.4 14.2 42 22.2 61.8 27.9 55.9 16.1 116.4 19.8 173.5 8 21.5-4.5 55.7-10.2 72.7-24.9 36.2-31.5 43.3-97.1 37.9-141.2-2.7-22.1-19-43.5-20.4-64.5-1.1-17.3 22-38.6 24.8-57.8 9-62.9-40.6-120.6-106.1-108.2" style="fill:#fcfc01"/><path d="M340.3 95.3c-29.3 11.2-10 54.3 17.7 42.8 28.9-11.8 10.1-53.4-17.7-42.8" class="st0"/><path d="M435.9 122.6c4.9 26.7-2.9 47.6-6.7 73.4 41-3.6 62.2-34.1 62.3-73.4z" style="fill:#fd0101"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 21 KiB |
@@ -0,0 +1,124 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Generator: Adobe Illustrator 21.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||||
|
xmlns:cc="http://creativecommons.org/ns#"
|
||||||
|
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
version="1.1"
|
||||||
|
id="Layer_1"
|
||||||
|
x="0px"
|
||||||
|
y="0px"
|
||||||
|
viewBox="0 0 64 64"
|
||||||
|
xml:space="preserve"
|
||||||
|
sodipodi:docname="foundationdb-icon.svg"
|
||||||
|
width="64"
|
||||||
|
height="64"
|
||||||
|
inkscape:version="0.92.4 (5da689c313, 2019-01-14)"><metadata
|
||||||
|
id="metadata47"><rdf:RDF><cc:Work
|
||||||
|
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
|
||||||
|
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><defs
|
||||||
|
id="defs45">
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</defs><sodipodi:namedview
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#666666"
|
||||||
|
borderopacity="1"
|
||||||
|
objecttolerance="10"
|
||||||
|
gridtolerance="10"
|
||||||
|
guidetolerance="10"
|
||||||
|
inkscape:pageopacity="0"
|
||||||
|
inkscape:pageshadow="2"
|
||||||
|
inkscape:window-width="1920"
|
||||||
|
inkscape:window-height="1001"
|
||||||
|
id="namedview43"
|
||||||
|
showgrid="false"
|
||||||
|
inkscape:zoom="4.52"
|
||||||
|
inkscape:cx="92.484101"
|
||||||
|
inkscape:cy="34.852159"
|
||||||
|
inkscape:window-x="-9"
|
||||||
|
inkscape:window-y="-9"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="Layer_1" />
|
||||||
|
<style
|
||||||
|
type="text/css"
|
||||||
|
id="style2">
|
||||||
|
.st0{fill:#3F9AFB;}
|
||||||
|
.st1{fill:#0B70E0;}
|
||||||
|
.st2{fill:#9ECCFD;}
|
||||||
|
.st3{fill:#047BFE;}
|
||||||
|
.st4{fill:#087EFE;}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style
|
||||||
|
id="style854"
|
||||||
|
type="text/css">
|
||||||
|
.st0{fill:#3F9AFB;}
|
||||||
|
.st1{fill:#0B70E0;}
|
||||||
|
.st2{fill:#9ECCFD;}
|
||||||
|
.st3{fill:#047BFE;}
|
||||||
|
.st4{fill:#087EFE;}
|
||||||
|
</style><g
|
||||||
|
id="g1134"
|
||||||
|
transform="matrix(1.518929,0,0,1.518929,-59.143687,0.45053731)"><g
|
||||||
|
transform="matrix(0.08541251,0,0,0.08541251,8.7615159,9.5962543)"
|
||||||
|
id="g10">
|
||||||
|
<polygon
|
||||||
|
style="fill:#3f9afb"
|
||||||
|
class="st0"
|
||||||
|
points="845.8,143 846.4,189.9 667.4,165.8 560.6,177.3 457.1,165.4 354.2,177.6 354.1,165.7 457.2,150.5 457.2,98.6 561.4,124 561.6,164.8 666.6,150.9 666.3,98.7 "
|
||||||
|
id="polygon4" />
|
||||||
|
<path
|
||||||
|
style="fill:#0b70e0"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
class="st1"
|
||||||
|
d="m 666.6,183.2 179.6,18.6 v 46 H 353.8 l -0.5,-12.2 h 103.5 c 0,0 0,-34.2 0,-52.3 34.8,3.4 103.8,10.2 103.8,10.2 v 40.9 h 106 z"
|
||||||
|
id="path6" />
|
||||||
|
<path
|
||||||
|
style="fill:#9eccfd"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
class="st2"
|
||||||
|
d="m 561.4,109.1 -0.3,-12.6 c 0,0 68.1,-20.4 103.3,-30.8 0,-16.9 0,-33.2 0,-52.9 61.8,24.8 121.2,48.8 181.2,72.9 0,15 0,29.4 0,45.4 -61.5,-16.9 -121.7,-33.5 -180.2,-49.6 -35.6,9.5 -104,27.6 -104,27.6 z"
|
||||||
|
id="path8" />
|
||||||
|
</g><polygon
|
||||||
|
transform="matrix(0.08541251,0,0,0.08541251,8.7795597,9.6869671)"
|
||||||
|
style="fill:#3f9afb"
|
||||||
|
class="st0"
|
||||||
|
points="457.1,165.4 354.2,177.6 354.1,165.7 457.2,150.5 457.2,98.6 561.4,124 561.6,164.8 666.6,150.9 666.3,98.7 845.8,143 846.4,189.9 667.4,165.8 560.6,177.3 "
|
||||||
|
id="polygon856" /><path
|
||||||
|
style="fill:#0b70e0;stroke-width:0.08541251"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
class="st1"
|
||||||
|
d="m 65.715539,25.334539 15.340087,1.588673 v 3.928975 h -42.05712 l -0.04271,-1.042033 h 8.840195 c 0,0 0,-2.921107 0,-4.467074 2.972356,0.290403 8.865819,0.871208 8.865819,0.871208 v 3.493371 h 9.053726 z"
|
||||||
|
id="path858" /><path
|
||||||
|
style="fill:#9eccfd;stroke-width:0.08541251"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
class="st2"
|
||||||
|
d="m 56.730143,19.005472 -0.02562,-1.076198 c 0,0 5.816592,-1.742415 8.823112,-2.630705 0,-1.443471 0,-2.835695 0,-4.518322 5.278493,2.11823 10.351997,4.168131 15.476747,6.226572 0,1.281188 0,2.511128 0,3.877728 -5.252869,-1.443471 -10.394702,-2.861319 -15.391334,-4.23646 -3.040686,0.811419 -8.882901,2.357385 -8.882901,2.357385 z"
|
||||||
|
id="path860" /></g></svg>
|
||||||
|
After Width: | Height: | Size: 3.9 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"><path d="M401.2 118.4c-14.4-10.2-34.3-6.8-44.5 7.6-12.5 17.7-26.3 30.8-42.1 39.8-1.5.8-2.9 1.6-4.4 2.4-1.4.7-2.8 1.4-4.2 2-14.7 6.8-31.1 10.4-50 11-18.9-.6-35.3-4.1-50-11-19.4-9-35.9-23.5-50.7-44.3-10.2-14.4-30.1-17.8-44.5-7.6s-17.8 30.1-7.6 44.5c21 29.7 46.6 51.7 75.9 65.3 5.8 2.7 11.6 5 17.7 7.1-24.9 18-51.9 43.2-64.8 77.8-13.4 35.9-13.2 74.8.5 109.4 13.1 33.3 37.5 60.3 68.5 76.2 34.6 17.7 78.6 17.7 117.8.1 41.1-18.5 67.9-52.3 71.7-90.6 4-40.2-15.3-76.1-49.2-91.3-35.1-15.8-74.7-4.5-100.8 28.6-10.9 13.8-8.6 33.9 5.3 44.8 13.8 10.9 33.9 8.6 44.8-5.3 7.8-9.8 16.7-13.5 24.6-10 6.7 3 13.3 11.6 11.8 26.8-1.5 15-14.7 29.8-34.4 38.7-21.3 9.6-45.3 10.1-62.5 1.3s-30.8-24-38.2-42.8c-5.6-14.1-10.1-36.6 0-63.7 7.6-20.3 26.6-39.4 60-60.2 14.2-8.8 29.4-17 44.1-25 16.9-9.2 33-17.9 47.1-27.2 25.1-13.7 47.2-33.9 65.7-60.2 10.2-14.1 6.8-34-7.6-44.2m-162.3-60c4.1-4.1 9.5-6.3 15.3-6.3s11.2 2.3 15.3 6.3c4.1 4.1 6.3 9.5 6.3 15.3s-2.3 11.2-6.3 15.3c-4.1 4.1-9.5 6.3-15.3 6.3S243 93 238.9 89c-4.1-4.1-6.3-9.5-6.3-15.3s2.2-11.3 6.3-15.3m15.3 88.9c19.7 0 38.2-7.7 52.1-21.6s21.6-32.4 21.6-52.1-7.7-38.2-21.6-52.1C292.4 7.7 273.9 0 254.2 0S216 7.7 202.1 21.6 180.5 54 180.5 73.7s7.7 38.2 21.6 52.1c14 13.8 32.5 21.5 52.1 21.5" style="fill:#16f597"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="92" height="82"><defs><path id="a" d="M0 0h92v82H0z"/></defs><g fill="none" fill-rule="evenodd"><mask id="b" fill="#fff"><use xlink:href="#a"/></mask><path fill="#1BDBDB" d="M82.435 40.18c-1.23 4.686-3.225 9.257-5.927 13.585a51 51 0 0 1-5.8 7.613c2.434-9.933.786-22.102-5.186-33.513a1.504 1.504 0 0 0-2.158-.588L44.766 38.91a1.524 1.524 0 0 0-.484 2.098l2.727 4.368a1.52 1.52 0 0 0 2.095.485l12.054-7.54a47 47 0 0 1 1.075 3.5c1.16 4.415 1.592 8.723 1.284 12.806-.573 7.6-3.664 13.522-8.704 16.674-2.517 1.575-5.44 2.409-8.619 2.5h-.387c-3.178-.091-6.103-.925-8.62-2.5-5.04-3.152-8.131-9.074-8.704-16.674-.307-4.083.125-8.391 1.284-12.807 1.231-4.686 3.225-9.256 5.927-13.585 2.702-4.328 5.931-8.126 9.6-11.287 3.455-2.98 7.134-5.257 10.935-6.77 7.075-2.816 13.746-2.63 18.786.522s8.13 9.074 8.704 16.674c.308 4.083-.124 8.391-1.284 12.806M15.492 53.765c-2.702-4.328-4.696-8.899-5.927-13.585-1.16-4.415-1.591-8.723-1.284-12.806.573-7.6 3.664-13.522 8.704-16.674 5.04-3.153 11.711-3.338 18.786-.522 1.065.424 2.12.917 3.163 1.462-3.771 3.42-7.236 7.531-10.183 12.253-7.805 12.503-10.178 26.39-7.457 37.487a51 51 0 0 1-5.802-7.615M79.353 3.75C69.792-2.231 57.205-.81 45.997 6.255 34.79-.808 22.207-2.23 12.647 3.75c-15.105 9.448-16.94 33.786-4.098 54.358 9.47 15.17 24.273 24.055 37.452 23.89 13.178.164 27.981-8.721 37.45-23.89 12.842-20.572 11.008-44.91-4.098-54.358" mask="url(#b)"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="#4285F4" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Google Cloud</title><path d="M12.19 2.38a9.344 9.344 0 0 0-9.234 6.893c.053-.02-.055.013 0 0-3.875 2.551-3.922 8.11-.247 10.941l.006-.007-.007.03a6.717 6.717 0 0 0 4.077 1.356h5.173l.03.03h5.192c6.687.053 9.376-8.605 3.835-12.35a9.365 9.365 0 0 0-2.821-4.552l-.043.043.006-.05A9.344 9.344 0 0 0 12.19 2.38zm-.358 4.146c1.244-.04 2.518.368 3.486 1.15a5.186 5.186 0 0 1 1.862 4.078v.518c3.53-.07 3.53 5.262 0 5.193h-5.193l-.008.009v-.04H6.785a2.59 2.59 0 0 1-1.067-.23h.001a2.597 2.597 0 1 1 3.437-3.437l3.013-3.012A6.747 6.747 0 0 0 8.11 8.24c.018-.01.04-.026.054-.023a5.186 5.186 0 0 1 3.67-1.69z"/></svg>
|
||||||
|
After Width: | Height: | Size: 698 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"><path d="M501.8 261.8c0-18.2-1.6-35.6-4.7-52.4H256v99.1h137.8c-6.1 31.9-24.2 58.9-51.4 77V450h83.1c48.3-44.6 76.3-110.2 76.3-188.2" style="fill:#4285f4"/><path d="M256 512c69.1 0 127.1-22.8 169.4-61.9l-83.1-64.5c-22.8 15.4-51.9 24.7-86.3 24.7-66.6 0-123.1-44.9-143.4-105.4H27.5V371C69.6 454.5 155.9 512 256 512" style="fill:#34a853"/><path d="M112.6 304.6c-5.1-15.4-8.1-31.7-8.1-48.6s3-33.3 8.1-48.6v-66.1H27.5C10 175.7 0 214.6 0 256s10 80.3 27.5 114.7L93.8 319c0 .1 18.8-14.4 18.8-14.4" style="fill:#fbbc05"/><path d="M256 101.9c37.7 0 71.2 13 98 38.2l73.3-73.3C382.8 25.4 325.1 0 256 0 155.9 0 69.6 57.5 27.5 141.3l85.2 66.1c20.2-60.5 76.7-105.5 143.3-105.5" style="fill:#ea4335"/><path d="M0 0h512v512H0z" style="fill:none"/></svg>
|
||||||
|
After Width: | Height: | Size: 817 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"><circle cx="256" cy="256" r="256" style="fill:#d50c2d"/><path d="M395.6 105.3h-42.1c-9.5 0-13.5 3.9-13.5 13.5v106.6H172.2V118.9c0-9.5-3.9-13.5-13.5-13.5h-42.4c-9.6 0-13.5 3.9-13.5 13.5v274.2c0 9.6 3.9 13.5 13.5 13.5h42.4c9.5 0 13.5-3.8 13.5-13.5V284.8h167.9v108.3c0 9.5 3.9 13.5 13.5 13.5h42.1c9.5 0 13.5-3.9 13.5-13.5V118.9c-.2-9.2-4.1-13.6-13.6-13.6" style="fill:#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 461 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="2112" height="2500" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="2" clip-rule="evenodd" viewBox="7.002 8.287 148.203 175.426"><path fill="#6747c7" d="m7.002 8.287 39.319 21.172v39.32h57.467l36.295 21.172H7.002zm148.203 75.615V29.459L112.861 8.287v51.418zm0 99.811-39.319-21.172v-39.32H58.419l-36.295-21.172h133.081zM7.002 108.098v54.443l42.343 21.172v-51.418z"/></svg>
|
||||||
|
After Width: | Height: | Size: 433 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg class="c4d--footer-logo__logo" xmlns="http://www.w3.org/2000/svg" aria-labelledby="footer-logo" width="157" height="65" viewBox="0 0 157 65" fill="#fff"><path d="M30.444 60.208v4.03H0v-4.03zm78.291-.001v4.03H86.983v-4.03zm47.858 0v4.03H134.84v-4.03zm-33.416 0-1.398 4.03-1.38-4.03zm-88.384 0h42.775c-2.797 2.426-6.39 3.925-10.327 4.025l-.423.006H34.793zh42.775Zm-4.35-8.46v4.03H0v-4.03zm52.402 0c-.332 1.248-.8 2.44-1.389 3.555l-.259.474H34.793v-4.029zm73.748-.005v4.031H134.84v-4.03zm-47.858 0v4.031H86.983v-4.03zm17.375 0-1.398 4.031h-5.85l-1.395-4.03zM21.745 43.285v4.03H8.698v-4.03zm61.195 0a17.3 17.3 0 0 1 .476 3.51l.008.52H68.796v-4.03zm-26.401 0v4.03H43.491v-4.03zm72.502-.007-1.396 4.03H115.93l-1.397-4.03zm18.85 0v4.03h-13.05v-4.03zm-39.156 0v4.03H95.684v-4.03zm-86.99-8.454v4.03H8.698v-4.03zm56.117 0a17 17 0 0 1 2.926 3.582l.264.447h-37.56v-4.03zm30.873-.01v4.03H95.684v-4.03zm39.157 0v4.03H134.84v-4.03zm-15.919 0-1.396 4.03h-17.579l-1.396-4.03zm-50.778-8.452a17 17 0 0 1-2.82 3.674l-.37.355H43.49v-4.029zm-59.45 0v4.03H8.698v-4.03zm126.147-.013v4.031H134.84v-3.839l-1.33 3.839h-11.456l1.373-4.03zm-27.743 0 1.372 4.031h-11.456l-1.33-3.839v3.84H95.684v-4.032zm-98.404-8.448v4.03H8.698V17.9zm61.68 0c0 1.215-.134 2.399-.375 3.542l-.11.487H68.796V17.9zM56.538 17.9v4.03H43.491V17.9zm91.352-.015v4.03h-22.954l1.37-4.03zm-30.624 0 1.372 4.03H95.684v-4.03zM30.444 9.437v4.03H0v-4.03zm50.753 0a17 17 0 0 1 1.498 3.499l.15.531H34.794v-4.03zm75.396-.018v4.03h-28.776l1.373-4.03zm-42.207 0 1.372 4.031H86.982V9.42zM30.444.978v4.03H0V.977zm36.374 0c3.96 0 7.594 1.415 10.448 3.772l.303.257H34.794V.977zm89.775-.022v4.031h-25.894l1.372-4.03zm-45.098 0 1.372 4.03H86.982V.955z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" style="background:#003c8f;fill:#fff" viewBox="0 0 40 40"><path d="M20 35.9a2.6 2.6 0 0 1-2.6-2.6V6.7c0-1.4 1.2-2.6 2.6-2.6 1.2 0 2.2.8 2.5 1.9v27.1c.2 1.6-1 2.8-2.5 2.8"/></svg>
|
||||||
|
After Width: | Height: | Size: 217 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="2085" height="2500" preserveAspectRatio="xMidYMid" viewBox="0 0 256 307"><path fill="#004711" d="m66.766 141.595-12.64-83.896L2.919 26.102l15.254 78.884zM78.75 231.81l-8.063-59.49-45.543-37.698 11.767 58.836zm-36.173-13.292 8.063 44.67 38.788 40.315-5.448-45.544zm115.71-13.73-19.938-14.817s-.284 5.306-.817 5.666l-14.546 10.242 15.907 13.074.927 14.873 18.903 15.417zm60.253-43.472-3.596 41.893-13.401-8.825s1.477-21.181 1.47-21.192c-.077-.142-25.822-17.433-25.822-17.433l.817-19.285z"/><path fill="#00B159" d="m133.665 106.947-3.704-78.012-75.833 28.764 12.638 83.896zm3.487 88.472-3.051-59.49-63.412 36.39 9.37 61.016zm1.089 24.188-54.26 38.352 6.756 46.85 49.465-39.441zm20.92-15.036.87 45.326 39.225-31.815 1.96-43.147zm93.429-65.646-5.121 39.769-30.671 24.624 3.65-41.784 32.142-22.61z"/><path d="M140.278 218.847c-.002-.038-.016-.073-.02-.11a1.8 1.8 0 0 0-.107-.46 2 2 0 0 0-.125-.26q-.04-.076-.088-.146a2 2 0 0 0-.202-.234c-.035-.035-.066-.074-.104-.107l-.028-.03-14.511-11.843 13.343-8.99c.533-.36.839-.966.81-1.603l-.135-3.165 17.692 13.504.344 41.008-16.276-13.702zm-71.23-45.953 8.205 56.072-38.886-36.633-11.26-54.382 41.94 34.943zm66.478 21.296-54.29 36.575-8.467-57.86 60.384-34.37zm-6.982-163.451 3.229 75.494-63.888 33.245L56.037 58.6zm-76.24 27.78 11.649 79.516L20.026 103.8 4.5 28.795 52.305 58.52zm.678 204.375-8.3-40.105 36.998 36.387 5.926 40.468zm85.558 1.459-46.91 37.37-6.238-42.582 51.354-36.742zm58.888-46.95-36.6 29.192-.342-41.166 39.383-28.17zm5.109-23.378 1.24-20.395c0-.023-.006-.044-.006-.067 0-.027.009-.052.008-.08-.001-.08-.022-.158-.034-.237-.01-.057-.01-.114-.024-.17a2 2 0 0 0-.156-.41l-.014-.034a2 2 0 0 0-.291-.388c-.023-.024-.051-.042-.075-.064-.074-.07-.146-.141-.232-.2l-24.655-16.282.359-17.868 38.8 24.178-3.46 39.993zm15.22 7.196 3.39-39.175 30.623-21.916-5.147 38.082zM256 136.045c-.002-.035-.015-.067-.02-.102a2 2 0 0 0-.036-.194 2 2 0 0 0-.17-.463 2 2 0 0 0-.181-.27c-.036-.045-.07-.091-.11-.133a2 2 0 0 0-.283-.233c-.032-.022-.055-.053-.09-.073-.006-.005-.016-.006-.024-.011l-.031-.022-41.91-23.223a1.84 1.84 0 0 0-1.85.043l-35.374 21.625c-.057.034-.102.083-.154.123-.063.048-.128.092-.184.148-.06.059-.106.127-.156.194-.044.058-.094.112-.13.175-.046.077-.075.162-.11.245-.024.063-.057.12-.075.187-.025.09-.032.184-.043.278-.007.06-.026.118-.028.18l-.376 18.779-14.39-9.503a1.84 1.84 0 0 0-1.976-.037l-20.702 12.656-.897-21.047c-.002-.044-.018-.084-.022-.128a2 2 0 0 0-.048-.273c-.018-.066-.045-.128-.07-.192a2 2 0 0 0-.104-.228c-.037-.065-.081-.123-.125-.183a2 2 0 0 0-.148-.183 2 2 0 0 0-.18-.157c-.04-.032-.072-.072-.115-.102l-21.545-14.471 20.167-10.494a1.85 1.85 0 0 0 .987-1.712l-3.387-79.222c-.004-.097-.027-.19-.046-.284-.008-.04-.01-.08-.02-.118a1.8 1.8 0 0 0-.188-.45c-.015-.026-.036-.047-.052-.071a1.8 1.8 0 0 0-.253-.311q-.053-.05-.111-.094c-.053-.044-.1-.094-.157-.132-.045-.03-.096-.046-.143-.072-.024-.013-.042-.033-.067-.045L76.736.225a1.83 1.83 0 0 0-1.347-.1L1.279 23.209l-.015.008c-.03.009-.056.03-.086.04a1.8 1.8 0 0 0-.391.196l-.011.005c-.041.029-.07.07-.107.102a2 2 0 0 0-.265.249c-.055.068-.092.146-.137.22s-.095.144-.13.224c-.04.096-.06.198-.082.3-.015.062-.042.118-.051.18-.001.01.002.019 0 .028-.01.093 0 .186.005.28.003.087-.001.173.014.257.003.014 0 .027.003.04l16.536 79.869c.09.427.323.808.67 1.077l22.444 17.493-16.226 7.726c-.03.014-.052.036-.08.05-.025.015-.052.018-.076.032-.036.022-.06.054-.093.078a2 2 0 0 0-.258.216q-.069.066-.13.138a2 2 0 0 0-.2.315c-.02.04-.047.078-.065.12a1.8 1.8 0 0 0-.133.482v.02c-.003.026.002.052 0 .078q-.017.226.022.447c.003.02-.001.039.003.058l12.44 60.109c.077.372.265.708.54.967l15.622 14.714-10.352 6.332a2 2 0 0 0-.36.293c-.03.029-.05.062-.076.093q-.134.158-.23.342a1.9 1.9 0 0 0-.204.628c-.02.177-.005.352.025.524.004.019-.001.038.003.058l9.638 46.554c.069.33.228.637.463.886l38.982 41.385c.059.062.127.11.192.164.024.019.044.04.069.059q.182.132.388.216.007.006.018.01c.01.005.024.003.036.007.205.076.42.122.639.122a1.8 1.8 0 0 0 .659-.134c.046-.018.095-.027.14-.05.076-.036.132-.094.199-.14.048-.03.103-.045.148-.081l50.145-39.95c.46-.368.716-.931.692-1.516l-1.172-27.43 16.753 14.1c.029.025.067.034.098.057.095.07.186.144.294.196.036.016.073.023.109.038q.068.026.136.046c.18.057.362.096.547.096.18 0 .358-.037.535-.091q.06-.019.122-.042c.042-.016.085-.024.126-.043.093-.044.168-.108.25-.165.036-.025.078-.035.112-.062l40.197-32.056c.409-.33.657-.81.689-1.332l1.223-20.115 12.23 8.513c.026.017.055.023.08.039.036.022.063.054.1.074.072.038.149.057.223.085.05.019.099.042.152.057.162.045.328.074.494.074.18 0 .36-.036.537-.09q.06-.018.12-.04c.04-.016.084-.024.125-.042.1-.048.186-.115.274-.178.029-.02.064-.028.092-.05l32.79-26.134a1.85 1.85 0 0 0 .678-1.195l5.817-43.041c.006-.046-.005-.09-.002-.136.004-.073.019-.143.015-.217"/></svg>
|
||||||
|
After Width: | Height: | Size: 4.7 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"><path d="M471.6 393.4c-27.9-.7-49.4 2.1-67.6 9.8-5.2 2.1-13.5 2.1-14.3 8.7 2.8 2.8 3.2 7.3 5.6 11.2 4.2 7 11.5 16.3 18.1 21.2 7.3 5.6 14.6 11.1 22.3 16 13.5 8.4 28.9 13.3 42.1 21.6 7.7 4.9 15.3 11.1 23 16.4 3.8 2.8 6.2 7.3 11.1 9.1v-1.1c-2.5-3.1-3.2-7.7-5.6-11.1L496 485c-10.1-13.6-22.7-25.4-36.2-35.2-11.1-7.7-35.5-18.1-40.1-31l-.7-.7c7.7-.7 16.8-3.5 24-5.6 11.8-3.1 22.7-2.4 34.8-5.5 5.6-1.4 11.1-3.2 16.8-4.9V399c-6.3-6.3-10.8-14.6-17.4-20.5-17.7-15.3-37.3-30.3-57.5-42.8-10.8-7-24.7-11.5-36.2-17.4-4.1-2.1-11.1-3.1-13.6-6.6-6.2-7.7-9.8-17.7-14.3-26.8-10.1-19.1-19.8-40.4-28.5-60.6-6.3-13.6-10.1-27.1-17.8-39.7-35.9-59.2-74.9-95-134.8-130.2-12.8-7.5-28.1-10.6-44.5-14.4l-26.1-1.4c-5.6-2.4-11.2-9.1-16-12.2C68 13.9 16.8-13.3 2.2 22.6-7.2 45.2 16.1 67.5 24.1 79c5.9 8 13.6 17.1 17.7 26.1 2.5 5.9 3.1 12.2 5.6 18.5 5.6 15.3 10.8 32.4 18.1 46.7 3.8 7.3 8 15 12.9 21.6 2.8 3.9 7.7 5.6 8.7 11.9-4.9 6.9-5.2 17.4-8 26.1-12.5 39.3-7.6 88.1 10.1 117 5.6 8.7 18.8 27.9 36.5 20.5 15.7-6.3 12.2-26.1 16.7-43.5 1-4.2.4-7 2.4-9.7v.7c4.9 9.7 9.8 19.1 14.3 28.9 10.8 17 29.6 34.8 45.3 46.6 8.3 6.3 15 17.1 25.4 20.9v-1h-.7c-2.1-3.1-5.2-4.5-8-6.9-6.3-6.3-13.2-13.9-18.1-20.9-14.6-19.5-27.5-41.1-39-63.4-5.6-10.8-10.4-22.6-15-33.4-2.1-4.2-2.1-10.4-5.6-12.5-5.2 7.7-12.9 14.3-16.7 23.7-6.6 15-7.3 33.4-9.8 52.6l-1.4.7c-11.1-2.8-15-14.3-19.2-24-10.4-24.7-12.2-64.4-3.1-93 2.4-7.3 12.9-30.3 8.7-37.2-2.1-6.7-9.1-10.5-12.9-15.7-4.5-6.6-9.4-15-12.5-22.3-8.4-19.5-12.6-41.1-21.6-60.6C51 88 43.7 78.6 37.8 70.3c-6.6-9.4-13.9-16-19.2-27.2-1.7-3.8-4.2-10.1-1.4-14.3.7-2.8 2.1-3.8 4.9-4.5 4.5-3.8 17.4 1 21.9 3.1 12.9 5.2 23.7 10.1 34.5 17.4 4.9 3.5 10.1 10.1 16.4 11.9h7.3c11.1 2.4 23.7.7 34.1 3.8 18.4 5.9 35.2 14.6 50.1 24 45.6 28.9 83.2 70 108.6 119.1 4.2 8 5.9 15.3 9.7 23.7 7.3 17.1 16.4 34.5 23.7 51.2 7.3 16.4 14.3 33.1 24.7 46.7 5.2 7.3 26.1 11.1 35.5 15 6.9 3.1 17.8 5.9 24 9.7 11.8 7.3 23.6 15.7 34.8 23.7 5.7 4.1 23.1 12.9 24.2 19.8M116.4 90.8c-4.8 0-9.6.5-14.3 1.8v.7h.7c2.8 5.6 7.7 9.4 11.2 14.3 2.8 5.6 5.2 11.1 8 16.7l.7-.7c4.9-3.5 7.4-9.1 7.4-17.4-2.1-2.5-2.4-4.9-4.2-7.3-2.2-3.6-6.7-5.3-9.5-8.1" style="fill:#5d87a1"/></svg>
|
||||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,31 @@
|
|||||||
|
<svg xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" version="1.1" id="svg22" sodipodi:docname="namecheap-resized.svg" inkscape:version="1.4 (e7c3feb1, 2024-10-09)" viewBox="-0.02 0 84.05 46.59">
|
||||||
|
<sodipodi:namedview id="namedview22" pagecolor="#ffffff" bordercolor="#000000" borderopacity="0.25" inkscape:showpageshadow="2" inkscape:pageopacity="0.0" inkscape:pagecheckerboard="0" inkscape:deskcolor="#d1d1d1" inkscape:zoom="4.0930233" inkscape:cx="128.87784" inkscape:cy="23.332386" inkscape:window-width="2224" inkscape:window-height="1290" inkscape:window-x="554" inkscape:window-y="25" inkscape:window-maximized="0" inkscape:current-layer="svg22"/>
|
||||||
|
<defs id="defs9">
|
||||||
|
<linearGradient id="a" x1="59.990002" y1="44.16" x2="80.010002" y2="1.23" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0" stop-color="#d4202c" id="stop1"/>
|
||||||
|
<stop offset="0.1" stop-color="#dc3d29" stop-opacity="0.79" id="stop2"/>
|
||||||
|
<stop offset="0.2" stop-color="#e45926" stop-opacity="0.58" id="stop3"/>
|
||||||
|
<stop offset="0.32" stop-color="#ea7123" stop-opacity="0.4" id="stop4"/>
|
||||||
|
<stop offset="0.43" stop-color="#f08521" stop-opacity="0.25" id="stop5"/>
|
||||||
|
<stop offset="0.55" stop-color="#f4941f" stop-opacity="0.14" id="stop6"/>
|
||||||
|
<stop offset="0.68" stop-color="#f79f1e" stop-opacity="0.06" id="stop7"/>
|
||||||
|
<stop offset="0.82" stop-color="#f8a51d" stop-opacity="0.02" id="stop8"/>
|
||||||
|
<stop offset="1" stop-color="#f9a71d" stop-opacity="0" id="stop9"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="b" x1="1777.48" y1="1048.76" x2="1797.5" y2="1005.83" gradientTransform="rotate(180,901.25,525.945)" xlink:href="#a"/>
|
||||||
|
<linearGradient inkscape:collect="always" xlink:href="#a" id="linearGradient22" gradientUnits="userSpaceOnUse" x1="59.990002" y1="44.16" x2="80.010002" y2="1.23" gradientTransform="translate(-1,-0.71)"/>
|
||||||
|
</defs>
|
||||||
|
<title id="title9">cn-logo</title>
|
||||||
|
<path d="M 76.17,0 A 7.87,7.87 0 0 0 69.29,4.05 L 69.13,4.38 63,16.54 l -7.8,15.37 5.11,10.07 0.28,0.55 A 8,8 0 0 0 64,45.76 8.05,8.05 0 0 0 67.41,42.53 L 67.69,41.98 83,11.77 83.37,11.04 a 7.86,7.86 0 0 0 -7.19,-11 z" fill="#ff5100" id="path9"/>
|
||||||
|
<path d="m 28.85,14.63 -5.1,-10 -0.28,-0.55 a 7.89,7.89 0 0 0 -3.4,-3.22 7.92,7.92 0 0 0 -3.4,3.21 L 16.38,4.63 1.05,34.81 0.68,35.53 a 7.86,7.86 0 0 0 14.06,7 l 0.17,-0.32 6.17,-12.16 7.79,-15.36 z" fill="#ff5100" id="path10"/>
|
||||||
|
<path d="m 76.15,0 a 7.86,7.86 0 0 0 -6.87,4.05 l -0.17,0.33 -6.17,12.16 -7.81,15.37 5.12,10.07 0.28,0.55 a 7.94,7.94 0 0 0 3.41,3.23 7.94,7.94 0 0 0 3.41,-3.23 L 67.64,41.98 83,11.77 83.36,11.04 a 7.86,7.86 0 0 0 -7.19,-11 z" fill="url(#a)" id="path11" style="fill:url(#linearGradient22)"/>
|
||||||
|
<path d="m 7.86,46.58 a 7.86,7.86 0 0 0 6.87,-4 L 14.9,42.25 21.08,30.09 28.88,14.72 23.77,4.6 23.49,4.05 A 8,8 0 0 0 20.07,0.82 8,8 0 0 0 16.66,4.05 L 16.38,4.6 1,34.81 0.63,35.54 a 7.86,7.86 0 0 0 7.19,11 z" fill="url(#b)" id="path12" style="fill:url(#b)"/>
|
||||||
|
<path d="m 28.85,14.63 -5.1,-10 -0.28,-0.55 a 7.94,7.94 0 0 0 -3.41,-3.23 8.31,8.31 0 0 1 1.49,-0.56 8.16,8.16 0 0 1 2,-0.25 h 10.68 a 7.92,7.92 0 0 1 6.86,4 l 0.28,0.55 13.81,27.36 5.09,10 0.28,0.55 A 8,8 0 0 0 64,45.76 8.05,8.05 0 0 1 60.53,46.57 H 49.79 a 7.91,7.91 0 0 1 -6.85,-4 l -0.29,-0.55 z" fill="#ff8c44" id="path13"/>
|
||||||
|
<metadata id="metadata22">
|
||||||
|
<rdf:RDF>
|
||||||
|
<cc:Work rdf:about="">
|
||||||
|
<dc:title>cn-logo</dc:title>
|
||||||
|
</cc:Work>
|
||||||
|
</rdf:RDF>
|
||||||
|
</metadata>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.7 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"><radialGradient id="a" cx="-45.399" cy="659.487" r="13.368" gradientTransform="matrix(0 38.301 44.1228 0 -28842.379 1738.555)" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#20c6b7"/><stop offset="1" style="stop-color:#4d9abf"/></radialGradient><path d="m370.9 177.7-.2-.1c-.1 0-.2-.1-.3-.2-.3-.3-.4-.8-.4-1.2l10.3-63.1 48.4 48.4-50.3 21.4c-.1.1-.3.1-.4.1h-.2c-.1 0-.1-.1-.3-.2-1.8-2.1-4.1-3.8-6.6-5.1m70.2-3.9 51.8 51.8c10.7 10.8 16.1 16.1 18.1 22.4.3.9.5 1.8.7 2.8L388 198.3c-.1 0-.1-.1-.2-.1-.5-.2-1.1-.4-1.1-.9s.6-.7 1.1-.9l.2-.1zm68.5 93.5c-2.7 5-7.9 10.2-16.7 19.1l-58.4 58.3-75.4-15.7-.4-.1c-.7-.1-1.4-.2-1.4-.8-.6-6.3-3.7-12.1-8.7-15.9-.3-.3-.2-.8-.1-1.2v-.2l14.2-87.1.1-.3c.1-.7.2-1.4.8-1.4 6.1-.8 11.7-4 15.5-8.9.1-.1.2-.3.4-.4.4-.2.9 0 1.4.2zm-88.5 90.9-96 96 16.4-100.9v-.1c0-.1 0-.3.1-.4.1-.3.5-.5.8-.6l.2-.1c3.6-1.5 6.8-3.9 9.3-6.9.3-.4.7-.7 1.2-.8h.4zM304.9 474.4l-10.8 10.8-119.6-172.8c0-.1-.1-.1-.1-.2-.2-.3-.4-.5-.3-.8q0-.3.3-.6l.1-.2c.4-.5.7-1.1 1-1.6l.3-.5c.2-.3.4-.6.7-.8.3-.1.7-.1 1 0L310 335c.4.1.7.2 1 .4.2.2.2.4.3.6 1.9 7.1 7 12.9 13.7 15.7.4.2.2.6 0 1-.1.2-.2.4-.2.6-1.8 10.3-16.1 97.6-19.9 121.1M282.3 497c-8 7.9-12.7 12.1-18 13.8-5.2 1.7-10.9 1.7-16.1 0-6.2-2-11.6-7.3-22.4-18.1l-120-120.1 31.4-48.6c.1-.2.3-.5.5-.6.3-.2.8-.1 1.2 0 7.2 2.2 14.9 1.8 21.9-1.1.4-.1.7-.2 1 0l.4.4zM94.2 361l-27.5-27.5 54.4-23.2c.1-.1.3-.1.4-.1.5 0 .7.5 1 .9.5.8 1.1 1.7 1.7 2.5l.2.2c.2.2.1.5-.1.7zm-39.8-39.8-34.9-34.9c-5.9-5.9-10.2-10.2-13.2-13.9l106 22c.1 0 .3 0 .4.1.7.1 1.4.2 1.4.8 0 .7-.8 1-1.5 1.2l-.3.1zM.3 254.6c.1-2.2.5-4.5 1.2-6.6 2-6.2 7.3-11.6 18.1-22.4L64.2 181c20.5 29.8 41.1 59.6 61.8 89.3.4.5.8 1 .3 1.4-1.9 2.1-3.9 4.5-5.3 7.1-.1.3-.4.6-.7.8q-.3.15-.6 0zm75.8-85.5 59.9-60c5.6 2.5 26.2 11.1 44.5 18.9 13.9 5.9 26.5 11.2 30.5 13 .4.2.8.3.9.7.1.2.1.5 0 .8-1.9 8.8.7 18 7 24.4.4.4 0 1-.3 1.5l-.2.3-60.9 94.3c-.2.3-.3.5-.6.7s-.8.1-1.1 0c-2.4-.6-4.8-1-7.3-1-2.2 0-4.6.4-7 .8-.3 0-.5.1-.7-.1s-.4-.4-.6-.7zM148.2 97l77.6-77.6C236.6 8.6 242 3.2 248.2 1.2c5.2-1.7 10.9-1.7 16.1 0 6.2 2 11.6 7.3 22.4 18.1l16.8 16.8-55.2 85.5c-.1.2-.3.5-.5.6-.3.2-.8.1-1.2 0-8.8-2.7-18.4-.8-25.6 4.9-.4.4-.9.2-1.3 0-7.4-3-63.5-26.7-71.5-30.1m167-49.1 51 51-12.3 76.1v.2c0 .2 0 .3-.1.5-.1.3-.4.3-.7.4-2.6.8-5.1 2-7.3 3.6-.1.1-.2.1-.3.2-.1.2-.3.3-.5.3s-.4 0-.6-.1l-77.7-33-.1-.1c-.5-.2-1.1-.4-1.1-.9-.5-4.3-1.9-8.5-4.1-12.2-.4-.6-.8-1.3-.5-1.9zm-52.5 114.9 72.8 30.8c.4.2.8.4 1 .8.1.2.1.5 0 .8-.2 1.1-.4 2.3-.4 3.5v2c0 .5-.5.7-1 .9l-.1.1c-11.5 4.9-162 69.1-162.2 69.1s-.5 0-.7-.2c-.4-.4 0-1 .4-1.5.1-.1.1-.2.2-.3l59.8-92.7.1-.2c.3-.6.7-1.2 1.4-1.2l.6.1c1.4.2 2.6.4 3.8.4 9.1 0 17.5-4.4 22.6-12 .1-.2.3-.4.5-.5.2-.2.8-.1 1.2.1m-83.4 122.7 164-69.9s.2 0 .5.2c.9.9 1.7 1.5 2.4 2.1l.4.2c.3.2.7.4.7.7v.3l-14 86.3-.1.3c-.1.7-.2 1.4-.8 1.4-7.6.5-14.5 4.7-18.3 11.3l-.1.1c-.2.3-.4.6-.7.8q-.45.15-.9 0l-130.8-27c-.3.1-2.2-6.8-2.3-6.8" style="fill:url(#a)"/></svg>
|
||||||
|
After Width: | Height: | Size: 2.9 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="20"><path fill="none" stroke="#C74634" stroke-width="4" d="M10 2a8 8 0 1 0 0 16h12a8 8 0 1 0 0-16z"/></svg>
|
||||||
|
After Width: | Height: | Size: 166 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1505" height="909" baseProfile="tiny-ps" version="1.2"><path fill-rule="evenodd" d="m1407.08 52.27-160.24 283.35h-168.16L880.85 684.34h168.16l-126.62 223.9h413.47c193.88-243.71 223.55-582.53 71.22-855.97M592.01 908.24 1116.27.76H673.13L372.42 523.85 99.41 50.29C-54.9 323.73-27.2 664.53 172.61 908.24z" style="fill:#000e9c"/></svg>
|
||||||
|
After Width: | Height: | Size: 378 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="40.6 36.4 418.8 173.8"><path d="M53.3 210.2H40.6V77.1c10.6-3.3 21.1-4.9 31.3-4.9 7.5 0 14.2 1.2 20.3 3.5s11.3 5.6 15.7 9.7c4.3 4.2 7.7 9.1 10 14.8s3.5 12 3.5 18.7c0 8.2-1.4 15.2-4.2 21.1s-6.5 10.7-11.1 14.5-9.8 6.6-15.7 8.3q-8.85 2.7-18 2.7c-3.3 0-6.5-.2-9.7-.7s-6.4-1.2-9.5-2v47.4zm53.7-91.5c0-10.6-3-19.2-8.9-25.7s-14.3-9.8-25-9.8c-3.4 0-6.8.2-10.1.6s-6.5 1.2-9.7 2.3V151c2.9 1 6 1.8 9.1 2.5s6.1 1 9.1 1c10.9 0 19.5-3.1 25.9-9.3s9.6-15 9.6-26.5m40.1 46V36.4h12.8v128.3zm117.7-13.4c-4.3 4.6-9.2 8.2-14.8 10.5-5.6 2.4-12.4 3.6-20.3 3.6-7.3 0-13.7-1.3-19.2-3.8s-10.1-5.9-13.7-10.2c-3.7-4.3-6.4-9.2-8.2-14.7q-2.7-8.25-2.7-17.4 0-12.15 3.3-21c2.2-5.9 5.2-10.8 9-14.8q5.7-5.85 13.2-8.7c5-1.9 10.3-2.8 15.9-2.8 13.4 0 23.6 4.1 30.4 12.4q10.2 12.3 9.9 36h-68.7c.4 10.4 3.4 18.6 9 24.7s13.3 9.2 22.9 9.2q8.1 0 14.7-2.7c4.4-1.8 8.4-4.6 11.9-8.5zm-10.3-41.6c0-3.8-.6-7.3-1.7-10.6-1.2-3.3-2.8-6.1-4.9-8.4s-4.9-4.2-8.3-5.6-7.4-2.1-11.9-2.1q-6.9 0-12 2.1c-3.4 1.4-6 3.2-8 5.4s-3.8 5-5.4 8.3c-1.7 3.4-2.7 7-3.2 10.9zm96.8-15c-3.2-4-6.7-7-10.4-8.9s-8.1-2.8-13.2-2.8c-6.5 0-11.1 1.2-13.7 3.5s-3.9 5.3-3.9 8.8c0 2.4.6 4.5 1.8 6.2s2.8 3.2 4.8 4.4 4.3 2.3 6.9 3.2 5.3 1.8 8.2 2.7c3.5 1.1 6.9 2.3 10.4 3.6s6.6 3 9.5 5c2.9 2.1 5.2 4.6 7 7.6s2.7 6.8 2.7 11.3c0 4.2-.9 7.9-2.7 11.2s-4.2 6.1-7.3 8.3q-4.65 3.45-10.8 5.1c-4.2 1.2-8.6 1.7-13.3 1.7-7.3 0-13.8-1.2-19.4-3.6s-10.9-6.3-15.9-11.8l10-8.8c6.7 8.6 15.3 12.8 25.9 12.8 6.9 0 12-1.3 15.4-3.9 3.3-2.6 5-5.7 5-9.3 0-2.8-.6-5.2-1.8-7.1s-2.9-3.5-5-4.9c-2.1-1.3-4.5-2.5-7.3-3.4q-4.2-1.35-8.7-2.7c-3.5-1-6.9-2.1-10.3-3.3s-6.4-2.8-9.1-4.7q-4.05-2.85-6.6-7.2c-1.7-2.9-2.6-6.7-2.6-11.2 0-8.1 2.9-14.3 8.8-18.7s13.5-6.6 22.9-6.6c6.6 0 12.6 1.1 17.8 3.3s10.1 6 14.9 11.5l-9.4 8.6zm34.1 70V36.6h12.8v128.1zm12.8-47.3 44-44.4h17.2l-45.1 44 44.9 47.6h-17.8z" style="fill:#fff"/><path fill="#53bce6" d="M191.7 210.2h-77.2v-12.1h77.2z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 20 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" id="Layer_1" x="0" y="0" version="1.1" viewBox="0 0 512 512"><style>.st2{fill:none;stroke:#fff;stroke-width:12.4651;stroke-linecap:round;stroke-linejoin:round}</style><g id="Layer_x0020_3"><path d="M378.5 372.5c3.2-26.9 2.3-30.8 22.3-26.5l5.1.4c15.4.7 35.5-2.5 47.4-8 25.5-11.8 40.6-31.5 15.5-26.4-57.3 11.8-61.2-7.6-61.2-7.6 60.5-89.7 85.8-203.6 63.9-231.5C411.9-3 308.8 33 307.1 33.9l-.5.1c-11.3-2.3-24-3.8-38.2-4-25.9-.4-45.6 6.8-60.5 18.1 0 0-183.8-75.7-175.2 95.2 1.8 36.4 52.1 275.2 112.1 203 21.9-26.4 43.1-48.7 43.1-48.7 10.5 7 23.1 10.6 36.3 9.3l1-.9c-.3 3.3-.2 6.5.4 10.3-15.5 17.3-10.9 20.3-41.8 26.7-31.3 6.4-12.9 17.9-.9 20.9 14.5 3.6 48.2 8.8 70.9-23l-.9 3.6c6.1 4.9 5.7 34.9 6.5 56.3.9 21.4 2.3 41.5 6.7 53.3s9.5 42.2 50.1 33.5c34-7.3 59.9-17.7 62.3-115.1" style="stroke:#000;stroke-width:37.3953"/><path d="M468.7 312.1c-57.3 11.8-61.2-7.6-61.2-7.6C468 214.8 493.3 100.9 471.4 73 411.9-3 308.8 33 307.1 33.9l-.6.1c-11.3-2.3-24-3.7-38.2-4-25.9-.4-45.6 6.8-60.5 18.1 0 0-183.8-75.7-175.2 95.2 1.8 36.4 52.1 275.2 112.1 203 21.9-26.4 43.1-48.7 43.1-48.7 10.5 7 23.1 10.6 36.3 9.3l1-.9c-.3 3.3-.2 6.5.4 10.3-15.5 17.3-10.9 20.3-41.8 26.7-31.3 6.4-12.9 17.9-.9 20.9 14.5 3.6 48.2 8.8 70.9-23l-.9 3.6c6.1 4.9 10.3 31.6 9.6 55.8s-1.2 40.8 3.6 53.8 9.5 42.2 50.1 33.5c33.9-7.3 51.5-26.1 54-57.6 1.7-22.4 5.7-19 5.9-39l3.2-9.5c3.6-30.3.6-40.1 21.5-35.5l5.1.4c15.4.7 35.5-2.5 47.4-8 25.5-11.7 40.5-31.4 15.5-26.3" style="fill:#336791"/><path d="M256.3 329.5c-1.6 56.4.4 113.2 5.9 126.9 5.5 13.8 17.3 40.6 58 31.9 33.9-7.3 46.3-21.4 51.6-52.4 3.9-22.9 11.6-86.4 12.5-99.4M207.6 46.9S23.7-28.3 32.2 142.7c1.8 36.4 52.1 275.2 112.1 203 21.9-26.4 41.8-47.1 41.8-47.1M306.9 33.2c-6.4 2 102.3-39.7 164.1 39.2 21.8 27.9-3.5 141.8-63.9 231.5" class="st2"/><path d="M407 303.9s3.9 19.4 61.2 7.6c25.1-5.2 10 14.5-15.5 26.4-20.9 9.7-67.7 12.2-68.5-1.2-1.9-34.7 24.8-24.2 22.8-32.8-1.7-7.8-13.6-15.5-21.5-34.5-6.9-16.7-94.3-144.4 24.2-125.5 4.3-.9-30.9-112.7-141.8-114.5S160.7 165.8 160.7 165.8" style="fill:none;stroke:#fff;stroke-width:12.4651;stroke-linecap:round;stroke-linejoin:bevel"/><path d="M225.2 315.7c-15.5 17.3-10.9 20.3-41.8 26.7-31.3 6.4-12.9 17.9-.9 20.9 14.5 3.6 48.2 8.8 70.9-23 6.9-9.7 0-25.1-9.5-29.1-4.6-2-10.8-4.4-18.7 4.5" class="st2"/><path d="M224.2 315.4c-1.6-10.2 3.3-22.2 8.6-36.4 7.9-21.2 26.1-42.4 11.5-109.7-10.8-50.1-83.6-10.4-83.6-3.6s3.3 34.5-1.2 66.7c-5.9 42 26.7 77.6 64.3 73.9" class="st2"/><path d="M206.9 164.7c-.3 2.3 4.3 8.5 10.2 9.3 6 .8 11.1-4 11.4-6.3s-4.2-4.9-10.2-5.7c-6-.9-11.1.3-11.4 2.7z" style="fill:#fff;stroke:#fff;stroke-width:4.155"/><path d="M388.4 159.9c.3 2.3-4.2 8.5-10.2 9.3s-11.1-4-11.4-6.3 4.3-4.9 10.2-5.7 11.1.4 11.4 2.7z" style="fill:#fff;stroke:#fff;stroke-width:2.0775"/><path d="M409.8 143.9c1 18.2-3.9 30.6-4.5 50-.9 28.2 13.4 60.4-8.2 92.7" class="st2"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 2.8 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="#DC205E" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Quad9</title><path d="M6.822 24h5.608l6.331-9.48c1.463-2.185 2.288-4.197 2.288-6.4C21.05 3.458 17.144 0 12 0 6.822 0 2.95 3.493 2.95 8.207c0 4.507 3.459 8 8.345 8 .413 0 .757-.018 1.083-.07zM12 12.129c-2.426 0-4.215-1.634-4.215-3.957 0-2.34 1.79-3.957 4.215-3.957 2.409 0 4.215 1.617 4.215 3.957 0 2.323-1.806 3.957-4.215 3.957z"/></svg>
|
||||||
|
After Width: | Height: | Size: 430 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMinYMin meet" viewBox="0 0.58 256 218.59"><path fill="#912626" d="M245.97 168.943c-13.662 7.121-84.434 36.22-99.501 44.075s-23.437 7.78-35.34 2.09c-11.902-5.69-87.216-36.112-100.783-42.597C3.566 169.271 0 166.535 0 163.951v-25.876s98.05-21.345 113.879-27.024c15.828-5.679 21.32-5.884 34.79-.95 13.472 4.936 94.018 19.468 107.331 24.344l-.006 25.51c.002 2.558-3.07 5.364-10.024 8.988"/><path fill="#C6302B" d="M245.965 143.22c-13.661 7.118-84.431 36.218-99.498 44.072-15.066 7.857-23.436 7.78-35.338 2.09-11.903-5.686-87.214-36.113-100.78-42.594-13.566-6.485-13.85-10.948-.524-16.166 13.326-5.22 88.224-34.605 104.055-40.284 15.828-5.677 21.319-5.884 34.789-.948 13.471 4.934 83.819 32.935 97.13 37.81 13.316 4.881 13.827 8.9.166 16.02"/><path fill="#912626" d="M245.97 127.074c-13.662 7.122-84.434 36.22-99.501 44.078-15.067 7.853-23.437 7.777-35.34 2.087-11.903-5.687-87.216-36.112-100.783-42.597C3.566 127.402 0 124.67 0 122.085V96.206s98.05-21.344 113.879-27.023c15.828-5.679 21.32-5.885 34.79-.95C162.142 73.168 242.688 87.697 256 92.574l-.006 25.513c.002 2.557-3.07 5.363-10.024 8.987"/><path fill="#C6302B" d="M245.965 101.351c-13.661 7.12-84.431 36.218-99.498 44.075-15.066 7.854-23.436 7.777-35.338 2.087-11.903-5.686-87.214-36.112-100.78-42.594-13.566-6.483-13.85-10.947-.524-16.167C23.151 83.535 98.05 54.148 113.88 48.47c15.828-5.678 21.319-5.884 34.789-.949 13.471 4.934 83.819 32.933 97.13 37.81 13.316 4.88 13.827 8.9.166 16.02"/><path fill="#912626" d="M245.97 83.653c-13.662 7.12-84.434 36.22-99.501 44.078-15.067 7.854-23.437 7.777-35.34 2.087-11.903-5.687-87.216-36.113-100.783-42.595C3.566 83.98 0 81.247 0 78.665v-25.88s98.05-21.343 113.879-27.021c15.828-5.68 21.32-5.884 34.79-.95C162.142 29.749 242.688 44.278 256 49.155l-.006 25.512c.002 2.555-3.07 5.361-10.024 8.986"/><path fill="#C6302B" d="M245.965 57.93c-13.661 7.12-84.431 36.22-99.498 44.074-15.066 7.854-23.436 7.777-35.338 2.09C99.227 98.404 23.915 67.98 10.35 61.497-3.217 55.015-3.5 50.55 9.825 45.331 23.151 40.113 98.05 10.73 113.88 5.05c15.828-5.679 21.319-5.883 34.789-.948s83.819 32.934 97.13 37.811c13.316 4.876 13.827 8.897.166 16.017"/><path fill="#FFF" d="m159.283 32.757-22.01 2.285-4.927 11.856-7.958-13.23-25.415-2.284 18.964-6.839-5.69-10.498 17.755 6.944 16.738-5.48-4.524 10.855zm-28.251 57.518L89.955 73.238l58.86-9.035zm-56.95-50.928c17.375 0 31.46 5.46 31.46 12.194 0 6.736-14.085 12.195-31.46 12.195s-31.46-5.46-31.46-12.195c0-6.734 14.085-12.194 31.46-12.194"/><path fill="#621B1C" d="m185.295 35.998 34.836 13.766-34.806 13.753z"/><path fill="#9A2928" d="m146.755 51.243 38.54-15.245.03 27.519-3.779 1.478z"/></svg>
|
||||||
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"><path d="M357.8 237.3V359c-1.1 19.5-16 35.4-35.4 37.8h-84.9v.1c-17.2.2-31.3-13.7-31.5-30.9-.1-17.2 13.8-31.2 31.1-31.3h38.4c11.2 0 20.3-9 20.3-20.1v-77.3c0-17 13.9-30.9 31.1-30.9 17 0 30.9 13.9 30.9 30.9m-140.3 37.8v-77.3c0-11.1 9.1-20.2 20.3-20.2h38.5c8.3 0 16.3-3.4 22.1-9.2 5.8-5.9 9-13.8 9-22.1-.3-17.1-14.3-30.8-31.5-30.8h-85c-19.4 2.4-34.3 18.4-35.3 37.9v121.7c0 17.1 13.9 30.9 31.1 30.9 17 0 30.8-13.9 30.8-30.9m256.4-92.5v243.5c-5 46.1-42.2 82.1-88.7 85.9h-163c-48.8.1-95.6-19.1-130.3-53.5C57.4 424.3 38 377.7 38 329.1V97.2C38 43.6 81.7.2 135.6 0h154.2c48.7-.1 95.5 19.1 130.1 53.4 34.5 34.1 54 80.7 54 129.2m-62.2 1.8c.1-32.4-12.7-63.5-35.7-86.3-23-22.9-54.2-35.8-86.7-35.7L138 62.1c-20.9.2-37.7 17.2-37.7 38.1v227.7c0 32.3 12.9 63.3 35.8 86.1 23 22.9 54.1 35.7 86.6 35.6h156.5c16.4-2.2 29.5-14.8 32.4-31.1V184.4z" style="fill:#55139a"/></svg>
|
||||||
|
After Width: | Height: | Size: 935 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="#003B57" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>SQLite</title><path d="M21.678.521c-1.032-.92-2.28-.55-3.513.544a8.71 8.71 0 0 0-.547.535c-2.109 2.237-4.066 6.38-4.674 9.544.237.48.422 1.093.544 1.561a13.044 13.044 0 0 1 .164.703s-.019-.071-.096-.296l-.05-.146a1.689 1.689 0 0 0-.033-.08c-.138-.32-.518-.995-.686-1.289-.143.423-.27.818-.376 1.176.484.884.778 2.4.778 2.4s-.025-.099-.147-.442c-.107-.303-.644-1.244-.772-1.464-.217.804-.304 1.346-.226 1.478.152.256.296.698.422 1.186.286 1.1.485 2.44.485 2.44l.017.224a22.41 22.41 0 0 0 .056 2.748c.095 1.146.273 2.13.5 2.657l.155-.084c-.334-1.038-.47-2.399-.41-3.967.09-2.398.642-5.29 1.661-8.304 1.723-4.55 4.113-8.201 6.3-9.945-1.993 1.8-4.692 7.63-5.5 9.788-.904 2.416-1.545 4.684-1.931 6.857.666-2.037 2.821-2.912 2.821-2.912s1.057-1.304 2.292-3.166c-.74.169-1.955.458-2.362.629-.6.251-.762.337-.762.337s1.945-1.184 3.613-1.72C21.695 7.9 24.195 2.767 21.678.521m-18.573.543A1.842 1.842 0 0 0 1.27 2.9v16.608a1.84 1.84 0 0 0 1.835 1.834h9.418a22.953 22.953 0 0 1-.052-2.707c-.006-.062-.011-.141-.016-.2a27.01 27.01 0 0 0-.473-2.378c-.121-.47-.275-.898-.369-1.057-.116-.197-.098-.31-.097-.432 0-.12.015-.245.037-.386a9.98 9.98 0 0 1 .234-1.045l.217-.028c-.017-.035-.014-.065-.031-.097l-.041-.381a32.8 32.8 0 0 1 .382-1.194l.2-.019c-.008-.016-.01-.038-.018-.053l-.043-.316c.63-3.28 2.587-7.443 4.8-9.791.066-.069.133-.128.198-.194Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>TencentCloud</title><path d="M20.0483 17.1416C19.6945 17.4914 18.987 18.0161 17.7488 18.0161C17.2182 18.0161 16.5991 18.0161 16.3338 18.0161C15.98 18.0161 13.3268 18.0161 10.143 18.0161C12.4424 15.8298 14.3881 13.9932 14.565 13.8183C14.7419 13.6434 15.1841 13.2061 15.6263 12.8563C16.5107 12.0692 17.2182 11.9817 17.8373 11.9817C18.7217 11.9817 19.4292 12.3316 20.0483 12.8563C21.2864 13.9932 21.2864 16.0047 20.0483 17.1416ZM21.5518 11.457C20.6674 10.495 19.3408 9.88281 17.9257 9.88281C16.6875 9.88281 15.6263 10.3201 14.6534 11.0197C14.2997 11.3695 13.769 11.7194 13.3268 12.2441C12.9731 12.5939 5.36719 19.9401 5.36719 19.9401C5.80939 20.0276 6.34003 20.0276 6.78223 20.0276C7.22443 20.0276 16.0685 20.0276 16.4222 20.0276C17.1298 20.0276 17.6604 20.0276 18.191 19.9401C19.3408 19.8527 20.4905 19.4154 21.4633 18.5409C23.4975 16.6168 23.4975 13.381 21.5518 11.457Z" fill="#00A3FF"></path><path d="M9.1701 10.9323C8.19726 10.2326 7.22442 9.88281 6.07469 9.88281C4.65965 9.88281 3.33304 10.495 2.44864 11.457C0.502952 13.4685 0.502952 16.6168 2.53708 18.6283C3.42148 19.4154 4.30589 19.8527 5.36717 19.9401L7.4013 18.0161C7.04754 18.0161 6.60533 18.0161 6.25157 18.0161C5.10185 17.9287 4.39433 17.5789 3.95212 17.1416C2.71396 15.9172 2.71396 13.9932 3.86368 12.7688C4.48277 12.1566 5.19029 11.8943 6.07469 11.8943C6.60533 11.8943 7.4013 11.9817 8.19726 12.7688C8.55102 13.1186 9.52386 13.8183 9.87763 14.1681H9.96607L11.2927 12.8563V12.7688C10.6736 12.1566 9.70075 11.3695 9.1701 10.9323Z" fill="#00C8DC"></path><path d="M18.4564 8.74536C17.4836 6.12171 14.9188 4.28516 12.0003 4.28516C8.5511 4.28516 5.80945 6.82135 5.27881 9.96973C5.54413 9.96973 5.80945 9.88228 6.16321 9.88228C6.51697 9.88228 6.95917 9.96973 7.31294 9.96973C7.75514 7.78336 9.70082 6.20917 12.0003 6.20917C13.946 6.20917 15.6263 7.34608 16.4223 9.00773C16.4223 9.00773 16.5107 9.09518 16.5107 9.00773C17.1298 8.92027 17.8373 8.74536 18.4564 8.74536C18.4564 8.83282 18.4564 8.83282 18.4564 8.74536Z" fill="#006EFF"></path></svg>
|
||||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"><path d="m126.1 431.2-91.7-57.4V128.6L258.7 0l218.9 128.8v258L255.2 512 178 463.7V346.2L136.2 320V187l121.2-69.5 118.4 69.7v139.4L282 379.4v-56.1c28.1-10.8 48.3-38.6 48.3-71.5 0-42.3-33.5-76.3-74.3-76.3s-74.3 34-74.3 76.3c0 32.8 20.2 60.6 48.3 71.5v106.3l26.8 16.8 164.4-92.6V161.1L258.3 65.3l-167.5 96v181.3l35.3 22.1zM256 216.7c18.5 0 33.1 15.9 33.1 35.1 0 19.1-14.6 35.1-33.1 35.1s-33.1-15.9-33.1-35.1 14.6-35.1 33.1-35.1" style="fill-rule:evenodd;clip-rule:evenodd;fill:#123678"/></svg>
|
||||||
|
After Width: | Height: | Size: 573 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1155" height="1000" fill="none"><path fill="#000" d="m577.344 0 577.346 1000H0z"/></svg>
|
||||||
|
After Width: | Height: | Size: 135 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="14.4 37.7 267 229.53"><path fill="#c9f4ff" d="M107.4 44.1c-2.4-3.9-6.7-6.3-11.3-6.4H27.8c-7.4 0-13.4 6-13.4 13.4 0 2.5.7 5 2 7.1L34 84.1 122.1 65z"/><path fill="#51b9ff" d="M122.5 65.5c-2.4-3.9-6.7-6.3-11.3-6.4H43c-7.3-.3-13.5 5.3-13.9 12.7-.1 2.8.6 5.6 2.2 7.9L51 110.9l91-14.2z"/><path d="M50.1 109.6c-2.2-3.6-2.7-8-1.2-12 2-5.2 7.1-8.6 12.7-8.5h68.2c4.6 0 8.9 2.4 11.3 6.4l61.2 97.1c1.3 2.1 2 4.6 2 7.1s-.7 5-2 7.1L168.1 261c-4 6.3-12.3 8.1-18.5 4.1-1.7-1.1-3.1-2.5-4.1-4.1zm172.5 31.1c4 6.3 12.3 8.1 18.5 4.1 1.7-1.1 3.1-2.5 4.1-4.1L257 122l22.4-35.6c1.3-2.1 2-4.6 2-7.1s-.7-5-2-7.1l-17.8-28.1c-2.4-3.9-6.7-6.3-11.3-6.4h-68.4c-7.4 0-13.4 6-13.4 13.4 0 2.5.7 5 2.1 7.1z" style="fill:#007bfc"/></svg>
|
||||||
|
After Width: | Height: | Size: 772 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1309" height="1581" version="1.2"><path d="M792.7 1312.1v268.3H518.1V1128L0 .7h286.5l403.7 881.9c77.8 168.5 102.5 227.1 102.5 429.5M1308.9.7 972.1 764.5H693L1029.8.7z" style="fill:#fc3f1d"/></svg>
|
||||||
|
After Width: | Height: | Size: 243 B |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 28 KiB |
@@ -0,0 +1,139 @@
|
|||||||
|
#Requires -Version 5.1
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Populates the disposable Stalwart test server with sample Users, Groups,
|
||||||
|
Mailing Lists, and Roles, so each admin panel screen has something to
|
||||||
|
click through and test against.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Local development only. Run after scripts/dev-server-init.ps1.
|
||||||
|
Idempotent: does nothing if the seed users already exist. Only a fresh
|
||||||
|
volume (`docker compose down -v`) clears this data.
|
||||||
|
#>
|
||||||
|
param(
|
||||||
|
[string]$ApiBaseUrl = "http://localhost:8080"
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
$DevAdminAccount = "devadmin@example.org"
|
||||||
|
$DevAdminSecret = "DevAdminPass123!"
|
||||||
|
|
||||||
|
$creds = [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes("$($DevAdminAccount):$($DevAdminSecret)"))
|
||||||
|
$authHeader = @{ Authorization = "Basic $creds" }
|
||||||
|
|
||||||
|
function Invoke-Jmap($body) {
|
||||||
|
Invoke-RestMethod -Uri "$ApiBaseUrl/jmap/" -Method Post -ContentType "application/json" -Headers $authHeader -Body ($body | ConvertTo-Json -Depth 10 -Compress) -TimeoutSec 15
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$session = Invoke-RestMethod -Uri "$ApiBaseUrl/jmap/session" -Headers $authHeader -TimeoutSec 15
|
||||||
|
} catch {
|
||||||
|
throw "Could not reach $ApiBaseUrl as $DevAdminAccount. Run scripts/dev-server-init.ps1 first."
|
||||||
|
}
|
||||||
|
$accountId = $session.primaryAccounts.'urn:stalwart:jmap'
|
||||||
|
|
||||||
|
$existing = Invoke-Jmap @{
|
||||||
|
using = @("urn:ietf:params:jmap:core", "urn:stalwart:jmap")
|
||||||
|
methodCalls = @(, @("x:Account/query", @{ accountId = $accountId; filter = @{ text = "alice" } }, "0"))
|
||||||
|
}
|
||||||
|
if ($existing.methodResponses[0][1].ids.Count -gt 0) {
|
||||||
|
Write-Host "Seed data already present (found 'alice'), skipping. Use 'docker compose down -v' + dev-server-init.ps1 to start fresh."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
$domainResult = Invoke-Jmap @{
|
||||||
|
using = @("urn:ietf:params:jmap:core", "urn:stalwart:jmap")
|
||||||
|
methodCalls = @(, @("x:Domain/query", @{ accountId = $accountId }, "0"))
|
||||||
|
}
|
||||||
|
$domainId = $domainResult.methodResponses[0][1].ids[0]
|
||||||
|
|
||||||
|
Write-Host "Creating groups..."
|
||||||
|
$groupsResult = Invoke-Jmap @{
|
||||||
|
using = @("urn:ietf:params:jmap:core", "urn:stalwart:jmap")
|
||||||
|
methodCalls = @(, @("x:Account/set", @{
|
||||||
|
accountId = $accountId
|
||||||
|
create = @{
|
||||||
|
g_eng = @{ "@type" = "Group"; name = "engineering"; domainId = $domainId; description = "Engineering team" }
|
||||||
|
g_mkt = @{ "@type" = "Group"; name = "marketing"; domainId = $domainId; description = "Marketing team" }
|
||||||
|
}
|
||||||
|
}, "0"))
|
||||||
|
}
|
||||||
|
$groupEngId = $groupsResult.methodResponses[0][1].created.g_eng.id
|
||||||
|
$groupMktId = $groupsResult.methodResponses[0][1].created.g_mkt.id
|
||||||
|
|
||||||
|
Write-Host "Creating users..."
|
||||||
|
$usersResult = Invoke-Jmap @{
|
||||||
|
using = @("urn:ietf:params:jmap:core", "urn:stalwart:jmap")
|
||||||
|
methodCalls = @(, @("x:Account/set", @{
|
||||||
|
accountId = $accountId
|
||||||
|
create = @{
|
||||||
|
u_alice = @{
|
||||||
|
"@type" = "User"; name = "alice"; domainId = $domainId; description = "Alice Smith"
|
||||||
|
roles = @{ "@type" = "User" }
|
||||||
|
credentials = @{ "0" = @{ "@type" = "Password"; secret = "AlicePass123!" } }
|
||||||
|
memberGroupIds = @{ $groupEngId = $true }
|
||||||
|
}
|
||||||
|
u_bob = @{
|
||||||
|
"@type" = "User"; name = "bob"; domainId = $domainId; description = "Bob Jones"
|
||||||
|
roles = @{ "@type" = "User" }
|
||||||
|
credentials = @{ "0" = @{ "@type" = "Password"; secret = "BobPass123!" } }
|
||||||
|
memberGroupIds = @{ $groupEngId = $true; $groupMktId = $true }
|
||||||
|
}
|
||||||
|
u_carol = @{
|
||||||
|
"@type" = "User"; name = "carol"; domainId = $domainId; description = "Carol Diaz"
|
||||||
|
roles = @{ "@type" = "Admin" }
|
||||||
|
credentials = @{ "0" = @{ "@type" = "Password"; secret = "CarolPass123!" } }
|
||||||
|
memberGroupIds = @{ $groupMktId = $true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, "0"))
|
||||||
|
}
|
||||||
|
$userAliceId = $usersResult.methodResponses[0][1].created.u_alice.id
|
||||||
|
|
||||||
|
Write-Host "Adding an alias to alice (for the Aliases column)..."
|
||||||
|
Invoke-Jmap @{
|
||||||
|
using = @("urn:ietf:params:jmap:core", "urn:stalwart:jmap")
|
||||||
|
methodCalls = @(, @("x:Account/set", @{
|
||||||
|
accountId = $accountId
|
||||||
|
update = @{ $userAliceId = @{ aliases = @{ "0" = @{ name = "a.smith"; domainId = $domainId } } } }
|
||||||
|
}, "0"))
|
||||||
|
} | Out-Null
|
||||||
|
|
||||||
|
Write-Host "Creating mailing lists..."
|
||||||
|
Invoke-Jmap @{
|
||||||
|
using = @("urn:ietf:params:jmap:core", "urn:stalwart:jmap")
|
||||||
|
methodCalls = @(, @("x:MailingList/set", @{
|
||||||
|
accountId = $accountId
|
||||||
|
create = @{
|
||||||
|
m_news = @{
|
||||||
|
name = "newsletter"; domainId = $domainId; description = "Company newsletter"
|
||||||
|
recipients = @{ "alice@example.org" = $true; "bob@example.org" = $true; "carol@example.org" = $true }
|
||||||
|
}
|
||||||
|
m_support = @{
|
||||||
|
name = "support"; domainId = $domainId; description = "Support queue"
|
||||||
|
recipients = @{ "bob@example.org" = $true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, "0"))
|
||||||
|
} | Out-Null
|
||||||
|
|
||||||
|
Write-Host "Creating roles..."
|
||||||
|
Invoke-Jmap @{
|
||||||
|
using = @("urn:ietf:params:jmap:core", "urn:stalwart:jmap")
|
||||||
|
methodCalls = @(, @("x:Role/set", @{
|
||||||
|
accountId = $accountId
|
||||||
|
create = @{
|
||||||
|
r_support = @{ description = "Support Agent"; enabledPermissions = @{ authenticate = $true; emailSend = $true; emailReceive = $true } }
|
||||||
|
r_audit = @{ description = "Read-only Auditor"; enabledPermissions = @{ authenticate = $true } }
|
||||||
|
}
|
||||||
|
}, "0"))
|
||||||
|
} | Out-Null
|
||||||
|
|
||||||
|
Write-Host "Done. Seeded:"
|
||||||
|
Write-Host " Users: alice@example.org / AlicePass123! (User role, in engineering, 1 alias)"
|
||||||
|
Write-Host " bob@example.org / BobPass123! (User role, in engineering + marketing)"
|
||||||
|
Write-Host " carol@example.org / CarolPass123! (Admin role, in marketing)"
|
||||||
|
Write-Host " Groups: engineering, marketing"
|
||||||
|
Write-Host " Mailing lists: newsletter@example.org, support@example.org"
|
||||||
|
Write-Host " Roles: Support Agent, Read-only Auditor"
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Local development only. Populates the disposable Stalwart test server
|
||||||
|
# (docker-compose.yml) with sample data across Users, Groups, Mailing Lists,
|
||||||
|
# and Roles, so each admin panel screen has something to click through and
|
||||||
|
# test against. Run after scripts/dev-server-init.sh.
|
||||||
|
#
|
||||||
|
# Idempotent: does nothing if the seed users already exist (re-running
|
||||||
|
# scripts/dev-server-init.sh does not wipe this data; only a fresh volume
|
||||||
|
# via `docker compose down -v` does).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
API_BASE_URL="${1:-http://localhost:8080}"
|
||||||
|
DEVADMIN_ACCOUNT="devadmin@example.org"
|
||||||
|
DEVADMIN_SECRET="DevAdminPass123!"
|
||||||
|
|
||||||
|
jmap() {
|
||||||
|
curl -sf --compressed -u "$DEVADMIN_ACCOUNT:$DEVADMIN_SECRET" \
|
||||||
|
-X POST -H "Content-Type: application/json" -d "$1" "$API_BASE_URL/jmap/"
|
||||||
|
}
|
||||||
|
|
||||||
|
extract_id() {
|
||||||
|
# $1 = JSON response, $2 = create-id key (e.g. "u1")
|
||||||
|
printf '%s' "$1" | grep -o "\"$2\":{\"id\":\"[^\"]*\"" | head -1 | grep -o '"id":"[^"]*"' | cut -d'"' -f4
|
||||||
|
}
|
||||||
|
|
||||||
|
SESSION=$(curl -sf --compressed -u "$DEVADMIN_ACCOUNT:$DEVADMIN_SECRET" "$API_BASE_URL/jmap/session") || {
|
||||||
|
echo "Could not reach $API_BASE_URL as $DEVADMIN_ACCOUNT. Run scripts/dev-server-init.sh first." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
ACCOUNT_ID=$(printf '%s' "$SESSION" | grep -o '"urn:stalwart:jmap":"[^"]*"' | cut -d'"' -f4)
|
||||||
|
|
||||||
|
EXISTING=$(jmap "{\"using\":[\"urn:ietf:params:jmap:core\",\"urn:stalwart:jmap\"],\"methodCalls\":[[\"x:Account/query\",{\"accountId\":\"$ACCOUNT_ID\",\"filter\":{\"text\":\"alice\"}},\"0\"]]}")
|
||||||
|
if printf '%s' "$EXISTING" | grep -q '"ids":\["'; then
|
||||||
|
echo "Seed data already present (found 'alice'), skipping. Use 'docker compose down -v' + dev-server-init.sh to start fresh."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
DOMAIN_RESULT=$(jmap "{\"using\":[\"urn:ietf:params:jmap:core\",\"urn:stalwart:jmap\"],\"methodCalls\":[[\"x:Domain/query\",{\"accountId\":\"$ACCOUNT_ID\"},\"0\"]]}")
|
||||||
|
DOMAIN_ID=$(printf '%s' "$DOMAIN_RESULT" | grep -o '"ids":\["[^"]*"' | cut -d'"' -f4)
|
||||||
|
|
||||||
|
echo "Creating groups..."
|
||||||
|
GROUPS_RESULT=$(jmap "{\"using\":[\"urn:ietf:params:jmap:core\",\"urn:stalwart:jmap\"],\"methodCalls\":[[\"x:Account/set\",{\"accountId\":\"$ACCOUNT_ID\",\"create\":{
|
||||||
|
\"g_eng\":{\"@type\":\"Group\",\"name\":\"engineering\",\"domainId\":\"$DOMAIN_ID\",\"description\":\"Engineering team\"},
|
||||||
|
\"g_mkt\":{\"@type\":\"Group\",\"name\":\"marketing\",\"domainId\":\"$DOMAIN_ID\",\"description\":\"Marketing team\"}
|
||||||
|
}},\"0\"]]}")
|
||||||
|
GROUP_ENG_ID=$(extract_id "$GROUPS_RESULT" g_eng)
|
||||||
|
GROUP_MKT_ID=$(extract_id "$GROUPS_RESULT" g_mkt)
|
||||||
|
|
||||||
|
echo "Creating users..."
|
||||||
|
USERS_RESULT=$(jmap "{\"using\":[\"urn:ietf:params:jmap:core\",\"urn:stalwart:jmap\"],\"methodCalls\":[[\"x:Account/set\",{\"accountId\":\"$ACCOUNT_ID\",\"create\":{
|
||||||
|
\"u_alice\":{\"@type\":\"User\",\"name\":\"alice\",\"domainId\":\"$DOMAIN_ID\",\"description\":\"Alice Smith\",\"roles\":{\"@type\":\"User\"},\"credentials\":{\"0\":{\"@type\":\"Password\",\"secret\":\"AlicePass123!\"}},\"memberGroupIds\":{\"$GROUP_ENG_ID\":true}},
|
||||||
|
\"u_bob\":{\"@type\":\"User\",\"name\":\"bob\",\"domainId\":\"$DOMAIN_ID\",\"description\":\"Bob Jones\",\"roles\":{\"@type\":\"User\"},\"credentials\":{\"0\":{\"@type\":\"Password\",\"secret\":\"BobPass123!\"}},\"memberGroupIds\":{\"$GROUP_ENG_ID\":true,\"$GROUP_MKT_ID\":true}},
|
||||||
|
\"u_carol\":{\"@type\":\"User\",\"name\":\"carol\",\"domainId\":\"$DOMAIN_ID\",\"description\":\"Carol Diaz\",\"roles\":{\"@type\":\"Admin\"},\"credentials\":{\"0\":{\"@type\":\"Password\",\"secret\":\"CarolPass123!\"}},\"memberGroupIds\":{\"$GROUP_MKT_ID\":true}}
|
||||||
|
}},\"0\"]]}")
|
||||||
|
USER_ALICE_ID=$(extract_id "$USERS_RESULT" u_alice)
|
||||||
|
|
||||||
|
echo "Adding an alias to alice (for the Aliases column)..."
|
||||||
|
jmap "{\"using\":[\"urn:ietf:params:jmap:core\",\"urn:stalwart:jmap\"],\"methodCalls\":[[\"x:Account/set\",{\"accountId\":\"$ACCOUNT_ID\",\"update\":{\"$USER_ALICE_ID\":{\"aliases\":{\"0\":{\"name\":\"a.smith\",\"domainId\":\"$DOMAIN_ID\"}}}}},\"0\"]]}" > /dev/null
|
||||||
|
|
||||||
|
echo "Creating mailing lists..."
|
||||||
|
jmap "{\"using\":[\"urn:ietf:params:jmap:core\",\"urn:stalwart:jmap\"],\"methodCalls\":[[\"x:MailingList/set\",{\"accountId\":\"$ACCOUNT_ID\",\"create\":{
|
||||||
|
\"m_news\":{\"name\":\"newsletter\",\"domainId\":\"$DOMAIN_ID\",\"description\":\"Company newsletter\",\"recipients\":{\"alice@example.org\":true,\"bob@example.org\":true,\"carol@example.org\":true}},
|
||||||
|
\"m_support\":{\"name\":\"support\",\"domainId\":\"$DOMAIN_ID\",\"description\":\"Support queue\",\"recipients\":{\"bob@example.org\":true}}
|
||||||
|
}},\"0\"]]}" > /dev/null
|
||||||
|
|
||||||
|
echo "Creating roles..."
|
||||||
|
jmap "{\"using\":[\"urn:ietf:params:jmap:core\",\"urn:stalwart:jmap\"],\"methodCalls\":[[\"x:Role/set\",{\"accountId\":\"$ACCOUNT_ID\",\"create\":{
|
||||||
|
\"r_support\":{\"description\":\"Support Agent\",\"enabledPermissions\":{\"authenticate\":true,\"emailSend\":true,\"emailReceive\":true}},
|
||||||
|
\"r_audit\":{\"description\":\"Read-only Auditor\",\"enabledPermissions\":{\"authenticate\":true}}
|
||||||
|
}},\"0\"]]}" > /dev/null
|
||||||
|
|
||||||
|
echo "Done. Seeded:"
|
||||||
|
echo " Users: alice@example.org / AlicePass123! (User role, in engineering, 1 alias)"
|
||||||
|
echo " bob@example.org / BobPass123! (User role, in engineering + marketing)"
|
||||||
|
echo " carol@example.org / CarolPass123! (Admin role, in marketing)"
|
||||||
|
echo " Groups: engineering, marketing"
|
||||||
|
echo " Mailing lists: newsletter@example.org, support@example.org"
|
||||||
|
echo " Roles: Support Agent, Read-only Auditor"
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
#Requires -Version 5.1
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
One-time setup for the disposable Stalwart test server from
|
||||||
|
docker-compose.yml: completes the bootstrap wizard, creates a real
|
||||||
|
"devadmin" account, and sets the default OAuth access token lifetime.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Local development only. Requires the dev container to be running
|
||||||
|
('npm run dev:server'). The STALWART_RECOVERY_ADMIN account is
|
||||||
|
break-glass only and always issues fixed 1h OAuth tokens regardless of
|
||||||
|
server config, so dev-token.ps1 authenticates as "devadmin" instead,
|
||||||
|
created here. Idempotent: safe to re-run; does nothing if the server
|
||||||
|
already left bootstrap mode.
|
||||||
|
#>
|
||||||
|
param(
|
||||||
|
[string]$ApiBaseUrl = "http://localhost:8080"
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
$RecoveryAccount = "admin@example.org"
|
||||||
|
$RecoverySecret = "c8321iEscHDy0GWV"
|
||||||
|
$DevDomain = "example.org"
|
||||||
|
$DevHostname = "mail.example.org"
|
||||||
|
$DevAdminName = "devadmin"
|
||||||
|
$DevAdminSecret = "DevAdminPass123!"
|
||||||
|
$DefaultTokenExpiryMs = 10800000 # 3 hours
|
||||||
|
|
||||||
|
$recoveryCreds = [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes("$($RecoveryAccount):$($RecoverySecret)"))
|
||||||
|
$authHeader = @{ Authorization = "Basic $recoveryCreds" }
|
||||||
|
|
||||||
|
function Invoke-Jmap($body) {
|
||||||
|
Invoke-RestMethod -Uri "$ApiBaseUrl/jmap/" -Method Post -ContentType "application/json" -Headers $authHeader -Body ($body | ConvertTo-Json -Depth 10 -Compress) -TimeoutSec 15
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Waiting for $ApiBaseUrl to be reachable..."
|
||||||
|
$ready = $false
|
||||||
|
for ($i = 0; $i -lt 30; $i++) {
|
||||||
|
try {
|
||||||
|
Invoke-RestMethod -Uri "$ApiBaseUrl/jmap/session" -Headers $authHeader -TimeoutSec 5 | Out-Null
|
||||||
|
$ready = $true
|
||||||
|
break
|
||||||
|
} catch { Start-Sleep -Seconds 1 }
|
||||||
|
}
|
||||||
|
if (-not $ready) { throw "Server did not become reachable at $ApiBaseUrl" }
|
||||||
|
|
||||||
|
$session = Invoke-RestMethod -Uri "$ApiBaseUrl/jmap/session" -Headers $authHeader -TimeoutSec 15
|
||||||
|
$accountId = $session.primaryAccounts.'urn:stalwart:jmap'
|
||||||
|
|
||||||
|
$queryResult = Invoke-Jmap @{
|
||||||
|
using = @("urn:ietf:params:jmap:core", "urn:stalwart:jmap")
|
||||||
|
methodCalls = @(, @("x:Domain/query", @{ accountId = $accountId }, "0"))
|
||||||
|
}
|
||||||
|
$alreadyBootstrapped = -not ($queryResult.methodResponses[0][1].type -eq "forbidden")
|
||||||
|
|
||||||
|
if ($alreadyBootstrapped) {
|
||||||
|
Write-Host "Server already bootstrapped, skipping setup. (Use 'docker compose down -v; npm run dev:server' to start fresh.)"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Completing server bootstrap (domain: $DevDomain, no TLS certificate request)..."
|
||||||
|
Invoke-Jmap @{
|
||||||
|
using = @("urn:ietf:params:jmap:core", "urn:stalwart:jmap")
|
||||||
|
methodCalls = @(, @("x:Bootstrap/set", @{
|
||||||
|
accountId = $accountId
|
||||||
|
update = @{ singleton = @{ defaultDomain = $DevDomain; serverHostname = $DevHostname; requestTlsCertificate = $false } }
|
||||||
|
}, "0"))
|
||||||
|
} | Out-Null
|
||||||
|
|
||||||
|
Write-Host "Restarting the container to apply bootstrap config (one-time only)..."
|
||||||
|
docker compose restart stalwart | Out-Null
|
||||||
|
$ready = $false
|
||||||
|
for ($i = 0; $i -lt 30; $i++) {
|
||||||
|
try {
|
||||||
|
Invoke-RestMethod -Uri "$ApiBaseUrl/jmap/session" -Headers $authHeader -TimeoutSec 5 | Out-Null
|
||||||
|
$ready = $true
|
||||||
|
break
|
||||||
|
} catch { Start-Sleep -Seconds 1 }
|
||||||
|
}
|
||||||
|
if (-not $ready) { throw "Server did not come back up after restart" }
|
||||||
|
|
||||||
|
$domainResult = Invoke-Jmap @{
|
||||||
|
using = @("urn:ietf:params:jmap:core", "urn:stalwart:jmap")
|
||||||
|
methodCalls = @(, @("x:Domain/query", @{ accountId = $accountId }, "0"))
|
||||||
|
}
|
||||||
|
$domainId = $domainResult.methodResponses[0][1].ids[0]
|
||||||
|
|
||||||
|
Write-Host "Creating devadmin account ($DevAdminName@$DevDomain)..."
|
||||||
|
$createResult = Invoke-Jmap @{
|
||||||
|
using = @("urn:ietf:params:jmap:core", "urn:stalwart:jmap")
|
||||||
|
methodCalls = @(, @("x:Account/set", @{
|
||||||
|
accountId = $accountId
|
||||||
|
create = @{ u1 = @{ "@type" = "User"; name = $DevAdminName; domainId = $domainId; roles = @{ "@type" = "Admin" } } }
|
||||||
|
}, "0"))
|
||||||
|
}
|
||||||
|
$devAdminId = $createResult.methodResponses[0][1].created.u1.id
|
||||||
|
|
||||||
|
Invoke-Jmap @{
|
||||||
|
using = @("urn:ietf:params:jmap:core", "urn:stalwart:jmap")
|
||||||
|
methodCalls = @(, @("x:Account/set", @{
|
||||||
|
accountId = $accountId
|
||||||
|
update = @{ $devAdminId = @{ credentials = @{ "0" = @{ "@type" = "Password"; secret = $DevAdminSecret } } } }
|
||||||
|
}, "0"))
|
||||||
|
} | Out-Null
|
||||||
|
|
||||||
|
Write-Host "Setting default OAuth access token lifetime to 3 hours..."
|
||||||
|
Invoke-Jmap @{
|
||||||
|
using = @("urn:ietf:params:jmap:core", "urn:stalwart:jmap")
|
||||||
|
methodCalls = @(, @("x:OidcProvider/set", @{
|
||||||
|
accountId = $accountId
|
||||||
|
update = @{ singleton = @{ accessTokenExpiry = $DefaultTokenExpiryMs } }
|
||||||
|
}, "0"))
|
||||||
|
} | Out-Null
|
||||||
|
try {
|
||||||
|
Invoke-Jmap @{
|
||||||
|
using = @("urn:ietf:params:jmap:core", "urn:stalwart:jmap")
|
||||||
|
methodCalls = @(, @("x:Action/set", @{ accountId = $accountId; create = @{ a1 = @{ "@type" = "ReloadSettings" } } }, "0"))
|
||||||
|
} | Out-Null
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
# Cosmetic only: Stalwart seeds a default "Stalwart Web Interface" /
|
||||||
|
# stalwartlabs/webui entry for the x:Application record serving /admin and
|
||||||
|
# /account (see Settings > Web Applications' "Active WebUI" card). Point it
|
||||||
|
# at this fork so that card isn't misleading in local dev too.
|
||||||
|
Write-Host "Pointing the active WebUI's description at this fork..."
|
||||||
|
try {
|
||||||
|
$appResult = Invoke-Jmap @{
|
||||||
|
using = @("urn:ietf:params:jmap:core", "urn:stalwart:jmap")
|
||||||
|
methodCalls = @(, @("x:Application/query", @{ accountId = $accountId }, "0"))
|
||||||
|
}
|
||||||
|
$appId = $appResult.methodResponses[0][1].ids[0]
|
||||||
|
if ($appId) {
|
||||||
|
Invoke-Jmap @{
|
||||||
|
using = @("urn:ietf:params:jmap:core", "urn:stalwart:jmap")
|
||||||
|
methodCalls = @(, @("x:Application/set", @{
|
||||||
|
accountId = $accountId
|
||||||
|
update = @{ $appId = @{
|
||||||
|
description = "Stalwart WebUI Fork"
|
||||||
|
resourceUrl = "https://github.com/LinkPhoenix/stalwart-webui-fork/releases/latest/download/webui.zip"
|
||||||
|
} }
|
||||||
|
}, "0"))
|
||||||
|
} | Out-Null
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
Write-Host "Done. $DevAdminName@$DevDomain / $DevAdminSecret is ready - run scripts/dev-token.ps1 to get a token."
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Local development only. One-time setup for the disposable Stalwart test
|
||||||
|
# server from docker-compose.yml: completes the server's bootstrap wizard,
|
||||||
|
# creates a real "devadmin" account (used by dev-token.sh/.ps1 — the
|
||||||
|
# STALWART_RECOVERY_ADMIN account is break-glass only and always issues
|
||||||
|
# fixed 1h OAuth tokens regardless of server config), and sets the default
|
||||||
|
# OAuth access token lifetime to 3 hours.
|
||||||
|
#
|
||||||
|
# Idempotent: safe to re-run; does nothing if the server already left
|
||||||
|
# bootstrap mode. Run this once after `npm run dev:server` on a fresh
|
||||||
|
# volume (or after `docker compose down -v`).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
API_BASE_URL="${1:-http://localhost:8080}"
|
||||||
|
RECOVERY_ACCOUNT="admin@example.org"
|
||||||
|
RECOVERY_SECRET="c8321iEscHDy0GWV"
|
||||||
|
DEV_DOMAIN="example.org"
|
||||||
|
DEV_HOSTNAME="mail.example.org"
|
||||||
|
DEVADMIN_NAME="devadmin"
|
||||||
|
DEVADMIN_SECRET="DevAdminPass123!"
|
||||||
|
DEFAULT_TOKEN_EXPIRY_MS=10800000 # 3 hours
|
||||||
|
|
||||||
|
jmap() {
|
||||||
|
curl -sf --compressed -u "$RECOVERY_ACCOUNT:$RECOVERY_SECRET" \
|
||||||
|
-X POST -H "Content-Type: application/json" -d "$1" "$API_BASE_URL/jmap/"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Waiting for $API_BASE_URL to be reachable..."
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
|
curl -sf -o /dev/null -u "$RECOVERY_ACCOUNT:$RECOVERY_SECRET" "$API_BASE_URL/jmap/session" && break
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
SESSION=$(curl -sf --compressed -u "$RECOVERY_ACCOUNT:$RECOVERY_SECRET" "$API_BASE_URL/jmap/session")
|
||||||
|
ACCOUNT_ID=$(printf '%s' "$SESSION" | grep -o '"urn:stalwart:jmap":"[^"]*"' | cut -d'"' -f4)
|
||||||
|
|
||||||
|
QUERY_RESULT=$(jmap "{\"using\":[\"urn:ietf:params:jmap:core\",\"urn:stalwart:jmap\"],\"methodCalls\":[[\"x:Domain/query\",{\"accountId\":\"$ACCOUNT_ID\"},\"0\"]]}")
|
||||||
|
if ! printf '%s' "$QUERY_RESULT" | grep -q '"forbidden"'; then
|
||||||
|
echo "Server already bootstrapped, skipping setup. (Use 'docker compose down -v && npm run dev:server' to start fresh.)"
|
||||||
|
else
|
||||||
|
echo "Completing server bootstrap (domain: $DEV_DOMAIN, no TLS certificate request)..."
|
||||||
|
jmap "{\"using\":[\"urn:ietf:params:jmap:core\",\"urn:stalwart:jmap\"],\"methodCalls\":[[\"x:Bootstrap/set\",{\"accountId\":\"$ACCOUNT_ID\",\"update\":{\"singleton\":{\"defaultDomain\":\"$DEV_DOMAIN\",\"serverHostname\":\"$DEV_HOSTNAME\",\"requestTlsCertificate\":false}}},\"0\"]]}" > /dev/null
|
||||||
|
|
||||||
|
echo "Restarting the container to apply bootstrap config (one-time only)..."
|
||||||
|
docker compose restart stalwart > /dev/null
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
|
curl -sf -o /dev/null -u "$RECOVERY_ACCOUNT:$RECOVERY_SECRET" "$API_BASE_URL/jmap/session" && break
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
DOMAIN_RESULT=$(jmap "{\"using\":[\"urn:ietf:params:jmap:core\",\"urn:stalwart:jmap\"],\"methodCalls\":[[\"x:Domain/query\",{\"accountId\":\"$ACCOUNT_ID\"},\"0\"]]}")
|
||||||
|
DOMAIN_ID=$(printf '%s' "$DOMAIN_RESULT" | grep -o '"ids":\["[^"]*"' | cut -d'"' -f4)
|
||||||
|
|
||||||
|
echo "Creating devadmin account ($DEVADMIN_NAME@$DEV_DOMAIN)..."
|
||||||
|
CREATE_RESULT=$(jmap "{\"using\":[\"urn:ietf:params:jmap:core\",\"urn:stalwart:jmap\"],\"methodCalls\":[[\"x:Account/set\",{\"accountId\":\"$ACCOUNT_ID\",\"create\":{\"u1\":{\"@type\":\"User\",\"name\":\"$DEVADMIN_NAME\",\"domainId\":\"$DOMAIN_ID\",\"roles\":{\"@type\":\"Admin\"}}}},\"0\"]]}")
|
||||||
|
DEVADMIN_ID=$(printf '%s' "$CREATE_RESULT" | grep -o '"id":"[^"]*"' | cut -d'"' -f4)
|
||||||
|
|
||||||
|
jmap "{\"using\":[\"urn:ietf:params:jmap:core\",\"urn:stalwart:jmap\"],\"methodCalls\":[[\"x:Account/set\",{\"accountId\":\"$ACCOUNT_ID\",\"update\":{\"$DEVADMIN_ID\":{\"credentials\":{\"0\":{\"@type\":\"Password\",\"secret\":\"$DEVADMIN_SECRET\"}}}}},\"0\"]]}" > /dev/null
|
||||||
|
|
||||||
|
echo "Setting default OAuth access token lifetime to 3 hours..."
|
||||||
|
jmap "{\"using\":[\"urn:ietf:params:jmap:core\",\"urn:stalwart:jmap\"],\"methodCalls\":[[\"x:OidcProvider/set\",{\"accountId\":\"$ACCOUNT_ID\",\"update\":{\"singleton\":{\"accessTokenExpiry\":$DEFAULT_TOKEN_EXPIRY_MS}}},\"0\"]]}" > /dev/null
|
||||||
|
jmap "{\"using\":[\"urn:ietf:params:jmap:core\",\"urn:stalwart:jmap\"],\"methodCalls\":[[\"x:Action/set\",{\"accountId\":\"$ACCOUNT_ID\",\"create\":{\"a1\":{\"@type\":\"ReloadSettings\"}}},\"0\"]]}" > /dev/null 2>&1 || true
|
||||||
|
|
||||||
|
# Cosmetic only: Stalwart seeds a default "Stalwart Web Interface" /
|
||||||
|
# stalwartlabs/webui entry for the x:Application record serving /admin
|
||||||
|
# and /account (see Settings > Web Applications' "Active WebUI" card).
|
||||||
|
# Point it at this fork so that card isn't misleading in local dev too.
|
||||||
|
echo "Pointing the active WebUI's description at this fork..."
|
||||||
|
APP_RESULT=$(jmap "{\"using\":[\"urn:ietf:params:jmap:core\",\"urn:stalwart:jmap\"],\"methodCalls\":[[\"x:Application/query\",{\"accountId\":\"$ACCOUNT_ID\"},\"0\"]]}") || true
|
||||||
|
APP_ID=$(printf '%s' "$APP_RESULT" | grep -o '"ids":\["[^"]*"' | cut -d'"' -f4)
|
||||||
|
if [ -n "$APP_ID" ]; then
|
||||||
|
jmap "{\"using\":[\"urn:ietf:params:jmap:core\",\"urn:stalwart:jmap\"],\"methodCalls\":[[\"x:Application/set\",{\"accountId\":\"$ACCOUNT_ID\",\"update\":{\"$APP_ID\":{\"description\":\"Stalwart WebUI Fork\",\"resourceUrl\":\"https://github.com/LinkPhoenix/stalwart-webui-fork/releases/latest/download/webui.zip\"}}},\"0\"]]}" > /dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Done. devadmin@$DEV_DOMAIN / $DEVADMIN_SECRET is ready — run scripts/dev-token.sh to get a token."
|
||||||
|
fi
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
#Requires -Version 5.1
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Generates a fresh access token from the local Stalwart dev container and
|
||||||
|
writes it to .env.development.local (gitignored).
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Local development only. Requires scripts/dev-server-init.ps1 to have been
|
||||||
|
run once first (creates the "devadmin" account this script authenticates
|
||||||
|
as — the STALWART_RECOVERY_ADMIN account is break-glass only and always
|
||||||
|
issues fixed 1h tokens regardless of server config, so it can't honor a
|
||||||
|
custom duration). See DEVELOPMENT.md for the full workflow. Non-Windows
|
||||||
|
shells (and AI agents without PowerShell) can use scripts/dev-token.sh
|
||||||
|
instead.
|
||||||
|
|
||||||
|
.PARAMETER DurationSeconds
|
||||||
|
How long the token should stay valid, in seconds. Defaults to 3 hours
|
||||||
|
(10800), matching the server default set by dev-server-init.ps1.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
./dev-token.ps1 # 3 hour token
|
||||||
|
.EXAMPLE
|
||||||
|
./dev-token.ps1 -DurationSeconds 1800 # 30 minute token
|
||||||
|
#>
|
||||||
|
param(
|
||||||
|
[int]$DurationSeconds = 10800,
|
||||||
|
[string]$ApiBaseUrl = "http://localhost:8080"
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$root = Split-Path -Parent $PSScriptRoot
|
||||||
|
|
||||||
|
$DevAdminAccount = "devadmin@example.org"
|
||||||
|
$DevAdminSecret = "DevAdminPass123!"
|
||||||
|
|
||||||
|
$creds = [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes("$($DevAdminAccount):$($DevAdminSecret)"))
|
||||||
|
$authHeader = @{ Authorization = "Basic $creds" }
|
||||||
|
|
||||||
|
try {
|
||||||
|
$session = Invoke-RestMethod -Uri "$ApiBaseUrl/jmap/session" -Headers $authHeader -TimeoutSec 15
|
||||||
|
} catch {
|
||||||
|
throw "Could not reach $ApiBaseUrl as $DevAdminAccount. Is the server running ('npm run dev:server') and initialized ('scripts/dev-server-init.ps1')?"
|
||||||
|
}
|
||||||
|
$accountId = $session.primaryAccounts.'urn:stalwart:jmap'
|
||||||
|
|
||||||
|
$expiresAt = [DateTime]::UtcNow.AddSeconds($DurationSeconds).ToString("yyyy-MM-ddTHH:mm:ssZ")
|
||||||
|
|
||||||
|
$request = @{
|
||||||
|
using = @("urn:ietf:params:jmap:core", "urn:stalwart:jmap")
|
||||||
|
methodCalls = @(, @("x:ApiKey/set", @{
|
||||||
|
accountId = $accountId
|
||||||
|
create = @{ k1 = @{ description = "dev-token.ps1"; expiresAt = $expiresAt } }
|
||||||
|
}, "0"))
|
||||||
|
} | ConvertTo-Json -Depth 10 -Compress
|
||||||
|
|
||||||
|
$response = Invoke-RestMethod -Uri "$ApiBaseUrl/jmap/" -Method Post -ContentType "application/json" -Headers $authHeader -Body $request -TimeoutSec 15
|
||||||
|
$secret = $response.methodResponses[0][1].created.k1.secret
|
||||||
|
|
||||||
|
if (-not $secret) {
|
||||||
|
throw "Unexpected x:ApiKey/set response: $($response | ConvertTo-Json -Depth 10 -Compress)"
|
||||||
|
}
|
||||||
|
|
||||||
|
$envPath = Join-Path $root ".env.development.local"
|
||||||
|
@"
|
||||||
|
# Generated by scripts/dev-token.ps1 - gitignored, do not commit.
|
||||||
|
# Empty base URL: API calls stay same-origin and go through the Vite proxy.
|
||||||
|
VITE_API_BASE_URL=
|
||||||
|
VITE_ACCESS_TOKEN=$secret
|
||||||
|
"@ | Set-Content -Path $envPath -Encoding ascii
|
||||||
|
|
||||||
|
Write-Host "Token written to $envPath (expires $expiresAt, in ${DurationSeconds}s). Restart 'npm run dev' to pick it up."
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Local development only. Generates a fresh access token from the local
|
||||||
|
# Stalwart dev container (see docker-compose.yml) and writes it to
|
||||||
|
# .env.development.local (gitignored). Bash equivalent of dev-token.ps1, for
|
||||||
|
# non-Windows shells (and AI agents without PowerShell).
|
||||||
|
#
|
||||||
|
# Requires scripts/dev-server-init.sh to have been run once first (creates
|
||||||
|
# the "devadmin" account this script authenticates as — the
|
||||||
|
# STALWART_RECOVERY_ADMIN account is break-glass only and always issues
|
||||||
|
# fixed 1h tokens regardless of server config, so it can't honor a custom
|
||||||
|
# duration).
|
||||||
|
#
|
||||||
|
# Usage: dev-token.sh [duration_seconds] [api_base_url]
|
||||||
|
# dev-token.sh # 3 hour token (server default, see dev-server-init.sh)
|
||||||
|
# dev-token.sh 1800 # 30 minute token
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
DURATION_SECONDS="${1:-10800}"
|
||||||
|
API_BASE_URL="${2:-http://localhost:8080}"
|
||||||
|
DEVADMIN_ACCOUNT="devadmin@example.org"
|
||||||
|
DEVADMIN_SECRET="DevAdminPass123!"
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
|
||||||
|
if ! date -u -d "+1 minute" +"%Y-%m-%dT%H:%M:%SZ" >/dev/null 2>&1; then
|
||||||
|
EXPIRES_AT=$(date -u -v+"${DURATION_SECONDS}"S +"%Y-%m-%dT%H:%M:%SZ") # BSD/macOS date
|
||||||
|
else
|
||||||
|
EXPIRES_AT=$(date -u -d "+${DURATION_SECONDS} seconds" +"%Y-%m-%dT%H:%M:%SZ") # GNU date
|
||||||
|
fi
|
||||||
|
|
||||||
|
SESSION=$(curl -sf --compressed -u "$DEVADMIN_ACCOUNT:$DEVADMIN_SECRET" "$API_BASE_URL/jmap/session") || {
|
||||||
|
echo "Could not reach $API_BASE_URL as $DEVADMIN_ACCOUNT. Is the server running ('npm run dev:server') and initialized ('scripts/dev-server-init.sh')?" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
ACCOUNT_ID=$(printf '%s' "$SESSION" | grep -o '"urn:stalwart:jmap":"[^"]*"' | cut -d'"' -f4)
|
||||||
|
|
||||||
|
REQ=$(cat <<JSON
|
||||||
|
{"using":["urn:ietf:params:jmap:core","urn:stalwart:jmap"],"methodCalls":[["x:ApiKey/set",{"accountId":"$ACCOUNT_ID","create":{"k1":{"description":"dev-token.sh","expiresAt":"$EXPIRES_AT"}}},"0"]]}
|
||||||
|
JSON
|
||||||
|
)
|
||||||
|
|
||||||
|
RESPONSE=$(curl -sf --compressed -u "$DEVADMIN_ACCOUNT:$DEVADMIN_SECRET" -X POST -H "Content-Type: application/json" -d "$REQ" "$API_BASE_URL/jmap/")
|
||||||
|
TOKEN=$(printf '%s' "$RESPONSE" | grep -o '"secret":"[^"]*"' | cut -d'"' -f4)
|
||||||
|
|
||||||
|
if [ -z "$TOKEN" ]; then
|
||||||
|
echo "Unexpected x:ApiKey/set response: $RESPONSE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
ENV_PATH="$ROOT_DIR/.env.development.local"
|
||||||
|
cat > "$ENV_PATH" <<EOF
|
||||||
|
# Generated by scripts/dev-token.sh - gitignored, do not commit.
|
||||||
|
# Empty base URL: API calls stay same-origin and go through the Vite proxy.
|
||||||
|
VITE_API_BASE_URL=
|
||||||
|
VITE_ACCESS_TOKEN=$TOKEN
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "Token written to $ENV_PATH (expires $EXPIRES_AT, in ${DURATION_SECONDS}s). Restart 'npm run dev' to pick it up."
|
||||||
@@ -9,6 +9,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import { ArrowLeft, ArrowRight, Check, Copy, Loader2, Rocket } from 'lucide-react';
|
import { ArrowLeft, ArrowRight, Check, Copy, Loader2, Rocket } from 'lucide-react';
|
||||||
|
|
||||||
import { useSchemaStore } from '@/stores/schemaStore';
|
import { useSchemaStore } from '@/stores/schemaStore';
|
||||||
@@ -403,13 +404,15 @@ export function BootstrapWizard() {
|
|||||||
|
|
||||||
function WizardShell({ children }: { children: React.ReactNode }) {
|
function WizardShell({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col bg-content-background">
|
<div className="flex h-screen flex-col bg-content-background">
|
||||||
<header className="flex items-center px-6 py-4 border-b bg-background">
|
<header className="flex items-center px-6 py-4 border-b bg-background">
|
||||||
<DefaultLogo />
|
<DefaultLogo />
|
||||||
</header>
|
</header>
|
||||||
<main className="flex-1 overflow-auto p-6">
|
<ScrollArea role="main" className="min-w-0 flex-1">
|
||||||
<div className="mx-auto max-w-3xl">{children}</div>
|
<div className="min-w-0 p-4 sm:p-6">
|
||||||
</main>
|
<div className="mx-auto w-full min-w-0 max-w-3xl">{children}</div>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const BACKEND_ICONS: Record<string, { path: string; isIco?: boolean }> = {
|
||||||
|
// Storage / database backends
|
||||||
|
foundationdb: { path: '/icons/backends/foundationdb.svg' },
|
||||||
|
mariadb: { path: '/icons/backends/mysql.svg' },
|
||||||
|
mysql: { path: '/icons/backends/mysql.svg' },
|
||||||
|
postgres: { path: '/icons/backends/postgresql.svg' },
|
||||||
|
postgresql: { path: '/icons/backends/postgresql.svg' },
|
||||||
|
quad9: { path: '/icons/backends/quad9.svg' },
|
||||||
|
redis: { path: '/icons/backends/redis.svg' },
|
||||||
|
rediscluster: { path: '/icons/backends/redis.svg' },
|
||||||
|
redissentinel: { path: '/icons/backends/redis.svg' },
|
||||||
|
redisvalkey: { path: '/icons/backends/redis.svg' },
|
||||||
|
rocksdb: { path: '/icons/backends/rocksdb.svg' },
|
||||||
|
sqlite: { path: '/icons/backends/sqlite.svg' },
|
||||||
|
valkey: { path: '/icons/backends/valkey.svg' },
|
||||||
|
|
||||||
|
// Cloud / DNS providers with official icons
|
||||||
|
alibaba: { path: '/icons/backends/alibaba.svg' },
|
||||||
|
alibabacloud: { path: '/icons/backends/alibaba.svg' },
|
||||||
|
alidns: { path: '/icons/backends/alibaba.svg' },
|
||||||
|
amazonwebservices: { path: '/icons/backends/aws-light.svg' },
|
||||||
|
aws: { path: '/icons/backends/aws-light.svg' },
|
||||||
|
lightsail: { path: '/icons/backends/aws-light.svg' },
|
||||||
|
route53: { path: '/icons/backends/aws-light.svg' },
|
||||||
|
azure: { path: '/icons/backends/azure.ico', isIco: true },
|
||||||
|
azuredns: { path: '/icons/backends/azure.ico', isIco: true },
|
||||||
|
baidu: { path: '/icons/backends/baiducloud-color.svg' },
|
||||||
|
baiducloud: { path: '/icons/backends/baiducloud-color.svg' },
|
||||||
|
bunny: { path: '/icons/backends/bunny.svg' },
|
||||||
|
bunnynet: { path: '/icons/backends/bunny.svg' },
|
||||||
|
cloudflare: { path: '/icons/backends/cloudflare.svg' },
|
||||||
|
cpanel: { path: '/icons/backends/cpanel.svg' },
|
||||||
|
digitalocean: { path: '/icons/backends/digital-ocean.svg' },
|
||||||
|
dnsimple: { path: '/icons/backends/dnsimple.svg' },
|
||||||
|
dreamhost: { path: '/icons/backends/dream-host.svg' },
|
||||||
|
duckdns: { path: '/icons/backends/duckdns.svg' },
|
||||||
|
dynu: { path: '/icons/backends/dynu.png' },
|
||||||
|
gandi: { path: '/icons/backends/gandi.svg' },
|
||||||
|
gandiv5: { path: '/icons/backends/gandi.svg' },
|
||||||
|
godaddy: { path: '/icons/backends/godaddy.svg' },
|
||||||
|
google: { path: '/icons/backends/google.svg' },
|
||||||
|
googleclouddns: { path: '/icons/backends/google-cloud.svg' },
|
||||||
|
googlecloud: { path: '/icons/backends/google-cloud.svg' },
|
||||||
|
hetzner: { path: '/icons/backends/hetzner.svg' },
|
||||||
|
hostinger: { path: '/icons/backends/hostinger.svg' },
|
||||||
|
ibm: { path: '/icons/backends/ibm.svg' },
|
||||||
|
ibmcloud: { path: '/icons/backends/ibm.svg' },
|
||||||
|
ionos: { path: '/icons/backends/ionos.svg' },
|
||||||
|
linode: { path: '/icons/backends/linode.svg' },
|
||||||
|
namecheap: { path: '/icons/backends/namecheap.svg' },
|
||||||
|
netlify: { path: '/icons/backends/netlify.svg' },
|
||||||
|
oracle: { path: '/icons/backends/oracle-cloud.svg' },
|
||||||
|
oraclecloud: { path: '/icons/backends/oracle-cloud.svg' },
|
||||||
|
ovh: { path: '/icons/backends/ovh.svg' },
|
||||||
|
plesk: { path: '/icons/backends/plesk.svg' },
|
||||||
|
porkbun: { path: '/icons/backends/porkbun.png' },
|
||||||
|
scaleway: { path: '/icons/backends/scaleway.svg' },
|
||||||
|
tencent: { path: '/icons/backends/tencentcloud-color.svg' },
|
||||||
|
tencentcloud: { path: '/icons/backends/tencentcloud-color.svg' },
|
||||||
|
vercel: { path: '/icons/backends/vercel.svg' },
|
||||||
|
vultr: { path: '/icons/backends/vultr.svg' },
|
||||||
|
yandex: { path: '/icons/backends/yandex.svg' },
|
||||||
|
yandexcloud: { path: '/icons/backends/yandex.svg' },
|
||||||
|
};
|
||||||
|
|
||||||
|
interface BackendIconProps {
|
||||||
|
backend: string | null | undefined;
|
||||||
|
className?: string;
|
||||||
|
fallback?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BackendIcon({ backend, className, fallback = null }: BackendIconProps): React.ReactElement | null {
|
||||||
|
if (!backend) return null;
|
||||||
|
|
||||||
|
const key = backend.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||||
|
const icon = BACKEND_ICONS[key] ?? BACKEND_ICONS[backend.toLowerCase()];
|
||||||
|
if (!icon) return fallback as React.ReactElement | null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={icon.path}
|
||||||
|
alt={`${backend} icon`}
|
||||||
|
className={className ?? 'h-4 w-4 object-contain'}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BackendVariantIconProps {
|
||||||
|
variant: { name: string; label: string };
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BackendVariantIcon({ variant, className }: BackendVariantIconProps): React.ReactElement | null {
|
||||||
|
const nameLower = variant.name.toLowerCase();
|
||||||
|
const labelLower = variant.label?.toLowerCase() ?? '';
|
||||||
|
const isRedisValkey = nameLower === 'redis' && labelLower.includes('valkey');
|
||||||
|
if (isRedisValkey) {
|
||||||
|
return (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<BackendIcon backend="redis" className={className} />
|
||||||
|
<BackendIcon backend="valkey" className={className} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <BackendIcon backend={variant.name} className={className} />;
|
||||||
|
}
|
||||||
@@ -4,10 +4,17 @@
|
|||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo } from 'react';
|
import { useCallback, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
|
||||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
} from '@/components/ui/command';
|
||||||
import { friendlyName, getActionInfo, getObjectKind, useGlobalSearch } from '@/hooks/useGlobalSearch';
|
import { friendlyName, getActionInfo, getObjectKind, useGlobalSearch } from '@/hooks/useGlobalSearch';
|
||||||
import type { SearchIndexEntry } from '@/stores/schemaStore';
|
import type { SearchIndexEntry } from '@/stores/schemaStore';
|
||||||
|
|
||||||
@@ -19,16 +26,13 @@ interface CommandPaletteProps {
|
|||||||
export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
|
export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const closePalette = useCallback(() => onOpenChange(false), [onOpenChange]);
|
const closePalette = useCallback(() => onOpenChange(false), [onOpenChange]);
|
||||||
const { query, setQuery, debouncedQuery, groups, selectEntry, reset, schema } = useGlobalSearch(closePalette);
|
const { query, setQuery, results, groups, selectEntry, reset, schema } = useGlobalSearch(closePalette);
|
||||||
|
|
||||||
const groupLabels: Record<SearchIndexEntry['type'], string> = useMemo(
|
const GROUP_LABELS: Record<SearchIndexEntry['type'], string> = {
|
||||||
() => ({
|
|
||||||
link: t('globalSearch.pages', 'Pages'),
|
link: t('globalSearch.pages', 'Pages'),
|
||||||
form: t('globalSearch.formSections', 'Form Sections'),
|
form: t('globalSearch.formSections', 'Form Sections'),
|
||||||
field: t('globalSearch.fields', 'Fields'),
|
field: t('globalSearch.fields', 'Fields'),
|
||||||
}),
|
};
|
||||||
[t],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) reset();
|
if (!open) reset();
|
||||||
@@ -47,27 +51,26 @@ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
|
|||||||
placeholder={t('globalSearch.placeholder', 'Search pages, fields, settings...')}
|
placeholder={t('globalSearch.placeholder', 'Search pages, fields, settings...')}
|
||||||
value={query}
|
value={query}
|
||||||
onValueChange={setQuery}
|
onValueChange={setQuery}
|
||||||
trailing={
|
|
||||||
<kbd className="pointer-events-none ml-2 inline-flex h-5 shrink-0 select-none items-center rounded-md border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground">
|
|
||||||
ESC
|
|
||||||
</kbd>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<CommandList>
|
<CommandList>
|
||||||
<CommandEmpty>
|
<CommandEmpty>
|
||||||
{debouncedQuery.trim()
|
{query.trim()
|
||||||
? t('globalSearch.noResults', 'No results found.')
|
? t('globalSearch.noResults', 'No results found.')
|
||||||
: t('globalSearch.typeToSearch', 'Type to search the admin panel.')}
|
: t('globalSearch.typeToSearch', 'Type to search the admin panel.')}
|
||||||
</CommandEmpty>
|
</CommandEmpty>
|
||||||
{Array.from(groups.entries()).map(([type, entries]) => (
|
{Array.from(groups.entries()).map(([type, entries]) => (
|
||||||
<CommandGroup key={type} heading={groupLabels[type]}>
|
<CommandGroup key={type} heading={GROUP_LABELS[type]}>
|
||||||
{entries.map((entry, idx) => {
|
{entries.map((entry) => {
|
||||||
|
const flatIdx = results.indexOf(entry);
|
||||||
const objectKind = schema ? getObjectKind(schema, entry.viewName) : null;
|
const objectKind = schema ? getObjectKind(schema, entry.viewName) : null;
|
||||||
const { label: actionLabel, Icon: ActionIcon } = getActionInfo(entry.type, objectKind, t);
|
const { label: actionLabel, Icon: ActionIcon } = getActionInfo(entry.type, objectKind, t);
|
||||||
const itemValue = `${type}-${idx}-${entry.viewName}`;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CommandItem key={itemValue} value={itemValue} onSelect={() => selectEntry(entry)}>
|
<CommandItem
|
||||||
|
key={`${type}-${entry.viewName}-${flatIdx}`}
|
||||||
|
value={`${type}-${entry.viewName}-${flatIdx}`}
|
||||||
|
onSelect={() => selectEntry(entry)}
|
||||||
|
>
|
||||||
<ActionIcon className="mr-2 h-4 w-4 shrink-0 text-muted-foreground" />
|
<ActionIcon className="mr-2 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
<div className="flex flex-1 flex-col overflow-hidden">
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
<span className="truncate font-medium">{friendlyName(entry.text)}</span>
|
<span className="truncate font-medium">{friendlyName(entry.text)}</span>
|
||||||
|
|||||||
@@ -6,14 +6,19 @@
|
|||||||
|
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
export function LoadingFallback({ fullScreen = false }: { fullScreen?: boolean }) {
|
interface LoadingFallbackProps {
|
||||||
|
fullScreen?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoadingFallback({ fullScreen }: LoadingFallbackProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex flex-col items-center justify-center gap-3', fullScreen ? 'min-h-screen' : 'p-8')}>
|
<div className={`flex items-center justify-center ${fullScreen ? 'min-h-screen' : 'p-8'}`}>
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||||
<p className="text-muted-foreground">{t('common.loading')}</p>
|
<p className="text-muted-foreground">{t('common.loading')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,9 @@
|
|||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useEffect, useSyncExternalStore } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { getApiBaseUrl } from '@/services/api';
|
import { ensureLogoLoaded, getLogoSnapshot, subscribeLogo } from '@/lib/logoCache';
|
||||||
|
|
||||||
export function DefaultLogo() {
|
export function DefaultLogo() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -31,54 +31,18 @@ export function DefaultLogo() {
|
|||||||
|
|
||||||
export default function Logo() {
|
export default function Logo() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [logoUrl, setLogoUrl] = useState<string | null>(null);
|
const logo = useSyncExternalStore(subscribeLogo, getLogoSnapshot, getLogoSnapshot);
|
||||||
const [failed, setFailed] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const controller = new AbortController();
|
ensureLogoLoaded();
|
||||||
|
|
||||||
async function fetchLogo() {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${getApiBaseUrl()}/logo`, {
|
|
||||||
signal: controller.signal,
|
|
||||||
});
|
|
||||||
const contentType = response.headers.get('content-type') ?? '';
|
|
||||||
|
|
||||||
if (response.ok && contentType.startsWith('image/')) {
|
|
||||||
const blob = await response.blob();
|
|
||||||
if (!controller.signal.aborted) {
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
setLogoUrl(url);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (!controller.signal.aborted) setFailed(true);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
if (!controller.signal.aborted) setFailed(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchLogo();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
controller.abort();
|
|
||||||
};
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
if (logo.status === 'custom') {
|
||||||
return () => {
|
return <img src={logo.url} alt={t('logo.alt', 'Logo')} className="h-7 w-auto max-w-[220px] object-contain" />;
|
||||||
if (logoUrl) {
|
|
||||||
URL.revokeObjectURL(logoUrl);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [logoUrl]);
|
|
||||||
|
|
||||||
if (logoUrl && !failed) {
|
|
||||||
return <img src={logoUrl} alt={t('logo.alt', 'Logo')} className="h-7 w-auto max-w-[220px] object-contain" />;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!failed) {
|
if (logo.status === 'loading') {
|
||||||
return <div className="h-7" aria-hidden="true" />;
|
return <span className="h-7 w-[140px] block" aria-hidden="true" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <DefaultLogo />;
|
return <DefaultLogo />;
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Moon, Sun } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useUIStore } from '@/stores/uiStore';
|
||||||
|
|
||||||
|
// Single-click light/dark toggle. Color themes and corner radius live on the
|
||||||
|
// Appearance settings page instead of a dropdown.
|
||||||
|
export function ModeToggle() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const theme = useUIStore((s) => s.theme);
|
||||||
|
const setTheme = useUIStore((s) => s.setTheme);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}
|
||||||
|
aria-label={
|
||||||
|
theme === 'light'
|
||||||
|
? t('appearance.switchToDark', 'Switch to dark mode')
|
||||||
|
: t('appearance.switchToLight', 'Switch to light mode')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{theme === 'light' ? <Moon className="h-4 w-4" /> : <Sun className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Info } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
import { formatSize } from '@/lib/durationFormat';
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
|
|
||||||
|
interface SizeDisplayProps {
|
||||||
|
bytes: number;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a byte size. Negative values (stale Stalwart quota counters) are
|
||||||
|
* shown in red with an info tooltip pointing admins at recalculateQuota.
|
||||||
|
*/
|
||||||
|
export function SizeDisplay({ bytes, className }: SizeDisplayProps): ReactNode {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const label = Number.isFinite(bytes) ? formatSize(bytes) : formatSize(0);
|
||||||
|
|
||||||
|
if (!Number.isFinite(bytes) || bytes >= 0) {
|
||||||
|
return <span className={className}>{label}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center gap-1.5 ${className ?? ''}`.trim()}>
|
||||||
|
<span className="font-medium text-destructive">{label}</span>
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex shrink-0 text-destructive hover:text-destructive/80 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
|
aria-label={t('list.negativeQuotaInfoAria', 'Why is disk usage negative?')}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Info className="h-3.5 w-3.5" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top" className="max-w-xs text-left">
|
||||||
|
{t(
|
||||||
|
'list.negativeQuotaTooltip',
|
||||||
|
'This disk-usage counter is out of sync (often after a migration or reset). Schedule a task: Perform account maintenance operations → Recalculate storage quota usage for the account. Or for all accounts: Perform store maintenance operations → Reset all user quotas.',
|
||||||
|
)}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select';
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -53,6 +53,7 @@ import { SECRET_MASK } from '@/lib/jmapUtils';
|
|||||||
import { toast } from '@/hooks/use-toast';
|
import { toast } from '@/hooks/use-toast';
|
||||||
import { logFormChange } from '@/lib/debug';
|
import { logFormChange } from '@/lib/debug';
|
||||||
import { FieldWidget } from '@/components/forms/FieldWidget';
|
import { FieldWidget } from '@/components/forms/FieldWidget';
|
||||||
|
import { BackendVariantIcon } from '@/components/common/BackendIcon';
|
||||||
|
|
||||||
import type { Field, Fields, Form, FormField, Schema } from '@/types/schema';
|
import type { Field, Fields, Form, FormField, Schema } from '@/types/schema';
|
||||||
import type { JmapSetResponse, JmapSetError, JmapMethodCall } from '@/types/jmap';
|
import type { JmapSetResponse, JmapSetError, JmapMethodCall } from '@/types/jmap';
|
||||||
@@ -742,7 +743,7 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
|
|||||||
const sectionsToRender = buildSections(combinedForm, currentFields, isCreate, edition);
|
const sectionsToRender = buildSections(combinedForm, currentFields, isCreate, edition);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-4xl space-y-6">
|
<div className="mx-auto w-full min-w-0 max-w-4xl space-y-6">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||||
<ArrowLeft className="h-5 w-5" />
|
<ArrowLeft className="h-5 w-5" />
|
||||||
@@ -773,17 +774,26 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
|
|||||||
if (!visible) return null;
|
if (!visible) return null;
|
||||||
|
|
||||||
if (formField.name === '@type' && sch.type === 'multiple') {
|
if (formField.name === '@type' && sch.type === 'multiple') {
|
||||||
|
const selectedVariantLabel = sch.variants.find((v) => v.name === selectedVariant)?.label;
|
||||||
return (
|
return (
|
||||||
<div key="@type" className="space-y-1.5">
|
<div key="@type" className="space-y-1.5">
|
||||||
<Label className="text-sm font-medium">{formField.label}</Label>
|
<Label className="text-sm font-medium">{formField.label}</Label>
|
||||||
<Select value={selectedVariant} onValueChange={handleVariantChange} disabled={readOnly}>
|
<Select value={selectedVariant} onValueChange={handleVariantChange} disabled={readOnly}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder={t('form.selectType', 'Select type...')} />
|
<div className="flex flex-1 items-center gap-2 overflow-hidden">
|
||||||
|
<BackendVariantIcon variant={{ name: selectedVariant, label: selectedVariantLabel ?? selectedVariant }} />
|
||||||
|
<span className="truncate">
|
||||||
|
{selectedVariantLabel || t('form.selectType', 'Select type...')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{sch.variants.map((v) => (
|
{sch.variants.map((v) => (
|
||||||
<SelectItem key={v.name} value={v.name}>
|
<SelectItem key={v.name} value={v.name}>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<BackendVariantIcon variant={v} />
|
||||||
{v.label}
|
{v.label}
|
||||||
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -835,7 +845,7 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
|
|||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<div className="flex items-center justify-between pt-2 pb-8">
|
<div className="flex flex-col-reverse gap-3 pt-2 pb-8 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
{canDelete && (
|
{canDelete && (
|
||||||
<Button type="button" variant="destructive" disabled={saving} onClick={() => setDeleteConfirmOpen(true)}>
|
<Button type="button" variant="destructive" disabled={saving} onClick={() => setDeleteConfirmOpen(true)}>
|
||||||
@@ -844,7 +854,7 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex flex-wrap items-center justify-end gap-3">
|
||||||
{isDirty && (
|
{isDirty && (
|
||||||
<span className="text-xs text-muted-foreground">{t('form.unsavedChangesLabel', 'Unsaved changes')}</span>
|
<span className="text-xs text-muted-foreground">{t('form.unsavedChangesLabel', 'Unsaved changes')}</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
@@ -22,19 +23,19 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
|
|||||||
import { Combobox, type ComboboxOption } from '@/components/ui/combobox';
|
import { Combobox, type ComboboxOption } from '@/components/ui/combobox';
|
||||||
import { Calendar } from '@/components/ui/calendar';
|
import { Calendar } from '@/components/ui/calendar';
|
||||||
|
|
||||||
import { Plus, X, Eye, EyeOff, Loader2, Search, Check, ChevronRight, Calendar as CalendarIcon } from 'lucide-react';
|
import { Plus, X, Eye, EyeOff, Loader2, Search, Check, ChevronRight, Calendar as CalendarIcon, Clock } from 'lucide-react';
|
||||||
|
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||||
|
|
||||||
import { ExpressionEditor } from '@/components/expression/ExpressionEditor';
|
import { ExpressionEditor } from '@/components/expression/ExpressionEditor';
|
||||||
import { OtpAuthField } from '@/components/forms/OtpAuthField';
|
import { OtpAuthField } from '@/components/forms/OtpAuthField';
|
||||||
|
import { SizeDisplay } from '@/components/common/SizeDisplay';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
bytesToHuman,
|
bytesToHuman,
|
||||||
humanToBytes,
|
humanToBytes,
|
||||||
msToHuman,
|
msToHuman,
|
||||||
humanToMs,
|
humanToMs,
|
||||||
formatSize,
|
|
||||||
formatDuration,
|
formatDuration,
|
||||||
SIZE_UNITS,
|
SIZE_UNITS,
|
||||||
DURATION_UNITS,
|
DURATION_UNITS,
|
||||||
@@ -44,6 +45,7 @@ import { cn } from '@/lib/utils';
|
|||||||
import { useAccountStore } from '@/stores/accountStore';
|
import { useAccountStore } from '@/stores/accountStore';
|
||||||
import { useEffectiveEdition } from '@/components/forms/FormEditionContext';
|
import { useEffectiveEdition } from '@/components/forms/FormEditionContext';
|
||||||
import { useObjectList, useObjectLabel, useNoPermissionMessage, type ObjectOption } from '@/lib/objectOptions';
|
import { useObjectList, useObjectLabel, useNoPermissionMessage, type ObjectOption } from '@/lib/objectOptions';
|
||||||
|
import { BackendVariantIcon } from '@/components/common/BackendIcon';
|
||||||
import { SECRET_MASK } from '@/lib/jmapUtils';
|
import { SECRET_MASK } from '@/lib/jmapUtils';
|
||||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
|
||||||
import { jmapGet, getAccountId } from '@/services/jmap/client';
|
import { jmapGet, getAccountId } from '@/services/jmap/client';
|
||||||
@@ -400,7 +402,7 @@ function StringField({
|
|||||||
value={strValue || '#000000'}
|
value={strValue || '#000000'}
|
||||||
onChange={(e) => handleCommit(e.target.value)}
|
onChange={(e) => handleCommit(e.target.value)}
|
||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
className="h-9 w-14 cursor-pointer rounded-md border border-input bg-transparent p-1"
|
className="h-9 w-14 cursor-pointer rounded-md border border-input bg-field p-1"
|
||||||
/>
|
/>
|
||||||
<BufferedInput
|
<BufferedInput
|
||||||
value={strValue}
|
value={strValue}
|
||||||
@@ -649,7 +651,11 @@ function SizeInput({ value, onChange, readOnly, nullable }: SizeInputProps) {
|
|||||||
if (readOnly) {
|
if (readOnly) {
|
||||||
if (value == null)
|
if (value == null)
|
||||||
return <span className="text-sm text-muted-foreground italic">{t('field.notSet', 'Not set')}</span>;
|
return <span className="text-sm text-muted-foreground italic">{t('field.notSet', 'Not set')}</span>;
|
||||||
return <span className="text-sm">{formatSize(value as number)}</span>;
|
return (
|
||||||
|
<span className="text-sm">
|
||||||
|
<SizeDisplay bytes={value as number} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return <SizeInputEditable value={value} onChange={onChange} nullable={nullable} />;
|
return <SizeInputEditable value={value} onChange={onChange} nullable={nullable} />;
|
||||||
}
|
}
|
||||||
@@ -833,65 +839,166 @@ interface DateTimeFieldProps {
|
|||||||
nullable?: boolean;
|
nullable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DATE_TIME_FORMAT = new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' });
|
const TIME_HOURS = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0'));
|
||||||
|
const TIME_MINUTES = Array.from({ length: 60 }, (_, i) => String(i).padStart(2, '0'));
|
||||||
|
|
||||||
|
/** Themed hour/minute selects — avoids the native time picker which ignores app dark/light tokens. */
|
||||||
|
function DateTimeTimeSelect({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [hour = '00', minute = '00'] = value.split(':');
|
||||||
|
|
||||||
|
const update = (nextHour: string, nextMinute: string) => {
|
||||||
|
onChange(`${nextHour}:${nextMinute}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 items-center gap-1.5">
|
||||||
|
<Select
|
||||||
|
value={TIME_HOURS.includes(hour) ? hour : '00'}
|
||||||
|
disabled={disabled}
|
||||||
|
onValueChange={(nextHour) => update(nextHour, TIME_MINUTES.includes(minute) ? minute : '00')}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-9 w-[4.75rem]" aria-label={t('field.hour', 'Hour')}>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="max-h-60" position="popper">
|
||||||
|
{TIME_HOURS.map((h) => (
|
||||||
|
<SelectItem key={h} value={h}>
|
||||||
|
{h}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<span className="text-sm text-muted-foreground" aria-hidden="true">
|
||||||
|
:
|
||||||
|
</span>
|
||||||
|
<Select
|
||||||
|
value={TIME_MINUTES.includes(minute) ? minute : '00'}
|
||||||
|
disabled={disabled}
|
||||||
|
onValueChange={(nextMinute) => update(TIME_HOURS.includes(hour) ? hour : '00', nextMinute)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-9 w-[4.75rem]" aria-label={t('field.minute', 'Minute')}>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="max-h-60" position="popper">
|
||||||
|
{TIME_MINUTES.map((m) => (
|
||||||
|
<SelectItem key={m} value={m}>
|
||||||
|
{m}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function DateTimeField({ value, onChange, readOnly, nullable }: DateTimeFieldProps) {
|
function DateTimeField({ value, onChange, readOnly, nullable }: DateTimeFieldProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const strValue = typeof value === 'string' ? value : '';
|
const strValue = typeof value === 'string' ? value : '';
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
const selected = useMemo(() => {
|
const parsed = useMemo(() => {
|
||||||
const d = strValue ? new Date(strValue) : null;
|
if (!strValue) return null;
|
||||||
return d && !isNaN(d.getTime()) ? d : null;
|
const d = new Date(strValue);
|
||||||
|
return isNaN(d.getTime()) ? null : d;
|
||||||
}, [strValue]);
|
}, [strValue]);
|
||||||
|
|
||||||
const localTime = selected ? selected.toTimeString().slice(0, 5) : '';
|
const timeValue = parsed
|
||||||
|
? `${String(parsed.getHours()).padStart(2, '0')}:${String(parsed.getMinutes()).padStart(2, '0')}`
|
||||||
|
: '';
|
||||||
|
|
||||||
const commit = (day: Date, time: string) => {
|
// Date and time are committed together so the field never holds a
|
||||||
|
// half-entered value the way a free-form datetime input does.
|
||||||
|
const commit = (date: Date, time: string) => {
|
||||||
const [hours, minutes] = time.split(':').map(Number);
|
const [hours, minutes] = time.split(':').map(Number);
|
||||||
const next = new Date(day);
|
const next = new Date(date);
|
||||||
next.setHours(hours || 0, minutes || 0, 0, 0);
|
next.setHours(Number.isFinite(hours) ? hours : 0, Number.isFinite(minutes) ? minutes : 0, 0, 0);
|
||||||
onChange(next.toISOString());
|
onChange(next.toISOString());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const formatClock = (d: Date): string =>
|
||||||
|
`${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||||
|
|
||||||
|
/** Empty fields open on "now" so the calendar and time selects start useful. */
|
||||||
|
const handleOpenChange = (next: boolean) => {
|
||||||
|
setOpen(next);
|
||||||
|
if (next && !parsed) {
|
||||||
|
const now = new Date();
|
||||||
|
now.setSeconds(0, 0);
|
||||||
|
onChange(now.toISOString());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (readOnly) {
|
if (readOnly) {
|
||||||
if (!strValue) {
|
if (!strValue) {
|
||||||
return <span className="text-sm text-muted-foreground italic">{t('field.notSet', 'Not set')}</span>;
|
return <span className="text-sm text-muted-foreground italic">{t('field.notSet', 'Not set')}</span>;
|
||||||
}
|
}
|
||||||
return <span className="text-sm">{selected ? DATE_TIME_FORMAT.format(selected) : strValue}</span>;
|
let formatted = strValue;
|
||||||
|
try {
|
||||||
|
const d = new Date(strValue);
|
||||||
|
if (!isNaN(d.getTime())) {
|
||||||
|
formatted = new Intl.DateTimeFormat(undefined, {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeStyle: 'short',
|
||||||
|
}).format(d);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line no-empty
|
||||||
|
} catch {}
|
||||||
|
return <span className="text-sm">{formatted}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Popover>
|
<Popover open={open} onOpenChange={handleOpenChange} modal={false}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn('flex-1 justify-start text-left font-normal', !selected && 'text-muted-foreground')}
|
className={cn('flex-1 justify-start text-left font-normal', !parsed && 'text-muted-foreground')}
|
||||||
>
|
>
|
||||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||||
{selected ? DATE_TIME_FORMAT.format(selected) : t('field.pickDate', 'Pick a date')}
|
{parsed
|
||||||
|
? new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(parsed)
|
||||||
|
: t('field.pickDate', 'Pick a date')}
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-auto p-0" align="start">
|
<PopoverContent
|
||||||
|
className="w-auto p-0"
|
||||||
|
align="start"
|
||||||
|
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||||
|
onInteractOutside={(e) => {
|
||||||
|
const target = e.target as HTMLElement | null;
|
||||||
|
if (target?.closest('[data-radix-select-content]')) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Calendar
|
<Calendar
|
||||||
mode="single"
|
mode="single"
|
||||||
selected={selected ?? undefined}
|
selected={parsed ?? undefined}
|
||||||
defaultMonth={selected ?? undefined}
|
defaultMonth={parsed ?? new Date()}
|
||||||
autoFocus
|
|
||||||
onSelect={(day) => {
|
onSelect={(day) => {
|
||||||
if (day) commit(day, localTime || '00:00');
|
if (!day) return;
|
||||||
|
commit(day, timeValue || formatClock(new Date()));
|
||||||
}}
|
}}
|
||||||
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<div className="border-t p-3">
|
<div className="flex items-center gap-2 border-t bg-background p-3">
|
||||||
<Input
|
<Clock className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||||
type="time"
|
<DateTimeTimeSelect
|
||||||
value={localTime}
|
value={timeValue || formatClock(new Date())}
|
||||||
disabled={!selected}
|
disabled={!parsed}
|
||||||
onChange={(e) => {
|
onChange={(next) => {
|
||||||
if (selected && e.target.value) commit(selected, e.target.value);
|
if (parsed) commit(parsed, next);
|
||||||
}}
|
}}
|
||||||
aria-label={t('field.time', 'Time')}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
@@ -1449,18 +1556,28 @@ function EmbeddedObjectField({
|
|||||||
onChange(newObj);
|
onChange(newObj);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const selectedVariantLabel = resolvedSchema.variants.find((v) => v.name === currentType)?.label;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md border p-4 space-y-4">
|
<div className="rounded-md border p-4 space-y-4">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-sm font-medium">{formField.label ?? t('field.type', 'Type')}</Label>
|
<Label className="text-sm font-medium">{formField.label ?? t('field.type', 'Type')}</Label>
|
||||||
<Select value={currentType} onValueChange={handleVariantChange} disabled={readOnly}>
|
<Select value={currentType} onValueChange={handleVariantChange} disabled={readOnly}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder={t('form.selectType', 'Select type...')} />
|
<div className="flex flex-1 items-center gap-2 overflow-hidden">
|
||||||
|
<BackendVariantIcon variant={{ name: currentType, label: selectedVariantLabel ?? currentType }} />
|
||||||
|
<span className="truncate">
|
||||||
|
{selectedVariantLabel || t('form.selectType', 'Select type...')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{resolvedSchema.variants.map((v) => (
|
{resolvedSchema.variants.map((v) => (
|
||||||
<SelectItem key={v.name} value={v.name}>
|
<SelectItem key={v.name} value={v.name}>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<BackendVariantIcon variant={v} />
|
||||||
{v.label}
|
{v.label}
|
||||||
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -1842,7 +1959,8 @@ function EnumMultiSelect({ enumName, items, onChange, readOnly, schema, minItems
|
|||||||
className="h-8"
|
className="h-8"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-60 overflow-y-auto p-2 space-y-1">
|
<ScrollArea viewportClassName="max-h-60">
|
||||||
|
<div className="p-2 space-y-1">
|
||||||
{filtered.length === 0 && (
|
{filtered.length === 0 && (
|
||||||
<p className="text-sm text-muted-foreground text-center py-2">{t('field.noMatches', 'No matches')}</p>
|
<p className="text-sm text-muted-foreground text-center py-2">{t('field.noMatches', 'No matches')}</p>
|
||||||
)}
|
)}
|
||||||
@@ -1871,6 +1989,7 @@ function EnumMultiSelect({ enumName, items, onChange, readOnly, schema, minItems
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { lazy, Suspense, useEffect, type ComponentType, type ReactNode } from 'react';
|
import { lazy, Suspense, useEffect } from 'react';
|
||||||
import { useSchemaStore } from '@/stores/schemaStore';
|
import { useSchemaStore } from '@/stores/schemaStore';
|
||||||
import { useCacheStore } from '@/stores/cacheStore';
|
import { useCacheStore } from '@/stores/cacheStore';
|
||||||
import { useAccountStore } from '@/stores/accountStore';
|
import { useAccountStore } from '@/stores/accountStore';
|
||||||
@@ -13,32 +13,22 @@ import { DynamicList } from '@/components/lists/DynamicList';
|
|||||||
import { DynamicForm } from '@/components/forms/DynamicForm';
|
import { DynamicForm } from '@/components/forms/DynamicForm';
|
||||||
import { DynamicViewPage } from '@/components/views/DynamicViewPage';
|
import { DynamicViewPage } from '@/components/views/DynamicViewPage';
|
||||||
import { LoadingFallback } from '@/components/common/LoadingFallback';
|
import { LoadingFallback } from '@/components/common/LoadingFallback';
|
||||||
import type { Schema } from '@/types/schema';
|
|
||||||
|
|
||||||
function lazyFeature<M, P>(load: () => Promise<M>, select: (module: M) => ComponentType<P>) {
|
// Heavy or rarely used feature pages are code-split so the initial bundle
|
||||||
return lazy(() => load().then((module) => ({ default: select(module) })));
|
// stays small (the dashboard pulls in recharts, ~150 kB gzipped on its own).
|
||||||
}
|
const DashboardView = lazy(() =>
|
||||||
|
import('@/features/dashboard/components/DashboardView').then((m) => ({ default: m.DashboardView })),
|
||||||
const DashboardView = lazyFeature(
|
|
||||||
() => import('@/features/dashboard/components/DashboardView'),
|
|
||||||
(m) => m.DashboardView,
|
|
||||||
);
|
);
|
||||||
const DeliveryTracePage = lazyFeature(
|
const DeliveryTracePage = lazy(() =>
|
||||||
() => import('@/features/troubleshoot/DeliveryTracePage'),
|
import('@/features/troubleshoot/DeliveryTracePage').then((m) => ({ default: m.DeliveryTracePage })),
|
||||||
(m) => m.DeliveryTracePage,
|
|
||||||
);
|
);
|
||||||
const LiveTracingPage = lazyFeature(
|
const LiveTracingPage = lazy(() =>
|
||||||
() => import('@/features/tracing/components/LiveTracingPage'),
|
import('@/features/tracing/components/LiveTracingPage').then((m) => ({ default: m.LiveTracingPage })),
|
||||||
(m) => m.LiveTracingPage,
|
|
||||||
);
|
);
|
||||||
const TraceDetailView = lazyFeature(
|
const TraceDetailView = lazy(() =>
|
||||||
() => import('@/features/tracing/components/TraceDetailView'),
|
import('@/features/tracing/components/TraceDetailView').then((m) => ({ default: m.TraceDetailView })),
|
||||||
(m) => m.TraceDetailView,
|
|
||||||
);
|
|
||||||
const ActionPage = lazyFeature(
|
|
||||||
() => import('@/features/actions/ActionPage'),
|
|
||||||
(m) => m.ActionPage,
|
|
||||||
);
|
);
|
||||||
|
const ActionPage = lazy(() => import('@/features/actions/ActionPage').then((m) => ({ default: m.ActionPage })));
|
||||||
|
|
||||||
interface MainContentProps {
|
interface MainContentProps {
|
||||||
viewName?: string;
|
viewName?: string;
|
||||||
@@ -47,6 +37,14 @@ interface MainContentProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function MainContent({ viewName, id, section }: MainContentProps) {
|
export function MainContent({ viewName, id, section }: MainContentProps) {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<LoadingFallback />}>
|
||||||
|
<MainContentView viewName={viewName} id={id} section={section} />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MainContentView({ viewName, id, section }: MainContentProps) {
|
||||||
const schema = useSchemaStore((s) => s.schema);
|
const schema = useSchemaStore((s) => s.schema);
|
||||||
const invalidateAllObjectLists = useCacheStore((s) => s.invalidateAllObjectLists);
|
const invalidateAllObjectLists = useCacheStore((s) => s.invalidateAllObjectLists);
|
||||||
|
|
||||||
@@ -54,10 +52,6 @@ export function MainContent({ viewName, id, section }: MainContentProps) {
|
|||||||
invalidateAllObjectLists();
|
invalidateAllObjectLists();
|
||||||
}, [viewName, invalidateAllObjectLists]);
|
}, [viewName, invalidateAllObjectLists]);
|
||||||
|
|
||||||
return <Suspense fallback={<LoadingFallback />}>{renderView(schema, viewName, id, section)}</Suspense>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderView(schema: Schema | null, viewName?: string, id?: string, section?: string): ReactNode {
|
|
||||||
if (!viewName) {
|
if (!viewName) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center p-8 text-muted-foreground">Select a view from the sidebar.</div>
|
<div className="flex items-center justify-center p-8 text-muted-foreground">Select a view from the sidebar.</div>
|
||||||
@@ -110,7 +104,7 @@ function renderView(schema: Schema | null, viewName?: string, id?: string, secti
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (id) {
|
if (id) {
|
||||||
if (resolved.objectName === 'x:Trace') {
|
if (resolved.objectName === 'x:Trace' && id !== 'new') {
|
||||||
return <TraceDetailView viewName={viewName} objectId={id} />;
|
return <TraceDetailView viewName={viewName} objectId={id} />;
|
||||||
}
|
}
|
||||||
const canUpdate = useAccountStore.getState().hasObjectPermission(resolved.permissionPrefix, 'Update');
|
const canUpdate = useAccountStore.getState().hasObjectPermission(resolved.permissionPrefix, 'Update');
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { createContext, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
import * as LucideIcons from 'lucide-react';
|
import * as LucideIcons from 'lucide-react';
|
||||||
const { ChevronDown, Lock } = LucideIcons;
|
const { ChevronDown, Lock } = LucideIcons;
|
||||||
@@ -12,6 +12,7 @@ import { cn } from '@/lib/utils';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { EnterpriseUpsell } from '@/components/common/EnterpriseUpsell';
|
import { EnterpriseUpsell } from '@/components/common/EnterpriseUpsell';
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import { useUIStore } from '@/stores/uiStore';
|
import { useUIStore } from '@/stores/uiStore';
|
||||||
import { useAccountStore } from '@/stores/accountStore';
|
import { useAccountStore } from '@/stores/accountStore';
|
||||||
@@ -23,6 +24,7 @@ import {
|
|||||||
isLinkEnterprise,
|
isLinkEnterprise,
|
||||||
isLinkVisible,
|
isLinkVisible,
|
||||||
} from '@/lib/layout';
|
} from '@/lib/layout';
|
||||||
|
import { findLastVisitedLinkInLayout, setLastVisitedSection } from '@/lib/lastVisited';
|
||||||
import type { Layout, LayoutItem, LayoutSubItem } from '@/types/schema';
|
import type { Layout, LayoutItem, LayoutSubItem } from '@/types/schema';
|
||||||
|
|
||||||
function LucideIcon({ name, className }: { name: string; className?: string }) {
|
function LucideIcon({ name, className }: { name: string; className?: string }) {
|
||||||
@@ -74,25 +76,57 @@ function subtreeHasVisibleLink(items: LayoutSubItem[], edition: string): boolean
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AutoOpenCollapsibleProps {
|
interface AccordionLevelContextValue {
|
||||||
containsActive: boolean;
|
openId: string | null;
|
||||||
children: React.ReactNode;
|
setOpenId: (id: string | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function AutoOpenCollapsible({ containsActive, children }: AutoOpenCollapsibleProps) {
|
const AccordionLevelContext = createContext<AccordionLevelContextValue | null>(null);
|
||||||
const [open, setOpen] = useState(containsActive);
|
|
||||||
const [prevContainsActive, setPrevContainsActive] = useState(containsActive);
|
// Sibling collapsibles share a single open id, so expanding one collapses the
|
||||||
if (containsActive !== prevContainsActive) {
|
// others at the same level (accordion behavior).
|
||||||
setPrevContainsActive(containsActive);
|
function AccordionLevel({ children }: { children: React.ReactNode }) {
|
||||||
if (containsActive) setOpen(true);
|
const [openId, setOpenId] = useState<string | null>(null);
|
||||||
|
const value = useMemo(() => ({ openId, setOpenId }), [openId]);
|
||||||
|
return <AccordionLevelContext.Provider value={value}>{children}</AccordionLevelContext.Provider>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A collapsible wired to its accordion level. It opens itself whenever the
|
||||||
|
// active page lands inside it, while still allowing manual toggling.
|
||||||
|
function AccordionCollapsible({
|
||||||
|
id,
|
||||||
|
containsActive,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
containsActive: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const level = useContext(AccordionLevelContext);
|
||||||
|
if (!level) throw new Error('AccordionCollapsible must be used within AccordionLevel');
|
||||||
|
const { openId, setOpenId } = level;
|
||||||
|
// Layout effect so the branch containing the active page is already open on
|
||||||
|
// the first paint after a navigation.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (containsActive) setOpenId(id);
|
||||||
|
}, [containsActive, id, setOpenId]);
|
||||||
return (
|
return (
|
||||||
<Collapsible open={open} onOpenChange={setOpen}>
|
<Collapsible open={openId === id} onOpenChange={(open) => setOpenId(open ? id : null)}>
|
||||||
{children}
|
{children}
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Softer than the default ghost hover, closer to documentation sidebars:
|
||||||
|
// muted text that brightens with a faint background instead of a strong fill.
|
||||||
|
const sidebarItemClass =
|
||||||
|
'w-full justify-start gap-2 font-normal text-muted-foreground hover:bg-accent/50 hover:text-foreground';
|
||||||
|
|
||||||
|
// In square mode the sidebar follows documentation conventions (better-auth):
|
||||||
|
// full-bleed rows with a barely-there hover wash instead of inset pills.
|
||||||
|
const sidebarItemSquareClass =
|
||||||
|
"[[data-radius='square']_&]:hover:bg-foreground/[0.03] [[data-radius='square']_&]:hover:text-foreground/90";
|
||||||
|
|
||||||
function checkLinkVisible(viewName: string): boolean {
|
function checkLinkVisible(viewName: string): boolean {
|
||||||
const schema = useSchemaStore.getState().schema;
|
const schema = useSchemaStore.getState().schema;
|
||||||
if (!schema) return true;
|
if (!schema) return true;
|
||||||
@@ -114,8 +148,6 @@ function checkIsEnterprise(viewName: string): boolean {
|
|||||||
return isLinkEnterprise(schema, viewName, edition);
|
return isLinkEnterprise(schema, viewName, edition);
|
||||||
}
|
}
|
||||||
|
|
||||||
type ActiveItemRef = (el: HTMLButtonElement | null) => void;
|
|
||||||
|
|
||||||
interface SidebarSubItemProps {
|
interface SidebarSubItemProps {
|
||||||
item: LayoutSubItem;
|
item: LayoutSubItem;
|
||||||
depth: number;
|
depth: number;
|
||||||
@@ -124,19 +156,14 @@ interface SidebarSubItemProps {
|
|||||||
navigate: ReturnType<typeof useNavigate>;
|
navigate: ReturnType<typeof useNavigate>;
|
||||||
edition: string;
|
edition: string;
|
||||||
onUpsell: () => void;
|
onUpsell: () => void;
|
||||||
activeItemRef: ActiveItemRef;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarSubItem({
|
function SidebarSubItem({ item, depth, sectionName, currentPath, navigate, edition, onUpsell }: SidebarSubItemProps) {
|
||||||
item,
|
// Picking a plain link closes any sibling group left open at this same
|
||||||
depth,
|
// accordion level — it's not part of a collapsible, so nothing should
|
||||||
sectionName,
|
// stay expanded on its account once it's the one that's active.
|
||||||
currentPath,
|
const level = useContext(AccordionLevelContext);
|
||||||
navigate,
|
|
||||||
edition,
|
|
||||||
onUpsell,
|
|
||||||
activeItemRef,
|
|
||||||
}: SidebarSubItemProps) {
|
|
||||||
if (item.type === 'link') {
|
if (item.type === 'link') {
|
||||||
if (!checkLinkVisible(item.viewName)) return null;
|
if (!checkLinkVisible(item.viewName)) return null;
|
||||||
|
|
||||||
@@ -151,10 +178,11 @@ function SidebarSubItem({
|
|||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
ref={isActive ? activeItemRef : undefined}
|
data-sidebar-active={isActive || undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full justify-start gap-2 font-normal',
|
sidebarItemClass,
|
||||||
isActive && 'bg-accent text-accent-foreground',
|
sidebarItemSquareClass,
|
||||||
|
isActive && 'bg-accent text-accent-foreground hover:bg-accent',
|
||||||
depth > 0 && 'text-sm',
|
depth > 0 && 'text-sm',
|
||||||
)}
|
)}
|
||||||
style={{ paddingLeft: `${(depth + 1) * 12 + 8}px` }}
|
style={{ paddingLeft: `${(depth + 1) * 12 + 8}px` }}
|
||||||
@@ -162,6 +190,8 @@ function SidebarSubItem({
|
|||||||
if (isLocked) {
|
if (isLocked) {
|
||||||
onUpsell();
|
onUpsell();
|
||||||
} else {
|
} else {
|
||||||
|
level?.setOpenId(null);
|
||||||
|
setLastVisitedSection(sectionName, item.viewName);
|
||||||
navigate(path);
|
navigate(path);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -177,11 +207,11 @@ function SidebarSubItem({
|
|||||||
|
|
||||||
const containsActive = subtreeContainsActive(item.items, currentPath, sectionName);
|
const containsActive = subtreeContainsActive(item.items, currentPath, sectionName);
|
||||||
return (
|
return (
|
||||||
<AutoOpenCollapsible containsActive={containsActive}>
|
<AccordionCollapsible id={item.name} containsActive={containsActive}>
|
||||||
<CollapsibleTrigger asChild>
|
<CollapsibleTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="w-full justify-start gap-2 font-normal text-sm"
|
className={cn(sidebarItemClass, sidebarItemSquareClass, 'text-sm')}
|
||||||
style={{ paddingLeft: `${(depth + 1) * 12 + 8}px` }}
|
style={{ paddingLeft: `${(depth + 1) * 12 + 8}px` }}
|
||||||
>
|
>
|
||||||
<ChevronDown className="h-3 w-3 shrink-0 transition-transform duration-200 [[data-state=closed]>&]:rotate-[-90deg]" />
|
<ChevronDown className="h-3 w-3 shrink-0 transition-transform duration-200 [[data-state=closed]>&]:rotate-[-90deg]" />
|
||||||
@@ -189,6 +219,7 @@ function SidebarSubItem({
|
|||||||
</Button>
|
</Button>
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
<CollapsibleContent>
|
<CollapsibleContent>
|
||||||
|
<AccordionLevel>
|
||||||
{item.items.map((sub) => (
|
{item.items.map((sub) => (
|
||||||
<SidebarSubItem
|
<SidebarSubItem
|
||||||
key={sub.type === 'link' ? sub.viewName : sub.name}
|
key={sub.type === 'link' ? sub.viewName : sub.name}
|
||||||
@@ -199,11 +230,11 @@ function SidebarSubItem({
|
|||||||
navigate={navigate}
|
navigate={navigate}
|
||||||
edition={edition}
|
edition={edition}
|
||||||
onUpsell={onUpsell}
|
onUpsell={onUpsell}
|
||||||
activeItemRef={activeItemRef}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
</AccordionLevel>
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</AutoOpenCollapsible>
|
</AccordionCollapsible>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,18 +248,14 @@ interface SidebarTopItemProps {
|
|||||||
navigate: ReturnType<typeof useNavigate>;
|
navigate: ReturnType<typeof useNavigate>;
|
||||||
edition: string;
|
edition: string;
|
||||||
onUpsell: () => void;
|
onUpsell: () => void;
|
||||||
activeItemRef: ActiveItemRef;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarTopItem({
|
function SidebarTopItem({ item, sectionName, currentPath, navigate, edition, onUpsell }: SidebarTopItemProps) {
|
||||||
item,
|
// Picking a plain link closes any sibling group left open at this same
|
||||||
sectionName,
|
// accordion level — it's not part of a collapsible, so nothing should
|
||||||
currentPath,
|
// stay expanded on its account once it's the one that's active.
|
||||||
navigate,
|
const level = useContext(AccordionLevelContext);
|
||||||
edition,
|
|
||||||
onUpsell,
|
|
||||||
activeItemRef,
|
|
||||||
}: SidebarTopItemProps) {
|
|
||||||
if ('link' in item) {
|
if ('link' in item) {
|
||||||
const { name, icon, viewName } = item.link;
|
const { name, icon, viewName } = item.link;
|
||||||
|
|
||||||
@@ -245,12 +272,18 @@ function SidebarTopItem({
|
|||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
ref={isActive ? activeItemRef : undefined}
|
data-sidebar-active={isActive || undefined}
|
||||||
className={cn('w-full justify-start gap-2 font-normal', isActive && 'bg-accent text-accent-foreground')}
|
className={cn(
|
||||||
|
sidebarItemClass,
|
||||||
|
sidebarItemSquareClass,
|
||||||
|
isActive && 'bg-accent text-accent-foreground hover:bg-accent',
|
||||||
|
)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (isLocked) {
|
if (isLocked) {
|
||||||
onUpsell();
|
onUpsell();
|
||||||
} else {
|
} else {
|
||||||
|
level?.setOpenId(null);
|
||||||
|
setLastVisitedSection(sectionName, viewName);
|
||||||
navigate(path);
|
navigate(path);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -269,15 +302,16 @@ function SidebarTopItem({
|
|||||||
const containsActive = subtreeContainsActive(items, currentPath, sectionName);
|
const containsActive = subtreeContainsActive(items, currentPath, sectionName);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AutoOpenCollapsible containsActive={containsActive}>
|
<AccordionCollapsible id={name} containsActive={containsActive}>
|
||||||
<CollapsibleTrigger asChild>
|
<CollapsibleTrigger asChild>
|
||||||
<Button variant="ghost" className="w-full justify-start gap-2 font-normal">
|
<Button variant="ghost" className={cn(sidebarItemClass, sidebarItemSquareClass)}>
|
||||||
<LucideIcon name={icon} className="h-4 w-4 shrink-0" />
|
<LucideIcon name={icon} className="h-4 w-4 shrink-0" />
|
||||||
<span className="truncate">{name}</span>
|
<span className="truncate">{name}</span>
|
||||||
<ChevronDown className="ml-auto h-3 w-3 shrink-0 transition-transform duration-200 [[data-state=closed]>&]:rotate-[-90deg]" />
|
<ChevronDown className="ml-auto h-3 w-3 shrink-0 transition-transform duration-200 [[data-state=closed]>&]:rotate-[-90deg]" />
|
||||||
</Button>
|
</Button>
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
<CollapsibleContent>
|
<CollapsibleContent>
|
||||||
|
<AccordionLevel>
|
||||||
{items.map((sub) => (
|
{items.map((sub) => (
|
||||||
<SidebarSubItem
|
<SidebarSubItem
|
||||||
key={sub.type === 'link' ? sub.viewName : sub.name}
|
key={sub.type === 'link' ? sub.viewName : sub.name}
|
||||||
@@ -288,11 +322,11 @@ function SidebarTopItem({
|
|||||||
navigate={navigate}
|
navigate={navigate}
|
||||||
edition={edition}
|
edition={edition}
|
||||||
onUpsell={onUpsell}
|
onUpsell={onUpsell}
|
||||||
activeItemRef={activeItemRef}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
</AccordionLevel>
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</AutoOpenCollapsible>
|
</AccordionCollapsible>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,11 +345,16 @@ export function Sidebar() {
|
|||||||
const permissions = useAccountStore((s) => s.permissions);
|
const permissions = useAccountStore((s) => s.permissions);
|
||||||
const hasPermission = useAccountStore((s) => s.hasPermission);
|
const hasPermission = useAccountStore((s) => s.hasPermission);
|
||||||
const [upsellOpen, setUpsellOpen] = useState(false);
|
const [upsellOpen, setUpsellOpen] = useState(false);
|
||||||
const activeItem = useRef<HTMLButtonElement | null>(null);
|
const navRef = useRef<HTMLElement>(null);
|
||||||
const activeItemRef = useCallback<ActiveItemRef>((el) => {
|
// Set by handleSectionClick right before navigating: switching sections
|
||||||
activeItem.current = el;
|
// from the footer should keep the sidebar open so the new section's pages
|
||||||
}, []);
|
// are still browsable, instead of closing right back up like a leaf-page
|
||||||
|
// navigation would.
|
||||||
|
const skipCloseOnNavigateRef = useRef(false);
|
||||||
|
|
||||||
|
// Build the permission checks from the permissions array itself: the store
|
||||||
|
// accessors are stable refs, so depending on them alone would keep a stale
|
||||||
|
// layout list after access data finishes loading.
|
||||||
const layouts = useMemo(() => {
|
const layouts = useMemo(() => {
|
||||||
if (!schema) return [];
|
if (!schema) return [];
|
||||||
const canGet = (prefix: string) => permissions.includes(`${prefix}Get`);
|
const canGet = (prefix: string) => permissions.includes(`${prefix}Get`);
|
||||||
@@ -324,13 +363,20 @@ export function Sidebar() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
|
if (skipCloseOnNavigateRef.current) {
|
||||||
|
skipCloseOnNavigateRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (window.matchMedia('(max-width: 767px)').matches) {
|
if (window.matchMedia('(max-width: 767px)').matches) {
|
||||||
setSidebarOpen(false);
|
setSidebarOpen(false);
|
||||||
}
|
}
|
||||||
}, [location.pathname, setSidebarOpen]);
|
}, [location.pathname, setSidebarOpen]);
|
||||||
|
|
||||||
|
// Keep the active page visible in the sidebar after any navigation
|
||||||
|
// (e.g. from the command palette or an external link).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
activeItem.current?.scrollIntoView({ block: 'nearest' });
|
const active = navRef.current?.querySelector('[data-sidebar-active="true"]');
|
||||||
|
active?.scrollIntoView({ block: 'nearest' });
|
||||||
}, [location.pathname, activeSection]);
|
}, [location.pathname, activeSection]);
|
||||||
|
|
||||||
if (!sidebarOpen || !schema) return null;
|
if (!sidebarOpen || !schema) return null;
|
||||||
@@ -341,10 +387,15 @@ export function Sidebar() {
|
|||||||
const handleSectionClick = (target: Layout) => {
|
const handleSectionClick = (target: Layout) => {
|
||||||
setActiveSection(target.name);
|
setActiveSection(target.name);
|
||||||
const canGet = (prefix: string) => permissions.includes(`${prefix}Get`);
|
const canGet = (prefix: string) => permissions.includes(`${prefix}Get`);
|
||||||
|
const last = findLastVisitedLinkInLayout(schema, target, edition, canGet, hasPermission);
|
||||||
const first =
|
const first =
|
||||||
|
last ??
|
||||||
findFirstAccessibleLinkInLayout(schema, target, edition, canGet, hasPermission) ??
|
findFirstAccessibleLinkInLayout(schema, target, edition, canGet, hasPermission) ??
|
||||||
findFirstVisibleLinkInLayout(schema, target, edition, canGet, hasPermission);
|
findFirstVisibleLinkInLayout(schema, target, edition, canGet, hasPermission);
|
||||||
if (first) navigate(`/${target.name}/${first}`);
|
if (first) {
|
||||||
|
skipCloseOnNavigateRef.current = true;
|
||||||
|
navigate(`/${target.name}/${first}`);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -355,8 +406,12 @@ export function Sidebar() {
|
|||||||
onClick={() => setSidebarOpen(false)}
|
onClick={() => setSidebarOpen(false)}
|
||||||
/>
|
/>
|
||||||
<aside className="fixed top-14 left-0 bottom-0 z-30 flex w-64 flex-col border-r bg-background">
|
<aside className="fixed top-14 left-0 bottom-0 z-30 flex w-64 flex-col border-r bg-background">
|
||||||
<div className="flex-1 overflow-y-auto py-2 [scrollbar-width:thin]">
|
<ScrollArea className="flex-1">
|
||||||
<nav className="flex flex-col gap-0.5 px-2">
|
<nav
|
||||||
|
ref={navRef}
|
||||||
|
className="flex flex-col gap-0.5 px-2 py-2 [[data-radius='square']_&]:gap-0 [[data-radius='square']_&]:px-0"
|
||||||
|
>
|
||||||
|
<AccordionLevel>
|
||||||
{layout.items.map((item) => (
|
{layout.items.map((item) => (
|
||||||
<SidebarTopItem
|
<SidebarTopItem
|
||||||
key={'link' in item ? item.link.viewName : item.container.name}
|
key={'link' in item ? item.link.viewName : item.container.name}
|
||||||
@@ -366,15 +421,15 @@ export function Sidebar() {
|
|||||||
navigate={navigate}
|
navigate={navigate}
|
||||||
edition={edition}
|
edition={edition}
|
||||||
onUpsell={() => setUpsellOpen(true)}
|
onUpsell={() => setUpsellOpen(true)}
|
||||||
activeItemRef={activeItemRef}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
</AccordionLevel>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</ScrollArea>
|
||||||
|
|
||||||
{layouts.length > 1 && (
|
{layouts.length > 1 && (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<div className="flex items-center justify-around border-t bg-background px-2 py-2">
|
<div className="flex items-center justify-around border-t bg-background px-2 py-2 [[data-radius='square']_&]:px-0 [[data-radius='square']_&]:py-0">
|
||||||
{layouts.map((target) => {
|
{layouts.map((target) => {
|
||||||
const Icon = (LucideIcons as Record<string, unknown>)[
|
const Icon = (LucideIcons as Record<string, unknown>)[
|
||||||
target.icon
|
target.icon
|
||||||
@@ -393,7 +448,11 @@ export function Sidebar() {
|
|||||||
aria-label={target.name}
|
aria-label={target.name}
|
||||||
aria-current={isActive ? 'page' : undefined}
|
aria-current={isActive ? 'page' : undefined}
|
||||||
onClick={() => handleSectionClick(target)}
|
onClick={() => handleSectionClick(target)}
|
||||||
className={cn('h-9 w-9', isActive && 'bg-accent text-accent-foreground')}
|
className={cn(
|
||||||
|
'h-9 w-9',
|
||||||
|
"[[data-radius='square']_&]:h-12 [[data-radius='square']_&]:flex-1 [[data-radius='square']_&]:border-r [[data-radius='square']_&]:border-border [[data-radius='square']_&]:last:border-r-0 [[data-radius='square']_&]:hover:bg-foreground/[0.03]",
|
||||||
|
isActive && 'bg-accent text-accent-foreground',
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{Icon ? <Icon className="h-4 w-4" /> : <LucideIcons.Circle className="h-4 w-4" />}
|
{Icon ? <Icon className="h-4 w-4" /> : <LucideIcons.Circle className="h-4 w-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import * as LucideIcons from 'lucide-react';
|
import * as LucideIcons from 'lucide-react';
|
||||||
const { Sun, Moon, User, LogOut, Check, Menu, Sparkles, Search } = LucideIcons;
|
const { User, LogOut, Check, Menu, Sparkles, Search, Palette, ScrollText } = LucideIcons;
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { CommandPalette } from '@/components/common/CommandPalette';
|
import { CommandPalette } from '@/components/common/CommandPalette';
|
||||||
import {
|
import {
|
||||||
@@ -20,18 +20,19 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import Logo from '@/components/common/Logo';
|
import Logo from '@/components/common/Logo';
|
||||||
|
import { ModeToggle } from '@/components/common/ModeToggle';
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import { EnterpriseUpsell } from '@/components/common/EnterpriseUpsell';
|
import { EnterpriseUpsell } from '@/components/common/EnterpriseUpsell';
|
||||||
import { findFirstAccessibleLinkInLayout, findFirstVisibleLinkInLayout, visibleLayouts } from '@/lib/layout';
|
import { findFirstAccessibleLinkInLayout, findFirstVisibleLinkInLayout, visibleLayouts } from '@/lib/layout';
|
||||||
|
import { findLastVisitedLinkInLayout } from '@/lib/lastVisited';
|
||||||
import { useUIStore } from '@/stores/uiStore';
|
import { useUIStore } from '@/stores/uiStore';
|
||||||
import { useAuthStore } from '@/stores/authStore';
|
import { useAuthStore } from '@/stores/authStore';
|
||||||
import { buildEndSessionUrl, getPostLogoutRedirectUri } from '@/services/auth/oauth';
|
import { buildEndSessionUrl, getPostLogoutRedirectUri } from '@/services/auth/oauth';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useAccountStore } from '@/stores/accountStore';
|
import { useAccountStore } from '@/stores/accountStore';
|
||||||
|
import { useCurrentAccountDetails } from '@/hooks/useCurrentAccount';
|
||||||
import { useSchemaStore } from '@/stores/schemaStore';
|
import { useSchemaStore } from '@/stores/schemaStore';
|
||||||
|
|
||||||
const IS_MAC = /Mac|iPhone|iPad|iPod/.test(navigator.userAgent);
|
|
||||||
|
|
||||||
function getIcon(name: string): LucideIcons.LucideIcon {
|
function getIcon(name: string): LucideIcons.LucideIcon {
|
||||||
const formatted = name
|
const formatted = name
|
||||||
.split('-')
|
.split('-')
|
||||||
@@ -43,14 +44,16 @@ function getIcon(name: string): LucideIcons.LucideIcon {
|
|||||||
export function TopBar() {
|
export function TopBar() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const theme = useUIStore((s) => s.theme);
|
|
||||||
const toggleTheme = useUIStore((s) => s.toggleTheme);
|
|
||||||
const toggleSidebar = useUIStore((s) => s.toggleSidebar);
|
const toggleSidebar = useUIStore((s) => s.toggleSidebar);
|
||||||
const setActiveSection = useUIStore((s) => s.setActiveSection);
|
const setActiveSection = useUIStore((s) => s.setActiveSection);
|
||||||
const accounts = useAuthStore((s) => s.accounts);
|
const accounts = useAuthStore((s) => s.accounts);
|
||||||
const activeAccountId = useAuthStore((s) => s.activeAccountId);
|
const activeAccountId = useAuthStore((s) => s.activeAccountId);
|
||||||
const switchAccount = useAuthStore((s) => s.switchAccount);
|
const switchAccount = useAuthStore((s) => s.switchAccount);
|
||||||
const logout = useAuthStore((s) => s.logout);
|
const logout = useAuthStore((s) => s.logout);
|
||||||
|
const activeAccount = activeAccountId ? accounts[activeAccountId] : null;
|
||||||
|
const currentAccount = useCurrentAccountDetails();
|
||||||
|
const displayName = currentAccount.username?.trim() || activeAccount?.name?.trim() || activeAccountId?.split('@')[0] || activeAccountId;
|
||||||
|
const displayEmail = currentAccount.email || activeAccountId;
|
||||||
const edition = useAccountStore((s) => s.edition);
|
const edition = useAccountStore((s) => s.edition);
|
||||||
const hasObjectPermission = useAccountStore((s) => s.hasObjectPermission);
|
const hasObjectPermission = useAccountStore((s) => s.hasObjectPermission);
|
||||||
const hasPermission = useAccountStore((s) => s.hasPermission);
|
const hasPermission = useAccountStore((s) => s.hasPermission);
|
||||||
@@ -60,7 +63,7 @@ export function TopBar() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handleGlobalKeyDown(e: KeyboardEvent) {
|
function handleGlobalKeyDown(e: KeyboardEvent) {
|
||||||
if ((e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'k') {
|
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setPaletteOpen((open) => !open);
|
setPaletteOpen((open) => !open);
|
||||||
}
|
}
|
||||||
@@ -69,6 +72,8 @@ export function TopBar() {
|
|||||||
return () => document.removeEventListener('keydown', handleGlobalKeyDown);
|
return () => document.removeEventListener('keydown', handleGlobalKeyDown);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const isMac = /Mac|iPhone|iPad|iPod/.test(navigator.userAgent);
|
||||||
|
|
||||||
const navigableLayouts = schema
|
const navigableLayouts = schema
|
||||||
? visibleLayouts(schema, edition, (prefix) => hasObjectPermission(prefix, 'Get'), hasPermission)
|
? visibleLayouts(schema, edition, (prefix) => hasObjectPermission(prefix, 'Get'), hasPermission)
|
||||||
: [];
|
: [];
|
||||||
@@ -87,7 +92,7 @@ export function TopBar() {
|
|||||||
</Link>
|
</Link>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="bottom">
|
<TooltipContent side="bottom">
|
||||||
{t('version.label', 'Stalwart WebUI v{{version}}', { version: __APP_VERSION__ })}
|
{t('version.label', 'Stalwart WebUI Fork v{{version}}', { version: __APP_VERSION__ })}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
@@ -99,9 +104,11 @@ export function TopBar() {
|
|||||||
className="flex h-9 w-full max-w-md items-center gap-2 rounded-md border border-input bg-transparent px-3 text-sm text-muted-foreground shadow-sm transition-colors hover:bg-accent"
|
className="flex h-9 w-full max-w-md items-center gap-2 rounded-md border border-input bg-transparent px-3 text-sm text-muted-foreground shadow-sm transition-colors hover:bg-accent"
|
||||||
>
|
>
|
||||||
<Search className="h-4 w-4" />
|
<Search className="h-4 w-4" />
|
||||||
<span className="flex-1 text-left">{t('globalSearch.placeholder', 'Search pages, fields, settings...')}</span>
|
<span className="flex-1 text-left">
|
||||||
|
{t('globalSearch.placeholder', 'Search pages, fields, settings...')}
|
||||||
|
</span>
|
||||||
<kbd className="pointer-events-none flex h-5 select-none items-center rounded border bg-muted px-1.5 font-mono text-[10px] font-medium">
|
<kbd className="pointer-events-none flex h-5 select-none items-center rounded border bg-muted px-1.5 font-mono text-[10px] font-medium">
|
||||||
{IS_MAC ? '⌘K' : 'Ctrl K'}
|
{isMac ? '⌘K' : 'Ctrl K'}
|
||||||
</kbd>
|
</kbd>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -121,14 +128,16 @@ export function TopBar() {
|
|||||||
|
|
||||||
<CommandPalette open={paletteOpen} onOpenChange={setPaletteOpen} />
|
<CommandPalette open={paletteOpen} onOpenChange={setPaletteOpen} />
|
||||||
|
|
||||||
<Button variant="ghost" size="icon" onClick={toggleTheme} aria-label={t('toggleTheme', 'Toggle theme')}>
|
<ModeToggle />
|
||||||
{theme === 'light' ? <Moon className="h-4 w-4" /> : <Sun className="h-4 w-4" />}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" size="icon" aria-label={t('userMenu', 'User menu')}>
|
<Button variant="ghost" className="h-auto gap-2 px-2" aria-label={t('userMenu', 'User menu')}>
|
||||||
<User className="h-4 w-4" />
|
<User className="h-4 w-4" />
|
||||||
|
<div className="hidden flex-col items-start text-left md:flex">
|
||||||
|
<span className="text-sm font-medium leading-none">{displayName}</span>
|
||||||
|
<span className="text-xs text-muted-foreground leading-none">{displayEmail}</span>
|
||||||
|
</div>
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="w-56">
|
<DropdownMenuContent align="end" className="w-56">
|
||||||
@@ -144,7 +153,9 @@ export function TopBar() {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setActiveSection(layout.name);
|
setActiveSection(layout.name);
|
||||||
const canGet = (prefix: string) => hasObjectPermission(prefix, 'Get');
|
const canGet = (prefix: string) => hasObjectPermission(prefix, 'Get');
|
||||||
|
const lastLink = findLastVisitedLinkInLayout(schema, layout, edition, canGet, hasPermission);
|
||||||
const firstLink =
|
const firstLink =
|
||||||
|
lastLink ??
|
||||||
findFirstAccessibleLinkInLayout(schema, layout, edition, canGet, hasPermission) ??
|
findFirstAccessibleLinkInLayout(schema, layout, edition, canGet, hasPermission) ??
|
||||||
findFirstVisibleLinkInLayout(schema, layout, edition, canGet, hasPermission);
|
findFirstVisibleLinkInLayout(schema, layout, edition, canGet, hasPermission);
|
||||||
if (firstLink) {
|
if (firstLink) {
|
||||||
@@ -187,7 +198,18 @@ export function TopBar() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<DropdownMenuItem onClick={() => navigate('/Appearance')}>
|
||||||
|
<Palette className="mr-2 h-4 w-4" />
|
||||||
|
{t('appearance.label', 'Appearance')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuItem onClick={() => navigate('/Changelog')}>
|
||||||
|
<ScrollText className="mr-2 h-4 w-4" />
|
||||||
|
{t('changelog.label', 'Changelog')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
|
variant="destructive"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const endSessionEndpoint = useAuthStore.getState().endSessionEndpoint;
|
const endSessionEndpoint = useAuthStore.getState().endSessionEndpoint;
|
||||||
logout();
|
logout();
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
@@ -20,14 +20,18 @@ import {
|
|||||||
Lock,
|
Lock,
|
||||||
Search,
|
Search,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
|
RefreshCw,
|
||||||
|
CornerDownRight,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select';
|
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select';
|
||||||
|
import { Combobox } from '@/components/ui/combobox';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { formatSize as fmtSize, formatDuration as fmtDuration } from '@/lib/durationFormat';
|
import { formatSize as fmtSize, formatDuration as fmtDuration } from '@/lib/durationFormat';
|
||||||
|
import { SizeDisplay } from '@/components/common/SizeDisplay';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
@@ -46,6 +50,7 @@ import {
|
|||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from '@/components/ui/alert-dialog';
|
} from '@/components/ui/alert-dialog';
|
||||||
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible';
|
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { ObjectPicker } from '@/components/common/ObjectPicker';
|
import { ObjectPicker } from '@/components/common/ObjectPicker';
|
||||||
import { EnterpriseUpsell } from '@/components/common/EnterpriseUpsell';
|
import { EnterpriseUpsell } from '@/components/common/EnterpriseUpsell';
|
||||||
import { toast } from '@/hooks/use-toast';
|
import { toast } from '@/hooks/use-toast';
|
||||||
@@ -59,14 +64,32 @@ import { useAuthStore } from '@/stores/authStore';
|
|||||||
import { useAccountStore } from '@/stores/accountStore';
|
import { useAccountStore } from '@/stores/accountStore';
|
||||||
import { useCacheStore } from '@/stores/cacheStore';
|
import { useCacheStore } from '@/stores/cacheStore';
|
||||||
import { resolveObject, resolveSchema, resolveList, getDisplayProperty } from '@/lib/schemaResolver';
|
import { resolveObject, resolveSchema, resolveList, getDisplayProperty } from '@/lib/schemaResolver';
|
||||||
import { jmapGetBatched, jmapQueryAll, jmapQueryAndGet, jmapSet, getAccountId } from '@/services/jmap/client';
|
import {
|
||||||
|
jmapGetBatched,
|
||||||
|
jmapQueryAll,
|
||||||
|
jmapQueryAndGet,
|
||||||
|
jmapQueryAllAndGet,
|
||||||
|
jmapSet,
|
||||||
|
getAccountId,
|
||||||
|
} from '@/services/jmap/client';
|
||||||
|
|
||||||
import type { Schema, Field, MassAction, ItemAction, Filter as FilterDef } from '@/types/schema';
|
import type { Schema, Field, MassAction, ItemAction, Filter as FilterDef } from '@/types/schema';
|
||||||
import type { JmapSetResponse, JmapSetError } from '@/types/jmap';
|
import type { JmapSetResponse, JmapSetError } from '@/types/jmap';
|
||||||
import type { ResolvedSchema } from '@/lib/schemaResolver';
|
import type { ResolvedSchema } from '@/lib/schemaResolver';
|
||||||
|
import { isClientOnlyFilterEnum, isClientSortableColumn } from '@/lib/schemaDeviationTypes';
|
||||||
|
|
||||||
const PAGE_SIZE = 25;
|
const PAGE_SIZE = 25;
|
||||||
const MAX_REPORTED_ERRORS = 3;
|
const MAX_REPORTED_ERRORS = 3;
|
||||||
|
// Combobox threshold: plain <Select> is fine for a handful of options, but
|
||||||
|
// unusable (no search) once an enum has dozens of entries.
|
||||||
|
const ENUM_COMBOBOX_THRESHOLD = 15;
|
||||||
|
// Manual refresh on the Logs list is rate-limited to avoid hammering the
|
||||||
|
// server if someone leaves it clicked repeatedly.
|
||||||
|
const REFRESH_COOLDOWN_MS = 5000;
|
||||||
|
|
||||||
|
function isClientOnlyFilter(f: FilterDef): boolean {
|
||||||
|
return f.type === 'enum' && isClientOnlyFilterEnum(f);
|
||||||
|
}
|
||||||
|
|
||||||
function parseSetResponse(raw: [string, Record<string, unknown>, string][]): JmapSetResponse | null {
|
function parseSetResponse(raw: [string, Record<string, unknown>, string][]): JmapSetResponse | null {
|
||||||
const entry = raw.find(([name]) => name.endsWith('/set'));
|
const entry = raw.find(([name]) => name.endsWith('/set'));
|
||||||
@@ -165,6 +188,138 @@ function formatNumber(value: unknown): string {
|
|||||||
return value.toLocaleString();
|
return value.toLocaleString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SCHEMA-DEVIATION: mailbox-client-hierarchy-sort (see SCHEMA_DEVIATIONS.md)
|
||||||
|
//
|
||||||
|
// Orders mailboxes so each parent is immediately followed by its
|
||||||
|
// descendants (siblings alphabetical), and records each row's depth.
|
||||||
|
// Requires the full set (not just one page) since a mailbox's parent
|
||||||
|
// could be on a different page than the mailbox itself.
|
||||||
|
function sortMailboxesByHierarchy(items: Record<string, unknown>[]): {
|
||||||
|
items: Record<string, unknown>[];
|
||||||
|
depths: Map<string, number>;
|
||||||
|
} {
|
||||||
|
const byId = new Set(items.map((item) => item.id as string));
|
||||||
|
const childrenOf = new Map<string, Record<string, unknown>[]>();
|
||||||
|
const roots: Record<string, unknown>[] = [];
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const parentId = item.parentId as string | null | undefined;
|
||||||
|
if (parentId && byId.has(parentId)) {
|
||||||
|
const siblings = childrenOf.get(parentId) ?? [];
|
||||||
|
siblings.push(item);
|
||||||
|
childrenOf.set(parentId, siblings);
|
||||||
|
} else {
|
||||||
|
roots.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const byName = (a: Record<string, unknown>, b: Record<string, unknown>) =>
|
||||||
|
String(a.name ?? '').localeCompare(String(b.name ?? ''));
|
||||||
|
roots.sort(byName);
|
||||||
|
for (const siblings of childrenOf.values()) siblings.sort(byName);
|
||||||
|
|
||||||
|
const ordered: Record<string, unknown>[] = [];
|
||||||
|
const depths = new Map<string, number>();
|
||||||
|
|
||||||
|
function visit(item: Record<string, unknown>, depth: number) {
|
||||||
|
ordered.push(item);
|
||||||
|
depths.set(item.id as string, depth);
|
||||||
|
for (const child of childrenOf.get(item.id as string) ?? []) {
|
||||||
|
visit(child, depth + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const root of roots) visit(root, 0);
|
||||||
|
|
||||||
|
return { items: ordered, depths };
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUserRole(item: Record<string, unknown>, schema: Schema): React.ReactNode {
|
||||||
|
// The x:Account list merges field definitions across its User/Group
|
||||||
|
// variants (see getFieldsRecord), and Group's `roles` property points to
|
||||||
|
// a different object (`x:Roles`) that overrides User's (`x:UserRoles`)
|
||||||
|
// in that merge. This list only ever shows Users, so resolve the label
|
||||||
|
// directly against x:UserRoles instead of the ambiguous merged field.
|
||||||
|
const roles = item.roles as Record<string, unknown> | undefined;
|
||||||
|
const type = roles && typeof roles['@type'] === 'string' ? roles['@type'] : undefined;
|
||||||
|
if (!type) return <span className="text-muted-foreground">-</span>;
|
||||||
|
|
||||||
|
const variantSchema = schema.schemas['x:UserRoles'];
|
||||||
|
if (variantSchema?.type === 'multiple') {
|
||||||
|
const variant = variantSchema.variants.find((v) => v.name === type);
|
||||||
|
if (variant) return <Badge variant="secondary">{variant.label}</Badge>;
|
||||||
|
}
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderQuotaUsage(item: Record<string, unknown>, t: TFn): React.ReactNode {
|
||||||
|
const rawUsed = typeof item.usedDiskQuota === 'number' ? item.usedDiskQuota : 0;
|
||||||
|
const used = Number.isFinite(rawUsed) ? rawUsed : 0;
|
||||||
|
const quotas = item.quotas as Record<string, unknown> | undefined;
|
||||||
|
const rawLimit = quotas && typeof quotas.maxDiskQuota === 'number' ? quotas.maxDiskQuota : 0;
|
||||||
|
const limit = Number.isFinite(rawLimit) && rawLimit > 0 ? rawLimit : 0;
|
||||||
|
const limitLabel = limit ? formatSize(limit) : t('list.unlimitedQuota', '∞');
|
||||||
|
|
||||||
|
if (used >= 0) {
|
||||||
|
return `${formatSize(used)} / ${limitLabel}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<SizeDisplay bytes={used} />
|
||||||
|
<span className="text-muted-foreground">/</span>
|
||||||
|
<span>{limitLabel}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SCHEMA-DEVIATION: account-alias-count-column, role-permission-count-columns
|
||||||
|
* (see SCHEMA_DEVIATIONS.md)
|
||||||
|
*
|
||||||
|
* Synthetic "count" columns, keyed by column name, each naming the real
|
||||||
|
* set/objectList property whose entry count they render. Generic and
|
||||||
|
* table-level: whichever list's schema patch tags a column with one of
|
||||||
|
* these names (see withAccountListColumns, withMailingListColumns,
|
||||||
|
* withRoleListColumns) gets it resolved and rendered automatically, with
|
||||||
|
* no per-list wiring in this component.
|
||||||
|
*/
|
||||||
|
const COUNT_COLUMN_SOURCES: Record<string, string> = {
|
||||||
|
aliasCount: 'aliases',
|
||||||
|
enabledPermissionCount: 'enabledPermissions',
|
||||||
|
disabledPermissionCount: 'disabledPermissions',
|
||||||
|
};
|
||||||
|
|
||||||
|
function getCountColumnValue(colName: string, item: Record<string, unknown>): number {
|
||||||
|
const source = COUNT_COLUMN_SOURCES[colName];
|
||||||
|
return Object.keys((item[source] as Record<string, unknown>) ?? {}).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SCHEMA-DEVIATION: account-client-sort (see SCHEMA_DEVIATIONS.md)
|
||||||
|
*
|
||||||
|
* Generic, table-level client-sort mechanism: any column tagged
|
||||||
|
* `clientSortable` in the schema (see ClientSortableColumn /
|
||||||
|
* withAccountListColumns) gets fetch-all-then-sort-in-memory behavior on
|
||||||
|
* click, for whichever list declares it — this isn't specific to Accounts
|
||||||
|
* or Groups, and needs no per-list wiring in this component.
|
||||||
|
*
|
||||||
|
* Real columns compare their own property directly; synthetic deviation
|
||||||
|
* columns (quotaUsage, and any COUNT_COLUMN_SOURCES entry) aren't real
|
||||||
|
* properties, so they need an override to compute a comparable value from
|
||||||
|
* what's actually on the item.
|
||||||
|
*/
|
||||||
|
const CLIENT_SORT_VALUE_OVERRIDES: Record<string, (item: Record<string, unknown>) => string | number> = {
|
||||||
|
quotaUsage: (item) => (typeof item.usedDiskQuota === 'number' ? item.usedDiskQuota : 0),
|
||||||
|
};
|
||||||
|
|
||||||
|
function getClientSortValue(colName: string, item: Record<string, unknown>): string | number {
|
||||||
|
if (colName in COUNT_COLUMN_SOURCES) return getCountColumnValue(colName, item);
|
||||||
|
const override = CLIENT_SORT_VALUE_OVERRIDES[colName];
|
||||||
|
if (override) return override(item);
|
||||||
|
const raw = item[colName];
|
||||||
|
return typeof raw === 'number' ? raw : String(raw ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
function getFieldsRecord(resolvedSchema: ResolvedSchema): Record<string, Field> {
|
function getFieldsRecord(resolvedSchema: ResolvedSchema): Record<string, Field> {
|
||||||
if (resolvedSchema.type === 'single') {
|
if (resolvedSchema.type === 'single') {
|
||||||
return resolvedSchema.fields.properties;
|
return resolvedSchema.fields.properties;
|
||||||
@@ -287,7 +442,14 @@ function renderCellValue(
|
|||||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||||
const obj = value as Record<string, unknown>;
|
const obj = value as Record<string, unknown>;
|
||||||
if ('@type' in obj && typeof obj['@type'] === 'string') {
|
if ('@type' in obj && typeof obj['@type'] === 'string') {
|
||||||
return obj['@type'];
|
const variantSchema = schema.schemas[ft.objectName];
|
||||||
|
if (variantSchema?.type === 'multiple') {
|
||||||
|
const variant = variantSchema.variants.find((v) => v.name === obj['@type']);
|
||||||
|
if (variant) {
|
||||||
|
return <Badge variant="secondary">{variant.label}</Badge>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return String(obj['@type']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return <span className="text-muted-foreground">-</span>;
|
return <span className="text-muted-foreground">-</span>;
|
||||||
@@ -329,6 +491,26 @@ interface ConfirmAction {
|
|||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function isActiveWebApplication(item: Record<string, unknown>): boolean {
|
||||||
|
const prefixes = item.urlPrefix;
|
||||||
|
const values: string[] = [];
|
||||||
|
if (Array.isArray(prefixes)) {
|
||||||
|
values.push(...prefixes.map(String));
|
||||||
|
} else if (typeof prefixes === 'string') {
|
||||||
|
values.push(...prefixes.split(',').map((s) => s.trim()));
|
||||||
|
} else if (prefixes && typeof prefixes === 'object') {
|
||||||
|
// JMAP exposes urlPrefix as a set object such as { "/admin": true, "/account": true }
|
||||||
|
for (const [key, value] of Object.entries(prefixes)) {
|
||||||
|
if (value === true) {
|
||||||
|
values.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (prefixes != null) {
|
||||||
|
values.push(String(prefixes));
|
||||||
|
}
|
||||||
|
return values.some((p) => p === '/admin' || p === '/account');
|
||||||
|
}
|
||||||
interface DynamicListProps {
|
interface DynamicListProps {
|
||||||
viewName: string;
|
viewName: string;
|
||||||
}
|
}
|
||||||
@@ -342,6 +524,12 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
|||||||
const viewToSection = useSchemaStore((s) => s.viewToSection);
|
const viewToSection = useSchemaStore((s) => s.viewToSection);
|
||||||
const hasObjectPermission = useAccountStore((s) => s.hasObjectPermission);
|
const hasObjectPermission = useAccountStore((s) => s.hasObjectPermission);
|
||||||
const edition = useAccountStore((s) => s.edition);
|
const edition = useAccountStore((s) => s.edition);
|
||||||
|
// Reactive, unlike the getAccountId() snapshot read inside fetchData: needed
|
||||||
|
// so switching accounts from the profile dropdown (a pure store update with
|
||||||
|
// no navigation) re-triggers the fetch effect below for account-scoped
|
||||||
|
// views (Mailboxes, Calendars, Sieve Scripts, ...) even when viewName
|
||||||
|
// itself doesn't change.
|
||||||
|
const activeAccountId = useAuthStore((s) => s.activeAccountId);
|
||||||
const [upsellOpen, setUpsellOpen] = useState(false);
|
const [upsellOpen, setUpsellOpen] = useState(false);
|
||||||
|
|
||||||
const resolved = useMemo(() => {
|
const resolved = useMemo(() => {
|
||||||
@@ -354,6 +542,48 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
|||||||
return { obj, schema: schem, list };
|
return { obj, schema: schem, list };
|
||||||
}, [schema, viewName]);
|
}, [schema, viewName]);
|
||||||
|
|
||||||
|
const objectName = resolved?.obj.objectName;
|
||||||
|
const isWebApplications = viewName === 'x:Application' || objectName === 'x:Application';
|
||||||
|
const isLogEntries = viewName === 'x:Log' || objectName === 'x:Log';
|
||||||
|
const isAccountsList = viewName === 'x:Account/User';
|
||||||
|
const isMailboxList = viewName === 'Mailbox';
|
||||||
|
// Not tied to a specific viewName: any list whose schema-driven columns
|
||||||
|
// (see withAccountListColumns, account-quota-usage-column deviation)
|
||||||
|
// include the synthetic `quotaUsage` column gets it resolved and rendered.
|
||||||
|
const hasQuotaUsageColumn = (resolved?.list?.columns ?? []).some((c) => c.name === 'quotaUsage');
|
||||||
|
// SCHEMA-DEVIATION: account-alias-count-column, role-permission-count-columns
|
||||||
|
// (see SCHEMA_DEVIATIONS.md) — which COUNT_COLUMN_SOURCES entries this
|
||||||
|
// particular list's columns actually declare.
|
||||||
|
const activeCountColumns = useMemo(
|
||||||
|
() => (resolved?.list?.columns ?? []).filter((c) => c.name in COUNT_COLUMN_SOURCES).map((c) => c.name),
|
||||||
|
[resolved?.list?.columns],
|
||||||
|
);
|
||||||
|
|
||||||
|
const displayColumns = useMemo(() => {
|
||||||
|
const columns = resolved?.list?.columns ?? [];
|
||||||
|
if (!isWebApplications) return columns;
|
||||||
|
|
||||||
|
// For Web Applications, present Description first and Enabled second
|
||||||
|
// to match the layout of other tables such as Domains. Reordering real
|
||||||
|
// schema columns is fine, but the synthetic fallback below (when the
|
||||||
|
// schema doesn't list an Enabled column at all) is a tracked deviation.
|
||||||
|
// SCHEMA-DEVIATION: webapp-enabled-column-fallback (see SCHEMA_DEVIATIONS.md)
|
||||||
|
const ordered = ['description', 'enabled'];
|
||||||
|
const rest = columns.filter((c) => !ordered.includes(c.name));
|
||||||
|
const descriptionCol = columns.find((c) => c.name === 'description');
|
||||||
|
const enabledCol = columns.find((c) => c.name === 'enabled');
|
||||||
|
|
||||||
|
const result: Array<{ name: string; label: string }> = [];
|
||||||
|
if (descriptionCol) result.push(descriptionCol);
|
||||||
|
if (enabledCol) {
|
||||||
|
result.push(enabledCol);
|
||||||
|
} else {
|
||||||
|
result.push({ name: 'enabled', label: t('webApplications.enabled', 'Enabled') });
|
||||||
|
}
|
||||||
|
result.push(...rest);
|
||||||
|
return result;
|
||||||
|
}, [resolved?.list?.columns, isWebApplications]);
|
||||||
|
|
||||||
const [items, setItems] = useState<Record<string, unknown>[]>([]);
|
const [items, setItems] = useState<Record<string, unknown>[]>([]);
|
||||||
const [total, setTotal] = useState<number | null>(null);
|
const [total, setTotal] = useState<number | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -376,8 +606,74 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
|||||||
const [appliedFilters, setAppliedFilters] = useState<Record<string, string>>(readUrlFilters);
|
const [appliedFilters, setAppliedFilters] = useState<Record<string, string>>(readUrlFilters);
|
||||||
|
|
||||||
const [sort, setSort] = useState<SortState | null>(readUrlSort);
|
const [sort, setSort] = useState<SortState | null>(readUrlSort);
|
||||||
|
// SCHEMA-DEVIATION: account-client-sort (see SCHEMA_DEVIATIONS.md)
|
||||||
|
// Any column the schema tags `clientSortable` (currently Email/Full Name/
|
||||||
|
// Usage/Aliases on Accounts and Groups — see withAccountListColumns) is
|
||||||
|
// sorted client-side instead of via a JMAP `sort`, only when the user
|
||||||
|
// actually picks one of them. Not list-specific: any list whose columns
|
||||||
|
// carry the flag gets this for free.
|
||||||
|
const clientSortableColumns = useMemo(
|
||||||
|
() => new Set((resolved?.list?.columns ?? []).filter(isClientSortableColumn).map((c) => c.name)),
|
||||||
|
[resolved?.list?.columns],
|
||||||
|
);
|
||||||
|
const clientSortField = sort && clientSortableColumns.has(sort.field) ? sort.field : null;
|
||||||
|
|
||||||
|
// Filters marked `clientOnly` (currently Level/Event on the Logs list) are
|
||||||
|
// not supported by the server's query engine, so they narrow an
|
||||||
|
// already-fetched result set in the browser instead of being sent as a
|
||||||
|
// JMAP filter. That switches pagination to a client-held array.
|
||||||
|
const clientFilterDefs = useMemo(
|
||||||
|
() => (resolved?.list?.filters ?? []).filter(isClientOnlyFilter),
|
||||||
|
[resolved?.list?.filters],
|
||||||
|
);
|
||||||
|
const activeClientFilters = useMemo(
|
||||||
|
() =>
|
||||||
|
clientFilterDefs
|
||||||
|
.map((f) => ({ field: f.field, value: appliedFilters[f.field] ?? '' }))
|
||||||
|
.filter((f) => f.value !== ''),
|
||||||
|
[clientFilterDefs, appliedFilters],
|
||||||
|
);
|
||||||
|
const [clientAllItems, setClientAllItems] = useState<Record<string, unknown>[] | null>(null);
|
||||||
|
const [clientPage, setClientPage] = useState(0);
|
||||||
|
const [mailboxDepths, setMailboxDepths] = useState<Map<string, number>>(new Map());
|
||||||
|
|
||||||
|
const [refreshOnCooldown, setRefreshOnCooldown] = useState(false);
|
||||||
|
const refreshCooldownTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (refreshCooldownTimer.current) clearTimeout(refreshCooldownTimer.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
|
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
|
||||||
|
const [activeWebApp, setActiveWebApp] = useState<Record<string, unknown> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isWebApplications || !schema) return;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const accountId = getAccountId(objectName ?? 'x:Application');
|
||||||
|
const result = await jmapQueryAllAndGet(
|
||||||
|
objectName ?? 'x:Application',
|
||||||
|
accountId,
|
||||||
|
{},
|
||||||
|
['id', 'description', 'enabled', 'urlPrefix', 'resourceUrl'],
|
||||||
|
);
|
||||||
|
if (cancelled) return;
|
||||||
|
const active = result.list.find((item) => isActiveWebApplication(item));
|
||||||
|
setActiveWebApp(active ?? null);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch active web application:', err);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [isWebApplications, schema]);
|
||||||
|
|
||||||
useResetOnChange(viewName, () => {
|
useResetOnChange(viewName, () => {
|
||||||
setItems([]);
|
setItems([]);
|
||||||
@@ -387,6 +683,11 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
|||||||
setSelectedIds(new Set());
|
setSelectedIds(new Set());
|
||||||
setSelectAllMode(false);
|
setSelectAllMode(false);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setClientAllItems(null);
|
||||||
|
setClientPage(0);
|
||||||
|
setMailboxDepths(new Map());
|
||||||
|
if (refreshCooldownTimer.current) clearTimeout(refreshCooldownTimer.current);
|
||||||
|
setRefreshOnCooldown(false);
|
||||||
|
|
||||||
const initialFilters = readUrlFilters();
|
const initialFilters = readUrlFilters();
|
||||||
setFilterValues(initialFilters);
|
setFilterValues(initialFilters);
|
||||||
@@ -395,15 +696,21 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
|||||||
setSort(readUrlSort());
|
setSort(readUrlSort());
|
||||||
});
|
});
|
||||||
|
|
||||||
const objectName = resolved?.obj.objectName;
|
|
||||||
const buildFilter = useCallback((): Record<string, unknown> => {
|
const buildFilter = useCallback((): Record<string, unknown> => {
|
||||||
|
const clientFields = new Set(clientFilterDefs.map((f) => f.field));
|
||||||
|
const serverAppliedFilters: Record<string, string> = {};
|
||||||
|
for (const [key, val] of Object.entries(appliedFilters)) {
|
||||||
|
const baseKey = key.endsWith('Op') ? key.slice(0, -2) : key;
|
||||||
|
if (clientFields.has(baseKey)) continue;
|
||||||
|
serverAppliedFilters[key] = val;
|
||||||
|
}
|
||||||
return buildJmapFilter({
|
return buildJmapFilter({
|
||||||
appliedFilters,
|
appliedFilters: serverAppliedFilters,
|
||||||
filters: resolved?.list?.filters,
|
filters: resolved?.list?.filters,
|
||||||
filtersStatic: resolved?.list?.filtersStatic,
|
filtersStatic: resolved?.list?.filtersStatic,
|
||||||
isXPrefixed: objectName?.startsWith('x:') ?? false,
|
isXPrefixed: objectName?.startsWith('x:') ?? false,
|
||||||
});
|
});
|
||||||
}, [appliedFilters, resolved?.list, objectName]);
|
}, [appliedFilters, resolved?.list, objectName, clientFilterDefs]);
|
||||||
|
|
||||||
const buildSort = useCallback((): Record<string, unknown>[] | undefined => {
|
const buildSort = useCallback((): Record<string, unknown>[] | undefined => {
|
||||||
if (!sort) return undefined;
|
if (!sort) return undefined;
|
||||||
@@ -421,9 +728,71 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
|||||||
try {
|
try {
|
||||||
const accountId = getAccountId(obj.objectName);
|
const accountId = getAccountId(obj.objectName);
|
||||||
const properties = ['id', ...list.columns.map((c) => c.name)];
|
const properties = ['id', ...list.columns.map((c) => c.name)];
|
||||||
|
if (isWebApplications && !properties.includes('enabled')) {
|
||||||
|
properties.push('enabled');
|
||||||
|
}
|
||||||
|
if (hasQuotaUsageColumn) {
|
||||||
|
const quotaIdx = properties.indexOf('quotaUsage');
|
||||||
|
if (quotaIdx !== -1) {
|
||||||
|
properties.splice(quotaIdx, 1, 'usedDiskQuota', 'quotas');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const countCol of activeCountColumns) {
|
||||||
|
const idx = properties.indexOf(countCol);
|
||||||
|
if (idx !== -1) {
|
||||||
|
properties.splice(idx, 1, COUNT_COLUMN_SOURCES[countCol]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isMailboxList && !properties.includes('parentId')) {
|
||||||
|
properties.push('parentId');
|
||||||
|
}
|
||||||
const filter = buildFilter();
|
const filter = buildFilter();
|
||||||
const sortArr = buildSort();
|
const sortArr = buildSort();
|
||||||
|
|
||||||
|
if (activeClientFilters.length > 0 || isMailboxList || clientSortField) {
|
||||||
|
// No server-side pagination possible once a client-only filter is
|
||||||
|
// active (SCHEMA-DEVIATION: log-client-filters): fetch every
|
||||||
|
// server-matching row up front, narrow it in the browser, then
|
||||||
|
// paginate the in-memory result locally. Mailbox hierarchy needs
|
||||||
|
// this too (SCHEMA-DEVIATION: mailbox-client-hierarchy-sort) — a
|
||||||
|
// mailbox's parent can land on a different server page than the
|
||||||
|
// mailbox itself, so the full set is required to place each row
|
||||||
|
// under its parent correctly. Sorting by Email/Full Name/Usage/
|
||||||
|
// Aliases on Accounts/Groups needs it too (SCHEMA-DEVIATION:
|
||||||
|
// account-client-sort) since the server doesn't support sorting
|
||||||
|
// on any property of either list.
|
||||||
|
const { list: fullList } = await jmapQueryAllAndGet(
|
||||||
|
obj.objectName,
|
||||||
|
accountId,
|
||||||
|
{ filter: Object.keys(filter).length > 0 ? filter : undefined, sort: clientSortField ? undefined : sortArr },
|
||||||
|
properties,
|
||||||
|
);
|
||||||
|
let matched = fullList.filter((item) =>
|
||||||
|
activeClientFilters.every((f) => String(item[f.field] ?? '') === f.value),
|
||||||
|
);
|
||||||
|
if (isMailboxList) {
|
||||||
|
const { items: ordered, depths } = sortMailboxesByHierarchy(matched);
|
||||||
|
matched = ordered;
|
||||||
|
setMailboxDepths(depths);
|
||||||
|
}
|
||||||
|
if (clientSortField) {
|
||||||
|
const direction = sort!.ascending ? 1 : -1;
|
||||||
|
matched = [...matched].sort((a, b) => {
|
||||||
|
const av = getClientSortValue(clientSortField, a);
|
||||||
|
const bv = getClientSortValue(clientSortField, b);
|
||||||
|
const cmp = typeof av === 'number' && typeof bv === 'number' ? av - bv : String(av).localeCompare(String(bv));
|
||||||
|
return cmp * direction;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setClientAllItems(matched);
|
||||||
|
setClientPage(0);
|
||||||
|
setTotal(matched.length);
|
||||||
|
setItems(matched.slice(0, PAGE_SIZE));
|
||||||
|
setSelectedIds(new Set());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setClientAllItems(null);
|
||||||
|
|
||||||
const queryOptions: Record<string, unknown> = {
|
const queryOptions: Record<string, unknown> = {
|
||||||
filter: Object.keys(filter).length > 0 ? filter : undefined,
|
filter: Object.keys(filter).length > 0 ? filter : undefined,
|
||||||
sort: sortArr,
|
sort: sortArr,
|
||||||
@@ -470,7 +839,20 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[resolved, schema, buildFilter, buildSort, t],
|
[
|
||||||
|
resolved,
|
||||||
|
schema,
|
||||||
|
buildFilter,
|
||||||
|
buildSort,
|
||||||
|
isWebApplications,
|
||||||
|
isAccountsList,
|
||||||
|
hasQuotaUsageColumn,
|
||||||
|
activeCountColumns,
|
||||||
|
isMailboxList,
|
||||||
|
clientSortField,
|
||||||
|
sort,
|
||||||
|
activeClientFilters,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -480,7 +862,7 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
|||||||
setCurrentAnchor(null);
|
setCurrentAnchor(null);
|
||||||
fetchData(null);
|
fetchData(null);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [viewName, sort, resolved?.list, appliedFilters]);
|
}, [viewName, sort, resolved?.list, appliedFilters, activeAccountId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!schema || !resolved?.list || items.length === 0) return;
|
if (!schema || !resolved?.list || items.length === 0) return;
|
||||||
@@ -545,6 +927,13 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
|||||||
setAppliedFilters({});
|
setAppliedFilters({});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleRefresh = useCallback(() => {
|
||||||
|
if (refreshOnCooldown) return;
|
||||||
|
setRefreshOnCooldown(true);
|
||||||
|
fetchData(currentAnchor, 0);
|
||||||
|
refreshCooldownTimer.current = setTimeout(() => setRefreshOnCooldown(false), REFRESH_COOLDOWN_MS);
|
||||||
|
}, [refreshOnCooldown, fetchData, currentAnchor]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
for (const [key, val] of Object.entries(appliedFilters)) {
|
for (const [key, val] of Object.entries(appliedFilters)) {
|
||||||
@@ -561,6 +950,13 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
|||||||
}, [filterValues, sort]);
|
}, [filterValues, sort]);
|
||||||
|
|
||||||
const handleNextPage = useCallback(() => {
|
const handleNextPage = useCallback(() => {
|
||||||
|
if (clientAllItems !== null) {
|
||||||
|
const nextPage = clientPage + 1;
|
||||||
|
setClientPage(nextPage);
|
||||||
|
setItems(clientAllItems.slice(nextPage * PAGE_SIZE, nextPage * PAGE_SIZE + PAGE_SIZE));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (items.length === 0) return;
|
if (items.length === 0) return;
|
||||||
const lastItem = items[items.length - 1];
|
const lastItem = items[items.length - 1];
|
||||||
const lastId = lastItem?.id as string;
|
const lastId = lastItem?.id as string;
|
||||||
@@ -572,9 +968,16 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
|||||||
}
|
}
|
||||||
setCurrentAnchor(lastId);
|
setCurrentAnchor(lastId);
|
||||||
fetchData(lastId, 1);
|
fetchData(lastId, 1);
|
||||||
}, [items, fetchData]);
|
}, [items, fetchData, clientAllItems, clientPage]);
|
||||||
|
|
||||||
const handlePrevPage = useCallback(() => {
|
const handlePrevPage = useCallback(() => {
|
||||||
|
if (clientAllItems !== null) {
|
||||||
|
const prevPage = Math.max(0, clientPage - 1);
|
||||||
|
setClientPage(prevPage);
|
||||||
|
setItems(clientAllItems.slice(prevPage * PAGE_SIZE, prevPage * PAGE_SIZE + PAGE_SIZE));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (anchorStack.length === 0) {
|
if (anchorStack.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -590,7 +993,7 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
|||||||
setCurrentAnchor(prevFirstId);
|
setCurrentAnchor(prevFirstId);
|
||||||
fetchData(prevFirstId, 0);
|
fetchData(prevFirstId, 0);
|
||||||
}
|
}
|
||||||
}, [anchorStack, fetchData]);
|
}, [anchorStack, fetchData, clientAllItems, clientPage]);
|
||||||
|
|
||||||
const toggleSelectAll = useCallback(() => {
|
const toggleSelectAll = useCallback(() => {
|
||||||
if (selectedIds.size === items.length) {
|
if (selectedIds.size === items.length) {
|
||||||
@@ -615,12 +1018,16 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Cycle: unsorted → ascending → descending → unsorted (default).
|
||||||
const toggleSort = useCallback((field: string) => {
|
const toggleSort = useCallback((field: string) => {
|
||||||
setSort((prev) => {
|
setSort((prev) => {
|
||||||
if (prev?.field === field) {
|
if (prev?.field !== field) {
|
||||||
return { field, ascending: !prev.ascending };
|
|
||||||
}
|
|
||||||
return { field, ascending: true };
|
return { field, ascending: true };
|
||||||
|
}
|
||||||
|
if (prev.ascending) {
|
||||||
|
return { field, ascending: false };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -654,12 +1061,18 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
|||||||
|
|
||||||
let targetIds: string[];
|
let targetIds: string[];
|
||||||
if (selectAllMode) {
|
if (selectAllMode) {
|
||||||
|
if (clientAllItems !== null) {
|
||||||
|
// A client-only filter is active: the full server-matching set
|
||||||
|
// would include rows it excludes, so use the already-narrowed list.
|
||||||
|
targetIds = clientAllItems.map((item) => item.id as string);
|
||||||
|
} else {
|
||||||
const filter = buildFilter();
|
const filter = buildFilter();
|
||||||
const sortArr = buildSort();
|
const sortArr = buildSort();
|
||||||
targetIds = await jmapQueryAll(obj.objectName, accountId, {
|
targetIds = await jmapQueryAll(obj.objectName, accountId, {
|
||||||
filter: Object.keys(filter).length > 0 ? filter : undefined,
|
filter: Object.keys(filter).length > 0 ? filter : undefined,
|
||||||
sort: sortArr,
|
sort: sortArr,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
targetIds = Array.from(selectedIds);
|
targetIds = Array.from(selectedIds);
|
||||||
}
|
}
|
||||||
@@ -714,7 +1127,7 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[resolved, selectedIds, selectAllMode, buildFilter, buildSort, fetchData, currentAnchor, t],
|
[resolved, selectedIds, selectAllMode, buildFilter, buildSort, fetchData, currentAnchor, t, clientAllItems],
|
||||||
);
|
);
|
||||||
|
|
||||||
const executeItemAction = useCallback(
|
const executeItemAction = useCallback(
|
||||||
@@ -840,11 +1253,11 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
|||||||
const hasMassActions = effectiveMassActions.length > 0;
|
const hasMassActions = effectiveMassActions.length > 0;
|
||||||
const hasItemActions = (list.itemActions?.length ?? 0) > 0;
|
const hasItemActions = (list.itemActions?.length ?? 0) > 0;
|
||||||
|
|
||||||
const pageStart = anchorStack.length * PAGE_SIZE;
|
const pageStart = clientAllItems !== null ? clientPage * PAGE_SIZE : anchorStack.length * PAGE_SIZE;
|
||||||
const rangeStart = pageStart + 1;
|
const rangeStart = pageStart + 1;
|
||||||
const rangeEnd = pageStart + items.length;
|
const rangeEnd = pageStart + items.length;
|
||||||
const hasNextPage = total !== null && rangeEnd < total;
|
const hasNextPage = clientAllItems !== null ? rangeEnd < clientAllItems.length : total !== null && rangeEnd < total;
|
||||||
const hasPrevPage = anchorStack.length > 0;
|
const hasPrevPage = clientAllItems !== null ? clientPage > 0 : anchorStack.length > 0;
|
||||||
|
|
||||||
function renderFilter(filterDef: FilterDef): React.ReactNode {
|
function renderFilter(filterDef: FilterDef): React.ReactNode {
|
||||||
const value = filterValues[filterDef.field] ?? '';
|
const value = filterValues[filterDef.field] ?? '';
|
||||||
@@ -869,6 +1282,22 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
|||||||
|
|
||||||
case 'enum': {
|
case 'enum': {
|
||||||
const enumVariants = schema!.enums[filterDef.enumName] ?? [];
|
const enumVariants = schema!.enums[filterDef.enumName] ?? [];
|
||||||
|
|
||||||
|
if (enumVariants.length > ENUM_COMBOBOX_THRESHOLD) {
|
||||||
|
return wrapper(
|
||||||
|
<Combobox
|
||||||
|
options={enumVariants.map((v) => ({ value: v.name, label: v.label }))}
|
||||||
|
value={value}
|
||||||
|
onValueChange={(v) => handleFilterChange(filterDef.field, v)}
|
||||||
|
placeholder={filterDef.label}
|
||||||
|
searchPlaceholder={t('list.comboboxSearchPlaceholder', 'Search...')}
|
||||||
|
emptyText={t('list.comboboxEmptyText', 'No matches.')}
|
||||||
|
nullable
|
||||||
|
nullLabel={t('filters.all', 'All')}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return wrapper(
|
return wrapper(
|
||||||
<Select value={value || '__all__'} onValueChange={(v) => handleFilterSelectChange(filterDef.field, v)}>
|
<Select value={value || '__all__'} onValueChange={(v) => handleFilterSelectChange(filterDef.field, v)}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
@@ -996,7 +1425,7 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderSortIndicator(colName: string): React.ReactNode {
|
function renderSortIndicator(colName: string): React.ReactNode {
|
||||||
if (!sortableFields.has(colName)) return null;
|
if (!sortableFields.has(colName) && !clientSortableColumns.has(colName)) return null;
|
||||||
const isActive = sort?.field === colName;
|
const isActive = sort?.field === colName;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -1086,13 +1515,13 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative space-y-4">
|
<div className="relative min-w-0 space-y-4">
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div>
|
<div className="min-w-0 flex-1">
|
||||||
<h1 className="text-2xl font-bold tracking-tight">{list.title}</h1>
|
<h1 className="text-2xl font-bold tracking-tight truncate">{list.title}</h1>
|
||||||
{list.subtitle && <p className="text-sm text-muted-foreground mt-1">{list.subtitle}</p>}
|
{list.subtitle && <p className="text-sm text-muted-foreground mt-1 line-clamp-2">{list.subtitle}</p>}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||||
{hasMassActions && selectedIds.size > 0 && (
|
{hasMassActions && selectedIds.size > 0 && (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
@@ -1153,6 +1582,7 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
|||||||
|
|
||||||
{list.filters && list.filters.length > 0 && (
|
{list.filters && list.filters.length > 0 && (
|
||||||
<Collapsible open={filtersOpen} onOpenChange={setFiltersOpen}>
|
<Collapsible open={filtersOpen} onOpenChange={setFiltersOpen}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
<CollapsibleTrigger asChild>
|
<CollapsibleTrigger asChild>
|
||||||
<Button variant="ghost" size="sm" className="gap-2">
|
<Button variant="ghost" size="sm" className="gap-2">
|
||||||
<Filter className="h-4 w-4" />
|
<Filter className="h-4 w-4" />
|
||||||
@@ -1160,6 +1590,25 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
|||||||
{filtersOpen ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
{filtersOpen ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
|
{isLogEntries && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="gap-2"
|
||||||
|
onClick={handleRefresh}
|
||||||
|
disabled={refreshOnCooldown || loading}
|
||||||
|
title={
|
||||||
|
refreshOnCooldown
|
||||||
|
? t('list.refreshCooldown', 'Please wait a few seconds before refreshing again')
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
||||||
|
{t('list.refresh', 'Refresh')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<CollapsibleContent>
|
<CollapsibleContent>
|
||||||
<div className="mt-2 rounded-lg border bg-background shadow-sm">
|
<div className="mt-2 rounded-lg border bg-background shadow-sm">
|
||||||
<div className="grid gap-4 p-5 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-4 p-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
@@ -1214,13 +1663,50 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="rounded-lg border bg-background shadow-sm">
|
{isWebApplications && activeWebApp && (
|
||||||
<div className="overflow-x-auto rounded-[calc(var(--radius-lg)-1px)]">
|
<Card>
|
||||||
<table className="w-full text-sm">
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-base">
|
||||||
|
{t('webApplications.activeWebUI', 'Active WebUI')}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2 pt-0">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{String(activeWebApp.description ?? '-')}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<span className="text-muted-foreground">{t('webApplications.version', 'Version')}:</span>
|
||||||
|
<Badge variant="secondary">{__APP_VERSION__}</Badge>
|
||||||
|
</div>
|
||||||
|
{typeof activeWebApp.resourceUrl === 'string' && activeWebApp.resourceUrl && (
|
||||||
|
<div className="flex items-start gap-2 text-sm">
|
||||||
|
<span className="shrink-0 text-muted-foreground">{t('webApplications.resourceUrl', 'Source')}:</span>
|
||||||
|
<a
|
||||||
|
href={activeWebApp.resourceUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="break-all text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{activeWebApp.resourceUrl}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="min-w-0 rounded-lg border bg-background shadow-sm">
|
||||||
|
{/* The scroll container must clip with the parent's inner radius
|
||||||
|
(outer radius minus the 1px border), otherwise filled header rows
|
||||||
|
paint square corners behind the rounded border.
|
||||||
|
`w-max min-w-full` keeps the table at least as wide as the card,
|
||||||
|
but lets wide column sets scroll horizontally inside this wrapper. */}
|
||||||
|
<div className="overflow-x-auto overscroll-x-contain rounded-[calc(var(--radius-lg)-1px)] [-webkit-overflow-scrolling:touch]">
|
||||||
|
<table className="w-max min-w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b bg-muted">
|
<tr className="border-b bg-muted">
|
||||||
{hasMassActions && (
|
{hasMassActions && (
|
||||||
<th className="w-10 px-3 py-3">
|
<th className="w-10 px-3 py-3 whitespace-nowrap">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={items.length > 0 && selectedIds.size === items.length}
|
checked={items.length > 0 && selectedIds.size === items.length}
|
||||||
onCheckedChange={toggleSelectAll}
|
onCheckedChange={toggleSelectAll}
|
||||||
@@ -1228,8 +1714,11 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
|||||||
/>
|
/>
|
||||||
</th>
|
</th>
|
||||||
)}
|
)}
|
||||||
{list.columns.map((col) => (
|
{displayColumns.map((col) => (
|
||||||
<th key={col.name} className="px-3 py-3 text-left font-medium text-muted-foreground">
|
<th
|
||||||
|
key={col.name}
|
||||||
|
className="px-3 py-3 text-left font-medium text-muted-foreground whitespace-nowrap"
|
||||||
|
>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
{col.label}
|
{col.label}
|
||||||
{renderSortIndicator(col.name)}
|
{renderSortIndicator(col.name)}
|
||||||
@@ -1237,7 +1726,7 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
|||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
{hasItemActions && (
|
{hasItemActions && (
|
||||||
<th className="w-12 px-3 py-3 text-right font-medium text-muted-foreground">
|
<th className="w-12 px-3 py-3 text-right font-medium text-muted-foreground whitespace-nowrap">
|
||||||
{t('list.actions', 'Actions')}
|
{t('list.actions', 'Actions')}
|
||||||
</th>
|
</th>
|
||||||
)}
|
)}
|
||||||
@@ -1247,7 +1736,7 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
|||||||
{loading && items.length === 0 ? (
|
{loading && items.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
colSpan={list.columns.length + (hasMassActions ? 1 : 0) + (hasItemActions ? 1 : 0)}
|
colSpan={displayColumns.length + (hasMassActions ? 1 : 0) + (hasItemActions ? 1 : 0)}
|
||||||
className="px-3 py-12 text-center"
|
className="px-3 py-12 text-center"
|
||||||
>
|
>
|
||||||
<Loader2 className="mx-auto h-6 w-6 animate-spin" />
|
<Loader2 className="mx-auto h-6 w-6 animate-spin" />
|
||||||
@@ -1256,7 +1745,7 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
|||||||
) : items.length === 0 ? (
|
) : items.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
colSpan={list.columns.length + (hasMassActions ? 1 : 0) + (hasItemActions ? 1 : 0)}
|
colSpan={displayColumns.length + (hasMassActions ? 1 : 0) + (hasItemActions ? 1 : 0)}
|
||||||
className="px-3 py-12 text-center text-muted-foreground"
|
className="px-3 py-12 text-center text-muted-foreground"
|
||||||
>
|
>
|
||||||
{t('list.noResults', 'No results found')}
|
{t('list.noResults', 'No results found')}
|
||||||
@@ -1272,7 +1761,7 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
|||||||
onClick={() => handleRowClick(item)}
|
onClick={() => handleRowClick(item)}
|
||||||
>
|
>
|
||||||
{hasMassActions && (
|
{hasMassActions && (
|
||||||
<td className="px-3 py-2" onClick={(e) => e.stopPropagation()}>
|
<td className="px-3 py-2 whitespace-nowrap" onClick={(e) => e.stopPropagation()}>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={selectedIds.has(itemId)}
|
checked={selectedIds.has(itemId)}
|
||||||
onCheckedChange={() => toggleSelectItem(itemId)}
|
onCheckedChange={() => toggleSelectItem(itemId)}
|
||||||
@@ -1280,19 +1769,47 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
|||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
)}
|
)}
|
||||||
{list.columns.map((col) => (
|
{displayColumns.map((col) => (
|
||||||
<td key={col.name} className="px-3 py-2">
|
<td key={col.name} className="px-3 py-2 whitespace-nowrap">
|
||||||
{renderCellValue(
|
{isWebApplications && col.name === 'enabled' && !fields[col.name] ? (
|
||||||
|
item.enabled === true ? (
|
||||||
|
<Check className="h-4 w-4 text-green-600" />
|
||||||
|
) : (
|
||||||
|
<X className="h-4 w-4 text-red-500" />
|
||||||
|
)
|
||||||
|
) : isAccountsList && col.name === 'roles' ? (
|
||||||
|
formatUserRole(item, schema!)
|
||||||
|
) : hasQuotaUsageColumn && col.name === 'quotaUsage' ? (
|
||||||
|
renderQuotaUsage(item, t)
|
||||||
|
) : col.name in COUNT_COLUMN_SOURCES && activeCountColumns.includes(col.name) ? (
|
||||||
|
getCountColumnValue(col.name, item)
|
||||||
|
) : isMailboxList && col.name === 'name' ? (
|
||||||
|
(() => {
|
||||||
|
const depth = mailboxDepths.get(item.id as string) ?? 0;
|
||||||
|
return (
|
||||||
|
<div style={{ paddingLeft: depth * 20 }} className="flex items-center gap-1.5">
|
||||||
|
{depth > 0 && (
|
||||||
|
<CornerDownRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
<span>{String(item.name ?? '')}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()
|
||||||
|
) : (
|
||||||
|
renderCellValue(
|
||||||
item[col.name],
|
item[col.name],
|
||||||
fields[col.name],
|
fields[col.name],
|
||||||
col.name,
|
col.name,
|
||||||
schema!,
|
schema!,
|
||||||
resolved.obj.objectName,
|
resolved.obj.objectName,
|
||||||
getDisplayName,
|
getDisplayName,
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
{hasItemActions && <td className="px-3 py-2 text-right">{renderItemActions(item)}</td>}
|
{hasItemActions && (
|
||||||
|
<td className="px-3 py-2 text-right whitespace-nowrap">{renderItemActions(item)}</td>
|
||||||
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
@@ -1303,8 +1820,8 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{items.length > 0 && (
|
{items.length > 0 && (
|
||||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between text-sm text-muted-foreground">
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
{total !== null
|
{total !== null
|
||||||
? t('list.showing', 'Showing {{from}}-{{to}} of {{total}} {{name}}', {
|
? t('list.showing', 'Showing {{from}}-{{to}} of {{total}} {{name}}', {
|
||||||
from: rangeStart,
|
from: rangeStart,
|
||||||
@@ -1316,7 +1833,7 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
|||||||
count: items.length,
|
count: items.length,
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex shrink-0 items-center gap-2 self-end sm:self-auto">
|
||||||
<Button variant="outline" size="sm" disabled={!hasPrevPage || loading} onClick={handlePrevPage}>
|
<Button variant="outline" size="sm" disabled={!hasPrevPage || loading} onClick={handlePrevPage}>
|
||||||
{t('list.previous', 'Previous')}
|
{t('list.previous', 'Previous')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react';
|
import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
|
||||||
import { DayPicker, getDefaultClassNames, type DayButton } from '@daypicker/react';
|
import { DayPicker, getDefaultClassNames, type DayButton } from '@daypicker/react';
|
||||||
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -29,7 +29,7 @@ function Calendar({
|
|||||||
<DayPicker
|
<DayPicker
|
||||||
showOutsideDays={showOutsideDays}
|
showOutsideDays={showOutsideDays}
|
||||||
className={cn(
|
className={cn(
|
||||||
'group/calendar bg-background p-3 [--cell-size:--spacing(8)] [[data-slot=popover-content]_&]:bg-transparent',
|
'group/calendar bg-background p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent',
|
||||||
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
||||||
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
||||||
className,
|
className,
|
||||||
@@ -93,29 +93,35 @@ function Calendar({
|
|||||||
range_start: cn('rounded-l-md bg-accent', defaultClassNames.range_start),
|
range_start: cn('rounded-l-md bg-accent', defaultClassNames.range_start),
|
||||||
range_middle: cn('rounded-none', defaultClassNames.range_middle),
|
range_middle: cn('rounded-none', defaultClassNames.range_middle),
|
||||||
range_end: cn('rounded-r-md bg-accent', defaultClassNames.range_end),
|
range_end: cn('rounded-r-md bg-accent', defaultClassNames.range_end),
|
||||||
today: cn(
|
today: cn('rounded-md bg-accent text-accent-foreground data-[selected=true]:rounded-none', defaultClassNames.today),
|
||||||
'rounded-md bg-accent text-accent-foreground data-[selected=true]:rounded-none',
|
|
||||||
defaultClassNames.today,
|
|
||||||
),
|
|
||||||
outside: cn('text-muted-foreground aria-selected:text-muted-foreground', defaultClassNames.outside),
|
outside: cn('text-muted-foreground aria-selected:text-muted-foreground', defaultClassNames.outside),
|
||||||
disabled: cn('text-muted-foreground opacity-50', defaultClassNames.disabled),
|
disabled: cn('text-muted-foreground opacity-50', defaultClassNames.disabled),
|
||||||
hidden: cn('invisible', defaultClassNames.hidden),
|
hidden: cn('invisible', defaultClassNames.hidden),
|
||||||
...classNames,
|
...classNames,
|
||||||
}}
|
}}
|
||||||
components={{
|
components={{
|
||||||
Root: ({ className, rootRef, ...props }) => (
|
Root: ({ className, rootRef, ...props }) => {
|
||||||
<div data-slot="calendar" ref={rootRef} className={cn(className)} {...props} />
|
return <div data-slot="calendar" ref={rootRef} className={cn(className)} {...props} />;
|
||||||
),
|
},
|
||||||
Chevron: ({ className, orientation, ...props }) => {
|
Chevron: ({ className, orientation, ...props }) => {
|
||||||
const Icon = orientation === 'left' ? ChevronLeft : orientation === 'right' ? ChevronRight : ChevronDown;
|
if (orientation === 'left') {
|
||||||
return <Icon className={cn('size-4', className)} {...props} />;
|
return <ChevronLeftIcon className={cn('size-4', className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (orientation === 'right') {
|
||||||
|
return <ChevronRightIcon className={cn('size-4', className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <ChevronDownIcon className={cn('size-4', className)} {...props} />;
|
||||||
},
|
},
|
||||||
DayButton: CalendarDayButton,
|
DayButton: CalendarDayButton,
|
||||||
WeekNumber: ({ children, ...props }) => (
|
WeekNumber: ({ children, ...props }) => {
|
||||||
|
return (
|
||||||
<td {...props}>
|
<td {...props}>
|
||||||
<div className="flex size-(--cell-size) items-center justify-center text-center">{children}</div>
|
<div className="flex size-(--cell-size) items-center justify-center text-center">{children}</div>
|
||||||
</td>
|
</td>
|
||||||
),
|
);
|
||||||
|
},
|
||||||
...components,
|
...components,
|
||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -123,7 +129,12 @@ function Calendar({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CalendarDayButton({ className, day, modifiers, ...props }: React.ComponentProps<typeof DayButton>) {
|
function CalendarDayButton({
|
||||||
|
className,
|
||||||
|
day,
|
||||||
|
modifiers,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DayButton>) {
|
||||||
const defaultClassNames = getDefaultClassNames();
|
const defaultClassNames = getDefaultClassNames();
|
||||||
|
|
||||||
const ref = React.useRef<HTMLButtonElement>(null);
|
const ref = React.useRef<HTMLButtonElement>(null);
|
||||||
|
|||||||
@@ -5,11 +5,27 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible';
|
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible';
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const Collapsible = CollapsiblePrimitive.Root;
|
const Collapsible = CollapsiblePrimitive.Root;
|
||||||
|
|
||||||
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
|
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
|
||||||
|
|
||||||
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
|
const CollapsibleContent = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof CollapsiblePrimitive.CollapsibleContent>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CollapsiblePrimitive.CollapsibleContent>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CollapsiblePrimitive.CollapsibleContent
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'overflow-hidden data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CollapsibleContent.displayName = CollapsiblePrimitive.CollapsibleContent.displayName;
|
||||||
|
|
||||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
|
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { Search } from 'lucide-react';
|
|||||||
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Dialog, DialogContent } from '@/components/ui/dialog';
|
import { Dialog, DialogContent } from '@/components/ui/dialog';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
|
||||||
const Command = React.forwardRef<
|
const Command = React.forwardRef<
|
||||||
React.ComponentRef<typeof CommandPrimitive>,
|
React.ComponentRef<typeof CommandPrimitive>,
|
||||||
@@ -30,7 +31,7 @@ Command.displayName = CommandPrimitive.displayName;
|
|||||||
const CommandDialog = ({ children, ...props }: DialogProps) => {
|
const CommandDialog = ({ children, ...props }: DialogProps) => {
|
||||||
return (
|
return (
|
||||||
<Dialog {...props}>
|
<Dialog {...props}>
|
||||||
<DialogContent className="overflow-hidden p-0">
|
<DialogContent className="overflow-hidden p-0" showCloseButton={false}>
|
||||||
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||||
{children}
|
{children}
|
||||||
</Command>
|
</Command>
|
||||||
@@ -41,8 +42,8 @@ const CommandDialog = ({ children, ...props }: DialogProps) => {
|
|||||||
|
|
||||||
const CommandInput = React.forwardRef<
|
const CommandInput = React.forwardRef<
|
||||||
React.ComponentRef<typeof CommandPrimitive.Input>,
|
React.ComponentRef<typeof CommandPrimitive.Input>,
|
||||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input> & { trailing?: React.ReactNode }
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||||
>(({ className, trailing, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
||||||
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
<CommandPrimitive.Input
|
<CommandPrimitive.Input
|
||||||
@@ -53,7 +54,9 @@ const CommandInput = React.forwardRef<
|
|||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
{trailing}
|
<kbd className="pointer-events-none ml-2 inline-flex h-5 shrink-0 select-none items-center rounded-md border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground">
|
||||||
|
ESC
|
||||||
|
</kbd>
|
||||||
</div>
|
</div>
|
||||||
));
|
));
|
||||||
CommandInput.displayName = CommandPrimitive.Input.displayName;
|
CommandInput.displayName = CommandPrimitive.Input.displayName;
|
||||||
@@ -62,11 +65,9 @@ const CommandList = React.forwardRef<
|
|||||||
React.ComponentRef<typeof CommandPrimitive.List>,
|
React.ComponentRef<typeof CommandPrimitive.List>,
|
||||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<CommandPrimitive.List
|
<ScrollArea viewportClassName="max-h-[300px]">
|
||||||
ref={ref}
|
<CommandPrimitive.List ref={ref} className={cn('overflow-x-hidden', className)} {...props} />
|
||||||
className={cn('max-h-[300px] overflow-y-auto overflow-x-hidden', className)}
|
</ScrollArea>
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
));
|
));
|
||||||
CommandList.displayName = CommandPrimitive.List.displayName;
|
CommandList.displayName = CommandPrimitive.List.displayName;
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,9 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
|||||||
|
|
||||||
const DialogContent = React.forwardRef<
|
const DialogContent = React.forwardRef<
|
||||||
React.ComponentRef<typeof DialogPrimitive.Content>,
|
React.ComponentRef<typeof DialogPrimitive.Content>,
|
||||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & { showCloseButton?: boolean }
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
||||||
|
showCloseButton?: boolean;
|
||||||
|
}
|
||||||
>(({ className, children, showCloseButton = true, ...props }, ref) => (
|
>(({ className, children, showCloseButton = true, ...props }, ref) => (
|
||||||
<DialogPortal>
|
<DialogPortal>
|
||||||
<DialogOverlay />
|
<DialogOverlay />
|
||||||
|
|||||||
@@ -81,12 +81,15 @@ const DropdownMenuItem = React.forwardRef<
|
|||||||
React.ComponentRef<typeof DropdownMenuPrimitive.Item>,
|
React.ComponentRef<typeof DropdownMenuPrimitive.Item>,
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||||
inset?: boolean;
|
inset?: boolean;
|
||||||
|
variant?: 'default' | 'destructive';
|
||||||
}
|
}
|
||||||
>(({ className, inset, ...props }, ref) => (
|
>(({ className, inset, variant = 'default', ...props }, ref) => (
|
||||||
<DropdownMenuPrimitive.Item
|
<DropdownMenuPrimitive.Item
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
data-variant={variant}
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0',
|
'relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0',
|
||||||
|
'data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive',
|
||||||
inset && 'pl-8',
|
inset && 'pl-8',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLI
|
|||||||
<input
|
<input
|
||||||
type={type}
|
type={type}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
'flex h-9 w-full rounded-md border border-input bg-field px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|||||||
@@ -11,10 +11,25 @@ import { cn } from '@/lib/utils';
|
|||||||
|
|
||||||
const ScrollArea = React.forwardRef<
|
const ScrollArea = React.forwardRef<
|
||||||
React.ComponentRef<typeof ScrollAreaPrimitive.Root>,
|
React.ComponentRef<typeof ScrollAreaPrimitive.Root>,
|
||||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> & {
|
||||||
>(({ className, children, ...props }, ref) => (
|
// Height caps (max-h-*) must be applied to the Viewport — the actual
|
||||||
<ScrollAreaPrimitive.Root ref={ref} className={cn('relative overflow-hidden', className)} {...props}>
|
// scroller — because a percentage height resolves to auto when the
|
||||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">{children}</ScrollAreaPrimitive.Viewport>
|
// Root's height is content-driven, which would break scrolling.
|
||||||
|
viewportClassName?: string;
|
||||||
|
}
|
||||||
|
>(({ className, viewportClassName, children, ...props }, ref) => (
|
||||||
|
<ScrollAreaPrimitive.Root ref={ref} className={cn('relative min-w-0 overflow-hidden', className)} {...props}>
|
||||||
|
{/*
|
||||||
|
Radix wraps children in a `display: table` div with an intrinsic min-width.
|
||||||
|
That lets wide tables expand the whole shell and clip under overflow-x:hidden,
|
||||||
|
so horizontal scroll never reaches the table's own overflow-x-auto wrapper.
|
||||||
|
Force the inner wrapper to shrink to the viewport width instead.
|
||||||
|
*/}
|
||||||
|
<ScrollAreaPrimitive.Viewport
|
||||||
|
className={cn('h-full w-full rounded-[inherit] [&>div]:!block [&>div]:!min-w-0', viewportClassName)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ScrollAreaPrimitive.Viewport>
|
||||||
<ScrollBar />
|
<ScrollBar />
|
||||||
<ScrollAreaPrimitive.Corner />
|
<ScrollAreaPrimitive.Corner />
|
||||||
</ScrollAreaPrimitive.Root>
|
</ScrollAreaPrimitive.Root>
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ const SelectTrigger = React.forwardRef<
|
|||||||
<SelectPrimitive.Trigger
|
<SelectPrimitive.Trigger
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
|
'flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-field px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const Switch = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<SwitchPrimitives.Root
|
<SwitchPrimitives.Root
|
||||||
className={cn(
|
className={cn(
|
||||||
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
|
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-green-600 data-[state=unchecked]:bg-red-600 disabled:data-[state=checked]:bg-primary disabled:data-[state=unchecked]:bg-input',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, React.TextareaHTMLAttribu
|
|||||||
return (
|
return (
|
||||||
<textarea
|
<textarea
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
'flex min-h-[60px] w-full rounded-md border border-input bg-field px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp
|
|||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||||
import { resolveSchema, resolveVariantForm, resolveForm } from '@/lib/schemaResolver';
|
import { resolveSchema, resolveVariantForm, resolveForm } from '@/lib/schemaResolver';
|
||||||
import { useObjectList, useObjectLabel } from '@/lib/objectOptions';
|
import { useObjectList, useObjectLabel } from '@/lib/objectOptions';
|
||||||
import { formatSize, formatDuration } from '@/lib/durationFormat';
|
import { formatDuration } from '@/lib/durationFormat';
|
||||||
|
import { SizeDisplay } from '@/components/common/SizeDisplay';
|
||||||
import type { Schema, Field, FieldType, FormField, Form, Fields, EnumVariant, ScalarType } from '@/types/schema';
|
import type { Schema, Field, FieldType, FormField, Form, Fields, EnumVariant, ScalarType } from '@/types/schema';
|
||||||
|
|
||||||
export interface DynamicViewProps {
|
export interface DynamicViewProps {
|
||||||
@@ -126,8 +127,8 @@ function ViewField({ label, field, value, schema }: { label: string; field: Fiel
|
|||||||
const isBlock = isBlockType(field.type, value);
|
const isBlock = isBlockType(field.type, value);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={isBlock ? 'space-y-1' : 'flex items-baseline gap-2'}>
|
<div className={isBlock ? 'space-y-1' : 'flex flex-col gap-1 sm:flex-row sm:items-baseline sm:gap-2'}>
|
||||||
<dt className="flex items-center gap-1 text-sm text-muted-foreground shrink-0 min-w-[140px]">
|
<dt className="flex items-center gap-1 text-sm text-muted-foreground shrink-0 sm:min-w-[140px]">
|
||||||
{label}
|
{label}
|
||||||
{field.description && (
|
{field.description && (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
@@ -144,7 +145,7 @@ function ViewField({ label, field, value, schema }: { label: string; field: Fiel
|
|||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
)}
|
)}
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="text-sm min-w-0">
|
<dd className="text-sm min-w-0 break-words">
|
||||||
<ViewValue type={field.type} value={value} schema={schema} />
|
<ViewValue type={field.type} value={value} schema={schema} />
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
@@ -224,7 +225,7 @@ function NumberValue({ value, format }: { value: unknown; format: string }) {
|
|||||||
|
|
||||||
switch (format) {
|
switch (format) {
|
||||||
case 'size':
|
case 'size':
|
||||||
return <span>{formatSize(num)}</span>;
|
return <SizeDisplay bytes={num} />;
|
||||||
case 'duration':
|
case 'duration':
|
||||||
return <span>{formatDuration(num)}</span>;
|
return <span>{formatDuration(num)}</span>;
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { CSSProperties, ReactNode } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Check, Moon, Sun } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { COLOR_THEMES, useUIStore } from '@/stores/uiStore';
|
||||||
|
|
||||||
|
/** Matches `:root { --radius: 0.5rem }` so only the Rounded choice stays curved in square mode. */
|
||||||
|
const ROUNDED_RADIUS_STYLE = { '--radius': '0.5rem' } as CSSProperties;
|
||||||
|
|
||||||
|
export function AppearancePage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const theme = useUIStore((s) => s.theme);
|
||||||
|
const setTheme = useUIStore((s) => s.setTheme);
|
||||||
|
const colorTheme = useUIStore((s) => s.colorTheme);
|
||||||
|
const setColorTheme = useUIStore((s) => s.setColorTheme);
|
||||||
|
const radius = useUIStore((s) => s.radius);
|
||||||
|
const setRadius = useUIStore((s) => s.setRadius);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-3xl space-y-6">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">{t('appearance.label', 'Appearance')}</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t('appearance.description', 'Customize how the interface looks and feels.')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">{t('appearance.mode', 'Mode')}</CardTitle>
|
||||||
|
<CardDescription>{t('appearance.modeDescription', 'Switch between light and dark mode.')}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid grid-cols-2 gap-3">
|
||||||
|
<OptionCard
|
||||||
|
selected={theme === 'light'}
|
||||||
|
onClick={() => setTheme('light')}
|
||||||
|
label={t('appearance.light', 'Light')}
|
||||||
|
>
|
||||||
|
<Sun className="h-5 w-5" />
|
||||||
|
</OptionCard>
|
||||||
|
<OptionCard selected={theme === 'dark'} onClick={() => setTheme('dark')} label={t('appearance.dark', 'Dark')}>
|
||||||
|
<Moon className="h-5 w-5" />
|
||||||
|
</OptionCard>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">{t('appearance.colorTheme', 'Color theme')}</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t('appearance.colorThemeDescription', 'Pick the accent color used across the interface.')}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
|
{COLOR_THEMES.map((entry) => (
|
||||||
|
<OptionCard
|
||||||
|
key={entry.value}
|
||||||
|
selected={colorTheme === entry.value}
|
||||||
|
onClick={() => setColorTheme(entry.value)}
|
||||||
|
label={t(entry.labelKey, entry.fallback)}
|
||||||
|
>
|
||||||
|
<span className="h-6 w-6 rounded-full border border-border" style={{ background: entry.swatch }} />
|
||||||
|
</OptionCard>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">{t('appearance.corners', 'Corners')}</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t('appearance.cornersDescription', 'Choose between rounded and square corners. Applies to every theme.')}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid grid-cols-2 gap-3">
|
||||||
|
<OptionCard
|
||||||
|
selected={radius === 'rounded'}
|
||||||
|
onClick={() => setRadius('rounded')}
|
||||||
|
label={t('appearance.rounded', 'Rounded')}
|
||||||
|
style={ROUNDED_RADIUS_STYLE}
|
||||||
|
>
|
||||||
|
<span className="h-6 w-10 rounded-md border-2 border-current" />
|
||||||
|
</OptionCard>
|
||||||
|
<OptionCard
|
||||||
|
selected={radius === 'square'}
|
||||||
|
onClick={() => setRadius('square')}
|
||||||
|
label={t('appearance.square', 'Square')}
|
||||||
|
className="rounded-none"
|
||||||
|
>
|
||||||
|
<span className="h-6 w-10 rounded-none border-2 border-current" />
|
||||||
|
</OptionCard>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OptionCard({
|
||||||
|
selected,
|
||||||
|
onClick,
|
||||||
|
label,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
}: {
|
||||||
|
selected: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
label: string;
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
style?: CSSProperties;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
aria-pressed={selected}
|
||||||
|
style={style}
|
||||||
|
className={cn(
|
||||||
|
'relative flex flex-col items-center gap-2 rounded-lg border bg-field p-4 text-sm transition-colors',
|
||||||
|
selected
|
||||||
|
? 'border-primary text-foreground'
|
||||||
|
: 'border-border text-muted-foreground hover:bg-accent/50 hover:text-foreground',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{selected && <Check className="absolute right-2 top-2 h-4 w-4 text-primary" />}
|
||||||
|
{children}
|
||||||
|
<span>{label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ExternalLink } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import type { Components } from 'react-markdown';
|
||||||
|
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import changelogSource from '../../../CHANGELOG.md?raw';
|
||||||
|
|
||||||
|
// Renders this fork's actual CHANGELOG.md — the single source of truth for
|
||||||
|
// release notes, kept up to date after every release — instead of a
|
||||||
|
// separately maintained copy that could drift from it.
|
||||||
|
const components: Components = {
|
||||||
|
h1: () => null,
|
||||||
|
h2: ({ children }) => (
|
||||||
|
<h2 className="mt-10 border-b pb-2 text-lg font-semibold tracking-tight first:mt-0">{children}</h2>
|
||||||
|
),
|
||||||
|
h3: ({ children }) => (
|
||||||
|
<h3 className="mt-5 mb-2 text-xs font-semibold tracking-wide text-muted-foreground uppercase">{children}</h3>
|
||||||
|
),
|
||||||
|
p: ({ children }) => <p className="text-sm text-muted-foreground">{children}</p>,
|
||||||
|
ul: ({ children }) => <ul className="mt-1 list-disc space-y-1.5 pl-5 text-sm">{children}</ul>,
|
||||||
|
li: ({ children }) => <li className="leading-relaxed">{children}</li>,
|
||||||
|
a: ({ children, href }) => (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-primary underline underline-offset-2 hover:no-underline"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
code: ({ children }) => <code className="rounded bg-muted px-1 py-0.5 font-mono text-xs">{children}</code>,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ChangelogPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-3xl space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-start gap-3 p-4 sm:p-6">
|
||||||
|
<ExternalLink className="mt-0.5 h-4 w-4 shrink-0 text-primary" aria-hidden />
|
||||||
|
<p className="text-sm leading-relaxed">
|
||||||
|
{t(
|
||||||
|
'changelog.officialNotice',
|
||||||
|
'The official WebUI is maintained by the Stalwart team and is available at',
|
||||||
|
)}{' '}
|
||||||
|
<a
|
||||||
|
href="https://github.com/stalwartlabs/webui"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="font-medium text-primary underline underline-offset-2 hover:no-underline"
|
||||||
|
>
|
||||||
|
https://github.com/stalwartlabs/webui
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">{t('changelog.label', 'Changelog')}</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t('changelog.description', "What's new in this fork, release by release.")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ReactMarkdown components={components}>{changelogSource}</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { useAuthStore } from '@/stores/authStore';
|
||||||
|
import { jmapGetBatched } from '@/services/jmap/client';
|
||||||
|
|
||||||
|
export interface CurrentAccountDetails {
|
||||||
|
username: string | null;
|
||||||
|
email: string | null;
|
||||||
|
fullName: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetches the current user's x:Account record to expose the login name,
|
||||||
|
// email address and full name independently from the JMAP session account name.
|
||||||
|
export function useCurrentAccountDetails(): CurrentAccountDetails {
|
||||||
|
const { primaryAccountId } = useAuthStore();
|
||||||
|
const [details, setDetails] = useState<CurrentAccountDetails>({
|
||||||
|
username: null,
|
||||||
|
email: null,
|
||||||
|
fullName: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!primaryAccountId) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const list = await jmapGetBatched(
|
||||||
|
'x:Account',
|
||||||
|
primaryAccountId,
|
||||||
|
[primaryAccountId],
|
||||||
|
['id', 'name', 'emailAddress', 'description'],
|
||||||
|
);
|
||||||
|
const record = list[0];
|
||||||
|
if (!record || cancelled) return;
|
||||||
|
setDetails({
|
||||||
|
username: (record.name as string | undefined) ?? null,
|
||||||
|
email: (record.emailAddress as string | undefined) ?? null,
|
||||||
|
fullName: (record.description as string | undefined) ?? null,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch current account details:', err);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [primaryAccountId]);
|
||||||
|
|
||||||
|
return details;
|
||||||
|
}
|
||||||
@@ -6,13 +6,12 @@
|
|||||||
|
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
const APP_NAME = 'Stalwart WebUI';
|
const APP_NAME = 'Stalwart';
|
||||||
|
|
||||||
|
// Keeps the tab title in sync with the current page instead of the static
|
||||||
|
// index.html title; falls back to the bare app name when no page title is given.
|
||||||
export function useDocumentTitle(title?: string | null) {
|
export function useDocumentTitle(title?: string | null) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = title ? `${title} · ${APP_NAME}` : APP_NAME;
|
document.title = title ? `${APP_NAME} | ${title}` : APP_NAME;
|
||||||
return () => {
|
|
||||||
document.title = APP_NAME;
|
|
||||||
};
|
|
||||||
}, [title]);
|
}, [title]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,11 +34,13 @@ export function getActionInfo(
|
|||||||
objectKind: ObjectKind,
|
objectKind: ObjectKind,
|
||||||
t: (key: string, fallback: string) => string,
|
t: (key: string, fallback: string) => string,
|
||||||
): { label: string; Icon: typeof List } {
|
): { label: string; Icon: typeof List } {
|
||||||
if (objectKind === 'singleton') {
|
if (entryType === 'link') {
|
||||||
return { label: t('globalSearch.settings', 'Settings'), Icon: Settings };
|
return objectKind === 'singleton'
|
||||||
|
? { label: t('globalSearch.settings', 'Settings'), Icon: Settings }
|
||||||
|
: { label: t('globalSearch.list', 'List'), Icon: List };
|
||||||
}
|
}
|
||||||
return entryType === 'link'
|
return objectKind === 'singleton'
|
||||||
? { label: t('globalSearch.list', 'List'), Icon: List }
|
? { label: t('globalSearch.settings', 'Settings'), Icon: Settings }
|
||||||
: { label: t('globalSearch.create', 'Create'), Icon: Plus };
|
: { label: t('globalSearch.create', 'Create'), Icon: Plus };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,8 +50,10 @@ function getNavigationPath(
|
|||||||
section: string,
|
section: string,
|
||||||
viewName: string,
|
viewName: string,
|
||||||
): string {
|
): string {
|
||||||
if (objectKind === 'singleton') return `/${section}/${viewName}/singleton`;
|
if (entryType === 'link') {
|
||||||
return entryType === 'link' ? `/${section}/${viewName}` : `/${section}/${viewName}/new`;
|
return objectKind === 'singleton' ? `/${section}/${viewName}/singleton` : `/${section}/${viewName}`;
|
||||||
|
}
|
||||||
|
return objectKind === 'singleton' ? `/${section}/${viewName}/singleton` : `/${section}/${viewName}/new`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function friendlyName(viewName: string): string {
|
export function friendlyName(viewName: string): string {
|
||||||
@@ -124,10 +128,9 @@ export function useGlobalSearch(onAfterSelect?: () => void) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const reset = useCallback(() => {
|
const reset = useCallback(() => {
|
||||||
if (timerRef.current) clearTimeout(timerRef.current);
|
|
||||||
setQuery('');
|
setQuery('');
|
||||||
setDebouncedQuery('');
|
setDebouncedQuery('');
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return { query, setQuery: handleQueryChange, debouncedQuery, results, groups, selectEntry, reset, schema };
|
return { query, setQuery: handleQueryChange, results, groups, selectEntry, reset, schema };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,31 @@
|
|||||||
{
|
{
|
||||||
"accounts": "Accounts",
|
"accounts": "Accounts",
|
||||||
|
"appearance": {
|
||||||
|
"colorTheme": "Color theme",
|
||||||
|
"colorThemeDescription": "Pick the accent color used across the interface.",
|
||||||
|
"corners": "Corners",
|
||||||
|
"cornersDescription": "Choose between rounded and square corners. Applies to every theme.",
|
||||||
|
"dark": "Dark",
|
||||||
|
"description": "Customize how the interface looks and feels.",
|
||||||
|
"label": "Appearance",
|
||||||
|
"light": "Light",
|
||||||
|
"mode": "Mode",
|
||||||
|
"modeDescription": "Switch between light and dark mode.",
|
||||||
|
"rounded": "Rounded",
|
||||||
|
"square": "Square",
|
||||||
|
"switchToDark": "Switch to dark mode",
|
||||||
|
"switchToLight": "Switch to light mode",
|
||||||
|
"theme": {
|
||||||
|
"amber": "Amber",
|
||||||
|
"default": "Default",
|
||||||
|
"forest": "Forest",
|
||||||
|
"ocean": "Ocean",
|
||||||
|
"rose": "Rose",
|
||||||
|
"stalwart": "Stalwart",
|
||||||
|
"teal": "Teal",
|
||||||
|
"violet": "Violet"
|
||||||
|
}
|
||||||
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"actionFailed": "Action failed.",
|
"actionFailed": "Action failed.",
|
||||||
"backToActions": "Back to actions",
|
"backToActions": "Back to actions",
|
||||||
@@ -30,6 +56,11 @@
|
|||||||
"welcome": "Welcome to Stalwart",
|
"welcome": "Welcome to Stalwart",
|
||||||
"welcomeSubtitle": "Let's get your server set up."
|
"welcomeSubtitle": "Let's get your server set up."
|
||||||
},
|
},
|
||||||
|
"changelog": {
|
||||||
|
"description": "What's new in this fork, release by release.",
|
||||||
|
"label": "Changelog",
|
||||||
|
"officialNotice": "The official WebUI is maintained by the Stalwart team and is available at"
|
||||||
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"apply": "Apply",
|
"apply": "Apply",
|
||||||
"back": "Back",
|
"back": "Back",
|
||||||
@@ -171,6 +202,8 @@
|
|||||||
"notSet": "Not set",
|
"notSet": "Not set",
|
||||||
"optional": "(optional)",
|
"optional": "(optional)",
|
||||||
"pickDate": "Pick a date",
|
"pickDate": "Pick a date",
|
||||||
|
"hour": "Hour",
|
||||||
|
"minute": "Minute",
|
||||||
"required": "required",
|
"required": "required",
|
||||||
"selectEllipsis": "Select...",
|
"selectEllipsis": "Select...",
|
||||||
"selectKey": "Select key...",
|
"selectKey": "Select key...",
|
||||||
@@ -267,6 +300,8 @@
|
|||||||
"bulkSuccessUpdate_one": "{{count}} item updated successfully.",
|
"bulkSuccessUpdate_one": "{{count}} item updated successfully.",
|
||||||
"bulkSuccessUpdate_other": "{{count}} items updated successfully.",
|
"bulkSuccessUpdate_other": "{{count}} items updated successfully.",
|
||||||
"clearSelection": "Clear selection",
|
"clearSelection": "Clear selection",
|
||||||
|
"comboboxEmptyText": "No matches.",
|
||||||
|
"comboboxSearchPlaceholder": "Search...",
|
||||||
"confirmDescription": "Are you sure you want to proceed with: {{action}}?",
|
"confirmDescription": "Are you sure you want to proceed with: {{action}}?",
|
||||||
"confirmTitle": "Confirm Action",
|
"confirmTitle": "Confirm Action",
|
||||||
"create": "Create {{name}}",
|
"create": "Create {{name}}",
|
||||||
@@ -278,6 +313,8 @@
|
|||||||
"next": "Next",
|
"next": "Next",
|
||||||
"noResults": "No results found",
|
"noResults": "No results found",
|
||||||
"previous": "Previous",
|
"previous": "Previous",
|
||||||
|
"refresh": "Refresh",
|
||||||
|
"refreshCooldown": "Please wait a few seconds before refreshing again",
|
||||||
"resetFilters": "Reset",
|
"resetFilters": "Reset",
|
||||||
"searchFilters": "Search",
|
"searchFilters": "Search",
|
||||||
"selectAll": "Select all",
|
"selectAll": "Select all",
|
||||||
@@ -287,7 +324,10 @@
|
|||||||
"showing": "Showing {{from}}-{{to}} of {{total}} {{name}}",
|
"showing": "Showing {{from}}-{{to}} of {{total}} {{name}}",
|
||||||
"showingItems": "Showing {{count}} items",
|
"showingItems": "Showing {{count}} items",
|
||||||
"sort": "Sort",
|
"sort": "Sort",
|
||||||
"unknownError": "Unknown error"
|
"unknownError": "Unknown error",
|
||||||
|
"unlimitedQuota": "∞",
|
||||||
|
"negativeQuotaInfoAria": "Why is disk usage negative?",
|
||||||
|
"negativeQuotaTooltip": "This disk-usage counter is out of sync (often after a migration or reset). Schedule a task: Perform account maintenance operations → Recalculate storage quota usage for the account. Or for all accounts: Perform store maintenance operations → Reset all user quotas."
|
||||||
},
|
},
|
||||||
"login": {
|
"login": {
|
||||||
"continue": "Continue",
|
"continue": "Continue",
|
||||||
@@ -302,7 +342,7 @@
|
|||||||
},
|
},
|
||||||
"logout": "Logout",
|
"logout": "Logout",
|
||||||
"version": {
|
"version": {
|
||||||
"label": "Stalwart WebUI v{{version}}"
|
"label": "Stalwart WebUI Fork v{{version}}"
|
||||||
},
|
},
|
||||||
"oauth": {
|
"oauth": {
|
||||||
"backToLogin": "Back to login",
|
"backToLogin": "Back to login",
|
||||||
@@ -340,7 +380,6 @@
|
|||||||
"periodValue": "Period value"
|
"periodValue": "Period value"
|
||||||
},
|
},
|
||||||
"sections": "Sections",
|
"sections": "Sections",
|
||||||
"toggleTheme": "Toggle theme",
|
|
||||||
"tracing": {
|
"tracing": {
|
||||||
"addFilter": "Add filter",
|
"addFilter": "Add filter",
|
||||||
"bufferFull": "(buffer full)",
|
"bufferFull": "(buffer full)",
|
||||||
@@ -376,5 +415,10 @@
|
|||||||
"failedToLoad": "Failed to load",
|
"failedToLoad": "Failed to load",
|
||||||
"noGetResponse": "No get response",
|
"noGetResponse": "No get response",
|
||||||
"objectNotFound": "Object not found"
|
"objectNotFound": "Object not found"
|
||||||
|
},
|
||||||
|
"webApplications": {
|
||||||
|
"activeWebUI": "Active WebUI",
|
||||||
|
"version": "Version",
|
||||||
|
"enabled": "Enabled"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
--background: oklch(1 0 0);
|
--background: oklch(1 0 0);
|
||||||
--foreground: oklch(0.145 0.017 285.823);
|
--foreground: oklch(0.145 0.017 285.823);
|
||||||
|
|
||||||
@@ -37,6 +38,10 @@
|
|||||||
--input: oklch(0.922 0.007 285.823);
|
--input: oklch(0.922 0.007 285.823);
|
||||||
--ring: oklch(0.708 0.015 285.823);
|
--ring: oklch(0.708 0.015 285.823);
|
||||||
|
|
||||||
|
/* Form fields use the page background tone so cards read as elevated
|
||||||
|
surfaces (shadcn convention: inset fields on a raised card). */
|
||||||
|
--field: var(--background);
|
||||||
|
|
||||||
--radius: 0.5rem;
|
--radius: 0.5rem;
|
||||||
|
|
||||||
--chart-1: 220 70% 50%;
|
--chart-1: 220 70% 50%;
|
||||||
@@ -47,10 +52,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
|
color-scheme: dark;
|
||||||
--background: oklch(0.141 0.005 285.823);
|
--background: oklch(0.141 0.005 285.823);
|
||||||
--foreground: oklch(0.985 0.002 285.823);
|
--foreground: oklch(0.985 0.002 285.823);
|
||||||
|
|
||||||
--card: oklch(0.205 0.007 285.823);
|
--card: oklch(0.235 0.008 285.823);
|
||||||
--card-foreground: oklch(0.985 0.002 285.823);
|
--card-foreground: oklch(0.985 0.002 285.823);
|
||||||
|
|
||||||
--popover: oklch(0.205 0.007 285.823);
|
--popover: oklch(0.205 0.007 285.823);
|
||||||
@@ -77,6 +83,8 @@
|
|||||||
--input: oklch(0.274 0.009 285.823);
|
--input: oklch(0.274 0.009 285.823);
|
||||||
--ring: oklch(0.553 0.013 285.823);
|
--ring: oklch(0.553 0.013 285.823);
|
||||||
|
|
||||||
|
--field: var(--background);
|
||||||
|
|
||||||
--chart-1: 220 70% 60%;
|
--chart-1: 220 70% 60%;
|
||||||
--chart-2: 160 60% 55%;
|
--chart-2: 160 60% 55%;
|
||||||
--chart-3: 30 80% 60%;
|
--chart-3: 30 80% 60%;
|
||||||
@@ -84,6 +92,298 @@
|
|||||||
--chart-5: 340 75% 60%;
|
--chart-5: 340 75% 60%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Square mode: every radius token derives multiplicatively from --radius,
|
||||||
|
so zeroing it here flattens all corners (rounded-full stays untouched). */
|
||||||
|
:root[data-radius='square'] {
|
||||||
|
--radius: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Color themes. Each theme re-hues the neutral tokens at the same lightness
|
||||||
|
and chroma as the default palette (contrast is preserved) and replaces
|
||||||
|
primary/ring with the theme color. The neutral "Default" theme is the
|
||||||
|
bare :root/.dark above and needs no data-theme attribute. Dark theme
|
||||||
|
blocks must stay after their light counterpart: both selectors have the
|
||||||
|
same specificity, so source order decides. */
|
||||||
|
|
||||||
|
/* "Stalwart" theme: matches the brand colors from stalw.art's own site
|
||||||
|
(src/styles/tokens.css there) — brand red #db2d54/#ff4570 converted to
|
||||||
|
oklch, neutrals kept close to true gray (unlike the other theme
|
||||||
|
presets below, which lightly re-hue their neutrals) since that's what
|
||||||
|
the real site does: saturated color only on the accent, not the
|
||||||
|
backdrop. */
|
||||||
|
:root[data-theme='stalwart'] {
|
||||||
|
--foreground: oklch(0.147 0.009 285.3);
|
||||||
|
--content-background: oklch(0.974 0.003 286.4);
|
||||||
|
--primary: oklch(0.586 0.207 14.6);
|
||||||
|
--primary-foreground: oklch(0.985 0.002 285.3);
|
||||||
|
--secondary: oklch(0.965 0.005 285.3);
|
||||||
|
--secondary-foreground: oklch(0.205 0.017 285.3);
|
||||||
|
--muted: oklch(0.93 0.005 285.3);
|
||||||
|
--muted-foreground: oklch(0.431 0.016 285.7);
|
||||||
|
--accent: oklch(0.965 0.005 285.3);
|
||||||
|
--accent-foreground: oklch(0.205 0.017 285.3);
|
||||||
|
--border: oklch(0.926 0.005 286.3);
|
||||||
|
--input: oklch(0.926 0.005 286.3);
|
||||||
|
--ring: oklch(0.586 0.207 14.6);
|
||||||
|
--chart-1: 350 65% 52%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark[data-theme='stalwart'] {
|
||||||
|
--background: oklch(0.136 0.007 285.5);
|
||||||
|
--foreground: oklch(0.968 0.003 286.4);
|
||||||
|
--card: oklch(0.19 0.012 285.2);
|
||||||
|
--card-foreground: oklch(0.968 0.003 286.4);
|
||||||
|
--popover: oklch(0.221 0.016 285.1);
|
||||||
|
--popover-foreground: oklch(0.968 0.003 286.4);
|
||||||
|
--content-background: oklch(0.11 0.006 285.5);
|
||||||
|
--primary: oklch(0.672 0.221 12.3);
|
||||||
|
--primary-foreground: oklch(0.15 0.03 14.6);
|
||||||
|
--secondary: oklch(0.256 0.019 285);
|
||||||
|
--secondary-foreground: oklch(0.968 0.003 286.4);
|
||||||
|
--muted: oklch(0.256 0.019 285);
|
||||||
|
--muted-foreground: oklch(0.719 0.014 286);
|
||||||
|
--accent: oklch(0.28 0.02 285.1);
|
||||||
|
--accent-foreground: oklch(0.968 0.003 286.4);
|
||||||
|
--border: oklch(0.272 0.017 285.2);
|
||||||
|
--input: oklch(0.272 0.017 285.2);
|
||||||
|
--ring: oklch(0.672 0.221 12.3);
|
||||||
|
--chart-1: 350 80% 65%;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='ocean'] {
|
||||||
|
--foreground: oklch(0.145 0.02 264);
|
||||||
|
--content-background: oklch(0.965 0.008 264);
|
||||||
|
--primary: oklch(0.546 0.215 262.9);
|
||||||
|
--primary-foreground: oklch(0.985 0.002 264);
|
||||||
|
--secondary: oklch(0.955 0.012 264);
|
||||||
|
--secondary-foreground: oklch(0.3 0.035 264);
|
||||||
|
--muted: oklch(0.935 0.01 264);
|
||||||
|
--muted-foreground: oklch(0.556 0.022 264);
|
||||||
|
--accent: oklch(0.94 0.018 264);
|
||||||
|
--accent-foreground: oklch(0.3 0.035 264);
|
||||||
|
--border: oklch(0.912 0.012 264);
|
||||||
|
--input: oklch(0.912 0.012 264);
|
||||||
|
--ring: oklch(0.546 0.215 262.9);
|
||||||
|
--chart-1: 221 83% 53%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark[data-theme='ocean'] {
|
||||||
|
--background: oklch(0.152 0.014 264);
|
||||||
|
--foreground: oklch(0.96 0.008 264);
|
||||||
|
--card: oklch(0.235 0.02 264);
|
||||||
|
--card-foreground: oklch(0.96 0.008 264);
|
||||||
|
--popover: oklch(0.208 0.018 264);
|
||||||
|
--popover-foreground: oklch(0.96 0.008 264);
|
||||||
|
--content-background: oklch(0.122 0.012 264);
|
||||||
|
--primary: oklch(0.623 0.214 259.8);
|
||||||
|
--primary-foreground: oklch(0.985 0.002 264);
|
||||||
|
--secondary: oklch(0.28 0.022 264);
|
||||||
|
--secondary-foreground: oklch(0.96 0.008 264);
|
||||||
|
--muted: oklch(0.28 0.022 264);
|
||||||
|
--muted-foreground: oklch(0.715 0.022 264);
|
||||||
|
--accent: oklch(0.305 0.028 264);
|
||||||
|
--accent-foreground: oklch(0.96 0.008 264);
|
||||||
|
--border: oklch(0.29 0.024 264);
|
||||||
|
--input: oklch(0.29 0.024 264);
|
||||||
|
--ring: oklch(0.623 0.214 259.8);
|
||||||
|
--chart-1: 217 91% 60%;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='forest'] {
|
||||||
|
--foreground: oklch(0.145 0.02 150);
|
||||||
|
--content-background: oklch(0.965 0.008 150);
|
||||||
|
--primary: oklch(0.527 0.137 150.1);
|
||||||
|
--primary-foreground: oklch(0.985 0.004 150);
|
||||||
|
--secondary: oklch(0.955 0.012 150);
|
||||||
|
--secondary-foreground: oklch(0.3 0.035 150);
|
||||||
|
--muted: oklch(0.935 0.01 150);
|
||||||
|
--muted-foreground: oklch(0.556 0.022 150);
|
||||||
|
--accent: oklch(0.94 0.018 150);
|
||||||
|
--accent-foreground: oklch(0.3 0.035 150);
|
||||||
|
--border: oklch(0.912 0.012 150);
|
||||||
|
--input: oklch(0.912 0.012 150);
|
||||||
|
--ring: oklch(0.527 0.137 150.1);
|
||||||
|
--chart-1: 142 71% 45%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark[data-theme='forest'] {
|
||||||
|
--background: oklch(0.152 0.012 150);
|
||||||
|
--foreground: oklch(0.96 0.008 150);
|
||||||
|
--card: oklch(0.235 0.018 150);
|
||||||
|
--card-foreground: oklch(0.96 0.008 150);
|
||||||
|
--popover: oklch(0.208 0.016 150);
|
||||||
|
--popover-foreground: oklch(0.96 0.008 150);
|
||||||
|
--content-background: oklch(0.122 0.01 150);
|
||||||
|
--primary: oklch(0.723 0.219 149.6);
|
||||||
|
--primary-foreground: oklch(0.2 0.03 150);
|
||||||
|
--secondary: oklch(0.28 0.02 150);
|
||||||
|
--secondary-foreground: oklch(0.96 0.008 150);
|
||||||
|
--muted: oklch(0.28 0.02 150);
|
||||||
|
--muted-foreground: oklch(0.715 0.02 150);
|
||||||
|
--accent: oklch(0.305 0.024 150);
|
||||||
|
--accent-foreground: oklch(0.96 0.008 150);
|
||||||
|
--border: oklch(0.29 0.022 150);
|
||||||
|
--input: oklch(0.29 0.022 150);
|
||||||
|
--ring: oklch(0.723 0.219 149.6);
|
||||||
|
--chart-1: 142 69% 58%;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='violet'] {
|
||||||
|
--foreground: oklch(0.145 0.02 290);
|
||||||
|
--content-background: oklch(0.965 0.008 290);
|
||||||
|
--primary: oklch(0.541 0.281 293);
|
||||||
|
--primary-foreground: oklch(0.985 0.002 290);
|
||||||
|
--secondary: oklch(0.955 0.012 290);
|
||||||
|
--secondary-foreground: oklch(0.3 0.04 290);
|
||||||
|
--muted: oklch(0.935 0.01 290);
|
||||||
|
--muted-foreground: oklch(0.556 0.025 290);
|
||||||
|
--accent: oklch(0.94 0.02 290);
|
||||||
|
--accent-foreground: oklch(0.3 0.04 290);
|
||||||
|
--border: oklch(0.912 0.014 290);
|
||||||
|
--input: oklch(0.912 0.014 290);
|
||||||
|
--ring: oklch(0.541 0.281 293);
|
||||||
|
--chart-1: 262 83% 58%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark[data-theme='violet'] {
|
||||||
|
--background: oklch(0.152 0.014 290);
|
||||||
|
--foreground: oklch(0.96 0.008 290);
|
||||||
|
--card: oklch(0.235 0.02 290);
|
||||||
|
--card-foreground: oklch(0.96 0.008 290);
|
||||||
|
--popover: oklch(0.208 0.018 290);
|
||||||
|
--popover-foreground: oklch(0.96 0.008 290);
|
||||||
|
--content-background: oklch(0.122 0.012 290);
|
||||||
|
--primary: oklch(0.702 0.183 293.5);
|
||||||
|
--primary-foreground: oklch(0.21 0.03 293);
|
||||||
|
--secondary: oklch(0.28 0.024 290);
|
||||||
|
--secondary-foreground: oklch(0.96 0.008 290);
|
||||||
|
--muted: oklch(0.28 0.024 290);
|
||||||
|
--muted-foreground: oklch(0.715 0.024 290);
|
||||||
|
--accent: oklch(0.305 0.03 290);
|
||||||
|
--accent-foreground: oklch(0.96 0.008 290);
|
||||||
|
--border: oklch(0.29 0.026 290);
|
||||||
|
--input: oklch(0.29 0.026 290);
|
||||||
|
--ring: oklch(0.702 0.183 293.5);
|
||||||
|
--chart-1: 263 70% 66%;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='rose'] {
|
||||||
|
--foreground: oklch(0.145 0.02 12);
|
||||||
|
--content-background: oklch(0.965 0.008 12);
|
||||||
|
--primary: oklch(0.577 0.245 12);
|
||||||
|
--primary-foreground: oklch(0.985 0.002 12);
|
||||||
|
--secondary: oklch(0.955 0.012 12);
|
||||||
|
--secondary-foreground: oklch(0.3 0.035 12);
|
||||||
|
--muted: oklch(0.935 0.01 12);
|
||||||
|
--muted-foreground: oklch(0.556 0.022 12);
|
||||||
|
--accent: oklch(0.94 0.018 12);
|
||||||
|
--accent-foreground: oklch(0.3 0.035 12);
|
||||||
|
--border: oklch(0.912 0.012 12);
|
||||||
|
--input: oklch(0.912 0.012 12);
|
||||||
|
--ring: oklch(0.577 0.245 12);
|
||||||
|
--chart-1: 350 83% 55%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark[data-theme='rose'] {
|
||||||
|
--background: oklch(0.152 0.014 12);
|
||||||
|
--foreground: oklch(0.96 0.008 12);
|
||||||
|
--card: oklch(0.235 0.02 12);
|
||||||
|
--card-foreground: oklch(0.96 0.008 12);
|
||||||
|
--popover: oklch(0.208 0.018 12);
|
||||||
|
--popover-foreground: oklch(0.96 0.008 12);
|
||||||
|
--content-background: oklch(0.122 0.012 12);
|
||||||
|
--primary: oklch(0.704 0.191 12);
|
||||||
|
--primary-foreground: oklch(0.21 0.03 12);
|
||||||
|
--secondary: oklch(0.28 0.022 12);
|
||||||
|
--secondary-foreground: oklch(0.96 0.008 12);
|
||||||
|
--muted: oklch(0.28 0.022 12);
|
||||||
|
--muted-foreground: oklch(0.715 0.022 12);
|
||||||
|
--accent: oklch(0.305 0.028 12);
|
||||||
|
--accent-foreground: oklch(0.96 0.008 12);
|
||||||
|
--border: oklch(0.29 0.024 12);
|
||||||
|
--input: oklch(0.29 0.024 12);
|
||||||
|
--ring: oklch(0.704 0.191 12);
|
||||||
|
--chart-1: 350 80% 66%;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='amber'] {
|
||||||
|
--foreground: oklch(0.145 0.02 70);
|
||||||
|
--content-background: oklch(0.965 0.008 70);
|
||||||
|
--primary: oklch(0.65 0.16 70);
|
||||||
|
--primary-foreground: oklch(0.2 0.02 70);
|
||||||
|
--secondary: oklch(0.955 0.012 70);
|
||||||
|
--secondary-foreground: oklch(0.3 0.035 70);
|
||||||
|
--muted: oklch(0.935 0.01 70);
|
||||||
|
--muted-foreground: oklch(0.556 0.022 70);
|
||||||
|
--accent: oklch(0.94 0.018 70);
|
||||||
|
--accent-foreground: oklch(0.3 0.035 70);
|
||||||
|
--border: oklch(0.912 0.012 70);
|
||||||
|
--input: oklch(0.912 0.012 70);
|
||||||
|
--ring: oklch(0.65 0.16 70);
|
||||||
|
--chart-1: 43 96% 56%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark[data-theme='amber'] {
|
||||||
|
--background: oklch(0.152 0.014 70);
|
||||||
|
--foreground: oklch(0.96 0.008 70);
|
||||||
|
--card: oklch(0.235 0.02 70);
|
||||||
|
--card-foreground: oklch(0.96 0.008 70);
|
||||||
|
--popover: oklch(0.208 0.018 70);
|
||||||
|
--popover-foreground: oklch(0.96 0.008 70);
|
||||||
|
--content-background: oklch(0.122 0.012 70);
|
||||||
|
--primary: oklch(0.75 0.17 75);
|
||||||
|
--primary-foreground: oklch(0.2 0.02 70);
|
||||||
|
--secondary: oklch(0.28 0.022 70);
|
||||||
|
--secondary-foreground: oklch(0.96 0.008 70);
|
||||||
|
--muted: oklch(0.28 0.022 70);
|
||||||
|
--muted-foreground: oklch(0.715 0.022 70);
|
||||||
|
--accent: oklch(0.305 0.028 70);
|
||||||
|
--accent-foreground: oklch(0.96 0.008 70);
|
||||||
|
--border: oklch(0.29 0.024 70);
|
||||||
|
--input: oklch(0.29 0.024 70);
|
||||||
|
--ring: oklch(0.75 0.17 75);
|
||||||
|
--chart-1: 43 96% 62%;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='teal'] {
|
||||||
|
--foreground: oklch(0.145 0.02 195);
|
||||||
|
--content-background: oklch(0.965 0.008 195);
|
||||||
|
--primary: oklch(0.55 0.13 195);
|
||||||
|
--primary-foreground: oklch(0.985 0.002 195);
|
||||||
|
--secondary: oklch(0.955 0.012 195);
|
||||||
|
--secondary-foreground: oklch(0.3 0.035 195);
|
||||||
|
--muted: oklch(0.935 0.01 195);
|
||||||
|
--muted-foreground: oklch(0.556 0.022 195);
|
||||||
|
--accent: oklch(0.94 0.018 195);
|
||||||
|
--accent-foreground: oklch(0.3 0.035 195);
|
||||||
|
--border: oklch(0.912 0.012 195);
|
||||||
|
--input: oklch(0.912 0.012 195);
|
||||||
|
--ring: oklch(0.55 0.13 195);
|
||||||
|
--chart-1: 189 94% 43%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark[data-theme='teal'] {
|
||||||
|
--background: oklch(0.152 0.014 195);
|
||||||
|
--foreground: oklch(0.96 0.008 195);
|
||||||
|
--card: oklch(0.235 0.02 195);
|
||||||
|
--card-foreground: oklch(0.96 0.008 195);
|
||||||
|
--popover: oklch(0.208 0.018 195);
|
||||||
|
--popover-foreground: oklch(0.96 0.008 195);
|
||||||
|
--content-background: oklch(0.122 0.012 195);
|
||||||
|
--primary: oklch(0.68 0.13 195);
|
||||||
|
--primary-foreground: oklch(0.2 0.02 195);
|
||||||
|
--secondary: oklch(0.28 0.022 195);
|
||||||
|
--secondary-foreground: oklch(0.96 0.008 195);
|
||||||
|
--muted: oklch(0.28 0.022 195);
|
||||||
|
--muted-foreground: oklch(0.715 0.022 195);
|
||||||
|
--accent: oklch(0.305 0.028 195);
|
||||||
|
--accent-foreground: oklch(0.96 0.008 195);
|
||||||
|
--border: oklch(0.29 0.024 195);
|
||||||
|
--input: oklch(0.29 0.024 195);
|
||||||
|
--ring: oklch(0.68 0.13 195);
|
||||||
|
--chart-1: 189 90% 55%;
|
||||||
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
@@ -113,6 +413,7 @@
|
|||||||
--color-border: var(--border);
|
--color-border: var(--border);
|
||||||
--color-input: var(--input);
|
--color-input: var(--input);
|
||||||
--color-ring: var(--ring);
|
--color-ring: var(--ring);
|
||||||
|
--color-field: var(--field);
|
||||||
|
|
||||||
--color-chart-1: hsl(var(--chart-1));
|
--color-chart-1: hsl(var(--chart-1));
|
||||||
--color-chart-2: hsl(var(--chart-2));
|
--color-chart-2: hsl(var(--chart-2));
|
||||||
@@ -120,10 +421,31 @@
|
|||||||
--color-chart-4: hsl(var(--chart-4));
|
--color-chart-4: hsl(var(--chart-4));
|
||||||
--color-chart-5: hsl(var(--chart-5));
|
--color-chart-5: hsl(var(--chart-5));
|
||||||
|
|
||||||
--radius-sm: calc(var(--radius) - 4px);
|
--radius-sm: calc(var(--radius) * 0.5);
|
||||||
--radius-md: calc(var(--radius) - 2px);
|
--radius-md: calc(var(--radius) * 0.75);
|
||||||
--radius-lg: var(--radius);
|
--radius-lg: var(--radius);
|
||||||
--radius-xl: calc(var(--radius) + 4px);
|
--radius-xl: calc(var(--radius) * 1.5);
|
||||||
|
|
||||||
|
--animate-collapsible-down: collapsible-down 0.2s ease-out;
|
||||||
|
--animate-collapsible-up: collapsible-up 0.2s ease-out;
|
||||||
|
|
||||||
|
@keyframes collapsible-down {
|
||||||
|
from {
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
height: var(--radix-collapsible-content-height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes collapsible-up {
|
||||||
|
from {
|
||||||
|
height: var(--radix-collapsible-content-height);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
@@ -135,7 +457,3 @@
|
|||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark input[type='number'] {
|
|
||||||
color-scheme: dark;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Schema } from '@/types/schema';
|
||||||
|
import { clientSortable as sortable } from './schemaDeviationTypes';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SCHEMA-DEVIATION: account-quota-usage-column (see SCHEMA_DEVIATIONS.md)
|
||||||
|
*
|
||||||
|
* The Accounts list only shows Email/Full Name/Created At, hiding role and
|
||||||
|
* storage usage that otherwise require opening each account individually.
|
||||||
|
* `createdAt` is dropped to make room; `roles` is a real property so it
|
||||||
|
* renders through the normal field pipeline, but `quotaUsage` is synthetic
|
||||||
|
* (not a real server property) — DynamicList resolves it to the
|
||||||
|
* `usedDiskQuota` + `quotas.maxDiskQuota` pair and formats it specially.
|
||||||
|
*
|
||||||
|
* The Groups list gets the same `quotaUsage` column (groups have their own
|
||||||
|
* `usedDiskQuota`/`quotas`, same as users), but not `roles` — that column
|
||||||
|
* is specific to the Users list.
|
||||||
|
*
|
||||||
|
* SCHEMA-DEVIATION: account-alias-count-column (see SCHEMA_DEVIATIONS.md)
|
||||||
|
*
|
||||||
|
* Both lists also get a synthetic `aliasCount` column — DynamicList
|
||||||
|
* resolves it from the real `aliases` objectList property (an id-keyed
|
||||||
|
* map, per JMAP's objectList wire format) and renders its entry count.
|
||||||
|
*
|
||||||
|
* SCHEMA-DEVIATION: account-client-sort (see SCHEMA_DEVIATIONS.md)
|
||||||
|
*
|
||||||
|
* Email Address, Full Name (`description`), `quotaUsage`, and
|
||||||
|
* `aliasCount` are tagged `clientSortable` — DynamicList's generic
|
||||||
|
* client-sort mechanism picks this flag up from the column definition
|
||||||
|
* itself, the same way it already resolves `quotaUsage`/`aliasCount`,
|
||||||
|
* instead of hardcoding which lists/columns support it.
|
||||||
|
*/
|
||||||
|
export function withAccountListColumns(schema: Schema): Schema {
|
||||||
|
let lists = schema.lists;
|
||||||
|
|
||||||
|
const userList = lists['x:Account/User'];
|
||||||
|
if (userList) {
|
||||||
|
const columns = [
|
||||||
|
...userList.columns
|
||||||
|
.filter((c) => c.name !== 'createdAt')
|
||||||
|
.map((c) => (c.name === 'emailAddress' || c.name === 'description' ? sortable(c) : c)),
|
||||||
|
{ name: 'roles', label: 'Role' },
|
||||||
|
sortable({ name: 'quotaUsage', label: 'Usage / Quota' }),
|
||||||
|
sortable({ name: 'aliasCount', label: 'Aliases' }),
|
||||||
|
];
|
||||||
|
lists = { ...lists, 'x:Account/User': { ...userList, columns } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupList = lists['x:Account/Group'];
|
||||||
|
if (groupList) {
|
||||||
|
const columns = [
|
||||||
|
...groupList.columns.map((c) => (c.name === 'emailAddress' || c.name === 'description' ? sortable(c) : c)),
|
||||||
|
sortable({ name: 'quotaUsage', label: 'Usage / Quota' }),
|
||||||
|
sortable({ name: 'aliasCount', label: 'Aliases' }),
|
||||||
|
];
|
||||||
|
lists = { ...lists, 'x:Account/Group': { ...groupList, columns } };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...schema, lists };
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Schema } from '@/types/schema';
|
||||||
|
import { clientSortable } from './schemaDeviationTypes';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SCHEMA-DEVIATION: account-alias-count-column (see SCHEMA_DEVIATIONS.md)
|
||||||
|
*
|
||||||
|
* The Domains list doesn't expose alias-domain count as a column, only
|
||||||
|
* the full `aliases` set on the detail view. Adds the same synthetic
|
||||||
|
* `aliasCount` column already used on Accounts/Groups/Mailing Lists —
|
||||||
|
* DynamicList's generic count-column handling picks it up with no
|
||||||
|
* further wiring.
|
||||||
|
*
|
||||||
|
* SCHEMA-DEVIATION: account-client-sort (see SCHEMA_DEVIATIONS.md)
|
||||||
|
*
|
||||||
|
* Domain Name, Enabled, and `aliasCount` are tagged `clientSortable`.
|
||||||
|
* `name` is actually accepted by the live server's `sort` (unlike every
|
||||||
|
* other property tried on every other list — see SCHEMA_DEVIATIONS.md),
|
||||||
|
* but the schema still doesn't declare it in `list.sort`, so it's routed
|
||||||
|
* through the same client-sort mechanism as the rest for consistency
|
||||||
|
* rather than adding a second, one-off "trust an undeclared sort"
|
||||||
|
* pathway for a single column.
|
||||||
|
*/
|
||||||
|
export function withDomainColumns(schema: Schema): Schema {
|
||||||
|
const list = schema.lists['x:Domain'];
|
||||||
|
if (!list) return schema;
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
...list.columns.map((c) => (c.name === 'name' || c.name === 'isEnabled' ? clientSortable(c) : c)),
|
||||||
|
clientSortable({ name: 'aliasCount', label: 'Aliases' }),
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
...schema,
|
||||||
|
lists: {
|
||||||
|
...schema.lists,
|
||||||
|
'x:Domain': { ...list, columns },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -68,6 +68,15 @@ describe('bytesToHuman', () => {
|
|||||||
it('should convert 1099511627776 bytes to 1 TB', () => {
|
it('should convert 1099511627776 bytes to 1 TB', () => {
|
||||||
expect(bytesToHuman(1099511627776)).toEqual({ value: 1, unit: 'TB' });
|
expect(bytesToHuman(1099511627776)).toEqual({ value: 1, unit: 'TB' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should format negative byte counts with a minus sign', () => {
|
||||||
|
expect(bytesToHuman(-9515272)).toEqual({ value: -9.07, unit: 'MB' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should treat non-finite byte counts as empty usage', () => {
|
||||||
|
expect(bytesToHuman(Number.NaN)).toEqual({ value: 0, unit: 'B' });
|
||||||
|
expect(bytesToHuman(Number.POSITIVE_INFINITY)).toEqual({ value: 0, unit: 'B' });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('humanToBytes', () => {
|
describe('humanToBytes', () => {
|
||||||
@@ -108,6 +117,10 @@ describe('formatSize', () => {
|
|||||||
it('should format large values in TB', () => {
|
it('should format large values in TB', () => {
|
||||||
expect(formatSize(1099511627776)).toBe('1 TB');
|
expect(formatSize(1099511627776)).toBe('1 TB');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should format negative byte counts with a minus sign', () => {
|
||||||
|
expect(formatSize(-9515272)).toBe('-9.07 MB');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('msToHuman', () => {
|
describe('msToHuman', () => {
|
||||||
|
|||||||
@@ -15,15 +15,18 @@ const SIZE_FACTORS: Record<string, number> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function bytesToHuman(bytes: number): { value: number; unit: string } {
|
export function bytesToHuman(bytes: number): { value: number; unit: string } {
|
||||||
if (bytes === 0) return { value: 0, unit: 'B' };
|
if (!Number.isFinite(bytes) || bytes === 0) return { value: 0, unit: 'B' };
|
||||||
|
|
||||||
|
const sign = bytes < 0 ? -1 : 1;
|
||||||
|
const abs = Math.abs(bytes);
|
||||||
|
|
||||||
for (let i = SIZE_UNITS.length - 1; i >= 0; i--) {
|
for (let i = SIZE_UNITS.length - 1; i >= 0; i--) {
|
||||||
const unit = SIZE_UNITS[i];
|
const unit = SIZE_UNITS[i];
|
||||||
const factor = SIZE_FACTORS[unit];
|
const factor = SIZE_FACTORS[unit];
|
||||||
const v = bytes / factor;
|
const v = abs / factor;
|
||||||
if (v >= 1) {
|
if (v >= 1) {
|
||||||
const rounded = Math.round(v * 100) / 100;
|
const rounded = Math.round(v * 100) / 100;
|
||||||
return { value: rounded, unit };
|
return { value: sign * rounded, unit };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { isLinkAccessible } from './layout';
|
||||||
|
import type { Layout, Schema } from '@/types/schema';
|
||||||
|
|
||||||
|
type CanGet = (prefix: string) => boolean;
|
||||||
|
type HasPermission = (permission: string) => boolean;
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'stalwart-last-visited';
|
||||||
|
|
||||||
|
// Stores the last visited view name for a given top-level section in localStorage.
|
||||||
|
export function setLastVisitedSection(section: string, viewName: string): void {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
const data = raw ? (JSON.parse(raw) as Record<string, string>) : {};
|
||||||
|
data[section] = viewName;
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
|
||||||
|
} catch {
|
||||||
|
// Ignore storage errors (e.g. private mode).
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retrieves the last visited view name for a section, or null if none is stored.
|
||||||
|
export function getLastVisitedSection(section: string): string | null {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
const data = JSON.parse(raw) as Record<string, string>;
|
||||||
|
return data[section] ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the stored view name only if it is still accessible in the target layout.
|
||||||
|
export function findLastVisitedLinkInLayout(
|
||||||
|
schema: Schema,
|
||||||
|
layout: Layout,
|
||||||
|
edition: string,
|
||||||
|
canGet: CanGet,
|
||||||
|
hasPermission: HasPermission,
|
||||||
|
): string | null {
|
||||||
|
const last = getLastVisitedSection(layout.name);
|
||||||
|
if (!last) return null;
|
||||||
|
if (isLinkAccessible(schema, last, edition, canGet, hasPermission)) return last;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Schema } from '@/types/schema';
|
||||||
|
import type { ClientOnlyFilterEnum } from './schemaDeviationTypes';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SCHEMA-DEVIATION: log-client-filters (see SCHEMA_DEVIATIONS.md)
|
||||||
|
*
|
||||||
|
* The Stalwart JMAP backend rejects `level`/`event` as filter conditions on
|
||||||
|
* `x:Log/query` (`unsupportedFilter`), even though both properties are
|
||||||
|
* already returned per row. Until the backend adds real support, these two
|
||||||
|
* filters are appended client-side and applied entirely in the browser
|
||||||
|
* (see the `clientOnly` flag consumed by DynamicList) instead of being sent
|
||||||
|
* to the server.
|
||||||
|
*/
|
||||||
|
export function withClientLogFilters(schema: Schema): Schema {
|
||||||
|
const logList = schema.lists['x:Log'];
|
||||||
|
if (!logList || !schema.enums['TracingLevel'] || !schema.enums['EventType']) return schema;
|
||||||
|
|
||||||
|
const levelFilter: ClientOnlyFilterEnum = {
|
||||||
|
type: 'enum',
|
||||||
|
field: 'level',
|
||||||
|
enumName: 'TracingLevel',
|
||||||
|
label: 'Level',
|
||||||
|
clientOnly: true,
|
||||||
|
};
|
||||||
|
const eventFilter: ClientOnlyFilterEnum = {
|
||||||
|
type: 'enum',
|
||||||
|
field: 'event',
|
||||||
|
enumName: 'EventType',
|
||||||
|
label: 'Event',
|
||||||
|
clientOnly: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...schema,
|
||||||
|
lists: {
|
||||||
|
...schema.lists,
|
||||||
|
'x:Log': {
|
||||||
|
...logList,
|
||||||
|
filters: [...(logList.filters ?? []), levelFilter, eventFilter],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||