mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
Merge remote-tracking branch 'origin/release/v1.46.0' into fix/trend-slope-span
This commit is contained in:
commit
8b6b3bccce
19 files changed
+476
-102
No files matched your search
@@ -89,7 +89,7 @@ curl -s http://localhost:3000/api/version # returns version + buildSha + built
|
||||
|
||||
Persistent state lives in exactly one named volume (`pgdata`, the Postgres data dir); the app container is stateless, so backing up the database backs up everything.
|
||||
|
||||
NAS and homelab users have ready-made paths: [Unraid Community Applications](docs/self-hosting/unraid.md) and [Portainer app templates](docs/self-hosting/portainer/README.md).
|
||||
NAS and homelab users have ready-made paths: [Unraid Community Applications](docs/self-hosting/unraid.md), [Portainer app templates](docs/self-hosting/portainer/README.md) and [TrueNAS](docs/self-hosting/truenas.md) (the Community train app is pending catalog review).
|
||||
|
||||
## Self-hosting
|
||||
|
||||
|
||||
+20
-5
@@ -199,11 +199,26 @@ dev --create-only --name vX_Y_Z_rollback_<col>`), edit the SQL,
|
||||
|
||||
## Catalog templates
|
||||
|
||||
A new required environment variable, or a change to the compose shape, needs a
|
||||
matching pull request to the TrueNAS apps catalog in the same release cycle
|
||||
(and an update to `docs/self-hosting/unraid/healthlog.xml` and
|
||||
`docs/self-hosting/portainer/templates.json`). See
|
||||
`docs/self-hosting/truenas.md` for what the catalog template carries.
|
||||
The TrueNAS catalog bot follows new image tags on its own, so a routine
|
||||
release needs nothing there. These changes do need a matching pull request to
|
||||
`truenas/apps` (`ix-dev/community/healthlog/`) in the same release cycle, and
|
||||
an update to `docs/self-hosting/unraid/healthlog.xml` and
|
||||
`docs/self-hosting/portainer/templates.json`:
|
||||
|
||||
- a new required environment variable, or a new format or minimum for an
|
||||
existing one (the form validates the secrets);
|
||||
- a changed listening port, health check path or health semantics, or start
|
||||
time beyond the template's five-minute start period;
|
||||
- a required PostgreSQL major version, or an extension the stock `postgres`
|
||||
image does not ship;
|
||||
- any other change to the compose shape: a new container, a volume the app
|
||||
must write to, a different user.
|
||||
|
||||
The catalog app sets `HEALTHLOG_PLATFORM=truenas`, `PORT`, `HOSTNAME`,
|
||||
`DATABASE_URL`, `ENCRYPTION_KEY`, `API_TOKEN_HMAC_KEY`, `APP_URL`,
|
||||
`NEXT_PUBLIC_APP_URL` and `SESSION_COOKIE_SECURE`; everything else reaches it
|
||||
through the form's additional environment variables. See
|
||||
`docs/self-hosting/truenas.md` for the operator side.
|
||||
|
||||
## Verify the served version
|
||||
|
||||
|
||||
@@ -274,13 +274,13 @@ Encryption: the step shows the key id and a fingerprint (the first 12 hex
|
||||
characters of SHA-256 over the key) and can check a pasted copy without
|
||||
storing it. Admins see a dashboard reminder until the active key is confirmed.
|
||||
|
||||
| Platform | Where the key is set |
|
||||
| --------- | --------------------------------------------------------------- |
|
||||
| Compose | `.env` next to `docker-compose.yml` |
|
||||
| TrueNAS | Apps, HealthLog, Edit, Environment |
|
||||
| Coolify | the application's Environment Variables |
|
||||
| Unraid | the container template (Docker tab, Edit) or the Compose `.env` |
|
||||
| Portainer | the stack's Environment variables |
|
||||
| Platform | Where the key is set |
|
||||
| --------- | -------------------------------------------------------------------------------------------------- |
|
||||
| Compose | `.env` next to `docker-compose.yml` |
|
||||
| TrueNAS | Apps, HealthLog, Edit: the Encryption Key field (`ENCRYPTION_KEY` in a YAML install's environment) |
|
||||
| Coolify | the application's Environment Variables |
|
||||
| Unraid | the container template (Docker tab, Edit) or the Compose `.env` |
|
||||
| Portainer | the stack's Environment variables |
|
||||
|
||||
Set `HEALTHLOG_PLATFORM` (`compose`, `truenas`, `coolify`, `unraid`,
|
||||
`portainer`) to open the step on your platform's instructions.
|
||||
|
||||
+171
-59
@@ -1,82 +1,152 @@
|
||||
# HealthLog on TrueNAS SCALE
|
||||
# HealthLog on TrueNAS
|
||||
|
||||
HealthLog runs on TrueNAS SCALE as a Docker app: one `app` container from
|
||||
`ghcr.io/mbombeck/healthlog` and one PostgreSQL 16 database. The app container
|
||||
is stateless; everything you care about lives in the database dataset and in
|
||||
one value you must keep yourself: the encryption key.
|
||||
HealthLog runs on TrueNAS Community Edition (formerly SCALE) as a Docker app:
|
||||
one container from `ghcr.io/mbombeck/healthlog` and one PostgreSQL database.
|
||||
The app container is stateless. Everything you care about lives in the
|
||||
database dataset and in one value you must keep yourself: the encryption key.
|
||||
|
||||
## What the app needs
|
||||
> **Status: pending catalog review.** A HealthLog app for the TrueNAS
|
||||
> Community train has been prepared but is not listed yet. Until it is, use
|
||||
> [Install via YAML](#before-the-listing-install-via-yaml) at the end of this
|
||||
> page. This note goes away once the app appears under Apps, Discover Apps.
|
||||
|
||||
| Setting | Value |
|
||||
| ------------ | ---------------------------------------------------------------------------------------- |
|
||||
| Image | `ghcr.io/mbombeck/healthlog`, tag `X.Y.Z` (amd64 and arm64) |
|
||||
| Port | `3000` (HTTP) |
|
||||
| User | runs as uid `1001`; no extra capabilities, `no-new-privileges` is fine |
|
||||
| Health check | `GET /api/health`, `200` when ready |
|
||||
| Start period | at least 60 s, with more retries than the image's 5 × 30 s: migrations run at every boot |
|
||||
| Database | PostgreSQL 16 (17 and later are not a drop-in), no extensions |
|
||||
| Volumes | none for the app; the database gets its own dataset |
|
||||
## Install from the Community train
|
||||
|
||||
## Environment
|
||||
1. Generate two secrets. Open the TrueNAS shell (System, Shell) and run the
|
||||
command twice, once for each field:
|
||||
|
||||
| Variable | Required | Notes |
|
||||
| ----------------------- | -------- | ------------------------------------------------------------------------------------------- |
|
||||
| `DATABASE_URL` | yes | `postgresql://<user>:<password>@<host>:5432/<db>` |
|
||||
| `ENCRYPTION_KEY` | yes | 64 hex characters: `openssl rand -hex 32`. Keep a copy outside the NAS (see below). |
|
||||
| `API_TOKEN_HMAC_KEY` | yes | 64 hex characters, generated the same way. Changing it signs everyone out. |
|
||||
| `NEXT_PUBLIC_APP_URL` | yes | the address people open, for example `http://truenas.local:30180` |
|
||||
| `APP_URL` | yes | the same address |
|
||||
| `SESSION_COOKIE_SECURE` | no | `false` for a LAN install served over plain `http://`; leave unset behind an HTTPS proxy |
|
||||
| `HEALTHLOG_PLATFORM` | no | `truenas`: the admin's key backup step opens on the TrueNAS instructions |
|
||||
| `GEOLITE2_DIR` | no | a mounted directory of GeoLite2 `.mmdb` files, so sign-in locations are resolved on the NAS |
|
||||
```bash
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
`HEALTHLOG_PROCESS_TYPE` stays unset (`all`): one container serves the web app
|
||||
and runs the background jobs.
|
||||
Each run prints 64 hex characters. Put both in your password manager
|
||||
before you continue; see [Back up your encryption key](#back-up-your-encryption-key).
|
||||
|
||||
2. Open Apps, Discover Apps, search for **HealthLog** and choose Install.
|
||||
3. Fill in the form:
|
||||
|
||||
| Field | What to enter |
|
||||
| --------------------- | -------------------------------------------------------------------------------------------------------------- |
|
||||
| Timezone | your timezone |
|
||||
| Database Password | any strong password; only the two containers use it |
|
||||
| Encryption Key | the first `openssl rand -hex 32` output |
|
||||
| Token Signing Key | the second output. Changing it later signs everyone out |
|
||||
| App URL | the address you open HealthLog at, with scheme and port, for example `http://192.168.1.10:30536` |
|
||||
| Secure Cookies | leave off when you open HealthLog over `http://` on the LAN; turn on behind an HTTPS reverse proxy (see below) |
|
||||
| WebUI Port | `30536` unless that port is taken |
|
||||
| Postgres Data Storage | the default ixVolume, or a host path on a dataset you snapshot |
|
||||
| Resources | keep memory at 1536 MB or more; the default of 4096 MB is fine |
|
||||
|
||||
4. Install. The first start applies the database migrations before the web
|
||||
server listens, so give it a minute. The app shows as running once
|
||||
`/api/health` answers.
|
||||
5. Open the App URL. The first account you register becomes the admin.
|
||||
|
||||
The App URL has to match the address in the browser: passkeys, sign-in
|
||||
redirects and the links in notifications are built from it. If you later put
|
||||
HealthLog behind a reverse proxy with its own domain, change App URL to that
|
||||
`https://` address.
|
||||
|
||||
Settings the form does not show go under Additional Environment Variables, for
|
||||
example `UPDATE_CHECK_DISABLED=1`. The full list is in
|
||||
[environment.md](environment.md). To use your own GeoLite2 databases, add the
|
||||
directory under Additional Storage and point `GEOLITE2_DIR` at its mount path
|
||||
([geolite2.md](geolite2.md)).
|
||||
|
||||
## Plain HTTP on the LAN
|
||||
|
||||
Most NAS installs are reached over `http://`. Set `SESSION_COOKIE_SECURE=false`
|
||||
for that. If it is left at its default, the browser drops the sign-in cookie
|
||||
and the login page says so before you type a password: "This server only sets
|
||||
its sign-in cookie over https://".
|
||||
Most NAS installs are reached over `http://`. Leave **Secure Cookies** off for
|
||||
that. With it on, the browser drops the sign-in cookie on a plain-HTTP page,
|
||||
and the login page says so before you type a password: "Signing in cannot
|
||||
complete on this page". Turn it on only when HealthLog is served over
|
||||
`https://`, and set App URL to that `https://` address.
|
||||
|
||||
## Back up your encryption key
|
||||
|
||||
HealthLog encrypts tokens, notes, documents and in-database backups with
|
||||
`ENCRYPTION_KEY`. The key is not stored in the database. If the app is deleted
|
||||
and reinstalled with a new key over the old database dataset, the data is
|
||||
HealthLog encrypts tokens, notes, documents and in-database backups with the
|
||||
Encryption Key. The key is not stored in the database. If the app is deleted
|
||||
and installed again with a new key over the old database dataset, the data is
|
||||
still there but cannot be read.
|
||||
|
||||
1. Copy `ENCRYPTION_KEY` from the app's settings (Apps, HealthLog, Edit,
|
||||
Environment) into your password manager.
|
||||
2. Sign in as the admin and open Admin, Encryption. The step shows the key id
|
||||
and a fingerprint, the first 12 hex characters of SHA-256 over the key.
|
||||
Compare it with your copy:
|
||||
TrueNAS keeps the values you typed into the form with the app's configuration
|
||||
on the apps pool. Treat that as a convenience, not as your copy: deleting the
|
||||
app removes it, and a reinstall starts with empty fields. Keep the key in a
|
||||
password manager or another place outside the NAS.
|
||||
|
||||
1. Open Apps, select HealthLog and choose Edit. The key is the Encryption Key
|
||||
field under HealthLog Configuration.
|
||||
2. Sign in as the admin and open Admin, Encryption. The **Back up your
|
||||
encryption key** step shows the active key id and a fingerprint, the first
|
||||
12 hex characters of SHA-256 over the key. Compare it with your copy:
|
||||
|
||||
```bash
|
||||
echo -n "<your key>" | xxd -r -p | sha256sum | cut -c1-12
|
||||
```
|
||||
|
||||
or paste the copy into "Check my copy" (it is compared and dropped, never
|
||||
stored or logged).
|
||||
or paste the copy into **Check my copy**. It is compared and dropped, never
|
||||
stored or logged.
|
||||
|
||||
3. Confirm the backup. Until you do, admins see a reminder on the dashboard.
|
||||
After a key rotation the step comes back for the new key.
|
||||
3. Press **I have backed up this key**. Until you do, admins see a reminder on
|
||||
the dashboard. After a key rotation the step comes back for the new key.
|
||||
|
||||
The catalog app sets `HEALTHLOG_PLATFORM=truenas`, so the step opens on these
|
||||
instructions.
|
||||
|
||||
## Backups and restore
|
||||
|
||||
A restore needs two things: a backup of the database **and** the Encryption
|
||||
Key that was active when it was taken. One without the other gets you nothing
|
||||
back.
|
||||
|
||||
- **Snapshots.** Snapshot the Postgres Data Storage dataset (the ixVolume
|
||||
lives under the apps pool, a host path wherever you put it). Stop the app
|
||||
first, or the snapshot holds a database that was mid-write.
|
||||
- **A dump.** Open a shell on the `postgres` container (Apps, HealthLog,
|
||||
the container's Shell) and run:
|
||||
|
||||
```bash
|
||||
pg_dump -U healthlog healthlog > /var/lib/postgresql/healthlog-$(date +%F).sql
|
||||
```
|
||||
|
||||
The file lands in the Postgres Data Storage dataset, next to the database
|
||||
files, so copy it off the NAS as well.
|
||||
|
||||
- **Off-host.** HealthLog can push encrypted nightly backups to any
|
||||
S3-compatible bucket; see [backup-restore.md](../ops/backup-restore.md).
|
||||
|
||||
To restore a dump, run the app with the Encryption Key the dump was taken
|
||||
under, put the dump file in the Postgres Data Storage dataset, and from the
|
||||
`postgres` container shell replace the database with it:
|
||||
|
||||
```bash
|
||||
psql -U healthlog -d postgres \
|
||||
-c "DROP DATABASE healthlog WITH (FORCE);" \
|
||||
-c "CREATE DATABASE healthlog OWNER healthlog;"
|
||||
psql -v ON_ERROR_STOP=1 -U healthlog healthlog < /var/lib/postgresql/healthlog-YYYY-MM-DD.sql
|
||||
```
|
||||
|
||||
Then restart the app. Restoring over the existing database without dropping
|
||||
it first does not work: the tables the background queue creates on its own
|
||||
refuse a plain overwrite.
|
||||
|
||||
## When the key does not match the database
|
||||
|
||||
At every start HealthLog checks that its key opens the data already in the
|
||||
database. If it does not, it refuses to serve instead of failing on every
|
||||
encrypted value: pages show "HealthLog cannot open its data", API requests
|
||||
answer `503` with `meta.errorCode` `encryption.key_mismatch`, `/api/health`
|
||||
answers `503` with `reason: "encryption_key_mismatch"`, and no background jobs
|
||||
run. TrueNAS shows the app as unhealthy rather than crashed, so the reason
|
||||
stays visible.
|
||||
database. A missing or malformed key stops the container at once with
|
||||
`HealthLog: ERROR — encryption configuration is invalid.` in its log. A
|
||||
well-formed key that does not open the stored data is different: HealthLog
|
||||
refuses to serve instead of failing on every encrypted value. Pages show
|
||||
"HealthLog cannot open its data", API requests answer `503` with
|
||||
`meta.errorCode` `encryption.key_mismatch`, `/api/health` answers `503` with
|
||||
`reason: "encryption_key_mismatch"`, and no background jobs run. TrueNAS
|
||||
shows the app as unhealthy rather than crashed, so the reason stays visible in
|
||||
the app's logs.
|
||||
|
||||
Fix it one of three ways, then restart the app:
|
||||
|
||||
1. Put the original `ENCRYPTION_KEY` back.
|
||||
2. Point `DATABASE_URL` at the database that belongs to this key.
|
||||
1. Put the original Encryption Key back (Apps, HealthLog, Edit).
|
||||
2. Point the app at the database that belongs to this key, for example by
|
||||
restoring that dataset.
|
||||
3. Start fresh with an empty database. The old data and its backups need the
|
||||
old key.
|
||||
|
||||
@@ -91,9 +161,8 @@ checks again at the next start.
|
||||
|
||||
If you are certain the configured key is the one the data was written with,
|
||||
and the app still refuses because the check recorded a different key earlier,
|
||||
remove that record and restart. Open a shell on the database container (Apps,
|
||||
HealthLog, the database container's Shell) and run, with the key id the log
|
||||
names (`v1` unless you use `ENCRYPTION_KEYS`):
|
||||
remove that record and restart. Open a shell on the `postgres` container and
|
||||
run, with the key id the log names (`v1` unless you use `ENCRYPTION_KEYS`):
|
||||
|
||||
```bash
|
||||
psql -U healthlog healthlog \
|
||||
@@ -108,7 +177,7 @@ id: the rotation script removes its record once no values remain under it.
|
||||
|
||||
On a first start there is no record to remove. If the check refuses there and
|
||||
you are certain the key is right, add `ENCRYPTION_KEY_CHECK` = `warn` under
|
||||
Apps, HealthLog, Edit, Environment as a last resort. The check still runs and
|
||||
Additional Environment Variables as a last resort. The check still runs and
|
||||
logs, `/api/health` reports `warning: "encryption_key_mismatch"` without
|
||||
failing, the app serves, and no key is recorded until the check passes. This
|
||||
switches off a safety check: with a wrong key every encrypted value fails and
|
||||
@@ -117,6 +186,49 @@ confirmed.
|
||||
|
||||
## Updates
|
||||
|
||||
Pick the new tag, take a snapshot of the database dataset first, and update.
|
||||
Migrations run on start; the first start of a new install applies all of them,
|
||||
which is why the start period above is generous.
|
||||
The catalog follows HealthLog releases on its own: when a new image is
|
||||
published, the TrueNAS catalog picks it up and the app shows an update. Take a
|
||||
snapshot or a dump first, then update.
|
||||
|
||||
Database migrations run at every start, before the web server listens, and
|
||||
only go forward. The start log says how many are pending. Going back to an
|
||||
older version with Roll Back does not undo them, and an older version may not
|
||||
start against a database a newer one has migrated. The way back is the
|
||||
database backup you took before the update, restored together with the older
|
||||
version.
|
||||
|
||||
The Postgres Image field offers the Postgres major versions the catalog
|
||||
supports. Changing it runs a one-way upgrade of the database files, so back up
|
||||
before you touch it.
|
||||
|
||||
## Before the listing: install via YAML
|
||||
|
||||
Until the catalog app is listed, install HealthLog with Apps, Discover Apps,
|
||||
the menu next to Custom App, Install via YAML, using these values:
|
||||
|
||||
| Setting | Value |
|
||||
| ------------ | ---------------------------------------------------------------------------------------- |
|
||||
| Image | `ghcr.io/mbombeck/healthlog`, tag `X.Y.Z` (amd64 and arm64) |
|
||||
| Port | `3000` (HTTP) |
|
||||
| User | runs as uid `1001` by default; no extra capabilities, `no-new-privileges` is fine |
|
||||
| Health check | `GET /api/health`, `200` when ready |
|
||||
| Start period | at least 60 s, with more retries than the image's 5 × 30 s: migrations run at every boot |
|
||||
| Database | PostgreSQL 16 or 18, no extensions, in its own container with its own dataset |
|
||||
| Volumes | none for the app; the database gets its own dataset |
|
||||
|
||||
| Variable | Required | Notes |
|
||||
| ----------------------- | -------- | ---------------------------------------------------------------------------------------- |
|
||||
| `DATABASE_URL` | yes | `postgresql://<user>:<password>@<host>:5432/<db>` |
|
||||
| `ENCRYPTION_KEY` | yes | 64 hex characters: `openssl rand -hex 32`. Keep a copy outside the NAS. |
|
||||
| `API_TOKEN_HMAC_KEY` | yes | 64 hex characters, generated the same way. Changing it signs everyone out. |
|
||||
| `NEXT_PUBLIC_APP_URL` | yes | the address people open, for example `http://truenas.local:30180` |
|
||||
| `APP_URL` | yes | the same address |
|
||||
| `SESSION_COOKIE_SECURE` | no | `false` for a LAN install served over plain `http://`; leave unset behind an HTTPS proxy |
|
||||
| `HEALTHLOG_PLATFORM` | no | `truenas`: the admin's key backup step opens on the TrueNAS instructions |
|
||||
|
||||
`HEALTHLOG_PROCESS_TYPE` stays unset (`all`): one container serves the web app
|
||||
and runs the background jobs. In a YAML install the key sits in the
|
||||
environment block, so the place to copy it from is `ENCRYPTION_KEY` there.
|
||||
To move from a YAML install to the catalog app later, take a dump, install the
|
||||
catalog app with the same key, and restore the dump as described under
|
||||
[Backups and restore](#backups-and-restore).
|
||||
@@ -610,3 +610,119 @@ for (const vp of [
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A side inset. An iPad in a Safari tab laid the shell out 10 px in from the
|
||||
* left edge in portrait, which no browser under test reports. The shell and
|
||||
* the rail read the side insets through `--safe-area-left` / `-right`
|
||||
* (globals.css), so setting them is the same inset to both.
|
||||
*/
|
||||
const SIDE_INSET = 10;
|
||||
|
||||
async function setSideInsets(page: Page, px: number) {
|
||||
await page.addStyleTag({
|
||||
content: `:root { --safe-area-left: ${px}px !important; --safe-area-right: ${px}px !important; }`,
|
||||
});
|
||||
}
|
||||
|
||||
const SIDE_INSET_VIEWPORTS = [
|
||||
{ name: "iPad Pro 11 portrait", width: 834, height: 1194 },
|
||||
{ name: "iPad Air 11 portrait", width: 820, height: 1180 },
|
||||
{ name: "iPad Air 11 landscape", width: 1180, height: 820 },
|
||||
{ name: "iPad Pro 13 landscape", width: 1366, height: 1024 },
|
||||
] as const;
|
||||
|
||||
for (const vp of SIDE_INSET_VIEWPORTS) {
|
||||
test(`${vp.name}: the rail runs to the left edge over a side inset`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: vp.width, height: vp.height });
|
||||
await openDashboard(page);
|
||||
await setSideInsets(page, SIDE_INSET);
|
||||
await setBottomInset(page, HOME_INDICATOR);
|
||||
await expectShellPinned(page);
|
||||
await expect(page.locator("aside")).toBeVisible();
|
||||
|
||||
const m = await page.evaluate(() => {
|
||||
const aside = document.querySelector("aside")!;
|
||||
const box = (el: Element | null) => {
|
||||
const r = el!.getBoundingClientRect();
|
||||
return { left: r.left, right: r.right, top: r.top, bottom: r.bottom };
|
||||
};
|
||||
// What paints a pixel: the rail, or the page behind the shell (the
|
||||
// root, the body, or one of the shell's own unpainted boxes around the
|
||||
// rail and `<main>`), or some other chrome that meets the edge.
|
||||
const owner = (x: number, y: number) => {
|
||||
const hit = document.elementFromPoint(x, y);
|
||||
if (!hit) return "nothing";
|
||||
if (hit.closest("aside")) return "rail";
|
||||
const unpainted =
|
||||
hit === document.documentElement ||
|
||||
hit === document.body ||
|
||||
(hit.id !== "main-content" &&
|
||||
hit.contains(document.getElementById("main-content")));
|
||||
return unpainted ? `page:${hit.tagName}` : "chrome";
|
||||
};
|
||||
const header = aside.querySelector('[data-slot="sidebar-header"]')!;
|
||||
const account = aside.querySelector(
|
||||
'[data-slot="sidebar-column"]',
|
||||
)!.lastElementChild!;
|
||||
const avatar = account.querySelector('[data-slot="avatar"]')!;
|
||||
const footer = aside.querySelector('[data-slot="sidebar-footer"]')!;
|
||||
const links = [...aside.querySelectorAll("nav a[href]")].map(
|
||||
(a) => a.getBoundingClientRect().left,
|
||||
);
|
||||
const headerBox = box(header);
|
||||
const accountBox = box(account);
|
||||
return {
|
||||
innerWidth,
|
||||
innerHeight,
|
||||
aside: box(aside),
|
||||
header: headerBox,
|
||||
account: accountBox,
|
||||
avatar: box(avatar),
|
||||
logoLeft: box(header.querySelector("svg")).left,
|
||||
footerLeft: box(footer).left,
|
||||
firstLinkLeft: Math.min(...links),
|
||||
// Down the window's first column of pixels, and its last.
|
||||
leftEdge: [
|
||||
(headerBox.top + headerBox.bottom) / 2,
|
||||
innerHeight / 2,
|
||||
(accountBox.top + accountBox.bottom) / 2,
|
||||
innerHeight - 2,
|
||||
].map((y) => owner(1, y)),
|
||||
rightEdge: [
|
||||
(headerBox.top + headerBox.bottom) / 2,
|
||||
innerHeight / 2,
|
||||
].map((y) => owner(innerWidth - 1, y)),
|
||||
};
|
||||
});
|
||||
|
||||
// No strip: the rail paints the inset from the logo band to the bottom
|
||||
// edge, and the top bar and `<main>` (or a docked panel) meet the right
|
||||
// edge.
|
||||
expect(m.leftEdge).toEqual(["rail", "rail", "rail", "rail"]);
|
||||
expect(m.rightEdge).toEqual(["chrome", "chrome"]);
|
||||
expect(m.aside.left).toBe(0);
|
||||
// The band lines run to the edge: the logo band, the account band and
|
||||
// the footer group (whose scroll edge draws a line) start at x = 0.
|
||||
expect(m.header.left).toBe(0);
|
||||
expect(m.account.left).toBe(0);
|
||||
expect(m.footerLeft).toBe(0);
|
||||
// And everything in the rail stays clear of the inset.
|
||||
expect(m.logoLeft).toBeGreaterThanOrEqual(SIDE_INSET);
|
||||
expect(m.firstLinkLeft).toBeGreaterThanOrEqual(SIDE_INSET);
|
||||
expect(m.avatar.left).toBeGreaterThanOrEqual(SIDE_INSET);
|
||||
|
||||
// The account band mirrors the logo band: the same height, the avatar
|
||||
// centred in it, and the home indicator's inset below it.
|
||||
expect(m.account.bottom - m.account.top).toBe(
|
||||
m.header.bottom - m.header.top,
|
||||
);
|
||||
const above = m.avatar.top - m.account.top;
|
||||
const below = m.account.bottom - m.avatar.bottom;
|
||||
expect(Math.abs(above - below)).toBeLessThanOrEqual(1);
|
||||
expect(m.account.bottom).toBe(m.innerHeight - HOME_INDICATOR);
|
||||
expect(m.aside.bottom).toBe(m.innerHeight);
|
||||
});
|
||||
}
|
||||
+1
-1
@@ -8537,7 +8537,7 @@
|
||||
"composeLabel": "Compose",
|
||||
"composeWhere": "Der Schlüssel ist ENCRYPTION_KEY (oder ENCRYPTION_KEYS) in der .env-Datei neben docker-compose.yml.",
|
||||
"truenasLabel": "TrueNAS",
|
||||
"truenasWhere": "Öffne Apps, wähle HealthLog, dann Bearbeiten, und kopiere ENCRYPTION_KEY aus den Umgebungsvariablen.",
|
||||
"truenasWhere": "Öffne Apps, wähle HealthLog, dann Bearbeiten. Der Schlüssel steht im Feld Encryption Key unter HealthLog Configuration; bei einer per YAML installierten App ist es ENCRYPTION_KEY in den Umgebungsvariablen.",
|
||||
"coolifyLabel": "Coolify",
|
||||
"coolifyWhere": "Öffne die Anwendung in Coolify und kopiere ENCRYPTION_KEY aus den Environment Variables.",
|
||||
"unraidLabel": "Unraid",
|
||||
|
||||
+1
-1
@@ -8537,7 +8537,7 @@
|
||||
"composeLabel": "Compose",
|
||||
"composeWhere": "The key is ENCRYPTION_KEY (or ENCRYPTION_KEYS) in the .env file next to docker-compose.yml.",
|
||||
"truenasLabel": "TrueNAS",
|
||||
"truenasWhere": "Open Apps, select HealthLog, choose Edit and copy ENCRYPTION_KEY from the environment settings.",
|
||||
"truenasWhere": "Open Apps, select HealthLog and choose Edit. The key is the Encryption Key field under HealthLog Configuration; in an app installed via YAML, it is ENCRYPTION_KEY in the environment settings.",
|
||||
"coolifyLabel": "Coolify",
|
||||
"coolifyWhere": "Open the application in Coolify and copy ENCRYPTION_KEY from its Environment Variables.",
|
||||
"unraidLabel": "Unraid",
|
||||
|
||||
+1
-1
@@ -8537,7 +8537,7 @@
|
||||
"composeLabel": "Compose",
|
||||
"composeWhere": "La clave es ENCRYPTION_KEY (o ENCRYPTION_KEYS) en el archivo .env junto a docker-compose.yml.",
|
||||
"truenasLabel": "TrueNAS",
|
||||
"truenasWhere": "Abre Apps, selecciona HealthLog, elige Editar y copia ENCRYPTION_KEY de las variables de entorno.",
|
||||
"truenasWhere": "Abre Apps, selecciona HealthLog y elige Editar. La clave está en el campo Encryption Key de HealthLog Configuration; en una app instalada con YAML, es ENCRYPTION_KEY en las variables de entorno.",
|
||||
"coolifyLabel": "Coolify",
|
||||
"coolifyWhere": "Abre la aplicación en Coolify y copia ENCRYPTION_KEY de sus Environment Variables.",
|
||||
"unraidLabel": "Unraid",
|
||||
|
||||
+1
-1
@@ -8537,7 +8537,7 @@
|
||||
"composeLabel": "Compose",
|
||||
"composeWhere": "La clé est ENCRYPTION_KEY (ou ENCRYPTION_KEYS) dans le fichier .env à côté de docker-compose.yml.",
|
||||
"truenasLabel": "TrueNAS",
|
||||
"truenasWhere": "Ouvre Apps, sélectionne HealthLog, choisis Modifier et copie ENCRYPTION_KEY dans les variables d'environnement.",
|
||||
"truenasWhere": "Ouvre Apps, sélectionne HealthLog et choisis Modifier. La clé se trouve dans le champ Encryption Key sous HealthLog Configuration ; pour une app installée via YAML, c'est ENCRYPTION_KEY dans les variables d'environnement.",
|
||||
"coolifyLabel": "Coolify",
|
||||
"coolifyWhere": "Ouvre l'application dans Coolify et copie ENCRYPTION_KEY depuis ses Environment Variables.",
|
||||
"unraidLabel": "Unraid",
|
||||
|
||||
+1
-1
@@ -8537,7 +8537,7 @@
|
||||
"composeLabel": "Compose",
|
||||
"composeWhere": "La chiave è ENCRYPTION_KEY (o ENCRYPTION_KEYS) nel file .env accanto a docker-compose.yml.",
|
||||
"truenasLabel": "TrueNAS",
|
||||
"truenasWhere": "Apri Apps, seleziona HealthLog, scegli Modifica e copia ENCRYPTION_KEY dalle variabili d'ambiente.",
|
||||
"truenasWhere": "Apri Apps, seleziona HealthLog e scegli Modifica. La chiave è nel campo Encryption Key sotto HealthLog Configuration; in un'app installata tramite YAML è ENCRYPTION_KEY nelle variabili d'ambiente.",
|
||||
"coolifyLabel": "Coolify",
|
||||
"coolifyWhere": "Apri l'applicazione in Coolify e copia ENCRYPTION_KEY dalle sue Environment Variables.",
|
||||
"unraidLabel": "Unraid",
|
||||
|
||||
+1
-1
@@ -8537,7 +8537,7 @@
|
||||
"composeLabel": "Compose",
|
||||
"composeWhere": "키는 docker-compose.yml 옆 .env 파일의 ENCRYPTION_KEY(또는 ENCRYPTION_KEYS)입니다.",
|
||||
"truenasLabel": "TrueNAS",
|
||||
"truenasWhere": "Apps에서 HealthLog를 선택하고 Edit을 누른 뒤 환경 설정에서 ENCRYPTION_KEY를 복사하세요.",
|
||||
"truenasWhere": "Apps에서 HealthLog를 선택하고 Edit을 누르세요. 키는 HealthLog Configuration 아래의 Encryption Key 필드에 있습니다. YAML로 설치한 앱이라면 환경 설정의 ENCRYPTION_KEY입니다.",
|
||||
"coolifyLabel": "Coolify",
|
||||
"coolifyWhere": "Coolify에서 애플리케이션을 열고 Environment Variables에서 ENCRYPTION_KEY를 복사하세요.",
|
||||
"unraidLabel": "Unraid",
|
||||
|
||||
+1
-1
@@ -8537,7 +8537,7 @@
|
||||
"composeLabel": "Compose",
|
||||
"composeWhere": "De sleutel is ENCRYPTION_KEY (of ENCRYPTION_KEYS) in het .env-bestand naast docker-compose.yml.",
|
||||
"truenasLabel": "TrueNAS",
|
||||
"truenasWhere": "Open Apps, selecteer HealthLog, kies Edit en kopieer ENCRYPTION_KEY uit de omgevingsinstellingen.",
|
||||
"truenasWhere": "Open Apps, selecteer HealthLog en kies Edit. De sleutel staat in het veld Encryption Key onder HealthLog Configuration; bij een app die via YAML is geïnstalleerd, is het ENCRYPTION_KEY in de omgevingsinstellingen.",
|
||||
"coolifyLabel": "Coolify",
|
||||
"coolifyWhere": "Open de applicatie in Coolify en kopieer ENCRYPTION_KEY uit de Environment Variables.",
|
||||
"unraidLabel": "Unraid",
|
||||
|
||||
+1
-1
@@ -8537,7 +8537,7 @@
|
||||
"composeLabel": "Compose",
|
||||
"composeWhere": "Klucz to ENCRYPTION_KEY (lub ENCRYPTION_KEYS) w pliku .env obok docker-compose.yml.",
|
||||
"truenasLabel": "TrueNAS",
|
||||
"truenasWhere": "Otwórz Apps, wybierz HealthLog, kliknij Edytuj i skopiuj ENCRYPTION_KEY ze zmiennych środowiskowych.",
|
||||
"truenasWhere": "Otwórz Apps, wybierz HealthLog i kliknij Edytuj. Klucz znajduje się w polu Encryption Key w sekcji HealthLog Configuration; w aplikacji zainstalowanej przez YAML to ENCRYPTION_KEY w zmiennych środowiskowych.",
|
||||
"coolifyLabel": "Coolify",
|
||||
"coolifyWhere": "Otwórz aplikację w Coolify i skopiuj ENCRYPTION_KEY z jej Environment Variables.",
|
||||
"unraidLabel": "Unraid",
|
||||
|
||||
+1
-1
@@ -8537,7 +8537,7 @@
|
||||
"composeLabel": "Compose",
|
||||
"composeWhere": "A chave é ENCRYPTION_KEY (ou ENCRYPTION_KEYS) no arquivo .env ao lado do docker-compose.yml.",
|
||||
"truenasLabel": "TrueNAS",
|
||||
"truenasWhere": "Abra Apps, selecione HealthLog, escolha Editar e copie ENCRYPTION_KEY das configurações de ambiente.",
|
||||
"truenasWhere": "Abra Apps, selecione HealthLog e escolha Editar. A chave está no campo Encryption Key em HealthLog Configuration; em um app instalado via YAML, é ENCRYPTION_KEY nas configurações de ambiente.",
|
||||
"coolifyLabel": "Coolify",
|
||||
"coolifyWhere": "Abra o aplicativo no Coolify e copie ENCRYPTION_KEY de suas Variáveis de Ambiente.",
|
||||
"unraidLabel": "Unraid",
|
||||
|
||||
+27
-8
@@ -631,10 +631,13 @@ body,
|
||||
* owns the shell's bottom edge (the rail, `<main>`, the bottom bar, the
|
||||
* Coach button). It is `env()` everywhere it matters; a test that needs an
|
||||
* inset no desktop browser reports sets it instead
|
||||
* (`e2e/tablet-shell.spec.ts`).
|
||||
* (`e2e/tablet-shell.spec.ts`). The side insets are read the same way, by
|
||||
* the shell and the rail (`.shell-safe-area` below).
|
||||
*/
|
||||
:root {
|
||||
--safe-area-bottom: env(safe-area-inset-bottom, 0px);
|
||||
--safe-area-left: env(safe-area-inset-left, 0px);
|
||||
--safe-area-right: env(safe-area-inset-right, 0px);
|
||||
}
|
||||
html[data-scroll="body"] {
|
||||
scrollbar-gutter: stable;
|
||||
@@ -691,18 +694,34 @@ body,
|
||||
@layer components {
|
||||
/*
|
||||
* `.shell-safe-area` — the authenticated app shell's share of the installed
|
||||
* app's safe area (see the note in `auth-shell.tsx`). The shell takes the top
|
||||
* and side insets as padding, and the strip behind the status bar is painted
|
||||
* in the chrome colour (`--card`, the top bar and the sidebar) rather than
|
||||
* the page colour, so the status bar reads as part of the bar beneath it. A
|
||||
* zero inset makes the gradient zero tall: nothing changes in a browser tab.
|
||||
* app's safe area (see the note in `auth-shell.tsx`). The shell takes the
|
||||
* top inset as padding (and the side insets in the phone shell), and the
|
||||
* strip behind the status bar is painted in the chrome colour (`--card`,
|
||||
* the top bar and the sidebar) rather than the page colour, so the status
|
||||
* bar reads as part of the bar beneath it. A zero inset makes the gradient
|
||||
* zero tall.
|
||||
*
|
||||
* The side insets are not always zero in a browser tab: an iPad in a Safari
|
||||
* tab laid the shell out 10 px in from the left edge in portrait, with
|
||||
* Safari's own toolbar buttons moved in by the same 10 px. Padded here, that
|
||||
* inset showed the page colour in a strip left of the rail, from the logo
|
||||
* band down to the account row, and cut the rail's band borders short of the
|
||||
* edge. So the desktop shell leaves the sides to what meets them: the rail
|
||||
* takes the left inset inside its own background (`sidebar-nav.tsx`), and
|
||||
* on the right the top bar and `<main>` run to the edge with gutters wider
|
||||
* than any tablet's side inset. The phone shell has no rail and keeps them
|
||||
* here.
|
||||
*/
|
||||
.shell-safe-area {
|
||||
padding-top: env(safe-area-inset-top, 0px);
|
||||
padding-right: env(safe-area-inset-right, 0px);
|
||||
padding-left: env(safe-area-inset-left, 0px);
|
||||
padding-right: var(--safe-area-right);
|
||||
padding-left: var(--safe-area-left);
|
||||
background: linear-gradient(var(--card), var(--card)) top / 100%
|
||||
env(safe-area-inset-top, 0px) no-repeat;
|
||||
@variant shell-desktop {
|
||||
padding-right: 0;
|
||||
padding-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
@@ -12,6 +12,10 @@ import { readSource } from "@/__tests__/helpers/source-files";
|
||||
|
||||
import {
|
||||
MAIN_SCROLL_CLASS,
|
||||
RAIL_ACCOUNT_BAND,
|
||||
RAIL_WIDTH_COLLAPSED,
|
||||
RAIL_WIDTH_EXPANDED,
|
||||
SHELL_HEADER_BAND,
|
||||
SHELL_ROOT_CLASS,
|
||||
SUB_SHELL_GRID_FLOOR,
|
||||
} from "../shell-metrics";
|
||||
@@ -137,3 +141,53 @@ describe("the bottom edge is cleared in both shells", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The side insets. An iPad in a Safari tab laid the shell out 10 px in from
|
||||
* the left edge in portrait; the shell padded that inset and its strip showed
|
||||
* the page colour beside the rail, from the logo band to the account row.
|
||||
* Desktop browsers report no side inset, so `e2e/tablet-shell.spec.ts`
|
||||
* simulates one through `--safe-area-left` and measures the result; these
|
||||
* hold the decisions that route the inset to the rail.
|
||||
*/
|
||||
describe("the side insets belong to what meets them", () => {
|
||||
const css = () => readSource("src/app/globals.css", ".shell-safe-area {");
|
||||
const rail = () =>
|
||||
readSource(
|
||||
"src/components/layout/sidebar-nav.tsx",
|
||||
"export function SidebarNav",
|
||||
);
|
||||
|
||||
it("are env() behind one property each, which the shell and the rail read", () => {
|
||||
const root = readSource("src/app/globals.css", "--safe-area-left");
|
||||
expect(root).toMatch(
|
||||
/--safe-area-left:\s*env\(safe-area-inset-left,\s*0px\);/,
|
||||
);
|
||||
expect(root).toMatch(
|
||||
/--safe-area-right:\s*env\(safe-area-inset-right,\s*0px\);/,
|
||||
);
|
||||
expect(css()).not.toMatch(/padding-(?:left|right):\s*env\(/);
|
||||
});
|
||||
|
||||
it("the desktop shell pads no side, the phone shell pads both", () => {
|
||||
const block = /\.shell-safe-area\s*\{([\s\S]*?)\n \}/.exec(css())![1];
|
||||
expect(block).toMatch(/padding-left:\s*var\(--safe-area-left\);/);
|
||||
expect(block).toMatch(/padding-right:\s*var\(--safe-area-right\);/);
|
||||
expect(block).toMatch(
|
||||
/@variant shell-desktop\s*\{\s*padding-right:\s*0;\s*padding-left:\s*0;\s*\}/,
|
||||
);
|
||||
});
|
||||
|
||||
it("the rail takes the left inset inside its own background", () => {
|
||||
const source = rail();
|
||||
expect(source).toMatch(/<aside[\s\S]*?pl-\(--safe-area-left\)/);
|
||||
expect(RAIL_WIDTH_COLLAPSED).toBe("w-[calc(4rem+var(--safe-area-left))]");
|
||||
expect(RAIL_WIDTH_EXPANDED).toBe("w-[calc(16rem+var(--safe-area-left))]");
|
||||
});
|
||||
|
||||
it("the account band mirrors the logo band", () => {
|
||||
const height = (value: string) => /\bh-(\d+)\b/.exec(value)?.[1];
|
||||
expect(height(RAIL_ACCOUNT_BAND)).toBe(height(SHELL_HEADER_BAND));
|
||||
expect(RAIL_ACCOUNT_BAND).toContain("border-t");
|
||||
});
|
||||
});
|
||||
@@ -56,6 +56,7 @@ vi.mock("@/hooks/use-mounted", () => ({
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { RAIL_WIDTH_COLLAPSED } from "../shell-metrics";
|
||||
import {
|
||||
SIDEBAR_EXPANDED_CONTENT_WIDTH,
|
||||
SidebarCollapseToggle,
|
||||
@@ -559,7 +560,7 @@ describe("<SidebarNav> width transition never overflows the nav", () => {
|
||||
});
|
||||
mockMountedRef.value = true;
|
||||
const html = render();
|
||||
expect(asideClass(html).split(" ")).toContain("w-16");
|
||||
expect(asideClass(html).split(" ")).toContain(RAIL_WIDTH_COLLAPSED);
|
||||
expect(columnClass(html).split(" ")).not.toContain(
|
||||
SIDEBAR_EXPANDED_CONTENT_WIDTH,
|
||||
);
|
||||
|
||||
@@ -91,3 +91,52 @@ export const MAIN_SCROLL_CLASS =
|
||||
* widths in both the expanded and the collapsed rail.
|
||||
*/
|
||||
export const SHELL_HEADER_BAND = "h-16 border-b";
|
||||
|
||||
/**
|
||||
* The rail's account band, the bottom counterpart of its logo band: the same
|
||||
* height, the border on the other side, the avatar centred in it. Padded on
|
||||
* its own (`p-3`, `p-2` on short windows) it came out 61 to 69 px tall with
|
||||
* 18 px above the avatar and more below, and its divider sat at no height
|
||||
* the logo band shares. The home-indicator inset stays under the band (the
|
||||
* rail's `pb-(--safe-area-bottom)`), so the band itself never shifts.
|
||||
*/
|
||||
export const RAIL_ACCOUNT_BAND = "h-16 shrink-0 border-t";
|
||||
|
||||
/**
|
||||
* The rail's width, collapsed (4rem) and expanded (16rem), plus the window's
|
||||
* left inset. The rail runs to the window's left edge and takes that inset as
|
||||
* padding inside its own background (`pl-(--safe-area-left)` on the
|
||||
* `<aside>`), so its content keeps the 4rem / 16rem it was laid out for and
|
||||
* stays clear of the inset. The shell leaves the left side to the rail
|
||||
* (`.shell-safe-area`, globals.css): an iPad in a Safari tab laid the shell
|
||||
* out 10 px in from the left, and that strip showed the page colour beside
|
||||
* the rail. Zero inset: exactly `w-16` / `w-64`.
|
||||
*/
|
||||
export const RAIL_WIDTH_COLLAPSED = "w-[calc(4rem+var(--safe-area-left))]";
|
||||
export const RAIL_WIDTH_EXPANDED = "w-[calc(16rem+var(--safe-area-left))]";
|
||||
|
||||
/**
|
||||
* A rail band that draws a border across the rail (the logo band, the footer
|
||||
* group's scroll edge, the account band) reaches back over the left inset, so
|
||||
* its line runs to the window's edge like the top bar's does, and adds the
|
||||
* inset to its own left padding, so its content stays where it was.
|
||||
*/
|
||||
const RAIL_BAND_BLEED = "-ml-(--safe-area-left)";
|
||||
|
||||
/** The logo band's and the footer group's horizontal padding, inset included. */
|
||||
export const RAIL_BAND_INSET = {
|
||||
header: {
|
||||
collapsed: `${RAIL_BAND_BLEED} pr-3 pl-[calc(0.75rem+var(--safe-area-left))]`,
|
||||
expanded: `${RAIL_BAND_BLEED} pr-6 pl-[calc(1.5rem+var(--safe-area-left))]`,
|
||||
},
|
||||
footer: {
|
||||
collapsed: `${RAIL_BAND_BLEED} pr-1.5 pl-[calc(0.375rem+var(--safe-area-left))]`,
|
||||
expanded: `${RAIL_BAND_BLEED} pr-3 pl-[calc(0.75rem+var(--safe-area-left))]`,
|
||||
},
|
||||
} as const;
|
||||
|
||||
/** The account band, collapsed (the avatar centred) and expanded. */
|
||||
export const RAIL_ACCOUNT_BAND_CLASS = {
|
||||
collapsed: `${RAIL_ACCOUNT_BAND} ${RAIL_BAND_BLEED} border-sidebar-border flex items-center justify-center pl-(--safe-area-left)`,
|
||||
expanded: `${RAIL_ACCOUNT_BAND} ${RAIL_BAND_BLEED} border-sidebar-border flex items-center pr-3 pl-[calc(0.75rem+var(--safe-area-left))]`,
|
||||
} as const;
|
||||
@@ -26,7 +26,13 @@ import {
|
||||
visibleNavDestinations,
|
||||
visibleUtilityDestinations,
|
||||
} from "@/components/layout/nav-model";
|
||||
import { SHELL_HEADER_BAND } from "@/components/layout/shell-metrics";
|
||||
import {
|
||||
RAIL_ACCOUNT_BAND_CLASS,
|
||||
RAIL_BAND_INSET,
|
||||
RAIL_WIDTH_COLLAPSED,
|
||||
RAIL_WIDTH_EXPANDED,
|
||||
SHELL_HEADER_BAND,
|
||||
} from "@/components/layout/shell-metrics";
|
||||
import {
|
||||
RailNavList,
|
||||
useNavOverflows,
|
||||
@@ -268,10 +274,8 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) {
|
||||
if (collapsed) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"border-sidebar-border border-t p-3",
|
||||
"[@media(max-height:940px)]:p-2",
|
||||
)}
|
||||
data-slot="sidebar-account"
|
||||
className={RAIL_ACCOUNT_BAND_CLASS.collapsed}
|
||||
>
|
||||
<div className="flex items-center justify-center">
|
||||
<DropdownMenu>
|
||||
@@ -294,8 +298,11 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-sidebar-border border-t p-3">
|
||||
<div className="flex items-center gap-3 px-2 py-1">
|
||||
<div
|
||||
data-slot="sidebar-account"
|
||||
className={RAIL_ACCOUNT_BAND_CLASS.expanded}
|
||||
>
|
||||
<div className="flex w-full min-w-0 items-center gap-3 px-2 py-1">
|
||||
{/* The avatar + name in the expanded footer routes straight
|
||||
into the account settings; the kebab to the right still opens
|
||||
the rest of the user menu. */}
|
||||
@@ -343,7 +350,8 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) {
|
||||
*
|
||||
* Steps (rows are full-width, so the click target is the whole row):
|
||||
* - default: 40 px rows, 4 px apart
|
||||
* - max-height 940 px: gaps close (40 px pitch), list and user padding tighten
|
||||
* - max-height 940 px: gaps close (40 px pitch), the list padding tightens
|
||||
* (the account band keeps the logo band's height, `RAIL_ACCOUNT_BAND`)
|
||||
* - max-height 820 px: 36 px rows
|
||||
* - max-height 740 px: 32 px rows — the one step under 40 px, taken only
|
||||
* where the alternative is rows hidden behind a scroll edge
|
||||
@@ -627,8 +635,8 @@ export function SidebarNav() {
|
||||
<aside
|
||||
aria-label={t("nav.sidebar")}
|
||||
className={cn(
|
||||
"bg-sidebar border-sidebar-border shell-desktop:flex shell-desktop:flex-col relative hidden h-full flex-shrink-0 overflow-hidden border-r pb-(--safe-area-bottom) transition-[width] duration-200 motion-reduce:transition-none",
|
||||
collapsed ? "w-16" : "w-64",
|
||||
"bg-sidebar border-sidebar-border shell-desktop:flex shell-desktop:flex-col relative hidden h-full flex-shrink-0 overflow-hidden border-r pb-(--safe-area-bottom) pl-(--safe-area-left) transition-[width] duration-200 motion-reduce:transition-none",
|
||||
collapsed ? RAIL_WIDTH_COLLAPSED : RAIL_WIDTH_EXPANDED,
|
||||
)}
|
||||
>
|
||||
{/* The rail animates its width; the content does not. Expanded, this
|
||||
@@ -656,7 +664,7 @@ export function SidebarNav() {
|
||||
className={cn(
|
||||
"border-sidebar-border",
|
||||
SHELL_HEADER_BAND,
|
||||
collapsed ? "px-3" : "px-6",
|
||||
RAIL_BAND_INSET.header[collapsed ? "collapsed" : "expanded"],
|
||||
)}
|
||||
>
|
||||
<Link
|
||||
@@ -755,7 +763,7 @@ export function SidebarNav() {
|
||||
className={cn(
|
||||
RAIL_LIST_GAP,
|
||||
"pb-1",
|
||||
collapsed ? "px-1.5" : "px-3",
|
||||
RAIL_BAND_INSET.footer[collapsed ? "collapsed" : "expanded"],
|
||||
navOverflows && "border-sidebar-border border-t pt-1",
|
||||
)}
|
||||
>
|
||||
|
||||
Reference in new issue
Block a user