92 Commits
Author SHA1 Message Date
gawells 8804c944ce change upstream
Build and Release / build (push) Successful in 10m19s
Build and Release / Cleanup failed release (push) Skipped
Build and Release / Publish release (push) Failing after 4s
2026-08-03 14:08:20 -04:00
Steven RYDELL eda20cbe2f chore: release v1.1.3
Fix column sort headers so a third click clears the sort and restores
the list's default order.
2026-08-01 23:31:27 +02:00
Steven RYDELL 41b3824894 chore: release v1.1.2
Add a Changelog page that renders CHANGELOG.md from the header menu,
switch the default color theme to the brand-accurate Stalwart theme, and
document all changes since v1.1.1.
2026-08-01 23:25:02 +02:00
Steven RYDELL f5524eb45a fix(ui): close sidebar group when picking a link outside it
Plain links (not wrapped in a collapsible) are siblings of collapsible
groups within the same AccordionLevel, but their onClick never touched
that level's openId — so a group left expanded stayed expanded even
after navigating to an unrelated top-level page, or to a sibling link
outside any group.

Both SidebarTopItem and SidebarSubItem's link branches now read the
AccordionLevelContext they're rendered in and call setOpenId(null)
before navigating. For a link inside its own group, this is
immediately overridden by AccordionCollapsible's existing
containsActive effect (which reopens the branch that now contains the
active page), so nested navigation is unaffected — only a truly
unrelated group gets collapsed.

Verified: opening Directory (showing Groups), then clicking Cluster (a
plain top-level link) now collapses Directory instead of leaving it
expanded.
2026-08-01 23:07:48 +02:00
Steven RYDELL 637d8bb286 feat(appearance): rename default theme, add real brand-accurate Stalwart theme
The color theme previously named "Stalwart" was actually just the
neutral black/white default — it never used Stalwart's own brand
color. Renamed it to "Default" (ColorTheme 'stalwart' -> 'default'
value; label/i18n key updated) and added a genuinely new "Stalwart"
theme in its place.

Colors sourced directly from the official site's own design tokens
(D:\project\stalwart_website/src/styles/tokens.css): brand red/pink
#db2d54 (light primary/ring) and #ff4570 (--brand-hi, dark
primary/ring), converted to oklch via the sRGB->OKLab->OKLCH matrix
transform to match this project's existing token format. Neutrals use
near-true-gray values (also converted from the site's actual light/
dark backgrounds and text colors) rather than being re-hued toward the
brand color like the other theme presets — matching how the real site
treats color (saturated only on the accent, neutral everywhere else).

Verified in the running dev server: Default reverts to the original
neutral look, Stalwart renders the brand red across primary buttons,
active nav state, checkboxes, and badges in both light and dark mode.
No regression (243 tests, typecheck, lint all pass).
2026-08-01 23:02:32 +02:00
Steven RYDELL 410a01700b feat(lists): make the most relevant columns sortable on the new tables
Mailing Lists, Roles, and Domains got new/existing columns in prior
commits but no way to sort them — the account-client-sort mechanism
was only wired up in accountColumns.ts. Extracted its one-line
clientSortable() column tagger into schemaDeviationTypes.ts (shared,
same intersection-type pattern) and used it in all four column-patch
files:

- Mailing Lists: Email Address, Description, Aliases
- Roles: Description, Enabled Permissions, Disabled Permissions
- Domains: Domain Name, Enabled, Aliases

Re-verified server sort support live, per list this time rather than
just Accounts: every property tried returns unsupportedSort except one
surprise — x:Domain/query actually accepts sort by "name", despite the
schema not declaring it. Documented in SCHEMA_DEVIATIONS.md and
deliberately routed through the same client-sort path as everything
else instead of adding a one-off "trust an undeclared sort" mechanism
for that single case.

Verified in the running dev server: sort indicators appear on the
intended columns for all three lists, clicking reorders rows correctly
(alphabetical on Mailing Lists' Email Address, numeric on Roles'
Enabled Permissions: 1 -> 3 -> 50 -> 229 -> 244 -> 452), Domains sorts
without error. No regression on Accounts/Groups.
2026-08-01 22:31:25 +02:00
Steven RYDELL 26a3878faa feat(domains): add Aliases count column
Same account-alias-count-column deviation already used on Accounts,
Groups, and Mailing Lists, extended to x:Domain: adds a synthetic
aliasCount column resolved from the real aliases property (a `set` of
alias domain names here, vs. an objectList elsewhere — same id-keyed
wire format, so the existing generic COUNT_COLUMN_SOURCES handling
needed no changes).

Verified in the running dev server: the column renders correctly
alongside the domain's existing Enabled/Certificate/DNS columns.
2026-08-01 22:25:26 +02:00
Steven RYDELL 503cae465f feat(roles): add Enabled/Disabled Permissions count columns
New SCHEMA-DEVIATION: role-permission-count-columns — the Roles list
only shows Description, so seeing how broad or restrictive a role is
required opening it and counting permissions by hand.

Generalized DynamicList's existing alias-count handling (previously
hardcoded to the single `aliasCount` -> `aliases` mapping) into
COUNT_COLUMN_SOURCES, a table of synthetic "*Count" column names to the
real set/objectList property they count. account-alias-count-column
now runs through the same generic path with no behavior change;
role-permission-count-columns (enabledPermissionCount/
disabledPermissionCount -> enabledPermissions/disabledPermissions) is
the second consumer, added with zero new DynamicList.tsx branching.

Verified against the live dev server: correct counts for both seeded
custom roles (Support Agent: 3/0, Read-only Auditor: 1/0) and the
built-in roles (System Administrator: 452/0, User: 244/0, etc.).
Confirmed no regression on Accounts' existing Aliases column/sort.
2026-08-01 22:23:48 +02:00
Steven RYDELL 9cd21a2d34 feat(mailing-lists): add Aliases count column
Same account-alias-count-column deviation already used on Accounts and
Groups, extended to x:MailingList: adds a synthetic aliasCount column
and lets DynamicList's existing generic column-name-driven handling
resolve/render it — no changes needed there, the mechanism was already
list-agnostic.

Verified in the running dev server: the column renders 0 with no
aliases set on either seeded mailing list.
2026-08-01 22:19:35 +02:00
Steven RYDELL d9b073ad6c feat(dev): add scripts/dev-seed to populate sample test data
New scripts/dev-seed.sh (+ .ps1), run after dev-server-init: seeds the
test server with Users (alice, bob, carol — spanning User/Admin roles,
group membership, an alias), Groups (engineering, marketing), Mailing
Lists (newsletter, support), and custom Roles (Support Agent,
Read-only Auditor), so each admin panel screen has real data to test
against instead of an empty list.

Idempotent (skips if 'alice' already exists), documented as workflow
step 3 in DEVELOPMENT.md (renumbering the steps after it) and listed
in AGENTS.md. Verified end-to-end with both scripts against a fresh
server, including idempotency and the resulting UI (Accounts, Groups,
Mailing Lists, Roles all populated as expected).
2026-08-01 22:10:43 +02:00
Steven RYDELL face5b4161 fix(dev): respect $PORT env var in vite.config.ts
Preview/dev tooling that assigns a free port via $PORT (because the
project's usual 5173 is taken) had no way to make Vite actually bind
there — Vite ignores PORT by default and falls back to its own
auto-increment, so the dev server would come up on a different port
than the one the tooling proxied to.
2026-08-01 21:58:34 +02:00
Steven RYDELL 93b10c3ed1 refactor(sort): make client-side column sorting generic, not view-hardcoded
Previous commit hardcoded which lists (viewName === 'x:Account/User' /
'x:Account/Group') and columns (a fixed accessor map) got client-side
sort. Moved the "which columns" decision into the schema itself instead:

- New ClientSortableColumn type in schemaDeviationTypes.ts (intersection
  with the official Column type, same pattern as ClientOnlyFilterEnum —
  src/types/schema.ts stays untouched).
- withAccountListColumns tags Email Address, Full Name, quotaUsage, and
  aliasCount with clientSortable: true when it builds the Accounts/
  Groups column lists — the deviation-specific knowledge lives where the
  columns themselves are defined.
- DynamicList reads that flag generically (clientSortableColumns, a
  useMemo over resolved.list.columns) with no viewName check at all.
  getClientSortValue() replaces the old per-list accessor map: real
  columns compare their own property directly, only the two synthetic
  columns need a value override.

Net effect for Accounts/Groups is unchanged (verified: sort indicators
still only on Email/Full Name/Usage/Aliases, ascending/descending still
works). Any other list can now opt into the same client-sort mechanism
by tagging a column clientSortable, without touching DynamicList.tsx.
2026-08-01 20:32:04 +02:00
Steven RYDELL 518ec4059a feat(accounts): add client-side column sorting to Accounts and Groups
Verified against a live server: x:Account/query rejects sort on every
property tried, including real ones like emailAddress, with
unsupportedSort — the schema's empty list.sort is accurate, this is a
systemic server gap, not something specific to this fork's synthetic
columns.

Added client-side sorting (SCHEMA-DEVIATION: account-client-sort) for
Email Address, Full Name, Usage/Quota, and Aliases on both lists,
reusing the fetch-all-then-sort-locally mechanism already established
for mailbox-client-hierarchy-sort: clicking a sortable header switches
that one query to an unpaginated fetch, sorts the results in memory by
the appropriate accessor (numeric for Usage/Aliases, string compare
otherwise), then paginates client-side. Role isn't included (not a
sortable scalar). Normal server-paginated lists are unaffected — this
only activates when a client-sortable column is actually clicked.

Verified end-to-end: sort indicators appear only on the intended
columns, clicking Email Address/Aliases correctly reorders rows and
toggles ascending/descending, Groups behaves the same as Accounts.
2026-08-01 20:23:42 +02:00
Steven RYDELL 3241dea5e4 feat(accounts): add Aliases count column to Accounts and Groups
Same pattern as the existing quotaUsage deviation: neither list's
schema exposes an alias count as a column, only the full `aliases`
objectList on the detail view. Added a synthetic `aliasCount` column
(SCHEMA-DEVIATION: account-alias-count-column, documented in
SCHEMA_DEVIATIONS.md) that DynamicList resolves by fetching the real
`aliases` property and counting its entries.

Verified against a live test server: adding a real alias to an
account correctly bumps its Aliases count from 0 to 1 in the list.
2026-08-01 20:18:01 +02:00
Steven RYDELL 0aa92c394c feat(accounts): add Usage/Quota column to Groups, show unlimited as ∞
Groups have real usedDiskQuota/quotas fields, same as Users, but the
Groups list schema doesn't expose usage as a column any more than the
Accounts list did — extend the existing account-quota-usage-column
deviation to x:Account/Group too (SCHEMA_DEVIATIONS.md updated).

The isAccountsList gate that resolved/rendered quotaUsage was hardcoded
to viewName === 'x:Account/User', so it silently no-oped for Groups.
Replaced with hasQuotaUsageColumn, derived from whether the resolved
list's own columns include the synthetic quotaUsage column — works for
any list withAccountListColumns patches, not just a hardcoded pair of
view names.

Also: "Unlimited" (no quota configured) now renders as "∞" instead of
the word, matching the size formatting style used elsewhere in the
column.
2026-08-01 20:02:10 +02:00
Steven RYDELL 09910a7bf9 fix(lint): remove unused isBackendIconKnown, unblocking CI
Pre-existing dead code (defined, never imported anywhere) that also
tripped react-refresh/only-export-components since it mixed a
non-component export into a component-only file — eslint treats that
as an error, which was silently failing every CI run's lint step
(including this release build) well before this session's changes.
2026-08-01 19:31:57 +02:00
Steven RYDELL 95053d7dc8 chore: release v1.1.1 2026-08-01 19:24:08 +02:00
Steven RYDELL 52123bce29 fix(assets): use upstream's favicon.ico instead of a mis-cropped fork export
This fork's favicon.ico had drifted from upstream's: same Stalwart logo
and color (#DB2D54) in both, but the fork's version had visible padding
around the logo (trimmed bounding box 120x104 within the 128x128 icon)
while upstream's fills the full canvas edge-to-edge. Also encoded at a
lower bit depth (8-bit indexed vs upstream's 32-bit true color).

Replaced with the exact file from stalwartlabs/webui (md5 e9f5eaa1...,
matches byte-for-byte) rather than re-exporting, so it can't drift
again the same way.
2026-08-01 19:21:19 +02:00
Steven RYDELL 40164a6652 feat(webapps): show and correctly set the active WebUI's resource URL
The "Active WebUI" card (Settings > Web Applications) only showed
description and __APP_VERSION__, never the resourceUrl it's actually
built from — add it as a linked "Source" row.

That card also turned out to be misleading in local dev: it reads the
x:Application record whose urlPrefix matches /admin or /account, and a
freshly bootstrapped server keeps Stalwart's seeded default there
("Stalwart Web Interface", pointing at stalwartlabs/webui's release),
regardless of what's actually being served — in dev that's this fork,
served directly by Vite, which never touches that record at all.

dev-server-init.sh/.ps1 now point that record's description and
resourceUrl at this fork's own release, so the card (now including the
visible Source URL) reflects what's actually running instead of
Stalwart's factory default.

Verified end-to-end against a fresh container with both scripts.
2026-08-01 19:17:44 +02:00
Steven RYDELL 7fab3540ea fix(ui): keep mobile sidebar open when switching sections
Tapping Management/Settings/Account in the sidebar footer navigated to
that section's default page, which triggered the same effect that
closes the sidebar after picking a leaf page on mobile — so the
sidebar snapped shut right after switching sections, forcing users to
reopen it just to browse the new section's pages.

Have handleSectionClick flag the navigation as section-only via a ref;
the mobile auto-close effect consumes that flag and skips closing for
that one navigation, leaving normal leaf-page navigation unaffected.

Verified on a mobile viewport: switching sections now keeps the
sidebar open, while picking a specific page still closes it as before.
2026-08-01 19:07:22 +02:00
Steven RYDELL 18264a5b76 docs(dev): add troubleshooting section, fix stale "one command" claim
Add a Troubleshooting section to DEVELOPMENT.md covering the case that
just happened: npm run dev only reads .env.development.local at
startup, so regenerating a token or restarting the Docker container
while it's already running silently leaves the UI unable to reach the
backend until it's restarted.

README.md's "one command" claim for spinning up the test server was
stale since dev-server-init.sh became a separate one-time step.
2026-08-01 18:39:33 +02:00
Steven RYDELL adca6d8730 feat(dev): switch dev tokens to a real admin account with configurable duration
Requested: dev-token.sh/.ps1 tokens should last 3h by default, with an
argument to override the duration.

Server-verified finding: STALWART_RECOVERY_ADMIN (the break-glass
account docker-compose.yml and the scripts used) always issues OAuth
tokens with a fixed 1h expiry, regardless of the server's configured
accessTokenExpiry — confirmed against the live container, including
after changing the setting and restarting. Confirmed x:ApiKey objects,
by contrast, support an arbitrary expiresAt set per request, and their
secret works directly as a bearer token.

Add scripts/dev-server-init.sh (+ .ps1): a one-time, idempotent setup
step that completes the server's bootstrap wizard (default domain, no
TLS certificate request), creates a real "devadmin" admin account, and
sets the server's default OAuth token lifetime to 3h.

Rework dev-token.sh/.ps1 to authenticate as devadmin and create an
x:ApiKey with a caller-supplied expiry (`dev-token.sh 1800` for 30
minutes, defaults to 10800s/3h) instead of running the OAuth PKCE flow
against the recovery account. Verified end-to-end against a fresh
container, including a real browser session against the running WebUI.

Also includes an incidental package-lock.json sync (was still pinned to
v1.0.8 / stale dependency ranges from before the upstream merge).
2026-08-01 18:32:13 +02:00
Steven RYDELL f4c8f8f21c feat(dev): add one-command local Stalwart test server + workflow docs
Add docker-compose.yml: a disposable Stalwart instance with a fixed dev
admin account (STALWART_RECOVERY_ADMIN), matching the credentials
scripts/dev-token.ps1 already expected. Wired up via new npm scripts
(dev:server, dev:server:down, dev:server:logs).

scripts/dev-token.ps1 was previously untracked (the whole scripts/
directory was gitignored) even though it's part of the documented dev
workflow — un-ignored it, and added scripts/dev-token.sh, a POSIX
equivalent for non-Windows shells and AI agents without PowerShell.

New DEVELOPMENT.md documents the full loop end-to-end (start server,
get a token, run the dev server, verify), written so it's actionable by
both humans and AI coding agents without needing a browser. Linked from
AGENTS.md (Commands) and README.md (Getting started).

Verified manually: docker compose up brings the server to a healthy
state, /api/auth + /auth/token issue a working bearer token, and
/jmap/session returns 200 with it end-to-end.
2026-08-01 18:05:43 +02:00
Steven RYDELL 38abba39f8 docs(readme): document fork identity, deviations policy, and UI switching
Rewrite everything below the project header to actually describe this
fork instead of a generic copy of upstream's README:

- "About this fork" + list of official Stalwart repositories.
- Corrected the "Nothing is hardcoded" overclaim; Features now lists
  what's shared with upstream vs. this fork's own additions, and links
  to SCHEMA_DEVIATIONS.md for the tracked exceptions.
- New "Switching your server to this fork's UI" section: how to point
  a Stalwart server's WEBAPP application at this fork's release build
  via stalwart-cli, and how to switch back.
- Support section now distinguishes fork-specific issues (this repo)
  from Stalwart Mail Server support (upstream channels).
- License/Copyright text left untouched; added one line noting fork
  changes stay under the same dual license per each file's SPDX header.

The project header block (logo, title, badges) is unchanged.
2026-08-01 17:54:24 +02:00
Steven RYDELL 101b24b361 fix(schema): track client-side schema deviations, restore schema.ts fidelity
src/types/schema.ts had drifted from the official schema contract: a
`clientOnly` field had been added to `FilterEnum` to support client-side
log filtering. Restored it to match upstream exactly and moved the
deviation-only type into a new intersection type in
src/lib/schemaDeviationTypes.ts instead.

Audited the codebase for every place the UI does something the official
schema doesn't support and tagged each with `// SCHEMA-DEVIATION: <id>`,
documented in the new SCHEMA_DEVIATIONS.md registry (what, why, and the
ideal server-side fix):

- log-client-filters: Level/Event filters on Log Entries, applied
  client-side because x:Log/query rejects them as JMAP filters.
- account-quota-usage-column: synthetic quotaUsage column on the
  Accounts list.
- mailbox-client-hierarchy-sort: full-fetch + client-side sort to
  reconstruct mailbox parent/child hierarchy.
- webapp-enabled-column-fallback: synthetic "Enabled" column label for
  x:Application when the schema list doesn't define one.

Other viewName/objectName special cases (x:OtpAuth, x:Expression,
x:Rate, x:Action, x:Trace, CustomComponent/*) were checked against
upstream and are pre-existing architecture, not fork deviations.
2026-08-01 17:54:08 +02:00
Steven RYDELL 4a6340e83f chore(agents): add AGENTS.md/CLAUDE.md rules for AI coding agents
Any AI agent working in this repo (Claude Code, Codex, Kimi, or any
other) must stay schema-driven and never hardcode object types, field
names, filters, or columns as a shortcut. AGENTS.md is the index (tech
stack, commands, universal rules); CLAUDE.md just points Claude Code at
it so the same rules apply without duplicating content. The detailed
schema-fidelity rule lives in .agents/rules/.

.gitignore previously excluded *.md except README/CHANGELOG, which
would have hidden these files (and SCHEMA_DEVIATIONS.md) from git.
2026-08-01 17:53:04 +02:00
Steven RYDELL 137e6c6ed9 fix(merge): remove upstream loadLogoOnce leftover, restore schema.ts formatting
main.tsx no longer needs an eager logo fetch call: Logo.tsx already
triggers ensureLogoLoaded() from logoCache.ts (the fork's own
implementation, kept over upstream's during the sync merge).
2026-08-01 17:00:12 +02:00
Steven RYDELL 4c953e599d Merge remote-tracking branch 'upstream/main' into sync-upstream
# Conflicts:
#	CHANGELOG.md
#	package-lock.json
#	package.json
#	src/components/common/CommandPalette.tsx
#	src/components/common/LoadingFallback.tsx
#	src/components/common/Logo.tsx
#	src/components/forms/DynamicForm.tsx
#	src/components/forms/FieldWidget.tsx
#	src/components/layout/MainContent.tsx
#	src/components/layout/Sidebar.tsx
#	src/components/layout/TopBar.tsx
#	src/components/lists/DynamicList.tsx
#	src/components/ui/calendar.tsx
#	src/components/ui/command.tsx
#	src/components/ui/dialog.tsx
#	src/hooks/useDocumentTitle.ts
#	src/hooks/useGlobalSearch.ts
#	src/i18n/en.json
#	src/lib/lastVisited.ts
#	src/lib/logoCache.ts
#	src/pages/AdminPanel.lazy.tsx
#	src/pages/AdminPanel.tsx
#	src/pages/LoginPage.tsx
#	src/pages/NotFound.tsx
#	src/pages/OAuthCallback.tsx
#	src/stores/authStore.test.ts
#	src/stores/authStore.ts
#	src/stores/cacheStore.ts
#	vite.config.ts
2026-08-01 16:59:31 +02:00
Maurus Decimus 8cab61a9c5 v1.0.8 2026-07-31 15:52:36 +02:00
gawells d84784c9d9 fix merge error
Build and Release / build (push) Canceled after 0s
Build and Release / Publish release (push) Canceled after 0s
Build and Release / Cleanup failed release (push) Canceled after 0s
2026-07-30 22:53:01 -07:00
Steven RYDELL a4b324bb8b fix(ui): prefill empty date/time picker with now 2026-07-30 20:15:07 +02:00
Steven RYDELL 08382472f4 fix(ui): theme date/time picker for dark mode 2026-07-30 20:08:10 +02:00
Steven RYDELL 75823ebb21 chore: release v1.1.0 2026-07-30 19:55:14 +02:00
Steven RYDELL 0dec14c3f8 feat(accounts): highlight negative disk usage with recalculate hint 2026-07-30 19:54:46 +02:00
Steven RYDELL 1ab574a42a fix(ui): make admin lists usable on mobile viewports 2026-07-30 19:54:34 +02:00
Steven RYDELL d1284de415 feat: add iOS home-screen icon and Stalwart title without service worker 2026-07-30 18:56:29 +02:00
Steven RYDELL 3189be2de0 refactor(logo): encapsulate shared logo cache with AbortController cleanup 2026-07-30 18:49:35 +02:00
Steven RYDELL eda9c9c8bf fix: remove PWA service worker that breaks Stalwart /admin and /account mounts 2026-07-30 18:30:36 +02:00
Steven RYDELL 4e089d9b6b fix(appearance): keep Rounded corners preview curved in square mode 2026-07-30 18:09:51 +02:00
Steven RYDELLandClaude Sonnet 5 8b5b9c8f8e docs: add v1.0.9 CHANGELOG entry
Documents everything since the v1.0.8 entry: icon batches, the Web
Applications active-app info card, PWA support, Log Entries filters
and refresh button, Accounts list columns, the account-switch fixes
(reviewed against and aligned with upstream's own fix for the same
issue), the WebUI Fork rename, mailbox hierarchy, new color themes,
and the confirmed-backend-only limitations (Log filter properties,
exact-match-only text search across the admin API).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 17:51:04 +02:00
Steven RYDELLandClaude Sonnet 5 bd00b4e922 fix: persist active account across reload, remount views on switch
Aligns with upstream stalwartlabs/webui@189e270 (v1.0.7, "fixes #17"),
reviewed after they independently landed a fix for the same issue:

- authStore now persists activeAccountId (sessionStorage) and
  setSession preserves it across a session refresh instead of always
  resetting to primaryAccountId, so a hard reload keeps you on the
  group account you had selected instead of bouncing back to your own.
- switchAccount now clears cacheStore (displayNames/objectLists) when
  actually changing account, since those were resolved against the
  previous account and would otherwise show stale labels.
- The ErrorBoundary wrapping MainContent is now keyed on
  activeAccountId, forcing a full remount of every view on switch.
  This is more robust than gating individual components' fetch
  effects on activeAccountId (our earlier fix in DynamicList.tsx,
  kept as-is — harmless now, but no longer load-bearing on its own)
  since it covers every current and future view type, not just lists.

Verified against a live instance: switching to a group account updates
the JMAP accountId immediately (no tab switch needed), and a full page
reload keeps the group account active instead of resetting to admin.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 17:48:19 +02:00
Steven RYDELLandClaude Sonnet 5 8c1e826d63 feat: add Rose/Amber/Teal color themes, default to Ocean + Square
Adds three new color themes alongside the existing Stalwart/Ocean/
Forest/Violet set so more people can find one they like, and switches
the out-of-the-box defaults to Ocean + square corners instead of the
neutral Stalwart theme + rounded corners (existing users' saved
preferences are untouched, this only changes what a fresh install
starts with).

Verified in the browser: all three new themes render with legible
primary-foreground contrast in both light and dark mode, and the
pre-hydration flash-prevention script in main.tsx (which reads
localStorage before React mounts) now recognizes the new theme names.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 17:38:40 +02:00
Steven RYDELLandClaude Sonnet 5 7a96fa6786 feat: show mailbox hierarchy in the Mailboxes list
Fixes stalwartlabs/webui#16. Mailboxes had no visual indication of
parent/child relationships. Since a mailbox's parent can land on a
different server page than the mailbox itself, the Mailbox list now
always fetches the full set (like the existing client-filter path)
and orders it depth-first by parentId, then indents each row's name
with a corner connector proportional to its depth.

Verified against a live instance with a 3-level nested hierarchy
(Projects > Alpha > Docs) alongside the default flat mailboxes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 17:27:48 +02:00
Steven RYDELLandClaude Sonnet 5 3e08dcaff5 chore: rename WebUI label to "Stalwart WebUI Fork"
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 17:07:44 +02:00
Steven RYDELLandClaude Sonnet 5 2ac3526da9 fix: refresh account-scoped lists when switching accounts
Switching accounts via the profile dropdown (TopBar) only updates
authStore.activeAccountId with no navigation, but DynamicList's fetch
effect depended on [viewName, sort, resolved?.list, appliedFilters] —
missing activeAccountId, and account resolution only happened through
a non-reactive getState() snapshot inside getAccountId(). So views
like Mailboxes, Calendars, or Sieve Scripts kept showing the previous
account's data until an unrelated viewName change (switching tabs)
incidentally re-ran the effect.

Reproduced and verified against a live Stalwart instance: a mailbox
list stayed on accountId "b" after switching to a group account "d"
in the dropdown, and only picked up "d" after navigating away and
back. Subscribing to activeAccountId reactively and adding it to the
effect's dependencies fixes the switch to apply immediately.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 17:05:12 +02:00
Steven RYDELLandClaude Sonnet 5 3dda6d1527 feat: show Role and Usage/Quota columns on the Accounts list
Created At is replaced with two columns that previously required
opening each account individually: Role (badge, resolved against
x:UserRoles specifically since the list's merged User/Group field
definitions otherwise resolve `roles` against the wrong object) and
Usage / Quota (usedDiskQuota vs quotas.maxDiskQuota, "Unlimited" when
no limit is set).

Also makes renderCellValue's generic 'object' case resolve variant
labels via schema.schemas, instead of only ever printing the raw
@type string.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 16:47:26 +02:00
Steven RYDELLandClaude Sonnet 5 3113e6309d feat: add rate-limited manual refresh button to Log Entries
Adds a Refresh button next to Filters, right-aligned, specific to the
Logs list. Guarded to one click per 5 seconds (button disabled and
re-enabled via a timer) to avoid hammering the server if left clicked
repeatedly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 16:36:25 +02:00
Steven RYDELLandClaude Sonnet 5 21b183327c feat: add client-side Level/Event filters to Log Entries
The backend rejects `level`/`event` as x:Log/query filter conditions
(unsupportedFilter), even though both are already returned per row.
Since a real fix requires backend changes outside this repo, the two
filters are injected into the schema client-side (clientOnly flag) and
applied entirely in the browser: excluded from the JMAP filter sent to
the server, and used to narrow an eagerly-fetched, locally-paginated
result set instead.

Also makes large enum filters (Event has 634 values) render as a
searchable Combobox instead of a plain Select, generically for any
list with more than 15 enum options.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 16:31:36 +02:00
Steven RYDELLandClaude Sonnet 5 da2c99990f feat: add PWA support (manifest, service worker, icons)
Adds vite-plugin-pwa with a generated manifest, icon set derived from
the Stalwart mark (192/512/maskable/apple-touch), and theme-color meta
tags. /api and /jmap are excluded from the service worker's navigate
fallback and are never precached or runtime-cached, so authenticated
mail data can't end up in Cache Storage.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 16:06:59 +02:00
Steven RYDELL 25a38cd560 fix: prevent custom logo blink with global loading state 2026-07-30 15:42:27 +02:00
Steven RYDELL fe24a9bec0 chore: upgrade Vite to 8.2.0 and @vitejs/plugin-react to 6.0.5 2026-07-30 15:42:16 +02:00
Steven RYDELL b9489a795b feat: show Redis and Valkey icons for Redis/Valkey backend variant 2026-07-30 15:31:16 +02:00
Steven RYDELL 49b3d60378 chore(icons): add final batch of backend/provider icons 2026-07-30 15:13:29 +02:00
Steven RYDELL 532e560106 fix(ui): align icon and label in backend select trigger 2026-07-30 14:47:04 +02:00
Steven RYDELL 0a417c5123 chore(icons): rescan backend icons and add DNS provider mappings 2026-07-30 14:43:45 +02:00
Steven RYDELL 0e41f4d18e feat(ui): add backend icons to variant selectors 2026-07-30 14:28:43 +02:00
Steven RYDELL a7e69eefa6 refactor(ui): move Appearance from sidebar to header user dropdown 2026-07-30 13:50:28 +02:00
Steven RYDELL bfdf8f4ba2 fix(web-applications): show Enabled column and active WebUI info card 2026-07-30 13:43:23 +02:00
Steven RYDELL 87cabf3886 Align x:Application list layout with Domains: Description first, Enabled second 2026-07-30 13:06:33 +02:00
Steven RYDELL aa48b90323 Show active WebUI column in x:Application list and active app info card 2026-07-30 13:03:05 +02:00
Steven RYDELL 231f2ec3ee Remember last visited page per section in localStorage 2026-07-30 12:48:25 +02:00
Steven RYDELL 5127a3bf8a Show current user username and email in the TopBar user menu trigger 2026-07-30 12:37:36 +02:00
Steven RYDELL e4223d8cb4 Prefix dynamic page titles with Stalwart | 2026-07-30 12:14:17 +02:00
Steven RYDELL b1849230dc Update lucide-react to 1.28.0 2026-07-30 12:10:02 +02:00
Steven RYDELL 40c04555b4 Remove the Web Applications version column and update action 2026-07-30 12:00:01 +02:00
Steven RYDELL 5dac3490ee Update react-router-dom and migrate to @daypicker/react 10 2026-07-29 11:40:29 +02:00
Steven RYDELL 868dd5e877 v1.0.8 2026-07-29 11:28:56 +02:00
Steven RYDELL eec7f2ada5 Redirect to a default page when the URL has no view 2026-07-29 11:23:50 +02:00
Steven RYDELL 2ac9a6b90e Add an Appearance settings page with color theme and corner options 2026-07-29 11:21:44 +02:00
Steven RYDELL dca6082039 Restore the direct light/dark toggle instead of a dropdown 2026-07-29 11:16:42 +02:00
Steven RYDELL 544ee36a1e Set dynamic document titles per page 2026-07-29 09:43:30 +02:00
Steven RYDELL 5152b07a9a Make the theme switcher available on the login pages 2026-07-29 09:41:28 +02:00
Steven RYDELL 2a872d2ccf Add selectable color themes with a global appearance switcher 2026-07-29 09:40:40 +02:00
Steven RYDELL 0a8dfb44be Mark the logout menu item as destructive 2026-07-29 09:06:20 +02:00
Steven RYDELL 09d43f1e9b Stretch sidebar hover full-width with a separated footer in square mode 2026-07-29 08:55:57 +02:00
Steven RYDELL 272502e0a3 Add a square corners toggle for a border-radius-free interface 2026-07-29 08:52:37 +02:00
Steven RYDELL 13c21fc6d5 v1.0.7 2026-07-29 08:05:20 +02:00
Steven RYDELL 23bef766ca Hide the close cross in the global search palette 2026-07-29 07:55:24 +02:00
Steven RYDELL 0fb734a6c7 Replace native datetime inputs with a calendar date picker 2026-07-29 07:50:55 +02:00
Steven RYDELL 3f31a115c9 Show an ESC hint instead of the close button in the command palette 2026-07-29 07:34:48 +02:00
Steven RYDELL 149524376b Make the sidebar an animated accordion with a softer hover 2026-07-29 07:32:36 +02:00
Steven RYDELL 956212031a Clip table header background inside the rounded border 2026-07-29 07:24:58 +02:00
Steven RYDELL 6d3ea6c6b1 Keep the sidebar section synced with the URL on full page loads 2026-07-29 07:21:27 +02:00
Steven RYDELL c15cb22be4 Adopt the shared ScrollArea for app-wide scrolling 2026-07-29 07:20:43 +02:00
Steven RYDELL bd16e64e51 Show WebUI version and update action in the Web Applications list 2026-07-29 06:48:41 +02:00
Steven RYDELL 8fde771ff2 Show switch state with green when on and red when off 2026-07-29 06:48:34 +02:00
Steven RYDELL da5eaf0558 Center main content horizontally
Wrap the main area in a centered max-w-7xl container and center the max-w-4xl form column inside it, so pages no longer hug the left edge on wide screens. Lists and views center up to 7xl, forms stay at a readable 4xl.
2026-07-29 06:20:14 +02:00
Steven RYDELL eb57250409 Distinguish form field backgrounds from card surfaces
Introduce a --field design token (page background tone) used by inputs, textareas and select triggers, and raise the dark-mode card lightness so cards read as elevated surfaces with inset fields, following the shadcn convention.
2026-07-29 06:15:49 +02:00
Steven RYDELL a657ad1b93 Sync sidebar with programmatic navigation
Collapsible groups now auto-open when the active page lands inside them (while remaining manually toggleable), and the sidebar scrolls the active item into view. Previously, navigating via the command palette left the sidebar collapsed on the wrong spot.
2026-07-29 06:11:06 +02:00
Steven RYDELL 7c97297ce6 Add Ctrl+K/Cmd+K command palette search
Replace the TopBar dropdown search with a cmdk-based command palette opened via a visible trigger button (with platform-aware shortcut badge) or the Ctrl+K/Cmd+K global shortcut. Shared search logic moves to a useGlobalSearch hook; the palette reuses the existing ui/command components.
2026-07-29 06:02:18 +02:00
Steven RYDELL 4739f9ed92 Proxy API and JMAP requests to a local Stalwart server in dev
Same-origin proxying to localhost:8080 avoids CORS without weakening the server's CORS policy; pair with VITE_API_BASE_URL= (empty) in .env.development.local.
2026-07-29 05:48:07 +02:00
Steven RYDELL a83e75a79f Code-split feature pages and admin shell with React.lazy
The dashboard (recharts, ~120 kB gzip), tracing, troubleshoot, actions and bootstrap wizard now load on demand, and the admin panel is split out of the entry chunk so anonymous visitors only download the login page (~134 kB gzip instead of ~557 kB).
2026-07-29 05:47:58 +02:00
116 changed files with 5343 additions and 719 deletions
+30
View File
@@ -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]
```
+20 -1
View File
@@ -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
+34
View File
@@ -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.
+116 -18
View File
@@ -2,26 +2,124 @@
All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/).
## [1.0.7] - 2026-07-30 ## [1.1.3] - 2026-08-01
### Added
- `Ctrl+K` / `Cmd+K` command palette for global search (credits @LinkPhoenix).
- Calendar date picker for date and time fields (credits @LinkPhoenix).
- Dynamic document titles per page (credits @LinkPhoenix).
### Changed
- Code-split the admin shell and heavy feature pages to speed up the initial load (credits @LinkPhoenix).
- Center forms horizontally on wide screens (credits @LinkPhoenix).
### Fixed ### Fixed
- Redirect URLs without a view to the first accessible page of their section (credits @LinkPhoenix). - Column sort headers now cycle unsorted → ascending → descending → unsorted, so a third click clears the sort and restores the list's default order (previously stuck alternating between ascending and descending).
- Sidebar groups auto-open and scroll the active item into view after navigation (credits @LinkPhoenix).
- Keep the sidebar section synced with the URL on full page loads (credits @LinkPhoenix). ## [1.1.2] - 2026-08-01
- Date and time fields no longer shift values by the UTC offset when editing (credits @LinkPhoenix).
- Clip the table header background inside the rounded card border (credits @LinkPhoenix). ### Added
- Keep the selected account across page reloads (#17). - Brand-accurate "Stalwart" color theme (red/pink accent from the official site tokens). The previous theme named "Stalwart" was only the neutral black/white look and is now labeled "Default".
- Refresh open views when switching accounts (#17). - Changelog page in the header user dropdown, rendering this repository's `CHANGELOG.md` so release notes stay in sync with every release.
- Custom logos no longer flash the default logo while loading. - Usage/Quota column on the Groups list (same synthetic column as Accounts); unlimited quota renders as ∞ instead of the word "Unlimited".
- Aliases count column on Accounts, Groups, Mailing Lists, and Domains (resolved from each object's real `aliases` property).
- Enabled Permissions and Disabled Permissions count columns on the Roles list.
- Client-side column sorting on Accounts, Groups, Mailing Lists, Roles, and Domains for the most useful columns (Email/Name/Description, Usage/Quota, Aliases, permission counts, Domain Name, Enabled). The backend rejects sort on these properties (`unsupportedSort`), so lists fetch then sort/paginate locally when a sortable header is clicked — same approach as mailbox hierarchy sort.
- `scripts/dev-seed` (`.sh` / `.ps1`) to populate the local test server with sample Users, Groups, Mailing Lists, and Roles after `dev-server-init` (idempotent; documented in DEVELOPMENT.md).
### Changed
- Default color theme is now the new Stalwart theme (previously Ocean since 1.0.9); existing saved preferences are unaffected.
- Client-side sort is driven by a shared `clientSortable` column flag instead of hardcoded view/column maps, so any list can opt in without touching `DynamicList` branching.
### Fixed
- Sidebar: expanding a collapsible group (e.g. Directory) and then navigating to an unrelated top-level link (e.g. Cluster) now collapses that group instead of leaving it open.
- Vite respects the `$PORT` environment variable so preview/dev tooling that assigns a free port can bind the same port Vite actually listens on.
## [1.1.1] - 2026-08-01
### Fixed
- Mobile sidebar no longer snaps shut when switching between Management/Settings/Account — it now stays open so you can pick a page in the new section, and only closes once you actually navigate to one.
- Favicon replaced with upstream's original: this fork's had drifted to a mis-cropped export (visible padding around the logo, lower color depth) despite showing the same logo and color.
### Changed
- The "Active WebUI" card (Settings > Web Applications) now also shows the resource URL the active WebUI was installed from, alongside its description and version.
## [1.1.0] - 2026-07-30
### Fixed
- Mobile admin shell and lists: wide tables no longer expand/clip the page. The shared ScrollArea constrains content width, tables scroll horizontally inside their card, and list Create / pagination actions stay visible in the mobile viewport.
- Form and detail layouts wrap more cleanly on narrow screens (action bars, label/value rows).
- Date/time picker in dark mode: replaced the native time input (invisible clock icon + always-light popup) with themed hour/minute selects and a visible Clock icon; set `color-scheme` on light/dark themes so remaining native date controls follow the UI.
- Empty date/time fields open prefilled with the current date and time so the calendar and hour/minute selects start on a useful default.
### Changed
- Negative account disk-usage values (stale Stalwart quota counters) are shown in red with an info tooltip that explains how to recalculate usage via Tasks (Perform account maintenance operations → Recalculate storage quota usage, or store-wide Reset all user quotas).
## [1.0.9] - 2026-07-30
### Added
- Level and Event filters on the Log Entries list, applied client-side since the backend doesn't support filtering on these properties yet. Event uses a searchable combobox given its ~600 possible values.
- Rate-limited manual Refresh button on the Log Entries list (one click per 5 seconds) ([webui#8](https://github.com/stalwartlabs/webui/issues/8)).
- Role and Usage/Quota columns on the Accounts list, replacing Created At ([webui#12](https://github.com/stalwartlabs/webui/issues/12)).
- Mailbox hierarchy: mailboxes are now indented under their parent instead of shown as a flat list ([webui#16](https://github.com/stalwartlabs/webui/issues/16)).
- Three additional color themes — Rose, Amber, Teal — alongside Stalwart/Ocean/Forest/Violet.
- "Remember last visited page" per section (localStorage), so switching sections returns to where you left off.
- Username and email shown directly in the TopBar user menu trigger.
- "Active WebUI" info card and column in the Web Applications list, showing which web app is currently serving the admin UI.
- Backend/provider icons across the variant selectors (DNS providers, storage/directory backends, Redis/Valkey).
### Changed
- Default color theme is now Ocean with square corners (previously Stalwart with rounded corners); existing saved preferences are unaffected.
- Active account is preserved across page reloads instead of resetting to the primary account, and switching accounts fully remounts the current view (and clears cached display-name/list lookups) so account-scoped data refreshes immediately instead of requiring a tab switch first ([webui#17](https://github.com/stalwartlabs/webui/issues/17); reviewed against upstream's own fix for the same issue in [stalwartlabs/webui@189e270](https://github.com/stalwartlabs/webui/commit/189e270785a6953a99d11c958fd52daa94e1f5c7) and aligned with it).
- WebUI label renamed to "Stalwart WebUI Fork" to distinguish this fork from upstream.
- Appearance settings moved from the sidebar to the header user dropdown.
- Dynamic page titles prefixed with "Stalwart |".
- x:Application list layout aligned with Domains (Description first, Enabled second).
- Updated Vite to 8.2.0, `@vitejs/plugin-react` to 6.0.5, and `lucide-react` to 1.28.0.
### Fixed
- Custom logos no longer flash the default Stalwart logo while loading. Loading is encapsulated in `logoCache` (shared fetch + AbortController + blob URL revoke), keeping `uiStore` free of logo state while still caching across TopBar/Login remounts.
- Icon/label alignment in backend select triggers.
- Web Applications list shows an Enabled column again.
- Appearance Corners preview: only the Rounded choice forces rounded radius on its card and sample; Square stays sharp even when the global theme is square.
- Removed the experimental PWA/service worker: it precached `index.html` with `<base href="/">`, which broke Stalwart's mount-path rewrite (`/admin`, `/account`) and produced a blank UI. Aligns with upstream webui, which does not ship a service worker.
- iOS home-screen Web App support without a service worker: `apple-touch-icon` plus `apple-mobile-web-app-title` set to "Stalwart".
### Known limitations (confirmed backend-side, not fixable from this fork)
- Level/Event filters on Log Entries are client-side only (see Added above) because the backend's JMAP query engine rejects `level`/`event` as filter conditions ([webui#15](https://github.com/stalwartlabs/webui/issues/15)).
- Text filters across the admin API (Spam Rules & Scores, Accounts, Domains, ...) only support exact match, not partial/glob/wildcard search — confirmed systemic across the whole `x:` filter engine, requires a server-side change.
## [1.0.8] - 2026-07-29
### Added
- Appearance settings page (linked at the bottom of the sidebar) with light/dark mode, four selectable color themes (Stalwart, Ocean, Forest, Violet) and a rounded/square corners option that applies globally across all themes.
- Light/dark toggle on the login pages.
- Dynamic document titles per page, mirroring the sidebar navigation labels.
- Square corners toggle for a border-radius-free interface.
- Full-width sidebar hover with a separated footer in square mode.
### Changed
- The light/dark toggle is a single-click button again, in the top bar and on the login pages.
- The logout menu item is marked as destructive.
- Updated react-router-dom to 7.18.2 and migrated the date picker to `@daypicker/react` 10 (the new react-day-picker package name).
### Fixed
- Section URLs without a view (e.g. /admin) redirect to the first accessible page instead of the "Select a view" empty state.
### Removed
- The Web Applications list no longer shows a "Version" column or an "Update" button, because Stalwart does not expose the installed version of each web application and `/latest/` GitHub URLs hide it.
## [1.0.7] - 2026-07-29
### Added
- Ctrl+K / Cmd+K command palette to search pages, form sections and fields across the admin panel, with an ESC hint badge instead of the close cross.
- WebUI version and update action with a confirmation modal in the Web Applications list.
- Calendar date picker with time input replacing native datetime inputs in all schema-driven forms.
- Shared styled ScrollArea used app-wide, including the sidebar and the command palette.
- Development proxy forwarding API and JMAP requests to a local Stalwart server.
### Changed
- Renamed the package to `stalwart-webui-fork` to identify the community fork.
- Sidebar behaves as an animated accordion: expanding a section collapses the others at the same level, with a softer hover.
- Main content is horizontally centered.
- Form fields now use a background color distinct from card surfaces.
- Feature pages and the admin shell are code-split with React.lazy to reduce the initial bundle size.
- Switches show green when enabled and red when disabled.
### Fixed
- Table header background is clipped inside the rounded border, removing the square corner visible behind the radius.
- Sidebar section stays synced with the URL on programmatic navigation and full page loads.
## [1.0.6] - 2026-07-28 ## [1.0.6] - 2026-07-28
+1
View File
@@ -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.
+190
View File
@@ -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 15) 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.
+77 -11
View File
@@ -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
@@ -143,7 +207,9 @@ This project is dual-licensed under the **GNU Affero General Public License v3.0
- The [Stalwart Enterprise License v1 (SELv1)](./LICENSES/LicenseRef-SEL.txt) is a proprietary license designed for commercial use. It offers additional features and greater flexibility for businesses that do not wish to comply with the AGPL-3.0 license requirements. - The [Stalwart Enterprise License v1 (SELv1)](./LICENSES/LicenseRef-SEL.txt) is a proprietary license designed for commercial use. It offers additional features and greater flexibility for businesses that do not wish to comply with the AGPL-3.0 license requirements.
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
+105
View File
@@ -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).
+1077
View File
File diff suppressed because it is too large Load Diff
+32
View File
@@ -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:
+3 -1
View File
@@ -6,7 +6,9 @@
<base href="/" /> <base href="/" />
<link rel="icon" type="image/svg+xml" href="favicon.png" /> <link rel="icon" type="image/svg+xml" href="favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Portal</title> <meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#09090b" media="(prefers-color-scheme: dark)" />
<title>Stalwart WebUI</title>
</head> </head>
<body> <body>
+203 -203
View File
@@ -1,12 +1,12 @@
{ {
"name": "stalwart-webui", "name": "stalwart-webui-fork",
"version": "1.0.7", "version": "1.1.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "stalwart-webui", "name": "stalwart-webui-fork",
"version": "1.0.7", "version": "1.1.3",
"dependencies": { "dependencies": {
"@daypicker/react": "^10.0.1", "@daypicker/react": "^10.0.1",
"@radix-ui/react-alert-dialog": "^1.1.23", "@radix-ui/react-alert-dialog": "^1.1.23",
@@ -28,7 +28,7 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"i18next": "^26.3.6", "i18next": "^26.3.6",
"lucide-react": "^1.28.0", "lucide-react": "1.28.0",
"otpauth": "^9.5.1", "otpauth": "^9.5.1",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"react": "^19.2.8", "react": "^19.2.8",
@@ -46,7 +46,7 @@
"@types/node": "^26.1.2", "@types/node": "^26.1.2",
"@types/react": "^19.2.17", "@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.5", "@vitejs/plugin-react": "6.0.5",
"eslint": "^10.8.0", "eslint": "^10.8.0",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-hooks": "^7.1.1",
@@ -57,7 +57,7 @@
"tailwindcss": "^4.3.3", "tailwindcss": "^4.3.3",
"typescript": "~6.0.3", "typescript": "~6.0.3",
"typescript-eslint": "^8.65.0", "typescript-eslint": "^8.65.0",
"vite": "^8.2.0", "vite": "8.2.0",
"vitest": "^4.1.10" "vitest": "^4.1.10"
} }
}, },
@@ -644,9 +644,9 @@
} }
}, },
"node_modules/@napi-rs/wasm-runtime": { "node_modules/@napi-rs/wasm-runtime": {
"version": "1.2.1", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.1.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz",
"integrity": "sha512-KjZdi8Q1wh89gsVmghvbrMgWl6ZWmRmHV6wjB7/g4Zf0dyO+hH3neZUtuDNPO00qq5YE5RITVWvrIZKRaAmzGQ==", "integrity": "sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
@@ -2492,6 +2492,92 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz",
"integrity": "sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.65.0",
"@typescript-eslint/type-utils": "8.65.0",
"@typescript-eslint/utils": "8.65.0",
"@typescript-eslint/visitor-keys": "8.65.0",
"ignore": "^7.0.5",
"natural-compare": "^1.4.0",
"ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "^8.65.0",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz",
"integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/@typescript-eslint/parser": {
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.65.0.tgz",
"integrity": "sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/scope-manager": "8.65.0",
"@typescript-eslint/types": "8.65.0",
"@typescript-eslint/typescript-estree": "8.65.0",
"@typescript-eslint/visitor-keys": "8.65.0",
"debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/project-service": {
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.65.0.tgz",
"integrity": "sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.65.0",
"@typescript-eslint/types": "^8.65.0",
"debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/scope-manager": { "node_modules/@typescript-eslint/scope-manager": {
"version": "8.65.0", "version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.65.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.65.0.tgz",
@@ -2510,6 +2596,48 @@
"url": "https://opencollective.com/typescript-eslint" "url": "https://opencollective.com/typescript-eslint"
} }
}, },
"node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.65.0.tgz",
"integrity": "sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.65.0.tgz",
"integrity": "sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.65.0",
"@typescript-eslint/typescript-estree": "8.65.0",
"@typescript-eslint/utils": "8.65.0",
"debug": "^4.4.3",
"ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/types": { "node_modules/@typescript-eslint/types": {
"version": "8.65.0", "version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.65.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.65.0.tgz",
@@ -2524,6 +2652,71 @@
"url": "https://opencollective.com/typescript-eslint" "url": "https://opencollective.com/typescript-eslint"
} }
}, },
"node_modules/@typescript-eslint/typescript-estree": {
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.65.0.tgz",
"integrity": "sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.65.0",
"@typescript-eslint/tsconfig-utils": "8.65.0",
"@typescript-eslint/types": "8.65.0",
"@typescript-eslint/visitor-keys": "8.65.0",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
"tinyglobby": "^0.2.15",
"ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/@typescript-eslint/utils": {
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.65.0.tgz",
"integrity": "sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.65.0",
"@typescript-eslint/types": "8.65.0",
"@typescript-eslint/typescript-estree": "8.65.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/visitor-keys": { "node_modules/@typescript-eslint/visitor-keys": {
"version": "8.65.0", "version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.65.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.65.0.tgz",
@@ -6067,199 +6260,6 @@
"typescript": ">=4.8.4 <6.1.0" "typescript": ">=4.8.4 <6.1.0"
} }
}, },
"node_modules/typescript-eslint/node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz",
"integrity": "sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.65.0",
"@typescript-eslint/type-utils": "8.65.0",
"@typescript-eslint/utils": "8.65.0",
"@typescript-eslint/visitor-keys": "8.65.0",
"ignore": "^7.0.5",
"natural-compare": "^1.4.0",
"ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "^8.65.0",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/typescript-eslint/node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/type-utils": {
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.65.0.tgz",
"integrity": "sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.65.0",
"@typescript-eslint/typescript-estree": "8.65.0",
"@typescript-eslint/utils": "8.65.0",
"debug": "^4.4.3",
"ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/typescript-eslint/node_modules/@typescript-eslint/parser": {
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.65.0.tgz",
"integrity": "sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/scope-manager": "8.65.0",
"@typescript-eslint/types": "8.65.0",
"@typescript-eslint/typescript-estree": "8.65.0",
"@typescript-eslint/visitor-keys": "8.65.0",
"debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/typescript-eslint/node_modules/@typescript-eslint/typescript-estree": {
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.65.0.tgz",
"integrity": "sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.65.0",
"@typescript-eslint/tsconfig-utils": "8.65.0",
"@typescript-eslint/types": "8.65.0",
"@typescript-eslint/visitor-keys": "8.65.0",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
"tinyglobby": "^0.2.15",
"ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/typescript-eslint/node_modules/@typescript-eslint/typescript-estree/node_modules/@typescript-eslint/project-service": {
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.65.0.tgz",
"integrity": "sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.65.0",
"@typescript-eslint/types": "^8.65.0",
"debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/typescript-eslint/node_modules/@typescript-eslint/typescript-estree/node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.65.0.tgz",
"integrity": "sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/typescript-eslint/node_modules/@typescript-eslint/utils": {
"version": "8.65.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.65.0.tgz",
"integrity": "sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.65.0",
"@typescript-eslint/types": "8.65.0",
"@typescript-eslint/typescript-estree": "8.65.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/typescript-eslint/node_modules/ignore": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz",
"integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/typescript-eslint/node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/undici-types": { "node_modules/undici-types": {
"version": "8.3.0", "version": "8.3.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
+9 -6
View File
@@ -1,11 +1,14 @@
{ {
"name": "stalwart-webui", "name": "stalwart-webui-fork",
"private": true, "private": true,
"version": "1.0.7", "version": "1.1.3",
"description": "Stalwart WebUI", "description": "Stalwart WebUI Fork",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"dev:server": "docker compose up -d",
"dev:server:down": "docker compose down",
"dev:server:logs": "docker compose logs -f stalwart",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"typecheck": "tsc -p tsconfig.app.json --noEmit", "typecheck": "tsc -p tsconfig.app.json --noEmit",
"lint": "eslint .", "lint": "eslint .",
@@ -36,7 +39,7 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"i18next": "^26.3.6", "i18next": "^26.3.6",
"lucide-react": "^1.28.0", "lucide-react": "1.28.0",
"otpauth": "^9.5.1", "otpauth": "^9.5.1",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"react": "^19.2.8", "react": "^19.2.8",
@@ -54,7 +57,7 @@
"@types/node": "^26.1.2", "@types/node": "^26.1.2",
"@types/react": "^19.2.17", "@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.5", "@vitejs/plugin-react": "6.0.5",
"eslint": "^10.8.0", "eslint": "^10.8.0",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-hooks": "^7.1.1",
@@ -65,7 +68,7 @@
"tailwindcss": "^4.3.3", "tailwindcss": "^4.3.3",
"typescript": "~6.0.3", "typescript": "~6.0.3",
"typescript-eslint": "^8.65.0", "typescript-eslint": "^8.65.0",
"vite": "^8.2.0", "vite": "8.2.0",
"vitest": "^4.1.10" "vitest": "^4.1.10"
} }
} }
+1
View File
@@ -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

+1
View File
@@ -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

Binary file not shown.

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

+28
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

+124
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+31
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.9 KiB

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

+139
View File
@@ -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"
+78
View File
@@ -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"
+147
View File
@@ -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."
+76
View File
@@ -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
+71
View File
@@ -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."
+57
View File
@@ -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."
+7 -4
View File
@@ -9,6 +9,7 @@ import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { ScrollArea } from '@/components/ui/scroll-area';
import { ArrowLeft, ArrowRight, Check, Copy, Loader2, Rocket } from 'lucide-react'; import { ArrowLeft, ArrowRight, Check, Copy, Loader2, Rocket } from 'lucide-react';
import { useSchemaStore } from '@/stores/schemaStore'; import { useSchemaStore } from '@/stores/schemaStore';
@@ -403,13 +404,15 @@ export function BootstrapWizard() {
function WizardShell({ children }: { children: React.ReactNode }) { function WizardShell({ children }: { children: React.ReactNode }) {
return ( return (
<div className="flex min-h-screen flex-col bg-content-background"> <div className="flex h-screen flex-col bg-content-background">
<header className="flex items-center px-6 py-4 border-b bg-background"> <header className="flex items-center px-6 py-4 border-b bg-background">
<DefaultLogo /> <DefaultLogo />
</header> </header>
<main className="flex-1 overflow-auto p-6"> <ScrollArea role="main" className="min-w-0 flex-1">
<div className="mx-auto max-w-3xl">{children}</div> <div className="min-w-0 p-4 sm:p-6">
</main> <div className="mx-auto w-full min-w-0 max-w-3xl">{children}</div>
</div>
</ScrollArea>
</div> </div>
); );
} }
+115
View File
@@ -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} />;
}
+24 -21
View File
@@ -4,10 +4,17 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/ */
import { useCallback, useEffect, useMemo } from 'react'; import { useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'; import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'; import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import { friendlyName, getActionInfo, getObjectKind, useGlobalSearch } from '@/hooks/useGlobalSearch'; import { friendlyName, getActionInfo, getObjectKind, useGlobalSearch } from '@/hooks/useGlobalSearch';
import type { SearchIndexEntry } from '@/stores/schemaStore'; import type { SearchIndexEntry } from '@/stores/schemaStore';
@@ -19,16 +26,13 @@ interface CommandPaletteProps {
export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) { export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const closePalette = useCallback(() => onOpenChange(false), [onOpenChange]); const closePalette = useCallback(() => onOpenChange(false), [onOpenChange]);
const { query, setQuery, debouncedQuery, groups, selectEntry, reset, schema } = useGlobalSearch(closePalette); const { query, setQuery, results, groups, selectEntry, reset, schema } = useGlobalSearch(closePalette);
const groupLabels: Record<SearchIndexEntry['type'], string> = useMemo( const GROUP_LABELS: Record<SearchIndexEntry['type'], string> = {
() => ({ link: t('globalSearch.pages', 'Pages'),
link: t('globalSearch.pages', 'Pages'), form: t('globalSearch.formSections', 'Form Sections'),
form: t('globalSearch.formSections', 'Form Sections'), field: t('globalSearch.fields', 'Fields'),
field: t('globalSearch.fields', 'Fields'), };
}),
[t],
);
useEffect(() => { useEffect(() => {
if (!open) reset(); if (!open) reset();
@@ -47,27 +51,26 @@ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
placeholder={t('globalSearch.placeholder', 'Search pages, fields, settings...')} placeholder={t('globalSearch.placeholder', 'Search pages, fields, settings...')}
value={query} value={query}
onValueChange={setQuery} onValueChange={setQuery}
trailing={
<kbd className="pointer-events-none ml-2 inline-flex h-5 shrink-0 select-none items-center rounded-md border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground">
ESC
</kbd>
}
/> />
<CommandList> <CommandList>
<CommandEmpty> <CommandEmpty>
{debouncedQuery.trim() {query.trim()
? t('globalSearch.noResults', 'No results found.') ? t('globalSearch.noResults', 'No results found.')
: t('globalSearch.typeToSearch', 'Type to search the admin panel.')} : t('globalSearch.typeToSearch', 'Type to search the admin panel.')}
</CommandEmpty> </CommandEmpty>
{Array.from(groups.entries()).map(([type, entries]) => ( {Array.from(groups.entries()).map(([type, entries]) => (
<CommandGroup key={type} heading={groupLabels[type]}> <CommandGroup key={type} heading={GROUP_LABELS[type]}>
{entries.map((entry, idx) => { {entries.map((entry) => {
const flatIdx = results.indexOf(entry);
const objectKind = schema ? getObjectKind(schema, entry.viewName) : null; const objectKind = schema ? getObjectKind(schema, entry.viewName) : null;
const { label: actionLabel, Icon: ActionIcon } = getActionInfo(entry.type, objectKind, t); const { label: actionLabel, Icon: ActionIcon } = getActionInfo(entry.type, objectKind, t);
const itemValue = `${type}-${idx}-${entry.viewName}`;
return ( return (
<CommandItem key={itemValue} value={itemValue} onSelect={() => selectEntry(entry)}> <CommandItem
key={`${type}-${entry.viewName}-${flatIdx}`}
value={`${type}-${entry.viewName}-${flatIdx}`}
onSelect={() => selectEntry(entry)}
>
<ActionIcon className="mr-2 h-4 w-4 shrink-0 text-muted-foreground" /> <ActionIcon className="mr-2 h-4 w-4 shrink-0 text-muted-foreground" />
<div className="flex flex-1 flex-col overflow-hidden"> <div className="flex flex-1 flex-col overflow-hidden">
<span className="truncate font-medium">{friendlyName(entry.text)}</span> <span className="truncate font-medium">{friendlyName(entry.text)}</span>
+10 -5
View File
@@ -6,14 +6,19 @@
import { Loader2 } from 'lucide-react'; import { Loader2 } from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils';
export function LoadingFallback({ fullScreen = false }: { fullScreen?: boolean }) { interface LoadingFallbackProps {
fullScreen?: boolean;
}
export function LoadingFallback({ fullScreen }: LoadingFallbackProps) {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<div className={cn('flex flex-col items-center justify-center gap-3', fullScreen ? 'min-h-screen' : 'p-8')}> <div className={`flex items-center justify-center ${fullScreen ? 'min-h-screen' : 'p-8'}`}>
<Loader2 className="h-8 w-8 animate-spin text-primary" /> <div className="flex flex-col items-center gap-3">
<p className="text-muted-foreground">{t('common.loading')}</p> <Loader2 className="h-8 w-8 animate-spin text-primary" />
<p className="text-muted-foreground">{t('common.loading')}</p>
</div>
</div> </div>
); );
} }
+25 -55
View File
@@ -4,76 +4,46 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/ */
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();
return ( return (
<img <svg
src={`${getApiBaseUrl()}/logo`} xmlns="http://www.w3.org/2000/svg"
alt={t('logo.alt', 'Logo')} viewBox="95 84 500 90"
className='h-7 w-auto max-w=[220px] object-contain' aria-label={t('logo.stalwartAlt', 'Stalwart Logo')}
/> className="h-7 w-auto max-w-[320px]"
) >
<path
className="fill-current"
d="M227.8 143.6c.3 4.2 2.1 7.6 5.1 10.1 3.1 2.5 7.1 3.8 12.1 3.8 4.3 0 7.9-.9 10.5-2.8 2.7-1.9 4-4.5 4-7.8 0-2.4-.7-4.3-2.2-5.7-1.5-1.4-3.4-2.5-6-3.2-2.5-.7-6-1.5-10.6-2.3-4.6-.8-8.6-1.9-11.9-3.2-3.3-1.3-6-3.3-8.1-6.1-2.1-2.7-3.1-6.3-3.1-10.7 0-4.1 1.1-7.7 3.2-10.9s5.1-5.7 9-7.4c3.8-1.8 8.2-2.6 13.2-2.6 5.1 0 9.6 1 13.7 2.9 4 1.9 7.2 4.5 9.5 7.8s3.6 7.1 3.8 11.4h-11.5c-.4-3.7-2-6.6-4.8-8.9-2.8-2.2-6.3-3.4-10.6-3.4-4.1 0-7.5.9-9.9 2.7-2.5 1.8-3.7 4.3-3.7 7.6 0 2.3.7 4.1 2.2 5.5 1.5 1.4 3.4 2.4 5.9 3.1 2.4.7 5.9 1.4 10.5 2.2 4.6.8 8.6 1.9 11.9 3.3 3.3 1.4 6 3.4 8.2 6 2.1 2.6 3.2 6.1 3.2 10.5 0 4.2-1.1 8-3.4 11.3-2.2 3.3-5.4 5.9-9.4 7.8-4 1.9-8.6 2.8-13.7 2.8-5.6 0-10.6-1-14.9-3.1-4.3-2-7.6-4.9-10-8.5-2.4-3.6-3.7-7.8-3.7-12.5l11.5.3zM278.5 102.1l11-2.1v14.6h12.6v9.7h-12.6v27.2c0 2 .4 3.5 1.2 4.3.8.9 2.2 1.3 4.2 1.3h8.4v9.7h-10.6c-5 0-8.6-1.2-10.8-3.5-2.2-2.3-3.4-5.9-3.4-10.7v-50.5zM356.8 114.6v52.2h-9.7l-1.2-7.9c-1.8 2.6-4.2 4.7-7 6.2-2.9 1.6-6.2 2.3-10 2.3-4.8 0-9-1.1-12.7-3.2-3.7-2.1-6.7-5.2-8.8-9.3-2.1-4-3.2-8.8-3.2-14.2 0-5.3 1.1-10 3.2-14s5.1-7.2 8.8-9.4c3.7-2.2 7.9-3.3 12.6-3.3 3.9 0 7.2.7 10.1 2.2 2.9 1.5 5.2 3.5 6.9 6.1l1.3-7.6h9.7zm-15.1 38.7c2.8-3.2 4.2-7.3 4.2-12.4 0-5.2-1.4-9.4-4.2-12.6-2.8-3.3-6.5-4.9-11-4.9-4.6 0-8.2 1.6-11 4.8-2.8 3.2-4.2 7.4-4.2 12.5 0 5.2 1.4 9.4 4.2 12.6 2.8 3.2 6.5 4.8 11 4.8s8.2-1.6 11-4.8zM365.5 97.5l11-2.1v71.3h-11V97.5zM380.3 114.6h11.6l11.9 39.9 11.9-39.9h10.1l11.4 39.9 12.3-39.9h11.2l-17.3 52.2h-11.8l-11-35.5-11.4 35.5-11.9.1-17-52.3zM513.7 114.6v52.2H504l-1.2-7.9c-1.8 2.6-4.2 4.7-7 6.2-2.9 1.6-6.2 2.3-10 2.3-4.8 0-9-1.1-12.7-3.2-3.7-2.1-6.7-5.2-8.8-9.3-2.1-4-3.2-8.8-3.2-14.2 0-5.3 1.1-10 3.2-14s5.1-7.2 8.8-9.4c3.7-2.2 7.9-3.3 12.6-3.3 3.9 0 7.2.7 10.1 2.2 2.9 1.5 5.2 3.5 6.9 6.1l1.3-7.6h9.7zm-15.1 38.7c2.8-3.2 4.2-7.3 4.2-12.4 0-5.2-1.4-9.4-4.2-12.6-2.8-3.3-6.5-4.9-11-4.9-4.6 0-8.2 1.6-11 4.8-2.8 3.2-4.2 7.4-4.2 12.5 0 5.2 1.4 9.4 4.2 12.6 2.8 3.2 6.5 4.8 11 4.8 4.6 0 8.2-1.6 11-4.8zM551.3 114.6v10.3h-4.9c-4.6 0-7.8 1.5-9.9 4.4-2 3-3.1 6.7-3.1 11.3v26.2h-11v-52.2h9.8l1.2 7.8c1.5-2.4 3.4-4.4 5.8-5.8 2.4-1.4 5.6-2.1 9.6-2.1h2.5zM556.3 102.1l11-2.1v14.6h12.6v9.7h-12.6v27.2c0 2 .4 3.5 1.2 4.3.8.9 2.2 1.3 4.2 1.3h8.4v9.7h-10.6c-5 0-8.6-1.2-10.8-3.5s-3.4-5.9-3.4-10.7v-50.5z"
/>
<path
fill="#db2d54"
d="M149.1 84.7h-4.8l-44.8 25.9v8.3l44.8 25.9h4.8l44.8-25.9v-8.3l-44.8-25.9zm32.9 30h-35.3V94.4l35.3 20.3zm-35.3 20.4-35.3-20.4 27-15.6v20.2l6.3 3.6h22.9l-20.9 12.2zM99.5 129.9v11l44.8 25.9h4.8l44.8-25.9v-11l-47.2 27.3zM187.3 166.8l6.6-3.8v-11l-25.7 14.8zM99.5 163l6.6 3.8h19.1L99.5 152z"
/>
</svg>
);
} }
export default function Logo() { export default function Logo() {
<<<<<<< HEAD
return <DefaultLogo />
=======
const { t } = useTranslation(); const { t } = useTranslation();
const [logoUrl, setLogoUrl] = useState<string | null>(null); const logo = useSyncExternalStore(subscribeLogo, getLogoSnapshot, getLogoSnapshot);
const [failed, setFailed] = useState(false);
useEffect(() => { useEffect(() => {
const controller = new AbortController(); ensureLogoLoaded();
async function fetchLogo() {
try {
const response = await fetch(`${getApiBaseUrl()}/logo`, {
signal: controller.signal,
});
const contentType = response.headers.get('content-type') ?? '';
if (response.ok && contentType.startsWith('image/')) {
const blob = await response.blob();
if (!controller.signal.aborted) {
const url = URL.createObjectURL(blob);
setLogoUrl(url);
}
} else {
if (!controller.signal.aborted) setFailed(true);
}
} catch {
if (!controller.signal.aborted) setFailed(true);
}
}
fetchLogo();
return () => {
controller.abort();
};
}, []); }, []);
useEffect(() => { if (logo.status === 'custom') {
return () => { return <img src={logo.url} alt={t('logo.alt', 'Logo')} className="h-7 w-auto max-w-[220px] object-contain" />;
if (logoUrl) {
URL.revokeObjectURL(logoUrl);
}
};
}, [logoUrl]);
if (logoUrl && !failed) {
return <img src={logoUrl} alt={t('logo.alt', 'Logo')} className="h-7 w-auto max-w-[220px] object-contain" />;
} }
if (!failed) { if (logo.status === 'loading') {
return <div className="h-7" aria-hidden="true" />; return <span className="h-7 w-[140px] block" aria-hidden="true" />;
} }
return <DefaultLogo />; return <DefaultLogo />;
>>>>>>> upstream/main
} }
+34
View File
@@ -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>
);
}
+56
View File
@@ -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>
);
}
+16 -6
View File
@@ -11,7 +11,7 @@ import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select';
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@@ -53,6 +53,7 @@ import { SECRET_MASK } from '@/lib/jmapUtils';
import { toast } from '@/hooks/use-toast'; import { toast } from '@/hooks/use-toast';
import { logFormChange } from '@/lib/debug'; import { logFormChange } from '@/lib/debug';
import { FieldWidget } from '@/components/forms/FieldWidget'; import { FieldWidget } from '@/components/forms/FieldWidget';
import { BackendVariantIcon } from '@/components/common/BackendIcon';
import type { Field, Fields, Form, FormField, Schema } from '@/types/schema'; import type { Field, Fields, Form, FormField, Schema } from '@/types/schema';
import type { JmapSetResponse, JmapSetError, JmapMethodCall } from '@/types/jmap'; import type { JmapSetResponse, JmapSetError, JmapMethodCall } from '@/types/jmap';
@@ -742,7 +743,7 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
const sectionsToRender = buildSections(combinedForm, currentFields, isCreate, edition); const sectionsToRender = buildSections(combinedForm, currentFields, isCreate, edition);
return ( return (
<div className="mx-auto max-w-4xl space-y-6"> <div className="mx-auto w-full min-w-0 max-w-4xl space-y-6">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}> <Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<ArrowLeft className="h-5 w-5" /> <ArrowLeft className="h-5 w-5" />
@@ -773,17 +774,26 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
if (!visible) return null; if (!visible) return null;
if (formField.name === '@type' && sch.type === 'multiple') { if (formField.name === '@type' && sch.type === 'multiple') {
const selectedVariantLabel = sch.variants.find((v) => v.name === selectedVariant)?.label;
return ( return (
<div key="@type" className="space-y-1.5"> <div key="@type" className="space-y-1.5">
<Label className="text-sm font-medium">{formField.label}</Label> <Label className="text-sm font-medium">{formField.label}</Label>
<Select value={selectedVariant} onValueChange={handleVariantChange} disabled={readOnly}> <Select value={selectedVariant} onValueChange={handleVariantChange} disabled={readOnly}>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder={t('form.selectType', 'Select type...')} /> <div className="flex flex-1 items-center gap-2 overflow-hidden">
<BackendVariantIcon variant={{ name: selectedVariant, label: selectedVariantLabel ?? selectedVariant }} />
<span className="truncate">
{selectedVariantLabel || t('form.selectType', 'Select type...')}
</span>
</div>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{sch.variants.map((v) => ( {sch.variants.map((v) => (
<SelectItem key={v.name} value={v.name}> <SelectItem key={v.name} value={v.name}>
{v.label} <span className="flex items-center gap-2">
<BackendVariantIcon variant={v} />
{v.label}
</span>
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -835,7 +845,7 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
</Card> </Card>
))} ))}
<div className="flex items-center justify-between pt-2 pb-8"> <div className="flex flex-col-reverse gap-3 pt-2 pb-8 sm:flex-row sm:items-center sm:justify-between">
<div> <div>
{canDelete && ( {canDelete && (
<Button type="button" variant="destructive" disabled={saving} onClick={() => setDeleteConfirmOpen(true)}> <Button type="button" variant="destructive" disabled={saving} onClick={() => setDeleteConfirmOpen(true)}>
@@ -844,7 +854,7 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
</Button> </Button>
)} )}
</div> </div>
<div className="flex items-center gap-3"> <div className="flex flex-wrap items-center justify-end gap-3">
{isDirty && ( {isDirty && (
<span className="text-xs text-muted-foreground">{t('form.unsavedChangesLabel', 'Unsaved changes')}</span> <span className="text-xs text-muted-foreground">{t('form.unsavedChangesLabel', 'Unsaved changes')}</span>
)} )}
+152 -33
View File
@@ -15,6 +15,7 @@ import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
@@ -22,19 +23,19 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
import { Combobox, type ComboboxOption } from '@/components/ui/combobox'; import { Combobox, type ComboboxOption } from '@/components/ui/combobox';
import { Calendar } from '@/components/ui/calendar'; import { Calendar } from '@/components/ui/calendar';
import { Plus, X, Eye, EyeOff, Loader2, Search, Check, ChevronRight, Calendar as CalendarIcon } from 'lucide-react'; import { Plus, X, Eye, EyeOff, Loader2, Search, Check, ChevronRight, Calendar as CalendarIcon, Clock } from 'lucide-react';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { ExpressionEditor } from '@/components/expression/ExpressionEditor'; import { ExpressionEditor } from '@/components/expression/ExpressionEditor';
import { OtpAuthField } from '@/components/forms/OtpAuthField'; import { OtpAuthField } from '@/components/forms/OtpAuthField';
import { SizeDisplay } from '@/components/common/SizeDisplay';
import { import {
bytesToHuman, bytesToHuman,
humanToBytes, humanToBytes,
msToHuman, msToHuman,
humanToMs, humanToMs,
formatSize,
formatDuration, formatDuration,
SIZE_UNITS, SIZE_UNITS,
DURATION_UNITS, DURATION_UNITS,
@@ -44,6 +45,7 @@ import { cn } from '@/lib/utils';
import { useAccountStore } from '@/stores/accountStore'; import { useAccountStore } from '@/stores/accountStore';
import { useEffectiveEdition } from '@/components/forms/FormEditionContext'; import { useEffectiveEdition } from '@/components/forms/FormEditionContext';
import { useObjectList, useObjectLabel, useNoPermissionMessage, type ObjectOption } from '@/lib/objectOptions'; import { useObjectList, useObjectLabel, useNoPermissionMessage, type ObjectOption } from '@/lib/objectOptions';
import { BackendVariantIcon } from '@/components/common/BackendIcon';
import { SECRET_MASK } from '@/lib/jmapUtils'; import { SECRET_MASK } from '@/lib/jmapUtils';
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
import { jmapGet, getAccountId } from '@/services/jmap/client'; import { jmapGet, getAccountId } from '@/services/jmap/client';
@@ -400,7 +402,7 @@ function StringField({
value={strValue || '#000000'} value={strValue || '#000000'}
onChange={(e) => handleCommit(e.target.value)} onChange={(e) => handleCommit(e.target.value)}
disabled={readOnly} disabled={readOnly}
className="h-9 w-14 cursor-pointer rounded-md border border-input bg-transparent p-1" className="h-9 w-14 cursor-pointer rounded-md border border-input bg-field p-1"
/> />
<BufferedInput <BufferedInput
value={strValue} value={strValue}
@@ -649,7 +651,11 @@ function SizeInput({ value, onChange, readOnly, nullable }: SizeInputProps) {
if (readOnly) { if (readOnly) {
if (value == null) if (value == null)
return <span className="text-sm text-muted-foreground italic">{t('field.notSet', 'Not set')}</span>; return <span className="text-sm text-muted-foreground italic">{t('field.notSet', 'Not set')}</span>;
return <span className="text-sm">{formatSize(value as number)}</span>; return (
<span className="text-sm">
<SizeDisplay bytes={value as number} />
</span>
);
} }
return <SizeInputEditable value={value} onChange={onChange} nullable={nullable} />; return <SizeInputEditable value={value} onChange={onChange} nullable={nullable} />;
} }
@@ -833,65 +839,166 @@ interface DateTimeFieldProps {
nullable?: boolean; nullable?: boolean;
} }
const DATE_TIME_FORMAT = new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }); const TIME_HOURS = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0'));
const TIME_MINUTES = Array.from({ length: 60 }, (_, i) => String(i).padStart(2, '0'));
/** Themed hour/minute selects — avoids the native time picker which ignores app dark/light tokens. */
function DateTimeTimeSelect({
value,
onChange,
disabled,
}: {
value: string;
onChange: (value: string) => void;
disabled?: boolean;
}) {
const { t } = useTranslation();
const [hour = '00', minute = '00'] = value.split(':');
const update = (nextHour: string, nextMinute: string) => {
onChange(`${nextHour}:${nextMinute}`);
};
return (
<div className="flex flex-1 items-center gap-1.5">
<Select
value={TIME_HOURS.includes(hour) ? hour : '00'}
disabled={disabled}
onValueChange={(nextHour) => update(nextHour, TIME_MINUTES.includes(minute) ? minute : '00')}
>
<SelectTrigger className="h-9 w-[4.75rem]" aria-label={t('field.hour', 'Hour')}>
<SelectValue />
</SelectTrigger>
<SelectContent className="max-h-60" position="popper">
{TIME_HOURS.map((h) => (
<SelectItem key={h} value={h}>
{h}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-sm text-muted-foreground" aria-hidden="true">
:
</span>
<Select
value={TIME_MINUTES.includes(minute) ? minute : '00'}
disabled={disabled}
onValueChange={(nextMinute) => update(TIME_HOURS.includes(hour) ? hour : '00', nextMinute)}
>
<SelectTrigger className="h-9 w-[4.75rem]" aria-label={t('field.minute', 'Minute')}>
<SelectValue />
</SelectTrigger>
<SelectContent className="max-h-60" position="popper">
{TIME_MINUTES.map((m) => (
<SelectItem key={m} value={m}>
{m}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
function DateTimeField({ value, onChange, readOnly, nullable }: DateTimeFieldProps) { function DateTimeField({ value, onChange, readOnly, nullable }: DateTimeFieldProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const strValue = typeof value === 'string' ? value : ''; const strValue = typeof value === 'string' ? value : '';
const [open, setOpen] = useState(false);
const selected = useMemo(() => { const parsed = useMemo(() => {
const d = strValue ? new Date(strValue) : null; if (!strValue) return null;
return d && !isNaN(d.getTime()) ? d : null; const d = new Date(strValue);
return isNaN(d.getTime()) ? null : d;
}, [strValue]); }, [strValue]);
const localTime = selected ? selected.toTimeString().slice(0, 5) : ''; const timeValue = parsed
? `${String(parsed.getHours()).padStart(2, '0')}:${String(parsed.getMinutes()).padStart(2, '0')}`
: '';
const commit = (day: Date, time: string) => { // Date and time are committed together so the field never holds a
// half-entered value the way a free-form datetime input does.
const commit = (date: Date, time: string) => {
const [hours, minutes] = time.split(':').map(Number); const [hours, minutes] = time.split(':').map(Number);
const next = new Date(day); const next = new Date(date);
next.setHours(hours || 0, minutes || 0, 0, 0); next.setHours(Number.isFinite(hours) ? hours : 0, Number.isFinite(minutes) ? minutes : 0, 0, 0);
onChange(next.toISOString()); onChange(next.toISOString());
}; };
const formatClock = (d: Date): string =>
`${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
/** Empty fields open on "now" so the calendar and time selects start useful. */
const handleOpenChange = (next: boolean) => {
setOpen(next);
if (next && !parsed) {
const now = new Date();
now.setSeconds(0, 0);
onChange(now.toISOString());
}
};
if (readOnly) { if (readOnly) {
if (!strValue) { if (!strValue) {
return <span className="text-sm text-muted-foreground italic">{t('field.notSet', 'Not set')}</span>; return <span className="text-sm text-muted-foreground italic">{t('field.notSet', 'Not set')}</span>;
} }
return <span className="text-sm">{selected ? DATE_TIME_FORMAT.format(selected) : strValue}</span>; let formatted = strValue;
try {
const d = new Date(strValue);
if (!isNaN(d.getTime())) {
formatted = new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
}).format(d);
}
// eslint-disable-next-line no-empty
} catch {}
return <span className="text-sm">{formatted}</span>;
} }
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Popover> <Popover open={open} onOpenChange={handleOpenChange} modal={false}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
className={cn('flex-1 justify-start text-left font-normal', !selected && 'text-muted-foreground')} className={cn('flex-1 justify-start text-left font-normal', !parsed && 'text-muted-foreground')}
> >
<CalendarIcon className="mr-2 h-4 w-4" /> <CalendarIcon className="mr-2 h-4 w-4" />
{selected ? DATE_TIME_FORMAT.format(selected) : t('field.pickDate', 'Pick a date')} {parsed
? new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(parsed)
: t('field.pickDate', 'Pick a date')}
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start"> <PopoverContent
className="w-auto p-0"
align="start"
onOpenAutoFocus={(e) => e.preventDefault()}
onInteractOutside={(e) => {
const target = e.target as HTMLElement | null;
if (target?.closest('[data-radix-select-content]')) {
e.preventDefault();
}
}}
>
<Calendar <Calendar
mode="single" mode="single"
selected={selected ?? undefined} selected={parsed ?? undefined}
defaultMonth={selected ?? undefined} defaultMonth={parsed ?? new Date()}
autoFocus
onSelect={(day) => { onSelect={(day) => {
if (day) commit(day, localTime || '00:00'); if (!day) return;
commit(day, timeValue || formatClock(new Date()));
}} }}
autoFocus
/> />
<div className="border-t p-3"> <div className="flex items-center gap-2 border-t bg-background p-3">
<Input <Clock className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
type="time" <DateTimeTimeSelect
value={localTime} value={timeValue || formatClock(new Date())}
disabled={!selected} disabled={!parsed}
onChange={(e) => { onChange={(next) => {
if (selected && e.target.value) commit(selected, e.target.value); if (parsed) commit(parsed, next);
}} }}
aria-label={t('field.time', 'Time')}
/> />
</div> </div>
</PopoverContent> </PopoverContent>
@@ -1449,18 +1556,28 @@ function EmbeddedObjectField({
onChange(newObj); onChange(newObj);
}; };
const selectedVariantLabel = resolvedSchema.variants.find((v) => v.name === currentType)?.label;
return ( return (
<div className="rounded-md border p-4 space-y-4"> <div className="rounded-md border p-4 space-y-4">
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label className="text-sm font-medium">{formField.label ?? t('field.type', 'Type')}</Label> <Label className="text-sm font-medium">{formField.label ?? t('field.type', 'Type')}</Label>
<Select value={currentType} onValueChange={handleVariantChange} disabled={readOnly}> <Select value={currentType} onValueChange={handleVariantChange} disabled={readOnly}>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder={t('form.selectType', 'Select type...')} /> <div className="flex flex-1 items-center gap-2 overflow-hidden">
<BackendVariantIcon variant={{ name: currentType, label: selectedVariantLabel ?? currentType }} />
<span className="truncate">
{selectedVariantLabel || t('form.selectType', 'Select type...')}
</span>
</div>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{resolvedSchema.variants.map((v) => ( {resolvedSchema.variants.map((v) => (
<SelectItem key={v.name} value={v.name}> <SelectItem key={v.name} value={v.name}>
{v.label} <span className="flex items-center gap-2">
<BackendVariantIcon variant={v} />
{v.label}
</span>
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -1842,7 +1959,8 @@ function EnumMultiSelect({ enumName, items, onChange, readOnly, schema, minItems
className="h-8" className="h-8"
/> />
</div> </div>
<div className="max-h-60 overflow-y-auto p-2 space-y-1"> <ScrollArea viewportClassName="max-h-60">
<div className="p-2 space-y-1">
{filtered.length === 0 && ( {filtered.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-2">{t('field.noMatches', 'No matches')}</p> <p className="text-sm text-muted-foreground text-center py-2">{t('field.noMatches', 'No matches')}</p>
)} )}
@@ -1870,7 +1988,8 @@ function EnumMultiSelect({ enumName, items, onChange, readOnly, schema, minItems
</Label> </Label>
</div> </div>
))} ))}
</div> </div>
</ScrollArea>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
</div> </div>
+21 -27
View File
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/ */
import { lazy, Suspense, useEffect, type ComponentType, type ReactNode } from 'react'; import { lazy, Suspense, useEffect } from 'react';
import { useSchemaStore } from '@/stores/schemaStore'; import { useSchemaStore } from '@/stores/schemaStore';
import { useCacheStore } from '@/stores/cacheStore'; import { useCacheStore } from '@/stores/cacheStore';
import { useAccountStore } from '@/stores/accountStore'; import { useAccountStore } from '@/stores/accountStore';
@@ -13,32 +13,22 @@ import { DynamicList } from '@/components/lists/DynamicList';
import { DynamicForm } from '@/components/forms/DynamicForm'; import { DynamicForm } from '@/components/forms/DynamicForm';
import { DynamicViewPage } from '@/components/views/DynamicViewPage'; import { DynamicViewPage } from '@/components/views/DynamicViewPage';
import { LoadingFallback } from '@/components/common/LoadingFallback'; import { LoadingFallback } from '@/components/common/LoadingFallback';
import type { Schema } from '@/types/schema';
function lazyFeature<M, P>(load: () => Promise<M>, select: (module: M) => ComponentType<P>) { // Heavy or rarely used feature pages are code-split so the initial bundle
return lazy(() => load().then((module) => ({ default: select(module) }))); // stays small (the dashboard pulls in recharts, ~150 kB gzipped on its own).
} const DashboardView = lazy(() =>
import('@/features/dashboard/components/DashboardView').then((m) => ({ default: m.DashboardView })),
const DashboardView = lazyFeature(
() => import('@/features/dashboard/components/DashboardView'),
(m) => m.DashboardView,
); );
const DeliveryTracePage = lazyFeature( const DeliveryTracePage = lazy(() =>
() => import('@/features/troubleshoot/DeliveryTracePage'), import('@/features/troubleshoot/DeliveryTracePage').then((m) => ({ default: m.DeliveryTracePage })),
(m) => m.DeliveryTracePage,
); );
const LiveTracingPage = lazyFeature( const LiveTracingPage = lazy(() =>
() => import('@/features/tracing/components/LiveTracingPage'), import('@/features/tracing/components/LiveTracingPage').then((m) => ({ default: m.LiveTracingPage })),
(m) => m.LiveTracingPage,
); );
const TraceDetailView = lazyFeature( const TraceDetailView = lazy(() =>
() => import('@/features/tracing/components/TraceDetailView'), import('@/features/tracing/components/TraceDetailView').then((m) => ({ default: m.TraceDetailView })),
(m) => m.TraceDetailView,
);
const ActionPage = lazyFeature(
() => import('@/features/actions/ActionPage'),
(m) => m.ActionPage,
); );
const ActionPage = lazy(() => import('@/features/actions/ActionPage').then((m) => ({ default: m.ActionPage })));
interface MainContentProps { interface MainContentProps {
viewName?: string; viewName?: string;
@@ -47,6 +37,14 @@ interface MainContentProps {
} }
export function MainContent({ viewName, id, section }: MainContentProps) { export function MainContent({ viewName, id, section }: MainContentProps) {
return (
<Suspense fallback={<LoadingFallback />}>
<MainContentView viewName={viewName} id={id} section={section} />
</Suspense>
);
}
function MainContentView({ viewName, id, section }: MainContentProps) {
const schema = useSchemaStore((s) => s.schema); const schema = useSchemaStore((s) => s.schema);
const invalidateAllObjectLists = useCacheStore((s) => s.invalidateAllObjectLists); const invalidateAllObjectLists = useCacheStore((s) => s.invalidateAllObjectLists);
@@ -54,10 +52,6 @@ export function MainContent({ viewName, id, section }: MainContentProps) {
invalidateAllObjectLists(); invalidateAllObjectLists();
}, [viewName, invalidateAllObjectLists]); }, [viewName, invalidateAllObjectLists]);
return <Suspense fallback={<LoadingFallback />}>{renderView(schema, viewName, id, section)}</Suspense>;
}
function renderView(schema: Schema | null, viewName?: string, id?: string, section?: string): ReactNode {
if (!viewName) { if (!viewName) {
return ( return (
<div className="flex items-center justify-center p-8 text-muted-foreground">Select a view from the sidebar.</div> <div className="flex items-center justify-center p-8 text-muted-foreground">Select a view from the sidebar.</div>
@@ -110,7 +104,7 @@ function renderView(schema: Schema | null, viewName?: string, id?: string, secti
} }
if (id) { if (id) {
if (resolved.objectName === 'x:Trace') { if (resolved.objectName === 'x:Trace' && id !== 'new') {
return <TraceDetailView viewName={viewName} objectId={id} />; return <TraceDetailView viewName={viewName} objectId={id} />;
} }
const canUpdate = useAccountStore.getState().hasObjectPermission(resolved.permissionPrefix, 'Update'); const canUpdate = useAccountStore.getState().hasObjectPermission(resolved.permissionPrefix, 'Update');
+154 -95
View File
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/ */
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { createContext, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import * as LucideIcons from 'lucide-react'; import * as LucideIcons from 'lucide-react';
const { ChevronDown, Lock } = LucideIcons; const { ChevronDown, Lock } = LucideIcons;
@@ -12,6 +12,7 @@ import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { EnterpriseUpsell } from '@/components/common/EnterpriseUpsell'; import { EnterpriseUpsell } from '@/components/common/EnterpriseUpsell';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { useUIStore } from '@/stores/uiStore'; import { useUIStore } from '@/stores/uiStore';
import { useAccountStore } from '@/stores/accountStore'; import { useAccountStore } from '@/stores/accountStore';
@@ -23,6 +24,7 @@ import {
isLinkEnterprise, isLinkEnterprise,
isLinkVisible, isLinkVisible,
} from '@/lib/layout'; } from '@/lib/layout';
import { findLastVisitedLinkInLayout, setLastVisitedSection } from '@/lib/lastVisited';
import type { Layout, LayoutItem, LayoutSubItem } from '@/types/schema'; import type { Layout, LayoutItem, LayoutSubItem } from '@/types/schema';
function LucideIcon({ name, className }: { name: string; className?: string }) { function LucideIcon({ name, className }: { name: string; className?: string }) {
@@ -74,25 +76,57 @@ function subtreeHasVisibleLink(items: LayoutSubItem[], edition: string): boolean
return false; return false;
} }
interface AutoOpenCollapsibleProps { interface AccordionLevelContextValue {
containsActive: boolean; openId: string | null;
children: React.ReactNode; setOpenId: (id: string | null) => void;
} }
function AutoOpenCollapsible({ containsActive, children }: AutoOpenCollapsibleProps) { const AccordionLevelContext = createContext<AccordionLevelContextValue | null>(null);
const [open, setOpen] = useState(containsActive);
const [prevContainsActive, setPrevContainsActive] = useState(containsActive); // Sibling collapsibles share a single open id, so expanding one collapses the
if (containsActive !== prevContainsActive) { // others at the same level (accordion behavior).
setPrevContainsActive(containsActive); function AccordionLevel({ children }: { children: React.ReactNode }) {
if (containsActive) setOpen(true); const [openId, setOpenId] = useState<string | null>(null);
} const value = useMemo(() => ({ openId, setOpenId }), [openId]);
return <AccordionLevelContext.Provider value={value}>{children}</AccordionLevelContext.Provider>;
}
// A collapsible wired to its accordion level. It opens itself whenever the
// active page lands inside it, while still allowing manual toggling.
function AccordionCollapsible({
id,
containsActive,
children,
}: {
id: string;
containsActive: boolean;
children: React.ReactNode;
}) {
const level = useContext(AccordionLevelContext);
if (!level) throw new Error('AccordionCollapsible must be used within AccordionLevel');
const { openId, setOpenId } = level;
// Layout effect so the branch containing the active page is already open on
// the first paint after a navigation.
useLayoutEffect(() => {
if (containsActive) setOpenId(id);
}, [containsActive, id, setOpenId]);
return ( return (
<Collapsible open={open} onOpenChange={setOpen}> <Collapsible open={openId === id} onOpenChange={(open) => setOpenId(open ? id : null)}>
{children} {children}
</Collapsible> </Collapsible>
); );
} }
// Softer than the default ghost hover, closer to documentation sidebars:
// muted text that brightens with a faint background instead of a strong fill.
const sidebarItemClass =
'w-full justify-start gap-2 font-normal text-muted-foreground hover:bg-accent/50 hover:text-foreground';
// In square mode the sidebar follows documentation conventions (better-auth):
// full-bleed rows with a barely-there hover wash instead of inset pills.
const sidebarItemSquareClass =
"[[data-radius='square']_&]:hover:bg-foreground/[0.03] [[data-radius='square']_&]:hover:text-foreground/90";
function checkLinkVisible(viewName: string): boolean { function checkLinkVisible(viewName: string): boolean {
const schema = useSchemaStore.getState().schema; const schema = useSchemaStore.getState().schema;
if (!schema) return true; if (!schema) return true;
@@ -114,8 +148,6 @@ function checkIsEnterprise(viewName: string): boolean {
return isLinkEnterprise(schema, viewName, edition); return isLinkEnterprise(schema, viewName, edition);
} }
type ActiveItemRef = (el: HTMLButtonElement | null) => void;
interface SidebarSubItemProps { interface SidebarSubItemProps {
item: LayoutSubItem; item: LayoutSubItem;
depth: number; depth: number;
@@ -124,19 +156,14 @@ interface SidebarSubItemProps {
navigate: ReturnType<typeof useNavigate>; navigate: ReturnType<typeof useNavigate>;
edition: string; edition: string;
onUpsell: () => void; onUpsell: () => void;
activeItemRef: ActiveItemRef;
} }
function SidebarSubItem({ function SidebarSubItem({ item, depth, sectionName, currentPath, navigate, edition, onUpsell }: SidebarSubItemProps) {
item, // Picking a plain link closes any sibling group left open at this same
depth, // accordion level — it's not part of a collapsible, so nothing should
sectionName, // stay expanded on its account once it's the one that's active.
currentPath, const level = useContext(AccordionLevelContext);
navigate,
edition,
onUpsell,
activeItemRef,
}: SidebarSubItemProps) {
if (item.type === 'link') { if (item.type === 'link') {
if (!checkLinkVisible(item.viewName)) return null; if (!checkLinkVisible(item.viewName)) return null;
@@ -151,10 +178,11 @@ function SidebarSubItem({
return ( return (
<Button <Button
variant="ghost" variant="ghost"
ref={isActive ? activeItemRef : undefined} data-sidebar-active={isActive || undefined}
className={cn( className={cn(
'w-full justify-start gap-2 font-normal', sidebarItemClass,
isActive && 'bg-accent text-accent-foreground', sidebarItemSquareClass,
isActive && 'bg-accent text-accent-foreground hover:bg-accent',
depth > 0 && 'text-sm', depth > 0 && 'text-sm',
)} )}
style={{ paddingLeft: `${(depth + 1) * 12 + 8}px` }} style={{ paddingLeft: `${(depth + 1) * 12 + 8}px` }}
@@ -162,6 +190,8 @@ function SidebarSubItem({
if (isLocked) { if (isLocked) {
onUpsell(); onUpsell();
} else { } else {
level?.setOpenId(null);
setLastVisitedSection(sectionName, item.viewName);
navigate(path); navigate(path);
} }
}} }}
@@ -177,11 +207,11 @@ function SidebarSubItem({
const containsActive = subtreeContainsActive(item.items, currentPath, sectionName); const containsActive = subtreeContainsActive(item.items, currentPath, sectionName);
return ( return (
<AutoOpenCollapsible containsActive={containsActive}> <AccordionCollapsible id={item.name} containsActive={containsActive}>
<CollapsibleTrigger asChild> <CollapsibleTrigger asChild>
<Button <Button
variant="ghost" variant="ghost"
className="w-full justify-start gap-2 font-normal text-sm" className={cn(sidebarItemClass, sidebarItemSquareClass, 'text-sm')}
style={{ paddingLeft: `${(depth + 1) * 12 + 8}px` }} style={{ paddingLeft: `${(depth + 1) * 12 + 8}px` }}
> >
<ChevronDown className="h-3 w-3 shrink-0 transition-transform duration-200 [[data-state=closed]>&]:rotate-[-90deg]" /> <ChevronDown className="h-3 w-3 shrink-0 transition-transform duration-200 [[data-state=closed]>&]:rotate-[-90deg]" />
@@ -189,21 +219,22 @@ function SidebarSubItem({
</Button> </Button>
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent> <CollapsibleContent>
{item.items.map((sub) => ( <AccordionLevel>
<SidebarSubItem {item.items.map((sub) => (
key={sub.type === 'link' ? sub.viewName : sub.name} <SidebarSubItem
item={sub} key={sub.type === 'link' ? sub.viewName : sub.name}
depth={depth + 1} item={sub}
sectionName={sectionName} depth={depth + 1}
currentPath={currentPath} sectionName={sectionName}
navigate={navigate} currentPath={currentPath}
edition={edition} navigate={navigate}
onUpsell={onUpsell} edition={edition}
activeItemRef={activeItemRef} onUpsell={onUpsell}
/> />
))} ))}
</AccordionLevel>
</CollapsibleContent> </CollapsibleContent>
</AutoOpenCollapsible> </AccordionCollapsible>
); );
} }
@@ -217,18 +248,14 @@ interface SidebarTopItemProps {
navigate: ReturnType<typeof useNavigate>; navigate: ReturnType<typeof useNavigate>;
edition: string; edition: string;
onUpsell: () => void; onUpsell: () => void;
activeItemRef: ActiveItemRef;
} }
function SidebarTopItem({ function SidebarTopItem({ item, sectionName, currentPath, navigate, edition, onUpsell }: SidebarTopItemProps) {
item, // Picking a plain link closes any sibling group left open at this same
sectionName, // accordion level — it's not part of a collapsible, so nothing should
currentPath, // stay expanded on its account once it's the one that's active.
navigate, const level = useContext(AccordionLevelContext);
edition,
onUpsell,
activeItemRef,
}: SidebarTopItemProps) {
if ('link' in item) { if ('link' in item) {
const { name, icon, viewName } = item.link; const { name, icon, viewName } = item.link;
@@ -245,12 +272,18 @@ function SidebarTopItem({
return ( return (
<Button <Button
variant="ghost" variant="ghost"
ref={isActive ? activeItemRef : undefined} data-sidebar-active={isActive || undefined}
className={cn('w-full justify-start gap-2 font-normal', isActive && 'bg-accent text-accent-foreground')} className={cn(
sidebarItemClass,
sidebarItemSquareClass,
isActive && 'bg-accent text-accent-foreground hover:bg-accent',
)}
onClick={() => { onClick={() => {
if (isLocked) { if (isLocked) {
onUpsell(); onUpsell();
} else { } else {
level?.setOpenId(null);
setLastVisitedSection(sectionName, viewName);
navigate(path); navigate(path);
} }
}} }}
@@ -269,30 +302,31 @@ function SidebarTopItem({
const containsActive = subtreeContainsActive(items, currentPath, sectionName); const containsActive = subtreeContainsActive(items, currentPath, sectionName);
return ( return (
<AutoOpenCollapsible containsActive={containsActive}> <AccordionCollapsible id={name} containsActive={containsActive}>
<CollapsibleTrigger asChild> <CollapsibleTrigger asChild>
<Button variant="ghost" className="w-full justify-start gap-2 font-normal"> <Button variant="ghost" className={cn(sidebarItemClass, sidebarItemSquareClass)}>
<LucideIcon name={icon} className="h-4 w-4 shrink-0" /> <LucideIcon name={icon} className="h-4 w-4 shrink-0" />
<span className="truncate">{name}</span> <span className="truncate">{name}</span>
<ChevronDown className="ml-auto h-3 w-3 shrink-0 transition-transform duration-200 [[data-state=closed]>&]:rotate-[-90deg]" /> <ChevronDown className="ml-auto h-3 w-3 shrink-0 transition-transform duration-200 [[data-state=closed]>&]:rotate-[-90deg]" />
</Button> </Button>
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent> <CollapsibleContent>
{items.map((sub) => ( <AccordionLevel>
<SidebarSubItem {items.map((sub) => (
key={sub.type === 'link' ? sub.viewName : sub.name} <SidebarSubItem
item={sub} key={sub.type === 'link' ? sub.viewName : sub.name}
depth={1} item={sub}
sectionName={sectionName} depth={1}
currentPath={currentPath} sectionName={sectionName}
navigate={navigate} currentPath={currentPath}
edition={edition} navigate={navigate}
onUpsell={onUpsell} edition={edition}
activeItemRef={activeItemRef} onUpsell={onUpsell}
/> />
))} ))}
</AccordionLevel>
</CollapsibleContent> </CollapsibleContent>
</AutoOpenCollapsible> </AccordionCollapsible>
); );
} }
@@ -311,11 +345,16 @@ export function Sidebar() {
const permissions = useAccountStore((s) => s.permissions); const permissions = useAccountStore((s) => s.permissions);
const hasPermission = useAccountStore((s) => s.hasPermission); const hasPermission = useAccountStore((s) => s.hasPermission);
const [upsellOpen, setUpsellOpen] = useState(false); const [upsellOpen, setUpsellOpen] = useState(false);
const activeItem = useRef<HTMLButtonElement | null>(null); const navRef = useRef<HTMLElement>(null);
const activeItemRef = useCallback<ActiveItemRef>((el) => { // Set by handleSectionClick right before navigating: switching sections
activeItem.current = el; // from the footer should keep the sidebar open so the new section's pages
}, []); // are still browsable, instead of closing right back up like a leaf-page
// navigation would.
const skipCloseOnNavigateRef = useRef(false);
// Build the permission checks from the permissions array itself: the store
// accessors are stable refs, so depending on them alone would keep a stale
// layout list after access data finishes loading.
const layouts = useMemo(() => { const layouts = useMemo(() => {
if (!schema) return []; if (!schema) return [];
const canGet = (prefix: string) => permissions.includes(`${prefix}Get`); const canGet = (prefix: string) => permissions.includes(`${prefix}Get`);
@@ -324,13 +363,20 @@ export function Sidebar() {
useEffect(() => { useEffect(() => {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
if (skipCloseOnNavigateRef.current) {
skipCloseOnNavigateRef.current = false;
return;
}
if (window.matchMedia('(max-width: 767px)').matches) { if (window.matchMedia('(max-width: 767px)').matches) {
setSidebarOpen(false); setSidebarOpen(false);
} }
}, [location.pathname, setSidebarOpen]); }, [location.pathname, setSidebarOpen]);
// Keep the active page visible in the sidebar after any navigation
// (e.g. from the command palette or an external link).
useEffect(() => { useEffect(() => {
activeItem.current?.scrollIntoView({ block: 'nearest' }); const active = navRef.current?.querySelector('[data-sidebar-active="true"]');
active?.scrollIntoView({ block: 'nearest' });
}, [location.pathname, activeSection]); }, [location.pathname, activeSection]);
if (!sidebarOpen || !schema) return null; if (!sidebarOpen || !schema) return null;
@@ -341,10 +387,15 @@ export function Sidebar() {
const handleSectionClick = (target: Layout) => { const handleSectionClick = (target: Layout) => {
setActiveSection(target.name); setActiveSection(target.name);
const canGet = (prefix: string) => permissions.includes(`${prefix}Get`); const canGet = (prefix: string) => permissions.includes(`${prefix}Get`);
const last = findLastVisitedLinkInLayout(schema, target, edition, canGet, hasPermission);
const first = const first =
last ??
findFirstAccessibleLinkInLayout(schema, target, edition, canGet, hasPermission) ?? findFirstAccessibleLinkInLayout(schema, target, edition, canGet, hasPermission) ??
findFirstVisibleLinkInLayout(schema, target, edition, canGet, hasPermission); findFirstVisibleLinkInLayout(schema, target, edition, canGet, hasPermission);
if (first) navigate(`/${target.name}/${first}`); if (first) {
skipCloseOnNavigateRef.current = true;
navigate(`/${target.name}/${first}`);
}
}; };
return ( return (
@@ -355,26 +406,30 @@ 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
{layout.items.map((item) => ( ref={navRef}
<SidebarTopItem className="flex flex-col gap-0.5 px-2 py-2 [[data-radius='square']_&]:gap-0 [[data-radius='square']_&]:px-0"
key={'link' in item ? item.link.viewName : item.container.name} >
item={item} <AccordionLevel>
sectionName={layout.name} {layout.items.map((item) => (
currentPath={location.pathname} <SidebarTopItem
navigate={navigate} key={'link' in item ? item.link.viewName : item.container.name}
edition={edition} item={item}
onUpsell={() => setUpsellOpen(true)} sectionName={layout.name}
activeItemRef={activeItemRef} currentPath={location.pathname}
/> navigate={navigate}
))} edition={edition}
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
@@ -393,7 +448,11 @@ export function Sidebar() {
aria-label={target.name} aria-label={target.name}
aria-current={isActive ? 'page' : undefined} aria-current={isActive ? 'page' : undefined}
onClick={() => handleSectionClick(target)} onClick={() => handleSectionClick(target)}
className={cn('h-9 w-9', isActive && 'bg-accent text-accent-foreground')} className={cn(
'h-9 w-9',
"[[data-radius='square']_&]:h-12 [[data-radius='square']_&]:flex-1 [[data-radius='square']_&]:border-r [[data-radius='square']_&]:border-border [[data-radius='square']_&]:last:border-r-0 [[data-radius='square']_&]:hover:bg-foreground/[0.03]",
isActive && 'bg-accent text-accent-foreground',
)}
> >
{Icon ? <Icon className="h-4 w-4" /> : <LucideIcons.Circle className="h-4 w-4" />} {Icon ? <Icon className="h-4 w-4" /> : <LucideIcons.Circle className="h-4 w-4" />}
</Button> </Button>
+35 -13
View File
@@ -7,7 +7,7 @@
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import * as LucideIcons from 'lucide-react'; import * as LucideIcons from 'lucide-react';
const { Sun, Moon, User, LogOut, Check, Menu, Sparkles, Search } = LucideIcons; const { User, LogOut, Check, Menu, Sparkles, Search, Palette, ScrollText } = LucideIcons;
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { CommandPalette } from '@/components/common/CommandPalette'; import { CommandPalette } from '@/components/common/CommandPalette';
import { import {
@@ -20,18 +20,19 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import Logo from '@/components/common/Logo'; import Logo from '@/components/common/Logo';
import { ModeToggle } from '@/components/common/ModeToggle';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { EnterpriseUpsell } from '@/components/common/EnterpriseUpsell'; import { EnterpriseUpsell } from '@/components/common/EnterpriseUpsell';
import { findFirstAccessibleLinkInLayout, findFirstVisibleLinkInLayout, visibleLayouts } from '@/lib/layout'; import { findFirstAccessibleLinkInLayout, findFirstVisibleLinkInLayout, visibleLayouts } from '@/lib/layout';
import { findLastVisitedLinkInLayout } from '@/lib/lastVisited';
import { useUIStore } from '@/stores/uiStore'; import { useUIStore } from '@/stores/uiStore';
import { useAuthStore } from '@/stores/authStore'; import { useAuthStore } from '@/stores/authStore';
import { buildEndSessionUrl, getPostLogoutRedirectUri } from '@/services/auth/oauth'; import { buildEndSessionUrl, getPostLogoutRedirectUri } from '@/services/auth/oauth';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useAccountStore } from '@/stores/accountStore'; import { useAccountStore } from '@/stores/accountStore';
import { useCurrentAccountDetails } from '@/hooks/useCurrentAccount';
import { useSchemaStore } from '@/stores/schemaStore'; import { useSchemaStore } from '@/stores/schemaStore';
const IS_MAC = /Mac|iPhone|iPad|iPod/.test(navigator.userAgent);
function getIcon(name: string): LucideIcons.LucideIcon { function getIcon(name: string): LucideIcons.LucideIcon {
const formatted = name const formatted = name
.split('-') .split('-')
@@ -43,14 +44,16 @@ function getIcon(name: string): LucideIcons.LucideIcon {
export function TopBar() { export function TopBar() {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const theme = useUIStore((s) => s.theme);
const toggleTheme = useUIStore((s) => s.toggleTheme);
const toggleSidebar = useUIStore((s) => s.toggleSidebar); const toggleSidebar = useUIStore((s) => s.toggleSidebar);
const setActiveSection = useUIStore((s) => s.setActiveSection); const setActiveSection = useUIStore((s) => s.setActiveSection);
const accounts = useAuthStore((s) => s.accounts); const accounts = useAuthStore((s) => s.accounts);
const activeAccountId = useAuthStore((s) => s.activeAccountId); const activeAccountId = useAuthStore((s) => s.activeAccountId);
const switchAccount = useAuthStore((s) => s.switchAccount); const switchAccount = useAuthStore((s) => s.switchAccount);
const logout = useAuthStore((s) => s.logout); const logout = useAuthStore((s) => s.logout);
const activeAccount = activeAccountId ? accounts[activeAccountId] : null;
const currentAccount = useCurrentAccountDetails();
const displayName = currentAccount.username?.trim() || activeAccount?.name?.trim() || activeAccountId?.split('@')[0] || activeAccountId;
const displayEmail = currentAccount.email || activeAccountId;
const edition = useAccountStore((s) => s.edition); const edition = useAccountStore((s) => s.edition);
const hasObjectPermission = useAccountStore((s) => s.hasObjectPermission); const hasObjectPermission = useAccountStore((s) => s.hasObjectPermission);
const hasPermission = useAccountStore((s) => s.hasPermission); const hasPermission = useAccountStore((s) => s.hasPermission);
@@ -60,7 +63,7 @@ export function TopBar() {
useEffect(() => { useEffect(() => {
function handleGlobalKeyDown(e: KeyboardEvent) { function handleGlobalKeyDown(e: KeyboardEvent) {
if ((e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'k') { if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
e.preventDefault(); e.preventDefault();
setPaletteOpen((open) => !open); setPaletteOpen((open) => !open);
} }
@@ -69,6 +72,8 @@ export function TopBar() {
return () => document.removeEventListener('keydown', handleGlobalKeyDown); return () => document.removeEventListener('keydown', handleGlobalKeyDown);
}, []); }, []);
const isMac = /Mac|iPhone|iPad|iPod/.test(navigator.userAgent);
const navigableLayouts = schema const navigableLayouts = schema
? visibleLayouts(schema, edition, (prefix) => hasObjectPermission(prefix, 'Get'), hasPermission) ? visibleLayouts(schema, edition, (prefix) => hasObjectPermission(prefix, 'Get'), hasPermission)
: []; : [];
@@ -87,7 +92,7 @@ export function TopBar() {
</Link> </Link>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="bottom"> <TooltipContent side="bottom">
{t('version.label', 'Stalwart WebUI v{{version}}', { version: __APP_VERSION__ })} {t('version.label', 'Stalwart WebUI Fork v{{version}}', { version: __APP_VERSION__ })}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
@@ -99,9 +104,11 @@ export function TopBar() {
className="flex h-9 w-full max-w-md items-center gap-2 rounded-md border border-input bg-transparent px-3 text-sm text-muted-foreground shadow-sm transition-colors hover:bg-accent" className="flex h-9 w-full max-w-md items-center gap-2 rounded-md border border-input bg-transparent px-3 text-sm text-muted-foreground shadow-sm transition-colors hover:bg-accent"
> >
<Search className="h-4 w-4" /> <Search className="h-4 w-4" />
<span className="flex-1 text-left">{t('globalSearch.placeholder', 'Search pages, fields, settings...')}</span> <span className="flex-1 text-left">
{t('globalSearch.placeholder', 'Search pages, fields, settings...')}
</span>
<kbd className="pointer-events-none flex h-5 select-none items-center rounded border bg-muted px-1.5 font-mono text-[10px] font-medium"> <kbd className="pointer-events-none flex h-5 select-none items-center rounded border bg-muted px-1.5 font-mono text-[10px] font-medium">
{IS_MAC ? '⌘K' : 'Ctrl K'} {isMac ? '⌘K' : 'Ctrl K'}
</kbd> </kbd>
</button> </button>
</div> </div>
@@ -121,14 +128,16 @@ export function TopBar() {
<CommandPalette open={paletteOpen} onOpenChange={setPaletteOpen} /> <CommandPalette open={paletteOpen} onOpenChange={setPaletteOpen} />
<Button variant="ghost" size="icon" onClick={toggleTheme} aria-label={t('toggleTheme', 'Toggle theme')}> <ModeToggle />
{theme === 'light' ? <Moon className="h-4 w-4" /> : <Sun className="h-4 w-4" />}
</Button>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" aria-label={t('userMenu', 'User menu')}> <Button variant="ghost" className="h-auto gap-2 px-2" aria-label={t('userMenu', 'User menu')}>
<User className="h-4 w-4" /> <User className="h-4 w-4" />
<div className="hidden flex-col items-start text-left md:flex">
<span className="text-sm font-medium leading-none">{displayName}</span>
<span className="text-xs text-muted-foreground leading-none">{displayEmail}</span>
</div>
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56"> <DropdownMenuContent align="end" className="w-56">
@@ -144,7 +153,9 @@ export function TopBar() {
onClick={() => { onClick={() => {
setActiveSection(layout.name); setActiveSection(layout.name);
const canGet = (prefix: string) => hasObjectPermission(prefix, 'Get'); const canGet = (prefix: string) => hasObjectPermission(prefix, 'Get');
const lastLink = findLastVisitedLinkInLayout(schema, layout, edition, canGet, hasPermission);
const firstLink = const firstLink =
lastLink ??
findFirstAccessibleLinkInLayout(schema, layout, edition, canGet, hasPermission) ?? findFirstAccessibleLinkInLayout(schema, layout, edition, canGet, hasPermission) ??
findFirstVisibleLinkInLayout(schema, layout, edition, canGet, hasPermission); findFirstVisibleLinkInLayout(schema, layout, edition, canGet, hasPermission);
if (firstLink) { if (firstLink) {
@@ -187,7 +198,18 @@ export function TopBar() {
</> </>
)} )}
<DropdownMenuItem onClick={() => navigate('/Appearance')}>
<Palette className="mr-2 h-4 w-4" />
{t('appearance.label', 'Appearance')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => navigate('/Changelog')}>
<ScrollText className="mr-2 h-4 w-4" />
{t('changelog.label', 'Changelog')}
</DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
variant="destructive"
onClick={() => { onClick={() => {
const endSessionEndpoint = useAuthStore.getState().endSessionEndpoint; const endSessionEndpoint = useAuthStore.getState().endSessionEndpoint;
logout(); logout();
+577 -60
View File
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/ */
import React, { useState, useEffect, useCallback, useMemo } from 'react'; import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
@@ -20,14 +20,18 @@ import {
Lock, Lock,
Search, Search,
RotateCcw, RotateCcw,
RefreshCw,
CornerDownRight,
} from 'lucide-react'; } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select'; import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select';
import { Combobox } from '@/components/ui/combobox';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { formatSize as fmtSize, formatDuration as fmtDuration } from '@/lib/durationFormat'; import { formatSize as fmtSize, formatDuration as fmtDuration } from '@/lib/durationFormat';
import { SizeDisplay } from '@/components/common/SizeDisplay';
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuTrigger, DropdownMenuTrigger,
@@ -46,6 +50,7 @@ import {
AlertDialogTitle, AlertDialogTitle,
} from '@/components/ui/alert-dialog'; } from '@/components/ui/alert-dialog';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible'; import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { ObjectPicker } from '@/components/common/ObjectPicker'; import { ObjectPicker } from '@/components/common/ObjectPicker';
import { EnterpriseUpsell } from '@/components/common/EnterpriseUpsell'; import { EnterpriseUpsell } from '@/components/common/EnterpriseUpsell';
import { toast } from '@/hooks/use-toast'; import { toast } from '@/hooks/use-toast';
@@ -59,14 +64,32 @@ import { useAuthStore } from '@/stores/authStore';
import { useAccountStore } from '@/stores/accountStore'; import { useAccountStore } from '@/stores/accountStore';
import { useCacheStore } from '@/stores/cacheStore'; import { useCacheStore } from '@/stores/cacheStore';
import { resolveObject, resolveSchema, resolveList, getDisplayProperty } from '@/lib/schemaResolver'; import { resolveObject, resolveSchema, resolveList, getDisplayProperty } from '@/lib/schemaResolver';
import { jmapGetBatched, jmapQueryAll, jmapQueryAndGet, jmapSet, getAccountId } from '@/services/jmap/client'; import {
jmapGetBatched,
jmapQueryAll,
jmapQueryAndGet,
jmapQueryAllAndGet,
jmapSet,
getAccountId,
} from '@/services/jmap/client';
import type { Schema, Field, MassAction, ItemAction, Filter as FilterDef } from '@/types/schema'; import type { Schema, Field, MassAction, ItemAction, Filter as FilterDef } from '@/types/schema';
import type { JmapSetResponse, JmapSetError } from '@/types/jmap'; import type { JmapSetResponse, JmapSetError } from '@/types/jmap';
import type { ResolvedSchema } from '@/lib/schemaResolver'; import type { ResolvedSchema } from '@/lib/schemaResolver';
import { isClientOnlyFilterEnum, isClientSortableColumn } from '@/lib/schemaDeviationTypes';
const PAGE_SIZE = 25; const PAGE_SIZE = 25;
const MAX_REPORTED_ERRORS = 3; const MAX_REPORTED_ERRORS = 3;
// Combobox threshold: plain <Select> is fine for a handful of options, but
// unusable (no search) once an enum has dozens of entries.
const ENUM_COMBOBOX_THRESHOLD = 15;
// Manual refresh on the Logs list is rate-limited to avoid hammering the
// server if someone leaves it clicked repeatedly.
const REFRESH_COOLDOWN_MS = 5000;
function isClientOnlyFilter(f: FilterDef): boolean {
return f.type === 'enum' && isClientOnlyFilterEnum(f);
}
function parseSetResponse(raw: [string, Record<string, unknown>, string][]): JmapSetResponse | null { function parseSetResponse(raw: [string, Record<string, unknown>, string][]): JmapSetResponse | null {
const entry = raw.find(([name]) => name.endsWith('/set')); const entry = raw.find(([name]) => name.endsWith('/set'));
@@ -165,6 +188,138 @@ function formatNumber(value: unknown): string {
return value.toLocaleString(); return value.toLocaleString();
} }
// SCHEMA-DEVIATION: mailbox-client-hierarchy-sort (see SCHEMA_DEVIATIONS.md)
//
// Orders mailboxes so each parent is immediately followed by its
// descendants (siblings alphabetical), and records each row's depth.
// Requires the full set (not just one page) since a mailbox's parent
// could be on a different page than the mailbox itself.
function sortMailboxesByHierarchy(items: Record<string, unknown>[]): {
items: Record<string, unknown>[];
depths: Map<string, number>;
} {
const byId = new Set(items.map((item) => item.id as string));
const childrenOf = new Map<string, Record<string, unknown>[]>();
const roots: Record<string, unknown>[] = [];
for (const item of items) {
const parentId = item.parentId as string | null | undefined;
if (parentId && byId.has(parentId)) {
const siblings = childrenOf.get(parentId) ?? [];
siblings.push(item);
childrenOf.set(parentId, siblings);
} else {
roots.push(item);
}
}
const byName = (a: Record<string, unknown>, b: Record<string, unknown>) =>
String(a.name ?? '').localeCompare(String(b.name ?? ''));
roots.sort(byName);
for (const siblings of childrenOf.values()) siblings.sort(byName);
const ordered: Record<string, unknown>[] = [];
const depths = new Map<string, number>();
function visit(item: Record<string, unknown>, depth: number) {
ordered.push(item);
depths.set(item.id as string, depth);
for (const child of childrenOf.get(item.id as string) ?? []) {
visit(child, depth + 1);
}
}
for (const root of roots) visit(root, 0);
return { items: ordered, depths };
}
function formatUserRole(item: Record<string, unknown>, schema: Schema): React.ReactNode {
// The x:Account list merges field definitions across its User/Group
// variants (see getFieldsRecord), and Group's `roles` property points to
// a different object (`x:Roles`) that overrides User's (`x:UserRoles`)
// in that merge. This list only ever shows Users, so resolve the label
// directly against x:UserRoles instead of the ambiguous merged field.
const roles = item.roles as Record<string, unknown> | undefined;
const type = roles && typeof roles['@type'] === 'string' ? roles['@type'] : undefined;
if (!type) return <span className="text-muted-foreground">-</span>;
const variantSchema = schema.schemas['x:UserRoles'];
if (variantSchema?.type === 'multiple') {
const variant = variantSchema.variants.find((v) => v.name === type);
if (variant) return <Badge variant="secondary">{variant.label}</Badge>;
}
return type;
}
function renderQuotaUsage(item: Record<string, unknown>, t: TFn): React.ReactNode {
const rawUsed = typeof item.usedDiskQuota === 'number' ? item.usedDiskQuota : 0;
const used = Number.isFinite(rawUsed) ? rawUsed : 0;
const quotas = item.quotas as Record<string, unknown> | undefined;
const rawLimit = quotas && typeof quotas.maxDiskQuota === 'number' ? quotas.maxDiskQuota : 0;
const limit = Number.isFinite(rawLimit) && rawLimit > 0 ? rawLimit : 0;
const limitLabel = limit ? formatSize(limit) : t('list.unlimitedQuota', '∞');
if (used >= 0) {
return `${formatSize(used)} / ${limitLabel}`;
}
return (
<span className="inline-flex items-center gap-1.5">
<SizeDisplay bytes={used} />
<span className="text-muted-foreground">/</span>
<span>{limitLabel}</span>
</span>
);
}
/**
* SCHEMA-DEVIATION: account-alias-count-column, role-permission-count-columns
* (see SCHEMA_DEVIATIONS.md)
*
* Synthetic "count" columns, keyed by column name, each naming the real
* set/objectList property whose entry count they render. Generic and
* table-level: whichever list's schema patch tags a column with one of
* these names (see withAccountListColumns, withMailingListColumns,
* withRoleListColumns) gets it resolved and rendered automatically, with
* no per-list wiring in this component.
*/
const COUNT_COLUMN_SOURCES: Record<string, string> = {
aliasCount: 'aliases',
enabledPermissionCount: 'enabledPermissions',
disabledPermissionCount: 'disabledPermissions',
};
function getCountColumnValue(colName: string, item: Record<string, unknown>): number {
const source = COUNT_COLUMN_SOURCES[colName];
return Object.keys((item[source] as Record<string, unknown>) ?? {}).length;
}
/**
* SCHEMA-DEVIATION: account-client-sort (see SCHEMA_DEVIATIONS.md)
*
* Generic, table-level client-sort mechanism: any column tagged
* `clientSortable` in the schema (see ClientSortableColumn /
* withAccountListColumns) gets fetch-all-then-sort-in-memory behavior on
* click, for whichever list declares it — this isn't specific to Accounts
* or Groups, and needs no per-list wiring in this component.
*
* Real columns compare their own property directly; synthetic deviation
* columns (quotaUsage, and any COUNT_COLUMN_SOURCES entry) aren't real
* properties, so they need an override to compute a comparable value from
* what's actually on the item.
*/
const CLIENT_SORT_VALUE_OVERRIDES: Record<string, (item: Record<string, unknown>) => string | number> = {
quotaUsage: (item) => (typeof item.usedDiskQuota === 'number' ? item.usedDiskQuota : 0),
};
function getClientSortValue(colName: string, item: Record<string, unknown>): string | number {
if (colName in COUNT_COLUMN_SOURCES) return getCountColumnValue(colName, item);
const override = CLIENT_SORT_VALUE_OVERRIDES[colName];
if (override) return override(item);
const raw = item[colName];
return typeof raw === 'number' ? raw : String(raw ?? '');
}
function getFieldsRecord(resolvedSchema: ResolvedSchema): Record<string, Field> { function getFieldsRecord(resolvedSchema: ResolvedSchema): Record<string, Field> {
if (resolvedSchema.type === 'single') { if (resolvedSchema.type === 'single') {
return resolvedSchema.fields.properties; return resolvedSchema.fields.properties;
@@ -287,7 +442,14 @@ function renderCellValue(
if (value && typeof value === 'object' && !Array.isArray(value)) { if (value && typeof value === 'object' && !Array.isArray(value)) {
const obj = value as Record<string, unknown>; const obj = value as Record<string, unknown>;
if ('@type' in obj && typeof obj['@type'] === 'string') { if ('@type' in obj && typeof obj['@type'] === 'string') {
return obj['@type']; const variantSchema = schema.schemas[ft.objectName];
if (variantSchema?.type === 'multiple') {
const variant = variantSchema.variants.find((v) => v.name === obj['@type']);
if (variant) {
return <Badge variant="secondary">{variant.label}</Badge>;
}
}
return String(obj['@type']);
} }
} }
return <span className="text-muted-foreground">-</span>; return <span className="text-muted-foreground">-</span>;
@@ -329,6 +491,26 @@ interface ConfirmAction {
onConfirm: () => void; onConfirm: () => void;
} }
function isActiveWebApplication(item: Record<string, unknown>): boolean {
const prefixes = item.urlPrefix;
const values: string[] = [];
if (Array.isArray(prefixes)) {
values.push(...prefixes.map(String));
} else if (typeof prefixes === 'string') {
values.push(...prefixes.split(',').map((s) => s.trim()));
} else if (prefixes && typeof prefixes === 'object') {
// JMAP exposes urlPrefix as a set object such as { "/admin": true, "/account": true }
for (const [key, value] of Object.entries(prefixes)) {
if (value === true) {
values.push(key);
}
}
} else if (prefixes != null) {
values.push(String(prefixes));
}
return values.some((p) => p === '/admin' || p === '/account');
}
interface DynamicListProps { interface DynamicListProps {
viewName: string; viewName: string;
} }
@@ -342,6 +524,12 @@ export function DynamicList({ viewName }: DynamicListProps) {
const viewToSection = useSchemaStore((s) => s.viewToSection); const viewToSection = useSchemaStore((s) => s.viewToSection);
const hasObjectPermission = useAccountStore((s) => s.hasObjectPermission); const hasObjectPermission = useAccountStore((s) => s.hasObjectPermission);
const edition = useAccountStore((s) => s.edition); const edition = useAccountStore((s) => s.edition);
// Reactive, unlike the getAccountId() snapshot read inside fetchData: needed
// so switching accounts from the profile dropdown (a pure store update with
// no navigation) re-triggers the fetch effect below for account-scoped
// views (Mailboxes, Calendars, Sieve Scripts, ...) even when viewName
// itself doesn't change.
const activeAccountId = useAuthStore((s) => s.activeAccountId);
const [upsellOpen, setUpsellOpen] = useState(false); const [upsellOpen, setUpsellOpen] = useState(false);
const resolved = useMemo(() => { const resolved = useMemo(() => {
@@ -354,6 +542,48 @@ export function DynamicList({ viewName }: DynamicListProps) {
return { obj, schema: schem, list }; return { obj, schema: schem, list };
}, [schema, viewName]); }, [schema, viewName]);
const objectName = resolved?.obj.objectName;
const isWebApplications = viewName === 'x:Application' || objectName === 'x:Application';
const isLogEntries = viewName === 'x:Log' || objectName === 'x:Log';
const isAccountsList = viewName === 'x:Account/User';
const isMailboxList = viewName === 'Mailbox';
// Not tied to a specific viewName: any list whose schema-driven columns
// (see withAccountListColumns, account-quota-usage-column deviation)
// include the synthetic `quotaUsage` column gets it resolved and rendered.
const hasQuotaUsageColumn = (resolved?.list?.columns ?? []).some((c) => c.name === 'quotaUsage');
// SCHEMA-DEVIATION: account-alias-count-column, role-permission-count-columns
// (see SCHEMA_DEVIATIONS.md) — which COUNT_COLUMN_SOURCES entries this
// particular list's columns actually declare.
const activeCountColumns = useMemo(
() => (resolved?.list?.columns ?? []).filter((c) => c.name in COUNT_COLUMN_SOURCES).map((c) => c.name),
[resolved?.list?.columns],
);
const displayColumns = useMemo(() => {
const columns = resolved?.list?.columns ?? [];
if (!isWebApplications) return columns;
// For Web Applications, present Description first and Enabled second
// to match the layout of other tables such as Domains. Reordering real
// schema columns is fine, but the synthetic fallback below (when the
// schema doesn't list an Enabled column at all) is a tracked deviation.
// SCHEMA-DEVIATION: webapp-enabled-column-fallback (see SCHEMA_DEVIATIONS.md)
const ordered = ['description', 'enabled'];
const rest = columns.filter((c) => !ordered.includes(c.name));
const descriptionCol = columns.find((c) => c.name === 'description');
const enabledCol = columns.find((c) => c.name === 'enabled');
const result: Array<{ name: string; label: string }> = [];
if (descriptionCol) result.push(descriptionCol);
if (enabledCol) {
result.push(enabledCol);
} else {
result.push({ name: 'enabled', label: t('webApplications.enabled', 'Enabled') });
}
result.push(...rest);
return result;
}, [resolved?.list?.columns, isWebApplications]);
const [items, setItems] = useState<Record<string, unknown>[]>([]); const [items, setItems] = useState<Record<string, unknown>[]>([]);
const [total, setTotal] = useState<number | null>(null); const [total, setTotal] = useState<number | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -376,8 +606,74 @@ export function DynamicList({ viewName }: DynamicListProps) {
const [appliedFilters, setAppliedFilters] = useState<Record<string, string>>(readUrlFilters); const [appliedFilters, setAppliedFilters] = useState<Record<string, string>>(readUrlFilters);
const [sort, setSort] = useState<SortState | null>(readUrlSort); const [sort, setSort] = useState<SortState | null>(readUrlSort);
// SCHEMA-DEVIATION: account-client-sort (see SCHEMA_DEVIATIONS.md)
// Any column the schema tags `clientSortable` (currently Email/Full Name/
// Usage/Aliases on Accounts and Groups — see withAccountListColumns) is
// sorted client-side instead of via a JMAP `sort`, only when the user
// actually picks one of them. Not list-specific: any list whose columns
// carry the flag gets this for free.
const clientSortableColumns = useMemo(
() => new Set((resolved?.list?.columns ?? []).filter(isClientSortableColumn).map((c) => c.name)),
[resolved?.list?.columns],
);
const clientSortField = sort && clientSortableColumns.has(sort.field) ? sort.field : null;
// Filters marked `clientOnly` (currently Level/Event on the Logs list) are
// not supported by the server's query engine, so they narrow an
// already-fetched result set in the browser instead of being sent as a
// JMAP filter. That switches pagination to a client-held array.
const clientFilterDefs = useMemo(
() => (resolved?.list?.filters ?? []).filter(isClientOnlyFilter),
[resolved?.list?.filters],
);
const activeClientFilters = useMemo(
() =>
clientFilterDefs
.map((f) => ({ field: f.field, value: appliedFilters[f.field] ?? '' }))
.filter((f) => f.value !== ''),
[clientFilterDefs, appliedFilters],
);
const [clientAllItems, setClientAllItems] = useState<Record<string, unknown>[] | null>(null);
const [clientPage, setClientPage] = useState(0);
const [mailboxDepths, setMailboxDepths] = useState<Map<string, number>>(new Map());
const [refreshOnCooldown, setRefreshOnCooldown] = useState(false);
const refreshCooldownTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (refreshCooldownTimer.current) clearTimeout(refreshCooldownTimer.current);
};
}, []);
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null); const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
const [activeWebApp, setActiveWebApp] = useState<Record<string, unknown> | null>(null);
useEffect(() => {
if (!isWebApplications || !schema) return;
let cancelled = false;
void (async () => {
try {
const accountId = getAccountId(objectName ?? 'x:Application');
const result = await jmapQueryAllAndGet(
objectName ?? 'x:Application',
accountId,
{},
['id', 'description', 'enabled', 'urlPrefix', 'resourceUrl'],
);
if (cancelled) return;
const active = result.list.find((item) => isActiveWebApplication(item));
setActiveWebApp(active ?? null);
} catch (err) {
console.error('Failed to fetch active web application:', err);
}
})();
return () => {
cancelled = true;
};
}, [isWebApplications, schema]);
useResetOnChange(viewName, () => { useResetOnChange(viewName, () => {
setItems([]); setItems([]);
@@ -387,6 +683,11 @@ export function DynamicList({ viewName }: DynamicListProps) {
setSelectedIds(new Set()); setSelectedIds(new Set());
setSelectAllMode(false); setSelectAllMode(false);
setError(null); setError(null);
setClientAllItems(null);
setClientPage(0);
setMailboxDepths(new Map());
if (refreshCooldownTimer.current) clearTimeout(refreshCooldownTimer.current);
setRefreshOnCooldown(false);
const initialFilters = readUrlFilters(); const initialFilters = readUrlFilters();
setFilterValues(initialFilters); setFilterValues(initialFilters);
@@ -395,15 +696,21 @@ export function DynamicList({ viewName }: DynamicListProps) {
setSort(readUrlSort()); setSort(readUrlSort());
}); });
const objectName = resolved?.obj.objectName;
const buildFilter = useCallback((): Record<string, unknown> => { const buildFilter = useCallback((): Record<string, unknown> => {
const clientFields = new Set(clientFilterDefs.map((f) => f.field));
const serverAppliedFilters: Record<string, string> = {};
for (const [key, val] of Object.entries(appliedFilters)) {
const baseKey = key.endsWith('Op') ? key.slice(0, -2) : key;
if (clientFields.has(baseKey)) continue;
serverAppliedFilters[key] = val;
}
return buildJmapFilter({ return buildJmapFilter({
appliedFilters, appliedFilters: serverAppliedFilters,
filters: resolved?.list?.filters, filters: resolved?.list?.filters,
filtersStatic: resolved?.list?.filtersStatic, filtersStatic: resolved?.list?.filtersStatic,
isXPrefixed: objectName?.startsWith('x:') ?? false, isXPrefixed: objectName?.startsWith('x:') ?? false,
}); });
}, [appliedFilters, resolved?.list, objectName]); }, [appliedFilters, resolved?.list, objectName, clientFilterDefs]);
const buildSort = useCallback((): Record<string, unknown>[] | undefined => { const buildSort = useCallback((): Record<string, unknown>[] | undefined => {
if (!sort) return undefined; if (!sort) return undefined;
@@ -421,9 +728,71 @@ export function DynamicList({ viewName }: DynamicListProps) {
try { try {
const accountId = getAccountId(obj.objectName); const accountId = getAccountId(obj.objectName);
const properties = ['id', ...list.columns.map((c) => c.name)]; const properties = ['id', ...list.columns.map((c) => c.name)];
if (isWebApplications && !properties.includes('enabled')) {
properties.push('enabled');
}
if (hasQuotaUsageColumn) {
const quotaIdx = properties.indexOf('quotaUsage');
if (quotaIdx !== -1) {
properties.splice(quotaIdx, 1, 'usedDiskQuota', 'quotas');
}
}
for (const countCol of activeCountColumns) {
const idx = properties.indexOf(countCol);
if (idx !== -1) {
properties.splice(idx, 1, COUNT_COLUMN_SOURCES[countCol]);
}
}
if (isMailboxList && !properties.includes('parentId')) {
properties.push('parentId');
}
const filter = buildFilter(); const filter = buildFilter();
const sortArr = buildSort(); const sortArr = buildSort();
if (activeClientFilters.length > 0 || isMailboxList || clientSortField) {
// No server-side pagination possible once a client-only filter is
// active (SCHEMA-DEVIATION: log-client-filters): fetch every
// server-matching row up front, narrow it in the browser, then
// paginate the in-memory result locally. Mailbox hierarchy needs
// this too (SCHEMA-DEVIATION: mailbox-client-hierarchy-sort) — a
// mailbox's parent can land on a different server page than the
// mailbox itself, so the full set is required to place each row
// under its parent correctly. Sorting by Email/Full Name/Usage/
// Aliases on Accounts/Groups needs it too (SCHEMA-DEVIATION:
// account-client-sort) since the server doesn't support sorting
// on any property of either list.
const { list: fullList } = await jmapQueryAllAndGet(
obj.objectName,
accountId,
{ filter: Object.keys(filter).length > 0 ? filter : undefined, sort: clientSortField ? undefined : sortArr },
properties,
);
let matched = fullList.filter((item) =>
activeClientFilters.every((f) => String(item[f.field] ?? '') === f.value),
);
if (isMailboxList) {
const { items: ordered, depths } = sortMailboxesByHierarchy(matched);
matched = ordered;
setMailboxDepths(depths);
}
if (clientSortField) {
const direction = sort!.ascending ? 1 : -1;
matched = [...matched].sort((a, b) => {
const av = getClientSortValue(clientSortField, a);
const bv = getClientSortValue(clientSortField, b);
const cmp = typeof av === 'number' && typeof bv === 'number' ? av - bv : String(av).localeCompare(String(bv));
return cmp * direction;
});
}
setClientAllItems(matched);
setClientPage(0);
setTotal(matched.length);
setItems(matched.slice(0, PAGE_SIZE));
setSelectedIds(new Set());
return;
}
setClientAllItems(null);
const queryOptions: Record<string, unknown> = { const queryOptions: Record<string, unknown> = {
filter: Object.keys(filter).length > 0 ? filter : undefined, filter: Object.keys(filter).length > 0 ? filter : undefined,
sort: sortArr, sort: sortArr,
@@ -470,7 +839,20 @@ export function DynamicList({ viewName }: DynamicListProps) {
setLoading(false); setLoading(false);
} }
}, },
[resolved, schema, buildFilter, buildSort, t], [
resolved,
schema,
buildFilter,
buildSort,
isWebApplications,
isAccountsList,
hasQuotaUsageColumn,
activeCountColumns,
isMailboxList,
clientSortField,
sort,
activeClientFilters,
],
); );
useEffect(() => { useEffect(() => {
@@ -480,7 +862,7 @@ export function DynamicList({ viewName }: DynamicListProps) {
setCurrentAnchor(null); setCurrentAnchor(null);
fetchData(null); fetchData(null);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [viewName, sort, resolved?.list, appliedFilters]); }, [viewName, sort, resolved?.list, appliedFilters, activeAccountId]);
useEffect(() => { useEffect(() => {
if (!schema || !resolved?.list || items.length === 0) return; if (!schema || !resolved?.list || items.length === 0) return;
@@ -545,6 +927,13 @@ export function DynamicList({ viewName }: DynamicListProps) {
setAppliedFilters({}); setAppliedFilters({});
}, []); }, []);
const handleRefresh = useCallback(() => {
if (refreshOnCooldown) return;
setRefreshOnCooldown(true);
fetchData(currentAnchor, 0);
refreshCooldownTimer.current = setTimeout(() => setRefreshOnCooldown(false), REFRESH_COOLDOWN_MS);
}, [refreshOnCooldown, fetchData, currentAnchor]);
useEffect(() => { useEffect(() => {
const params = new URLSearchParams(); const params = new URLSearchParams();
for (const [key, val] of Object.entries(appliedFilters)) { for (const [key, val] of Object.entries(appliedFilters)) {
@@ -561,6 +950,13 @@ export function DynamicList({ viewName }: DynamicListProps) {
}, [filterValues, sort]); }, [filterValues, sort]);
const handleNextPage = useCallback(() => { const handleNextPage = useCallback(() => {
if (clientAllItems !== null) {
const nextPage = clientPage + 1;
setClientPage(nextPage);
setItems(clientAllItems.slice(nextPage * PAGE_SIZE, nextPage * PAGE_SIZE + PAGE_SIZE));
return;
}
if (items.length === 0) return; if (items.length === 0) return;
const lastItem = items[items.length - 1]; const lastItem = items[items.length - 1];
const lastId = lastItem?.id as string; const lastId = lastItem?.id as string;
@@ -572,9 +968,16 @@ export function DynamicList({ viewName }: DynamicListProps) {
} }
setCurrentAnchor(lastId); setCurrentAnchor(lastId);
fetchData(lastId, 1); fetchData(lastId, 1);
}, [items, fetchData]); }, [items, fetchData, clientAllItems, clientPage]);
const handlePrevPage = useCallback(() => { const handlePrevPage = useCallback(() => {
if (clientAllItems !== null) {
const prevPage = Math.max(0, clientPage - 1);
setClientPage(prevPage);
setItems(clientAllItems.slice(prevPage * PAGE_SIZE, prevPage * PAGE_SIZE + PAGE_SIZE));
return;
}
if (anchorStack.length === 0) { if (anchorStack.length === 0) {
return; return;
} }
@@ -590,7 +993,7 @@ export function DynamicList({ viewName }: DynamicListProps) {
setCurrentAnchor(prevFirstId); setCurrentAnchor(prevFirstId);
fetchData(prevFirstId, 0); fetchData(prevFirstId, 0);
} }
}, [anchorStack, fetchData]); }, [anchorStack, fetchData, clientAllItems, clientPage]);
const toggleSelectAll = useCallback(() => { const toggleSelectAll = useCallback(() => {
if (selectedIds.size === items.length) { if (selectedIds.size === items.length) {
@@ -615,12 +1018,16 @@ export function DynamicList({ viewName }: DynamicListProps) {
}); });
}, []); }, []);
// Cycle: unsorted → ascending → descending → unsorted (default).
const toggleSort = useCallback((field: string) => { const toggleSort = useCallback((field: string) => {
setSort((prev) => { setSort((prev) => {
if (prev?.field === field) { if (prev?.field !== field) {
return { field, ascending: !prev.ascending }; return { field, ascending: true };
} }
return { field, ascending: true }; if (prev.ascending) {
return { field, ascending: false };
}
return null;
}); });
}, []); }, []);
@@ -654,12 +1061,18 @@ export function DynamicList({ viewName }: DynamicListProps) {
let targetIds: string[]; let targetIds: string[];
if (selectAllMode) { if (selectAllMode) {
const filter = buildFilter(); if (clientAllItems !== null) {
const sortArr = buildSort(); // A client-only filter is active: the full server-matching set
targetIds = await jmapQueryAll(obj.objectName, accountId, { // would include rows it excludes, so use the already-narrowed list.
filter: Object.keys(filter).length > 0 ? filter : undefined, targetIds = clientAllItems.map((item) => item.id as string);
sort: sortArr, } else {
}); const filter = buildFilter();
const sortArr = buildSort();
targetIds = await jmapQueryAll(obj.objectName, accountId, {
filter: Object.keys(filter).length > 0 ? filter : undefined,
sort: sortArr,
});
}
} else { } else {
targetIds = Array.from(selectedIds); targetIds = Array.from(selectedIds);
} }
@@ -714,7 +1127,7 @@ export function DynamicList({ viewName }: DynamicListProps) {
setLoading(false); setLoading(false);
} }
}, },
[resolved, selectedIds, selectAllMode, buildFilter, buildSort, fetchData, currentAnchor, t], [resolved, selectedIds, selectAllMode, buildFilter, buildSort, fetchData, currentAnchor, t, clientAllItems],
); );
const executeItemAction = useCallback( const executeItemAction = useCallback(
@@ -840,11 +1253,11 @@ export function DynamicList({ viewName }: DynamicListProps) {
const hasMassActions = effectiveMassActions.length > 0; const hasMassActions = effectiveMassActions.length > 0;
const hasItemActions = (list.itemActions?.length ?? 0) > 0; const hasItemActions = (list.itemActions?.length ?? 0) > 0;
const pageStart = anchorStack.length * PAGE_SIZE; const pageStart = clientAllItems !== null ? clientPage * PAGE_SIZE : anchorStack.length * PAGE_SIZE;
const rangeStart = pageStart + 1; const rangeStart = pageStart + 1;
const rangeEnd = pageStart + items.length; const rangeEnd = pageStart + items.length;
const hasNextPage = total !== null && rangeEnd < total; const hasNextPage = clientAllItems !== null ? rangeEnd < clientAllItems.length : total !== null && rangeEnd < total;
const hasPrevPage = anchorStack.length > 0; const hasPrevPage = clientAllItems !== null ? clientPage > 0 : anchorStack.length > 0;
function renderFilter(filterDef: FilterDef): React.ReactNode { function renderFilter(filterDef: FilterDef): React.ReactNode {
const value = filterValues[filterDef.field] ?? ''; const value = filterValues[filterDef.field] ?? '';
@@ -869,6 +1282,22 @@ export function DynamicList({ viewName }: DynamicListProps) {
case 'enum': { case 'enum': {
const enumVariants = schema!.enums[filterDef.enumName] ?? []; const enumVariants = schema!.enums[filterDef.enumName] ?? [];
if (enumVariants.length > ENUM_COMBOBOX_THRESHOLD) {
return wrapper(
<Combobox
options={enumVariants.map((v) => ({ value: v.name, label: v.label }))}
value={value}
onValueChange={(v) => handleFilterChange(filterDef.field, v)}
placeholder={filterDef.label}
searchPlaceholder={t('list.comboboxSearchPlaceholder', 'Search...')}
emptyText={t('list.comboboxEmptyText', 'No matches.')}
nullable
nullLabel={t('filters.all', 'All')}
/>,
);
}
return wrapper( return wrapper(
<Select value={value || '__all__'} onValueChange={(v) => handleFilterSelectChange(filterDef.field, v)}> <Select value={value || '__all__'} onValueChange={(v) => handleFilterSelectChange(filterDef.field, v)}>
<SelectTrigger> <SelectTrigger>
@@ -996,7 +1425,7 @@ export function DynamicList({ viewName }: DynamicListProps) {
} }
function renderSortIndicator(colName: string): React.ReactNode { function renderSortIndicator(colName: string): React.ReactNode {
if (!sortableFields.has(colName)) return null; if (!sortableFields.has(colName) && !clientSortableColumns.has(colName)) return null;
const isActive = sort?.field === colName; const isActive = sort?.field === colName;
return ( return (
<button <button
@@ -1086,13 +1515,13 @@ export function DynamicList({ viewName }: DynamicListProps) {
} }
return ( return (
<div className="relative space-y-4"> <div className="relative min-w-0 space-y-4">
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-3">
<div> <div className="min-w-0 flex-1">
<h1 className="text-2xl font-bold tracking-tight">{list.title}</h1> <h1 className="text-2xl font-bold tracking-tight truncate">{list.title}</h1>
{list.subtitle && <p className="text-sm text-muted-foreground mt-1">{list.subtitle}</p>} {list.subtitle && <p className="text-sm text-muted-foreground mt-1 line-clamp-2">{list.subtitle}</p>}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
{hasMassActions && selectedIds.size > 0 && ( {hasMassActions && selectedIds.size > 0 && (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
@@ -1153,13 +1582,33 @@ export function DynamicList({ viewName }: DynamicListProps) {
{list.filters && list.filters.length > 0 && ( {list.filters && list.filters.length > 0 && (
<Collapsible open={filtersOpen} onOpenChange={setFiltersOpen}> <Collapsible open={filtersOpen} onOpenChange={setFiltersOpen}>
<CollapsibleTrigger asChild> <div className="flex items-center justify-between">
<Button variant="ghost" size="sm" className="gap-2"> <CollapsibleTrigger asChild>
<Filter className="h-4 w-4" /> <Button variant="ghost" size="sm" className="gap-2">
{t('list.filters', 'Filters')} <Filter className="h-4 w-4" />
{filtersOpen ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />} {t('list.filters', 'Filters')}
</Button> {filtersOpen ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</CollapsibleTrigger> </Button>
</CollapsibleTrigger>
{isLogEntries && (
<Button
type="button"
variant="outline"
size="sm"
className="gap-2"
onClick={handleRefresh}
disabled={refreshOnCooldown || loading}
title={
refreshOnCooldown
? t('list.refreshCooldown', 'Please wait a few seconds before refreshing again')
: undefined
}
>
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
{t('list.refresh', 'Refresh')}
</Button>
)}
</div>
<CollapsibleContent> <CollapsibleContent>
<div className="mt-2 rounded-lg border bg-background shadow-sm"> <div className="mt-2 rounded-lg border bg-background shadow-sm">
<div className="grid gap-4 p-5 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-4 p-5 sm:grid-cols-2 lg:grid-cols-3">
@@ -1214,13 +1663,50 @@ export function DynamicList({ viewName }: DynamicListProps) {
</div> </div>
)} )}
<div className="rounded-lg border bg-background shadow-sm"> {isWebApplications && activeWebApp && (
<div className="overflow-x-auto rounded-[calc(var(--radius-lg)-1px)]"> <Card>
<table className="w-full text-sm"> <CardHeader className="pb-3">
<CardTitle className="text-base">
{t('webApplications.activeWebUI', 'Active WebUI')}
</CardTitle>
</CardHeader>
<CardContent className="space-y-2 pt-0">
<p className="text-sm text-muted-foreground">
{String(activeWebApp.description ?? '-')}
</p>
<div className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">{t('webApplications.version', 'Version')}:</span>
<Badge variant="secondary">{__APP_VERSION__}</Badge>
</div>
{typeof activeWebApp.resourceUrl === 'string' && activeWebApp.resourceUrl && (
<div className="flex items-start gap-2 text-sm">
<span className="shrink-0 text-muted-foreground">{t('webApplications.resourceUrl', 'Source')}:</span>
<a
href={activeWebApp.resourceUrl}
target="_blank"
rel="noreferrer"
className="break-all text-primary hover:underline"
>
{activeWebApp.resourceUrl}
</a>
</div>
)}
</CardContent>
</Card>
)}
<div className="min-w-0 rounded-lg border bg-background shadow-sm">
{/* The scroll container must clip with the parent's inner radius
(outer radius minus the 1px border), otherwise filled header rows
paint square corners behind the rounded border.
`w-max min-w-full` keeps the table at least as wide as the card,
but lets wide column sets scroll horizontally inside this wrapper. */}
<div className="overflow-x-auto overscroll-x-contain rounded-[calc(var(--radius-lg)-1px)] [-webkit-overflow-scrolling:touch]">
<table className="w-max min-w-full text-sm">
<thead> <thead>
<tr className="border-b bg-muted"> <tr className="border-b bg-muted">
{hasMassActions && ( {hasMassActions && (
<th className="w-10 px-3 py-3"> <th className="w-10 px-3 py-3 whitespace-nowrap">
<Checkbox <Checkbox
checked={items.length > 0 && selectedIds.size === items.length} checked={items.length > 0 && selectedIds.size === items.length}
onCheckedChange={toggleSelectAll} onCheckedChange={toggleSelectAll}
@@ -1228,8 +1714,11 @@ export function DynamicList({ viewName }: DynamicListProps) {
/> />
</th> </th>
)} )}
{list.columns.map((col) => ( {displayColumns.map((col) => (
<th key={col.name} className="px-3 py-3 text-left font-medium text-muted-foreground"> <th
key={col.name}
className="px-3 py-3 text-left font-medium text-muted-foreground whitespace-nowrap"
>
<div className="flex items-center"> <div className="flex items-center">
{col.label} {col.label}
{renderSortIndicator(col.name)} {renderSortIndicator(col.name)}
@@ -1237,7 +1726,7 @@ export function DynamicList({ viewName }: DynamicListProps) {
</th> </th>
))} ))}
{hasItemActions && ( {hasItemActions && (
<th className="w-12 px-3 py-3 text-right font-medium text-muted-foreground"> <th className="w-12 px-3 py-3 text-right font-medium text-muted-foreground whitespace-nowrap">
{t('list.actions', 'Actions')} {t('list.actions', 'Actions')}
</th> </th>
)} )}
@@ -1247,7 +1736,7 @@ export function DynamicList({ viewName }: DynamicListProps) {
{loading && items.length === 0 ? ( {loading && items.length === 0 ? (
<tr> <tr>
<td <td
colSpan={list.columns.length + (hasMassActions ? 1 : 0) + (hasItemActions ? 1 : 0)} colSpan={displayColumns.length + (hasMassActions ? 1 : 0) + (hasItemActions ? 1 : 0)}
className="px-3 py-12 text-center" className="px-3 py-12 text-center"
> >
<Loader2 className="mx-auto h-6 w-6 animate-spin" /> <Loader2 className="mx-auto h-6 w-6 animate-spin" />
@@ -1256,7 +1745,7 @@ export function DynamicList({ viewName }: DynamicListProps) {
) : items.length === 0 ? ( ) : items.length === 0 ? (
<tr> <tr>
<td <td
colSpan={list.columns.length + (hasMassActions ? 1 : 0) + (hasItemActions ? 1 : 0)} colSpan={displayColumns.length + (hasMassActions ? 1 : 0) + (hasItemActions ? 1 : 0)}
className="px-3 py-12 text-center text-muted-foreground" className="px-3 py-12 text-center text-muted-foreground"
> >
{t('list.noResults', 'No results found')} {t('list.noResults', 'No results found')}
@@ -1272,7 +1761,7 @@ export function DynamicList({ viewName }: DynamicListProps) {
onClick={() => handleRowClick(item)} onClick={() => handleRowClick(item)}
> >
{hasMassActions && ( {hasMassActions && (
<td className="px-3 py-2" onClick={(e) => e.stopPropagation()}> <td className="px-3 py-2 whitespace-nowrap" onClick={(e) => e.stopPropagation()}>
<Checkbox <Checkbox
checked={selectedIds.has(itemId)} checked={selectedIds.has(itemId)}
onCheckedChange={() => toggleSelectItem(itemId)} onCheckedChange={() => toggleSelectItem(itemId)}
@@ -1280,19 +1769,47 @@ export function DynamicList({ viewName }: DynamicListProps) {
/> />
</td> </td>
)} )}
{list.columns.map((col) => ( {displayColumns.map((col) => (
<td key={col.name} className="px-3 py-2"> <td key={col.name} className="px-3 py-2 whitespace-nowrap">
{renderCellValue( {isWebApplications && col.name === 'enabled' && !fields[col.name] ? (
item[col.name], item.enabled === true ? (
fields[col.name], <Check className="h-4 w-4 text-green-600" />
col.name, ) : (
schema!, <X className="h-4 w-4 text-red-500" />
resolved.obj.objectName, )
getDisplayName, ) : isAccountsList && col.name === 'roles' ? (
formatUserRole(item, schema!)
) : hasQuotaUsageColumn && col.name === 'quotaUsage' ? (
renderQuotaUsage(item, t)
) : col.name in COUNT_COLUMN_SOURCES && activeCountColumns.includes(col.name) ? (
getCountColumnValue(col.name, item)
) : isMailboxList && col.name === 'name' ? (
(() => {
const depth = mailboxDepths.get(item.id as string) ?? 0;
return (
<div style={{ paddingLeft: depth * 20 }} className="flex items-center gap-1.5">
{depth > 0 && (
<CornerDownRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
)}
<span>{String(item.name ?? '')}</span>
</div>
);
})()
) : (
renderCellValue(
item[col.name],
fields[col.name],
col.name,
schema!,
resolved.obj.objectName,
getDisplayName,
)
)} )}
</td> </td>
))} ))}
{hasItemActions && <td className="px-3 py-2 text-right">{renderItemActions(item)}</td>} {hasItemActions && (
<td className="px-3 py-2 text-right whitespace-nowrap">{renderItemActions(item)}</td>
)}
</tr> </tr>
); );
}) })
@@ -1303,8 +1820,8 @@ export function DynamicList({ viewName }: DynamicListProps) {
</div> </div>
{items.length > 0 && ( {items.length > 0 && (
<div className="flex items-center justify-between text-sm text-muted-foreground"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between text-sm text-muted-foreground">
<div> <div className="min-w-0">
{total !== null {total !== null
? t('list.showing', 'Showing {{from}}-{{to}} of {{total}} {{name}}', { ? t('list.showing', 'Showing {{from}}-{{to}} of {{total}} {{name}}', {
from: rangeStart, from: rangeStart,
@@ -1316,7 +1833,7 @@ export function DynamicList({ viewName }: DynamicListProps) {
count: items.length, count: items.length,
})} })}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex shrink-0 items-center gap-2 self-end sm:self-auto">
<Button variant="outline" size="sm" disabled={!hasPrevPage || loading} onClick={handlePrevPage}> <Button variant="outline" size="sm" disabled={!hasPrevPage || loading} onClick={handlePrevPage}>
{t('list.previous', 'Previous')} {t('list.previous', 'Previous')}
</Button> </Button>
+28 -17
View File
@@ -5,7 +5,7 @@
*/ */
import * as React from 'react'; import * as React from 'react';
import { ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react'; import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
import { DayPicker, getDefaultClassNames, type DayButton } from '@daypicker/react'; import { DayPicker, getDefaultClassNames, type DayButton } from '@daypicker/react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -29,7 +29,7 @@ function Calendar({
<DayPicker <DayPicker
showOutsideDays={showOutsideDays} showOutsideDays={showOutsideDays}
className={cn( className={cn(
'group/calendar bg-background p-3 [--cell-size:--spacing(8)] [[data-slot=popover-content]_&]:bg-transparent', 'group/calendar bg-background p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent',
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`, String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`, String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className, className,
@@ -93,29 +93,35 @@ function Calendar({
range_start: cn('rounded-l-md bg-accent', defaultClassNames.range_start), range_start: cn('rounded-l-md bg-accent', defaultClassNames.range_start),
range_middle: cn('rounded-none', defaultClassNames.range_middle), range_middle: cn('rounded-none', defaultClassNames.range_middle),
range_end: cn('rounded-r-md bg-accent', defaultClassNames.range_end), range_end: cn('rounded-r-md bg-accent', defaultClassNames.range_end),
today: cn( today: cn('rounded-md bg-accent text-accent-foreground data-[selected=true]:rounded-none', defaultClassNames.today),
'rounded-md bg-accent text-accent-foreground data-[selected=true]:rounded-none',
defaultClassNames.today,
),
outside: cn('text-muted-foreground aria-selected:text-muted-foreground', defaultClassNames.outside), outside: cn('text-muted-foreground aria-selected:text-muted-foreground', defaultClassNames.outside),
disabled: cn('text-muted-foreground opacity-50', defaultClassNames.disabled), disabled: cn('text-muted-foreground opacity-50', defaultClassNames.disabled),
hidden: cn('invisible', defaultClassNames.hidden), hidden: cn('invisible', defaultClassNames.hidden),
...classNames, ...classNames,
}} }}
components={{ components={{
Root: ({ className, rootRef, ...props }) => ( Root: ({ className, rootRef, ...props }) => {
<div data-slot="calendar" ref={rootRef} className={cn(className)} {...props} /> return <div data-slot="calendar" ref={rootRef} className={cn(className)} {...props} />;
), },
Chevron: ({ className, orientation, ...props }) => { Chevron: ({ className, orientation, ...props }) => {
const Icon = orientation === 'left' ? ChevronLeft : orientation === 'right' ? ChevronRight : ChevronDown; if (orientation === 'left') {
return <Icon className={cn('size-4', className)} {...props} />; return <ChevronLeftIcon className={cn('size-4', className)} {...props} />;
}
if (orientation === 'right') {
return <ChevronRightIcon className={cn('size-4', className)} {...props} />;
}
return <ChevronDownIcon className={cn('size-4', className)} {...props} />;
}, },
DayButton: CalendarDayButton, DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => ( WeekNumber: ({ children, ...props }) => {
<td {...props}> return (
<div className="flex size-(--cell-size) items-center justify-center text-center">{children}</div> <td {...props}>
</td> <div className="flex size-(--cell-size) items-center justify-center text-center">{children}</div>
), </td>
);
},
...components, ...components,
}} }}
{...props} {...props}
@@ -123,7 +129,12 @@ function Calendar({
); );
} }
function CalendarDayButton({ className, day, modifiers, ...props }: React.ComponentProps<typeof DayButton>) { function CalendarDayButton({
className,
day,
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames(); const defaultClassNames = getDefaultClassNames();
const ref = React.useRef<HTMLButtonElement>(null); const ref = React.useRef<HTMLButtonElement>(null);
+17 -1
View File
@@ -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 };
+10 -9
View File
@@ -11,6 +11,7 @@ import { Search } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Dialog, DialogContent } from '@/components/ui/dialog'; import { Dialog, DialogContent } from '@/components/ui/dialog';
import { ScrollArea } from '@/components/ui/scroll-area';
const Command = React.forwardRef< const Command = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive>, React.ComponentRef<typeof CommandPrimitive>,
@@ -30,7 +31,7 @@ Command.displayName = CommandPrimitive.displayName;
const CommandDialog = ({ children, ...props }: DialogProps) => { const CommandDialog = ({ children, ...props }: DialogProps) => {
return ( return (
<Dialog {...props}> <Dialog {...props}>
<DialogContent className="overflow-hidden p-0"> <DialogContent className="overflow-hidden p-0" showCloseButton={false}>
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5"> <Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children} {children}
</Command> </Command>
@@ -41,8 +42,8 @@ const CommandDialog = ({ children, ...props }: DialogProps) => {
const CommandInput = React.forwardRef< const CommandInput = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive.Input>, React.ComponentRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input> & { trailing?: React.ReactNode } React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, trailing, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper=""> <div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" /> <Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input <CommandPrimitive.Input
@@ -53,7 +54,9 @@ const CommandInput = React.forwardRef<
)} )}
{...props} {...props}
/> />
{trailing} <kbd className="pointer-events-none ml-2 inline-flex h-5 shrink-0 select-none items-center rounded-md border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground">
ESC
</kbd>
</div> </div>
)); ));
CommandInput.displayName = CommandPrimitive.Input.displayName; CommandInput.displayName = CommandPrimitive.Input.displayName;
@@ -62,11 +65,9 @@ const CommandList = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive.List>, React.ComponentRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List> React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<CommandPrimitive.List <ScrollArea viewportClassName="max-h-[300px]">
ref={ref} <CommandPrimitive.List ref={ref} className={cn('overflow-x-hidden', className)} {...props} />
className={cn('max-h-[300px] overflow-y-auto overflow-x-hidden', className)} </ScrollArea>
{...props}
/>
)); ));
CommandList.displayName = CommandPrimitive.List.displayName; CommandList.displayName = CommandPrimitive.List.displayName;
+3 -1
View File
@@ -35,7 +35,9 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef< const DialogContent = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Content>, React.ComponentRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & { showCloseButton?: boolean } React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
}
>(({ className, children, showCloseButton = true, ...props }, ref) => ( >(({ className, children, showCloseButton = true, ...props }, ref) => (
<DialogPortal> <DialogPortal>
<DialogOverlay /> <DialogOverlay />
+4 -1
View File
@@ -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,
)} )}
+1 -1
View File
@@ -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}
+19 -4
View File
@@ -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>
+1 -1
View File
@@ -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}
+1 -1
View File
@@ -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}
+1 -1
View File
@@ -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 -5
View File
@@ -14,7 +14,8 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { resolveSchema, resolveVariantForm, resolveForm } from '@/lib/schemaResolver'; import { resolveSchema, resolveVariantForm, resolveForm } from '@/lib/schemaResolver';
import { useObjectList, useObjectLabel } from '@/lib/objectOptions'; import { useObjectList, useObjectLabel } from '@/lib/objectOptions';
import { formatSize, formatDuration } from '@/lib/durationFormat'; import { formatDuration } from '@/lib/durationFormat';
import { SizeDisplay } from '@/components/common/SizeDisplay';
import type { Schema, Field, FieldType, FormField, Form, Fields, EnumVariant, ScalarType } from '@/types/schema'; import type { Schema, Field, FieldType, FormField, Form, Fields, EnumVariant, ScalarType } from '@/types/schema';
export interface DynamicViewProps { export interface DynamicViewProps {
@@ -126,8 +127,8 @@ function ViewField({ label, field, value, schema }: { label: string; field: Fiel
const isBlock = isBlockType(field.type, value); const isBlock = isBlockType(field.type, value);
return ( return (
<div className={isBlock ? 'space-y-1' : 'flex items-baseline gap-2'}> <div className={isBlock ? 'space-y-1' : 'flex flex-col gap-1 sm:flex-row sm:items-baseline sm:gap-2'}>
<dt className="flex items-center gap-1 text-sm text-muted-foreground shrink-0 min-w-[140px]"> <dt className="flex items-center gap-1 text-sm text-muted-foreground shrink-0 sm:min-w-[140px]">
{label} {label}
{field.description && ( {field.description && (
<TooltipProvider> <TooltipProvider>
@@ -144,7 +145,7 @@ function ViewField({ label, field, value, schema }: { label: string; field: Fiel
</TooltipProvider> </TooltipProvider>
)} )}
</dt> </dt>
<dd className="text-sm min-w-0"> <dd className="text-sm min-w-0 break-words">
<ViewValue type={field.type} value={value} schema={schema} /> <ViewValue type={field.type} value={value} schema={schema} />
</dd> </dd>
</div> </div>
@@ -224,7 +225,7 @@ function NumberValue({ value, format }: { value: unknown; format: string }) {
switch (format) { switch (format) {
case 'size': case 'size':
return <span>{formatSize(num)}</span>; return <SizeDisplay bytes={num} />;
case 'duration': case 'duration':
return <span>{formatDuration(num)}</span>; return <span>{formatDuration(num)}</span>;
default: default:
+140
View File
@@ -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>
);
}
+78
View File
@@ -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>
);
}
+59
View File
@@ -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;
}
+4 -5
View File
@@ -6,13 +6,12 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
const APP_NAME = 'Stalwart WebUI'; const APP_NAME = 'Stalwart';
// Keeps the tab title in sync with the current page instead of the static
// index.html title; falls back to the bare app name when no page title is given.
export function useDocumentTitle(title?: string | null) { export function useDocumentTitle(title?: string | null) {
useEffect(() => { useEffect(() => {
document.title = title ? `${title} · ${APP_NAME}` : APP_NAME; document.title = title ? `${APP_NAME} | ${title}` : APP_NAME;
return () => {
document.title = APP_NAME;
};
}, [title]); }, [title]);
} }
+11 -8
View File
@@ -34,11 +34,13 @@ export function getActionInfo(
objectKind: ObjectKind, objectKind: ObjectKind,
t: (key: string, fallback: string) => string, t: (key: string, fallback: string) => string,
): { label: string; Icon: typeof List } { ): { label: string; Icon: typeof List } {
if (objectKind === 'singleton') { if (entryType === 'link') {
return { label: t('globalSearch.settings', 'Settings'), Icon: Settings }; return objectKind === 'singleton'
? { label: t('globalSearch.settings', 'Settings'), Icon: Settings }
: { label: t('globalSearch.list', 'List'), Icon: List };
} }
return entryType === 'link' return objectKind === 'singleton'
? { label: t('globalSearch.list', 'List'), Icon: List } ? { label: t('globalSearch.settings', 'Settings'), Icon: Settings }
: { label: t('globalSearch.create', 'Create'), Icon: Plus }; : { label: t('globalSearch.create', 'Create'), Icon: Plus };
} }
@@ -48,8 +50,10 @@ function getNavigationPath(
section: string, section: string,
viewName: string, viewName: string,
): string { ): string {
if (objectKind === 'singleton') return `/${section}/${viewName}/singleton`; if (entryType === 'link') {
return entryType === 'link' ? `/${section}/${viewName}` : `/${section}/${viewName}/new`; return objectKind === 'singleton' ? `/${section}/${viewName}/singleton` : `/${section}/${viewName}`;
}
return objectKind === 'singleton' ? `/${section}/${viewName}/singleton` : `/${section}/${viewName}/new`;
} }
export function friendlyName(viewName: string): string { export function friendlyName(viewName: string): string {
@@ -124,10 +128,9 @@ export function useGlobalSearch(onAfterSelect?: () => void) {
); );
const reset = useCallback(() => { const reset = useCallback(() => {
if (timerRef.current) clearTimeout(timerRef.current);
setQuery(''); setQuery('');
setDebouncedQuery(''); setDebouncedQuery('');
}, []); }, []);
return { query, setQuery: handleQueryChange, debouncedQuery, results, groups, selectEntry, reset, schema }; return { query, setQuery: handleQueryChange, results, groups, selectEntry, reset, schema };
} }
+47 -3
View File
@@ -1,5 +1,31 @@
{ {
"accounts": "Accounts", "accounts": "Accounts",
"appearance": {
"colorTheme": "Color theme",
"colorThemeDescription": "Pick the accent color used across the interface.",
"corners": "Corners",
"cornersDescription": "Choose between rounded and square corners. Applies to every theme.",
"dark": "Dark",
"description": "Customize how the interface looks and feels.",
"label": "Appearance",
"light": "Light",
"mode": "Mode",
"modeDescription": "Switch between light and dark mode.",
"rounded": "Rounded",
"square": "Square",
"switchToDark": "Switch to dark mode",
"switchToLight": "Switch to light mode",
"theme": {
"amber": "Amber",
"default": "Default",
"forest": "Forest",
"ocean": "Ocean",
"rose": "Rose",
"stalwart": "Stalwart",
"teal": "Teal",
"violet": "Violet"
}
},
"actions": { "actions": {
"actionFailed": "Action failed.", "actionFailed": "Action failed.",
"backToActions": "Back to actions", "backToActions": "Back to actions",
@@ -30,6 +56,11 @@
"welcome": "Welcome to Stalwart", "welcome": "Welcome to Stalwart",
"welcomeSubtitle": "Let's get your server set up." "welcomeSubtitle": "Let's get your server set up."
}, },
"changelog": {
"description": "What's new in this fork, release by release.",
"label": "Changelog",
"officialNotice": "The official WebUI is maintained by the Stalwart team and is available at"
},
"common": { "common": {
"apply": "Apply", "apply": "Apply",
"back": "Back", "back": "Back",
@@ -171,6 +202,8 @@
"notSet": "Not set", "notSet": "Not set",
"optional": "(optional)", "optional": "(optional)",
"pickDate": "Pick a date", "pickDate": "Pick a date",
"hour": "Hour",
"minute": "Minute",
"required": "required", "required": "required",
"selectEllipsis": "Select...", "selectEllipsis": "Select...",
"selectKey": "Select key...", "selectKey": "Select key...",
@@ -267,6 +300,8 @@
"bulkSuccessUpdate_one": "{{count}} item updated successfully.", "bulkSuccessUpdate_one": "{{count}} item updated successfully.",
"bulkSuccessUpdate_other": "{{count}} items updated successfully.", "bulkSuccessUpdate_other": "{{count}} items updated successfully.",
"clearSelection": "Clear selection", "clearSelection": "Clear selection",
"comboboxEmptyText": "No matches.",
"comboboxSearchPlaceholder": "Search...",
"confirmDescription": "Are you sure you want to proceed with: {{action}}?", "confirmDescription": "Are you sure you want to proceed with: {{action}}?",
"confirmTitle": "Confirm Action", "confirmTitle": "Confirm Action",
"create": "Create {{name}}", "create": "Create {{name}}",
@@ -278,6 +313,8 @@
"next": "Next", "next": "Next",
"noResults": "No results found", "noResults": "No results found",
"previous": "Previous", "previous": "Previous",
"refresh": "Refresh",
"refreshCooldown": "Please wait a few seconds before refreshing again",
"resetFilters": "Reset", "resetFilters": "Reset",
"searchFilters": "Search", "searchFilters": "Search",
"selectAll": "Select all", "selectAll": "Select all",
@@ -287,7 +324,10 @@
"showing": "Showing {{from}}-{{to}} of {{total}} {{name}}", "showing": "Showing {{from}}-{{to}} of {{total}} {{name}}",
"showingItems": "Showing {{count}} items", "showingItems": "Showing {{count}} items",
"sort": "Sort", "sort": "Sort",
"unknownError": "Unknown error" "unknownError": "Unknown error",
"unlimitedQuota": "∞",
"negativeQuotaInfoAria": "Why is disk usage negative?",
"negativeQuotaTooltip": "This disk-usage counter is out of sync (often after a migration or reset). Schedule a task: Perform account maintenance operations → Recalculate storage quota usage for the account. Or for all accounts: Perform store maintenance operations → Reset all user quotas."
}, },
"login": { "login": {
"continue": "Continue", "continue": "Continue",
@@ -302,7 +342,7 @@
}, },
"logout": "Logout", "logout": "Logout",
"version": { "version": {
"label": "Stalwart WebUI v{{version}}" "label": "Stalwart WebUI Fork v{{version}}"
}, },
"oauth": { "oauth": {
"backToLogin": "Back to login", "backToLogin": "Back to login",
@@ -340,7 +380,6 @@
"periodValue": "Period value" "periodValue": "Period value"
}, },
"sections": "Sections", "sections": "Sections",
"toggleTheme": "Toggle theme",
"tracing": { "tracing": {
"addFilter": "Add filter", "addFilter": "Add filter",
"bufferFull": "(buffer full)", "bufferFull": "(buffer full)",
@@ -376,5 +415,10 @@
"failedToLoad": "Failed to load", "failedToLoad": "Failed to load",
"noGetResponse": "No get response", "noGetResponse": "No get response",
"objectNotFound": "Object not found" "objectNotFound": "Object not found"
},
"webApplications": {
"activeWebUI": "Active WebUI",
"version": "Version",
"enabled": "Enabled"
} }
} }
+326 -8
View File
@@ -7,6 +7,7 @@
@import 'tailwindcss'; @import 'tailwindcss';
:root { :root {
color-scheme: light;
--background: oklch(1 0 0); --background: oklch(1 0 0);
--foreground: oklch(0.145 0.017 285.823); --foreground: oklch(0.145 0.017 285.823);
@@ -37,6 +38,10 @@
--input: oklch(0.922 0.007 285.823); --input: oklch(0.922 0.007 285.823);
--ring: oklch(0.708 0.015 285.823); --ring: oklch(0.708 0.015 285.823);
/* Form fields use the page background tone so cards read as elevated
surfaces (shadcn convention: inset fields on a raised card). */
--field: var(--background);
--radius: 0.5rem; --radius: 0.5rem;
--chart-1: 220 70% 50%; --chart-1: 220 70% 50%;
@@ -47,10 +52,11 @@
} }
.dark { .dark {
color-scheme: dark;
--background: oklch(0.141 0.005 285.823); --background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0.002 285.823); --foreground: oklch(0.985 0.002 285.823);
--card: oklch(0.205 0.007 285.823); --card: oklch(0.235 0.008 285.823);
--card-foreground: oklch(0.985 0.002 285.823); --card-foreground: oklch(0.985 0.002 285.823);
--popover: oklch(0.205 0.007 285.823); --popover: oklch(0.205 0.007 285.823);
@@ -77,6 +83,8 @@
--input: oklch(0.274 0.009 285.823); --input: oklch(0.274 0.009 285.823);
--ring: oklch(0.553 0.013 285.823); --ring: oklch(0.553 0.013 285.823);
--field: var(--background);
--chart-1: 220 70% 60%; --chart-1: 220 70% 60%;
--chart-2: 160 60% 55%; --chart-2: 160 60% 55%;
--chart-3: 30 80% 60%; --chart-3: 30 80% 60%;
@@ -84,6 +92,298 @@
--chart-5: 340 75% 60%; --chart-5: 340 75% 60%;
} }
/* Square mode: every radius token derives multiplicatively from --radius,
so zeroing it here flattens all corners (rounded-full stays untouched). */
:root[data-radius='square'] {
--radius: 0px;
}
/* Color themes. Each theme re-hues the neutral tokens at the same lightness
and chroma as the default palette (contrast is preserved) and replaces
primary/ring with the theme color. The neutral "Default" theme is the
bare :root/.dark above and needs no data-theme attribute. Dark theme
blocks must stay after their light counterpart: both selectors have the
same specificity, so source order decides. */
/* "Stalwart" theme: matches the brand colors from stalw.art's own site
(src/styles/tokens.css there) — brand red #db2d54/#ff4570 converted to
oklch, neutrals kept close to true gray (unlike the other theme
presets below, which lightly re-hue their neutrals) since that's what
the real site does: saturated color only on the accent, not the
backdrop. */
:root[data-theme='stalwart'] {
--foreground: oklch(0.147 0.009 285.3);
--content-background: oklch(0.974 0.003 286.4);
--primary: oklch(0.586 0.207 14.6);
--primary-foreground: oklch(0.985 0.002 285.3);
--secondary: oklch(0.965 0.005 285.3);
--secondary-foreground: oklch(0.205 0.017 285.3);
--muted: oklch(0.93 0.005 285.3);
--muted-foreground: oklch(0.431 0.016 285.7);
--accent: oklch(0.965 0.005 285.3);
--accent-foreground: oklch(0.205 0.017 285.3);
--border: oklch(0.926 0.005 286.3);
--input: oklch(0.926 0.005 286.3);
--ring: oklch(0.586 0.207 14.6);
--chart-1: 350 65% 52%;
}
.dark[data-theme='stalwart'] {
--background: oklch(0.136 0.007 285.5);
--foreground: oklch(0.968 0.003 286.4);
--card: oklch(0.19 0.012 285.2);
--card-foreground: oklch(0.968 0.003 286.4);
--popover: oklch(0.221 0.016 285.1);
--popover-foreground: oklch(0.968 0.003 286.4);
--content-background: oklch(0.11 0.006 285.5);
--primary: oklch(0.672 0.221 12.3);
--primary-foreground: oklch(0.15 0.03 14.6);
--secondary: oklch(0.256 0.019 285);
--secondary-foreground: oklch(0.968 0.003 286.4);
--muted: oklch(0.256 0.019 285);
--muted-foreground: oklch(0.719 0.014 286);
--accent: oklch(0.28 0.02 285.1);
--accent-foreground: oklch(0.968 0.003 286.4);
--border: oklch(0.272 0.017 285.2);
--input: oklch(0.272 0.017 285.2);
--ring: oklch(0.672 0.221 12.3);
--chart-1: 350 80% 65%;
}
:root[data-theme='ocean'] {
--foreground: oklch(0.145 0.02 264);
--content-background: oklch(0.965 0.008 264);
--primary: oklch(0.546 0.215 262.9);
--primary-foreground: oklch(0.985 0.002 264);
--secondary: oklch(0.955 0.012 264);
--secondary-foreground: oklch(0.3 0.035 264);
--muted: oklch(0.935 0.01 264);
--muted-foreground: oklch(0.556 0.022 264);
--accent: oklch(0.94 0.018 264);
--accent-foreground: oklch(0.3 0.035 264);
--border: oklch(0.912 0.012 264);
--input: oklch(0.912 0.012 264);
--ring: oklch(0.546 0.215 262.9);
--chart-1: 221 83% 53%;
}
.dark[data-theme='ocean'] {
--background: oklch(0.152 0.014 264);
--foreground: oklch(0.96 0.008 264);
--card: oklch(0.235 0.02 264);
--card-foreground: oklch(0.96 0.008 264);
--popover: oklch(0.208 0.018 264);
--popover-foreground: oklch(0.96 0.008 264);
--content-background: oklch(0.122 0.012 264);
--primary: oklch(0.623 0.214 259.8);
--primary-foreground: oklch(0.985 0.002 264);
--secondary: oklch(0.28 0.022 264);
--secondary-foreground: oklch(0.96 0.008 264);
--muted: oklch(0.28 0.022 264);
--muted-foreground: oklch(0.715 0.022 264);
--accent: oklch(0.305 0.028 264);
--accent-foreground: oklch(0.96 0.008 264);
--border: oklch(0.29 0.024 264);
--input: oklch(0.29 0.024 264);
--ring: oklch(0.623 0.214 259.8);
--chart-1: 217 91% 60%;
}
:root[data-theme='forest'] {
--foreground: oklch(0.145 0.02 150);
--content-background: oklch(0.965 0.008 150);
--primary: oklch(0.527 0.137 150.1);
--primary-foreground: oklch(0.985 0.004 150);
--secondary: oklch(0.955 0.012 150);
--secondary-foreground: oklch(0.3 0.035 150);
--muted: oklch(0.935 0.01 150);
--muted-foreground: oklch(0.556 0.022 150);
--accent: oklch(0.94 0.018 150);
--accent-foreground: oklch(0.3 0.035 150);
--border: oklch(0.912 0.012 150);
--input: oklch(0.912 0.012 150);
--ring: oklch(0.527 0.137 150.1);
--chart-1: 142 71% 45%;
}
.dark[data-theme='forest'] {
--background: oklch(0.152 0.012 150);
--foreground: oklch(0.96 0.008 150);
--card: oklch(0.235 0.018 150);
--card-foreground: oklch(0.96 0.008 150);
--popover: oklch(0.208 0.016 150);
--popover-foreground: oklch(0.96 0.008 150);
--content-background: oklch(0.122 0.01 150);
--primary: oklch(0.723 0.219 149.6);
--primary-foreground: oklch(0.2 0.03 150);
--secondary: oklch(0.28 0.02 150);
--secondary-foreground: oklch(0.96 0.008 150);
--muted: oklch(0.28 0.02 150);
--muted-foreground: oklch(0.715 0.02 150);
--accent: oklch(0.305 0.024 150);
--accent-foreground: oklch(0.96 0.008 150);
--border: oklch(0.29 0.022 150);
--input: oklch(0.29 0.022 150);
--ring: oklch(0.723 0.219 149.6);
--chart-1: 142 69% 58%;
}
:root[data-theme='violet'] {
--foreground: oklch(0.145 0.02 290);
--content-background: oklch(0.965 0.008 290);
--primary: oklch(0.541 0.281 293);
--primary-foreground: oklch(0.985 0.002 290);
--secondary: oklch(0.955 0.012 290);
--secondary-foreground: oklch(0.3 0.04 290);
--muted: oklch(0.935 0.01 290);
--muted-foreground: oklch(0.556 0.025 290);
--accent: oklch(0.94 0.02 290);
--accent-foreground: oklch(0.3 0.04 290);
--border: oklch(0.912 0.014 290);
--input: oklch(0.912 0.014 290);
--ring: oklch(0.541 0.281 293);
--chart-1: 262 83% 58%;
}
.dark[data-theme='violet'] {
--background: oklch(0.152 0.014 290);
--foreground: oklch(0.96 0.008 290);
--card: oklch(0.235 0.02 290);
--card-foreground: oklch(0.96 0.008 290);
--popover: oklch(0.208 0.018 290);
--popover-foreground: oklch(0.96 0.008 290);
--content-background: oklch(0.122 0.012 290);
--primary: oklch(0.702 0.183 293.5);
--primary-foreground: oklch(0.21 0.03 293);
--secondary: oklch(0.28 0.024 290);
--secondary-foreground: oklch(0.96 0.008 290);
--muted: oklch(0.28 0.024 290);
--muted-foreground: oklch(0.715 0.024 290);
--accent: oklch(0.305 0.03 290);
--accent-foreground: oklch(0.96 0.008 290);
--border: oklch(0.29 0.026 290);
--input: oklch(0.29 0.026 290);
--ring: oklch(0.702 0.183 293.5);
--chart-1: 263 70% 66%;
}
:root[data-theme='rose'] {
--foreground: oklch(0.145 0.02 12);
--content-background: oklch(0.965 0.008 12);
--primary: oklch(0.577 0.245 12);
--primary-foreground: oklch(0.985 0.002 12);
--secondary: oklch(0.955 0.012 12);
--secondary-foreground: oklch(0.3 0.035 12);
--muted: oklch(0.935 0.01 12);
--muted-foreground: oklch(0.556 0.022 12);
--accent: oklch(0.94 0.018 12);
--accent-foreground: oklch(0.3 0.035 12);
--border: oklch(0.912 0.012 12);
--input: oklch(0.912 0.012 12);
--ring: oklch(0.577 0.245 12);
--chart-1: 350 83% 55%;
}
.dark[data-theme='rose'] {
--background: oklch(0.152 0.014 12);
--foreground: oklch(0.96 0.008 12);
--card: oklch(0.235 0.02 12);
--card-foreground: oklch(0.96 0.008 12);
--popover: oklch(0.208 0.018 12);
--popover-foreground: oklch(0.96 0.008 12);
--content-background: oklch(0.122 0.012 12);
--primary: oklch(0.704 0.191 12);
--primary-foreground: oklch(0.21 0.03 12);
--secondary: oklch(0.28 0.022 12);
--secondary-foreground: oklch(0.96 0.008 12);
--muted: oklch(0.28 0.022 12);
--muted-foreground: oklch(0.715 0.022 12);
--accent: oklch(0.305 0.028 12);
--accent-foreground: oklch(0.96 0.008 12);
--border: oklch(0.29 0.024 12);
--input: oklch(0.29 0.024 12);
--ring: oklch(0.704 0.191 12);
--chart-1: 350 80% 66%;
}
:root[data-theme='amber'] {
--foreground: oklch(0.145 0.02 70);
--content-background: oklch(0.965 0.008 70);
--primary: oklch(0.65 0.16 70);
--primary-foreground: oklch(0.2 0.02 70);
--secondary: oklch(0.955 0.012 70);
--secondary-foreground: oklch(0.3 0.035 70);
--muted: oklch(0.935 0.01 70);
--muted-foreground: oklch(0.556 0.022 70);
--accent: oklch(0.94 0.018 70);
--accent-foreground: oklch(0.3 0.035 70);
--border: oklch(0.912 0.012 70);
--input: oklch(0.912 0.012 70);
--ring: oklch(0.65 0.16 70);
--chart-1: 43 96% 56%;
}
.dark[data-theme='amber'] {
--background: oklch(0.152 0.014 70);
--foreground: oklch(0.96 0.008 70);
--card: oklch(0.235 0.02 70);
--card-foreground: oklch(0.96 0.008 70);
--popover: oklch(0.208 0.018 70);
--popover-foreground: oklch(0.96 0.008 70);
--content-background: oklch(0.122 0.012 70);
--primary: oklch(0.75 0.17 75);
--primary-foreground: oklch(0.2 0.02 70);
--secondary: oklch(0.28 0.022 70);
--secondary-foreground: oklch(0.96 0.008 70);
--muted: oklch(0.28 0.022 70);
--muted-foreground: oklch(0.715 0.022 70);
--accent: oklch(0.305 0.028 70);
--accent-foreground: oklch(0.96 0.008 70);
--border: oklch(0.29 0.024 70);
--input: oklch(0.29 0.024 70);
--ring: oklch(0.75 0.17 75);
--chart-1: 43 96% 62%;
}
:root[data-theme='teal'] {
--foreground: oklch(0.145 0.02 195);
--content-background: oklch(0.965 0.008 195);
--primary: oklch(0.55 0.13 195);
--primary-foreground: oklch(0.985 0.002 195);
--secondary: oklch(0.955 0.012 195);
--secondary-foreground: oklch(0.3 0.035 195);
--muted: oklch(0.935 0.01 195);
--muted-foreground: oklch(0.556 0.022 195);
--accent: oklch(0.94 0.018 195);
--accent-foreground: oklch(0.3 0.035 195);
--border: oklch(0.912 0.012 195);
--input: oklch(0.912 0.012 195);
--ring: oklch(0.55 0.13 195);
--chart-1: 189 94% 43%;
}
.dark[data-theme='teal'] {
--background: oklch(0.152 0.014 195);
--foreground: oklch(0.96 0.008 195);
--card: oklch(0.235 0.02 195);
--card-foreground: oklch(0.96 0.008 195);
--popover: oklch(0.208 0.018 195);
--popover-foreground: oklch(0.96 0.008 195);
--content-background: oklch(0.122 0.012 195);
--primary: oklch(0.68 0.13 195);
--primary-foreground: oklch(0.2 0.02 195);
--secondary: oklch(0.28 0.022 195);
--secondary-foreground: oklch(0.96 0.008 195);
--muted: oklch(0.28 0.022 195);
--muted-foreground: oklch(0.715 0.022 195);
--accent: oklch(0.305 0.028 195);
--accent-foreground: oklch(0.96 0.008 195);
--border: oklch(0.29 0.024 195);
--input: oklch(0.29 0.024 195);
--ring: oklch(0.68 0.13 195);
--chart-1: 189 90% 55%;
}
@theme inline { @theme inline {
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
@@ -113,6 +413,7 @@
--color-border: var(--border); --color-border: var(--border);
--color-input: var(--input); --color-input: var(--input);
--color-ring: var(--ring); --color-ring: var(--ring);
--color-field: var(--field);
--color-chart-1: hsl(var(--chart-1)); --color-chart-1: hsl(var(--chart-1));
--color-chart-2: hsl(var(--chart-2)); --color-chart-2: hsl(var(--chart-2));
@@ -120,10 +421,31 @@
--color-chart-4: hsl(var(--chart-4)); --color-chart-4: hsl(var(--chart-4));
--color-chart-5: hsl(var(--chart-5)); --color-chart-5: hsl(var(--chart-5));
--radius-sm: calc(var(--radius) - 4px); --radius-sm: calc(var(--radius) * 0.5);
--radius-md: calc(var(--radius) - 2px); --radius-md: calc(var(--radius) * 0.75);
--radius-lg: var(--radius); --radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px); --radius-xl: calc(var(--radius) * 1.5);
--animate-collapsible-down: collapsible-down 0.2s ease-out;
--animate-collapsible-up: collapsible-up 0.2s ease-out;
@keyframes collapsible-down {
from {
height: 0;
}
to {
height: var(--radix-collapsible-content-height);
}
}
@keyframes collapsible-up {
from {
height: var(--radix-collapsible-content-height);
}
to {
height: 0;
}
}
} }
@layer base { @layer base {
@@ -135,7 +457,3 @@
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }
} }
.dark input[type='number'] {
color-scheme: dark;
}
+65
View File
@@ -0,0 +1,65 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import type { Schema } from '@/types/schema';
import { clientSortable as sortable } from './schemaDeviationTypes';
/**
* SCHEMA-DEVIATION: account-quota-usage-column (see SCHEMA_DEVIATIONS.md)
*
* The Accounts list only shows Email/Full Name/Created At, hiding role and
* storage usage that otherwise require opening each account individually.
* `createdAt` is dropped to make room; `roles` is a real property so it
* renders through the normal field pipeline, but `quotaUsage` is synthetic
* (not a real server property) — DynamicList resolves it to the
* `usedDiskQuota` + `quotas.maxDiskQuota` pair and formats it specially.
*
* The Groups list gets the same `quotaUsage` column (groups have their own
* `usedDiskQuota`/`quotas`, same as users), but not `roles` — that column
* is specific to the Users list.
*
* SCHEMA-DEVIATION: account-alias-count-column (see SCHEMA_DEVIATIONS.md)
*
* Both lists also get a synthetic `aliasCount` column — DynamicList
* resolves it from the real `aliases` objectList property (an id-keyed
* map, per JMAP's objectList wire format) and renders its entry count.
*
* SCHEMA-DEVIATION: account-client-sort (see SCHEMA_DEVIATIONS.md)
*
* Email Address, Full Name (`description`), `quotaUsage`, and
* `aliasCount` are tagged `clientSortable` — DynamicList's generic
* client-sort mechanism picks this flag up from the column definition
* itself, the same way it already resolves `quotaUsage`/`aliasCount`,
* instead of hardcoding which lists/columns support it.
*/
export function withAccountListColumns(schema: Schema): Schema {
let lists = schema.lists;
const userList = lists['x:Account/User'];
if (userList) {
const columns = [
...userList.columns
.filter((c) => c.name !== 'createdAt')
.map((c) => (c.name === 'emailAddress' || c.name === 'description' ? sortable(c) : c)),
{ name: 'roles', label: 'Role' },
sortable({ name: 'quotaUsage', label: 'Usage / Quota' }),
sortable({ name: 'aliasCount', label: 'Aliases' }),
];
lists = { ...lists, 'x:Account/User': { ...userList, columns } };
}
const groupList = lists['x:Account/Group'];
if (groupList) {
const columns = [
...groupList.columns.map((c) => (c.name === 'emailAddress' || c.name === 'description' ? sortable(c) : c)),
sortable({ name: 'quotaUsage', label: 'Usage / Quota' }),
sortable({ name: 'aliasCount', label: 'Aliases' }),
];
lists = { ...lists, 'x:Account/Group': { ...groupList, columns } };
}
return { ...schema, lists };
}
+45
View File
@@ -0,0 +1,45 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import type { Schema } from '@/types/schema';
import { clientSortable } from './schemaDeviationTypes';
/**
* SCHEMA-DEVIATION: account-alias-count-column (see SCHEMA_DEVIATIONS.md)
*
* The Domains list doesn't expose alias-domain count as a column, only
* the full `aliases` set on the detail view. Adds the same synthetic
* `aliasCount` column already used on Accounts/Groups/Mailing Lists —
* DynamicList's generic count-column handling picks it up with no
* further wiring.
*
* SCHEMA-DEVIATION: account-client-sort (see SCHEMA_DEVIATIONS.md)
*
* Domain Name, Enabled, and `aliasCount` are tagged `clientSortable`.
* `name` is actually accepted by the live server's `sort` (unlike every
* other property tried on every other list — see SCHEMA_DEVIATIONS.md),
* but the schema still doesn't declare it in `list.sort`, so it's routed
* through the same client-sort mechanism as the rest for consistency
* rather than adding a second, one-off "trust an undeclared sort"
* pathway for a single column.
*/
export function withDomainColumns(schema: Schema): Schema {
const list = schema.lists['x:Domain'];
if (!list) return schema;
const columns = [
...list.columns.map((c) => (c.name === 'name' || c.name === 'isEnabled' ? clientSortable(c) : c)),
clientSortable({ name: 'aliasCount', label: 'Aliases' }),
];
return {
...schema,
lists: {
...schema.lists,
'x:Domain': { ...list, columns },
},
};
}
+13
View File
@@ -68,6 +68,15 @@ describe('bytesToHuman', () => {
it('should convert 1099511627776 bytes to 1 TB', () => { it('should convert 1099511627776 bytes to 1 TB', () => {
expect(bytesToHuman(1099511627776)).toEqual({ value: 1, unit: 'TB' }); expect(bytesToHuman(1099511627776)).toEqual({ value: 1, unit: 'TB' });
}); });
it('should format negative byte counts with a minus sign', () => {
expect(bytesToHuman(-9515272)).toEqual({ value: -9.07, unit: 'MB' });
});
it('should treat non-finite byte counts as empty usage', () => {
expect(bytesToHuman(Number.NaN)).toEqual({ value: 0, unit: 'B' });
expect(bytesToHuman(Number.POSITIVE_INFINITY)).toEqual({ value: 0, unit: 'B' });
});
}); });
describe('humanToBytes', () => { describe('humanToBytes', () => {
@@ -108,6 +117,10 @@ describe('formatSize', () => {
it('should format large values in TB', () => { it('should format large values in TB', () => {
expect(formatSize(1099511627776)).toBe('1 TB'); expect(formatSize(1099511627776)).toBe('1 TB');
}); });
it('should format negative byte counts with a minus sign', () => {
expect(formatSize(-9515272)).toBe('-9.07 MB');
});
}); });
describe('msToHuman', () => { describe('msToHuman', () => {
+6 -3
View File
@@ -15,15 +15,18 @@ const SIZE_FACTORS: Record<string, number> = {
}; };
export function bytesToHuman(bytes: number): { value: number; unit: string } { export function bytesToHuman(bytes: number): { value: number; unit: string } {
if (bytes === 0) return { value: 0, unit: 'B' }; if (!Number.isFinite(bytes) || bytes === 0) return { value: 0, unit: 'B' };
const sign = bytes < 0 ? -1 : 1;
const abs = Math.abs(bytes);
for (let i = SIZE_UNITS.length - 1; i >= 0; i--) { for (let i = SIZE_UNITS.length - 1; i >= 0; i--) {
const unit = SIZE_UNITS[i]; const unit = SIZE_UNITS[i];
const factor = SIZE_FACTORS[unit]; const factor = SIZE_FACTORS[unit];
const v = bytes / factor; const v = abs / factor;
if (v >= 1) { if (v >= 1) {
const rounded = Math.round(v * 100) / 100; const rounded = Math.round(v * 100) / 100;
return { value: rounded, unit }; return { value: sign * rounded, unit };
} }
} }
+51
View File
@@ -0,0 +1,51 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { isLinkAccessible } from './layout';
import type { Layout, Schema } from '@/types/schema';
type CanGet = (prefix: string) => boolean;
type HasPermission = (permission: string) => boolean;
const STORAGE_KEY = 'stalwart-last-visited';
// Stores the last visited view name for a given top-level section in localStorage.
export function setLastVisitedSection(section: string, viewName: string): void {
try {
const raw = localStorage.getItem(STORAGE_KEY);
const data = raw ? (JSON.parse(raw) as Record<string, string>) : {};
data[section] = viewName;
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
} catch {
// Ignore storage errors (e.g. private mode).
}
}
// Retrieves the last visited view name for a section, or null if none is stored.
export function getLastVisitedSection(section: string): string | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const data = JSON.parse(raw) as Record<string, string>;
return data[section] ?? null;
} catch {
return null;
}
}
// Returns the stored view name only if it is still accessible in the target layout.
export function findLastVisitedLinkInLayout(
schema: Schema,
layout: Layout,
edition: string,
canGet: CanGet,
hasPermission: HasPermission,
): string | null {
const last = getLastVisitedSection(layout.name);
if (!last) return null;
if (isLinkAccessible(schema, last, edition, canGet, hasPermission)) return last;
return null;
}
+49
View File
@@ -0,0 +1,49 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import type { Schema } from '@/types/schema';
import type { ClientOnlyFilterEnum } from './schemaDeviationTypes';
/**
* SCHEMA-DEVIATION: log-client-filters (see SCHEMA_DEVIATIONS.md)
*
* The Stalwart JMAP backend rejects `level`/`event` as filter conditions on
* `x:Log/query` (`unsupportedFilter`), even though both properties are
* already returned per row. Until the backend adds real support, these two
* filters are appended client-side and applied entirely in the browser
* (see the `clientOnly` flag consumed by DynamicList) instead of being sent
* to the server.
*/
export function withClientLogFilters(schema: Schema): Schema {
const logList = schema.lists['x:Log'];
if (!logList || !schema.enums['TracingLevel'] || !schema.enums['EventType']) return schema;
const levelFilter: ClientOnlyFilterEnum = {
type: 'enum',
field: 'level',
enumName: 'TracingLevel',
label: 'Level',
clientOnly: true,
};
const eventFilter: ClientOnlyFilterEnum = {
type: 'enum',
field: 'event',
enumName: 'EventType',
label: 'Event',
clientOnly: true,
};
return {
...schema,
lists: {
...schema.lists,
'x:Log': {
...logList,
filters: [...(logList.filters ?? []), levelFilter, eventFilter],
},
},
};
}

Some files were not shown because too many files have changed in this diff Show More