Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8804c944ce | ||
|
|
eda20cbe2f | ||
|
|
41b3824894 | ||
|
|
f5524eb45a | ||
|
|
637d8bb286 | ||
|
|
410a01700b | ||
|
|
26a3878faa | ||
|
|
503cae465f | ||
|
|
9cd21a2d34 | ||
|
|
d9b073ad6c | ||
|
|
face5b4161 | ||
|
|
93b10c3ed1 | ||
|
|
518ec4059a | ||
|
|
3241dea5e4 | ||
|
|
0aa92c394c | ||
|
|
09910a7bf9 | ||
|
|
95053d7dc8 | ||
|
|
52123bce29 | ||
|
|
40164a6652 | ||
|
|
7fab3540ea | ||
|
|
18264a5b76 | ||
|
|
adca6d8730 | ||
|
|
f4c8f8f21c | ||
|
|
38abba39f8 | ||
|
|
101b24b361 | ||
|
|
4a6340e83f | ||
|
|
137e6c6ed9 | ||
|
|
4c953e599d | ||
|
|
8cab61a9c5 | ||
|
|
d84784c9d9 | ||
|
|
c40d1c69d8 | ||
|
|
a4b324bb8b | ||
|
|
08382472f4 | ||
|
|
75823ebb21 | ||
|
|
0dec14c3f8 | ||
|
|
1ab574a42a | ||
|
|
d1284de415 | ||
|
|
3189be2de0 | ||
|
|
eda9c9c8bf | ||
|
|
4e089d9b6b | ||
|
|
8b5b9c8f8e | ||
|
|
bd00b4e922 | ||
|
|
8c1e826d63 | ||
|
|
7a96fa6786 | ||
|
|
189e270785 | ||
|
|
3e08dcaff5 | ||
|
|
2ac3526da9 | ||
|
|
3dda6d1527 | ||
|
|
3113e6309d | ||
|
|
21b183327c | ||
|
|
da2c99990f | ||
|
|
25a38cd560 | ||
|
|
fe24a9bec0 | ||
|
|
b9489a795b | ||
|
|
49b3d60378 | ||
|
|
532e560106 | ||
|
|
0a417c5123 | ||
|
|
0e41f4d18e | ||
|
|
a7e69eefa6 | ||
|
|
bfdf8f4ba2 | ||
|
|
87cabf3886 | ||
|
|
aa48b90323 | ||
|
|
231f2ec3ee | ||
|
|
5127a3bf8a | ||
|
|
e4223d8cb4 | ||
|
|
b1849230dc | ||
|
|
40c04555b4 | ||
|
|
5dac3490ee | ||
|
|
868dd5e877 | ||
|
|
eec7f2ada5 | ||
|
|
2ac9a6b90e | ||
|
|
dca6082039 | ||
|
|
544ee36a1e | ||
|
|
5152b07a9a | ||
|
|
2a872d2ccf | ||
|
|
0a8dfb44be | ||
|
|
09d43f1e9b | ||
|
|
272502e0a3 | ||
|
|
13c21fc6d5 | ||
|
|
23bef766ca | ||
|
|
0fb734a6c7 | ||
|
|
3f31a115c9 | ||
|
|
149524376b | ||
|
|
956212031a | ||
|
|
6d3ea6c6b1 | ||
|
|
c15cb22be4 | ||
|
|
bd16e64e51 | ||
|
|
8fde771ff2 | ||
|
|
da5eaf0558 | ||
|
|
eb57250409 | ||
|
|
a657ad1b93 | ||
|
|
7c97297ce6 | ||
|
|
4739f9ed92 | ||
|
|
a83e75a79f | ||
|
|
f18f3012aa | ||
|
|
5eea77346a | ||
|
|
ef99135cae | ||
|
|
5ce2a5acf7 | ||
|
|
e080a6e061 | ||
|
|
574de03e42 | ||
|
|
a7fda8bd6b | ||
|
|
001a1f3a15 | ||
|
|
782dde0573 | ||
|
|
cbe77f9e4a | ||
|
|
e9b9efa084 | ||
|
|
dee0f7fbe3 |
@@ -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]
|
||||||
|
```
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
VITE_API_BASE_URL=http://localhost:8080
|
VITE_API_BASE_URL=http://mx.astralmail.org:8080
|
||||||
VITE_OAUTH_CLIENT_ID=stalwart-webui
|
VITE_OAUTH_CLIENT_ID=stalwart-webui
|
||||||
#VITE_ACCESS_TOKEN=OPEN_SESAME
|
#VITE_ACCESS_TOKEN=OPEN_SESAME
|
||||||
VITE_OAUTH_SCOPES=
|
VITE_OAUTH_SCOPES=
|
||||||
|
|||||||
@@ -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,6 +2,165 @@
|
|||||||
|
|
||||||
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.1.3] - 2026-08-01
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- 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).
|
||||||
|
|
||||||
|
## [1.1.2] - 2026-08-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- 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".
|
||||||
|
- Changelog page in the header user dropdown, rendering this repository's `CHANGELOG.md` so release notes stay in sync with every release.
|
||||||
|
- 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
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- WebUI version is now displayed when hovering over the logo.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Properly serialize `date` filters when applying them to the list filter.
|
||||||
|
|
||||||
|
## [1.0.5] - 2026-06-21
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Redirect to `/login` when there is no refresh token.
|
||||||
|
- Include required JMAP capabilities in `using`.
|
||||||
|
- Default scopes omit `offline_access`.
|
||||||
|
|
||||||
|
## [1.0.4] - 2026-05-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Align `base32` alphabet with the server.
|
||||||
|
|
||||||
|
## [1.0.3] - 2026-05-05
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Broken "Delivery History" link on OSS/Community editions.
|
||||||
|
- Resolve object ids in map keys.
|
||||||
|
|
||||||
## [1.0.2] - 2026-04-30
|
## [1.0.2] - 2026-04-30
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -4,9 +4,11 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<base href="/" />
|
<base href="/" />
|
||||||
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
|
<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,11 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "stalwart-webui",
|
"name": "stalwart-webui-fork",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.1",
|
"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 .",
|
||||||
@@ -16,55 +19,56 @@
|
|||||||
"format:check": "prettier --check src/"
|
"format:check": "prettier --check src/"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
"@daypicker/react": "^10.0.1",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-alert-dialog": "^1.1.23",
|
||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
"@radix-ui/react-checkbox": "^1.3.11",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-collapsible": "^1.1.20",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dialog": "^1.1.23",
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
"@radix-ui/react-dropdown-menu": "^2.1.24",
|
||||||
"@radix-ui/react-popover": "^1.1.15",
|
"@radix-ui/react-label": "^2.1.15",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
"@radix-ui/react-popover": "^1.1.23",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-scroll-area": "^1.2.18",
|
||||||
"@radix-ui/react-separator": "^1.1.8",
|
"@radix-ui/react-select": "^2.3.7",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-separator": "^1.1.15",
|
||||||
"@radix-ui/react-switch": "^1.2.6",
|
"@radix-ui/react-slot": "^1.3.3",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-switch": "^1.3.7",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tabs": "^1.1.21",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.16",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"i18next": "^26.0.4",
|
"i18next": "^26.3.6",
|
||||||
"lucide-react": "^1.8.0",
|
"lucide-react": "1.28.0",
|
||||||
"otpauth": "^9.5.0",
|
"otpauth": "^9.5.1",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.8",
|
||||||
"react-i18next": "^17.0.2",
|
"react-i18next": "^17.0.11",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^7.14.0",
|
"react-router-dom": "^7.18.2",
|
||||||
"recharts": "^3.8.1",
|
"recharts": "^3.10.1",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"zustand": "^5.0.12"
|
"zustand": "^5.0.14"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^10.0.1",
|
||||||
"@tailwindcss/vite": "^4.2.2",
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
"@types/node": "^24.12.2",
|
"@types/node": "^26.1.2",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.17",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "6.0.5",
|
||||||
"eslint": "^9.39.4",
|
"eslint": "^10.8.0",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
"eslint-plugin-react-refresh": "^0.5.2",
|
"eslint-plugin-react-refresh": "^0.5.3",
|
||||||
"globals": "^17.4.0",
|
"globals": "^17.8.0",
|
||||||
"happy-dom": "^20.9.0",
|
"happy-dom": "^20.11.1",
|
||||||
"prettier": "^3.8.2",
|
"prettier": "^3.9.6",
|
||||||
"tailwindcss": "^4.2.2",
|
"tailwindcss": "^4.3.3",
|
||||||
"typescript": "~6.0.2",
|
"typescript": "~6.0.3",
|
||||||
"typescript-eslint": "^8.58.0",
|
"typescript-eslint": "^8.65.0",
|
||||||
"vite": "^8.0.4",
|
"vite": "8.2.0",
|
||||||
"vitest": "^4.1.4"
|
"vitest": "^4.1.10"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 65 KiB |
@@ -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: 202 KiB |
|
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."
|
||||||
@@ -4,14 +4,18 @@
|
|||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { Suspense } from 'react';
|
||||||
import { Outlet } from 'react-router-dom';
|
import { Outlet } from 'react-router-dom';
|
||||||
import { ErrorBoundary } from '@/components/layout/ErrorBoundary';
|
import { ErrorBoundary } from '@/components/layout/ErrorBoundary';
|
||||||
|
import { LoadingFallback } from '@/components/common/LoadingFallback';
|
||||||
import { Toaster } from '@/components/ui/toaster';
|
import { Toaster } from '@/components/ui/toaster';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
|
<Suspense fallback={<LoadingFallback fullScreen />}>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
</Suspense>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -88,9 +89,9 @@ export function BootstrapWizard() {
|
|||||||
const { obj, sch } = resolved;
|
const { obj, sch } = resolved;
|
||||||
|
|
||||||
const ctrl = new AbortController();
|
const ctrl = new AbortController();
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const accountId = getAccountId(obj.objectName);
|
const accountId = getAccountId(obj.objectName);
|
||||||
const responses = await jmapGet(obj.objectName, accountId, ['singleton'], fetchProperties, ctrl.signal);
|
const responses = await jmapGet(obj.objectName, accountId, ['singleton'], fetchProperties, ctrl.signal);
|
||||||
@@ -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} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
} from '@/components/ui/command';
|
||||||
|
import { friendlyName, getActionInfo, getObjectKind, useGlobalSearch } from '@/hooks/useGlobalSearch';
|
||||||
|
import type { SearchIndexEntry } from '@/stores/schemaStore';
|
||||||
|
|
||||||
|
interface CommandPaletteProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const closePalette = useCallback(() => onOpenChange(false), [onOpenChange]);
|
||||||
|
const { query, setQuery, results, groups, selectEntry, reset, schema } = useGlobalSearch(closePalette);
|
||||||
|
|
||||||
|
const GROUP_LABELS: Record<SearchIndexEntry['type'], string> = {
|
||||||
|
link: t('globalSearch.pages', 'Pages'),
|
||||||
|
form: t('globalSearch.formSections', 'Form Sections'),
|
||||||
|
field: t('globalSearch.fields', 'Fields'),
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) reset();
|
||||||
|
}, [open, reset]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="top-[15%] translate-y-0 overflow-hidden p-0" showCloseButton={false}>
|
||||||
|
<DialogTitle className="sr-only">{t('globalSearch.title', 'Search')}</DialogTitle>
|
||||||
|
<Command
|
||||||
|
shouldFilter={false}
|
||||||
|
loop
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<CommandInput
|
||||||
|
placeholder={t('globalSearch.placeholder', 'Search pages, fields, settings...')}
|
||||||
|
value={query}
|
||||||
|
onValueChange={setQuery}
|
||||||
|
/>
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>
|
||||||
|
{query.trim()
|
||||||
|
? t('globalSearch.noResults', 'No results found.')
|
||||||
|
: t('globalSearch.typeToSearch', 'Type to search the admin panel.')}
|
||||||
|
</CommandEmpty>
|
||||||
|
{Array.from(groups.entries()).map(([type, entries]) => (
|
||||||
|
<CommandGroup key={type} heading={GROUP_LABELS[type]}>
|
||||||
|
{entries.map((entry) => {
|
||||||
|
const flatIdx = results.indexOf(entry);
|
||||||
|
const objectKind = schema ? getObjectKind(schema, entry.viewName) : null;
|
||||||
|
const { label: actionLabel, Icon: ActionIcon } = getActionInfo(entry.type, objectKind, t);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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" />
|
||||||
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
|
<span className="truncate font-medium">{friendlyName(entry.text)}</span>
|
||||||
|
<span className="truncate text-xs text-muted-foreground">{entry.breadcrumb}</span>
|
||||||
|
</div>
|
||||||
|
<span className="ml-auto shrink-0 pl-2 text-xs text-muted-foreground">{actionLabel}</span>
|
||||||
|
</CommandItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CommandGroup>
|
||||||
|
))}
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,245 +0,0 @@
|
|||||||
/*
|
|
||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
|
||||||
*
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { Search, List, Settings, Plus } from 'lucide-react';
|
|
||||||
import { useSchemaStore, type SearchIndexEntry } from '@/stores/schemaStore';
|
|
||||||
import { useAccountStore } from '@/stores/accountStore';
|
|
||||||
import { resolveObject } from '@/lib/schemaResolver';
|
|
||||||
import type { Schema } from '@/types/schema';
|
|
||||||
|
|
||||||
const MAX_RESULTS = 15;
|
|
||||||
|
|
||||||
const TYPE_ORDER: Record<SearchIndexEntry['type'], number> = {
|
|
||||||
link: 0,
|
|
||||||
form: 1,
|
|
||||||
field: 2,
|
|
||||||
};
|
|
||||||
|
|
||||||
function getObjectKind(schema: Schema, viewName: string): 'singleton' | 'object' | null {
|
|
||||||
const resolved = resolveObject(schema, viewName);
|
|
||||||
if (!resolved) return null;
|
|
||||||
return resolved.objectType.type === 'singleton' ? 'singleton' : 'object';
|
|
||||||
}
|
|
||||||
|
|
||||||
function getActionInfo(
|
|
||||||
entryType: SearchIndexEntry['type'],
|
|
||||||
objectKind: 'singleton' | 'object' | null,
|
|
||||||
t: (key: string, fallback: string) => string,
|
|
||||||
): { label: string; Icon: typeof List } {
|
|
||||||
if (entryType === 'link') {
|
|
||||||
return objectKind === 'singleton'
|
|
||||||
? { label: t('globalSearch.settings', 'Settings'), Icon: Settings }
|
|
||||||
: { label: t('globalSearch.list', 'List'), Icon: List };
|
|
||||||
}
|
|
||||||
return objectKind === 'singleton'
|
|
||||||
? { label: t('globalSearch.settings', 'Settings'), Icon: Settings }
|
|
||||||
: { label: t('globalSearch.create', 'Create'), Icon: Plus };
|
|
||||||
}
|
|
||||||
|
|
||||||
function getNavigationPath(
|
|
||||||
entryType: SearchIndexEntry['type'],
|
|
||||||
objectKind: 'singleton' | 'object' | null,
|
|
||||||
section: string,
|
|
||||||
viewName: string,
|
|
||||||
): string {
|
|
||||||
const encodedView = viewName;
|
|
||||||
if (entryType === 'link') {
|
|
||||||
return objectKind === 'singleton' ? `/${section}/${encodedView}/singleton` : `/${section}/${encodedView}`;
|
|
||||||
}
|
|
||||||
return objectKind === 'singleton' ? `/${section}/${encodedView}/singleton` : `/${section}/${encodedView}/new`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function friendlyName(viewName: string): string {
|
|
||||||
const stripped = viewName.replace(/^x:/, '');
|
|
||||||
const parts = stripped.split('/');
|
|
||||||
return parts[parts.length - 1];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GlobalSearchProps {
|
|
||||||
onAfterSelect?: () => void;
|
|
||||||
autoFocus?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GlobalSearch({ onAfterSelect, autoFocus }: GlobalSearchProps = {}) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const GROUP_LABELS: Record<SearchIndexEntry['type'], string> = {
|
|
||||||
link: t('globalSearch.pages', 'Pages'),
|
|
||||||
form: t('globalSearch.formSections', 'Form Sections'),
|
|
||||||
field: t('globalSearch.fields', 'Fields'),
|
|
||||||
};
|
|
||||||
const [query, setQuery] = useState('');
|
|
||||||
const [debouncedQuery, setDebouncedQuery] = useState('');
|
|
||||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
|
||||||
const [activeIndex, setActiveIndex] = useState(-1);
|
|
||||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
const schema = useSchemaStore((s) => s.schema);
|
|
||||||
const searchIndex = useSchemaStore((s) => s.searchIndex);
|
|
||||||
const hasObjectPermission = useAccountStore((s) => s.hasObjectPermission);
|
|
||||||
|
|
||||||
const handleQueryChange = useCallback((value: string) => {
|
|
||||||
setQuery(value);
|
|
||||||
setActiveIndex(-1);
|
|
||||||
if (timerRef.current) clearTimeout(timerRef.current);
|
|
||||||
timerRef.current = setTimeout(() => setDebouncedQuery(value), 300);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (timerRef.current) clearTimeout(timerRef.current);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
function handleClickOutside(e: MouseEvent) {
|
|
||||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
|
||||||
setDropdownOpen(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
|
||||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const results = useMemo(() => {
|
|
||||||
if (!debouncedQuery.trim() || !schema) return [];
|
|
||||||
|
|
||||||
const tokens = debouncedQuery
|
|
||||||
.toLowerCase()
|
|
||||||
.split(/\s+/)
|
|
||||||
.filter((s) => s.length > 0);
|
|
||||||
if (tokens.length === 0) return [];
|
|
||||||
|
|
||||||
const filtered = searchIndex.filter((entry) => {
|
|
||||||
const haystack = (entry.text + ' ' + (entry.keywords?.join(' ') ?? '')).toLowerCase();
|
|
||||||
for (const token of tokens) {
|
|
||||||
if (!haystack.includes(token)) return false;
|
|
||||||
}
|
|
||||||
const resolved = resolveObject(schema, entry.viewName);
|
|
||||||
if (!resolved) return false;
|
|
||||||
return hasObjectPermission(resolved.permissionPrefix, 'Get');
|
|
||||||
});
|
|
||||||
|
|
||||||
filtered.sort((a, b) => TYPE_ORDER[a.type] - TYPE_ORDER[b.type]);
|
|
||||||
return filtered.slice(0, MAX_RESULTS);
|
|
||||||
}, [debouncedQuery, searchIndex, schema, hasObjectPermission]);
|
|
||||||
|
|
||||||
const groups = useMemo(() => {
|
|
||||||
const map = new Map<SearchIndexEntry['type'], SearchIndexEntry[]>();
|
|
||||||
for (const entry of results) {
|
|
||||||
const arr = map.get(entry.type);
|
|
||||||
if (arr) arr.push(entry);
|
|
||||||
else map.set(entry.type, [entry]);
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}, [results]);
|
|
||||||
|
|
||||||
const handleSelect = useCallback(
|
|
||||||
(entry: SearchIndexEntry) => {
|
|
||||||
if (!schema) return;
|
|
||||||
const objectKind = getObjectKind(schema, entry.viewName);
|
|
||||||
const path = getNavigationPath(entry.type, objectKind, entry.section, entry.viewName);
|
|
||||||
setDropdownOpen(false);
|
|
||||||
setQuery('');
|
|
||||||
setDebouncedQuery('');
|
|
||||||
navigate(path);
|
|
||||||
onAfterSelect?.();
|
|
||||||
},
|
|
||||||
[schema, navigate, onAfterSelect],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleKeyDown = useCallback(
|
|
||||||
(e: React.KeyboardEvent) => {
|
|
||||||
if (!dropdownOpen || results.length === 0) return;
|
|
||||||
if (e.key === 'ArrowDown') {
|
|
||||||
e.preventDefault();
|
|
||||||
setActiveIndex((i) => (i + 1) % results.length);
|
|
||||||
} else if (e.key === 'ArrowUp') {
|
|
||||||
e.preventDefault();
|
|
||||||
setActiveIndex((i) => (i - 1 + results.length) % results.length);
|
|
||||||
} else if (e.key === 'Enter' && activeIndex >= 0) {
|
|
||||||
e.preventDefault();
|
|
||||||
handleSelect(results[activeIndex]);
|
|
||||||
} else if (e.key === 'Escape') {
|
|
||||||
setDropdownOpen(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[dropdownOpen, results, activeIndex, handleSelect],
|
|
||||||
);
|
|
||||||
|
|
||||||
const showDropdown = dropdownOpen && debouncedQuery.trim().length > 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative w-full max-w-md" ref={containerRef}>
|
|
||||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
||||||
<input
|
|
||||||
ref={inputRef}
|
|
||||||
type="text"
|
|
||||||
value={query}
|
|
||||||
autoFocus={autoFocus}
|
|
||||||
onChange={(e) => {
|
|
||||||
handleQueryChange(e.target.value);
|
|
||||||
setDropdownOpen(true);
|
|
||||||
}}
|
|
||||||
onFocus={() => {
|
|
||||||
if (query.trim()) setDropdownOpen(true);
|
|
||||||
}}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 pl-9 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{showDropdown && (
|
|
||||||
<div className="absolute top-full left-0 z-50 mt-1 w-full rounded-md border bg-popover shadow-lg">
|
|
||||||
{results.length === 0 ? (
|
|
||||||
<div className="px-3 py-4 text-center text-sm text-muted-foreground">
|
|
||||||
{t('globalSearch.noResults', 'No results found.')}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="max-h-80 overflow-y-auto py-1">
|
|
||||||
{Array.from(groups.entries()).map(([type, entries]) => (
|
|
||||||
<div key={type}>
|
|
||||||
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">{GROUP_LABELS[type]}</div>
|
|
||||||
{entries.map((entry) => {
|
|
||||||
const flatIdx = results.indexOf(entry);
|
|
||||||
const objectKind = schema ? getObjectKind(schema, entry.viewName) : null;
|
|
||||||
const { label: actionLabel, Icon: ActionIcon } = getActionInfo(entry.type, objectKind, t);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={`${type}-${entry.viewName}-${flatIdx}`}
|
|
||||||
type="button"
|
|
||||||
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-accent ${
|
|
||||||
flatIdx === activeIndex ? 'bg-accent' : ''
|
|
||||||
}`}
|
|
||||||
onMouseDown={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
handleSelect(entry);
|
|
||||||
}}
|
|
||||||
onMouseEnter={() => setActiveIndex(flatIdx)}
|
|
||||||
>
|
|
||||||
<ActionIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
||||||
<div className="flex flex-1 flex-col overflow-hidden">
|
|
||||||
<span className="truncate font-medium">{friendlyName(entry.text)}</span>
|
|
||||||
<span className="truncate text-xs text-muted-foreground">{entry.breadcrumb}</span>
|
|
||||||
</div>
|
|
||||||
<span className="shrink-0 text-xs text-muted-foreground">{actionLabel}</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
interface LoadingFallbackProps {
|
||||||
|
fullScreen?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoadingFallback({ fullScreen }: LoadingFallbackProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<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" />
|
||||||
|
<p className="text-muted-foreground">{t('common.loading')}</p>
|
||||||
|
</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,50 +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) {
|
if (logo.status === 'loading') {
|
||||||
return <img src={logoUrl} alt={t('logo.alt', 'Logo')} className="h-7 w-auto max-w-[220px] object-contain" />;
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,8 +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 { useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useBufferedValue } from '@/hooks/useBufferedValue';
|
||||||
|
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -21,11 +22,7 @@ function BufferedExprInput({
|
|||||||
React.InputHTMLAttributes<HTMLInputElement>,
|
React.InputHTMLAttributes<HTMLInputElement>,
|
||||||
'onChange' | 'value'
|
'onChange' | 'value'
|
||||||
>) {
|
>) {
|
||||||
const [local, setLocal] = useState(value);
|
const [local, setLocal] = useBufferedValue(value);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setLocal(value);
|
|
||||||
}, [value]);
|
|
||||||
|
|
||||||
const commit = () => {
|
const commit = () => {
|
||||||
if (local !== value) onCommit(local);
|
if (local !== value) onCommit(local);
|
||||||
|
|||||||
@@ -5,12 +5,13 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
|
import { flushSync } from 'react-dom';
|
||||||
import { useNavigate, useBlocker } from 'react-router-dom';
|
import { useNavigate, useBlocker } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
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,
|
||||||
@@ -52,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';
|
||||||
@@ -166,11 +168,11 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
|
|||||||
return Array.from(set);
|
return Array.from(set);
|
||||||
}, [schema, resolved, viewName]);
|
}, [schema, resolved, viewName]);
|
||||||
|
|
||||||
useEffect(() => {
|
const [prevCreateInitKey, setPrevCreateInitKey] = useState<typeof resolved | undefined>(undefined);
|
||||||
if (!schema || !resolved) return;
|
if (isCreate && schema && resolved) {
|
||||||
|
if (resolved !== prevCreateInitKey) {
|
||||||
|
setPrevCreateInitKey(resolved);
|
||||||
const { obj, sch } = resolved;
|
const { obj, sch } = resolved;
|
||||||
|
|
||||||
if (isCreate) {
|
|
||||||
const staticFilters = resolved.list?.filtersStatic;
|
const staticFilters = resolved.list?.filtersStatic;
|
||||||
if (sch.type === 'multiple') {
|
if (sch.type === 'multiple') {
|
||||||
const variantFromFilter = typeof staticFilters?.['@type'] === 'string' ? staticFilters['@type'] : undefined;
|
const variantFromFilter = typeof staticFilters?.['@type'] === 'string' ? staticFilters['@type'] : undefined;
|
||||||
@@ -184,13 +186,19 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
|
|||||||
setFormData(defaults);
|
setFormData(defaults);
|
||||||
setOriginalData(defaults);
|
setOriginalData(defaults);
|
||||||
}
|
}
|
||||||
return;
|
}
|
||||||
|
} else if (prevCreateInitKey !== undefined) {
|
||||||
|
setPrevCreateInitKey(undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!schema || !resolved || isCreate) return;
|
||||||
|
const { obj, sch } = resolved;
|
||||||
|
|
||||||
const ctrl = new AbortController();
|
const ctrl = new AbortController();
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const accountId = getAccountId(obj.objectName);
|
const accountId = getAccountId(obj.objectName);
|
||||||
const ids = isSingleton ? ['singleton'] : [objectId];
|
const ids = isSingleton ? ['singleton'] : [objectId];
|
||||||
@@ -251,13 +259,14 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
|
|||||||
|
|
||||||
const blocker = useBlocker(isDirty && !saving);
|
const blocker = useBlocker(isDirty && !saving);
|
||||||
|
|
||||||
const [pendingNavAfterCreate, setPendingNavAfterCreate] = useState(false);
|
const navigateAfterCreate = useCallback(() => {
|
||||||
useEffect(() => {
|
flushSync(() => {
|
||||||
if (!pendingNavAfterCreate) return;
|
setOriginalData({ ...formData });
|
||||||
setPendingNavAfterCreate(false);
|
setServerCreatedProps(null);
|
||||||
|
});
|
||||||
const section = viewToSection[viewName] ?? '';
|
const section = viewToSection[viewName] ?? '';
|
||||||
navigate(`/${section}/${viewName}`);
|
navigate(`/${section}/${viewName}`);
|
||||||
}, [pendingNavAfterCreate, viewName, viewToSection, navigate]);
|
}, [formData, viewToSection, viewName, navigate]);
|
||||||
|
|
||||||
const handleFieldChange = useCallback((fieldName: string, value: unknown) => {
|
const handleFieldChange = useCallback((fieldName: string, value: unknown) => {
|
||||||
setFormData((prev) => ({ ...prev, [fieldName]: value }));
|
setFormData((prev) => ({ ...prev, [fieldName]: value }));
|
||||||
@@ -350,9 +359,7 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
|
|||||||
newFieldErrors[ve.property] = validationErrorMessage(ve);
|
newFieldErrors[ve.property] = validationErrorMessage(ve);
|
||||||
} else if (ve.property) {
|
} else if (ve.property) {
|
||||||
const detail = ve.value && ve.value.length > 0 ? ve.value : ve.type;
|
const detail = ve.value && ve.value.length > 0 ? ve.value : ve.type;
|
||||||
setGeneralError((prev) =>
|
setGeneralError((prev) => (prev ? `${prev}\n${ve.property}: ${detail}` : `${ve.property}: ${detail}`));
|
||||||
prev ? `${prev}\n${ve.property}: ${detail}` : `${ve.property}: ${detail}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -736,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="space-y-6 max-w-4xl">
|
<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" />
|
||||||
@@ -767,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>
|
||||||
@@ -829,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)}>
|
||||||
@@ -838,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>
|
||||||
)}
|
)}
|
||||||
@@ -930,9 +946,7 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
|
|||||||
open={serverCreatedProps !== null && createdObjectId !== null}
|
open={serverCreatedProps !== null && createdObjectId !== null}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setOriginalData({ ...formData });
|
navigateAfterCreate();
|
||||||
setServerCreatedProps(null);
|
|
||||||
setPendingNavAfterCreate(true);
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -965,15 +979,7 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
|
|||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button onClick={navigateAfterCreate}>{t('common.continue', 'Continue')}</Button>
|
||||||
onClick={() => {
|
|
||||||
setOriginalData({ ...formData });
|
|
||||||
setServerCreatedProps(null);
|
|
||||||
setPendingNavAfterCreate(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t('common.continue', 'Continue')}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -4,8 +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, type KeyboardEvent } from 'react';
|
import { useState, useEffect, useMemo, type KeyboardEvent } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useBufferedValue, useResetOnChange } from '@/hooks/useBufferedValue';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -14,33 +15,37 @@ 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';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
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 { Plus, X, Eye, EyeOff, Loader2, Search, Check, ChevronRight } 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,
|
||||||
} from '@/lib/durationFormat';
|
} from '@/lib/durationFormat';
|
||||||
import { resolveSchema, resolveVariantForm, resolveObject, buildEmbeddedDefaults } from '@/lib/schemaResolver';
|
import { resolveSchema, resolveVariantForm, resolveObject, buildEmbeddedDefaults } from '@/lib/schemaResolver';
|
||||||
|
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';
|
||||||
@@ -243,11 +248,7 @@ interface BufferedInputProps extends Omit<React.InputHTMLAttributes<HTMLInputEle
|
|||||||
}
|
}
|
||||||
|
|
||||||
function BufferedInput({ value, onCommit, onBlur, onKeyDown, ...rest }: BufferedInputProps) {
|
function BufferedInput({ value, onCommit, onBlur, onKeyDown, ...rest }: BufferedInputProps) {
|
||||||
const [local, setLocal] = useState(value);
|
const [local, setLocal] = useBufferedValue(value);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setLocal(value);
|
|
||||||
}, [value]);
|
|
||||||
|
|
||||||
const commit = () => {
|
const commit = () => {
|
||||||
if (local !== value) onCommit(local);
|
if (local !== value) onCommit(local);
|
||||||
@@ -278,11 +279,7 @@ interface BufferedTextareaProps extends Omit<React.TextareaHTMLAttributes<HTMLTe
|
|||||||
}
|
}
|
||||||
|
|
||||||
function BufferedTextarea({ value, onCommit, onBlur, ...rest }: BufferedTextareaProps) {
|
function BufferedTextarea({ value, onCommit, onBlur, ...rest }: BufferedTextareaProps) {
|
||||||
const [local, setLocal] = useState(value);
|
const [local, setLocal] = useBufferedValue(value);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setLocal(value);
|
|
||||||
}, [value]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Textarea
|
<Textarea
|
||||||
@@ -405,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}
|
||||||
@@ -463,8 +460,7 @@ function SecretInput({ value, onChange, readOnly, placeholder, minLength, maxLen
|
|||||||
const [localValue, setLocalValue] = useState(() => (value === SECRET_MASK || value === '' ? '' : value));
|
const [localValue, setLocalValue] = useState(() => (value === SECRET_MASK || value === '' ? '' : value));
|
||||||
const [isMasked, setIsMasked] = useState(() => value === SECRET_MASK);
|
const [isMasked, setIsMasked] = useState(() => value === SECRET_MASK);
|
||||||
|
|
||||||
/* eslint-disable react-hooks/set-state-in-effect */
|
useResetOnChange(value, () => {
|
||||||
useEffect(() => {
|
|
||||||
if (value === SECRET_MASK || value === '') {
|
if (value === SECRET_MASK || value === '') {
|
||||||
setLocalValue('');
|
setLocalValue('');
|
||||||
setIsMasked(value === SECRET_MASK);
|
setIsMasked(value === SECRET_MASK);
|
||||||
@@ -472,8 +468,7 @@ function SecretInput({ value, onChange, readOnly, placeholder, minLength, maxLen
|
|||||||
setLocalValue(value);
|
setLocalValue(value);
|
||||||
setIsMasked(false);
|
setIsMasked(false);
|
||||||
}
|
}
|
||||||
}, [value]);
|
});
|
||||||
/* eslint-enable react-hooks/set-state-in-effect */
|
|
||||||
|
|
||||||
const handleLocalChange = (v: string) => {
|
const handleLocalChange = (v: string) => {
|
||||||
setLocalValue(v);
|
setLocalValue(v);
|
||||||
@@ -513,6 +508,10 @@ function SecretInput({ value, onChange, readOnly, placeholder, minLength, maxLen
|
|||||||
minLength={minLength}
|
minLength={minLength}
|
||||||
maxLength={maxLength}
|
maxLength={maxLength}
|
||||||
rows={4}
|
rows={4}
|
||||||
|
autoComplete="off"
|
||||||
|
data-1p-ignore
|
||||||
|
data-lpignore="true"
|
||||||
|
data-bwignore
|
||||||
className={visible ? '' : 'tracking-widest'}
|
className={visible ? '' : 'tracking-widest'}
|
||||||
style={visible ? undefined : ({ WebkitTextSecurity: 'disc' } as React.CSSProperties)}
|
style={visible ? undefined : ({ WebkitTextSecurity: 'disc' } as React.CSSProperties)}
|
||||||
/>
|
/>
|
||||||
@@ -535,6 +534,10 @@ function SecretInput({ value, onChange, readOnly, placeholder, minLength, maxLen
|
|||||||
placeholder={displayPlaceholder}
|
placeholder={displayPlaceholder}
|
||||||
minLength={minLength}
|
minLength={minLength}
|
||||||
maxLength={maxLength}
|
maxLength={maxLength}
|
||||||
|
autoComplete="off"
|
||||||
|
data-1p-ignore
|
||||||
|
data-lpignore="true"
|
||||||
|
data-bwignore
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
/>
|
/>
|
||||||
{toggleBtn}
|
{toggleBtn}
|
||||||
@@ -607,13 +610,7 @@ function BufferedNumberInput({
|
|||||||
disabled,
|
disabled,
|
||||||
onCommit,
|
onCommit,
|
||||||
}: BufferedNumberInputProps) {
|
}: BufferedNumberInputProps) {
|
||||||
const [local, setLocal] = useState<string>(value != null ? String(value) : '');
|
const [local, setLocal] = useBufferedValue(value, (v) => (v != null ? String(v) : ''));
|
||||||
|
|
||||||
/* eslint-disable react-hooks/set-state-in-effect */
|
|
||||||
useEffect(() => {
|
|
||||||
setLocal(value != null ? String(value) : '');
|
|
||||||
}, [value]);
|
|
||||||
/* eslint-enable react-hooks/set-state-in-effect */
|
|
||||||
|
|
||||||
const commit = () => {
|
const commit = () => {
|
||||||
if (local === '') {
|
if (local === '') {
|
||||||
@@ -654,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} />;
|
||||||
}
|
}
|
||||||
@@ -666,13 +667,11 @@ function SizeInputEditable({ value, onChange, nullable }: Omit<SizeInputProps, '
|
|||||||
const [unit, setUnit] = useState(initHuman.unit);
|
const [unit, setUnit] = useState(initHuman.unit);
|
||||||
const [localStr, setLocalStr] = useState<string>(isNull ? '' : String(initHuman.value));
|
const [localStr, setLocalStr] = useState<string>(isNull ? '' : String(initHuman.value));
|
||||||
|
|
||||||
/* eslint-disable react-hooks/set-state-in-effect */
|
useResetOnChange(value, () => {
|
||||||
useEffect(() => {
|
|
||||||
const h = bytesToHuman(typeof value === 'number' ? value : 0);
|
const h = bytesToHuman(typeof value === 'number' ? value : 0);
|
||||||
setUnit(h.unit);
|
setUnit(h.unit);
|
||||||
setLocalStr(value == null ? '' : String(h.value));
|
setLocalStr(value == null ? '' : String(h.value));
|
||||||
}, [value]);
|
});
|
||||||
/* eslint-enable react-hooks/set-state-in-effect */
|
|
||||||
|
|
||||||
const commit = () => {
|
const commit = () => {
|
||||||
if (localStr === '') {
|
if (localStr === '') {
|
||||||
@@ -771,13 +770,11 @@ function DurationInputEditable({ value, onChange, nullable }: Omit<DurationInput
|
|||||||
const [unit, setUnit] = useState(initHuman.unit);
|
const [unit, setUnit] = useState(initHuman.unit);
|
||||||
const [localStr, setLocalStr] = useState<string>(isNull ? '' : String(initHuman.value));
|
const [localStr, setLocalStr] = useState<string>(isNull ? '' : String(initHuman.value));
|
||||||
|
|
||||||
/* eslint-disable react-hooks/set-state-in-effect */
|
useResetOnChange(value, () => {
|
||||||
useEffect(() => {
|
|
||||||
const h = msToHuman(typeof value === 'number' ? value : 0);
|
const h = msToHuman(typeof value === 'number' ? value : 0);
|
||||||
setUnit(h.unit);
|
setUnit(h.unit);
|
||||||
setLocalStr(value == null ? '' : String(h.value));
|
setLocalStr(value == null ? '' : String(h.value));
|
||||||
}, [value]);
|
});
|
||||||
/* eslint-enable react-hooks/set-state-in-effect */
|
|
||||||
|
|
||||||
const commit = () => {
|
const commit = () => {
|
||||||
if (localStr === '') {
|
if (localStr === '') {
|
||||||
@@ -842,37 +839,102 @@ interface DateTimeFieldProps {
|
|||||||
nullable?: boolean;
|
nullable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 toLocal = (iso: string): string => {
|
const parsed = useMemo(() => {
|
||||||
if (!iso) return '';
|
if (!strValue) return null;
|
||||||
try {
|
const d = new Date(strValue);
|
||||||
const d = new Date(iso);
|
return isNaN(d.getTime()) ? null : d;
|
||||||
if (isNaN(d.getTime())) return '';
|
|
||||||
return d.toISOString().slice(0, 16);
|
|
||||||
} catch {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const toIso = (local: string): string | null => {
|
|
||||||
if (!local) return nullable ? null : '';
|
|
||||||
return new Date(local).toISOString();
|
|
||||||
};
|
|
||||||
|
|
||||||
const [local, setLocal] = useState(() => toLocal(strValue));
|
|
||||||
|
|
||||||
/* eslint-disable react-hooks/set-state-in-effect */
|
|
||||||
useEffect(() => {
|
|
||||||
setLocal(toLocal(strValue));
|
|
||||||
}, [strValue]);
|
}, [strValue]);
|
||||||
/* eslint-enable react-hooks/set-state-in-effect */
|
|
||||||
|
|
||||||
const commit = () => {
|
const timeValue = parsed
|
||||||
const iso = toIso(local);
|
? `${String(parsed.getHours()).padStart(2, '0')}:${String(parsed.getMinutes()).padStart(2, '0')}`
|
||||||
if (iso !== strValue) onChange(iso);
|
: '';
|
||||||
|
|
||||||
|
// 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 next = new Date(date);
|
||||||
|
next.setHours(Number.isFinite(hours) ? hours : 0, Number.isFinite(minutes) ? minutes : 0, 0, 0);
|
||||||
|
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) {
|
||||||
@@ -895,13 +957,52 @@ function DateTimeField({ value, onChange, readOnly, nullable }: DateTimeFieldPro
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Input
|
<Popover open={open} onOpenChange={handleOpenChange} modal={false}>
|
||||||
type="datetime-local"
|
<PopoverTrigger asChild>
|
||||||
value={local}
|
<Button
|
||||||
onChange={(e) => setLocal(e.target.value)}
|
type="button"
|
||||||
onBlur={commit}
|
variant="outline"
|
||||||
className="flex-1"
|
className={cn('flex-1 justify-start text-left font-normal', !parsed && 'text-muted-foreground')}
|
||||||
|
>
|
||||||
|
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||||
|
{parsed
|
||||||
|
? new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(parsed)
|
||||||
|
: t('field.pickDate', 'Pick a date')}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<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
|
||||||
|
mode="single"
|
||||||
|
selected={parsed ?? undefined}
|
||||||
|
defaultMonth={parsed ?? new Date()}
|
||||||
|
onSelect={(day) => {
|
||||||
|
if (!day) return;
|
||||||
|
commit(day, timeValue || formatClock(new Date()));
|
||||||
|
}}
|
||||||
|
autoFocus
|
||||||
/>
|
/>
|
||||||
|
<div className="flex items-center gap-2 border-t bg-background p-3">
|
||||||
|
<Clock className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||||
|
<DateTimeTimeSelect
|
||||||
|
value={timeValue || formatClock(new Date())}
|
||||||
|
disabled={!parsed}
|
||||||
|
onChange={(next) => {
|
||||||
|
if (parsed) commit(parsed, next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
{nullable && strValue && (
|
{nullable && strValue && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1005,9 +1106,9 @@ function BlobField({ value, onChange, readOnly }: BlobFieldProps) {
|
|||||||
if (!blobId || loaded) return;
|
if (!blobId || loaded) return;
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const accountId = getAccountId('x:Blob');
|
const accountId = getAccountId('x:Blob');
|
||||||
const responses = await jmapGet('Blob', accountId, [blobId], ['data:asText']);
|
const responses = await jmapGet('Blob', accountId, [blobId], ['data:asText']);
|
||||||
@@ -1253,12 +1354,8 @@ function RateField({ value, onChange, readOnly, nullable }: RateFieldProps) {
|
|||||||
const [localCount, setLocalCount] = useState(String(count));
|
const [localCount, setLocalCount] = useState(String(count));
|
||||||
const [localPeriod, setLocalPeriod] = useState(String(human.value));
|
const [localPeriod, setLocalPeriod] = useState(String(human.value));
|
||||||
|
|
||||||
useEffect(() => {
|
useResetOnChange(count, () => setLocalCount(String(count)));
|
||||||
setLocalCount(String(count));
|
useResetOnChange(human.value, () => setLocalPeriod(String(human.value)));
|
||||||
}, [count]);
|
|
||||||
useEffect(() => {
|
|
||||||
setLocalPeriod(String(human.value));
|
|
||||||
}, [human.value]);
|
|
||||||
|
|
||||||
const commitCount = () => {
|
const commitCount = () => {
|
||||||
const n = parseInt(localCount, 10);
|
const n = parseInt(localCount, 10);
|
||||||
@@ -1459,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>
|
||||||
@@ -1852,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>
|
||||||
)}
|
)}
|
||||||
@@ -1881,6 +1989,7 @@ function EnumMultiSelect({ enumName, items, onChange, readOnly, schema, minItems
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
@@ -1979,6 +2088,29 @@ function ObjectIdMultiSelectPill({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MapEntryKeyLabel({ keyClass, keyValue, schema }: { keyClass: ScalarType; keyValue: string; schema: Schema }) {
|
||||||
|
if (keyClass.type === 'enum') {
|
||||||
|
const variants = schema.enums[keyClass.enumName] ?? [];
|
||||||
|
const variant = variants.find((v) => v.name === keyValue);
|
||||||
|
return <>{variant?.label ?? keyValue}</>;
|
||||||
|
}
|
||||||
|
if (keyClass.type === 'objectId') {
|
||||||
|
return <ObjectIdKeyLabel objectName={keyClass.objectName} keyValue={keyValue} schema={schema} />;
|
||||||
|
}
|
||||||
|
return <>{keyValue}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ObjectIdKeyLabel({ objectName, keyValue, schema }: { objectName: string; keyValue: string; schema: Schema }) {
|
||||||
|
const list = useObjectList(objectName, schema);
|
||||||
|
const fromList = list.options.find((o) => o.id === keyValue)?.label;
|
||||||
|
const { label: cheapLabel, loading } = useObjectLabel(objectName, fromList ? null : keyValue, schema);
|
||||||
|
const display = fromList ?? cheapLabel;
|
||||||
|
if (loading && !display) {
|
||||||
|
return <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />;
|
||||||
|
}
|
||||||
|
return <>{display ?? keyValue}</>;
|
||||||
|
}
|
||||||
|
|
||||||
interface MapFieldProps {
|
interface MapFieldProps {
|
||||||
keyClass: ScalarType;
|
keyClass: ScalarType;
|
||||||
valueClass: MapValueType;
|
valueClass: MapValueType;
|
||||||
@@ -2038,15 +2170,6 @@ function MapField({ keyClass, valueClass, value, onChange, readOnly, schema, min
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getKeyLabel = (key: string): string => {
|
|
||||||
if (keyClass.type === 'enum') {
|
|
||||||
const variants = schema.enums[keyClass.enumName] ?? [];
|
|
||||||
const variant = variants.find((v) => v.name === key);
|
|
||||||
if (variant) return variant.label;
|
|
||||||
}
|
|
||||||
return key;
|
|
||||||
};
|
|
||||||
|
|
||||||
const existingKeys = new Set(Object.keys(mapValue));
|
const existingKeys = new Set(Object.keys(mapValue));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -2063,7 +2186,7 @@ function MapField({ keyClass, valueClass, value, onChange, readOnly, schema, min
|
|||||||
className="flex flex-1 items-center gap-2 p-3 text-sm font-medium hover:bg-accent/50 rounded-t-md transition-colors [&[data-state=closed]>svg]:rotate-0 [&[data-state=open]>svg]:rotate-90"
|
className="flex flex-1 items-center gap-2 p-3 text-sm font-medium hover:bg-accent/50 rounded-t-md transition-colors [&[data-state=closed]>svg]:rotate-0 [&[data-state=open]>svg]:rotate-90"
|
||||||
>
|
>
|
||||||
<ChevronRight className="h-4 w-4 shrink-0 transition-transform duration-200" />
|
<ChevronRight className="h-4 w-4 shrink-0 transition-transform duration-200" />
|
||||||
{getKeyLabel(key)}
|
<MapEntryKeyLabel keyClass={keyClass} keyValue={key} schema={schema} />
|
||||||
</button>
|
</button>
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
|
|||||||
@@ -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 { useEffect } 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';
|
||||||
@@ -12,11 +12,23 @@ import { resolveObject } from '@/lib/schemaResolver';
|
|||||||
import { DynamicList } from '@/components/lists/DynamicList';
|
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 { DashboardView } from '@/features/dashboard/components/DashboardView';
|
import { LoadingFallback } from '@/components/common/LoadingFallback';
|
||||||
import { DeliveryTracePage } from '@/features/troubleshoot/DeliveryTracePage';
|
|
||||||
import { LiveTracingPage } from '@/features/tracing/components/LiveTracingPage';
|
// Heavy or rarely used feature pages are code-split so the initial bundle
|
||||||
import { TraceDetailView } from '@/features/tracing/components/TraceDetailView';
|
// stays small (the dashboard pulls in recharts, ~150 kB gzipped on its own).
|
||||||
import { ActionPage } from '@/features/actions/ActionPage';
|
const DashboardView = lazy(() =>
|
||||||
|
import('@/features/dashboard/components/DashboardView').then((m) => ({ default: m.DashboardView })),
|
||||||
|
);
|
||||||
|
const DeliveryTracePage = lazy(() =>
|
||||||
|
import('@/features/troubleshoot/DeliveryTracePage').then((m) => ({ default: m.DeliveryTracePage })),
|
||||||
|
);
|
||||||
|
const LiveTracingPage = lazy(() =>
|
||||||
|
import('@/features/tracing/components/LiveTracingPage').then((m) => ({ default: m.LiveTracingPage })),
|
||||||
|
);
|
||||||
|
const TraceDetailView = lazy(() =>
|
||||||
|
import('@/features/tracing/components/TraceDetailView').then((m) => ({ default: m.TraceDetailView })),
|
||||||
|
);
|
||||||
|
const ActionPage = lazy(() => import('@/features/actions/ActionPage').then((m) => ({ default: m.ActionPage })));
|
||||||
|
|
||||||
interface MainContentProps {
|
interface MainContentProps {
|
||||||
viewName?: string;
|
viewName?: string;
|
||||||
@@ -25,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);
|
||||||
|
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ import { Navigate, useLocation } from 'react-router-dom';
|
|||||||
import { useAuthStore } from '@/stores/authStore';
|
import { useAuthStore } from '@/stores/authStore';
|
||||||
|
|
||||||
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||||
const accessToken = useAuthStore((s) => s.accessToken);
|
const authenticated = useAuthStore((s) => s.isAuthenticated());
|
||||||
const bypassToken = import.meta.env.VITE_ACCESS_TOKEN;
|
const bypassToken = import.meta.env.VITE_ACCESS_TOKEN;
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
if (!accessToken && !bypassToken) {
|
if (!authenticated && !bypassToken) {
|
||||||
const originalPath = location.pathname + location.search;
|
const originalPath = location.pathname + location.search;
|
||||||
return <Navigate to="/login" replace state={{ from: originalPath }} />;
|
return <Navigate to="/login" replace state={{ from: originalPath }} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { useEffect, useMemo, 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,6 +76,57 @@ function subtreeHasVisibleLink(items: LayoutSubItem[], edition: string): boolean
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AccordionLevelContextValue {
|
||||||
|
openId: string | null;
|
||||||
|
setOpenId: (id: string | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AccordionLevelContext = createContext<AccordionLevelContextValue | null>(null);
|
||||||
|
|
||||||
|
// Sibling collapsibles share a single open id, so expanding one collapses the
|
||||||
|
// others at the same level (accordion behavior).
|
||||||
|
function AccordionLevel({ children }: { children: React.ReactNode }) {
|
||||||
|
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 (
|
||||||
|
<Collapsible open={openId === id} onOpenChange={(open) => setOpenId(open ? id : null)}>
|
||||||
|
{children}
|
||||||
|
</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;
|
||||||
@@ -106,6 +159,11 @@ interface SidebarSubItemProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function SidebarSubItem({ item, depth, sectionName, currentPath, navigate, edition, onUpsell }: SidebarSubItemProps) {
|
function SidebarSubItem({ item, depth, sectionName, currentPath, navigate, edition, onUpsell }: SidebarSubItemProps) {
|
||||||
|
// Picking a plain link closes any sibling group left open at this same
|
||||||
|
// accordion level — it's not part of a collapsible, so nothing should
|
||||||
|
// stay expanded on its account once it's the one that's active.
|
||||||
|
const level = useContext(AccordionLevelContext);
|
||||||
|
|
||||||
if (item.type === 'link') {
|
if (item.type === 'link') {
|
||||||
if (!checkLinkVisible(item.viewName)) return null;
|
if (!checkLinkVisible(item.viewName)) return null;
|
||||||
|
|
||||||
@@ -120,9 +178,11 @@ function SidebarSubItem({ item, depth, sectionName, currentPath, navigate, editi
|
|||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
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` }}
|
||||||
@@ -130,6 +190,8 @@ function SidebarSubItem({ item, depth, sectionName, currentPath, navigate, editi
|
|||||||
if (isLocked) {
|
if (isLocked) {
|
||||||
onUpsell();
|
onUpsell();
|
||||||
} else {
|
} else {
|
||||||
|
level?.setOpenId(null);
|
||||||
|
setLastVisitedSection(sectionName, item.viewName);
|
||||||
navigate(path);
|
navigate(path);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -145,11 +207,11 @@ function SidebarSubItem({ item, depth, sectionName, currentPath, navigate, editi
|
|||||||
|
|
||||||
const containsActive = subtreeContainsActive(item.items, currentPath, sectionName);
|
const containsActive = subtreeContainsActive(item.items, currentPath, sectionName);
|
||||||
return (
|
return (
|
||||||
<Collapsible defaultOpen={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]" />
|
||||||
@@ -157,6 +219,7 @@ function SidebarSubItem({ item, depth, sectionName, currentPath, navigate, editi
|
|||||||
</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}
|
||||||
@@ -169,8 +232,9 @@ function SidebarSubItem({ item, depth, sectionName, currentPath, navigate, editi
|
|||||||
onUpsell={onUpsell}
|
onUpsell={onUpsell}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
</AccordionLevel>
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</Collapsible>
|
</AccordionCollapsible>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,6 +251,11 @@ interface SidebarTopItemProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function SidebarTopItem({ item, sectionName, currentPath, navigate, edition, onUpsell }: SidebarTopItemProps) {
|
function SidebarTopItem({ item, sectionName, currentPath, navigate, edition, onUpsell }: SidebarTopItemProps) {
|
||||||
|
// Picking a plain link closes any sibling group left open at this same
|
||||||
|
// accordion level — it's not part of a collapsible, so nothing should
|
||||||
|
// stay expanded on its account once it's the one that's active.
|
||||||
|
const level = useContext(AccordionLevelContext);
|
||||||
|
|
||||||
if ('link' in item) {
|
if ('link' in item) {
|
||||||
const { name, icon, viewName } = item.link;
|
const { name, icon, viewName } = item.link;
|
||||||
|
|
||||||
@@ -203,11 +272,18 @@ function SidebarTopItem({ item, sectionName, currentPath, navigate, edition, onU
|
|||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className={cn('w-full justify-start gap-2 font-normal', isActive && 'bg-accent text-accent-foreground')}
|
data-sidebar-active={isActive || undefined}
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -226,15 +302,16 @@ function SidebarTopItem({ item, sectionName, currentPath, navigate, edition, onU
|
|||||||
const containsActive = subtreeContainsActive(items, currentPath, sectionName);
|
const containsActive = subtreeContainsActive(items, currentPath, sectionName);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Collapsible defaultOpen={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}
|
||||||
@@ -247,8 +324,9 @@ function SidebarTopItem({ item, sectionName, currentPath, navigate, edition, onU
|
|||||||
onUpsell={onUpsell}
|
onUpsell={onUpsell}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
</AccordionLevel>
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</Collapsible>
|
</AccordionCollapsible>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,31 +342,43 @@ export function Sidebar() {
|
|||||||
const setSidebarOpen = useUIStore((s) => s.setSidebarOpen);
|
const setSidebarOpen = useUIStore((s) => s.setSidebarOpen);
|
||||||
const schema = useSchemaStore((s) => s.schema);
|
const schema = useSchemaStore((s) => s.schema);
|
||||||
const edition = useAccountStore((s) => s.edition);
|
const edition = useAccountStore((s) => s.edition);
|
||||||
const hasObjectPermission = useAccountStore((s) => s.hasObjectPermission);
|
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 navRef = useRef<HTMLElement>(null);
|
||||||
|
// Set by handleSectionClick right before navigating: switching sections
|
||||||
|
// 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);
|
||||||
|
|
||||||
const layouts = useMemo(
|
// Build the permission checks from the permissions array itself: the store
|
||||||
() =>
|
// accessors are stable refs, so depending on them alone would keep a stale
|
||||||
schema ? visibleLayouts(schema, edition, (prefix) => hasObjectPermission(prefix, 'Get'), hasPermission) : [],
|
// layout list after access data finishes loading.
|
||||||
[schema, edition, hasObjectPermission, hasPermission],
|
const layouts = useMemo(() => {
|
||||||
);
|
if (!schema) return [];
|
||||||
|
const canGet = (prefix: string) => permissions.includes(`${prefix}Get`);
|
||||||
useEffect(() => {
|
return visibleLayouts(schema, edition, canGet, hasPermission);
|
||||||
if (!schema) return;
|
}, [schema, edition, permissions, hasPermission]);
|
||||||
if (layouts.length === 0) return;
|
|
||||||
if (!layouts.find((l) => l.name === activeSection)) {
|
|
||||||
setActiveSection(layouts[0].name);
|
|
||||||
}
|
|
||||||
}, [schema, layouts, activeSection, setActiveSection]);
|
|
||||||
|
|
||||||
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(() => {
|
||||||
|
const active = navRef.current?.querySelector('[data-sidebar-active="true"]');
|
||||||
|
active?.scrollIntoView({ block: 'nearest' });
|
||||||
|
}, [location.pathname, activeSection]);
|
||||||
|
|
||||||
if (!sidebarOpen || !schema) return null;
|
if (!sidebarOpen || !schema) return null;
|
||||||
|
|
||||||
const layout: Layout | undefined = layouts.find((l) => l.name === activeSection);
|
const layout: Layout | undefined = layouts.find((l) => l.name === activeSection);
|
||||||
@@ -296,11 +386,16 @@ export function Sidebar() {
|
|||||||
|
|
||||||
const handleSectionClick = (target: Layout) => {
|
const handleSectionClick = (target: Layout) => {
|
||||||
setActiveSection(target.name);
|
setActiveSection(target.name);
|
||||||
const canGet = (prefix: string) => hasObjectPermission(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 (
|
||||||
@@ -311,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}
|
||||||
@@ -324,12 +423,13 @@ export function Sidebar() {
|
|||||||
onUpsell={() => setUpsellOpen(true)}
|
onUpsell={() => setUpsellOpen(true)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
</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
|
||||||
@@ -348,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,10 +7,9 @@
|
|||||||
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 { GlobalSearch } from '@/components/common/GlobalSearch';
|
import { CommandPalette } from '@/components/common/CommandPalette';
|
||||||
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -21,13 +20,17 @@ 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 { 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 { 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';
|
||||||
|
|
||||||
function getIcon(name: string): LucideIcons.LucideIcon {
|
function getIcon(name: string): LucideIcons.LucideIcon {
|
||||||
@@ -41,20 +44,35 @@ 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);
|
||||||
const schema = useSchemaStore((s) => s.schema);
|
const schema = useSchemaStore((s) => s.schema);
|
||||||
const [upsellOpen, setUpsellOpen] = useState(false);
|
const [upsellOpen, setUpsellOpen] = useState(false);
|
||||||
const [mobileSearchOpen, setMobileSearchOpen] = useState(false);
|
const [paletteOpen, setPaletteOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleGlobalKeyDown(e: KeyboardEvent) {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
|
||||||
|
e.preventDefault();
|
||||||
|
setPaletteOpen((open) => !open);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('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)
|
||||||
@@ -66,12 +84,33 @@ export function TopBar() {
|
|||||||
<Menu className="h-4 w-4" />
|
<Menu className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
<Link to="/" className="flex shrink-0 items-center">
|
<Link to="/" className="flex shrink-0 items-center">
|
||||||
<Logo />
|
<Logo />
|
||||||
</Link>
|
</Link>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">
|
||||||
|
{t('version.label', 'Stalwart WebUI Fork v{{version}}', { version: __APP_VERSION__ })}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
|
||||||
<div className="hidden min-w-0 flex-1 items-center justify-center px-4 md:flex">
|
<div className="hidden min-w-0 flex-1 items-center justify-center px-4 md:flex">
|
||||||
<GlobalSearch />
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPaletteOpen(true)}
|
||||||
|
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" />
|
||||||
|
<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">
|
||||||
|
{isMac ? '⌘K' : 'Ctrl K'}
|
||||||
|
</kbd>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="ml-auto flex items-center gap-2 md:ml-0">
|
<div className="ml-auto flex items-center gap-2 md:ml-0">
|
||||||
@@ -81,27 +120,24 @@ export function TopBar() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="md:hidden"
|
className="md:hidden"
|
||||||
onClick={() => setMobileSearchOpen(true)}
|
onClick={() => setPaletteOpen(true)}
|
||||||
aria-label={t('search', 'Search')}
|
aria-label={t('search', 'Search')}
|
||||||
>
|
>
|
||||||
<Search className="h-4 w-4" />
|
<Search className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Dialog open={mobileSearchOpen} onOpenChange={setMobileSearchOpen}>
|
<CommandPalette open={paletteOpen} onOpenChange={setPaletteOpen} />
|
||||||
<DialogContent className="top-4 translate-y-0 max-w-[calc(100vw-2rem)] p-4">
|
|
||||||
<DialogTitle className="sr-only">{t('search', 'Search')}</DialogTitle>
|
|
||||||
<GlobalSearch autoFocus onAfterSelect={() => setMobileSearchOpen(false)} />
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<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">
|
||||||
@@ -117,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) {
|
||||||
@@ -160,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();
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
|
||||||
|
import { DayPicker, getDefaultClassNames, type DayButton } from '@daypicker/react';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Button, buttonVariants } from '@/components/ui/button';
|
||||||
|
|
||||||
|
function Calendar({
|
||||||
|
className,
|
||||||
|
classNames,
|
||||||
|
showOutsideDays = true,
|
||||||
|
captionLayout = 'label',
|
||||||
|
buttonVariant = 'ghost',
|
||||||
|
formatters,
|
||||||
|
components,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DayPicker> & {
|
||||||
|
buttonVariant?: React.ComponentProps<typeof Button>['variant'];
|
||||||
|
}) {
|
||||||
|
const defaultClassNames = getDefaultClassNames();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DayPicker
|
||||||
|
showOutsideDays={showOutsideDays}
|
||||||
|
className={cn(
|
||||||
|
'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\_previous>svg]:rotate-180`,
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
captionLayout={captionLayout}
|
||||||
|
formatters={{
|
||||||
|
formatMonthDropdown: (date) => date.toLocaleString('default', { month: 'short' }),
|
||||||
|
...formatters,
|
||||||
|
}}
|
||||||
|
classNames={{
|
||||||
|
root: cn('w-fit', defaultClassNames.root),
|
||||||
|
months: cn('relative flex flex-col gap-4 md:flex-row', defaultClassNames.months),
|
||||||
|
month: cn('flex w-full flex-col gap-4', defaultClassNames.month),
|
||||||
|
nav: cn('absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1', defaultClassNames.nav),
|
||||||
|
button_previous: cn(
|
||||||
|
buttonVariants({ variant: buttonVariant }),
|
||||||
|
'size-(--cell-size) p-0 select-none aria-disabled:opacity-50',
|
||||||
|
defaultClassNames.button_previous,
|
||||||
|
),
|
||||||
|
button_next: cn(
|
||||||
|
buttonVariants({ variant: buttonVariant }),
|
||||||
|
'size-(--cell-size) p-0 select-none aria-disabled:opacity-50',
|
||||||
|
defaultClassNames.button_next,
|
||||||
|
),
|
||||||
|
month_caption: cn(
|
||||||
|
'flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)',
|
||||||
|
defaultClassNames.month_caption,
|
||||||
|
),
|
||||||
|
dropdowns: cn(
|
||||||
|
'flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium',
|
||||||
|
defaultClassNames.dropdowns,
|
||||||
|
),
|
||||||
|
dropdown_root: cn(
|
||||||
|
'relative rounded-md border border-input shadow-xs has-focus:border-ring has-focus:ring-[3px] has-focus:ring-ring/50',
|
||||||
|
defaultClassNames.dropdown_root,
|
||||||
|
),
|
||||||
|
dropdown: cn('absolute inset-0 bg-popover opacity-0', defaultClassNames.dropdown),
|
||||||
|
caption_label: cn(
|
||||||
|
'font-medium select-none',
|
||||||
|
captionLayout === 'label'
|
||||||
|
? 'text-sm'
|
||||||
|
: 'flex h-8 items-center gap-1 rounded-md pr-1 pl-2 text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground',
|
||||||
|
defaultClassNames.caption_label,
|
||||||
|
),
|
||||||
|
month_grid: cn('w-full border-collapse', defaultClassNames.month_grid),
|
||||||
|
weekdays: cn('flex', defaultClassNames.weekdays),
|
||||||
|
weekday: cn(
|
||||||
|
'flex-1 rounded-md text-[0.8rem] font-normal text-muted-foreground select-none',
|
||||||
|
defaultClassNames.weekday,
|
||||||
|
),
|
||||||
|
week: cn('mt-2 flex w-full', defaultClassNames.week),
|
||||||
|
week_number_header: cn('w-(--cell-size) select-none', defaultClassNames.week_number_header),
|
||||||
|
week_number: cn('text-[0.8rem] text-muted-foreground select-none', defaultClassNames.week_number),
|
||||||
|
day: cn(
|
||||||
|
'group/day relative aspect-square h-full w-full p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-md',
|
||||||
|
props.showWeekNumber
|
||||||
|
? '[&:nth-child(2)[data-selected=true]_button]:rounded-l-md'
|
||||||
|
: '[&:first-child[data-selected=true]_button]:rounded-l-md',
|
||||||
|
defaultClassNames.day,
|
||||||
|
),
|
||||||
|
range_start: cn('rounded-l-md bg-accent', defaultClassNames.range_start),
|
||||||
|
range_middle: cn('rounded-none', defaultClassNames.range_middle),
|
||||||
|
range_end: cn('rounded-r-md bg-accent', defaultClassNames.range_end),
|
||||||
|
today: cn('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),
|
||||||
|
disabled: cn('text-muted-foreground opacity-50', defaultClassNames.disabled),
|
||||||
|
hidden: cn('invisible', defaultClassNames.hidden),
|
||||||
|
...classNames,
|
||||||
|
}}
|
||||||
|
components={{
|
||||||
|
Root: ({ className, rootRef, ...props }) => {
|
||||||
|
return <div data-slot="calendar" ref={rootRef} className={cn(className)} {...props} />;
|
||||||
|
},
|
||||||
|
Chevron: ({ className, orientation, ...props }) => {
|
||||||
|
if (orientation === 'left') {
|
||||||
|
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,
|
||||||
|
WeekNumber: ({ children, ...props }) => {
|
||||||
|
return (
|
||||||
|
<td {...props}>
|
||||||
|
<div className="flex size-(--cell-size) items-center justify-center text-center">{children}</div>
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
...components,
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CalendarDayButton({
|
||||||
|
className,
|
||||||
|
day,
|
||||||
|
modifiers,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DayButton>) {
|
||||||
|
const defaultClassNames = getDefaultClassNames();
|
||||||
|
|
||||||
|
const ref = React.useRef<HTMLButtonElement>(null);
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (modifiers.focused) ref.current?.focus();
|
||||||
|
}, [modifiers.focused]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
ref={ref}
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
data-day={day.date.toLocaleDateString()}
|
||||||
|
data-selected-single={
|
||||||
|
modifiers.selected && !modifiers.range_start && !modifiers.range_end && !modifiers.range_middle
|
||||||
|
}
|
||||||
|
data-range-start={modifiers.range_start}
|
||||||
|
data-range-end={modifiers.range_end}
|
||||||
|
data-range-middle={modifiers.range_middle}
|
||||||
|
className={cn(
|
||||||
|
'flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[3px] group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:text-accent-foreground [&>span]:text-xs [&>span]:opacity-70',
|
||||||
|
defaultClassNames.day,
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Calendar, CalendarDayButton };
|
||||||
@@ -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>
|
||||||
@@ -53,6 +54,9 @@ const CommandInput = React.forwardRef<
|
|||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
|
<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;
|
||||||
@@ -61,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,8 +35,10 @@ 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>
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
||||||
>(({ className, children, ...props }, ref) => (
|
showCloseButton?: boolean;
|
||||||
|
}
|
||||||
|
>(({ className, children, showCloseButton = true, ...props }, ref) => (
|
||||||
<DialogPortal>
|
<DialogPortal>
|
||||||
<DialogOverlay />
|
<DialogOverlay />
|
||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
@@ -48,10 +50,12 @@ const DialogContent = React.forwardRef<
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
<span className="sr-only">Close</span>
|
<span className="sr-only">Close</span>
|
||||||
</DialogPrimitive.Close>
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
</DialogPrimitive.Content>
|
</DialogPrimitive.Content>
|
||||||
</DialogPortal>
|
</DialogPortal>
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -6,15 +6,17 @@
|
|||||||
|
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
import { Check, X, HelpCircle, ChevronRight } from 'lucide-react';
|
import { Check, X, HelpCircle, ChevronRight, Loader2 } from 'lucide-react';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { jmapMapToArray, SECRET_MASK } from '@/lib/jmapUtils';
|
import { jmapMapToArray, SECRET_MASK } from '@/lib/jmapUtils';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
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 { formatSize, formatDuration } from '@/lib/durationFormat';
|
import { useObjectList, useObjectLabel } from '@/lib/objectOptions';
|
||||||
import type { Schema, Field, FieldType, FormField, Form, Fields, EnumVariant } from '@/types/schema';
|
import { formatDuration } from '@/lib/durationFormat';
|
||||||
|
import { SizeDisplay } from '@/components/common/SizeDisplay';
|
||||||
|
import type { Schema, Field, FieldType, FormField, Form, Fields, EnumVariant, ScalarType } from '@/types/schema';
|
||||||
|
|
||||||
export interface DynamicViewProps {
|
export interface DynamicViewProps {
|
||||||
schema: Schema;
|
schema: Schema;
|
||||||
@@ -125,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>
|
||||||
@@ -143,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>
|
||||||
@@ -223,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:
|
||||||
@@ -401,7 +403,7 @@ function MapValue({
|
|||||||
schema,
|
schema,
|
||||||
}: {
|
}: {
|
||||||
value: unknown;
|
value: unknown;
|
||||||
keyClass: { type: string; enumName?: string };
|
keyClass: ScalarType;
|
||||||
valueClass: { type: string; objectName?: string };
|
valueClass: { type: string; objectName?: string };
|
||||||
schema: Schema;
|
schema: Schema;
|
||||||
}) {
|
}) {
|
||||||
@@ -417,12 +419,7 @@ function MapValue({
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{entries.map(([k, v]) => {
|
{entries.map(([k, v]) => {
|
||||||
let keyLabel = k;
|
const keyLabel = <MapKeyLabel keyClass={keyClass} keyValue={k} schema={schema} />;
|
||||||
if (keyClass.type === 'enum' && keyClass.enumName) {
|
|
||||||
const variants = schema.enums[keyClass.enumName] ?? [];
|
|
||||||
const variant = variants.find((e: EnumVariant) => e.name === k);
|
|
||||||
if (variant) keyLabel = variant.label;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (valueClass.type === 'object' && valueClass.objectName) {
|
if (valueClass.type === 'object' && valueClass.objectName) {
|
||||||
return (
|
return (
|
||||||
@@ -446,6 +443,29 @@ function MapValue({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MapKeyLabel({ keyClass, keyValue, schema }: { keyClass: ScalarType; keyValue: string; schema: Schema }) {
|
||||||
|
if (keyClass.type === 'enum') {
|
||||||
|
const variants = schema.enums[keyClass.enumName] ?? [];
|
||||||
|
const variant = variants.find((e: EnumVariant) => e.name === keyValue);
|
||||||
|
return <>{variant?.label ?? keyValue}</>;
|
||||||
|
}
|
||||||
|
if (keyClass.type === 'objectId') {
|
||||||
|
return <ObjectIdKeyLabel objectName={keyClass.objectName} keyValue={keyValue} schema={schema} />;
|
||||||
|
}
|
||||||
|
return <>{keyValue}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ObjectIdKeyLabel({ objectName, keyValue, schema }: { objectName: string; keyValue: string; schema: Schema }) {
|
||||||
|
const list = useObjectList(objectName, schema);
|
||||||
|
const fromList = list.options.find((o) => o.id === keyValue)?.label;
|
||||||
|
const { label: cheapLabel, loading } = useObjectLabel(objectName, fromList ? null : keyValue, schema);
|
||||||
|
const display = fromList ?? cheapLabel;
|
||||||
|
if (loading && !display) {
|
||||||
|
return <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />;
|
||||||
|
}
|
||||||
|
return <>{display ?? keyValue}</>;
|
||||||
|
}
|
||||||
|
|
||||||
function FieldTooltip({ description }: { description: string }) {
|
function FieldTooltip({ description }: { description: string }) {
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ import { CircleDot, CircleX, Clock } from 'lucide-react';
|
|||||||
import { useSchemaStore } from '@/stores/schemaStore';
|
import { useSchemaStore } from '@/stores/schemaStore';
|
||||||
import type { TraceEvent, TraceKeyValue, TraceValue } from '../types';
|
import type { TraceEvent, TraceKeyValue, TraceValue } from '../types';
|
||||||
|
|
||||||
const BASE32_ALPHABET = 'abcdefghijklmnopqrstuvwxyz234567';
|
const BASE32_ALPHABET = 'abcdefghijklmnopqrstuvwxyz792013';
|
||||||
|
|
||||||
function intToBase32(input: number | string): string {
|
function intToBase32(input: number | string): string {
|
||||||
let n: bigint;
|
let n: bigint;
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, type Dispatch, type SetStateAction } from 'react';
|
||||||
|
|
||||||
|
export function useBufferedValue<T, S = T>(
|
||||||
|
value: T,
|
||||||
|
transform: (v: T) => S = (v) => v as unknown as S,
|
||||||
|
): [S, Dispatch<SetStateAction<S>>] {
|
||||||
|
const [prev, setPrev] = useState(value);
|
||||||
|
const [local, setLocal] = useState<S>(() => transform(value));
|
||||||
|
if (!Object.is(value, prev)) {
|
||||||
|
setPrev(value);
|
||||||
|
setLocal(transform(value));
|
||||||
|
}
|
||||||
|
return [local, setLocal];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useResetOnChange<K>(key: K, reset: () => void): void {
|
||||||
|
const [prev, setPrev] = useState(key);
|
||||||
|
if (!Object.is(key, prev)) {
|
||||||
|
setPrev(key);
|
||||||
|
reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||