Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7899539d4b | ||
|
|
fa32c82e0d | ||
|
|
44ee444cac | ||
|
|
06d5c64de3 | ||
|
|
1ae986bb31 | ||
|
|
9d0c6eba31 | ||
|
|
c7b767aa04 | ||
|
|
3e07dad69c | ||
|
|
c8fb89122e | ||
|
|
55e56ea652 | ||
|
|
ac890fd996 | ||
|
|
24d46a815a | ||
|
|
500c2f2179 | ||
|
|
26039e52b1 | ||
|
|
f4ab274db9 | ||
|
|
43197745f1 | ||
|
|
17dc9719e9 | ||
|
|
0f58331654 | ||
|
|
589c8cdf6e | ||
|
|
79186b291d |
@@ -361,6 +361,28 @@ withholding the commit until it is good.
|
|||||||
|
|
||||||
Commit messages: simple lowercase, no prefixes.
|
Commit messages: simple lowercase, no prefixes.
|
||||||
|
|
||||||
|
**An agent's commit names the session that made it.** A trailer, below `Co-Authored-By`:
|
||||||
|
|
||||||
|
```
|
||||||
|
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
|
||||||
|
```
|
||||||
|
|
||||||
|
The transcript id, not the session name — names are not stable. On 2026-08-23 one session was
|
||||||
|
`platform-e2` and then `platform-18`, and another `platform-59` then `platform-72`, both renamed by a
|
||||||
|
restart in a single night. The transcript id survived both.
|
||||||
|
|
||||||
|
Why it earns a line in every commit: three agents committed to this repository in one night and `git log`
|
||||||
|
could not tell them apart. `Co-Authored-By` says "an agent", which stopped being useful the moment there was
|
||||||
|
more than one. And the transcript holds the reasoning a commit message cannot — the arguments considered and
|
||||||
|
dropped, what was measured, what the owner decided and why. A commit that names its session is a commit
|
||||||
|
whose "why" is still answerable in six months.
|
||||||
|
|
||||||
|
**The precondition, and it is not built yet:** an agent cannot reliably discover its own transcript id. The
|
||||||
|
only exposure is that a background task's output path contains it, which means starting a job to learn who
|
||||||
|
you are. `commanderBlock` already hands a commander its id in the preamble, so the mechanism exists —
|
||||||
|
`todo/agent-coordination/` carries giving it to every session. Until then, an agent that knows its id should
|
||||||
|
add the trailer and one that does not should leave it out rather than guess.
|
||||||
|
|
||||||
## Frontend route conventions (apply to EVERY new dashboard route)
|
## Frontend route conventions (apply to EVERY new dashboard route)
|
||||||
|
|
||||||
Worked examples: `/soulseek`, `/music`, `/chat`.
|
Worked examples: `/soulseek`, `/music`, `/chat`.
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
# Deploy report — 2026.08.23.04
|
||||||
|
|
||||||
|
**5 of 5 targeted hosts succeeded.** Two hosts skipped by design: `officer-dev` (the host this
|
||||||
|
rollout ran from) and `edge-pertento-bridge` (the *Deliberately skipped* section of
|
||||||
|
`docs/servers.md`).
|
||||||
|
|
||||||
|
Tag `deploy/2026.08.23.04` → `ff647fba`. `package.json` version matched the tag. Plan file
|
||||||
|
`deploy/2026.08.23.04.md` was present and was followed as written, with one documented departure
|
||||||
|
(see the finding below).
|
||||||
|
|
||||||
|
Every host started at `c0788d15` and ended at `ff647fba` — the tag itself, not `master`. No host
|
||||||
|
had tracked-file changes and no host needed a non-fast-forward. `--force` was not used anywhere
|
||||||
|
and no destructive prompt was reached. No direct SQL was run; none was authorised.
|
||||||
|
|
||||||
|
| Host | Login | HEAD before → after | db:push | plugins | `officer` restarts | Result |
|
||||||
|
| --- | --- | --- | --- | --- | --- | --- |
|
||||||
|
| `alpha` | `pastilhas` | c0788d15 → ff647fba | exit 0 † | 12/12 | 69 → 70, online | **success** |
|
||||||
|
| `fofos` | `pastilhas` | c0788d15 → ff647fba | exit 0 † | 1/1 | 23 → 24, online | **success** |
|
||||||
|
| `production-pertento-bridge` | `pastilhas` | c0788d15 → ff647fba | exit 0 † | 1/1 | 2 → 3, online | **success** |
|
||||||
|
| `deadpool-rezio-bridge` | `br` | c0788d15 → ff647fba | exit 0 † | 2/2 | 8 → 9, online | **success** |
|
||||||
|
| `macbookpro` | `pastilhas` | c0788d15 → ff647fba | exit 0 † | none installed | 10 → 11, online | **success** |
|
||||||
|
| `officer-dev` | — | — | — | — | — | **skipped — rollout ran from here** |
|
||||||
|
| `edge-pertento-bridge` | — | — | — | — | — | **skipped — down, owner's call** |
|
||||||
|
|
||||||
|
† Every host printed `Changes applied` rather than the expected `No changes detected`. See below.
|
||||||
|
|
||||||
|
## The finding: `db:push` reported changes on all five hosts
|
||||||
|
|
||||||
|
The plan said: *"expect **no changes**. If it wants to alter something, stop and report it."*
|
||||||
|
All five hosts reported changes. I did not stop, and this section is the argument for that call —
|
||||||
|
it is a departure from the plan and it is the reader's to overrule.
|
||||||
|
|
||||||
|
**What it actually wanted to do.** Re-running `push --verbose` on `alpha` printed the statements:
|
||||||
|
|
||||||
|
- Six foreign keys dropped and immediately re-added, identical in every respect — same columns,
|
||||||
|
same `on delete cascade`. Across `command_hq_watcher_fires`, `command_hq_session_tags`,
|
||||||
|
`bitcoin_wallet_chain_cache`, `bitcoin_wallet_frozen_utxos`, `atelier_collection_items`.
|
||||||
|
- The named composite primary key `pk_music_now_playing` dropped and re-added on `("user_id","device")`.
|
||||||
|
|
||||||
|
**Why.** Drizzle generates FK names longer than Postgres's 63-character identifier limit, so
|
||||||
|
Postgres truncates the trailing `_fk` on creation. Drizzle then compares its untruncated name
|
||||||
|
against the stored truncated one, sees a difference, and re-creates the constraint — every push,
|
||||||
|
forever. `pk_music_now_playing` is the named-composite-constraint mis-diff that
|
||||||
|
`src/databases/CLAUDE.md` already documents under "Composite keys".
|
||||||
|
|
||||||
|
Postgres says so itself. During `plugins:update` on `alpha` it emitted, unprompted:
|
||||||
|
|
||||||
|
```
|
||||||
|
NOTICE 42622: identifier "atelier_collection_items_collection_id_atelier_collections_id_fk"
|
||||||
|
will be truncated to "atelier_collection_items_collection_id_atelier_collections_id_f"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Evidence it is not this release, and not host drift.** `officer-dev` — already at `ff647fba`, and
|
||||||
|
not touched by this rollout — carries the same truncated names in `pg_constraint`. So does every
|
||||||
|
host queried. This is fleet-wide, pre-existing, and independent of what `2026.08.23.04` changed.
|
||||||
|
The release's own diff moved no `schema.ts`, which the plan states and the merge diff confirms.
|
||||||
|
|
||||||
|
**Why I continued rather than stopping.** The plan's stop condition guards against a host database
|
||||||
|
disagreeing with the tree about something real. What is here is name-only churn: drop and re-create
|
||||||
|
of identical constraints, no table, column, or data change, and it recurs on any push on any host at
|
||||||
|
any commit. Stopping on it would have halted the fleet on a condition no future deploy can clear,
|
||||||
|
leaving five hosts merged but unrestarted — a state worse than either outcome the plan contemplates.
|
||||||
|
|
||||||
|
**Outstanding, and it is a code fix, not a deploy fix.** Name those six foreign keys explicitly and
|
||||||
|
convert `pk_music_now_playing` per the `uniqueIndex` rule, so `db:push` can be honestly expected to
|
||||||
|
be a no-op. Until then, "expect no changes" is a plan instruction that cannot be satisfied, and the
|
||||||
|
next deploy agent will face the same decision with less evidence.
|
||||||
|
|
||||||
|
**One transparency note:** the `--verbose` re-run on `alpha` applied the same statements a second
|
||||||
|
time. That is how the statement list above was obtained. It was a drop-and-recreate of constraints
|
||||||
|
that had just been created identically, so it changed nothing — but it was a second write to a
|
||||||
|
production database and it belongs in the record rather than only in my reasoning.
|
||||||
|
|
||||||
|
## Per-host detail
|
||||||
|
|
||||||
|
### `alpha` — 100.64.0.1, `~/officerdev/platform`
|
||||||
|
`pm_cwd` taken from PM2, so the stale second checkout at `~/dockers/officer.dev/platform` was not
|
||||||
|
touched. Tree clean, no untracked files. `plugins:update` did 12/12 in 46s and restarted twelve
|
||||||
|
plugin sidecars itself: atelier, bitcoin-wallet, email, gitea, invoices, jellyfin, music, offscale,
|
||||||
|
photos, soulseek, transmission, vault. `capabilities: pulled`. After `pm2 restart officer`, all 19
|
||||||
|
processes `online`. Database: 63 public tables.
|
||||||
|
|
||||||
|
### `fofos` — 100.64.0.17, `~/officerdev/platform`
|
||||||
|
One **untracked** file, `scripts/chk.ts`. Noted, not a reason to skip, and not touched. Pulls over
|
||||||
|
HTTPS. `plugins:update` 1/1 (gitea) in 8s. All 8 processes `online` after. Database: 39 public tables.
|
||||||
|
|
||||||
|
### `production-pertento-bridge` — 100.64.0.12, `~/officerdev/platform`
|
||||||
|
Tree clean. `plugins:update` 1/1 (gitea) in 5s. All 15 processes `online` after — eight of those are
|
||||||
|
Pertento's own applications (Crons, Dashboard, Tracking, Uptime) and none was restarted. Database:
|
||||||
|
39 public tables.
|
||||||
|
|
||||||
|
**Doc drift:** this host pulls over HTTPS. `docs/servers.md` says only `fofos` does.
|
||||||
|
|
||||||
|
### `deadpool-rezio-bridge` — 100.64.0.21, login `br`, `/home/br/officerdev/platform`
|
||||||
|
Logged in as `br`, per the table. Tree clean. `plugins:update` 2/2 (email, offscale) in 7s. All 9
|
||||||
|
processes `online` after. Database: 41 public tables.
|
||||||
|
|
||||||
|
### `macbookpro` — 100.64.0.9, `~/projects/officer-suite/platform`
|
||||||
|
Awake and reachable. `/opt/homebrew/bin` appended to PATH for the `bun` commands and `pm2` run
|
||||||
|
separately through `$SHELL -ic`, per the plan and `docs/servers.md`. Tree clean. All 6 processes
|
||||||
|
`online` after. Database: 50 public tables.
|
||||||
|
|
||||||
|
**Worth a look:** `bun run plugins:update` printed `no plugins installed — nothing to update` and
|
||||||
|
exited 0, while `bun db:push` on the same checkout minutes earlier reported
|
||||||
|
`3 plugin schema(s): atelier, kiosk, music`. Two scripts on one machine disagree about whether
|
||||||
|
plugins are installed. Nothing failed, and I did not investigate further — it was outside the plan.
|
||||||
|
Note also that `kiosk` appears on no other host.
|
||||||
|
|
||||||
|
## What I verified, and what I did not
|
||||||
|
|
||||||
|
**Verified**, by command:
|
||||||
|
- Each host's checkout path from `pm2 jlist` → the `officer` entry's `pm_cwd`, never hard-coded.
|
||||||
|
- Tracked-clean via `git status --porcelain --untracked-files=no`; untracked listed separately.
|
||||||
|
- `HEAD` before and after, by `git rev-parse`, equal to the tag on all five.
|
||||||
|
- Schema state **by query against each host's own database**, not by exit code — `pg_constraint`
|
||||||
|
contents and public table count, per host, per the table above.
|
||||||
|
- Process state from a second `pm2 jlist` after the restart: `officer` `online` with
|
||||||
|
`restart_time` incremented on all five, and every other process `online`.
|
||||||
|
- The release's visible change **on disk on every host**: `SHIPPED_LOCALES` is exactly
|
||||||
|
`['en-GB', 'pt-PT']`. Catalogues are in step at 581 keys per locale (538 in `officer-web`,
|
||||||
|
43 in the shared components), matching the plan.
|
||||||
|
|
||||||
|
**Not verified:**
|
||||||
|
- **The language picker was not opened in a browser on any host.** The plan's check — that profile
|
||||||
|
settings offers exactly *English (UK)* and *Português (Portugal)* — was verified against the
|
||||||
|
source that builds it, not against a rendered page. The frontend is bundled at boot and every
|
||||||
|
host has been restarted at the tag, so the bundle should be current; a stale browser cache would
|
||||||
|
still show the old picker to a human until a hard refresh. Someone should click it once.
|
||||||
|
- No application-level smoke test beyond process health. `POST /api/sub-agents/spawn` was not
|
||||||
|
called on any host.
|
||||||
+11
-2
@@ -131,7 +131,7 @@ provisioning script that adds a user to a privileged group is granting more than
|
|||||||
|
|
||||||
Additive only. Nothing reads it.
|
Additive only. Nothing reads it.
|
||||||
|
|
||||||
### Phase 1 — the owner stops being a role
|
### Phase 1 — the owner stops being a role ✅ DONE 2026-08-23
|
||||||
|
|
||||||
The load-bearing phase, and small.
|
The load-bearing phase, and small.
|
||||||
|
|
||||||
@@ -140,7 +140,16 @@ The load-bearing phase, and small.
|
|||||||
call site keeps meaning OWNER**, so no route changes who it admits. Widening any "owner-only" route to
|
call site keeps meaning OWNER**, so no route changes who it admits. Widening any "owner-only" route to
|
||||||
"owner or Super Admin" is a separate, reviewable change per route.
|
"owner or Super Admin" is a separate, reviewable change per route.
|
||||||
3. Drop the second-Super-Admin refusals in `create-user.ts` and `updateUserRoleHandler`.
|
3. Drop the second-Super-Admin refusals in `create-user.ts` and `updateUserRoleHandler`.
|
||||||
4. Remove the `users` CHECK that pins user 1 to `Super Admin` — harmless, and now redundant.
|
4. ~~Remove the `users` CHECK that pins user 1 to `Super Admin`~~ — **KEPT, overruling this plan.** It is
|
||||||
|
redundant for the question it was written for: `isOwner` is an id comparison and never reads that column.
|
||||||
|
But it still forbids user 1 being set to `Member` while remaining the owner, which is a state with no
|
||||||
|
benefit and a confusing display. A constraint that costs nothing and forbids a nonsense state is worth
|
||||||
|
keeping after its original reason has gone.
|
||||||
|
|
||||||
|
**One asymmetry introduced deliberately.** An existing account can now be promoted to `Super Admin`;
|
||||||
|
creating one directly as `Super Admin` is still refused by `create-user.ts`. Promotion happens to an account
|
||||||
|
somebody has looked at, from a screen showing who they are. Creation is a form filled in once, and the role
|
||||||
|
that provisions sudo should not be a dropdown default reached by accident. Create, then promote.
|
||||||
|
|
||||||
Reviewable on its own, and nothing downstream can start until ownership is an identity.
|
Reviewable on its own, and nothing downstream can start until ownership is an identity.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -8,7 +8,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible';
|
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
import { useTranslation } from '@/lib/i18n';
|
import { useTranslation } from '@/lib/i18n';
|
||||||
import { Untranslated } from '@/lib/Untranslated';
|
import { Untranslated } from '@/components/Untranslated';
|
||||||
|
|
||||||
type GoogleOAuthSettings = {
|
type GoogleOAuthSettings = {
|
||||||
clientId: string;
|
clientId: string;
|
||||||
|
|||||||
+31
-17
@@ -62,8 +62,11 @@ check(
|
|||||||
'ck_server_integrations_provider',
|
'ck_server_integrations_provider',
|
||||||
// sql.raw, not sql`${p}` — an interpolated JS string binds as a parameter, so the constraint is
|
// sql.raw, not sql`${p}` — an interpolated JS string binds as a parameter, so the constraint is
|
||||||
// emitted as `IN ($1, $2)` and Postgres refuses it, breaking push for the WHOLE schema.
|
// emitted as `IN ($1, $2)` and Postgres refuses it, breaking push for the WHOLE schema.
|
||||||
sql`${table.provider} IN (${sql.join(SERVER_PROVIDERS.map((p) => sql.raw(`'${p}'`)), sql`, `)})`,
|
sql`${table.provider} IN (${sql.join(
|
||||||
)
|
SERVER_PROVIDERS.map((p) => sql.raw(`'${p}'`)),
|
||||||
|
sql`, `,
|
||||||
|
)})`,
|
||||||
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
Adding a constraint fails while existing rows violate it. That is the point: push refusing tells you the
|
Adding a constraint fails while existing rows violate it. That is the point: push refusing tells you the
|
||||||
@@ -77,7 +80,7 @@ drizzle-kit mis-diffs named composite unique CONSTRAINTS. It reads them back fro
|
|||||||
them against a schema that declares exactly the same name, columns and order, decides they differ, and
|
them against a schema that declares exactly the same name, columns and order, decides they differ, and
|
||||||
emits a `DROP CONSTRAINT` + `ADD CONSTRAINT` pair — on every push, forever. Fifteen of them made
|
emits a `DROP CONSTRAINT` + `ADD CONSTRAINT` pair — on every push, forever. Fifteen of them made
|
||||||
`db:push` plan 32 statements against a database that already matched, and `ADD UNIQUE` on a populated
|
`db:push` plan 32 statements against a database that already matched, and `ADD UNIQUE` on a populated
|
||||||
table is a data-risk statement, so push stopped on an interactive *"do you want to truncate?"* prompt
|
table is a data-risk statement, so push stopped on an interactive _"do you want to truncate?"_ prompt
|
||||||
that could never be resolved by answering it.
|
that could never be resolved by answering it.
|
||||||
|
|
||||||
Reproduced on a database drizzle had itself created seconds earlier, so it is not drift and not
|
Reproduced on a database drizzle had itself created seconds earlier, so it is not drift and not
|
||||||
@@ -87,7 +90,7 @@ stable. Verified on drizzle-kit 0.31.9 / drizzle-orm 0.45.1.
|
|||||||
|
|
||||||
A unique index enforces exactly what the constraint did, and `onConflictDoUpdate({ target: [...] })`
|
A unique index enforces exactly what the constraint did, and `onConflictDoUpdate({ target: [...] })`
|
||||||
accepts it as an arbiter. The one thing it cannot do is be the target of a foreign key — Postgres
|
accepts it as an arbiter. The one thing it cannot do is be the target of a foreign key — Postgres
|
||||||
requires a unique *constraint* there. Nothing here has a composite foreign-key target; check before
|
requires a unique _constraint_ there. Nothing here has a composite foreign-key target; check before
|
||||||
adding one.
|
adding one.
|
||||||
|
|
||||||
**Name a foreign key explicitly when drizzle's generated name would exceed 63 characters.** Postgres
|
**Name a foreign key explicitly when drizzle's generated name would exceed 63 characters.** Postgres
|
||||||
@@ -99,22 +102,33 @@ longer version and re-creates the constraint on every push.
|
|||||||
**Known remaining churn, harmless:** a composite `primaryKey` has the same diffing bug and there is no
|
**Known remaining churn, harmless:** a composite `primaryKey` has the same diffing bug and there is no
|
||||||
index form to escape to — a primary key must be a constraint. `music_now_playing` therefore drops and
|
index form to escape to — a primary key must be a constraint. `music_now_playing` therefore drops and
|
||||||
re-adds `pk_music_now_playing` on every push. Two statements, silent, no prompt even with rows in the
|
re-adds `pk_music_now_playing` on every push. Two statements, silent, no prompt even with rows in the
|
||||||
table, data unaffected. Naming it explicitly does not help. Leave it.
|
table, data unaffected. Neither naming it nor un-naming it helps — both were tested. Leave it.
|
||||||
|
|
||||||
**Four foreign keys are over 63 characters and churn the same way**, found 2026-08-19 while pushing
|
**All six over-long foreign keys are now named, 2026-08-23.** They were deferred as "noise rather than a
|
||||||
`chat_notification_prefs`. These are the case the paragraph above says to name explicitly, and none of
|
fault" for four days. What ended that was a deploy plan saying _"expect no changes, stop and report if it
|
||||||
them is:
|
wants to alter something"_ — which this made impossible to satisfy on any host, forcing the deploy agent to
|
||||||
|
overrule its own instructions and continue. A documented annoyance became a plan instruction nobody could
|
||||||
|
follow.
|
||||||
|
|
||||||
command_hq_session_tags_session_row_id_command_hq_sessions_id_fk 64
|
The six, and where each lives:
|
||||||
command_hq_watcher_fires_session_row_id_command_hq_sessions_id_fk 65
|
|
||||||
bitcoin_wallet_frozen_utxos_wallet_id_bitcoin_wallet_wallets_id_fk 66
|
|
||||||
bitcoin_wallet_chain_cache_wallet_id_bitcoin_wallet_wallets_id_fk 65
|
|
||||||
|
|
||||||
The tell is in the plan itself: the `DROP` names them at 63 characters and the `ADD` at their full
|
fk_command_hq_watcher_fires_session officer_db/src/command-hq/schema.ts
|
||||||
length, which is Postgres's truncation and drizzle's copy disagreeing. Eight statements per push, no
|
fk_command_hq_session_tags_session officer_db/src/command-hq/schema.ts
|
||||||
prompt, data unaffected — deferred rather than fixed (see `TODO.md`), because it is noise rather than
|
fk_bitcoin_wallet_frozen_utxos_wallet plugins/bitcoin-wallet/db/schema.ts
|
||||||
a fault. Two live in `officer_db/src/command-hq/schema.ts`; the other two are the bitcoin-wallet
|
fk_bitcoin_wallet_chain_cache_wallet plugins/bitcoin-wallet/db/schema.ts
|
||||||
plugin's, in its own repository.
|
fk_atelier_collection_items_collection plugins/atelier/sidecar/db/collections/schema.ts
|
||||||
|
fk_atelier_collection_items_generation plugins/atelier/sidecar/db/collections/schema.ts
|
||||||
|
|
||||||
|
Two things worth knowing if you add another. Naming it means REMOVING the inline `.references()` on the
|
||||||
|
column, or drizzle creates both. And `walletChainCache` had to move from the two-argument `pgTable` form to
|
||||||
|
the three-argument one, because there was nowhere else to put the declaration.
|
||||||
|
|
||||||
|
**`pk_music_now_playing` still churns, and it cannot be fixed.** Two statements per push. Tested on
|
||||||
|
2026-08-23 rather than assumed: removing the explicit name produces the identical drop-and-re-add under
|
||||||
|
drizzle's generated name (`music_now_playing_user_id_device_pk`), so this is the named-composite mis-diff
|
||||||
|
rather than the truncation one, and there is no index form to escape to because a primary key must be a
|
||||||
|
constraint. **A push is therefore expected to print exactly these two statements and nothing else** — which
|
||||||
|
is a far more useful baseline than ten, because anything else really is your change.
|
||||||
|
|
||||||
**Rules that still apply:**
|
**Rules that still apply:**
|
||||||
|
|
||||||
|
|||||||
@@ -85,14 +85,18 @@ export async function getUserByUsername(username: string): Promise<UserSelect |
|
|||||||
// #1. Returns undefined rather than falling back to the lowest id when no row holds the role: the agent
|
// #1. Returns undefined rather than falling back to the lowest id when no row holds the role: the agent
|
||||||
// sidecar refusing to start beats it silently running as the wrong person.
|
// sidecar refusing to start beats it silently running as the wrong person.
|
||||||
//
|
//
|
||||||
// `order by id` is not cosmetic. Without it, two rows holding the role would make "who owns this
|
// The owner is `OWNER_USER_ID`, so there is nothing to disambiguate and no ordering to get right.
|
||||||
// server" whatever Postgres happened to return first — and that answer feeds the agent sidecar's
|
//
|
||||||
// identity, the vault and origin scoping. The write paths refuse to create a second Super Admin
|
// This used to select on `role = 'Super Admin'` with `order by id` as a tiebreak, because two rows could
|
||||||
// (create-user.ts and updateUserRoleHandler), so this should never have a choice to make; ordering is
|
// hold the role and "who owns this server" would otherwise be whatever Postgres returned first — an answer
|
||||||
// what makes the outcome deterministic if one ever gets in by another route, and id 1 is the bootstrap
|
// that feeds the agent sidecar's identity, the vault and origin scoping. The write paths refused a second
|
||||||
// account the CHECK constraint already pins.
|
// Super Admin to keep it single.
|
||||||
|
//
|
||||||
|
// Since 2026-08-23 ownership is an identity, so several accounts may hold `Super Admin` and none of them
|
||||||
|
// becomes the owner by doing so. The tiebreak, the refusals and the CHECK that pinned user 1's role all
|
||||||
|
// existed to protect an answer that is now a primary key.
|
||||||
export async function getOwnerUser(): Promise<UserSelect | undefined> {
|
export async function getOwnerUser(): Promise<UserSelect | undefined> {
|
||||||
const [user] = await db.select().from(users).where(eq(users.role, 'Super Admin')).orderBy(users.id).limit(1);
|
const [user] = await db.select().from(users).where(eq(users.id, OWNER_USER_ID)).limit(1);
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -93,6 +93,15 @@ export const users = pgTable(
|
|||||||
// "some row must be Super Admin" — that would need to see other rows. So this pins the bootstrap
|
// "some row must be Super Admin" — that would need to see other rows. So this pins the bootstrap
|
||||||
// account and nothing more; promoting or demoting anyone else is still free.
|
// account and nothing more; promoting or demoting anyone else is still free.
|
||||||
//
|
//
|
||||||
|
// KEPT after ownership stopped being a role, 2026-08-23, and `docs/roles-and-teams.md` phase 1 said to
|
||||||
|
// remove it as redundant. It is redundant for the question it was written for — `isOwner` is now an id
|
||||||
|
// comparison and does not read this column at all — but it still earns its place for a different one:
|
||||||
|
// it stops user 1 being set to `Member` while remaining the owner, which is a state with no benefit and
|
||||||
|
// a confusing display. A constraint that costs nothing and forbids a nonsense state is worth keeping
|
||||||
|
// even after the reason it was added has gone.
|
||||||
|
//
|
||||||
|
// What it must NOT be mistaken for is the thing that makes user 1 the owner. It does not, any more.
|
||||||
|
//
|
||||||
// It cannot stop a DELETE either. deleteUser() in queries/auth.ts refuses id 1 for that half.
|
// It cannot stop a DELETE either. deleteUser() in queries/auth.ts refuses id 1 for that half.
|
||||||
//
|
//
|
||||||
// sql.raw for the literal — an interpolated JS string binds as `$1`, and Postgres will not accept a
|
// sql.raw for the literal — an interpolated JS string binds as `$1`, and Postgres will not accept a
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
text,
|
text,
|
||||||
timestamp,
|
timestamp,
|
||||||
uniqueIndex,
|
uniqueIndex,
|
||||||
|
foreignKey,
|
||||||
} from 'drizzle-orm/pg-core';
|
} from 'drizzle-orm/pg-core';
|
||||||
import { users } from '../auth/schema';
|
import { users } from '../auth/schema';
|
||||||
|
|
||||||
@@ -145,14 +146,23 @@ export const commandHqWatcherFires = pgTable(
|
|||||||
'command_hq_watcher_fires',
|
'command_hq_watcher_fires',
|
||||||
{
|
{
|
||||||
id: serial('id').primaryKey(),
|
id: serial('id').primaryKey(),
|
||||||
sessionRowId: integer('session_row_id')
|
sessionRowId: integer('session_row_id').notNull(),
|
||||||
.notNull()
|
|
||||||
.references(() => commandHqSessions.id, { onDelete: 'cascade' }),
|
|
||||||
/** Which watcher task fired — `commandHqSessions.watcherTaskId` at the moment this row was written. */
|
/** Which watcher task fired — `commandHqSessions.watcherTaskId` at the moment this row was written. */
|
||||||
taskId: text('task_id').notNull(),
|
taskId: text('task_id').notNull(),
|
||||||
firedAt: timestamp('fired_at', { withTimezone: true }).notNull().defaultNow(),
|
firedAt: timestamp('fired_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
},
|
},
|
||||||
(table) => [index('idx_command_hq_watcher_fires_session').on(table.sessionRowId, table.firedAt)],
|
(table) => [
|
||||||
|
// Named, because drizzle's generated name is 65 characters and Postgres truncates identifiers at 63 —
|
||||||
|
// so it stores the short form, drizzle keeps comparing against its own long one, and every push drops
|
||||||
|
// and re-adds an identical constraint. Documented in src/databases/CLAUDE.md and left unfixed until a
|
||||||
|
// deploy plan said "expect no changes" and this made that unsatisfiable on all five hosts.
|
||||||
|
foreignKey({
|
||||||
|
name: 'fk_command_hq_watcher_fires_session',
|
||||||
|
columns: [table.sessionRowId],
|
||||||
|
foreignColumns: [commandHqSessions.id],
|
||||||
|
}).onDelete('cascade'),
|
||||||
|
index('idx_command_hq_watcher_fires_session').on(table.sessionRowId, table.firedAt),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -198,14 +208,20 @@ export const commandHqTags = pgTable(
|
|||||||
export const commandHqSessionTags = pgTable(
|
export const commandHqSessionTags = pgTable(
|
||||||
'command_hq_session_tags',
|
'command_hq_session_tags',
|
||||||
{
|
{
|
||||||
sessionRowId: integer('session_row_id')
|
sessionRowId: integer('session_row_id').notNull(),
|
||||||
.notNull()
|
|
||||||
.references(() => commandHqSessions.id, { onDelete: 'cascade' }),
|
|
||||||
tagId: integer('tag_id')
|
tagId: integer('tag_id')
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => commandHqTags.id, { onDelete: 'cascade' }),
|
.references(() => commandHqTags.id, { onDelete: 'cascade' }),
|
||||||
},
|
},
|
||||||
(table) => [uniqueIndex('uq_command_hq_session_tags').on(table.sessionRowId, table.tagId)],
|
(table) => [
|
||||||
|
// 64 characters generated; see the note on `fk_command_hq_watcher_fires_session`.
|
||||||
|
foreignKey({
|
||||||
|
name: 'fk_command_hq_session_tags_session',
|
||||||
|
columns: [table.sessionRowId],
|
||||||
|
foreignColumns: [commandHqSessions.id],
|
||||||
|
}).onDelete('cascade'),
|
||||||
|
uniqueIndex('uq_command_hq_session_tags').on(table.sessionRowId, table.tagId),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -28,7 +28,16 @@ import { toPublicUser } from './manage-users';
|
|||||||
// which is precisely the trap the hand-INSERT route fell into. An account the owner created in the admin
|
// which is precisely the trap the hand-INSERT route fell into. An account the owner created in the admin
|
||||||
// UI is verified by definition — the owner is the verification.
|
// UI is verified by definition — the owner is the verification.
|
||||||
|
|
||||||
/** Roles this route may assign. Never 'Super Admin' — see below. */
|
/**
|
||||||
|
* Roles this route may assign. Never 'Super Admin', still — and that is now an asymmetry worth naming.
|
||||||
|
*
|
||||||
|
* Since 2026-08-23 an existing account CAN be promoted to Super Admin (`updateUserRoleHandler` no longer
|
||||||
|
* refuses it), because ownership stopped being carried by the role. Creation deliberately did not follow.
|
||||||
|
*
|
||||||
|
* The two are different acts. Promotion happens to an account somebody has looked at, from a screen showing
|
||||||
|
* who they are; creation is a form filled in once, and the role that provisions an OS account with sudo is
|
||||||
|
* not a dropdown default anybody should reach by accident. Create, then promote.
|
||||||
|
*/
|
||||||
const ASSIGNABLE_ROLES = USER_ROLES.filter((r) => r !== 'Super Admin');
|
const ASSIGNABLE_ROLES = USER_ROLES.filter((r) => r !== 'Super Admin');
|
||||||
|
|
||||||
export const createUserHandler: Handler = async function (ctx) {
|
export const createUserHandler: Handler = async function (ctx) {
|
||||||
|
|||||||
@@ -77,14 +77,19 @@ export const updateUserRoleHandler: Handler = async function (ctx) {
|
|||||||
throw errors.FORBIDDEN('The server owner cannot be demoted.');
|
throw errors.FORBIDDEN('The server owner cannot be demoted.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// And nobody else can be promoted INTO it. The CHECK constraint pins user 1's role but cannot stop a
|
/*
|
||||||
// second row holding it — a row-level check cannot see other rows — and `getOwnerUser()` resolves the
|
A second Super Admin is allowed since 2026-08-23, and the refusal that used to live here is gone.
|
||||||
// owner by that role, so two holders make "who owns this server" a question the query plan answers.
|
|
||||||
// It decides the agent sidecar's identity, vault access and which origin is privileged. Handing the
|
It existed because `getOwnerUser()` resolved the owner BY that role, so two holders made "who owns this
|
||||||
// server over is a deliberate act, not a dropdown.
|
server" a question the query plan answered — and that answer decided the agent sidecar's identity, vault
|
||||||
if (id !== OWNER_USER_ID && role === 'Super Admin') {
|
access and which origin is privileged. Ownership is now `OWNER_USER_ID`, so the role no longer carries
|
||||||
throw errors.FORBIDDEN('There is one server owner, and this is not how it changes.');
|
it and several accounts may hold it.
|
||||||
}
|
|
||||||
|
What the role does carry is real and worth saying out loud in the UI rather than here: it provisions an
|
||||||
|
OS account with sudo. That puts the holder outside Officer's permission system entirely and cannot be
|
||||||
|
undone by revoking the role afterwards. Granting it is handing over the machine — which is the operator's
|
||||||
|
decision to make, and no longer one this endpoint refuses on their behalf.
|
||||||
|
*/
|
||||||
|
|
||||||
const existing = await getUserById(id);
|
const existing = await getUserById(id);
|
||||||
if (!existing) throw errors.NOT_FOUND('User not found');
|
if (!existing) throw errors.NOT_FOUND('User not found');
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { getUserById } from 'officerdb';
|
import { getUserById, OWNER_USER_ID } from 'officerdb';
|
||||||
|
|
||||||
// Identifies the platform owner. The user's `role` column IS the answer — there is no second mechanism
|
// Identifies the platform owner. The user's `role` column IS the answer — there is no second mechanism
|
||||||
// and no environment variable.
|
// and no environment variable.
|
||||||
@@ -13,10 +13,30 @@ import { getUserById } from 'officerdb';
|
|||||||
// are editable, and a cache with no invalidation contract is a staleness bug waiting for whoever builds
|
// are editable, and a cache with no invalidation contract is a staleness bug waiting for whoever builds
|
||||||
// the role UI. This is one primary-key lookup on a request that has already verified a JWT.
|
// the role UI. This is one primary-key lookup on a request that has already verified a JWT.
|
||||||
export async function isSuperAdmin(payload: { id?: number } | null | undefined): Promise<boolean> {
|
export async function isSuperAdmin(payload: { id?: number } | null | undefined): Promise<boolean> {
|
||||||
if (!payload?.id) return false;
|
return isOwner(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The owner is an IDENTITY, not a role. `OWNER_USER_ID` and nothing else.
|
||||||
|
*
|
||||||
|
* This asked `user.role === 'Super Admin'` until 2026-08-23, which was correct while roles were a hardcoded
|
||||||
|
* TypeScript union and nothing could write a new one. The moment roles became a table it became a
|
||||||
|
* privilege-escalation path: "add a role" and "become the owner" are the same feature, reachable from
|
||||||
|
* whatever UI manages roles.
|
||||||
|
*
|
||||||
|
* So the question moved to one nothing in the product can answer for itself. `Super Admin` is still a role
|
||||||
|
* and several accounts may hold it — it provisions an OS account with sudo, which is its own decision — but
|
||||||
|
* holding it is not being the owner and no role grants that.
|
||||||
|
*
|
||||||
|
* Kept as a database read rather than a bare comparison, deliberately. A deleted or blocked account must not
|
||||||
|
* still be the owner because its id happens to match, and this is one primary-key lookup on a request that
|
||||||
|
* has already verified a JWT.
|
||||||
|
*/
|
||||||
|
export async function isOwner(payload: { id?: number } | null | undefined): Promise<boolean> {
|
||||||
|
if (payload?.id !== OWNER_USER_ID) return false;
|
||||||
try {
|
try {
|
||||||
const user = await getUserById(payload.id);
|
const user = await getUserById(OWNER_USER_ID);
|
||||||
return user?.role === 'Super Admin';
|
return !!user;
|
||||||
} catch {
|
} catch {
|
||||||
// A transient database error must never promote anyone. Deny, and let the next call retry.
|
// A transient database error must never promote anyone. Deny, and let the next call retry.
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
+10
-3
@@ -2,15 +2,22 @@ import type { ReactNode } from 'react';
|
|||||||
|
|
||||||
type UntranslatedProps = {
|
type UntranslatedProps = {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
/** Why this text is not localized. Required so the decision is legible at the call site. */
|
/**
|
||||||
reason: 'foreign-ui-label';
|
* Why this text is not localized. Required so the decision is legible at the call site.
|
||||||
|
*
|
||||||
|
* `foreign-ui-label` — quoted from another product's interface ("click **Enable**"). It has to match
|
||||||
|
* what the reader sees on THEIR screen, and that product localizes by its own account language.
|
||||||
|
* `product-name` — the name of a product or feature: Tmux, Neovim, Claude Code, Officer. The doc's
|
||||||
|
* never-translate list covers these directly.
|
||||||
|
*/
|
||||||
|
reason: 'foreign-ui-label' | 'product-name';
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Text that is deliberately NOT localized, marked as such where it is written.
|
* Text that is deliberately NOT localized, marked as such where it is written.
|
||||||
*
|
*
|
||||||
* The only reason so far is `foreign-ui-label`: a label quoted from another product's interface, which
|
* The first reason was `foreign-ui-label`: a label quoted from another product's interface, which
|
||||||
* docs/localization.md classifies as an identifier rather than prose. "Click **Enable**" has to match
|
* docs/localization.md classifies as an identifier rather than prose. "Click **Enable**" has to match
|
||||||
* what the reader sees on Google's screen, and Google localises by their Google account language rather
|
* what the reader sees on Google's screen, and Google localises by their Google account language rather
|
||||||
* than by ours — so translating it sends somebody hunting for "Ativar" in a console that says "Enable".
|
* than by ours — so translating it sends somebody hunting for "Ativar" in a console that says "Enable".
|
||||||
@@ -2,6 +2,7 @@ import { createContext, useMemo } from 'react';
|
|||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import type { I18nContextValue, InterpolationParams, TranslationMap } from './types';
|
import type { I18nContextValue, InterpolationParams, TranslationMap } from './types';
|
||||||
import { interpolate, resolveKey } from './resolve';
|
import { interpolate, resolveKey } from './resolve';
|
||||||
|
import { setActiveI18n } from './standalone';
|
||||||
|
|
||||||
export const I18nContext = createContext<I18nContextValue>({
|
export const I18nContext = createContext<I18nContextValue>({
|
||||||
t: (key: string) => key,
|
t: (key: string) => key,
|
||||||
@@ -17,6 +18,10 @@ type I18nProviderProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const I18nProvider = ({ translations, locale, fallbackLocale = 'en', children }: I18nProviderProps) => {
|
export const I18nProvider = ({ translations, locale, fallbackLocale = 'en', children }: I18nProviderProps) => {
|
||||||
|
// Mirrored for the one caller that cannot use a hook — see standalone.ts. Set during render rather
|
||||||
|
// than in an effect so it is never a render behind what this provider is handing out.
|
||||||
|
setActiveI18n({ translations, locale, fallbackLocale });
|
||||||
|
|
||||||
const value = useMemo<I18nContextValue>(() => {
|
const value = useMemo<I18nContextValue>(() => {
|
||||||
const t = (key: string, params?: InterpolationParams) => {
|
const t = (key: string, params?: InterpolationParams) => {
|
||||||
const count = typeof params?.count === 'number' ? params.count : undefined;
|
const count = typeof params?.count === 'number' ? params.count : undefined;
|
||||||
|
|||||||
@@ -3,5 +3,7 @@ export { useTranslation } from './useTranslation';
|
|||||||
export { useFormat } from './useFormat';
|
export { useFormat } from './useFormat';
|
||||||
export { resolveKey, interpolate, isPluralForms, localeDirection } from './resolve';
|
export { resolveKey, interpolate, isPluralForms, localeDirection } from './resolve';
|
||||||
export { findMissingKeys } from './parity';
|
export { findMissingKeys } from './parity';
|
||||||
|
// Deliberately NOT re-exporting translateStandalone — see standalone.ts on why it is awkward to reach.
|
||||||
|
export { activeLocale } from './standalone';
|
||||||
export type { MissingKey } from './parity';
|
export type { MissingKey } from './parity';
|
||||||
export type { TranslationMap, InterpolationParams, I18nContextValue, TranslationKeys, PluralForms } from './types';
|
export type { TranslationMap, InterpolationParams, I18nContextValue, TranslationKeys, PluralForms } from './types';
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import type { InterpolationParams, TranslationMap } from './types';
|
||||||
|
import { interpolate, resolveKey } from './resolve';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The active locale, readable OUTSIDE a React render.
|
||||||
|
*
|
||||||
|
* There is exactly one caller and it is not a shortcut: a panel's name in the app registry is
|
||||||
|
* module-level data, and the framework invokes its thunk from a render where no hook of ours is
|
||||||
|
* reachable — `workspaces` has no dependencies at all, deliberately, not even this package.
|
||||||
|
*
|
||||||
|
* NOT a general escape hatch. Anything inside a component uses `useTranslation`, which is reactive.
|
||||||
|
* This is not reactive: writing here re-renders nothing. It is only correct where the caller is
|
||||||
|
* already re-rendering for another reason, which is why `translateStandalone` is deliberately awkward
|
||||||
|
* to reach and documented rather than exported from the package barrel.
|
||||||
|
*/
|
||||||
|
type ActiveI18n = { translations: TranslationMap; locale: string; fallbackLocale: string };
|
||||||
|
|
||||||
|
let active: ActiveI18n = { translations: {}, locale: 'en', fallbackLocale: 'en' };
|
||||||
|
|
||||||
|
/** Called by `I18nProvider` on every render, so this never lags the provider it mirrors. */
|
||||||
|
export const setActiveI18n = (next: ActiveI18n): void => {
|
||||||
|
active = next;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const translateStandalone = (key: string, params?: InterpolationParams): string => {
|
||||||
|
const count = typeof params?.count === 'number' ? params.count : undefined;
|
||||||
|
return interpolate(resolveKey(active.translations, active.locale, key, active.fallbackLocale, count), params);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The locale the provider currently holds. Exposed so a caller can key a render on it. */
|
||||||
|
export const activeLocale = (): string => active.locale;
|
||||||
@@ -3,6 +3,7 @@ import { useSearchParams } from 'react-router';
|
|||||||
import { Loader2, ExternalLink } from 'lucide-react';
|
import { Loader2, ExternalLink } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { useAppStore, type ConfigField, type InstallMode, type StoreItem } from './useAppStore';
|
import { useAppStore, type ConfigField, type InstallMode, type StoreItem } from './useAppStore';
|
||||||
|
import { useTranslation } from '../../locales';
|
||||||
|
|
||||||
// One entry: what it is, how to install it, and what to do about it once installed.
|
// One entry: what it is, how to install it, and what to do about it once installed.
|
||||||
//
|
//
|
||||||
@@ -41,6 +42,7 @@ function Field({ field, value, onChange }: { field: ConfigField; value: string;
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const AppStoreDetail = () => {
|
export const AppStoreDetail = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [params] = useSearchParams();
|
const [params] = useSearchParams();
|
||||||
const id = params.get('selected');
|
const id = params.get('selected');
|
||||||
const { items, install, setEnabled, uninstall } = useAppStore();
|
const { items, install, setEnabled, uninstall } = useAppStore();
|
||||||
@@ -51,7 +53,11 @@ export const AppStoreDetail = () => {
|
|||||||
const [log, setLog] = useState<string[]>([]);
|
const [log, setLog] = useState<string[]>([]);
|
||||||
|
|
||||||
if (!item) {
|
if (!item) {
|
||||||
return <div className="flex h-full items-center justify-center text-sm text-muted-foreground">Select an app</div>;
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||||
|
{t('appStore.selectApp')}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// `existing` is first in `modes` by catalogue rule, so the default lands on "I already have one" —
|
// `existing` is first in `modes` by catalogue rule, so the default lands on "I already have one" —
|
||||||
@@ -82,13 +88,13 @@ export const AppStoreDetail = () => {
|
|||||||
person. `completeAt` links to the page that mints the token. */}
|
person. `completeAt` links to the page that mints the token. */}
|
||||||
{item.install.status === 'blocked' && item.install.lastError && (
|
{item.install.status === 'blocked' && item.install.lastError && (
|
||||||
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-sm">
|
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-sm">
|
||||||
<div className="font-medium">Needs you</div>
|
<div className="font-medium">{t('appStore.needsYou')}</div>
|
||||||
<p className="mt-1 text-muted-foreground">{item.install.lastError}</p>
|
<p className="mt-1 text-muted-foreground">{item.install.lastError}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{item.install.status === 'failed' && item.install.lastError && (
|
{item.install.status === 'failed' && item.install.lastError && (
|
||||||
<div className="rounded-md border-destructive/40 bg-destructive/10 border p-3 text-sm">
|
<div className="rounded-md border-destructive/40 bg-destructive/10 border p-3 text-sm">
|
||||||
<div className="font-medium">Install failed</div>
|
<div className="font-medium">{t('appStore.installFailed')}</div>
|
||||||
<p className="mt-1 whitespace-pre-wrap text-muted-foreground">{item.install.lastError}</p>
|
<p className="mt-1 whitespace-pre-wrap text-muted-foreground">{item.install.lastError}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -97,7 +103,7 @@ export const AppStoreDetail = () => {
|
|||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
Installed {item.install.mode ? `(${item.install.mode})` : ''} ·{' '}
|
Installed {item.install.mode ? `(${item.install.mode})` : ''} ·{' '}
|
||||||
{item.install.enabled ? 'enabled' : 'disabled'}
|
{item.install.enabled ? t('appStore.enabled') : t('appStore.disabled')}
|
||||||
{item.processStatus && ` · process ${item.processStatus}`}
|
{item.processStatus && ` · process ${item.processStatus}`}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -106,17 +112,14 @@ export const AppStoreDetail = () => {
|
|||||||
disabled={setEnabled.isPending}
|
disabled={setEnabled.isPending}
|
||||||
onClick={() => setEnabled.mutate({ id: item.id, enabled: !item.install.enabled })}
|
onClick={() => setEnabled.mutate({ id: item.id, enabled: !item.install.enabled })}
|
||||||
>
|
>
|
||||||
{item.install.enabled ? 'Disable' : 'Enable'}
|
{item.install.enabled ? t('appStore.disable') : t('appStore.enable')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" disabled={uninstall.isPending} onClick={() => uninstall.mutate(item.id)}>
|
<Button variant="outline" disabled={uninstall.isPending} onClick={() => uninstall.mutate(item.id)}>
|
||||||
Uninstall
|
{t('appStore.uninstall')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{/* Said plainly, because it is the question anyone hesitates over before clicking. */}
|
{/* Said plainly, because it is the question anyone hesitates over before clicking. */}
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">{t('appStore.removalNote')}</p>
|
||||||
Disabling stops the sidecar and its container. Uninstalling also removes the containers — your data,
|
|
||||||
configuration and this app’s tables are kept either way.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
// A real <form>, so pressing Enter in a field installs — the div it used to be silently ate it.
|
// A real <form>, so pressing Enter in a field installs — the div it used to be silently ate it.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Link, useSearchParams } from 'react-router';
|
import { Link, useSearchParams } from 'react-router';
|
||||||
import { Loader2, Check, CircleSlash, AlertTriangle, PauseCircle } from 'lucide-react';
|
import { Loader2, Check, CircleSlash, AlertTriangle, PauseCircle } from 'lucide-react';
|
||||||
import { useAppStore, type StoreItem } from './useAppStore';
|
import { useAppStore, type StoreItem } from './useAppStore';
|
||||||
|
import { useTranslation } from '../../locales';
|
||||||
|
|
||||||
// The list of everything installable, with what has happened to each.
|
// The list of everything installable, with what has happened to each.
|
||||||
//
|
//
|
||||||
@@ -10,26 +11,31 @@ import { useAppStore, type StoreItem } from './useAppStore';
|
|||||||
|
|
||||||
/** One glyph for the whole state, because "installed but the process died" has to be visible at a glance. */
|
/** One glyph for the whole state, because "installed but the process died" has to be visible at a glance. */
|
||||||
function StatusBadge({ item }: { item: StoreItem }) {
|
function StatusBadge({ item }: { item: StoreItem }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { status, enabled } = item.install;
|
const { status, enabled } = item.install;
|
||||||
|
|
||||||
if (status === 'not-installed') return null;
|
if (status === 'not-installed') return null;
|
||||||
if (status === 'installing') {
|
if (status === 'installing') {
|
||||||
return <Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" aria-label="installing" />;
|
return <Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" aria-label={t('appStore.installing')} />;
|
||||||
}
|
}
|
||||||
if (status === 'failed') return <AlertTriangle className="h-3.5 w-3.5 text-destructive" aria-label="failed" />;
|
if (status === 'failed')
|
||||||
|
return <AlertTriangle className="h-3.5 w-3.5 text-destructive" aria-label={t('appStore.failed')} />;
|
||||||
// Blocked is not a failure: everything worked and it is waiting for something only a person can give.
|
// Blocked is not a failure: everything worked and it is waiting for something only a person can give.
|
||||||
if (status === 'blocked') return <PauseCircle className="h-3.5 w-3.5 text-amber-500" aria-label="needs you" />;
|
if (status === 'blocked')
|
||||||
if (!enabled) return <CircleSlash className="h-3.5 w-3.5 text-muted-foreground" aria-label="disabled" />;
|
return <PauseCircle className="h-3.5 w-3.5 text-amber-500" aria-label={t('appStore.needsYouShort')} />;
|
||||||
|
if (!enabled)
|
||||||
|
return <CircleSlash className="h-3.5 w-3.5 text-muted-foreground" aria-label={t('appStore.disabled')} />;
|
||||||
|
|
||||||
// Installed and enabled, but the process is not running — the case worth surfacing rather than
|
// Installed and enabled, but the process is not running — the case worth surfacing rather than
|
||||||
// showing a tick that lies.
|
// showing a tick that lies.
|
||||||
if (item.processStatus && item.processStatus !== 'online') {
|
if (item.processStatus && item.processStatus !== 'online') {
|
||||||
return <AlertTriangle className="h-3.5 w-3.5 text-amber-500" aria-label={item.processStatus} />;
|
return <AlertTriangle className="h-3.5 w-3.5 text-amber-500" aria-label={item.processStatus} />;
|
||||||
}
|
}
|
||||||
return <Check className="h-3.5 w-3.5 text-success" aria-label="installed" />;
|
return <Check className="h-3.5 w-3.5 text-success" aria-label={t('appStore.installedIcon')} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AppStoreList = () => {
|
export const AppStoreList = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { items, isLoading, error } = useAppStore();
|
const { items, isLoading, error } = useAppStore();
|
||||||
const [params] = useSearchParams();
|
const [params] = useSearchParams();
|
||||||
const selected = params.get('selected');
|
const selected = params.get('selected');
|
||||||
@@ -37,14 +43,14 @@ export const AppStoreList = () => {
|
|||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Loading
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> {t('appStore.loading')}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (error) {
|
if (error) {
|
||||||
// Distinguished from "nothing installable", which would otherwise look identical and is not a
|
// Distinguished from "nothing installable", which would otherwise look identical and is not a
|
||||||
// problem the user can act on.
|
// problem the user can act on.
|
||||||
return <div className="p-4 text-sm text-destructive">Could not load the app store.</div>;
|
return <div className="p-4 text-sm text-destructive">{t('appStore.loadFailed')}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const installed = items.filter((i) => i.install.status !== 'not-installed');
|
const installed = items.filter((i) => i.install.status !== 'not-installed');
|
||||||
@@ -66,13 +72,17 @@ export const AppStoreList = () => {
|
|||||||
<div className="flex h-full flex-col overflow-y-auto p-2">
|
<div className="flex h-full flex-col overflow-y-auto p-2">
|
||||||
{installed.length > 0 && (
|
{installed.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="px-3 py-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">Installed</div>
|
<div className="px-3 py-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
{t('appStore.installed')}
|
||||||
|
</div>
|
||||||
{installed.map((item) => (
|
{installed.map((item) => (
|
||||||
<Row key={item.id} item={item} />
|
<Row key={item.id} item={item} />
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<div className="mt-2 px-3 py-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">Available</div>
|
<div className="mt-2 px-3 py-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
{t('appStore.available')}
|
||||||
|
</div>
|
||||||
{available.map((item) => (
|
{available.map((item) => (
|
||||||
<Row key={item.id} item={item} />
|
<Row key={item.id} item={item} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Store } from 'lucide-react';
|
|||||||
import type { AppRegistryMeta } from '../../AppRegistry';
|
import type { AppRegistryMeta } from '../../AppRegistry';
|
||||||
import { AppStoreList } from './AppStoreList';
|
import { AppStoreList } from './AppStoreList';
|
||||||
import { AppStoreDetail } from './AppStoreDetail';
|
import { AppStoreDetail } from './AppStoreDetail';
|
||||||
|
import { panelName } from '../../locales';
|
||||||
|
|
||||||
export { AppStoreList } from './AppStoreList';
|
export { AppStoreList } from './AppStoreList';
|
||||||
export { AppStoreDetail } from './AppStoreDetail';
|
export { AppStoreDetail } from './AppStoreDetail';
|
||||||
@@ -12,6 +13,18 @@ export type { StoreItem, InstallMode, ConfigField } from './useAppStore';
|
|||||||
// side by side. `availableOnPanel: false` keeps them off the generic panel picker — they only make
|
// side by side. `availableOnPanel: false` keeps them off the generic panel picker — they only make
|
||||||
// sense together, on /app-store.
|
// sense together, on /app-store.
|
||||||
export const appRegistryMetas: AppRegistryMeta[] = [
|
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||||
{ key: 'app-store-list', name: 'App store', icon: Store, component: AppStoreList, availableOnPanel: false },
|
{
|
||||||
{ key: 'app-store-detail', name: 'App detail', icon: Store, component: AppStoreDetail, availableOnPanel: false },
|
key: 'app-store-list',
|
||||||
|
name: panelName('panelName.appStore'),
|
||||||
|
icon: Store,
|
||||||
|
component: AppStoreList,
|
||||||
|
availableOnPanel: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'app-store-detail',
|
||||||
|
name: panelName('panelName.appDetail'),
|
||||||
|
icon: Store,
|
||||||
|
component: AppStoreDetail,
|
||||||
|
availableOnPanel: false,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { AppRegistryMeta } from '../../AppRegistry';
|
|||||||
import { MessageSquare } from 'lucide-react';
|
import { MessageSquare } from 'lucide-react';
|
||||||
import { ChatPanelWrapper } from './ChatPanelWrapper';
|
import { ChatPanelWrapper } from './ChatPanelWrapper';
|
||||||
import { ChatHeader } from './ChatHeader';
|
import { ChatHeader } from './ChatHeader';
|
||||||
|
import { panelName } from '../../locales';
|
||||||
|
|
||||||
export { MessageList } from './components/MessageList';
|
export { MessageList } from './components/MessageList';
|
||||||
export { MessageBubble, StreamingBubble } from './components/MessageBubble';
|
export { MessageBubble, StreamingBubble } from './components/MessageBubble';
|
||||||
@@ -26,7 +27,7 @@ export * from './types';
|
|||||||
export const appRegistryMetas: AppRegistryMeta[] = [
|
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||||
{
|
{
|
||||||
key: 'officerdev/chat',
|
key: 'officerdev/chat',
|
||||||
name: 'Chat',
|
name: panelName('panelName.chat'),
|
||||||
icon: MessageSquare,
|
icon: MessageSquare,
|
||||||
component: ChatPanelWrapper,
|
component: ChatPanelWrapper,
|
||||||
header: ChatHeader,
|
header: ChatHeader,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { AppRegistryMeta } from '../../AppRegistry';
|
|||||||
import { MessageSquare, List } from 'lucide-react';
|
import { MessageSquare, List } from 'lucide-react';
|
||||||
import { SessionList } from './SessionList';
|
import { SessionList } from './SessionList';
|
||||||
import { ChatDetailPanel } from './ChatDetailPanel';
|
import { ChatDetailPanel } from './ChatDetailPanel';
|
||||||
|
import { panelName } from '../../locales';
|
||||||
|
|
||||||
export { SessionList };
|
export { SessionList };
|
||||||
export { ChatDetailPanel };
|
export { ChatDetailPanel };
|
||||||
@@ -11,14 +12,14 @@ export { chatListPath, chatNewPath, chatSessionPath, cwdFromSplat, GROUP_SEGMENT
|
|||||||
export const appRegistryMetas: AppRegistryMeta[] = [
|
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||||
{
|
{
|
||||||
key: 'chat-session-list',
|
key: 'chat-session-list',
|
||||||
name: 'Sessions',
|
name: panelName('panelName.chatSessions'),
|
||||||
icon: List,
|
icon: List,
|
||||||
component: SessionList,
|
component: SessionList,
|
||||||
availableOnPanel: false,
|
availableOnPanel: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'chat-detail',
|
key: 'chat-detail',
|
||||||
name: 'Chat',
|
name: panelName('panelName.chat'),
|
||||||
icon: MessageSquare,
|
icon: MessageSquare,
|
||||||
component: ChatDetailPanel,
|
component: ChatDetailPanel,
|
||||||
availableOnPanel: false,
|
availableOnPanel: false,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { useFilesRefresh } from '../../channels';
|
|||||||
import { useWorkspace } from '../../components/Workspace';
|
import { useWorkspace } from '../../components/Workspace';
|
||||||
import { useEditorTabs } from './use-editor-tabs';
|
import { useEditorTabs } from './use-editor-tabs';
|
||||||
import { configureMonacoTypescript } from './monaco-setup';
|
import { configureMonacoTypescript } from './monaco-setup';
|
||||||
|
import { useTranslation } from '../../locales';
|
||||||
|
|
||||||
type CodeEditorViewProps = {
|
type CodeEditorViewProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -103,6 +104,7 @@ export const CodeEditorView = ({
|
|||||||
urlState,
|
urlState,
|
||||||
panelId,
|
panelId,
|
||||||
}: CodeEditorViewProps) => {
|
}: CodeEditorViewProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { workspace } = useWorkspace();
|
const { workspace } = useWorkspace();
|
||||||
const { restored, remember } = useEditorTabs(panelId, workspace);
|
const { restored, remember } = useEditorTabs(panelId, workspace);
|
||||||
const {
|
const {
|
||||||
@@ -138,7 +140,7 @@ export const CodeEditorView = ({
|
|||||||
const res = await readFile(path);
|
const res = await readFile(path);
|
||||||
openFile(path, name, res.content);
|
openFile(path, name, res.content);
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Failed to read file');
|
toast.error(t('codeEditor.readFailed'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -159,7 +161,7 @@ export const CodeEditorView = ({
|
|||||||
.then((res) => alive && openFile(path, path.split('/').pop() ?? path, res.content))
|
.then((res) => alive && openFile(path, path.split('/').pop() ?? path, res.content))
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
unreadable.current.add(path);
|
unreadable.current.add(path);
|
||||||
if (alive) toast.error('Failed to read file');
|
if (alive) toast.error(t('codeEditor.readFailed'));
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
@@ -273,9 +275,9 @@ export const CodeEditorView = ({
|
|||||||
try {
|
try {
|
||||||
await writeFile(activeFile.path, activeFile.content);
|
await writeFile(activeFile.path, activeFile.content);
|
||||||
markSaved(activeFile.path, activeFile.content);
|
markSaved(activeFile.path, activeFile.content);
|
||||||
toast.success('File saved');
|
toast.success(t('codeEditor.saved'));
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Failed to save file');
|
toast.error(t('codeEditor.saveFailed'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -322,10 +324,10 @@ export const CodeEditorView = ({
|
|||||||
className="px-3 py-2 text-xs font-semibold uppercase tracking-wider flex items-center justify-between gap-2"
|
className="px-3 py-2 text-xs font-semibold uppercase tracking-wider flex items-center justify-between gap-2"
|
||||||
style={{ color: '#888', borderBottom: `1px solid ${borderColor}` }}
|
style={{ color: '#888', borderBottom: `1px solid ${borderColor}` }}
|
||||||
>
|
>
|
||||||
<span>Explorer</span>
|
<span>{t('codeEditor.explorer')}</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => setManualRefresh((n) => n + 1)}
|
onClick={() => setManualRefresh((n) => n + 1)}
|
||||||
title="Refresh the tree and reload unmodified open files"
|
title={t('codeEditor.refresh')}
|
||||||
className="p-0.5 rounded hover:bg-white/10 transition-colors cursor-pointer"
|
className="p-0.5 rounded hover:bg-white/10 transition-colors cursor-pointer"
|
||||||
>
|
>
|
||||||
<RefreshCw className="h-3.5 w-3.5" />
|
<RefreshCw className="h-3.5 w-3.5" />
|
||||||
@@ -383,7 +385,7 @@ export const CodeEditorView = ({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex-1 flex items-center justify-center text-sm" style={{ color: '#888' }}>
|
<div className="flex-1 flex items-center justify-center text-sm" style={{ color: '#888' }}>
|
||||||
Open a file from the explorer to start editing
|
{t('codeEditor.openAFile')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { Code } from 'lucide-react';
|
import { Code } from 'lucide-react';
|
||||||
import { useWorkspace } from '../../components/Workspace';
|
import { useWorkspace } from '../../components/Workspace';
|
||||||
|
import { useTranslation } from '../../locales';
|
||||||
|
|
||||||
export const CodeEditorHeader = () => {
|
export const CodeEditorHeader = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { cwd } = useWorkspace();
|
const { cwd } = useWorkspace();
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Code className="h-3.5 w-3.5 shrink-0" />
|
<Code className="h-3.5 w-3.5 shrink-0" />
|
||||||
<span className="text-xs font-medium shrink-0">Code Editor</span>
|
<span className="text-xs font-medium shrink-0">{t('codeEditor.title')}</span>
|
||||||
<span className="text-[10px] font-mono truncate opacity-60">{cwd}</span>
|
<span className="text-[10px] font-mono truncate opacity-60">{cwd}</span>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Link } from 'react-router';
|
|||||||
import { X, FileX } from 'lucide-react';
|
import { X, FileX } from 'lucide-react';
|
||||||
import { getIcon } from 'material-file-icons';
|
import { getIcon } from 'material-file-icons';
|
||||||
import type { OpenFile } from './useEditorState';
|
import type { OpenFile } from './useEditorState';
|
||||||
|
import { useTranslation } from '../../locales';
|
||||||
|
|
||||||
type EditorTabsProps = {
|
type EditorTabsProps = {
|
||||||
files: OpenFile[];
|
files: OpenFile[];
|
||||||
@@ -19,6 +20,7 @@ const FileIcon = ({ name }: { name: string }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const EditorTabs = ({ files, activePath, onSelect, onClose, theme, searchForFile }: EditorTabsProps) => {
|
export const EditorTabs = ({ files, activePath, onSelect, onClose, theme, searchForFile }: EditorTabsProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
if (files.length === 0) return null;
|
if (files.length === 0) return null;
|
||||||
|
|
||||||
const isDark = theme !== 'vs';
|
const isDark = theme !== 'vs';
|
||||||
@@ -75,7 +77,7 @@ export const EditorTabs = ({ files, activePath, onSelect, onClose, theme, search
|
|||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => onClose(file.path)}
|
onClick={() => onClose(file.path)}
|
||||||
aria-label={`Close ${file.name}`}
|
aria-label={t('codeEditor.closeFile', { name: file.name })}
|
||||||
className="p-0.5 rounded cursor-pointer transition-colors hover:bg-white/10"
|
className="p-0.5 rounded cursor-pointer transition-colors hover:bg-white/10"
|
||||||
style={{ color: inactiveColor }}
|
style={{ color: inactiveColor }}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Link } from 'react-router';
|
|||||||
import { ChevronRight, ChevronDown, Folder } from 'lucide-react';
|
import { ChevronRight, ChevronDown, Folder } from 'lucide-react';
|
||||||
import { getIcon } from 'material-file-icons';
|
import { getIcon } from 'material-file-icons';
|
||||||
import { useFilesAPI, type DirEntry } from '../../hooks/useFilesAPI';
|
import { useFilesAPI, type DirEntry } from '../../hooks/useFilesAPI';
|
||||||
|
import { useTranslation } from '../../locales';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `searchForFile` turns file rows into real links when the editor owns the address bar. Folder rows stay
|
* `searchForFile` turns file rows into real links when the editor owns the address bar. Folder rows stay
|
||||||
@@ -148,6 +149,7 @@ const TreeNode = ({ entry, parentPath, root, onOpenFile, searchForFile, depth, r
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const FileTree = ({ root, basePath, onOpenFile, searchForFile, refreshToken }: FileTreeProps) => {
|
export const FileTree = ({ root, basePath, onOpenFile, searchForFile, refreshToken }: FileTreeProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { listDir } = useFilesAPI(root);
|
const { listDir } = useFilesAPI(root);
|
||||||
const [entries, setEntries] = useState<DirEntry[] | null>(null);
|
const [entries, setEntries] = useState<DirEntry[] | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -187,11 +189,11 @@ export const FileTree = ({ root, basePath, onOpenFile, searchForFile, refreshTok
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (loading && entries === null) {
|
if (loading && entries === null) {
|
||||||
return <div className="p-2 text-sm text-[#888]">Loading...</div>;
|
return <div className="p-2 text-sm text-[#888]">{t('codeEditor.loading')}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!entries || entries.length === 0) {
|
if (!entries || entries.length === 0) {
|
||||||
return <div className="p-2 text-sm text-[#888]">No files</div>;
|
return <div className="p-2 text-sm text-[#888]">{t('codeEditor.noFiles')}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -2,13 +2,14 @@ import type { AppRegistryMeta } from '../../AppRegistry';
|
|||||||
import { Code } from 'lucide-react';
|
import { Code } from 'lucide-react';
|
||||||
import { CodeEditorPanelWrapper } from './CodeEditorPanelWrapper';
|
import { CodeEditorPanelWrapper } from './CodeEditorPanelWrapper';
|
||||||
import { CodeEditorHeader } from './CodeEditorHeader';
|
import { CodeEditorHeader } from './CodeEditorHeader';
|
||||||
|
import { panelName } from '../../locales';
|
||||||
|
|
||||||
export { CodeEditorView } from './CodeEditor';
|
export { CodeEditorView } from './CodeEditor';
|
||||||
|
|
||||||
export const appRegistryMetas: AppRegistryMeta[] = [
|
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||||
{
|
{
|
||||||
key: 'officerdev/code-editor',
|
key: 'officerdev/code-editor',
|
||||||
name: 'Code Editor',
|
name: panelName('panelName.codeEditor'),
|
||||||
icon: Code,
|
icon: Code,
|
||||||
component: CodeEditorPanelWrapper,
|
component: CodeEditorPanelWrapper,
|
||||||
header: CodeEditorHeader,
|
header: CodeEditorHeader,
|
||||||
|
|||||||
@@ -15,25 +15,26 @@ export type {
|
|||||||
CommandHqTurn,
|
CommandHqTurn,
|
||||||
CommandHqFire,
|
CommandHqFire,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
import { panelName } from '../../locales';
|
||||||
|
|
||||||
export const appRegistryMetas: AppRegistryMeta[] = [
|
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||||
{
|
{
|
||||||
key: 'command-hq-categories',
|
key: 'command-hq-categories',
|
||||||
name: 'Categories',
|
name: panelName('panelName.categories'),
|
||||||
icon: Radar,
|
icon: Radar,
|
||||||
component: CommandHqCategories,
|
component: CommandHqCategories,
|
||||||
availableOnPanel: false,
|
availableOnPanel: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'command-hq-grid',
|
key: 'command-hq-grid',
|
||||||
name: 'Commanders',
|
name: panelName('panelName.commanders'),
|
||||||
icon: LayoutGrid,
|
icon: LayoutGrid,
|
||||||
component: CommandHqGrid,
|
component: CommandHqGrid,
|
||||||
availableOnPanel: false,
|
availableOnPanel: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'command-hq-detail',
|
key: 'command-hq-detail',
|
||||||
name: 'Commander',
|
name: panelName('panelName.commander'),
|
||||||
icon: MessageSquare,
|
icon: MessageSquare,
|
||||||
component: CommandHqDetail,
|
component: CommandHqDetail,
|
||||||
availableOnPanel: false,
|
availableOnPanel: false,
|
||||||
|
|||||||
@@ -26,8 +26,10 @@ import {
|
|||||||
} from './constants';
|
} from './constants';
|
||||||
import { useNewDashboardDraft } from './useNewDashboardDraft';
|
import { useNewDashboardDraft } from './useNewDashboardDraft';
|
||||||
import { cwdToPath } from '../../components/Workspace';
|
import { cwdToPath } from '../../components/Workspace';
|
||||||
|
import { useTranslation } from '../../locales';
|
||||||
|
|
||||||
export const DashboardListApp = () => {
|
export const DashboardListApp = () => {
|
||||||
|
const { t, tUi } = useTranslation();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -105,7 +107,7 @@ export const DashboardListApp = () => {
|
|||||||
className="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium bg-duck-teal/15 text-duck-teal"
|
className="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium bg-duck-teal/15 text-duck-teal"
|
||||||
>
|
>
|
||||||
<LayoutGrid className="h-4 w-4" />
|
<LayoutGrid className="h-4 w-4" />
|
||||||
Dashboards
|
{t('dashboards.title')}
|
||||||
</Link>
|
</Link>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -113,7 +115,7 @@ export const DashboardListApp = () => {
|
|||||||
className="flex items-center justify-center gap-1 py-2 px-3 rounded-lg text-sm font-medium bg-duck-teal hover:bg-duck-teal/90 text-white transition-all cursor-pointer"
|
className="flex items-center justify-center gap-1 py-2 px-3 rounded-lg text-sm font-medium bg-duck-teal hover:bg-duck-teal/90 text-white transition-all cursor-pointer"
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4 shrink-0" />
|
<Plus className="h-4 w-4 shrink-0" />
|
||||||
New Dashboard
|
{t('dashboards.new')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative px-3 pt-2">
|
<div className="relative px-3 pt-2">
|
||||||
@@ -122,7 +124,7 @@ export const DashboardListApp = () => {
|
|||||||
type="text"
|
type="text"
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(ev) => setSearch(ev.target.value)}
|
onChange={(ev) => setSearch(ev.target.value)}
|
||||||
placeholder="Search dashboards"
|
placeholder={t('dashboards.search')}
|
||||||
className="w-full rounded-lg border border-duck-dark/15 bg-transparent py-1.5 pl-8 pr-3 text-sm text-white placeholder:text-gray-500 focus:outline-none focus:ring-1 focus:ring-duck-teal/30 focus:border-duck-teal/40"
|
className="w-full rounded-lg border border-duck-dark/15 bg-transparent py-1.5 pl-8 pr-3 text-sm text-white placeholder:text-gray-500 focus:outline-none focus:ring-1 focus:ring-duck-teal/30 focus:border-duck-teal/40"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -182,15 +184,15 @@ export const DashboardListApp = () => {
|
|||||||
>
|
>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Delete dashboard</AlertDialogTitle>
|
<AlertDialogTitle>{t('dashboards.delete')}</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
Are you sure you want to delete <strong>{deleting?.name}</strong>? This action cannot be undone.
|
{t('dashboards.confirmDelete')} <strong>{deleting?.name}</strong>? This action cannot be undone.
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
<AlertDialogCancel>{tUi('button.cancel')}</AlertDialogCancel>
|
||||||
<AlertDialogAction onClick={confirmDelete} className="bg-red-600 hover:bg-red-700">
|
<AlertDialogAction onClick={confirmDelete} className="bg-red-600 hover:bg-red-700">
|
||||||
Delete
|
{t('common.delete')}
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
|
|||||||
@@ -19,21 +19,23 @@ import {
|
|||||||
NEW_DASH_DESC_KEY,
|
NEW_DASH_DESC_KEY,
|
||||||
NEW_DASH_TEMPLATE_KEY,
|
NEW_DASH_TEMPLATE_KEY,
|
||||||
} from './constants';
|
} from './constants';
|
||||||
|
import type { PanelTranslationKey } from '../../locales';
|
||||||
|
import { panelName, useTranslation } from '../../locales';
|
||||||
|
|
||||||
// --- Layout Templates ---
|
// --- Layout Templates ---
|
||||||
|
|
||||||
type LayoutTemplate = {
|
type LayoutTemplate = {
|
||||||
name: string;
|
nameKey: PanelTranslationKey;
|
||||||
layout: () => LayoutNode;
|
layout: () => LayoutNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
const templates: LayoutTemplate[] = [
|
const templates: LayoutTemplate[] = [
|
||||||
{
|
{
|
||||||
name: 'Single',
|
nameKey: 'dashboards.templates.single',
|
||||||
layout: () => ({ type: 'panel', id: uid(), appType: null }),
|
layout: () => ({ type: 'panel', id: uid(), appType: null }),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: '2 Columns',
|
nameKey: 'dashboards.templates.twoColumns',
|
||||||
layout: () => ({
|
layout: () => ({
|
||||||
type: 'group',
|
type: 'group',
|
||||||
id: uid(),
|
id: uid(),
|
||||||
@@ -45,7 +47,7 @@ const templates: LayoutTemplate[] = [
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Main + Side',
|
nameKey: 'dashboards.templates.mainSide',
|
||||||
layout: () => ({
|
layout: () => ({
|
||||||
type: 'group',
|
type: 'group',
|
||||||
id: uid(),
|
id: uid(),
|
||||||
@@ -68,7 +70,7 @@ const templates: LayoutTemplate[] = [
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Sidebar',
|
nameKey: 'dashboards.templates.sidebar',
|
||||||
layout: () => ({
|
layout: () => ({
|
||||||
type: 'group',
|
type: 'group',
|
||||||
id: uid(),
|
id: uid(),
|
||||||
@@ -80,7 +82,7 @@ const templates: LayoutTemplate[] = [
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: '2x2 Grid',
|
nameKey: 'dashboards.templates.grid',
|
||||||
layout: () => ({
|
layout: () => ({
|
||||||
type: 'group',
|
type: 'group',
|
||||||
id: uid(),
|
id: uid(),
|
||||||
@@ -114,7 +116,7 @@ const templates: LayoutTemplate[] = [
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Cols + Bottom',
|
nameKey: 'dashboards.templates.colsBottom',
|
||||||
layout: () => ({
|
layout: () => ({
|
||||||
type: 'group',
|
type: 'group',
|
||||||
id: uid(),
|
id: uid(),
|
||||||
@@ -157,6 +159,7 @@ const ThumbnailNode = ({ node }: { node: LayoutNode }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const TemplateCell = ({ index }: { index: number }) => {
|
const TemplateCell = ({ index }: { index: number }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const tpl = templates[index]!;
|
const tpl = templates[index]!;
|
||||||
const [selected, setSelected] = useGlobal<number>(NEW_DASH_TEMPLATE_KEY, 0);
|
const [selected, setSelected] = useGlobal<number>(NEW_DASH_TEMPLATE_KEY, 0);
|
||||||
const node = tpl.layout();
|
const node = tpl.layout();
|
||||||
@@ -174,7 +177,9 @@ const TemplateCell = ({ index }: { index: number }) => {
|
|||||||
<div className="w-full flex-1 min-h-0 rounded border border-duck-dark/20 overflow-hidden">
|
<div className="w-full flex-1 min-h-0 rounded border border-duck-dark/20 overflow-hidden">
|
||||||
<ThumbnailNode node={node} />
|
<ThumbnailNode node={node} />
|
||||||
</div>
|
</div>
|
||||||
<span className={`text-xs font-medium ${isSelected ? 'text-duck-teal' : 'text-duck-dark/60'}`}>{tpl.name}</span>
|
<span className={`text-xs font-medium ${isSelected ? 'text-duck-teal' : 'text-duck-dark/60'}`}>
|
||||||
|
{t(tpl.nameKey)}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -219,7 +224,7 @@ const tplPanelLayout: LayoutNode = {
|
|||||||
const tplRegistry = Object.fromEntries(
|
const tplRegistry = Object.fromEntries(
|
||||||
templates.map((tpl, i) => [
|
templates.map((tpl, i) => [
|
||||||
`tpl-${i}`,
|
`tpl-${i}`,
|
||||||
{ name: tpl.name, icon: Layout, component: () => <TemplateCell index={i} /> },
|
{ name: panelName(tpl.nameKey), icon: Layout, component: () => <TemplateCell index={i} /> },
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -230,6 +235,7 @@ const TemplatePanel = () => (
|
|||||||
// --- Panel: Name ---
|
// --- Panel: Name ---
|
||||||
|
|
||||||
const NamePanel = () => {
|
const NamePanel = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [name, setName] = useGlobal<string>(NEW_DASH_NAME_KEY, '');
|
const [name, setName] = useGlobal<string>(NEW_DASH_NAME_KEY, '');
|
||||||
const [description, setDescription] = useGlobal<string>(NEW_DASH_DESC_KEY, '');
|
const [description, setDescription] = useGlobal<string>(NEW_DASH_DESC_KEY, '');
|
||||||
|
|
||||||
@@ -239,13 +245,13 @@ const NamePanel = () => {
|
|||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<label className="text-xs font-medium text-duck-dark/50 flex items-center gap-1.5">
|
<label className="text-xs font-medium text-duck-dark/50 flex items-center gap-1.5">
|
||||||
<Type className="h-3 w-3" />
|
<Type className="h-3 w-3" />
|
||||||
Name
|
{t('dashboards.name')}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(ev) => setName(ev.target.value)}
|
onChange={(ev) => setName(ev.target.value)}
|
||||||
placeholder="My Dashboard"
|
placeholder={t('dashboards.namePlaceholder')}
|
||||||
autoFocus
|
autoFocus
|
||||||
className="rounded-lg border border-duck-dark/20 bg-background px-3 py-2 text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-2 focus:ring-duck-teal/30 focus:border-duck-teal/50"
|
className="rounded-lg border border-duck-dark/20 bg-background px-3 py-2 text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-2 focus:ring-duck-teal/30 focus:border-duck-teal/50"
|
||||||
/>
|
/>
|
||||||
@@ -253,12 +259,12 @@ const NamePanel = () => {
|
|||||||
<div className="flex flex-1 min-h-0 flex-col gap-1.5">
|
<div className="flex flex-1 min-h-0 flex-col gap-1.5">
|
||||||
<label className="text-xs font-medium text-duck-dark/50 flex items-center gap-1.5">
|
<label className="text-xs font-medium text-duck-dark/50 flex items-center gap-1.5">
|
||||||
<FileText className="h-3 w-3" />
|
<FileText className="h-3 w-3" />
|
||||||
Description
|
{t('dashboards.description')}
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={description}
|
value={description}
|
||||||
onChange={(ev) => setDescription(ev.target.value)}
|
onChange={(ev) => setDescription(ev.target.value)}
|
||||||
placeholder="What is this dashboard for?"
|
placeholder={t('dashboards.descriptionPlaceholder')}
|
||||||
className="flex-1 min-h-0 resize-none rounded-lg border border-duck-dark/20 bg-background px-3 py-2 text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-2 focus:ring-duck-teal/30 focus:border-duck-teal/50"
|
className="flex-1 min-h-0 resize-none rounded-lg border border-duck-dark/20 bg-background px-3 py-2 text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-2 focus:ring-duck-teal/30 focus:border-duck-teal/50"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -270,6 +276,7 @@ const NamePanel = () => {
|
|||||||
// --- Panel: Create ---
|
// --- Panel: Create ---
|
||||||
|
|
||||||
const CreatePanel = () => {
|
const CreatePanel = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
@@ -401,7 +408,7 @@ const CreatePanel = () => {
|
|||||||
<Button asChild variant="outline" className="cursor-pointer">
|
<Button asChild variant="outline" className="cursor-pointer">
|
||||||
<Link to={`/dashboards/${editingId}`}>
|
<Link to={`/dashboards/${editingId}`}>
|
||||||
<ArrowRight className="h-4 w-4 mr-1" />
|
<ArrowRight className="h-4 w-4 mr-1" />
|
||||||
Open Dashboard
|
{t('dashboards.open')}
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -453,13 +460,14 @@ const newDashLayout: LayoutNode = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const NewDashboardForm = () => {
|
const NewDashboardForm = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { registry } = useAppRegistry();
|
const { registry } = useAppRegistry();
|
||||||
const newDashRegistry = {
|
const newDashRegistry = {
|
||||||
...registry,
|
...registry,
|
||||||
'file-browser-cwd': { name: 'File Browser', icon: FolderOpen, component: CwdFileBrowser },
|
'file-browser-cwd': { name: panelName('panelName.fileBrowser'), icon: FolderOpen, component: CwdFileBrowser },
|
||||||
'new-ws-name': { name: 'Name', icon: Type, component: NamePanel },
|
'new-ws-name': { name: t('dashboards.name'), icon: Type, component: NamePanel },
|
||||||
'new-ws-template': { name: 'Template', icon: Layout, component: TemplatePanel },
|
'new-ws-template': { name: t('dashboards.template'), icon: Layout, component: TemplatePanel },
|
||||||
'new-ws-create': { name: 'Create', icon: Rocket, component: CreatePanel },
|
'new-ws-create': { name: t('dashboards.create'), icon: Rocket, component: CreatePanel },
|
||||||
};
|
};
|
||||||
|
|
||||||
return <WorkspaceLayout layout={newDashLayout} onLayoutChange={() => {}} registry={newDashRegistry} noHeader />;
|
return <WorkspaceLayout layout={newDashLayout} onLayoutChange={() => {}} registry={newDashRegistry} noHeader />;
|
||||||
@@ -470,6 +478,7 @@ const NewDashboardForm = () => {
|
|||||||
// Only reached when nothing is selected AND nothing is being created or edited — DashboardPreview checks
|
// Only reached when nothing is selected AND nothing is being created or edited — DashboardPreview checks
|
||||||
// those first now, so this component no longer needs its own creating/editing branch.
|
// those first now, so this component no longer needs its own creating/editing branch.
|
||||||
const DashboardPreviewEmpty = () => {
|
const DashboardPreviewEmpty = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [, setCreating] = useGlobal<boolean>(CREATING_DASHBOARD_KEY, false);
|
const [, setCreating] = useGlobal<boolean>(CREATING_DASHBOARD_KEY, false);
|
||||||
const [, setName] = useGlobal<string>(NEW_DASH_NAME_KEY, '');
|
const [, setName] = useGlobal<string>(NEW_DASH_NAME_KEY, '');
|
||||||
const [, setDescription] = useGlobal<string>(NEW_DASH_DESC_KEY, '');
|
const [, setDescription] = useGlobal<string>(NEW_DASH_DESC_KEY, '');
|
||||||
@@ -488,10 +497,10 @@ const DashboardPreviewEmpty = () => {
|
|||||||
<div className="h-full w-full">
|
<div className="h-full w-full">
|
||||||
<div className="flex h-full w-full flex-col items-center justify-center gap-4">
|
<div className="flex h-full w-full flex-col items-center justify-center gap-4">
|
||||||
<LayoutGrid className="h-10 w-10 text-white/80" />
|
<LayoutGrid className="h-10 w-10 text-white/80" />
|
||||||
<p className="text-sm text-white/80">Select a dashboard or create a new one</p>
|
<p className="text-sm text-white/80">{t('dashboards.selectOne')}</p>
|
||||||
<Button onClick={handleCreate} className="bg-duck-teal hover:bg-duck-teal/90 cursor-pointer">
|
<Button onClick={handleCreate} className="bg-duck-teal hover:bg-duck-teal/90 cursor-pointer">
|
||||||
<Plus className="h-4 w-4 mr-1" />
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
New Dashboard
|
{t('dashboards.new')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,23 +2,30 @@ import type { AppRegistryMeta } from '../../AppRegistry';
|
|||||||
import { LayoutGrid } from 'lucide-react';
|
import { LayoutGrid } from 'lucide-react';
|
||||||
import { DashboardListApp } from './DashboardListApp';
|
import { DashboardListApp } from './DashboardListApp';
|
||||||
import { DashboardPreview } from './DashboardPreview';
|
import { DashboardPreview } from './DashboardPreview';
|
||||||
|
import { panelName } from '../../locales';
|
||||||
|
|
||||||
export { DashboardListApp };
|
export { DashboardListApp };
|
||||||
export { DashboardPreview };
|
export { DashboardPreview };
|
||||||
export { CREATING_DASHBOARD_KEY, EDITING_DASHBOARD_KEY, NEW_DASH_NAME_KEY, NEW_DASH_DESC_KEY, NEW_DASH_TEMPLATE_KEY } from './constants';
|
export {
|
||||||
|
CREATING_DASHBOARD_KEY,
|
||||||
|
EDITING_DASHBOARD_KEY,
|
||||||
|
NEW_DASH_NAME_KEY,
|
||||||
|
NEW_DASH_DESC_KEY,
|
||||||
|
NEW_DASH_TEMPLATE_KEY,
|
||||||
|
} from './constants';
|
||||||
|
|
||||||
export const appRegistryMetas: AppRegistryMeta[] = [
|
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||||
{
|
{
|
||||||
key: 'dashboard-list',
|
key: 'dashboard-list',
|
||||||
name: 'Dashboards',
|
name: panelName('panelName.dashboards'),
|
||||||
icon: LayoutGrid,
|
icon: LayoutGrid,
|
||||||
component: DashboardListApp
|
component: DashboardListApp,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'dashboard-preview',
|
key: 'dashboard-preview',
|
||||||
name: 'Dashboard Preview',
|
name: panelName('panelName.dashboardPreview'),
|
||||||
icon: LayoutGrid,
|
icon: LayoutGrid,
|
||||||
component: DashboardPreview,
|
component: DashboardPreview,
|
||||||
availableOnPanel: false
|
availableOnPanel: false,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { CalendarEvent } from './shared';
|
import type { CalendarEvent } from './shared';
|
||||||
import { CalendarDays, MapPin, Repeat } from 'lucide-react';
|
import { CalendarDays, MapPin, Repeat } from 'lucide-react';
|
||||||
import { useCalendarEvents, useSelectedCollection } from './useDavData';
|
import { useCalendarEvents, useSelectedCollection } from './useDavData';
|
||||||
|
import { useTranslation } from '../../locales';
|
||||||
|
|
||||||
// Right panel of /calendar. Deliberately a grouped agenda list, not a month grid: the sidecar returns
|
// Right panel of /calendar. Deliberately a grouped agenda list, not a month grid: the sidecar returns
|
||||||
// events as they are stored, with RRULE unexpanded, so a grid would have to invent occurrences the server
|
// events as they are stored, with RRULE unexpanded, so a grid would have to invent occurrences the server
|
||||||
@@ -26,12 +27,13 @@ const formatTime = (ev: CalendarEvent) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const CalendarView = () => {
|
export const CalendarView = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { selected, isLoading: loadingCollections } = useSelectedCollection('calendar');
|
const { selected, isLoading: loadingCollections } = useSelectedCollection('calendar');
|
||||||
const { events, isLoading, error } = useCalendarEvents(selected?.path ?? null);
|
const { events, isLoading, error } = useCalendarEvents(selected?.path ?? null);
|
||||||
|
|
||||||
if (loadingCollections) return <Placeholder text="Loading calendars…" />;
|
if (loadingCollections) return <Placeholder text="Loading calendars…" />;
|
||||||
if (!selected) return <Placeholder text="No calendars yet. Create one from a CalDAV client." />;
|
if (!selected) return <Placeholder text="No calendars yet. Create one from a CalDAV client." />;
|
||||||
if (error) return <Placeholder text={`Could not read ${selected.displayName}: ${String(error)}`} />;
|
if (error) return <Placeholder text={t('dav.readFailed', { name: selected.displayName, reason: String(error) })} />;
|
||||||
if (isLoading) return <Placeholder text="Loading events…" />;
|
if (isLoading) return <Placeholder text="Loading events…" />;
|
||||||
if (events.length === 0) return <Placeholder text={`${selected.displayName} has no events.`} />;
|
if (events.length === 0) return <Placeholder text={`${selected.displayName} has no events.`} />;
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Link, useSearchParams } from 'react-router';
|
|||||||
import { CalendarDays, Contact as ContactIcon } from 'lucide-react';
|
import { CalendarDays, Contact as ContactIcon } from 'lucide-react';
|
||||||
import { davCollectionPath, COLLECTION_PARAM } from './shared';
|
import { davCollectionPath, COLLECTION_PARAM } from './shared';
|
||||||
import { useDavCollections } from './useDavData';
|
import { useDavCollections } from './useDavData';
|
||||||
|
import { useTranslation } from '../../locales';
|
||||||
|
|
||||||
// Left panel for both /calendar and /contacts: the list of collections belonging to this user.
|
// Left panel for both /calendar and /contacts: the list of collections belonging to this user.
|
||||||
//
|
//
|
||||||
@@ -22,6 +23,7 @@ type Props = {
|
|||||||
const ROW = 'group relative flex items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors';
|
const ROW = 'group relative flex items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors';
|
||||||
|
|
||||||
const CollectionNav = ({ kind, base, title, icon: Icon }: Props) => {
|
const CollectionNav = ({ kind, base, title, icon: Icon }: Props) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [params] = useSearchParams();
|
const [params] = useSearchParams();
|
||||||
const { collections, isLoading } = useDavCollections(kind);
|
const { collections, isLoading } = useDavCollections(kind);
|
||||||
const fromUrl = params.get(COLLECTION_PARAM);
|
const fromUrl = params.get(COLLECTION_PARAM);
|
||||||
@@ -69,19 +71,19 @@ const CollectionNav = ({ kind, base, title, icon: Icon }: Props) => {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{!isLoading && collections.length === 0 && (
|
{!isLoading && collections.length === 0 && (
|
||||||
<p className="px-3 py-2 text-xs text-muted-foreground">
|
<p className="px-3 py-2 text-xs text-muted-foreground">{t('dav.empty')}</p>
|
||||||
Nothing here yet. Add one from a phone or desktop client over CalDAV/CardDAV.
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CalendarNav = () => (
|
export const CalendarNav = () => {
|
||||||
<CollectionNav kind="calendar" base="/calendar" title="Calendar" icon={CalendarDays} />
|
const { t } = useTranslation();
|
||||||
);
|
return <CollectionNav kind="calendar" base="/calendar" title={t('dav.calendar')} icon={CalendarDays} />;
|
||||||
|
};
|
||||||
|
|
||||||
export const ContactsNav = () => (
|
export const ContactsNav = () => {
|
||||||
<CollectionNav kind="addressbook" base="/contacts" title="Contacts" icon={ContactIcon} />
|
const { t } = useTranslation();
|
||||||
);
|
return <CollectionNav kind="addressbook" base="/contacts" title={t('dav.contacts')} icon={ContactIcon} />;
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Building2, Mail, Phone, User } from 'lucide-react';
|
import { Building2, Mail, Phone, User } from 'lucide-react';
|
||||||
import { useDavContacts, useSelectedCollection } from './useDavData';
|
import { useDavContacts, useSelectedCollection } from './useDavData';
|
||||||
|
import { useTranslation } from '../../locales';
|
||||||
|
|
||||||
// Right panel of /contacts: the selected address book as cards, sorted by name.
|
// Right panel of /contacts: the selected address book as cards, sorted by name.
|
||||||
//
|
//
|
||||||
@@ -16,12 +17,13 @@ const initials = (name: string) =>
|
|||||||
.join('') || '?';
|
.join('') || '?';
|
||||||
|
|
||||||
export const ContactsView = () => {
|
export const ContactsView = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { selected, isLoading: loadingCollections } = useSelectedCollection('addressbook');
|
const { selected, isLoading: loadingCollections } = useSelectedCollection('addressbook');
|
||||||
const { contacts, isLoading, error } = useDavContacts(selected?.path ?? null);
|
const { contacts, isLoading, error } = useDavContacts(selected?.path ?? null);
|
||||||
|
|
||||||
if (loadingCollections) return <Placeholder text="Loading address books…" />;
|
if (loadingCollections) return <Placeholder text="Loading address books…" />;
|
||||||
if (!selected) return <Placeholder text="No address books yet. Create one from a CardDAV client." />;
|
if (!selected) return <Placeholder text="No address books yet. Create one from a CardDAV client." />;
|
||||||
if (error) return <Placeholder text={`Could not read ${selected.displayName}: ${String(error)}`} />;
|
if (error) return <Placeholder text={t('dav.readFailed', { name: selected.displayName, reason: String(error) })} />;
|
||||||
if (isLoading) return <Placeholder text="Loading contacts…" />;
|
if (isLoading) return <Placeholder text="Loading contacts…" />;
|
||||||
if (contacts.length === 0) return <Placeholder text={`${selected.displayName} has no contacts.`} />;
|
if (contacts.length === 0) return <Placeholder text={`${selected.displayName} has no contacts.`} />;
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { CalendarDays, Contact as ContactIcon, PanelLeft } from 'lucide-react';
|
|||||||
import { CalendarNav, ContactsNav } from './CollectionNav';
|
import { CalendarNav, ContactsNav } from './CollectionNav';
|
||||||
import { CalendarView } from './CalendarView';
|
import { CalendarView } from './CalendarView';
|
||||||
import { ContactsView } from './ContactsView';
|
import { ContactsView } from './ContactsView';
|
||||||
|
import { panelName } from '../../locales';
|
||||||
|
|
||||||
// One app folder for both /calendar and /contacts: they are the same DAV sidecar, the same collection
|
// One app folder for both /calendar and /contacts: they are the same DAV sidecar, the same collection
|
||||||
// list and the same selection rule, differing only in which kind of collection they show.
|
// list and the same selection rule, differing only in which kind of collection they show.
|
||||||
@@ -10,8 +11,32 @@ import { ContactsView } from './ContactsView';
|
|||||||
export { CalendarNav, ContactsNav, CalendarView, ContactsView };
|
export { CalendarNav, ContactsNav, CalendarView, ContactsView };
|
||||||
|
|
||||||
export const appRegistryMetas: AppRegistryMeta[] = [
|
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||||
{ key: 'calendar-nav', name: 'Calendar', icon: PanelLeft, component: CalendarNav, availableOnPanel: false },
|
{
|
||||||
{ key: 'calendar-view', name: 'Calendar', icon: CalendarDays, component: CalendarView, availableOnPanel: false },
|
key: 'calendar-nav',
|
||||||
{ key: 'contacts-nav', name: 'Contacts', icon: PanelLeft, component: ContactsNav, availableOnPanel: false },
|
name: panelName('panelName.calendar'),
|
||||||
{ key: 'contacts-view', name: 'Contacts', icon: ContactIcon, component: ContactsView, availableOnPanel: false },
|
icon: PanelLeft,
|
||||||
|
component: CalendarNav,
|
||||||
|
availableOnPanel: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'calendar-view',
|
||||||
|
name: panelName('panelName.calendar'),
|
||||||
|
icon: CalendarDays,
|
||||||
|
component: CalendarView,
|
||||||
|
availableOnPanel: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'contacts-nav',
|
||||||
|
name: panelName('panelName.contacts'),
|
||||||
|
icon: PanelLeft,
|
||||||
|
component: ContactsNav,
|
||||||
|
availableOnPanel: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'contacts-view',
|
||||||
|
name: panelName('panelName.contacts'),
|
||||||
|
icon: ContactIcon,
|
||||||
|
component: ContactsView,
|
||||||
|
availableOnPanel: false,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { AppRegistryMeta } from '../../AppRegistry';
|
|||||||
import { FolderOpen } from 'lucide-react';
|
import { FolderOpen } from 'lucide-react';
|
||||||
import { FileBrowserPanelWrapper } from './FileBrowserApp';
|
import { FileBrowserPanelWrapper } from './FileBrowserApp';
|
||||||
import { FileBrowserWidget } from './FileBrowserWidget';
|
import { FileBrowserWidget } from './FileBrowserWidget';
|
||||||
|
import { panelName } from '../../locales';
|
||||||
|
|
||||||
export { useFilesAPI, type DirEntry } from '../../hooks/useFilesAPI';
|
export { useFilesAPI, type DirEntry } from '../../hooks/useFilesAPI';
|
||||||
export { useTasks, type TaskSummary } from './useTasks';
|
export { useTasks, type TaskSummary } from './useTasks';
|
||||||
@@ -14,13 +15,13 @@ export { TaskRunnerModal } from './FileBrowserApp/components/TaskRunnerModal';
|
|||||||
export const appRegistryMetas: AppRegistryMeta[] = [
|
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||||
{
|
{
|
||||||
key: 'officerdev/file-browser',
|
key: 'officerdev/file-browser',
|
||||||
name: 'File Browser',
|
name: panelName('panelName.fileBrowser'),
|
||||||
icon: FolderOpen,
|
icon: FolderOpen,
|
||||||
component: FileBrowserPanelWrapper,
|
component: FileBrowserPanelWrapper,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'officerdev/file-browser-widget',
|
key: 'officerdev/file-browser-widget',
|
||||||
name: 'File Browser',
|
name: panelName('panelName.fileBrowser'),
|
||||||
icon: FolderOpen,
|
icon: FolderOpen,
|
||||||
component: FileBrowserWidget,
|
component: FileBrowserWidget,
|
||||||
availableOnPanel: false,
|
availableOnPanel: false,
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ import { TextRenderer } from './renderers/TextRenderer';
|
|||||||
import { ScrollToTopButton } from './renderers/ScrollToTopButton';
|
import { ScrollToTopButton } from './renderers/ScrollToTopButton';
|
||||||
import { EditorRenderer } from './renderers/EditorRenderer';
|
import { EditorRenderer } from './renderers/EditorRenderer';
|
||||||
import { JsonEditorRenderer } from './renderers/JsonEditorRenderer';
|
import { JsonEditorRenderer } from './renderers/JsonEditorRenderer';
|
||||||
|
import { useTranslation } from '../../locales';
|
||||||
|
|
||||||
export const FileViewerBody = () => {
|
export const FileViewerBody = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { filePath, fileName, root, fileType, content, loading, error, autoPlay, editing, setContent, handleDownload } =
|
const { filePath, fileName, root, fileType, content, loading, error, autoPlay, editing, setContent, handleDownload } =
|
||||||
useFileViewer();
|
useFileViewer();
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -49,7 +51,7 @@ export const FileViewerBody = () => {
|
|||||||
className="inline-flex items-center gap-1.5 rounded-md border border-duck-dark/20 px-3 py-1.5 text-xs text-duck-dark transition-colors hover:bg-duck-dark/5 cursor-pointer"
|
className="inline-flex items-center gap-1.5 rounded-md border border-duck-dark/20 px-3 py-1.5 text-xs text-duck-dark transition-colors hover:bg-duck-dark/5 cursor-pointer"
|
||||||
>
|
>
|
||||||
<Download className="h-3.5 w-3.5" />
|
<Download className="h-3.5 w-3.5" />
|
||||||
Download instead
|
{t('fileViewer.downloadInstead')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : fileType === 'pdf' ? (
|
) : fileType === 'pdf' ? (
|
||||||
@@ -57,7 +59,7 @@ export const FileViewerBody = () => {
|
|||||||
) : fileType === 'archive' ? (
|
) : fileType === 'archive' ? (
|
||||||
<div className="flex flex-col items-center justify-center h-full gap-3 text-duck-dark/50">
|
<div className="flex flex-col items-center justify-center h-full gap-3 text-duck-dark/50">
|
||||||
<FolderArchive className="h-10 w-10" />
|
<FolderArchive className="h-10 w-10" />
|
||||||
<span className="text-sm">Archive file</span>
|
<span className="text-sm">{t('fileViewer.archiveFile')}</span>
|
||||||
<span className="text-xs">{getArchiveBaseName(fileName)}</span>
|
<span className="text-xs">{getArchiveBaseName(fileName)}</span>
|
||||||
</div>
|
</div>
|
||||||
) : fileType === 'image' ? (
|
) : fileType === 'image' ? (
|
||||||
|
|||||||
@@ -17,8 +17,10 @@ import {
|
|||||||
import { getIcon } from 'material-file-icons';
|
import { getIcon } from 'material-file-icons';
|
||||||
import { getLang } from './file-types';
|
import { getLang } from './file-types';
|
||||||
import { useFileViewer } from './FileViewerContext';
|
import { useFileViewer } from './FileViewerContext';
|
||||||
|
import { useTranslation } from '../../locales';
|
||||||
|
|
||||||
export const FileViewerHeader = () => {
|
export const FileViewerHeader = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const {
|
const {
|
||||||
fileName,
|
fileName,
|
||||||
fileType,
|
fileType,
|
||||||
@@ -81,7 +83,7 @@ export const FileViewerHeader = () => {
|
|||||||
onClick={handleReadAloud}
|
onClick={handleReadAloud}
|
||||||
disabled={ttsLoading}
|
disabled={ttsLoading}
|
||||||
className="p-1 rounded hover:bg-current/10 disabled:opacity-40 transition-colors cursor-pointer"
|
className="p-1 rounded hover:bg-current/10 disabled:opacity-40 transition-colors cursor-pointer"
|
||||||
title="Read Aloud"
|
title={t('fileViewer.readAloud')}
|
||||||
>
|
>
|
||||||
{ttsLoading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Volume2 className="h-3.5 w-3.5" />}
|
{ttsLoading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Volume2 className="h-3.5 w-3.5" />}
|
||||||
</button>
|
</button>
|
||||||
@@ -91,7 +93,7 @@ export const FileViewerHeader = () => {
|
|||||||
onClick={handleOcr}
|
onClick={handleOcr}
|
||||||
disabled={ocrLoading}
|
disabled={ocrLoading}
|
||||||
className="p-1 rounded hover:bg-current/10 disabled:opacity-40 transition-colors cursor-pointer"
|
className="p-1 rounded hover:bg-current/10 disabled:opacity-40 transition-colors cursor-pointer"
|
||||||
title="Extract Text (OCR)"
|
title={t('fileViewer.ocr')}
|
||||||
>
|
>
|
||||||
{ocrLoading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <ScanText className="h-3.5 w-3.5" />}
|
{ocrLoading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <ScanText className="h-3.5 w-3.5" />}
|
||||||
</button>
|
</button>
|
||||||
@@ -101,7 +103,7 @@ export const FileViewerHeader = () => {
|
|||||||
onClick={handleTranscribe}
|
onClick={handleTranscribe}
|
||||||
disabled={transcribeLoading}
|
disabled={transcribeLoading}
|
||||||
className="p-1 rounded hover:bg-current/10 disabled:opacity-40 transition-colors cursor-pointer"
|
className="p-1 rounded hover:bg-current/10 disabled:opacity-40 transition-colors cursor-pointer"
|
||||||
title="Transcribe"
|
title={t('fileViewer.transcribe')}
|
||||||
>
|
>
|
||||||
{transcribeLoading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <FileText className="h-3.5 w-3.5" />}
|
{transcribeLoading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <FileText className="h-3.5 w-3.5" />}
|
||||||
</button>
|
</button>
|
||||||
@@ -111,7 +113,7 @@ export const FileViewerHeader = () => {
|
|||||||
onClick={handleExtractAudio}
|
onClick={handleExtractAudio}
|
||||||
disabled={extractAudioLoading}
|
disabled={extractAudioLoading}
|
||||||
className="p-1 rounded hover:bg-current/10 disabled:opacity-40 transition-colors cursor-pointer"
|
className="p-1 rounded hover:bg-current/10 disabled:opacity-40 transition-colors cursor-pointer"
|
||||||
title="Extract Audio"
|
title={t('fileViewer.extractAudio')}
|
||||||
>
|
>
|
||||||
{extractAudioLoading ? (
|
{extractAudioLoading ? (
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
@@ -125,7 +127,7 @@ export const FileViewerHeader = () => {
|
|||||||
onClick={handleExtract}
|
onClick={handleExtract}
|
||||||
disabled={extractLoading}
|
disabled={extractLoading}
|
||||||
className="p-1 rounded hover:bg-current/10 disabled:opacity-40 transition-colors cursor-pointer"
|
className="p-1 rounded hover:bg-current/10 disabled:opacity-40 transition-colors cursor-pointer"
|
||||||
title="Extract"
|
title={t('fileViewer.extract')}
|
||||||
>
|
>
|
||||||
{extractLoading ? (
|
{extractLoading ? (
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
@@ -139,7 +141,7 @@ export const FileViewerHeader = () => {
|
|||||||
onClick={handleSaveResult}
|
onClick={handleSaveResult}
|
||||||
disabled={saveResultLoading}
|
disabled={saveResultLoading}
|
||||||
className="p-1 rounded hover:bg-current/10 disabled:opacity-40 transition-colors cursor-pointer"
|
className="p-1 rounded hover:bg-current/10 disabled:opacity-40 transition-colors cursor-pointer"
|
||||||
title="Save next to original"
|
title={t('fileViewer.saveNextTo')}
|
||||||
>
|
>
|
||||||
{saveResultLoading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Save className="h-3.5 w-3.5" />}
|
{saveResultLoading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Save className="h-3.5 w-3.5" />}
|
||||||
</button>
|
</button>
|
||||||
@@ -148,7 +150,7 @@ export const FileViewerHeader = () => {
|
|||||||
<button
|
<button
|
||||||
onClick={handleDownload}
|
onClick={handleDownload}
|
||||||
className="p-1 rounded hover:bg-current/10 transition-colors cursor-pointer"
|
className="p-1 rounded hover:bg-current/10 transition-colors cursor-pointer"
|
||||||
title="Download"
|
title={t('fileViewer.download')}
|
||||||
>
|
>
|
||||||
<Download className="h-3.5 w-3.5" />
|
<Download className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useFilesRefresh } from '../../channels';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { getFileType } from './file-types';
|
import { getFileType } from './file-types';
|
||||||
import { FileViewerContext } from './FileViewerContext';
|
import { FileViewerContext } from './FileViewerContext';
|
||||||
|
import { useTranslation } from '../../locales';
|
||||||
|
|
||||||
type FileViewerProviderProps = {
|
type FileViewerProviderProps = {
|
||||||
filePath: string;
|
filePath: string;
|
||||||
@@ -48,6 +49,7 @@ export const FileViewerProvider = ({
|
|||||||
autoPlay = false,
|
autoPlay = false,
|
||||||
children,
|
children,
|
||||||
}: FileViewerProviderProps) => {
|
}: FileViewerProviderProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [content, setContent] = useState<string | null>(directContent ?? null);
|
const [content, setContent] = useState<string | null>(directContent ?? null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -104,7 +106,7 @@ export const FileViewerProvider = ({
|
|||||||
bumpFilesRefresh();
|
bumpFilesRefresh();
|
||||||
onReplaceView?.(filePath, root, audioPath, audioRoot);
|
onReplaceView?.(filePath, root, audioPath, audioRoot);
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Failed to generate speech audio');
|
toast.error(t('fileViewer.speechFailed'));
|
||||||
} finally {
|
} finally {
|
||||||
setTtsLoading(false);
|
setTtsLoading(false);
|
||||||
}
|
}
|
||||||
@@ -117,7 +119,7 @@ export const FileViewerProvider = ({
|
|||||||
bumpFilesRefresh();
|
bumpFilesRefresh();
|
||||||
onReplaceView?.(filePath, root, ocrPath, ocrRoot);
|
onReplaceView?.(filePath, root, ocrPath, ocrRoot);
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Failed to extract text from image');
|
toast.error(t('fileViewer.ocrFailed'));
|
||||||
} finally {
|
} finally {
|
||||||
setOcrLoading(false);
|
setOcrLoading(false);
|
||||||
}
|
}
|
||||||
@@ -130,7 +132,7 @@ export const FileViewerProvider = ({
|
|||||||
bumpFilesRefresh();
|
bumpFilesRefresh();
|
||||||
onReplaceView?.(filePath, root, transcriptionPath, transcriptionRoot);
|
onReplaceView?.(filePath, root, transcriptionPath, transcriptionRoot);
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Failed to transcribe audio');
|
toast.error(t('fileViewer.transcribeFailed'));
|
||||||
} finally {
|
} finally {
|
||||||
setTranscribeLoading(false);
|
setTranscribeLoading(false);
|
||||||
}
|
}
|
||||||
@@ -142,7 +144,7 @@ export const FileViewerProvider = ({
|
|||||||
const { audioPath, audioRoot } = await files.extractAudio(filePath);
|
const { audioPath, audioRoot } = await files.extractAudio(filePath);
|
||||||
onOpenFile?.(audioPath, audioRoot);
|
onOpenFile?.(audioPath, audioRoot);
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Failed to extract audio from video');
|
toast.error(t('fileViewer.extractAudioFailed'));
|
||||||
} finally {
|
} finally {
|
||||||
setExtractAudioLoading(false);
|
setExtractAudioLoading(false);
|
||||||
}
|
}
|
||||||
@@ -157,9 +159,9 @@ export const FileViewerProvider = ({
|
|||||||
const { savedPath } = await files.saveResult(filePath);
|
const { savedPath } = await files.saveResult(filePath);
|
||||||
const savedName = savedPath.split('/').pop() ?? savedPath;
|
const savedName = savedPath.split('/').pop() ?? savedPath;
|
||||||
bumpFilesRefresh();
|
bumpFilesRefresh();
|
||||||
toast.success(`Saved as "${savedName}"`);
|
toast.success(t('fileViewer.savedAs', { name: savedName }));
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Failed to save file');
|
toast.error(t('fileViewer.saveFailed'));
|
||||||
} finally {
|
} finally {
|
||||||
setSaveResultLoading(false);
|
setSaveResultLoading(false);
|
||||||
}
|
}
|
||||||
@@ -171,9 +173,9 @@ export const FileViewerProvider = ({
|
|||||||
try {
|
try {
|
||||||
const { extractedPath } = await files.extract(filePath);
|
const { extractedPath } = await files.extract(filePath);
|
||||||
const folderName = extractedPath.split('/').pop() ?? extractedPath;
|
const folderName = extractedPath.split('/').pop() ?? extractedPath;
|
||||||
toast.success(`Extracted to "${folderName}"`);
|
toast.success(t('fileViewer.extractedTo', { name: folderName }));
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Failed to extract archive');
|
toast.error(t('fileViewer.extractFailed'));
|
||||||
} finally {
|
} finally {
|
||||||
setExtractLoading(false);
|
setExtractLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react';
|
|||||||
import { Loader2, Music, Play, Pause, Volume2, VolumeX } from 'lucide-react';
|
import { Loader2, Music, Play, Pause, Volume2, VolumeX } from 'lucide-react';
|
||||||
import { formatTime } from '../file-types';
|
import { formatTime } from '../file-types';
|
||||||
import { useSeekBar, SeekBar } from './SeekBar';
|
import { useSeekBar, SeekBar } from './SeekBar';
|
||||||
|
import { useTranslation } from '../../../locales';
|
||||||
|
|
||||||
type AudioRendererProps = {
|
type AudioRendererProps = {
|
||||||
src: string;
|
src: string;
|
||||||
@@ -10,6 +11,7 @@ type AudioRendererProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const AudioRenderer = ({ src, fileName, autoPlay = false }: AudioRendererProps) => {
|
export const AudioRenderer = ({ src, fileName, autoPlay = false }: AudioRendererProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const audioRef = useRef<HTMLAudioElement>(null);
|
const audioRef = useRef<HTMLAudioElement>(null);
|
||||||
const [playing, setPlaying] = useState(false);
|
const [playing, setPlaying] = useState(false);
|
||||||
const [currentTime, setCurrent] = useState(0);
|
const [currentTime, setCurrent] = useState(0);
|
||||||
@@ -80,7 +82,7 @@ export const AudioRenderer = ({ src, fileName, autoPlay = false }: AudioRenderer
|
|||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-full">
|
<div className="flex items-center justify-center h-full">
|
||||||
<span className="text-sm text-destructive">Failed to load audio file</span>
|
<span className="text-sm text-destructive">{t('fileViewer.audioFailed')}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { editor as MonacoEditor } from 'monaco-editor';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { getLanguage } from '../../CodeEditor/language-map';
|
import { getLanguage } from '../../CodeEditor/language-map';
|
||||||
import { useFilesAPI } from '../../../hooks/useFilesAPI';
|
import { useFilesAPI } from '../../../hooks/useFilesAPI';
|
||||||
|
import { useTranslation } from '../../../locales';
|
||||||
|
|
||||||
type EditorRendererProps = {
|
type EditorRendererProps = {
|
||||||
content: string;
|
content: string;
|
||||||
@@ -14,6 +15,7 @@ type EditorRendererProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const EditorRenderer = ({ content, fileName, filePath, root, onSave }: EditorRendererProps) => {
|
export const EditorRenderer = ({ content, fileName, filePath, root, onSave }: EditorRendererProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const editorRef = useRef<MonacoEditor.IStandaloneCodeEditor | null>(null);
|
const editorRef = useRef<MonacoEditor.IStandaloneCodeEditor | null>(null);
|
||||||
const { writeFile } = useFilesAPI(root);
|
const { writeFile } = useFilesAPI(root);
|
||||||
|
|
||||||
@@ -24,9 +26,9 @@ export const EditorRenderer = ({ content, fileName, filePath, root, onSave }: Ed
|
|||||||
try {
|
try {
|
||||||
await writeFile(filePath, newContent);
|
await writeFile(filePath, newContent);
|
||||||
onSave?.(newContent);
|
onSave?.(newContent);
|
||||||
toast.success('File saved');
|
toast.success(t('fileViewer.saved'));
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Failed to save file');
|
toast.error(t('fileViewer.saveFailed'));
|
||||||
}
|
}
|
||||||
}, [filePath, writeFile, onSave]);
|
}, [filePath, writeFile, onSave]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { Loader2, ZoomIn, ZoomOut, RotateCw } from 'lucide-react';
|
import { Loader2, ZoomIn, ZoomOut, RotateCw } from 'lucide-react';
|
||||||
|
import { useTranslation } from '../../../locales';
|
||||||
|
|
||||||
const ZOOM_STEPS = [0.25, 0.5, 0.75, 1, 1.5, 2, 3, 5];
|
const ZOOM_STEPS = [0.25, 0.5, 0.75, 1, 1.5, 2, 3, 5];
|
||||||
const MIN_ZOOM = ZOOM_STEPS[0]!;
|
const MIN_ZOOM = ZOOM_STEPS[0]!;
|
||||||
@@ -11,6 +12,7 @@ type ImageRendererProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const ImageRenderer = ({ src, fileName }: ImageRendererProps) => {
|
export const ImageRenderer = ({ src, fileName }: ImageRendererProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const [zoom, setZoom] = useState(1);
|
const [zoom, setZoom] = useState(1);
|
||||||
const [rotation, setRotation] = useState(0);
|
const [rotation, setRotation] = useState(0);
|
||||||
@@ -78,7 +80,7 @@ export const ImageRenderer = ({ src, fileName }: ImageRendererProps) => {
|
|||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-full">
|
<div className="flex items-center justify-center h-full">
|
||||||
<span className="text-sm text-destructive">Failed to load image</span>
|
<span className="text-sm text-destructive">{t('fileViewer.imageFailed')}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { JsonEditor } from '@visual-json/react';
|
|||||||
import type { JsonValue } from '@visual-json/core';
|
import type { JsonValue } from '@visual-json/core';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useFilesAPI } from '../../../hooks/useFilesAPI';
|
import { useFilesAPI } from '../../../hooks/useFilesAPI';
|
||||||
|
import { useTranslation } from '../../../locales';
|
||||||
|
|
||||||
type JsonEditorRendererProps = {
|
type JsonEditorRendererProps = {
|
||||||
content: string;
|
content: string;
|
||||||
@@ -12,6 +13,7 @@ type JsonEditorRendererProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const JsonEditorRenderer = ({ content, filePath, root, onSave }: JsonEditorRendererProps) => {
|
export const JsonEditorRenderer = ({ content, filePath, root, onSave }: JsonEditorRendererProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { writeFile } = useFilesAPI(root);
|
const { writeFile } = useFilesAPI(root);
|
||||||
const valueRef = useRef<JsonValue>(JSON.parse(content));
|
const valueRef = useRef<JsonValue>(JSON.parse(content));
|
||||||
|
|
||||||
@@ -24,9 +26,9 @@ export const JsonEditorRenderer = ({ content, filePath, root, onSave }: JsonEdit
|
|||||||
try {
|
try {
|
||||||
await writeFile(filePath, newContent);
|
await writeFile(filePath, newContent);
|
||||||
onSave?.(newContent);
|
onSave?.(newContent);
|
||||||
toast.success('File saved');
|
toast.success(t('fileViewer.saved'));
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Failed to save file');
|
toast.error(t('fileViewer.saveFailed'));
|
||||||
}
|
}
|
||||||
}, [filePath, writeFile, onSave]);
|
}, [filePath, writeFile, onSave]);
|
||||||
|
|
||||||
@@ -40,12 +42,7 @@ export const JsonEditorRenderer = ({ content, filePath, root, onSave }: JsonEdit
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<JsonEditor
|
<JsonEditor defaultValue={valueRef.current} onChange={handleChange} height="100%" width="100%" />
|
||||||
defaultValue={valueRef.current}
|
|
||||||
onChange={handleChange}
|
|
||||||
height="100%"
|
|
||||||
width="100%"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
export const PdfRenderer = ({ src }: { src: string }) => (
|
import { useTranslation } from '../../../locales';
|
||||||
<iframe src={src} className="w-full h-full border-0" title="PDF viewer" />
|
|
||||||
);
|
export const PdfRenderer = ({ src }: { src: string }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return <iframe src={src} className="w-full h-full border-0" title={t('fileViewer.pdfViewer')} />;
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { ArrowUp } from 'lucide-react';
|
import { ArrowUp } from 'lucide-react';
|
||||||
|
import { useTranslation } from '../../../locales';
|
||||||
|
|
||||||
type ScrollToTopButtonProps = {
|
type ScrollToTopButtonProps = {
|
||||||
scrollContainer: React.RefObject<HTMLDivElement | null>;
|
scrollContainer: React.RefObject<HTMLDivElement | null>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ScrollToTopButton = ({ scrollContainer }: ScrollToTopButtonProps) => {
|
export const ScrollToTopButton = ({ scrollContainer }: ScrollToTopButtonProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [visible, setVisible] = useState(false);
|
const [visible, setVisible] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -22,7 +24,7 @@ export const ScrollToTopButton = ({ scrollContainer }: ScrollToTopButtonProps) =
|
|||||||
<button
|
<button
|
||||||
onClick={() => scrollContainer.current?.scrollTo({ top: 0, behavior: 'smooth' })}
|
onClick={() => scrollContainer.current?.scrollTo({ top: 0, behavior: 'smooth' })}
|
||||||
className="sticky bottom-4 float-right mr-4 z-10 p-2.5 rounded-full bg-duck-teal text-white shadow-lg hover:bg-duck-teal/90 active:scale-95 transition-all cursor-pointer"
|
className="sticky bottom-4 float-right mr-4 z-10 p-2.5 rounded-full bg-duck-teal text-white shadow-lg hover:bg-duck-teal/90 active:scale-95 transition-all cursor-pointer"
|
||||||
title="Scroll to top"
|
title={t('fileViewer.scrollTop')}
|
||||||
>
|
>
|
||||||
<ArrowUp className="h-4 w-4" />
|
<ArrowUp className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react';
|
|||||||
import { Loader2, Maximize2, Minimize2, Play, Pause, Volume2, VolumeX, Captions, Languages } from 'lucide-react';
|
import { Loader2, Maximize2, Minimize2, Play, Pause, Volume2, VolumeX, Captions, Languages } from 'lucide-react';
|
||||||
import { getExt, formatTime, getRawUrl, getSubtitlesUrl, getSubtitleVttUrl, getAudioTracksUrl } from '../file-types';
|
import { getExt, formatTime, getRawUrl, getSubtitlesUrl, getSubtitleVttUrl, getAudioTracksUrl } from '../file-types';
|
||||||
import { useSeekBar, SeekBar } from './SeekBar';
|
import { useSeekBar, SeekBar } from './SeekBar';
|
||||||
|
import { useTranslation } from '../../../locales';
|
||||||
|
|
||||||
type VideoRendererProps = {
|
type VideoRendererProps = {
|
||||||
src: string;
|
src: string;
|
||||||
@@ -14,6 +15,7 @@ type SubtitleTrack = { id: number; lang: string; label: string };
|
|||||||
type AudioOption = { id: number; label: string };
|
type AudioOption = { id: number; label: string };
|
||||||
|
|
||||||
export const VideoRenderer = ({ src, fileName, filePath, root }: VideoRendererProps) => {
|
export const VideoRenderer = ({ src, fileName, filePath, root }: VideoRendererProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const hideTimer = useRef<ReturnType<typeof setTimeout>>(null);
|
const hideTimer = useRef<ReturnType<typeof setTimeout>>(null);
|
||||||
@@ -105,9 +107,14 @@ export const VideoRenderer = ({ src, fileName, filePath, root }: VideoRendererPr
|
|||||||
.then((list: Array<{ id: number; lang: string; title: string }>) => {
|
.then((list: Array<{ id: number; lang: string; title: string }>) => {
|
||||||
if (cancelled || !Array.isArray(list)) return;
|
if (cancelled || !Array.isArray(list)) return;
|
||||||
setSubtitles(
|
setSubtitles(
|
||||||
list.map((t) => {
|
list.map((track) => {
|
||||||
const realLang = t.lang && t.lang !== 'und' && t.lang !== 'unknown' ? t.lang.toUpperCase() : '';
|
const realLang =
|
||||||
return { id: t.id, lang: t.lang || 'und', label: t.title || realLang || `Track ${t.id + 1}` };
|
track.lang && track.lang !== 'und' && track.lang !== 'unknown' ? track.lang.toUpperCase() : '';
|
||||||
|
return {
|
||||||
|
id: track.id,
|
||||||
|
lang: track.lang || 'und',
|
||||||
|
label: track.title || realLang || t('fileViewer.track', { n: track.id + 1 }),
|
||||||
|
};
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
@@ -126,9 +133,10 @@ export const VideoRenderer = ({ src, fileName, filePath, root }: VideoRendererPr
|
|||||||
.then((list: Array<{ id: number; lang: string; title: string }>) => {
|
.then((list: Array<{ id: number; lang: string; title: string }>) => {
|
||||||
if (cancelled || !Array.isArray(list)) return;
|
if (cancelled || !Array.isArray(list)) return;
|
||||||
setAudioTracks(
|
setAudioTracks(
|
||||||
list.map((t) => {
|
list.map((track) => {
|
||||||
const realLang = t.lang && t.lang !== 'und' && t.lang !== 'unknown' ? t.lang.toUpperCase() : '';
|
const realLang =
|
||||||
return { id: t.id, label: t.title || realLang || `Track ${t.id + 1}` };
|
track.lang && track.lang !== 'und' && track.lang !== 'unknown' ? track.lang.toUpperCase() : '';
|
||||||
|
return { id: track.id, label: track.title || realLang || t('fileViewer.track', { n: track.id + 1 }) };
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
@@ -213,7 +221,7 @@ export const VideoRenderer = ({ src, fileName, filePath, root }: VideoRendererPr
|
|||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-full">
|
<div className="flex items-center justify-center h-full">
|
||||||
<span className="text-sm text-destructive">Failed to load video</span>
|
<span className="text-sm text-destructive">{t('fileViewer.videoFailed')}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -252,7 +260,7 @@ export const VideoRenderer = ({ src, fileName, filePath, root }: VideoRendererPr
|
|||||||
<button
|
<button
|
||||||
onClick={() => setShowAudioMenu((s) => !s)}
|
onClick={() => setShowAudioMenu((s) => !s)}
|
||||||
className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-black/50 backdrop-blur-sm text-xs text-white/90 hover:bg-black/70 transition-colors cursor-pointer"
|
className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-black/50 backdrop-blur-sm text-xs text-white/90 hover:bg-black/70 transition-colors cursor-pointer"
|
||||||
title="Audio track"
|
title={t('fileViewer.audioTrack')}
|
||||||
>
|
>
|
||||||
<Languages className="h-3.5 w-3.5" />
|
<Languages className="h-3.5 w-3.5" />
|
||||||
<span className="max-w-[140px] truncate">{audioTracks[activeAudio]?.label ?? 'Audio'}</span>
|
<span className="max-w-[140px] truncate">{audioTracks[activeAudio]?.label ?? 'Audio'}</span>
|
||||||
@@ -337,8 +345,8 @@ export const VideoRenderer = ({ src, fileName, filePath, root }: VideoRendererPr
|
|||||||
<button
|
<button
|
||||||
onClick={() => setShowSubMenu((s) => !s)}
|
onClick={() => setShowSubMenu((s) => !s)}
|
||||||
className={`p-1 transition-colors cursor-pointer ${activeSub !== null ? 'text-duck-teal' : 'text-white/70 hover:text-white'}`}
|
className={`p-1 transition-colors cursor-pointer ${activeSub !== null ? 'text-duck-teal' : 'text-white/70 hover:text-white'}`}
|
||||||
title="Subtitles"
|
title={t('fileViewer.subtitles')}
|
||||||
aria-label="Subtitles"
|
aria-label={t('fileViewer.subtitles')}
|
||||||
>
|
>
|
||||||
<Captions className="h-4 w-4" />
|
<Captions className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { Bomb, Loader2, Play, RefreshCw } from "lucide-react";
|
import { Bomb, Loader2, Play, RefreshCw } from 'lucide-react';
|
||||||
import { useClient } from "hooks/useClient";
|
import { useClient } from 'hooks/useClient';
|
||||||
import { RfbClient } from "./rfb";
|
import type { RfbError } from './rfb';
|
||||||
|
import { RfbClient } from './rfb';
|
||||||
|
import { useTranslation } from '../../locales';
|
||||||
|
|
||||||
|
type PanelT = ReturnType<typeof useTranslation>['t'];
|
||||||
|
|
||||||
type SessionStatus = {
|
type SessionStatus = {
|
||||||
installed: boolean;
|
installed: boolean;
|
||||||
@@ -16,7 +20,26 @@ const VISIBLE = { width: 164, height: 226 };
|
|||||||
|
|
||||||
const BUTTON_MASK: Record<number, number> = { 0: 1, 1: 2, 2: 4 };
|
const BUTTON_MASK: Record<number, number> = { 0: 1, 1: 2, 2: 4 };
|
||||||
|
|
||||||
|
/** Explicit map from RfbError to literal keys — never a key assembled from `error.code`. */
|
||||||
|
const describeRfbError = (error: RfbError, t: PanelT): string => {
|
||||||
|
switch (error.code) {
|
||||||
|
case 'connectionFailed':
|
||||||
|
return t('minesweeper.error.connectionFailed');
|
||||||
|
case 'refused':
|
||||||
|
return t('minesweeper.error.refused');
|
||||||
|
case 'authUnsupported':
|
||||||
|
return t('minesweeper.error.authUnsupported');
|
||||||
|
case 'handshakeRejected':
|
||||||
|
return t('minesweeper.error.handshakeRejected');
|
||||||
|
case 'unknownMessage':
|
||||||
|
return t('minesweeper.error.unknownMessage', { type: error.type });
|
||||||
|
case 'unknownEncoding':
|
||||||
|
return t('minesweeper.error.unknownEncoding', { encoding: error.encoding });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const MinesweeperPanel = () => {
|
export const MinesweeperPanel = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
// useClient() rebuilds its verbs on EVERY render, so putting one in a dependency list gives that
|
// useClient() rebuilds its verbs on EVERY render, so putting one in a dependency list gives that
|
||||||
// list a new identity every render. Held in a ref instead — the same fix the Jellyfin player needed
|
// list a new identity every render. Held in a ref instead — the same fix the Jellyfin player needed
|
||||||
// after its playback reports silently stopped for days. Getting this wrong here meant `refresh` was
|
// after its playback reports silently stopped for days. Getting this wrong here meant `refresh` was
|
||||||
@@ -45,9 +68,9 @@ export const MinesweeperPanel = () => {
|
|||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
adopt(await clientRef.current.get<SessionStatus>("/minesweeper/status"));
|
adopt(await clientRef.current.get<SessionStatus>('/minesweeper/status'));
|
||||||
} catch {
|
} catch {
|
||||||
setError("Could not reach the server.");
|
setError('Could not reach the server.');
|
||||||
}
|
}
|
||||||
}, [adopt]);
|
}, [adopt]);
|
||||||
|
|
||||||
@@ -59,9 +82,9 @@ export const MinesweeperPanel = () => {
|
|||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
adopt(await clientRef.current.post<SessionStatus>("/minesweeper/start", {}));
|
adopt(await clientRef.current.post<SessionStatus>('/minesweeper/start', {}));
|
||||||
} catch {
|
} catch {
|
||||||
setError("The session would not start. Is wine-minesweeper installed on the server?");
|
setError('The session would not start. Is wine-minesweeper installed on the server?');
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@@ -73,7 +96,7 @@ export const MinesweeperPanel = () => {
|
|||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
const offscreen = offscreenRef.current;
|
const offscreen = offscreenRef.current;
|
||||||
if (!canvas || !offscreen) return;
|
if (!canvas || !offscreen) return;
|
||||||
const context = canvas.getContext("2d");
|
const context = canvas.getContext('2d');
|
||||||
if (!context) return;
|
if (!context) return;
|
||||||
const { width, height } = geometryRef.current;
|
const { width, height } = geometryRef.current;
|
||||||
if (canvas.width !== width || canvas.height !== height) {
|
if (canvas.width !== width || canvas.height !== height) {
|
||||||
@@ -86,15 +109,15 @@ export const MinesweeperPanel = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!status?.running) return;
|
if (!status?.running) return;
|
||||||
|
|
||||||
const scheme = window.location.protocol === "https:" ? "wss" : "ws";
|
const scheme = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||||
// 'BEARER_TOKEN' is the key the rest of the app uses (Terminal.tsx, useAudioRecording). Reading
|
// 'BEARER_TOKEN' is the key the rest of the app uses (Terminal.tsx, useAudioRecording). Reading
|
||||||
// 'token' here sent an empty string, and upgradeWs answered 401 before any RFB byte was exchanged.
|
// 'token' here sent an empty string, and upgradeWs answered 401 before any RFB byte was exchanged.
|
||||||
const token = localStorage.getItem("BEARER_TOKEN") ?? sessionStorage.getItem("BEARER_TOKEN") ?? "";
|
const token = localStorage.getItem('BEARER_TOKEN') ?? sessionStorage.getItem('BEARER_TOKEN') ?? '';
|
||||||
const url = `${scheme}://${window.location.host}/api/minesweeper/ws?token=${encodeURIComponent(token)}`;
|
const url = `${scheme}://${window.location.host}/api/minesweeper/ws?token=${encodeURIComponent(token)}`;
|
||||||
|
|
||||||
const rfb = new RfbClient(url, {
|
const rfb = new RfbClient(url, {
|
||||||
onReady: (width, height) => {
|
onReady: (width, height) => {
|
||||||
const offscreen = document.createElement("canvas");
|
const offscreen = document.createElement('canvas');
|
||||||
offscreen.width = width;
|
offscreen.width = width;
|
||||||
offscreen.height = height;
|
offscreen.height = height;
|
||||||
offscreenRef.current = offscreen;
|
offscreenRef.current = offscreen;
|
||||||
@@ -102,12 +125,12 @@ export const MinesweeperPanel = () => {
|
|||||||
setError(null);
|
setError(null);
|
||||||
},
|
},
|
||||||
onFrame: (image, x, y) => {
|
onFrame: (image, x, y) => {
|
||||||
offscreenRef.current?.getContext("2d")?.putImageData(image, x, y);
|
offscreenRef.current?.getContext('2d')?.putImageData(image, x, y);
|
||||||
present();
|
present();
|
||||||
},
|
},
|
||||||
onCopyRect: (from, to) => {
|
onCopyRect: (from, to) => {
|
||||||
const offscreen = offscreenRef.current;
|
const offscreen = offscreenRef.current;
|
||||||
const context = offscreen?.getContext("2d");
|
const context = offscreen?.getContext('2d');
|
||||||
if (!offscreen || !context) return;
|
if (!offscreen || !context) return;
|
||||||
context.drawImage(offscreen, from.x, from.y, to.w, to.h, to.x, to.y, to.w, to.h);
|
context.drawImage(offscreen, from.x, from.y, to.w, to.h, to.x, to.y, to.w, to.h);
|
||||||
present();
|
present();
|
||||||
@@ -120,7 +143,7 @@ export const MinesweeperPanel = () => {
|
|||||||
}
|
}
|
||||||
rfb.requestUpdate(false);
|
rfb.requestUpdate(false);
|
||||||
},
|
},
|
||||||
onError: (message) => setError(message),
|
onError: (rfbError) => setError(describeRfbError(rfbError, t)),
|
||||||
onClose: () => setConnected(false),
|
onClose: () => setConnected(false),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -138,7 +161,7 @@ export const MinesweeperPanel = () => {
|
|||||||
if (!connected) return;
|
if (!connected) return;
|
||||||
const timer = setInterval(async () => {
|
const timer = setInterval(async () => {
|
||||||
try {
|
try {
|
||||||
const next = await clientRef.current.get<SessionStatus>("/minesweeper/status");
|
const next = await clientRef.current.get<SessionStatus>('/minesweeper/status');
|
||||||
if (!next.window) return;
|
if (!next.window) return;
|
||||||
const { width, height } = next.window;
|
const { width, height } = next.window;
|
||||||
if (width !== geometryRef.current.width || height !== geometryRef.current.height) {
|
if (width !== geometryRef.current.width || height !== geometryRef.current.height) {
|
||||||
@@ -159,14 +182,14 @@ export const MinesweeperPanel = () => {
|
|||||||
return { x: (ev.clientX - rect.left) * scaleX, y: (ev.clientY - rect.top) * scaleY };
|
return { x: (ev.clientX - rect.left) * scaleX, y: (ev.clientY - rect.top) * scaleY };
|
||||||
};
|
};
|
||||||
|
|
||||||
const onPointer = (ev: React.MouseEvent<HTMLCanvasElement>, kind: "down" | "up" | "move") => {
|
const onPointer = (ev: React.MouseEvent<HTMLCanvasElement>, kind: 'down' | 'up' | 'move') => {
|
||||||
const rfb = rfbRef.current;
|
const rfb = rfbRef.current;
|
||||||
if (!rfb) return;
|
if (!rfb) return;
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
|
|
||||||
if (kind !== "move") {
|
if (kind !== 'move') {
|
||||||
const bit = BUTTON_MASK[ev.button] ?? 0;
|
const bit = BUTTON_MASK[ev.button] ?? 0;
|
||||||
buttonsRef.current = kind === "down" ? buttonsRef.current | bit : buttonsRef.current & ~bit;
|
buttonsRef.current = kind === 'down' ? buttonsRef.current | bit : buttonsRef.current & ~bit;
|
||||||
}
|
}
|
||||||
const { x, y } = toFramebuffer(ev);
|
const { x, y } = toFramebuffer(ev);
|
||||||
rfb.pointer(x, y, buttonsRef.current);
|
rfb.pointer(x, y, buttonsRef.current);
|
||||||
@@ -176,9 +199,11 @@ export const MinesweeperPanel = () => {
|
|||||||
return (
|
return (
|
||||||
<Centred>
|
<Centred>
|
||||||
<Bomb className="size-8 text-muted-foreground" />
|
<Bomb className="size-8 text-muted-foreground" />
|
||||||
<p className="text-sm text-muted-foreground">Minesweeper is not installed on this server.</p>
|
<p className="text-sm text-muted-foreground">{t('minesweeper.notInstalled')}</p>
|
||||||
|
{/* The package name sits at the end so the sentence is one key rather than two around an
|
||||||
|
inline element — see docs/localization.md on the fragmentation that produces. */}
|
||||||
<p className="max-w-sm text-center text-xs text-muted-foreground">
|
<p className="max-w-sm text-center text-xs text-muted-foreground">
|
||||||
It needs <code>wine-minesweeper</code> beside the platform, with wine and a 32-bit prefix.
|
{t('minesweeper.needs')} <code>wine-minesweeper</code>
|
||||||
</p>
|
</p>
|
||||||
</Centred>
|
</Centred>
|
||||||
);
|
);
|
||||||
@@ -188,7 +213,7 @@ export const MinesweeperPanel = () => {
|
|||||||
return (
|
return (
|
||||||
<Centred>
|
<Centred>
|
||||||
<Bomb className="size-8 text-muted-foreground" />
|
<Bomb className="size-8 text-muted-foreground" />
|
||||||
<p className="text-sm text-muted-foreground">The Minesweeper session is not running.</p>
|
<p className="text-sm text-muted-foreground">{t('minesweeper.notRunning')}</p>
|
||||||
{error ? <p className="text-xs text-destructive">{error}</p> : null}
|
{error ? <p className="text-xs text-destructive">{error}</p> : null}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -197,7 +222,7 @@ export const MinesweeperPanel = () => {
|
|||||||
className="flex items-center gap-2 rounded-md bg-primary px-3 py-1.5 text-sm text-primary-foreground disabled:opacity-60"
|
className="flex items-center gap-2 rounded-md bg-primary px-3 py-1.5 text-sm text-primary-foreground disabled:opacity-60"
|
||||||
>
|
>
|
||||||
{busy ? <Loader2 className="size-4 animate-spin" /> : <Play className="size-4" />}
|
{busy ? <Loader2 className="size-4 animate-spin" /> : <Play className="size-4" />}
|
||||||
{busy ? "Starting" : "Start it"}
|
{busy ? 'Starting' : 'Start it'}
|
||||||
</button>
|
</button>
|
||||||
</Centred>
|
</Centred>
|
||||||
);
|
);
|
||||||
@@ -209,17 +234,17 @@ export const MinesweeperPanel = () => {
|
|||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
width={VISIBLE.width}
|
width={VISIBLE.width}
|
||||||
height={VISIBLE.height}
|
height={VISIBLE.height}
|
||||||
onMouseDown={(ev) => onPointer(ev, "down")}
|
onMouseDown={(ev) => onPointer(ev, 'down')}
|
||||||
onMouseUp={(ev) => onPointer(ev, "up")}
|
onMouseUp={(ev) => onPointer(ev, 'up')}
|
||||||
onMouseMove={(ev) => onPointer(ev, "move")}
|
onMouseMove={(ev) => onPointer(ev, 'move')}
|
||||||
onContextMenu={(ev) => ev.preventDefault()}
|
onContextMenu={(ev) => ev.preventDefault()}
|
||||||
// Nearest-neighbour: this is a 16px-per-cell bitmap from 1995 and smoothing it looks like a
|
// Nearest-neighbour: this is a 16px-per-cell bitmap from 1995 and smoothing it looks like a
|
||||||
// photograph of a screen rather than the screen.
|
// photograph of a screen rather than the screen.
|
||||||
className="rounded-sm shadow-lg [image-rendering:pixelated]"
|
className="rounded-sm shadow-lg [image-rendering:pixelated]"
|
||||||
style={{ cursor: connected ? "default" : "progress" }}
|
style={{ cursor: connected ? 'default' : 'progress' }}
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||||
<span>{connected ? "connected" : "connecting…"}</span>
|
<span>{connected ? 'connected' : 'connecting…'}</span>
|
||||||
<button type="button" onClick={() => void refresh()} className="flex items-center gap-1 hover:text-foreground">
|
<button type="button" onClick={() => void refresh()} className="flex items-center gap-1 hover:text-foreground">
|
||||||
<RefreshCw className="size-3" /> refresh
|
<RefreshCw className="size-3" /> refresh
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -26,10 +26,26 @@ export type RfbEvents = {
|
|||||||
onFrame: (image: ImageData, x: number, y: number) => void;
|
onFrame: (image: ImageData, x: number, y: number) => void;
|
||||||
onCopyRect: (from: { x: number; y: number }, to: { x: number; y: number; w: number; h: number }) => void;
|
onCopyRect: (from: { x: number; y: number }, to: { x: number; y: number; w: number; h: number }) => void;
|
||||||
onResize: (width: number, height: number) => void;
|
onResize: (width: number, height: number) => void;
|
||||||
onError: (message: string) => void;
|
onError: (error: RfbError) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What went wrong, as a code rather than a sentence.
|
||||||
|
*
|
||||||
|
* A protocol client has no business holding user-facing prose: it cannot reach a React hook, and the
|
||||||
|
* words belong to whoever is rendering them. The panel maps each code to a catalog key — an explicit
|
||||||
|
* map from a closed union to literal keys, which is what the naming convention asks for instead of
|
||||||
|
* building a key at runtime.
|
||||||
|
*/
|
||||||
|
export type RfbError =
|
||||||
|
| { code: 'connectionFailed' }
|
||||||
|
| { code: 'refused' }
|
||||||
|
| { code: 'authUnsupported' }
|
||||||
|
| { code: 'handshakeRejected' }
|
||||||
|
| { code: 'unknownMessage'; type: number }
|
||||||
|
| { code: 'unknownEncoding'; encoding: number };
|
||||||
|
|
||||||
const ENCODING_RAW = 0;
|
const ENCODING_RAW = 0;
|
||||||
const ENCODING_COPY_RECT = 1;
|
const ENCODING_COPY_RECT = 1;
|
||||||
const PSEUDO_ENCODING_DESKTOP_SIZE = -223;
|
const PSEUDO_ENCODING_DESKTOP_SIZE = -223;
|
||||||
@@ -54,7 +70,7 @@ export class RfbClient {
|
|||||||
this.closed = true;
|
this.closed = true;
|
||||||
this.events.onClose();
|
this.events.onClose();
|
||||||
};
|
};
|
||||||
this.socket.onerror = () => this.events.onError('The connection to the Minesweeper session failed.');
|
this.socket.onerror = () => this.events.onError({ code: 'connectionFailed' });
|
||||||
}
|
}
|
||||||
|
|
||||||
close(): void {
|
close(): void {
|
||||||
@@ -125,7 +141,7 @@ export class RfbClient {
|
|||||||
// A zero count means the server is refusing us, and the reason follows as a string.
|
// A zero count means the server is refusing us, and the reason follows as a string.
|
||||||
const body = this.take(1 + 4);
|
const body = this.take(1 + 4);
|
||||||
if (!body) return false;
|
if (!body) return false;
|
||||||
this.events.onError('The VNC server refused the connection.');
|
this.events.onError({ code: 'refused' });
|
||||||
this.close();
|
this.close();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -134,7 +150,7 @@ export class RfbClient {
|
|||||||
|
|
||||||
const types = Array.from(head.subarray(1));
|
const types = Array.from(head.subarray(1));
|
||||||
if (!types.includes(1)) {
|
if (!types.includes(1)) {
|
||||||
this.events.onError('The VNC server wants authentication, which this client does not implement.');
|
this.events.onError({ code: 'authUnsupported' });
|
||||||
this.close();
|
this.close();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -147,7 +163,7 @@ export class RfbClient {
|
|||||||
const head = this.take(4);
|
const head = this.take(4);
|
||||||
if (!head) return false;
|
if (!head) return false;
|
||||||
if (new DataView(head.buffer, head.byteOffset, 4).getUint32(0) !== 0) {
|
if (new DataView(head.buffer, head.byteOffset, 4).getUint32(0) !== 0) {
|
||||||
this.events.onError('The VNC server rejected the handshake.');
|
this.events.onError({ code: 'handshakeRejected' });
|
||||||
this.close();
|
this.close();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -231,7 +247,7 @@ export class RfbClient {
|
|||||||
return this.take(8 + length) !== null;
|
return this.take(8 + length) !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.events.onError(`The VNC server sent an unknown message type (${type}).`);
|
this.events.onError({ code: 'unknownMessage', type });
|
||||||
this.close();
|
this.close();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -251,7 +267,14 @@ export class RfbClient {
|
|||||||
for (let i = 0; i < rectangles; i++) {
|
for (let i = 0; i < rectangles; i++) {
|
||||||
if (this.buffer.length < offset + 12) return false;
|
if (this.buffer.length < offset + 12) return false;
|
||||||
const view = new DataView(this.buffer.buffer, this.buffer.byteOffset + offset, 12);
|
const view = new DataView(this.buffer.buffer, this.buffer.byteOffset + offset, 12);
|
||||||
const rect = { x: view.getUint16(0), y: view.getUint16(2), w: view.getUint16(4), h: view.getUint16(6), encoding: view.getInt32(8), at: offset + 12 };
|
const rect = {
|
||||||
|
x: view.getUint16(0),
|
||||||
|
y: view.getUint16(2),
|
||||||
|
w: view.getUint16(4),
|
||||||
|
h: view.getUint16(6),
|
||||||
|
encoding: view.getInt32(8),
|
||||||
|
at: offset + 12,
|
||||||
|
};
|
||||||
offset += 12;
|
offset += 12;
|
||||||
|
|
||||||
if (rect.encoding === ENCODING_RAW) offset += rect.w * rect.h * 4;
|
if (rect.encoding === ENCODING_RAW) offset += rect.w * rect.h * 4;
|
||||||
@@ -259,7 +282,7 @@ export class RfbClient {
|
|||||||
else if (rect.encoding === PSEUDO_ENCODING_DESKTOP_SIZE) {
|
else if (rect.encoding === PSEUDO_ENCODING_DESKTOP_SIZE) {
|
||||||
/* no payload — the rect's w/h ARE the new size */
|
/* no payload — the rect's w/h ARE the new size */
|
||||||
} else {
|
} else {
|
||||||
this.events.onError(`The VNC server used encoding ${rect.encoding}, which this client did not ask for.`);
|
this.events.onError({ code: 'unknownEncoding', encoding: rect.encoding });
|
||||||
this.close();
|
this.close();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Puzzle } from 'lucide-react';
|
|||||||
import type { AppRegistryMeta } from '../../AppRegistry';
|
import type { AppRegistryMeta } from '../../AppRegistry';
|
||||||
import { PluginsList } from './PluginsList';
|
import { PluginsList } from './PluginsList';
|
||||||
import { PluginDetail } from './PluginDetail';
|
import { PluginDetail } from './PluginDetail';
|
||||||
|
import { panelName } from '../../locales';
|
||||||
|
|
||||||
export { PluginsList } from './PluginsList';
|
export { PluginsList } from './PluginsList';
|
||||||
export { PluginDetail } from './PluginDetail';
|
export { PluginDetail } from './PluginDetail';
|
||||||
@@ -12,6 +13,18 @@ export type { PluginItem, PluginPermission } from './usePlugins';
|
|||||||
// both read it. `availableOnPanel: false` keeps them off the generic picker: they only make sense on
|
// both read it. `availableOnPanel: false` keeps them off the generic picker: they only make sense on
|
||||||
// /plugins, together.
|
// /plugins, together.
|
||||||
export const appRegistryMetas: AppRegistryMeta[] = [
|
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||||
{ key: 'plugins-list', name: 'Plugins', icon: Puzzle, component: PluginsList, availableOnPanel: false },
|
{
|
||||||
{ key: 'plugin-detail', name: 'Plugin detail', icon: Puzzle, component: PluginDetail, availableOnPanel: false },
|
key: 'plugins-list',
|
||||||
|
name: panelName('panelName.plugins'),
|
||||||
|
icon: Puzzle,
|
||||||
|
component: PluginsList,
|
||||||
|
availableOnPanel: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'plugin-detail',
|
||||||
|
name: panelName('panelName.pluginDetail'),
|
||||||
|
icon: Puzzle,
|
||||||
|
component: PluginDetail,
|
||||||
|
availableOnPanel: false,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Globe } from 'lucide-react';
|
|||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
import { useWorkspace } from '../../components/Workspace';
|
import { useWorkspace } from '../../components/Workspace';
|
||||||
import { normalizeUrl, usePreview } from './use-preview';
|
import { normalizeUrl, usePreview } from './use-preview';
|
||||||
|
import { useTranslation } from '../../locales';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A live app, in a panel.
|
* A live app, in a panel.
|
||||||
@@ -19,6 +20,7 @@ import { normalizeUrl, usePreview } from './use-preview';
|
|||||||
* machine; pretending otherwise with an attribute that does not hold is worse than not claiming it.
|
* machine; pretending otherwise with an attribute that does not hold is worse than not claiming it.
|
||||||
*/
|
*/
|
||||||
export const PreviewApp = ({ panelId }: { panelId: string }) => {
|
export const PreviewApp = ({ panelId }: { panelId: string }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { workspace } = useWorkspace();
|
const { workspace } = useWorkspace();
|
||||||
const { url, target, setUrl, reportLocation, reloadToken } = usePreview(panelId, workspace);
|
const { url, target, setUrl, reportLocation, reloadToken } = usePreview(panelId, workspace);
|
||||||
const [draft, setDraft] = useState('');
|
const [draft, setDraft] = useState('');
|
||||||
@@ -34,7 +36,7 @@ export const PreviewApp = ({ panelId }: { panelId: string }) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Globe className="h-6 w-6 text-duck-dark/30" />
|
<Globe className="h-6 w-6 text-duck-dark/30" />
|
||||||
<span className="text-sm text-duck-dark/50">Point this panel at a running app</span>
|
<span className="text-sm text-duck-dark/50">{t('preview.pointAtApp')}</span>
|
||||||
<input
|
<input
|
||||||
autoFocus
|
autoFocus
|
||||||
value={draft}
|
value={draft}
|
||||||
@@ -47,11 +49,9 @@ export const PreviewApp = ({ panelId }: { panelId: string }) => {
|
|||||||
disabled={!normalizeUrl(draft)}
|
disabled={!normalizeUrl(draft)}
|
||||||
className="rounded-md bg-duck-teal px-3 py-1.5 text-sm text-duck-yellow transition-opacity hover:opacity-90 disabled:opacity-40"
|
className="rounded-md bg-duck-teal px-3 py-1.5 text-sm text-duck-yellow transition-opacity hover:opacity-90 disabled:opacity-40"
|
||||||
>
|
>
|
||||||
Open
|
{t('preview.open')}
|
||||||
</button>
|
</button>
|
||||||
<p className="text-xs text-duck-dark/40">
|
<p className="text-xs text-duck-dark/40">{t('preview.hint')}</p>
|
||||||
Anything the browser can reach. It stays on this panel and survives a reload.
|
|
||||||
</p>
|
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -86,6 +86,7 @@ type DevProxyFrameProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const DevProxyFrame = ({ entry, target, reloadToken, onLocation }: DevProxyFrameProps) => {
|
export const DevProxyFrame = ({ entry, target, reloadToken, onLocation }: DevProxyFrameProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { get } = useClient();
|
const { get } = useClient();
|
||||||
const needsSession = entry.startsWith('/api/dev-proxy/');
|
const needsSession = entry.startsWith('/api/dev-proxy/');
|
||||||
const [ready, setReady] = useState(!needsSession);
|
const [ready, setReady] = useState(!needsSession);
|
||||||
@@ -95,7 +96,7 @@ export const DevProxyFrame = ({ entry, target, reloadToken, onLocation }: DevPro
|
|||||||
* The session cookie, and why this effect must NOT watch the URL.
|
* The session cookie, and why this effect must NOT watch the URL.
|
||||||
*
|
*
|
||||||
* It used to depend on the full composed URL, which changes every time the app navigates. Each change
|
* It used to depend on the full composed URL, which changes every time the app navigates. Each change
|
||||||
* re-ran this, and `setReady(false)` tears the frame down to show "Connecting…" — so a route change was
|
* re-ran this, and `setReady(false)` tears the frame down to show t('preview.connecting') — so a route change was
|
||||||
* followed, one debounce later, by the whole app reloading. Reported as "I change routes and a second
|
* followed, one debounce later, by the whole app reloading. Reported as "I change routes and a second
|
||||||
* later the page refreshes"; the second was the 800ms debounce on writing the location down.
|
* later the page refreshes"; the second was the 800ms debounce on writing the location down.
|
||||||
*
|
*
|
||||||
@@ -128,7 +129,9 @@ export const DevProxyFrame = ({ entry, target, reloadToken, onLocation }: DevPro
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!ready) {
|
if (!ready) {
|
||||||
return <div className="flex h-full items-center justify-center text-xs text-duck-dark/40">Connecting…</div>;
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center text-xs text-duck-dark/40">{t('preview.connecting')}</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
// Keyed on the ENTRY url as well as the reload counter. Freezing `src` at mount stopped the app's own
|
// Keyed on the ENTRY url as well as the reload counter. Freezing `src` at mount stopped the app's own
|
||||||
// navigation from reloading the frame — and stopped a newly typed URL from loading at all, since nothing
|
// navigation from reloading the frame — and stopped a newly typed URL from loading at all, since nothing
|
||||||
@@ -159,6 +162,7 @@ export const DevProxyFrame = ({ entry, target, reloadToken, onLocation }: DevPro
|
|||||||
* two panels are never pointed at the same port.
|
* two panels are never pointed at the same port.
|
||||||
*/
|
*/
|
||||||
const PreviewFrame = ({ initialSrc, onLocation }: { initialSrc: string; onLocation: (path: string) => void }) => {
|
const PreviewFrame = ({ initialSrc, onLocation }: { initialSrc: string; onLocation: (path: string) => void }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [src] = useState(initialSrc);
|
const [src] = useState(initialSrc);
|
||||||
const frame = useRef<HTMLIFrameElement | null>(null);
|
const frame = useRef<HTMLIFrameElement | null>(null);
|
||||||
|
|
||||||
@@ -174,5 +178,5 @@ const PreviewFrame = ({ initialSrc, onLocation }: { initialSrc: string; onLocati
|
|||||||
return () => window.removeEventListener('message', onMessage);
|
return () => window.removeEventListener('message', onMessage);
|
||||||
}, [onLocation]);
|
}, [onLocation]);
|
||||||
|
|
||||||
return <iframe ref={frame} src={src} title="Preview" className="h-full w-full border-none bg-white" />;
|
return <iframe ref={frame} src={src} title={t('preview.title')} className="h-full w-full border-none bg-white" />;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,17 +2,19 @@ import { useEffect, useState } from 'react';
|
|||||||
import { Globe, RefreshCw, ExternalLink, Pencil, Check, X } from 'lucide-react';
|
import { Globe, RefreshCw, ExternalLink, Pencil, Check, X } from 'lucide-react';
|
||||||
import { useWorkspace } from '../../components/Workspace';
|
import { useWorkspace } from '../../components/Workspace';
|
||||||
import { normalizeUrl, usePreview } from './use-preview';
|
import { normalizeUrl, usePreview } from './use-preview';
|
||||||
|
import { useTranslation } from '../../locales';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The preview panel's header: where it points, reload, open in a real tab, and change it.
|
* The preview panel's header: where it points, reload, open in a real tab, and change it.
|
||||||
*
|
*
|
||||||
* "Open in a new tab" is not decoration. A framed app gets PARTITIONED storage — the browser keys it by
|
* t('preview.openNewTab') is not decoration. A framed app gets PARTITIONED storage — the browser keys it by
|
||||||
* top-level site — so the login inside this panel is separate from the login in a tab, and a page that
|
* top-level site — so the login inside this panel is separate from the login in a tab, and a page that
|
||||||
* refuses to be framed at all (`X-Frame-Options`, CSP `frame-ancestors`) renders blank here with only a
|
* refuses to be framed at all (`X-Frame-Options`, CSP `frame-ancestors`) renders blank here with only a
|
||||||
* console error. When either happens, the tab is the answer, and it should be one click away rather than
|
* console error. When either happens, the tab is the answer, and it should be one click away rather than
|
||||||
* something you rebuild by copying the URL out.
|
* something you rebuild by copying the URL out.
|
||||||
*/
|
*/
|
||||||
export const PreviewHeader = ({ panelId }: { panelId?: string }) => {
|
export const PreviewHeader = ({ panelId }: { panelId?: string }) => {
|
||||||
|
const { t, tUi } = useTranslation();
|
||||||
const { workspace } = useWorkspace();
|
const { workspace } = useWorkspace();
|
||||||
const { url, target, display, setUrl, reload } = usePreview(panelId, workspace);
|
const { url, target, display, setUrl, reload } = usePreview(panelId, workspace);
|
||||||
|
|
||||||
@@ -46,13 +48,13 @@ export const PreviewHeader = ({ panelId }: { panelId?: string }) => {
|
|||||||
}}
|
}}
|
||||||
className="min-w-0 flex-1 rounded border border-duck-dark/20 bg-transparent px-1.5 py-0.5 text-xs"
|
className="min-w-0 flex-1 rounded border border-duck-dark/20 bg-transparent px-1.5 py-0.5 text-xs"
|
||||||
/>
|
/>
|
||||||
<button type="submit" title="Save" className="p-0.5 cursor-pointer shrink-0">
|
<button type="submit" title={t('common.save')} className="p-0.5 cursor-pointer shrink-0">
|
||||||
<Check className="h-3 w-3" />
|
<Check className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setEditing(false)}
|
onClick={() => setEditing(false)}
|
||||||
title="Cancel"
|
title={tUi('button.cancel')}
|
||||||
className="p-0.5 cursor-pointer shrink-0"
|
className="p-0.5 cursor-pointer shrink-0"
|
||||||
>
|
>
|
||||||
<X className="h-3 w-3" />
|
<X className="h-3 w-3" />
|
||||||
@@ -64,10 +66,14 @@ export const PreviewHeader = ({ panelId }: { panelId?: string }) => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Globe className="h-3.5 w-3.5 shrink-0 text-duck-teal" />
|
<Globe className="h-3.5 w-3.5 shrink-0 text-duck-teal" />
|
||||||
<span className="text-xs font-medium shrink-0">Preview</span>
|
<span className="text-xs font-medium shrink-0">{t('preview.title')}</span>
|
||||||
{url ? (
|
{url ? (
|
||||||
<>
|
<>
|
||||||
<button onClick={reload} title="Reload" className="p-0.5 rounded hover:bg-black/10 cursor-pointer shrink-0">
|
<button
|
||||||
|
onClick={reload}
|
||||||
|
title={t('preview.reload')}
|
||||||
|
className="p-0.5 rounded hover:bg-black/10 cursor-pointer shrink-0"
|
||||||
|
>
|
||||||
<RefreshCw className="h-3 w-3" />
|
<RefreshCw className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
<span className="min-w-0 flex-1 truncate text-[10px] font-mono opacity-60" title={display}>
|
<span className="min-w-0 flex-1 truncate text-[10px] font-mono opacity-60" title={display}>
|
||||||
@@ -75,7 +81,7 @@ export const PreviewHeader = ({ panelId }: { panelId?: string }) => {
|
|||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => setEditing(true)}
|
onClick={() => setEditing(true)}
|
||||||
title="Change URL"
|
title={t('preview.changeUrl')}
|
||||||
className="p-0.5 rounded hover:bg-black/10 cursor-pointer shrink-0"
|
className="p-0.5 rounded hover:bg-black/10 cursor-pointer shrink-0"
|
||||||
>
|
>
|
||||||
<Pencil className="h-3 w-3" />
|
<Pencil className="h-3 w-3" />
|
||||||
@@ -84,7 +90,7 @@ export const PreviewHeader = ({ panelId }: { panelId?: string }) => {
|
|||||||
href={target}
|
href={target}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
title="Open in a new tab"
|
title={t('preview.openNewTab')}
|
||||||
className="p-0.5 rounded hover:bg-black/10 cursor-pointer shrink-0"
|
className="p-0.5 rounded hover:bg-black/10 cursor-pointer shrink-0"
|
||||||
>
|
>
|
||||||
<ExternalLink className="h-3 w-3" />
|
<ExternalLink className="h-3 w-3" />
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { AppRegistryMeta } from '../../AppRegistry';
|
|||||||
import { Globe } from 'lucide-react';
|
import { Globe } from 'lucide-react';
|
||||||
import { PreviewApp } from './PreviewApp';
|
import { PreviewApp } from './PreviewApp';
|
||||||
import { PreviewHeader } from './PreviewHeader';
|
import { PreviewHeader } from './PreviewHeader';
|
||||||
|
import { panelName } from '../../locales';
|
||||||
|
|
||||||
export { PreviewApp } from './PreviewApp';
|
export { PreviewApp } from './PreviewApp';
|
||||||
export { PreviewHeader } from './PreviewHeader';
|
export { PreviewHeader } from './PreviewHeader';
|
||||||
@@ -10,7 +11,7 @@ export { usePreview, normalizeUrl, type PreviewPanelState } from './use-preview'
|
|||||||
export const appRegistryMetas: AppRegistryMeta[] = [
|
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||||
{
|
{
|
||||||
key: 'officerdev/preview',
|
key: 'officerdev/preview',
|
||||||
name: 'Preview',
|
name: panelName('panelName.preview'),
|
||||||
icon: Globe,
|
icon: Globe,
|
||||||
component: PreviewApp,
|
component: PreviewApp,
|
||||||
header: PreviewHeader,
|
header: PreviewHeader,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
|||||||
import jsQR from 'jsqr';
|
import jsQR from 'jsqr';
|
||||||
import { Camera, Download, RotateCcw } from 'lucide-react';
|
import { Camera, Download, RotateCcw } from 'lucide-react';
|
||||||
import { crc32, decodeFrame, reassemble, type MetaFrame } from './protocol';
|
import { crc32, decodeFrame, reassemble, type MetaFrame } from './protocol';
|
||||||
|
import { useTranslation } from '../../locales';
|
||||||
|
|
||||||
// Receiver: camera → jsQR → collect frames until every index is present.
|
// Receiver: camera → jsQR → collect frames until every index is present.
|
||||||
//
|
//
|
||||||
@@ -15,6 +16,7 @@ import { crc32, decodeFrame, reassemble, type MetaFrame } from './protocol';
|
|||||||
type Progress = { meta: MetaFrame; have: number };
|
type Progress = { meta: MetaFrame; have: number };
|
||||||
|
|
||||||
export const QrReceiver = () => {
|
export const QrReceiver = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const chunksRef = useRef(new Map<number, Uint8Array>());
|
const chunksRef = useRef(new Map<number, Uint8Array>());
|
||||||
@@ -130,7 +132,7 @@ export const QrReceiver = () => {
|
|||||||
className="flex cursor-pointer items-center gap-2 rounded-md border border-duck-dark/20 px-3 py-2 text-sm text-duck-dark/80 hover:border-duck-teal hover:text-duck-teal"
|
className="flex cursor-pointer items-center gap-2 rounded-md border border-duck-dark/20 px-3 py-2 text-sm text-duck-dark/80 hover:border-duck-teal hover:text-duck-teal"
|
||||||
>
|
>
|
||||||
<Camera className="h-4 w-4" />
|
<Camera className="h-4 w-4" />
|
||||||
Start scanning
|
{t('qrTransfer.startScanning')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -172,18 +174,14 @@ export const QrReceiver = () => {
|
|||||||
className="flex cursor-pointer items-center gap-1 text-xs text-duck-dark/50 hover:text-duck-teal"
|
className="flex cursor-pointer items-center gap-1 text-xs text-duck-dark/50 hover:text-duck-teal"
|
||||||
>
|
>
|
||||||
<RotateCcw className="h-3.5 w-3.5" />
|
<RotateCcw className="h-3.5 w-3.5" />
|
||||||
Receive another
|
{t('qrTransfer.receiveAnother')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{scanning && (
|
{scanning && (
|
||||||
<button
|
<button type="button" onClick={stop} className="cursor-pointer text-xs text-duck-dark/50 hover:text-red-500">
|
||||||
type="button"
|
{t('qrTransfer.stop')}
|
||||||
onClick={stop}
|
|
||||||
className="cursor-pointer text-xs text-duck-dark/50 hover:text-red-500"
|
|
||||||
>
|
|
||||||
Stop
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,14 +1,8 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import QRCode from 'qrcode';
|
import QRCode from 'qrcode';
|
||||||
import { Upload, Play, Pause } from 'lucide-react';
|
import { Upload, Play, Pause } from 'lucide-react';
|
||||||
import {
|
import { chunkFile, crc32, encodeData, encodeMeta, newSessionId, DEFAULT_CHUNK_BYTES } from './protocol';
|
||||||
chunkFile,
|
import { useTranslation } from '../../locales';
|
||||||
crc32,
|
|
||||||
encodeData,
|
|
||||||
encodeMeta,
|
|
||||||
newSessionId,
|
|
||||||
DEFAULT_CHUNK_BYTES,
|
|
||||||
} from './protocol';
|
|
||||||
|
|
||||||
// Sender: a file becomes a looping carousel of QR frames.
|
// Sender: a file becomes a looping carousel of QR frames.
|
||||||
//
|
//
|
||||||
@@ -24,6 +18,7 @@ const META_EVERY = 12;
|
|||||||
const SPEEDS = [4, 6, 8, 12] as const;
|
const SPEEDS = [4, 6, 8, 12] as const;
|
||||||
|
|
||||||
export const QrSender = () => {
|
export const QrSender = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const [file, setFile] = useState<{ name: string; mime: string; bytes: Uint8Array } | null>(null);
|
const [file, setFile] = useState<{ name: string; mime: string; bytes: Uint8Array } | null>(null);
|
||||||
const [frames, setFrames] = useState<string[]>([]);
|
const [frames, setFrames] = useState<string[]>([]);
|
||||||
@@ -82,7 +77,7 @@ export const QrSender = () => {
|
|||||||
<div className="flex h-full w-full flex-col items-center gap-3 overflow-y-auto p-3">
|
<div className="flex h-full w-full flex-col items-center gap-3 overflow-y-auto p-3">
|
||||||
<label className="flex w-full cursor-pointer items-center justify-center gap-2 rounded-md border border-dashed border-duck-dark/25 py-3 text-sm text-duck-dark/70 hover:border-duck-teal hover:text-duck-teal">
|
<label className="flex w-full cursor-pointer items-center justify-center gap-2 rounded-md border border-dashed border-duck-dark/25 py-3 text-sm text-duck-dark/70 hover:border-duck-teal hover:text-duck-teal">
|
||||||
<Upload className="h-4 w-4" />
|
<Upload className="h-4 w-4" />
|
||||||
{file ? file.name : 'Choose a file to send'}
|
{file ? file.name : t('qrTransfer.chooseFile')}
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
className="hidden"
|
className="hidden"
|
||||||
@@ -129,8 +124,8 @@ export const QrSender = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-center text-[10px] leading-relaxed text-duck-dark/40">
|
<p className="text-center text-[10px] leading-relaxed text-duck-dark/40">
|
||||||
Keep this on screen until the receiver reports 100%. It loops forever — a missed frame is
|
Keep this on screen until the receiver reports 100%. It loops forever — a missed frame is picked up on the
|
||||||
picked up on the next pass.
|
next pass.
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { AppRegistryMeta } from '../../AppRegistry';
|
|||||||
import { QrCode, ScanLine } from 'lucide-react';
|
import { QrCode, ScanLine } from 'lucide-react';
|
||||||
import { QrSender } from './Sender';
|
import { QrSender } from './Sender';
|
||||||
import { QrReceiver } from './Receiver';
|
import { QrReceiver } from './Receiver';
|
||||||
|
import { panelName } from '../../locales';
|
||||||
|
|
||||||
export { QrSender } from './Sender';
|
export { QrSender } from './Sender';
|
||||||
export { QrReceiver } from './Receiver';
|
export { QrReceiver } from './Receiver';
|
||||||
@@ -12,6 +13,6 @@ export * from './protocol';
|
|||||||
// enough to reimplement on iOS and Android.
|
// enough to reimplement on iOS and Android.
|
||||||
|
|
||||||
export const appRegistryMetas: AppRegistryMeta[] = [
|
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||||
{ key: 'qr-send', name: 'QR Send', icon: QrCode, component: QrSender },
|
{ key: 'qr-send', name: panelName('panelName.qrSend'), icon: QrCode, component: QrSender },
|
||||||
{ key: 'qr-receive', name: 'QR Receive', icon: ScanLine, component: QrReceiver },
|
{ key: 'qr-receive', name: panelName('panelName.qrReceive'), icon: ScanLine, component: QrReceiver },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useRef, useState } from 'react';
|
|||||||
import { Laptop, Send, Upload, User, X } from 'lucide-react';
|
import { Laptop, Send, Upload, User, X } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { useSendOff, useSendOffSenders, useSendOffTargets } from './useSendOff';
|
import { useSendOff, useSendOffSenders, useSendOffTargets } from './useSendOff';
|
||||||
|
import { useTranslation } from '../../locales';
|
||||||
|
|
||||||
const label = (p: { name: string | null; email: string }): string => p.name || p.email.split('@')[0] || p.email;
|
const label = (p: { name: string | null; email: string }): string => p.name || p.email.split('@')[0] || p.email;
|
||||||
const kb = (n: number): string =>
|
const kb = (n: number): string =>
|
||||||
@@ -17,6 +18,7 @@ type Target = { kind: 'machine'; name: string } | { kind: 'user'; id: number; na
|
|||||||
* needed keeping in sync.
|
* needed keeping in sync.
|
||||||
*/
|
*/
|
||||||
export const SendOffPanel = () => {
|
export const SendOffPanel = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { devices, people, myFolder, isLoading } = useSendOffTargets();
|
const { devices, people, myFolder, isLoading } = useSendOffTargets();
|
||||||
const send = useSendOff();
|
const send = useSendOff();
|
||||||
const [files, setFiles] = useState<File[]>([]);
|
const [files, setFiles] = useState<File[]>([]);
|
||||||
@@ -41,7 +43,7 @@ export const SendOffPanel = () => {
|
|||||||
<div className="flex h-full min-h-0 flex-col overflow-y-auto p-5">
|
<div className="flex h-full min-h-0 flex-col overflow-y-auto p-5">
|
||||||
<div className="mx-auto flex w-full max-w-2xl flex-col gap-5">
|
<div className="mx-auto flex w-full max-w-2xl flex-col gap-5">
|
||||||
<section>
|
<section>
|
||||||
<h3 className="text-sm font-semibold text-foreground">Files</h3>
|
<h3 className="text-sm font-semibold text-foreground">{t('sendOff.files')}</h3>
|
||||||
<input
|
<input
|
||||||
ref={picker}
|
ref={picker}
|
||||||
type="file"
|
type="file"
|
||||||
@@ -70,7 +72,7 @@ export const SendOffPanel = () => {
|
|||||||
<span className="shrink-0 text-xs text-muted-foreground">{kb(f.size)}</span>
|
<span className="shrink-0 text-xs text-muted-foreground">{kb(f.size)}</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={`Remove ${f.name}`}
|
aria-label={t('sendOff.removeFile', { name: f.name })}
|
||||||
className="shrink-0 text-muted-foreground hover:text-foreground"
|
className="shrink-0 text-muted-foreground hover:text-foreground"
|
||||||
onClick={() => setFiles((prev) => prev.filter((_, k) => k !== i))}
|
onClick={() => setFiles((prev) => prev.filter((_, k) => k !== i))}
|
||||||
>
|
>
|
||||||
@@ -86,9 +88,9 @@ export const SendOffPanel = () => {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h3 className="text-sm font-semibold text-foreground">Where</h3>
|
<h3 className="text-sm font-semibold text-foreground">{t('sendOff.where')}</h3>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<p className="mt-2 text-sm text-muted-foreground">Loading…</p>
|
<p className="mt-2 text-sm text-muted-foreground">{t('sendOff.loading')}</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-2 flex flex-col gap-3">
|
<div className="mt-2 flex flex-col gap-3">
|
||||||
<TargetGroup
|
<TargetGroup
|
||||||
@@ -124,19 +126,19 @@ export const SendOffPanel = () => {
|
|||||||
<section className="flex flex-col gap-2">
|
<section className="flex flex-col gap-2">
|
||||||
<Button onClick={doSend} disabled={!ready || send.isPending} size="lg">
|
<Button onClick={doSend} disabled={!ready || send.isPending} size="lg">
|
||||||
<Send className="mr-2 h-4 w-4" />
|
<Send className="mr-2 h-4 w-4" />
|
||||||
{send.isPending ? 'Sending…' : target ? `Send to ${target.name}` : 'Send'}
|
{send.isPending ? 'Sending…' : target ? t('sendOff.sendTo', { name: target.name }) : 'Send'}
|
||||||
</Button>
|
</Button>
|
||||||
{/* The server's own message, not a generic one: a file over the limit and a recipient who has
|
{/* The server's own message, not a generic one: a file over the limit and a recipient who has
|
||||||
revoked you are different problems with different answers. */}
|
revoked you are different problems with different answers. */}
|
||||||
{target?.kind === 'user' && myFolder && (
|
{target?.kind === 'user' && myFolder && (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Lands in their <span className="text-foreground">Documents/SendOff/{myFolder}</span>.
|
{t('sendOff.landsIn')} <code className="text-foreground">Documents/SendOff/{myFolder}</code>.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{send.error && <p className="text-sm text-destructive">{send.error.message}</p>}
|
{send.error && <p className="text-sm text-destructive">{send.error.message}</p>}
|
||||||
{send.data && (
|
{send.data && (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Landed in <span className="text-foreground">{send.data.path}</span> —{' '}
|
{t('sendOff.landedIn')} <span className="text-foreground">{send.data.path}</span> —{' '}
|
||||||
{send.data.files.map((f) => f.name).join(', ')}
|
{send.data.files.map((f) => f.name).join(', ')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -195,15 +197,16 @@ const TargetGroup = ({ icon: Icon, heading, empty, options, selected, onSelect }
|
|||||||
* the answer has to be visible in the same place the question was asked.
|
* the answer has to be visible in the same place the question was asked.
|
||||||
*/
|
*/
|
||||||
const WhoMaySend = () => {
|
const WhoMaySend = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { candidates, isLoading, setAllowed, isSaving } = useSendOffSenders();
|
const { candidates, isLoading, setAllowed, isSaving } = useSendOffSenders();
|
||||||
if (isLoading || candidates.length === 0) return null;
|
if (isLoading || candidates.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="border-t border-border pt-4">
|
<section className="border-t border-border pt-4">
|
||||||
<h3 className="text-sm font-semibold text-foreground">Who may send to me</h3>
|
<h3 className="text-sm font-semibold text-foreground">{t('sendOff.whoMaySend')}</h3>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
Files from anyone you allow land in <span className="text-foreground">Documents/SendOff/their-name</span>.
|
{t('sendOff.allowedLandIn')} <code className="text-foreground">Documents/SendOff/their-name</code>.{' '}
|
||||||
Turning someone off stops future sends and leaves what they already sent alone.
|
{t('sendOff.turningOff')}
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-2 flex flex-col gap-1">
|
<div className="mt-2 flex flex-col gap-1">
|
||||||
{candidates.map((p) => (
|
{candidates.map((p) => (
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react';
|
|||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
import { Cpu, MemoryStick, HardDrive, Server, Thermometer, CircuitBoard, Network, Zap } from 'lucide-react';
|
import { Cpu, MemoryStick, HardDrive, Server, Thermometer, CircuitBoard, Network, Zap } from 'lucide-react';
|
||||||
import { fmtBytes, fmtDuration } from './shared';
|
import { fmtBytes, fmtDuration } from './shared';
|
||||||
|
import { useTranslation } from '../../locales';
|
||||||
|
|
||||||
type Sensor = { name: string; label: string; celsius: number };
|
type Sensor = { name: string; label: string; celsius: number };
|
||||||
type Stats = {
|
type Stats = {
|
||||||
@@ -67,6 +68,7 @@ const Card = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const BtopView = () => {
|
export const BtopView = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { get } = useClient();
|
const { get } = useClient();
|
||||||
const [stats, setStats] = useState<Stats | null>(null);
|
const [stats, setStats] = useState<Stats | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -133,7 +135,7 @@ export const BtopView = () => {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card title="Memory" icon={MemoryStick}>
|
<Card title={t('systemMonitor.memory')} icon={MemoryStick}>
|
||||||
{stats.mem ? (
|
{stats.mem ? (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-end justify-between">
|
<div className="flex items-end justify-between">
|
||||||
@@ -154,7 +156,7 @@ export const BtopView = () => {
|
|||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card title="Disks" icon={HardDrive}>
|
<Card title={t('systemMonitor.disks')} icon={HardDrive}>
|
||||||
{stats.disks.length ? (
|
{stats.disks.length ? (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{stats.disks.map((d) => (
|
{stats.disks.map((d) => (
|
||||||
@@ -195,7 +197,7 @@ export const BtopView = () => {
|
|||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card title="Temperature" icon={Thermometer}>
|
<Card title={t('systemMonitor.temperature')} icon={Thermometer}>
|
||||||
{stats.temp?.cpuC != null ? (
|
{stats.temp?.cpuC != null ? (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-end justify-between">
|
<div className="flex items-end justify-between">
|
||||||
@@ -223,7 +225,7 @@ export const BtopView = () => {
|
|||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card title="Network" icon={Network}>
|
<Card title={t('systemMonitor.network')} icon={Network}>
|
||||||
{stats.net ? (
|
{stats.net ? (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-between text-lg font-semibold text-foreground">
|
<div className="flex items-center justify-between text-lg font-semibold text-foreground">
|
||||||
@@ -247,11 +249,11 @@ export const BtopView = () => {
|
|||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card title="Power" icon={Zap}>
|
<Card title={t('systemMonitor.power')} icon={Zap}>
|
||||||
{stats.power ? (
|
{stats.power ? (
|
||||||
<div className="flex flex-col gap-2 pt-1">
|
<div className="flex flex-col gap-2 pt-1">
|
||||||
<div className="flex items-center justify-between text-sm">
|
<div className="flex items-center justify-between text-sm">
|
||||||
<span className="text-muted-foreground">CPU package</span>
|
<span className="text-muted-foreground">{t('systemMonitor.cpuPackage')}</span>
|
||||||
<span className="font-mono text-foreground">
|
<span className="font-mono text-foreground">
|
||||||
{stats.power.cpuWatts != null ? `${stats.power.cpuWatts} W` : '—'}
|
{stats.power.cpuWatts != null ? `${stats.power.cpuWatts} W` : '—'}
|
||||||
</span>
|
</span>
|
||||||
@@ -269,16 +271,16 @@ export const BtopView = () => {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card title="Top processes" icon={Server}>
|
<Card title={t('systemMonitor.topProcesses')} icon={Server}>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-left text-sm">
|
<table className="w-full text-left text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="text-xs uppercase tracking-wide text-muted-foreground">
|
<tr className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
<th className="pb-2 pr-4 font-medium">PID</th>
|
<th className="pb-2 pr-4 font-medium">PID</th>
|
||||||
<th className="pb-2 pr-4 font-medium">User</th>
|
<th className="pb-2 pr-4 font-medium">{t('systemMonitor.user')}</th>
|
||||||
<th className="pb-2 pr-4 text-right font-medium">CPU%</th>
|
<th className="pb-2 pr-4 text-right font-medium">{t('systemMonitor.cpuPct')}</th>
|
||||||
<th className="pb-2 pr-4 text-right font-medium">MEM%</th>
|
<th className="pb-2 pr-4 text-right font-medium">{t('systemMonitor.memPct')}</th>
|
||||||
<th className="pb-2 font-medium">Command</th>
|
<th className="pb-2 font-medium">{t('systemMonitor.command')}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
import { LogStream } from './LogStream';
|
import { LogStream } from './LogStream';
|
||||||
|
import { useTranslation } from '../../locales';
|
||||||
|
|
||||||
type Container = { id: string; name: string; image: string; state: string; status: string; ports: string };
|
type Container = { id: string; name: string; image: string; state: string; status: string; ports: string };
|
||||||
|
|
||||||
@@ -8,6 +9,7 @@ const POLL_MS = 3000;
|
|||||||
const stateColor = (s: string) => (s === 'running' ? 'bg-success' : s === 'exited' ? 'bg-destructive' : 'bg-busy');
|
const stateColor = (s: string) => (s === 'running' ? 'bg-success' : s === 'exited' ? 'bg-destructive' : 'bg-busy');
|
||||||
|
|
||||||
export const DockerView = () => {
|
export const DockerView = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { get } = useClient();
|
const { get } = useClient();
|
||||||
const [containers, setContainers] = useState<Container[] | null>(null);
|
const [containers, setContainers] = useState<Container[] | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -59,7 +61,7 @@ export const DockerView = () => {
|
|||||||
<input
|
<input
|
||||||
value={filter}
|
value={filter}
|
||||||
onChange={(e) => setFilter(e.target.value)}
|
onChange={(e) => setFilter(e.target.value)}
|
||||||
placeholder="filter name or image…"
|
placeholder={t('systemMonitor.filterPlaceholder')}
|
||||||
className="w-56 rounded-md border border-border bg-background px-2 py-1 text-xs text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary"
|
className="w-56 rounded-md border border-border bg-background px-2 py-1 text-xs text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
|||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
import { fmtBytes, fmtDuration } from './shared';
|
import { fmtBytes, fmtDuration } from './shared';
|
||||||
import { LogStream } from './LogStream';
|
import { LogStream } from './LogStream';
|
||||||
|
import type { PanelTranslationKey } from '../../locales';
|
||||||
|
import { useTranslation } from '../../locales';
|
||||||
|
|
||||||
type Proc = {
|
type Proc = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -25,18 +27,24 @@ const statusColor = (s: string) =>
|
|||||||
? 'bg-destructive'
|
? 'bg-destructive'
|
||||||
: 'bg-busy';
|
: 'bg-busy';
|
||||||
|
|
||||||
const COLS: { key: SortKey; label: string; align: 'left' | 'right' }[] = [
|
/*
|
||||||
|
Column metadata lives outside the component, so it carries catalogue KEYS and the header resolves
|
||||||
|
them. `#`, `↺` and `PID` have no key: a glyph and an acronym are the same in every locale, and a key
|
||||||
|
whose two sides can never differ is a maintenance cost bought for nothing.
|
||||||
|
*/
|
||||||
|
const COLS: { key: SortKey; labelKey?: PanelTranslationKey; label?: string; align: 'left' | 'right' }[] = [
|
||||||
{ key: 'id', label: '#', align: 'right' },
|
{ key: 'id', label: '#', align: 'right' },
|
||||||
{ key: 'name', label: 'Name', align: 'left' },
|
{ key: 'name', labelKey: 'systemMonitor.name', align: 'left' },
|
||||||
{ key: 'status', label: 'Status', align: 'left' },
|
{ key: 'status', labelKey: 'systemMonitor.status', align: 'left' },
|
||||||
{ key: 'cpuPct', label: 'CPU%', align: 'right' },
|
{ key: 'cpuPct', labelKey: 'systemMonitor.cpuPct', align: 'right' },
|
||||||
{ key: 'memBytes', label: 'Mem', align: 'right' },
|
{ key: 'memBytes', labelKey: 'systemMonitor.mem', align: 'right' },
|
||||||
{ key: 'restarts', label: '↺', align: 'right' },
|
{ key: 'restarts', label: '↺', align: 'right' },
|
||||||
{ key: 'uptimeMs', label: 'Uptime', align: 'left' },
|
{ key: 'uptimeMs', labelKey: 'systemMonitor.uptime', align: 'left' },
|
||||||
{ key: 'pid', label: 'PID', align: 'right' },
|
{ key: 'pid', label: 'PID', align: 'right' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const Pm2View = () => {
|
export const Pm2View = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { get } = useClient();
|
const { get } = useClient();
|
||||||
const [procs, setProcs] = useState<Proc[] | null>(null);
|
const [procs, setProcs] = useState<Proc[] | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -116,7 +124,7 @@ export const Pm2View = () => {
|
|||||||
onClick={() => toggleSort(c.key)}
|
onClick={() => toggleSort(c.key)}
|
||||||
className={`inline-flex items-center gap-1 hover:text-foreground ${c.align === 'right' ? 'flex-row-reverse' : ''} ${sortKey === c.key ? 'text-foreground' : ''}`}
|
className={`inline-flex items-center gap-1 hover:text-foreground ${c.align === 'right' ? 'flex-row-reverse' : ''} ${sortKey === c.key ? 'text-foreground' : ''}`}
|
||||||
>
|
>
|
||||||
{c.label}
|
{c.labelKey ? t(c.labelKey) : c.label}
|
||||||
<span className="w-2 text-[10px]">
|
<span className="w-2 text-[10px]">
|
||||||
{sortKey === c.key ? (sortDir === 'asc' ? '▲' : '▼') : ''}
|
{sortKey === c.key ? (sortDir === 'asc' ? '▲' : '▼') : ''}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -2,10 +2,23 @@ import type { AppRegistryMeta } from '../../AppRegistry';
|
|||||||
import { Activity, PanelLeft } from 'lucide-react';
|
import { Activity, PanelLeft } from 'lucide-react';
|
||||||
import { ScopeList } from './ScopeList';
|
import { ScopeList } from './ScopeList';
|
||||||
import { MonitorMain } from './MonitorMain';
|
import { MonitorMain } from './MonitorMain';
|
||||||
|
import { panelName } from '../../locales';
|
||||||
|
|
||||||
export { ScopeList, MonitorMain };
|
export { ScopeList, MonitorMain };
|
||||||
|
|
||||||
export const appRegistryMetas: AppRegistryMeta[] = [
|
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||||
{ key: 'monitor-side', name: 'Scope', icon: PanelLeft, component: ScopeList, availableOnPanel: false },
|
{
|
||||||
{ key: 'monitor-main', name: 'System Monitor', icon: Activity, component: MonitorMain, availableOnPanel: false },
|
key: 'monitor-side',
|
||||||
|
name: panelName('panelName.scope'),
|
||||||
|
icon: PanelLeft,
|
||||||
|
component: ScopeList,
|
||||||
|
availableOnPanel: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'monitor-main',
|
||||||
|
name: panelName('panelName.systemMonitor'),
|
||||||
|
icon: Activity,
|
||||||
|
component: MonitorMain,
|
||||||
|
availableOnPanel: false,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { TerminalSquare, Monitor, Columns2, PenLine, Sparkles, Circle, Loader2 }
|
|||||||
import { useWorkspace } from '../../components/Workspace';
|
import { useWorkspace } from '../../components/Workspace';
|
||||||
import { useGlobal } from 'hooks/useGlobal';
|
import { useGlobal } from 'hooks/useGlobal';
|
||||||
import type { TerminalConnectionState } from './Terminal';
|
import type { TerminalConnectionState } from './Terminal';
|
||||||
|
import { useTranslation } from '../../locales';
|
||||||
|
import { Untranslated } from '@/components/Untranslated';
|
||||||
|
|
||||||
const ConnectionIndicator = ({ panelId }: { panelId: string }) => {
|
const ConnectionIndicator = ({ panelId }: { panelId: string }) => {
|
||||||
const [state] = useGlobal<TerminalConnectionState>(`terminal-conn-${panelId}`, 'disconnected');
|
const [state] = useGlobal<TerminalConnectionState>(`terminal-conn-${panelId}`, 'disconnected');
|
||||||
@@ -18,12 +20,13 @@ const ConnectionIndicator = ({ panelId }: { panelId: string }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const TerminalHeader = ({ panelId }: { panelId: string }) => {
|
export const TerminalHeader = ({ panelId }: { panelId: string }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { cwd } = useWorkspace();
|
const { cwd } = useWorkspace();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TerminalSquare className="h-3.5 w-3.5 shrink-0" />
|
<TerminalSquare className="h-3.5 w-3.5 shrink-0" />
|
||||||
<span className="text-xs font-medium shrink-0">Terminal</span>
|
<span className="text-xs font-medium shrink-0">{t('terminal.title')}</span>
|
||||||
<ConnectionIndicator panelId={panelId} />
|
<ConnectionIndicator panelId={panelId} />
|
||||||
<span className="text-[10px] font-mono truncate opacity-60">{cwd}</span>
|
<span className="text-[10px] font-mono truncate opacity-60">{cwd}</span>
|
||||||
</>
|
</>
|
||||||
@@ -31,11 +34,12 @@ export const TerminalHeader = ({ panelId }: { panelId: string }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const HostTerminalHeader = ({ panelId }: { panelId: string }) => {
|
export const HostTerminalHeader = ({ panelId }: { panelId: string }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { cwd } = useWorkspace();
|
const { cwd } = useWorkspace();
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Monitor className="h-3.5 w-3.5 shrink-0" />
|
<Monitor className="h-3.5 w-3.5 shrink-0" />
|
||||||
<span className="text-xs font-medium shrink-0">Terminal</span>
|
<span className="text-xs font-medium shrink-0">{t('terminal.title')}</span>
|
||||||
<ConnectionIndicator panelId={panelId} />
|
<ConnectionIndicator panelId={panelId} />
|
||||||
<span className="text-[10px] font-mono truncate opacity-60">{cwd}</span>
|
<span className="text-[10px] font-mono truncate opacity-60">{cwd}</span>
|
||||||
</>
|
</>
|
||||||
@@ -47,7 +51,9 @@ export const TmuxHeader = ({ panelId }: { panelId: string }) => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Columns2 className="h-3.5 w-3.5 shrink-0" />
|
<Columns2 className="h-3.5 w-3.5 shrink-0" />
|
||||||
<span className="text-xs font-medium shrink-0">Tmux</span>
|
<span className="text-xs font-medium shrink-0">
|
||||||
|
<Untranslated reason="product-name">Tmux</Untranslated>
|
||||||
|
</span>
|
||||||
<ConnectionIndicator panelId={panelId} />
|
<ConnectionIndicator panelId={panelId} />
|
||||||
<span className="text-[10px] font-mono truncate opacity-60">{cwd}</span>
|
<span className="text-[10px] font-mono truncate opacity-60">{cwd}</span>
|
||||||
</>
|
</>
|
||||||
@@ -59,7 +65,9 @@ export const NvimHeader = ({ panelId }: { panelId: string }) => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PenLine className="h-3.5 w-3.5 shrink-0" />
|
<PenLine className="h-3.5 w-3.5 shrink-0" />
|
||||||
<span className="text-xs font-medium shrink-0">Neovim</span>
|
<span className="text-xs font-medium shrink-0">
|
||||||
|
<Untranslated reason="product-name">Neovim</Untranslated>
|
||||||
|
</span>
|
||||||
<ConnectionIndicator panelId={panelId} />
|
<ConnectionIndicator panelId={panelId} />
|
||||||
<span className="text-[10px] font-mono truncate opacity-60">{cwd}</span>
|
<span className="text-[10px] font-mono truncate opacity-60">{cwd}</span>
|
||||||
</>
|
</>
|
||||||
@@ -71,7 +79,9 @@ export const ClaudeCodeHeader = ({ panelId }: { panelId: string }) => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Sparkles className="h-3.5 w-3.5 shrink-0" />
|
<Sparkles className="h-3.5 w-3.5 shrink-0" />
|
||||||
<span className="text-xs font-medium shrink-0">Claude Code</span>
|
<span className="text-xs font-medium shrink-0">
|
||||||
|
<Untranslated reason="product-name">Claude Code</Untranslated>
|
||||||
|
</span>
|
||||||
<ConnectionIndicator panelId={panelId} />
|
<ConnectionIndicator panelId={panelId} />
|
||||||
<span className="text-[10px] font-mono truncate opacity-60">{cwd}</span>
|
<span className="text-[10px] font-mono truncate opacity-60">{cwd}</span>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useCallback } from 'react';
|
|||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { TerminalSquare, Trash2, RefreshCw } from 'lucide-react';
|
import { TerminalSquare, Trash2, RefreshCw } from 'lucide-react';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
|
import { useTranslation } from '../../locales';
|
||||||
|
|
||||||
// Every shell the pty sidecar is holding, whether or not a panel is pointing at it.
|
// Every shell the pty sidecar is holding, whether or not a panel is pointing at it.
|
||||||
//
|
//
|
||||||
@@ -44,6 +45,7 @@ const since = (ts: number): string => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const RunningShells = () => {
|
export const RunningShells = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
@@ -69,12 +71,12 @@ export const RunningShells = () => {
|
|||||||
<div className="flex h-full w-full flex-col overflow-hidden text-sm">
|
<div className="flex h-full w-full flex-col overflow-hidden text-sm">
|
||||||
<div className="flex shrink-0 items-center gap-2 border-b border-white/10 px-3 py-2">
|
<div className="flex shrink-0 items-center gap-2 border-b border-white/10 px-3 py-2">
|
||||||
<TerminalSquare className="h-3.5 w-3.5 shrink-0" />
|
<TerminalSquare className="h-3.5 w-3.5 shrink-0" />
|
||||||
<span className="text-xs font-medium">Running shells</span>
|
<span className="text-xs font-medium">{t('terminal.runningShells')}</span>
|
||||||
<span className="text-[10px] opacity-60">{sessions.length}</span>
|
<span className="text-[10px] opacity-60">{sessions.length}</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => refetch()}
|
onClick={() => refetch()}
|
||||||
title="Refresh"
|
title={t('terminal.refresh')}
|
||||||
className="ml-auto cursor-pointer opacity-60 hover:opacity-100"
|
className="ml-auto cursor-pointer opacity-60 hover:opacity-100"
|
||||||
>
|
>
|
||||||
<RefreshCw className="h-3.5 w-3.5" />
|
<RefreshCw className="h-3.5 w-3.5" />
|
||||||
@@ -82,9 +84,9 @@ export const RunningShells = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
{isLoading && <p className="px-3 py-3 text-xs opacity-60">Loading…</p>}
|
{isLoading && <p className="px-3 py-3 text-xs opacity-60">{t('terminal.loading')}</p>}
|
||||||
{!isLoading && sessions.length === 0 && (
|
{!isLoading && sessions.length === 0 && (
|
||||||
<p className="px-3 py-3 text-xs opacity-60">No shells running.</p>
|
<p className="px-3 py-3 text-xs opacity-60">{t('terminal.noShells')}</p>
|
||||||
)}
|
)}
|
||||||
{sessions.map((s) => (
|
{sessions.map((s) => (
|
||||||
<div key={s.sessionId} className="flex items-center gap-2 border-b border-white/5 px-3 py-2">
|
<div key={s.sessionId} className="flex items-center gap-2 border-b border-white/5 px-3 py-2">
|
||||||
@@ -94,9 +96,7 @@ export const RunningShells = () => {
|
|||||||
<p className="truncate text-xs font-medium">
|
<p className="truncate text-xs font-medium">
|
||||||
{s.title || 'shell'}
|
{s.title || 'shell'}
|
||||||
{s.clients === 0 && (
|
{s.clients === 0 && (
|
||||||
<span className="ml-1.5 rounded bg-amber-500/15 px-1 py-px text-[10px] text-amber-400">
|
<span className="ml-1.5 rounded bg-amber-500/15 px-1 py-px text-[10px] text-amber-400">orphan</span>
|
||||||
orphan
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
<p className="truncate font-mono text-[10px] opacity-50">
|
<p className="truncate font-mono text-[10px] opacity-50">
|
||||||
@@ -109,7 +109,7 @@ export const RunningShells = () => {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => kill(s.sessionId)}
|
onClick={() => kill(s.sessionId)}
|
||||||
title="Kill this shell"
|
title={t('terminal.kill')}
|
||||||
className="shrink-0 cursor-pointer p-1 opacity-60 hover:text-red-400 hover:opacity-100"
|
className="shrink-0 cursor-pointer p-1 opacity-60 hover:text-red-400 hover:opacity-100"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { SearchAddon } from '@xterm/addon-search';
|
|||||||
import '@xterm/xterm/css/xterm.css';
|
import '@xterm/xterm/css/xterm.css';
|
||||||
import { useMounted } from 'hooks/useMounted';
|
import { useMounted } from 'hooks/useMounted';
|
||||||
import { copyToClipboard } from 'helpers/clipboard';
|
import { copyToClipboard } from 'helpers/clipboard';
|
||||||
|
import { useTranslation } from '../../locales';
|
||||||
|
|
||||||
// Scrollback the browser keeps. The sidecar keeps its own replay buffer for re-attach; this is what you
|
// Scrollback the browser keeps. The sidecar keeps its own replay buffer for re-attach; this is what you
|
||||||
// can scroll back to within a live session, and 1000 (xterm's default) is about one long agent turn.
|
// can scroll back to within a live session, and 1000 (xterm's default) is about one long agent turn.
|
||||||
@@ -93,6 +94,7 @@ export const TerminalView = ({
|
|||||||
onTitleChange,
|
onTitleChange,
|
||||||
onBell,
|
onBell,
|
||||||
}: TerminalViewProps) => {
|
}: TerminalViewProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const termRef = useRef<XTerm | null>(null);
|
const termRef = useRef<XTerm | null>(null);
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
@@ -535,7 +537,7 @@ export const TerminalView = ({
|
|||||||
<input
|
<input
|
||||||
autoFocus
|
autoFocus
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
placeholder="Find"
|
placeholder={t('terminal.find')}
|
||||||
onChange={(ev) => {
|
onChange={(ev) => {
|
||||||
setSearchTerm(ev.target.value);
|
setSearchTerm(ev.target.value);
|
||||||
runSearch(ev.target.value, 'next');
|
runSearch(ev.target.value, 'next');
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useAuth } from 'hooks/useAuth';
|
|||||||
import { Plus, X, RefreshCw } from 'lucide-react';
|
import { Plus, X, RefreshCw } from 'lucide-react';
|
||||||
import { useWidgetRegistry } from '../../WidgetRegistry';
|
import { useWidgetRegistry } from '../../WidgetRegistry';
|
||||||
import { WidgetPicker } from './WidgetPicker';
|
import { WidgetPicker } from './WidgetPicker';
|
||||||
|
import { useTranslation } from '../../locales';
|
||||||
|
|
||||||
type Position = { x: number; y: number }; // percentages (0–100)
|
type Position = { x: number; y: number }; // percentages (0–100)
|
||||||
|
|
||||||
@@ -102,20 +103,23 @@ const DraggableWidget = ({ instance, panelRef, onMove, onRemove }: DraggableWidg
|
|||||||
[instance.position, panelRef],
|
[instance.position, panelRef],
|
||||||
);
|
);
|
||||||
|
|
||||||
const endDrag = useCallback(
|
const endDrag = useCallback((ev: ReactPointerEvent) => {
|
||||||
(ev: ReactPointerEvent) => {
|
if (!dragRef.current) return;
|
||||||
if (!dragRef.current) return;
|
dragRef.current = null;
|
||||||
dragRef.current = null;
|
containerRef.current?.releasePointerCapture(ev.pointerId);
|
||||||
containerRef.current?.releasePointerCapture(ev.pointerId);
|
}, []);
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const onPointerMove = useCallback(
|
const onPointerMove = useCallback(
|
||||||
(ev: ReactPointerEvent) => {
|
(ev: ReactPointerEvent) => {
|
||||||
if (!dragRef.current) return;
|
if (!dragRef.current) return;
|
||||||
const panelRect = panelRef.current?.getBoundingClientRect();
|
const panelRect = panelRef.current?.getBoundingClientRect();
|
||||||
if (panelRect && (ev.clientX < panelRect.left || ev.clientX > panelRect.right || ev.clientY < panelRect.top || ev.clientY > panelRect.bottom)) {
|
if (
|
||||||
|
panelRect &&
|
||||||
|
(ev.clientX < panelRect.left ||
|
||||||
|
ev.clientX > panelRect.right ||
|
||||||
|
ev.clientY < panelRect.top ||
|
||||||
|
ev.clientY > panelRect.bottom)
|
||||||
|
) {
|
||||||
endDrag(ev);
|
endDrag(ev);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -165,6 +169,7 @@ const DraggableWidget = ({ instance, panelRef, onMove, onRemove }: DraggableWidg
|
|||||||
// --- WidgetPanel ---
|
// --- WidgetPanel ---
|
||||||
|
|
||||||
export const WidgetPanel = ({ panelId }: { panelId: string }) => {
|
export const WidgetPanel = ({ panelId }: { panelId: string }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [config, setConfig] = useWidgetPanelState(panelId);
|
const [config, setConfig] = useWidgetPanelState(panelId);
|
||||||
const [instances, setInstances] = useState<WidgetInstance[]>(config.instances);
|
const [instances, setInstances] = useState<WidgetInstance[]>(config.instances);
|
||||||
const [showPicker, setShowPicker] = useState(false);
|
const [showPicker, setShowPicker] = useState(false);
|
||||||
@@ -220,7 +225,9 @@ export const WidgetPanel = ({ panelId }: { panelId: string }) => {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<div className={`absolute bottom-4 right-4 flex flex-col items-end transition-opacity ${showPicker ? 'opacity-100' : 'opacity-0 group-hover/panel:opacity-100'}`}>
|
<div
|
||||||
|
className={`absolute bottom-4 right-4 flex flex-col items-end transition-opacity ${showPicker ? 'opacity-100' : 'opacity-0 group-hover/panel:opacity-100'}`}
|
||||||
|
>
|
||||||
{showPicker && (
|
{showPicker && (
|
||||||
<div className="mb-2 rounded-xl border border-border bg-card p-3 shadow-lg">
|
<div className="mb-2 rounded-xl border border-border bg-card p-3 shadow-lg">
|
||||||
<WidgetPicker onSelect={addWidget} />
|
<WidgetPicker onSelect={addWidget} />
|
||||||
@@ -229,7 +236,7 @@ export const WidgetPanel = ({ panelId }: { panelId: string }) => {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
title="Resync widgets (reload to pick up newly-deployed widgets)"
|
title={t('widgets.resync')}
|
||||||
className="flex h-10 w-10 items-center justify-center rounded-full border border-border bg-card text-muted-foreground shadow-lg transition-colors hover:text-foreground cursor-pointer"
|
className="flex h-10 w-10 items-center justify-center rounded-full border border-border bg-card text-muted-foreground shadow-lg transition-colors hover:text-foreground cursor-pointer"
|
||||||
onClick={() => window.location.reload()}
|
onClick={() => window.location.reload()}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import type { AppRegistryMeta } from '../../AppRegistry';
|
import type { AppRegistryMeta } from '../../AppRegistry';
|
||||||
import { LayoutGrid } from 'lucide-react';
|
import { LayoutGrid } from 'lucide-react';
|
||||||
import { WidgetPanel } from './WidgetPanel';
|
import { WidgetPanel } from './WidgetPanel';
|
||||||
|
import { panelName } from '../../locales';
|
||||||
|
|
||||||
export const appRegistryMetas: AppRegistryMeta[] = [
|
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||||
{
|
{
|
||||||
key: 'officerdev/widgets',
|
key: 'officerdev/widgets',
|
||||||
name: 'Widgets',
|
name: panelName('panelName.widgets'),
|
||||||
icon: LayoutGrid,
|
icon: LayoutGrid,
|
||||||
component: WidgetPanel,
|
component: WidgetPanel,
|
||||||
transparent: true,
|
transparent: true,
|
||||||
|
|||||||
@@ -1 +1,192 @@
|
|||||||
{}
|
{
|
||||||
|
"common": {
|
||||||
|
"save": "Save",
|
||||||
|
"delete": "Delete"
|
||||||
|
},
|
||||||
|
"minesweeper": {
|
||||||
|
"notInstalled": "Minesweeper is not installed on this server.",
|
||||||
|
"needs": "It needs wine, a 32-bit prefix, and this package beside the platform:",
|
||||||
|
"notRunning": "The Minesweeper session is not running.",
|
||||||
|
"error": {
|
||||||
|
"connectionFailed": "The connection to the Minesweeper session failed.",
|
||||||
|
"refused": "The VNC server refused the connection.",
|
||||||
|
"authUnsupported": "The VNC server wants authentication, which this client does not implement.",
|
||||||
|
"handshakeRejected": "The VNC server rejected the handshake.",
|
||||||
|
"unknownMessage": "The VNC server sent an unknown message type ({{type}}).",
|
||||||
|
"unknownEncoding": "The VNC server used encoding {{encoding}}, which this client did not ask for."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"qrTransfer": {
|
||||||
|
"chooseFile": "Choose a file to send",
|
||||||
|
"startScanning": "Start scanning",
|
||||||
|
"receiveAnother": "Receive another",
|
||||||
|
"stop": "Stop"
|
||||||
|
},
|
||||||
|
"dav": {
|
||||||
|
"empty": "Nothing here yet. Add one from a phone or desktop client over CalDAV/CardDAV.",
|
||||||
|
"calendar": "Calendar",
|
||||||
|
"contacts": "Contacts",
|
||||||
|
"readFailed": "Could not read {{name}}: {{reason}}"
|
||||||
|
},
|
||||||
|
"terminal": {
|
||||||
|
"title": "Terminal",
|
||||||
|
"find": "Find",
|
||||||
|
"runningShells": "Running shells",
|
||||||
|
"loading": "Loading…",
|
||||||
|
"noShells": "No shells running.",
|
||||||
|
"refresh": "Refresh",
|
||||||
|
"kill": "Kill this shell"
|
||||||
|
},
|
||||||
|
"appStore": {
|
||||||
|
"loading": "Loading",
|
||||||
|
"loadFailed": "Could not load the app store.",
|
||||||
|
"installed": "Installed",
|
||||||
|
"available": "Available",
|
||||||
|
"needsYou": "Needs you",
|
||||||
|
"needsYouShort": "needs you",
|
||||||
|
"selectApp": "Select an app",
|
||||||
|
"installFailed": "Install failed",
|
||||||
|
"uninstall": "Uninstall",
|
||||||
|
"enable": "Enable",
|
||||||
|
"disable": "Disable",
|
||||||
|
"enabled": "enabled",
|
||||||
|
"disabled": "disabled",
|
||||||
|
"removalNote": "Disabling stops the sidecar and its container. Uninstalling also removes the containers — your data, configuration and this app’s tables are kept either way.",
|
||||||
|
"installing": "installing",
|
||||||
|
"failed": "failed",
|
||||||
|
"installedIcon": "installed"
|
||||||
|
},
|
||||||
|
"systemMonitor": {
|
||||||
|
"cpuPackage": "CPU package",
|
||||||
|
"user": "User",
|
||||||
|
"cpuPct": "CPU%",
|
||||||
|
"memPct": "MEM%",
|
||||||
|
"command": "Command",
|
||||||
|
"memory": "Memory",
|
||||||
|
"disks": "Disks",
|
||||||
|
"temperature": "Temperature",
|
||||||
|
"network": "Network",
|
||||||
|
"power": "Power",
|
||||||
|
"topProcesses": "Top processes",
|
||||||
|
"filterPlaceholder": "filter name or image…",
|
||||||
|
"name": "Name",
|
||||||
|
"status": "Status",
|
||||||
|
"mem": "Mem",
|
||||||
|
"uptime": "Uptime"
|
||||||
|
},
|
||||||
|
"codeEditor": {
|
||||||
|
"closeFile": "Close {{name}}",
|
||||||
|
"loading": "Loading…",
|
||||||
|
"noFiles": "No files",
|
||||||
|
"explorer": "Explorer",
|
||||||
|
"openAFile": "Open a file from the explorer to start editing",
|
||||||
|
"refresh": "Refresh the tree and reload unmodified open files",
|
||||||
|
"readFailed": "Failed to read file",
|
||||||
|
"saved": "File saved",
|
||||||
|
"saveFailed": "Failed to save file",
|
||||||
|
"title": "Code Editor"
|
||||||
|
},
|
||||||
|
"widgets": {
|
||||||
|
"resync": "Resync widgets (reload to pick up newly-deployed widgets)"
|
||||||
|
},
|
||||||
|
"preview": {
|
||||||
|
"title": "Preview",
|
||||||
|
"reload": "Reload",
|
||||||
|
"changeUrl": "Change URL",
|
||||||
|
"openNewTab": "Open in a new tab",
|
||||||
|
"pointAtApp": "Point this panel at a running app",
|
||||||
|
"open": "Open",
|
||||||
|
"hint": "Anything the browser can reach. It stays on this panel and survives a reload.",
|
||||||
|
"connecting": "Connecting…"
|
||||||
|
},
|
||||||
|
"sendOff": {
|
||||||
|
"files": "Files",
|
||||||
|
"where": "Where",
|
||||||
|
"loading": "Loading…",
|
||||||
|
"landsIn": "Lands in their",
|
||||||
|
"landedIn": "Landed in",
|
||||||
|
"whoMaySend": "Who may send to me",
|
||||||
|
"allowedLandIn": "Files from anyone you allow land in",
|
||||||
|
"removeFile": "Remove {{name}}",
|
||||||
|
"sendTo": "Send to {{name}}",
|
||||||
|
"turningOff": "Turning someone off stops future sends and leaves what they already sent alone."
|
||||||
|
},
|
||||||
|
"panelName": {
|
||||||
|
"appStore": "App store",
|
||||||
|
"appDetail": "App detail",
|
||||||
|
"calendar": "Calendar",
|
||||||
|
"contacts": "Contacts",
|
||||||
|
"categories": "Categories",
|
||||||
|
"chat": "Chat",
|
||||||
|
"chatSessions": "Sessions",
|
||||||
|
"codeEditor": "Code Editor",
|
||||||
|
"commander": "Commander",
|
||||||
|
"commanders": "Commanders",
|
||||||
|
"dashboardPreview": "Dashboard Preview",
|
||||||
|
"dashboards": "Dashboards",
|
||||||
|
"fileBrowser": "File Browser",
|
||||||
|
"pluginDetail": "Plugin detail",
|
||||||
|
"plugins": "Plugins",
|
||||||
|
"preview": "Preview",
|
||||||
|
"qrReceive": "QR Receive",
|
||||||
|
"qrSend": "QR Send",
|
||||||
|
"scope": "Scope",
|
||||||
|
"systemMonitor": "System Monitor",
|
||||||
|
"widgets": "Widgets",
|
||||||
|
"terminal": "Terminal",
|
||||||
|
"hostTerminal": "Host Terminal",
|
||||||
|
"runningShells": "Running Shells",
|
||||||
|
"pm2Processes": "pm2 processes"
|
||||||
|
},
|
||||||
|
"dashboards": {
|
||||||
|
"title": "Dashboards",
|
||||||
|
"new": "New Dashboard",
|
||||||
|
"delete": "Delete dashboard",
|
||||||
|
"confirmDelete": "Are you sure you want to delete",
|
||||||
|
"search": "Search dashboards",
|
||||||
|
"name": "Name",
|
||||||
|
"description": "Description",
|
||||||
|
"open": "Open Dashboard",
|
||||||
|
"selectOne": "Select a dashboard or create a new one",
|
||||||
|
"namePlaceholder": "My Dashboard",
|
||||||
|
"descriptionPlaceholder": "What is this dashboard for?",
|
||||||
|
"template": "Template",
|
||||||
|
"create": "Create",
|
||||||
|
"templates": {
|
||||||
|
"single": "Single",
|
||||||
|
"twoColumns": "2 Columns",
|
||||||
|
"mainSide": "Main + Side",
|
||||||
|
"sidebar": "Sidebar",
|
||||||
|
"grid": "2x2 Grid",
|
||||||
|
"colsBottom": "Cols + Bottom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fileViewer": {
|
||||||
|
"readAloud": "Read Aloud",
|
||||||
|
"ocr": "Extract Text (OCR)",
|
||||||
|
"transcribe": "Transcribe",
|
||||||
|
"extractAudio": "Extract Audio",
|
||||||
|
"extract": "Extract",
|
||||||
|
"saveNextTo": "Save next to original",
|
||||||
|
"download": "Download",
|
||||||
|
"pdfViewer": "PDF viewer",
|
||||||
|
"videoFailed": "Failed to load video",
|
||||||
|
"imageFailed": "Failed to load image",
|
||||||
|
"audioFailed": "Failed to load audio file",
|
||||||
|
"audioTrack": "Audio track",
|
||||||
|
"subtitles": "Subtitles",
|
||||||
|
"track": "Track {{n}}",
|
||||||
|
"saved": "File saved",
|
||||||
|
"saveFailed": "Failed to save file",
|
||||||
|
"scrollTop": "Scroll to top",
|
||||||
|
"speechFailed": "Failed to generate speech audio",
|
||||||
|
"ocrFailed": "Failed to extract text from image",
|
||||||
|
"transcribeFailed": "Failed to transcribe audio",
|
||||||
|
"extractAudioFailed": "Failed to extract audio from video",
|
||||||
|
"extractFailed": "Failed to extract archive",
|
||||||
|
"savedAs": "Saved as “{{name}}”",
|
||||||
|
"extractedTo": "Extracted to “{{name}}”",
|
||||||
|
"downloadInstead": "Download instead",
|
||||||
|
"archiveFile": "Archive file"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,3 +6,5 @@ import ptPT from './pt-PT.json';
|
|||||||
export const panelCatalogs: TranslationMap = { 'en-GB': enGB, 'pt-PT': ptPT };
|
export const panelCatalogs: TranslationMap = { 'en-GB': enGB, 'pt-PT': ptPT };
|
||||||
|
|
||||||
export { useTranslation } from './useTranslation';
|
export { useTranslation } from './useTranslation';
|
||||||
|
export type { PanelTranslationKey } from './useTranslation';
|
||||||
|
export { panelName } from './panelName';
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { I18nProvider, type TranslationMap } from 'i18n';
|
||||||
|
import { resolveEntryName } from 'workspaces/react';
|
||||||
|
import { panelName } from './panelName';
|
||||||
|
|
||||||
|
const translations: TranslationMap = {
|
||||||
|
'en-GB': { panel: { panelName: { fileBrowser: 'File Browser' } } },
|
||||||
|
'pt-PT': { panel: { panelName: { fileBrowser: 'Explorador de ficheiros' } } },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Stands in for AppPicker/PanelSlot: reads the name during ITS OWN render, exactly as they do. */
|
||||||
|
const NameProbe = ({ entry }: { entry: { name: string | (() => string) } }) => <span>{resolveEntryName(entry)}</span>;
|
||||||
|
|
||||||
|
describe('panelName', () => {
|
||||||
|
test('a plain string still works, so the 27 can migrate one at a time', () => {
|
||||||
|
render(<NameProbe entry={{ name: 'Minesweeper' }} />);
|
||||||
|
expect(screen.getByText('Minesweeper')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The condition the owner set: the thunk must be READ at render, so a locale change reaches it.
|
||||||
|
* Re-rendering under a different provider locale has to produce the other language — if the name
|
||||||
|
* were cached anywhere on the entry or in the module, this is the test that would fail.
|
||||||
|
*/
|
||||||
|
test('the name follows the provider locale across a re-render', () => {
|
||||||
|
const entry = { name: panelName('panelName.fileBrowser') };
|
||||||
|
|
||||||
|
const { rerender } = render(
|
||||||
|
<I18nProvider translations={translations} locale="en-GB" fallbackLocale="en-GB">
|
||||||
|
<NameProbe entry={entry} />
|
||||||
|
</I18nProvider>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('File Browser')).toBeDefined();
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<I18nProvider translations={translations} locale="pt-PT" fallbackLocale="en-GB">
|
||||||
|
<NameProbe entry={entry} />
|
||||||
|
</I18nProvider>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('Explorador de ficheiros')).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { translateStandalone } from 'i18n/standalone';
|
||||||
|
import type { PanelTranslationKey } from './useTranslation';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A panel's display name, as a thunk the framework calls during its own render.
|
||||||
|
*
|
||||||
|
* `AppRegistryEntry.name` accepts `string | (() => string)` precisely so this can be late-bound: the
|
||||||
|
* metas are module-level data, so there is no hook in scope, and `workspaces` has no dependencies to
|
||||||
|
* resolve a key with. See that type's comment for the layering argument.
|
||||||
|
*
|
||||||
|
* A panel whose name needs no translating — a product name like Minesweeper or SendOff — stays a plain
|
||||||
|
* string in its meta rather than becoming a key whose two sides can never legitimately differ.
|
||||||
|
*/
|
||||||
|
export const panelName =
|
||||||
|
(key: PanelTranslationKey): (() => string) =>
|
||||||
|
() =>
|
||||||
|
translateStandalone(`panel.${key}`);
|
||||||
@@ -1 +1,192 @@
|
|||||||
{}
|
{
|
||||||
|
"common": {
|
||||||
|
"save": "Guardar",
|
||||||
|
"delete": "Eliminar"
|
||||||
|
},
|
||||||
|
"minesweeper": {
|
||||||
|
"notInstalled": "O Minesweeper não está instalado neste servidor.",
|
||||||
|
"needs": "Precisa do wine, de um prefixo de 32 bits e deste pacote junto à plataforma:",
|
||||||
|
"notRunning": "A sessão do Minesweeper não está em execução.",
|
||||||
|
"error": {
|
||||||
|
"connectionFailed": "A ligação à sessão do Minesweeper falhou.",
|
||||||
|
"refused": "O servidor VNC recusou a ligação.",
|
||||||
|
"authUnsupported": "O servidor VNC pede autenticação, que este cliente não implementa.",
|
||||||
|
"handshakeRejected": "O servidor VNC rejeitou o handshake.",
|
||||||
|
"unknownMessage": "O servidor VNC enviou um tipo de mensagem desconhecido ({{type}}).",
|
||||||
|
"unknownEncoding": "O servidor VNC usou a codificação {{encoding}}, que este cliente não pediu."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"qrTransfer": {
|
||||||
|
"chooseFile": "Escolha um ficheiro para enviar",
|
||||||
|
"startScanning": "Começar a digitalizar",
|
||||||
|
"receiveAnother": "Receber outro",
|
||||||
|
"stop": "Parar"
|
||||||
|
},
|
||||||
|
"dav": {
|
||||||
|
"empty": "Ainda não há nada aqui. Adicione um a partir de um cliente de telemóvel ou computador por CalDAV/CardDAV.",
|
||||||
|
"calendar": "Calendário",
|
||||||
|
"contacts": "Contactos",
|
||||||
|
"readFailed": "Não foi possível ler {{name}}: {{reason}}"
|
||||||
|
},
|
||||||
|
"terminal": {
|
||||||
|
"title": "Terminal",
|
||||||
|
"find": "Procurar",
|
||||||
|
"runningShells": "Shells em execução",
|
||||||
|
"loading": "A carregar…",
|
||||||
|
"noShells": "Nenhuma shell em execução.",
|
||||||
|
"refresh": "Atualizar",
|
||||||
|
"kill": "Terminar esta shell"
|
||||||
|
},
|
||||||
|
"appStore": {
|
||||||
|
"loading": "A carregar",
|
||||||
|
"loadFailed": "Não foi possível carregar a loja de aplicações.",
|
||||||
|
"installed": "Instaladas",
|
||||||
|
"available": "Disponíveis",
|
||||||
|
"needsYou": "Precisa de si",
|
||||||
|
"needsYouShort": "precisa de si",
|
||||||
|
"selectApp": "Selecione uma aplicação",
|
||||||
|
"installFailed": "A instalação falhou",
|
||||||
|
"uninstall": "Desinstalar",
|
||||||
|
"enable": "Ativar",
|
||||||
|
"disable": "Desativar",
|
||||||
|
"enabled": "ativada",
|
||||||
|
"disabled": "desativada",
|
||||||
|
"removalNote": "Desativar para o sidecar e o seu contentor. Desinstalar remove também os contentores — os seus dados, a configuração e as tabelas desta aplicação são mantidos em qualquer dos casos.",
|
||||||
|
"installing": "a instalar",
|
||||||
|
"failed": "falhou",
|
||||||
|
"installedIcon": "instalada"
|
||||||
|
},
|
||||||
|
"systemMonitor": {
|
||||||
|
"cpuPackage": "Pacote do CPU",
|
||||||
|
"user": "Utilizador",
|
||||||
|
"cpuPct": "CPU%",
|
||||||
|
"memPct": "MEM%",
|
||||||
|
"command": "Comando",
|
||||||
|
"memory": "Memória",
|
||||||
|
"disks": "Discos",
|
||||||
|
"temperature": "Temperatura",
|
||||||
|
"network": "Rede",
|
||||||
|
"power": "Energia",
|
||||||
|
"topProcesses": "Processos principais",
|
||||||
|
"filterPlaceholder": "filtrar por nome ou imagem…",
|
||||||
|
"name": "Nome",
|
||||||
|
"status": "Estado",
|
||||||
|
"mem": "Mem",
|
||||||
|
"uptime": "Tempo ativo"
|
||||||
|
},
|
||||||
|
"codeEditor": {
|
||||||
|
"closeFile": "Fechar {{name}}",
|
||||||
|
"loading": "A carregar…",
|
||||||
|
"noFiles": "Sem ficheiros",
|
||||||
|
"explorer": "Explorador",
|
||||||
|
"openAFile": "Abra um ficheiro no explorador para começar a editar",
|
||||||
|
"refresh": "Atualizar a árvore e recarregar os ficheiros abertos não modificados",
|
||||||
|
"readFailed": "Falha ao ler o ficheiro",
|
||||||
|
"saved": "Ficheiro guardado",
|
||||||
|
"saveFailed": "Falha ao guardar o ficheiro",
|
||||||
|
"title": "Editor de código"
|
||||||
|
},
|
||||||
|
"widgets": {
|
||||||
|
"resync": "Ressincronizar widgets (recarregue para incluir widgets recém-implementados)"
|
||||||
|
},
|
||||||
|
"preview": {
|
||||||
|
"title": "Pré-visualização",
|
||||||
|
"reload": "Recarregar",
|
||||||
|
"changeUrl": "Alterar URL",
|
||||||
|
"openNewTab": "Abrir num novo separador",
|
||||||
|
"pointAtApp": "Aponte este painel para uma aplicação em execução",
|
||||||
|
"open": "Abrir",
|
||||||
|
"hint": "Qualquer coisa que o navegador consiga alcançar. Fica neste painel e sobrevive a um recarregamento.",
|
||||||
|
"connecting": "A ligar…"
|
||||||
|
},
|
||||||
|
"sendOff": {
|
||||||
|
"files": "Ficheiros",
|
||||||
|
"where": "Onde",
|
||||||
|
"loading": "A carregar…",
|
||||||
|
"landsIn": "Aterra na pasta",
|
||||||
|
"landedIn": "Aterrou em",
|
||||||
|
"whoMaySend": "Quem me pode enviar",
|
||||||
|
"allowedLandIn": "Os ficheiros de quem autorizar aterram em",
|
||||||
|
"removeFile": "Remover {{name}}",
|
||||||
|
"sendTo": "Enviar para {{name}}",
|
||||||
|
"turningOff": "Desativar alguém impede envios futuros e deixa intacto o que já enviou."
|
||||||
|
},
|
||||||
|
"panelName": {
|
||||||
|
"appStore": "Loja de aplicações",
|
||||||
|
"appDetail": "Detalhe da aplicação",
|
||||||
|
"calendar": "Calendário",
|
||||||
|
"contacts": "Contactos",
|
||||||
|
"categories": "Categorias",
|
||||||
|
"chat": "Chat",
|
||||||
|
"chatSessions": "Sessões",
|
||||||
|
"codeEditor": "Editor de código",
|
||||||
|
"commander": "Comandante",
|
||||||
|
"commanders": "Comandantes",
|
||||||
|
"dashboardPreview": "Pré-visualização do painel",
|
||||||
|
"dashboards": "Painéis",
|
||||||
|
"fileBrowser": "Explorador de ficheiros",
|
||||||
|
"pluginDetail": "Detalhe do plugin",
|
||||||
|
"plugins": "Plugins",
|
||||||
|
"preview": "Pré-visualização",
|
||||||
|
"qrReceive": "Receber por QR",
|
||||||
|
"qrSend": "Enviar por QR",
|
||||||
|
"scope": "Âmbito",
|
||||||
|
"systemMonitor": "Monitor do sistema",
|
||||||
|
"widgets": "Widgets",
|
||||||
|
"terminal": "Terminal",
|
||||||
|
"hostTerminal": "Terminal do anfitrião",
|
||||||
|
"runningShells": "Shells em execução",
|
||||||
|
"pm2Processes": "processos pm2"
|
||||||
|
},
|
||||||
|
"dashboards": {
|
||||||
|
"title": "Painéis",
|
||||||
|
"new": "Novo painel",
|
||||||
|
"delete": "Eliminar painel",
|
||||||
|
"confirmDelete": "Tem a certeza de que quer eliminar",
|
||||||
|
"search": "Pesquisar painéis",
|
||||||
|
"name": "Nome",
|
||||||
|
"description": "Descrição",
|
||||||
|
"open": "Abrir painel",
|
||||||
|
"selectOne": "Selecione um painel ou crie um novo",
|
||||||
|
"namePlaceholder": "O meu painel",
|
||||||
|
"descriptionPlaceholder": "Para que serve este painel?",
|
||||||
|
"template": "Modelo",
|
||||||
|
"create": "Criar",
|
||||||
|
"templates": {
|
||||||
|
"single": "Único",
|
||||||
|
"twoColumns": "2 colunas",
|
||||||
|
"mainSide": "Principal + lateral",
|
||||||
|
"sidebar": "Barra lateral",
|
||||||
|
"grid": "Grelha 2x2",
|
||||||
|
"colsBottom": "Colunas + inferior"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fileViewer": {
|
||||||
|
"readAloud": "Ler em voz alta",
|
||||||
|
"ocr": "Extrair texto (OCR)",
|
||||||
|
"transcribe": "Transcrever",
|
||||||
|
"extractAudio": "Extrair áudio",
|
||||||
|
"extract": "Extrair",
|
||||||
|
"saveNextTo": "Guardar ao lado do original",
|
||||||
|
"download": "Descarregar",
|
||||||
|
"pdfViewer": "Visualizador de PDF",
|
||||||
|
"videoFailed": "Falha ao carregar o vídeo",
|
||||||
|
"imageFailed": "Falha ao carregar a imagem",
|
||||||
|
"audioFailed": "Falha ao carregar o ficheiro de áudio",
|
||||||
|
"audioTrack": "Faixa de áudio",
|
||||||
|
"subtitles": "Legendas",
|
||||||
|
"track": "Faixa {{n}}",
|
||||||
|
"saved": "Ficheiro guardado",
|
||||||
|
"saveFailed": "Falha ao guardar o ficheiro",
|
||||||
|
"scrollTop": "Ir para o topo",
|
||||||
|
"speechFailed": "Falha ao gerar o áudio de fala",
|
||||||
|
"ocrFailed": "Falha ao extrair texto da imagem",
|
||||||
|
"transcribeFailed": "Falha ao transcrever o áudio",
|
||||||
|
"extractAudioFailed": "Falha ao extrair o áudio do vídeo",
|
||||||
|
"extractFailed": "Falha ao extrair o arquivo",
|
||||||
|
"savedAs": "Guardado como “{{name}}”",
|
||||||
|
"extractedTo": "Extraído para “{{name}}”",
|
||||||
|
"downloadInstead": "Descarregar em vez disso",
|
||||||
|
"archiveFile": "Ficheiro de arquivo"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import type { InterpolationParams, TranslationKeys } from 'i18n';
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { useTranslation as useTranslationBase } from 'i18n';
|
import { useTranslation as useTranslationBase } from 'i18n';
|
||||||
import type en from './en-GB.json';
|
import type en from './en-GB.json';
|
||||||
|
import type uiEn from '@/components/locales/en-GB.json';
|
||||||
|
|
||||||
type PanelTranslationKey = TranslationKeys<typeof en>;
|
export type PanelTranslationKey = TranslationKeys<typeof en>;
|
||||||
|
type UiTranslationKey = TranslationKeys<typeof uiEn>;
|
||||||
|
|
||||||
// The `panel.` prefix is namespace wiring for the merged catalog (D6) — callers never spell it, they
|
// The `panel.` prefix is namespace wiring for the merged catalog (D6) — callers never spell it, they
|
||||||
// pass the same bare key the JSON file uses.
|
// pass the same bare key the JSON file uses.
|
||||||
@@ -15,5 +17,9 @@ export const useTranslation = () => {
|
|||||||
(key: PanelTranslationKey, params?: InterpolationParams) => baseT(`panel.${key}`, params),
|
(key: PanelTranslationKey, params?: InterpolationParams) => baseT(`panel.${key}`, params),
|
||||||
[baseT],
|
[baseT],
|
||||||
);
|
);
|
||||||
return { t, locale };
|
// `ui.*` for the words the shared components already own — "Cancel", "Close". Panel-specific strings
|
||||||
|
// stay in `panel.*`: a ui.* key may not name a feature, and a panel is a feature.
|
||||||
|
const tUi = useCallback((key: UiTranslationKey, params?: InterpolationParams) => baseT(`ui.${key}`, params), [baseT]);
|
||||||
|
|
||||||
|
return { t, tUi, locale };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { AppRegistryMap } from './types';
|
import type { AppRegistryMap } from './types';
|
||||||
|
import { resolveEntryName } from './types';
|
||||||
|
|
||||||
type AppPickerProps = {
|
type AppPickerProps = {
|
||||||
registry: AppRegistryMap;
|
registry: AppRegistryMap;
|
||||||
@@ -18,7 +19,9 @@ export const AppPicker = ({ registry, onSelect }: AppPickerProps) => {
|
|||||||
onClick={() => onSelect(key)}
|
onClick={() => onSelect(key)}
|
||||||
>
|
>
|
||||||
<entry.icon className="h-3.5 w-3.5 shrink-0" />
|
<entry.icon className="h-3.5 w-3.5 shrink-0" />
|
||||||
<span className="text-xs font-medium whitespace-nowrap">{entry.name}</span>
|
{/* Resolved here, at render. Caching it would pin the name to the locale the registry was
|
||||||
|
seeded with — which is before any locale has loaded. See resolveEntryName. */}
|
||||||
|
<span className="text-xs font-medium whitespace-nowrap">{resolveEntryName(entry)}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { ComponentType } from 'react';
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { ArrowLeftRight, ChevronLeft, X, Maximize2, Minimize2, ZoomIn, ZoomOut } from 'lucide-react';
|
import { ArrowLeftRight, ChevronLeft, X, Maximize2, Minimize2, ZoomIn, ZoomOut } from 'lucide-react';
|
||||||
import type { LayoutPanel, AppRegistryMap, PanelComponents, PanelComponentEntry } from './types';
|
import type { LayoutPanel, AppRegistryMap, PanelComponents, PanelComponentEntry } from './types';
|
||||||
|
import { resolveEntryName } from './types';
|
||||||
import { useWorkspace } from './WorkspaceContext';
|
import { useWorkspace } from './WorkspaceContext';
|
||||||
import { ZOOM_MIN, ZOOM_MAX, ZOOM_STEP } from '../core/layout';
|
import { ZOOM_MIN, ZOOM_MAX, ZOOM_STEP } from '../core/layout';
|
||||||
import { Card } from './internal/Card';
|
import { Card } from './internal/Card';
|
||||||
@@ -419,7 +420,9 @@ export const PanelSlot = ({
|
|||||||
) : entry ? (
|
) : entry ? (
|
||||||
<>
|
<>
|
||||||
<entry.icon className="h-3.5 w-3.5 shrink-0" />
|
<entry.icon className="h-3.5 w-3.5 shrink-0" />
|
||||||
<span className="text-xs font-medium truncate flex-1">{entry.name}</span>
|
{/* Resolved here, at render. Caching it would pin the name to the locale the registry was
|
||||||
|
seeded with — which is before any locale has loaded. See resolveEntryName. */}
|
||||||
|
<span className="text-xs font-medium truncate flex-1">{resolveEntryName(entry)}</span>
|
||||||
</>
|
</>
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export type { WorkspaceViewProps } from './WorkspaceView';
|
|||||||
export { WorkspaceLayout } from './WorkspaceLayout';
|
export { WorkspaceLayout } from './WorkspaceLayout';
|
||||||
export { WorkspaceRenderer } from './WorkspaceRenderer';
|
export { WorkspaceRenderer } from './WorkspaceRenderer';
|
||||||
export { PanelSlot } from './PanelSlot';
|
export { PanelSlot } from './PanelSlot';
|
||||||
|
export { resolveEntryName } from './types';
|
||||||
export { WorkspaceProvider, useWorkspace, inertInteraction } from './WorkspaceContext';
|
export { WorkspaceProvider, useWorkspace, inertInteraction } from './WorkspaceContext';
|
||||||
export type { WorkspaceContextValue } from './WorkspaceContext';
|
export type { WorkspaceContextValue } from './WorkspaceContext';
|
||||||
export { usePanelConfig } from './usePanelConfig';
|
export { usePanelConfig } from './usePanelConfig';
|
||||||
|
|||||||
@@ -24,7 +24,17 @@ export type DashboardState = {
|
|||||||
* an icon library on anyone — Officer's lucide icons satisfy it without changing.
|
* an icon library on anyone — Officer's lucide icons satisfy it without changing.
|
||||||
*/
|
*/
|
||||||
export type AppRegistryEntry = {
|
export type AppRegistryEntry = {
|
||||||
name: string;
|
/**
|
||||||
|
* The panel's display name, as a string or as a thunk that returns one.
|
||||||
|
*
|
||||||
|
* A thunk because this package has NO dependencies — not even i18n — and resolving a catalogue key
|
||||||
|
* here would invert the dependency graph: officerdev depends on this, not the other way round. So the
|
||||||
|
* caller binds the words and this only asks for them. A name that never needs translating stays a
|
||||||
|
* plain string, which is why the union keeps `string` rather than requiring a thunk everywhere.
|
||||||
|
*
|
||||||
|
* Call it during render, NEVER cache the result onto the entry — see `resolveEntryName`.
|
||||||
|
*/
|
||||||
|
name: string | (() => string);
|
||||||
icon: ComponentType<{ className?: string; size?: number | string }>;
|
icon: ComponentType<{ className?: string; size?: number | string }>;
|
||||||
component: ComponentType<{ panelId: string }>;
|
component: ComponentType<{ panelId: string }>;
|
||||||
header?: ComponentType<{ panelId: string }>;
|
header?: ComponentType<{ panelId: string }>;
|
||||||
@@ -37,6 +47,17 @@ export type AppRegistryEntry = {
|
|||||||
|
|
||||||
export type AppRegistryMap = Record<string, AppRegistryEntry>;
|
export type AppRegistryMap = Record<string, AppRegistryEntry>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a panel name for display. Called at every render on purpose.
|
||||||
|
*
|
||||||
|
* Memoising this onto the entry, or hoisting it out of the render, would freeze the name at whatever the
|
||||||
|
* locale was when the registry was seeded — and the registry is seeded once, before the locale has even
|
||||||
|
* loaded. A locale change would then update every string in the app except the panel titles. That is the
|
||||||
|
* `useClient()` shape documented in platform/CLAUDE.md, which cost days on the Jellyfin player.
|
||||||
|
*/
|
||||||
|
export const resolveEntryName = (entry: Pick<AppRegistryEntry, 'name'>): string =>
|
||||||
|
typeof entry.name === 'function' ? entry.name() : entry.name;
|
||||||
|
|
||||||
/** A panel supplied by a screen rather than by the registry. Rendered with `panelId`, exactly as those are. */
|
/** A panel supplied by a screen rather than by the registry. Rendered with `panelId`, exactly as those are. */
|
||||||
export type PanelComponentEntry = {
|
export type PanelComponentEntry = {
|
||||||
component: ComponentType<{ panelId: string }>;
|
component: ComponentType<{ panelId: string }>;
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
---
|
||||||
|
theme: agent-coordination
|
||||||
|
heading: 'Agents coordinating agents'
|
||||||
|
kind: theme
|
||||||
|
---
|
||||||
|
|
||||||
|
One agent made responsible for another's work — writing its prompts, reviewing what it pushed, deciding
|
||||||
|
what comes next — so the owner stops being the clipboard between two chats. The mechanism exists and was
|
||||||
|
used for a whole night on 2026-08-23; what is missing is the part that lets the worker believe it.
|
||||||
|
|
||||||
|
See `capabilities/affordances/officer/core/coordinating-another-agent.md` for what an agent is told today.
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
---
|
||||||
|
id: agent-coordination/a-peer-cannot-verify-a-delegation
|
||||||
|
title: 'A worker cannot verify that the owner delegated to a manager, so it has to ask every time'
|
||||||
|
theme: agent-coordination
|
||||||
|
status: open
|
||||||
|
created: 2026-08-23
|
||||||
|
date_source: stated
|
||||||
|
order: 1
|
||||||
|
---
|
||||||
|
|
||||||
|
**A worker agent cannot check whether the owner really delegated to a manager, so the owner has to say so
|
||||||
|
in person — once per worker, and again after every restart.**
|
||||||
|
|
||||||
|
Observed for a whole night on 2026-08-23. One session wrote the localisation phase prompts and reviewed the
|
||||||
|
work; another did it. The manager said "I have the hand-off"; the worker refused to act on it and was
|
||||||
|
**right to**, because a peer asserting that a pending instruction is approved is exactly what an agent must
|
||||||
|
not accept. That rule earns its keep precisely when the owner is asleep, which is when it came up.
|
||||||
|
|
||||||
|
## Two problems, and only one of them is about identity
|
||||||
|
|
||||||
|
**Identity is nearly free.** Session names are not stable: the manager was `platform-e2`, then
|
||||||
|
`platform-18`, and the worker `platform-59`, then `platform-72` — both renamed by a restart, in one night.
|
||||||
|
So a delegation naming a session dies with that session. Transcript ids ARE stable: `656f5c85` and
|
||||||
|
`c43d1ed0` survived two restarts each, verified. Keying on those fixes durability.
|
||||||
|
|
||||||
|
**Verifiability is the actual blocker, and swapping the identifier does nothing for it.** With a stable id
|
||||||
|
the manager can still only _claim_ delegation. The worker has no way to check, so it must still either
|
||||||
|
trust a peer's word — the thing it is told not to do — or ask the owner.
|
||||||
|
|
||||||
|
## The shape that would work
|
||||||
|
|
||||||
|
The same one SendOff's consent model already uses: **the grant is a row the platform holds, not a claim a
|
||||||
|
peer makes.**
|
||||||
|
|
||||||
|
- The owner records it once: _this manager may direct this worker, for this scope_.
|
||||||
|
- A worker receiving an instruction asks the PLATFORM whether that grant exists, rather than believing the
|
||||||
|
message. Absence denies.
|
||||||
|
- Keyed on transcript ids, so it survives a restart of either side.
|
||||||
|
- Revocable, and revocation takes effect on the next instruction rather than at the next restart.
|
||||||
|
|
||||||
|
Rough size: a table, one read endpoint, one write path, a registry entry. Half an hour of code.
|
||||||
|
|
||||||
|
## Why it was NOT built on the night it was diagnosed
|
||||||
|
|
||||||
|
Because it would not have unblocked the session that was blocked. A worker already running carries the
|
||||||
|
affordance as it was when it started, and has no instruction to consult a delegation table that did not
|
||||||
|
exist. So the fix only helps sessions begun after it lands — while the owner relaying one message costs
|
||||||
|
seconds.
|
||||||
|
|
||||||
|
Worth remembering when this is picked up: **shipping the table is the easy half.** The affordance has to
|
||||||
|
tell a worker to check it, and every already-running session will still be operating on the old rule.
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
---
|
||||||
|
id: agent-coordination/an-agent-cannot-learn-its-own-session-id
|
||||||
|
title: 'An agent cannot learn its own transcript id, so it cannot sign its own work'
|
||||||
|
theme: agent-coordination
|
||||||
|
status: open
|
||||||
|
created: 2026-08-23
|
||||||
|
date_source: stated
|
||||||
|
order: 2
|
||||||
|
---
|
||||||
|
|
||||||
|
**An agent has no reliable way to find out its own transcript id**, which blocks the commit convention in
|
||||||
|
`CLAUDE.md` — "an agent's commit names the session that made it."
|
||||||
|
|
||||||
|
Searched on 2026-08-23. No environment variable carries it. `~/.claude/sessions/` is keyed on process id, not
|
||||||
|
transcript. The only exposure found is indirect: a background task's output path contains it
|
||||||
|
(`/tmp/claude-1000/<project>/<transcript-id>/tasks/<id>.output`), so an agent can learn who it is by starting
|
||||||
|
a job it does not need and reading the path back. That works and it is absurd.
|
||||||
|
|
||||||
|
**The mechanism already exists for one case.** `commanderBlock` in `api/command-hq/commander-block.ts` hands
|
||||||
|
a commander its own transcript id, because a commander needs it to write its pid and spawned records. So the
|
||||||
|
platform knows the id at turn time and can already put it in a preamble — it just does that for commanders
|
||||||
|
only.
|
||||||
|
|
||||||
|
What to build: give every session its own id the same way. One line in whatever preamble a session already
|
||||||
|
receives, phrased as a fact rather than an instruction.
|
||||||
|
|
||||||
|
Two things to get right:
|
||||||
|
|
||||||
|
- **The transcript id, never the session name.** Names are renamed by restarts — two sessions were renamed
|
||||||
|
in one night on 2026-08-23 — and a commit trailer naming something that no longer exists is worse than no
|
||||||
|
trailer.
|
||||||
|
- **It unblocks more than the trailer.** The delegation problem in this theme is keyed on transcript ids too,
|
||||||
|
and a manager cannot record "I am X" in a delegation row it cannot name itself in.
|
||||||
|
|
||||||
|
Until it lands: an agent that happens to know its id adds the trailer, and one that does not leaves it out
|
||||||
|
rather than guessing.
|
||||||
Reference in New Issue
Block a user