Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d8709c05a | ||
|
|
2765e241d3 | ||
|
|
36e18f935d | ||
|
|
0ddcdcbdbf | ||
|
|
99a78f3c7f | ||
|
|
845699e697 | ||
|
|
fe000541b7 | ||
|
|
dfbf9be61c | ||
|
|
be29ae7aa7 | ||
|
|
e3e790fd2b | ||
|
|
2b0b671895 | ||
|
|
2ec3f11cdd | ||
|
|
5ce1a2928e | ||
|
|
339d31f05c | ||
|
|
a444280cb4 |
@@ -477,6 +477,9 @@ explaining why it was safe.
|
||||
owner-only while the plugin itself stays grantable. What the proxy should pass (`isOwner` and a level,
|
||||
never the role name), why declaring owner-only paths in the manifest beats trusting every plugin to
|
||||
remember, and the credential-resolution work that has to land first
|
||||
- `docs/roles-and-teams.md` — **plan, phase 0 built**: roles as a table, teams as scopes, and the one
|
||||
change that has to land first — the owner becomes an identity rather than whoever holds a role name,
|
||||
because otherwise "add a role" and "become the owner" are the same feature
|
||||
- `docs/localization.md` — **plan, not built**, with a working 5% skeleton already in the tree: taking the
|
||||
web UI to more than one language. What exists and what it cannot do yet, why the server should return
|
||||
error CODES rather than prose (and why a code must not leak more than the prose it replaced), the key
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# 2026.08.23.03 — deploy report
|
||||
|
||||
**5 of 5 target hosts succeeded.** Two hosts skipped by design: `officer-dev` (the rollout ran from it)
|
||||
and `edge-pertento-bridge` (deliberately skipped in `docs/servers.md`, down by the owner's call — not
|
||||
attempted, nothing spent on it).
|
||||
|
||||
Every host fast-forwarded to the tag `deploy/2026.08.23.03` (`c0788d15`) from `0a8fe1c2`. No host was
|
||||
dirty in tracked files. No `bun install` was run, per the plan. No direct SQL was run — none was
|
||||
authorised and none was needed.
|
||||
|
||||
**Schema check, per host, by query — not by exit code.** `select name from roles order by name` returned
|
||||
the same four rows everywhere: `Admin, Developer, Member, Super Admin`. `bun db:push` also printed
|
||||
`[seed:roles] 4 seeded, 0 already present` on all five, so the `postdb:push` hook ran.
|
||||
|
||||
## Per host
|
||||
|
||||
| Host | HEAD before → after | db:push | `roles` query | plugins:update | restarted | back up |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| `alpha` | `0a8fe1c2` → `c0788d15` | ✓ | 4 rows ✓ | 12/12 in 49s | `officer` 68→69, + 12 plugin sidecars | online |
|
||||
| `fofos` | `0a8fe1c2` → `c0788d15` | ✓ | 4 rows ✓ | 1/1 in 7s | `officer` 22→23, + `officer-gitea` | online |
|
||||
| `production-pertento-bridge` | `0a8fe1c2` → `c0788d15` | ✓ | 4 rows ✓ | 1/1 in 4s | `officer` 1→2, + `officer-gitea` | online |
|
||||
| `deadpool-rezio-bridge` (`br`) | `0a8fe1c2` → `c0788d15` | ✓ | 4 rows ✓ | 2/2 in 7s | `officer` 7→8, + `officer-email`, `officer-offscale` | online |
|
||||
| `macbookpro` | `0a8fe1c2` → `c0788d15` | ✓ | 4 rows ✓ | no plugins installed | `officer` 9→10 | online |
|
||||
| `officer-dev` | — | — | 4 rows ✓ (pre-existing) | — | **skipped: this rollout ran on it** | — |
|
||||
| `edge-pertento-bridge` | — | — | — | — | **skipped: down, per `docs/servers.md`** | — |
|
||||
|
||||
A final `pm2 jlist` sweep across the five deployed hosts found **no process in any state other than
|
||||
`online`** — not just `officer`, every entry.
|
||||
|
||||
## Notes, and what was not done
|
||||
|
||||
- **`officer-dev` still needs its own restart.** It already holds `c0788d15` and the four rows, but
|
||||
nothing on it was pulled or bounced: the deploy agent runs inside `officer-claude-code` there, and
|
||||
restarting `officer` on it is the owner's deliberate last step.
|
||||
- **`fofos` carries one untracked file**, `scripts/chk.ts`. Noted, not touched — a fast-forward could
|
||||
never have collided with it, so it is not a reason to skip a host.
|
||||
- **`plugins:update` restarts more than the plan's `pm2 restart officer`.** That is the script's own
|
||||
behaviour and it is in the "restarted" column above rather than hidden: 12 sidecars bounced on `alpha`
|
||||
before `officer` itself was touched.
|
||||
- **`macbookpro` was awake** and needed the documented PATH append (`/opt/homebrew/bin`) for `bun`, with
|
||||
`pm2` run separately through `$SHELL -ic`. It has no plugins installed, so step 3 was a no-op there.
|
||||
- **Nothing was retried and nothing was repaired.** No step failed on any host.
|
||||
@@ -360,6 +360,19 @@ else will ever notice.
|
||||
obvious first pass. Any locale beyond Portuguese needs an answer to this before it is offered.
|
||||
3. **Plugin frontends** are out of scope and will need a catalog-merging contract in the plugin
|
||||
manifest. Their strings are not counted above.
|
||||
|
||||
**One constraint that contract has to answer, found while converting the shared components
|
||||
(2026-08-23):** those components now call `useTranslation`, so they resolve against whatever
|
||||
`I18nProvider` is above them — and with none above them they render their own dot-paths. Today that
|
||||
is safe by accident: there are exactly two React roots, and the one that is not `officer-web`
|
||||
(`apps/marketplace`) imports nothing from `components/`. A plugin frontend mounting its own root, or
|
||||
rendering a shared component outside `I18nBridge`, would put `ui.button.cancel` in front of a person.
|
||||
|
||||
So the contract needs to say where a plugin's provider comes from — inherit the host's, or mount its
|
||||
own seeded with the host's catalogs — rather than leaving each plugin to remember. Note that a
|
||||
provider-absent fallback inside the `components` package was considered and rejected: it would have
|
||||
split resolution across two mechanisms and contradicted D6, which puts the merge at the bridge.
|
||||
|
||||
4. **The `translateTo` setting already in `settings.languages`** is about translating _content_, not
|
||||
the UI. It is a different feature that happens to sit in the same object — do not conflate them.
|
||||
5. **Does a member see the owner's locale anywhere?** Any server-rendered or cached string shared
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
# Roles and teams
|
||||
|
||||
**Status: plan, not built.** Phase 0 landed 2026-08-23 (`roles` is a table). Everything below phase 0 is
|
||||
design, written down so the work starts from a spec rather than from a conversation.
|
||||
|
||||
## What this is
|
||||
|
||||
Officer began as one person's server and its authorization model says so: four role names in a TypeScript
|
||||
union, one owner identified by holding one of them, and grants keyed on a role string. That is correct for
|
||||
a homelab and it cannot express a company, where the same person is an administrator of one area and an
|
||||
ordinary member of another.
|
||||
|
||||
Two things are wanted, and they are separable:
|
||||
|
||||
- **Roles as data**, so a role can be added without a deploy, and so each can eventually carry its own
|
||||
provisioning.
|
||||
- **Teams**, so a user belongs to several and holds a different role in each.
|
||||
|
||||
**Teams are optional and must stay optional.** A homelab with family and friends on it may never make one;
|
||||
its owner's answer is "team family, team friends" or no teams at all, and either has to work. A company
|
||||
uses both. Nothing below may make teams mandatory to reach a feature.
|
||||
|
||||
---
|
||||
|
||||
## What already landed — phase 0, 2026-08-23
|
||||
|
||||
The `roles` table exists, seeded with `Super Admin`, `Admin`, `Developer`, `Member`, each flagged
|
||||
`isSystem` because the platform's own code names it. `provisioningScript` is declared and null.
|
||||
|
||||
**Nothing reads it yet.** `USER_ROLES` is still the type, `users.role` is still plain text with no foreign
|
||||
key, and `authorize.ts` is untouched. That was deliberate: the change is additive, so it cannot fail on
|
||||
existing data, which is what made it safe to hand to an unattended rollout across five hosts.
|
||||
|
||||
**No foreign key, and this is the reason.** `db:push` creates a table and adds a constraint in the same
|
||||
run, so a foreign key from `users.role` onto an empty `roles` is violated by every existing account.
|
||||
Verified against a scratch database: push reported the violation, created the table, skipped the
|
||||
constraint, **and exited 0**. See `prompts/deploy-commander.md` → "Schema changes".
|
||||
|
||||
---
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1. The owner is an identity. It is never a role.
|
||||
|
||||
`OWNER_USER_ID` — user 1, the account whose home is the home of the OS user the platform runs as.
|
||||
|
||||
Before phase 0, `isSuperAdmin()` answered `user.role === 'Super Admin'`. That was correct while roles were
|
||||
a hardcoded union and nothing could write a new one. **The moment roles are editable data it becomes a
|
||||
privilege-escalation path**: "add a role" and "become the owner" are the same feature, reachable from
|
||||
whatever UI manages roles.
|
||||
|
||||
So ownership moves to `user.id === OWNER_USER_ID`, and `getOwnerUser()` becomes a primary-key lookup rather
|
||||
than "the lowest id holding Super Admin". **This is the load-bearing change in phase 1** and everything
|
||||
else can wait behind it.
|
||||
|
||||
### D2. Several Super Admins are allowed, and the role provisions sudo
|
||||
|
||||
Today `create-user.ts` and `updateUserRoleHandler` refuse to create a second Super Admin. That limit goes:
|
||||
a company has an owner and several administrators, and a homelab owner may add one or never do.
|
||||
|
||||
**Holding `Super Admin` provisions an OS account with sudo.** Which means, plainly: that person is outside
|
||||
Officer's permission system. `role_permissions`, the `confined` kernel containment and the
|
||||
"`execution` is never grantable" rule bound nobody who can `sudo cat` any home on the box. And the grant is
|
||||
irreversible in practice — revoking the role does not revoke an SSH key, a cron entry or a setuid binary
|
||||
they left behind.
|
||||
|
||||
That is the operator's decision to make, not the platform's to prevent. What the platform owes them is for
|
||||
the UI to say what it is rather than presenting it as one more row in a dropdown.
|
||||
|
||||
### D3. `Admin` is app-wide authority with no operating-system privilege
|
||||
|
||||
The role most companies actually staff: creates accounts, manages grants, sees every dashboard, and cannot
|
||||
read the CEO's files. It already exists and `rolePermitsDevTools()` already excludes it deliberately —
|
||||
"administering the platform is not developing on it". This decision only writes that down.
|
||||
|
||||
### D4. A role may be deleted, but not while anyone holds it
|
||||
|
||||
Refuse the delete; never reassign. Silently moving accounts to a fallback role is a permission change
|
||||
nobody asked for, and "absence denies" is the posture everywhere else in this system.
|
||||
|
||||
`isSystem` roles refuse both delete and rename, because the code names them by name:
|
||||
`rolePermitsDevTools()` tests for `Developer`, `bootstrap.ts` writes `Super Admin`, `users.role` defaults to
|
||||
`Member`. Renaming one is a code change that has not happened yet.
|
||||
|
||||
### D5. Grants stay keyed on the role NAME, with `ON UPDATE CASCADE`
|
||||
|
||||
`role_permissions.role` and `users.role` both hold a name. Re-keying them onto a serial id would touch
|
||||
`authorize.ts` — the file standing between a Member and a shell — to buy what Postgres gives for free: a
|
||||
rename cascades to every user and every grant, atomically, with no code change.
|
||||
|
||||
The cost is that a rename is a data migration rather than a label edit, which is honest — a role name
|
||||
appears in `rolePermitsDevTools`, in report text, and in whatever a plugin has written down.
|
||||
|
||||
### D6. A team is a scope. Permissions UNION across a user's teams.
|
||||
|
||||
Admin in marketing and member in developers means marketing's admin level does not reach developers'
|
||||
files — but a user's effective permission for a given capability is the **highest level any of their teams
|
||||
grants**, plus their global role.
|
||||
|
||||
Union is the only coherent resolution, and it has a consequence worth stating: **adding somebody to a team
|
||||
can only ever grant, never restrict.** A team cannot be used to limit someone. If restriction is ever
|
||||
wanted, that is a different and much harder system (deny rules, precedence, conflict resolution) and it
|
||||
should be refused rather than half-built.
|
||||
|
||||
### D7. The global role remains, and answers a different question
|
||||
|
||||
Two questions, two mechanisms:
|
||||
|
||||
- **"May this account administer the server at all?"** — the global role. Super Admin, Admin.
|
||||
- **"What may this account do in this area?"** — team membership with a role in that team.
|
||||
|
||||
This is the shape GitHub, Slack and Workspace all converged on, and the reason is that the first question
|
||||
has no natural team to ask it about. A homelab uses only the first and never makes a team.
|
||||
|
||||
### D8. Per-role provisioning is a script the role carries
|
||||
|
||||
`provisioningScript` on the role row. Run when an account is granted that role; the reference implementation
|
||||
is `Developer`, which today gets a Postgres role of its own through `rolePermitsDevTools()` — a special case
|
||||
in code that should become a row.
|
||||
|
||||
The historical note matters for whoever builds it: that gate was originally for **rootless** Docker, and
|
||||
rootless specifically. `usermod -aG docker` is root by a longer route —
|
||||
`docker run -v /:/host -it alpine chroot /host` reads `.env`, every member's home and the wallet seed. Any
|
||||
provisioning script that adds a user to a privileged group is granting more than its role name suggests.
|
||||
|
||||
---
|
||||
|
||||
## Phases
|
||||
|
||||
### Phase 0 — roles are a table ✅ DONE 2026-08-23
|
||||
|
||||
Additive only. Nothing reads it.
|
||||
|
||||
### Phase 1 — the owner stops being a role
|
||||
|
||||
The load-bearing phase, and small.
|
||||
|
||||
1. `isOwner(user)` = `user.id === OWNER_USER_ID`. `getOwnerUser()` = lookup by that id.
|
||||
2. Split the two meanings of today's `isSuperAdmin()`: ownership, and holding the role. **Every existing
|
||||
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.
|
||||
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.
|
||||
|
||||
Reviewable on its own, and nothing downstream can start until ownership is an identity.
|
||||
|
||||
### Phase 2 — the foreign key, and role CRUD
|
||||
|
||||
1. **Two releases, not one.** The rows are on every host after phase 0; the constraint ships separately,
|
||||
and its release plan carries the SQL explicitly per `prompts/deploy-commander.md`.
|
||||
2. `users.role` and `role_permissions.role` become foreign keys with `ON UPDATE CASCADE` (D5).
|
||||
3. Role CRUD: list with holder counts, create, rename, delete guarded by D4.
|
||||
4. `USER_ROLES` stops being the source of truth. `UserRole` becomes `string`, and the picker reads the
|
||||
table. **This is where the typed-union safety is lost** — worth a runtime validator at the write path to
|
||||
replace what the compiler was doing.
|
||||
|
||||
### Phase 3 — per-role provisioning
|
||||
|
||||
`provisioningScript` gets a runner, and `rolePermitsDevTools()` becomes a row rather than a branch. Needs
|
||||
D8's warning in front of whoever writes the first script.
|
||||
|
||||
### Phase 4 — teams
|
||||
|
||||
1. `teams`, and `team_members(team_id, user_id, role)` — the role scoped to the team.
|
||||
2. `team_permissions(team_id, role, permission, level)`, or a discriminator on `role_permissions`. Decide by
|
||||
asking whether a team's roles are the same set as the global ones; probably yes, and probably that means
|
||||
one table with a nullable `team_id`.
|
||||
3. `authorize.ts` resolves the union (D6): global role grants, plus the highest level any team grants.
|
||||
**This is the one change in the whole plan that alters the resolver**, so it wants tests before it wants
|
||||
code — `permissions/authorize.test.ts` is already pure and mockless, which is what makes that possible.
|
||||
4. Teams UI, and the account screen showing which teams a user is in and as what.
|
||||
|
||||
### Phase 5 — the thing that actually unblocks all of it
|
||||
|
||||
**There is still no way to create a second account.** `createUser` has one call site, `auth/bootstrap.ts`,
|
||||
gated on an empty table; the existing accounts were inserted by hand. Roles and teams are a permission model
|
||||
for accounts that cannot yet exist.
|
||||
|
||||
Sequenced last here because it is orthogonal, and flagged because a reader will otherwise finish phase 4 and
|
||||
find nothing to test it with. **It may be worth doing first.**
|
||||
|
||||
---
|
||||
|
||||
## What must not be built
|
||||
|
||||
- **Deny rules.** See D6. Teams grant; they never restrict.
|
||||
- **A role that confers ownership.** See D1. There is exactly one owner and no role reaches it.
|
||||
- **Team-scoped `execution` capabilities.** Tasks, items, desktop and browser run as the owner's OS user in
|
||||
the owner's home. A team cannot make that safe, and "team developers gets terminal" has to stay a no —
|
||||
the answer is a `confined` grant with a Linux user, which is per-account by nature.
|
||||
|
||||
## Verification
|
||||
|
||||
- `bunx tsgo` clean. Note that on this machine `earlyoom` SIGTERMs the `bunx` wrapper around 15s; run the
|
||||
binary directly.
|
||||
- `permissions/authorize.test.ts` is the primary guard, and it uses real registry keys deliberately so a
|
||||
rename goes red rather than passing vacuously. Phase 4 must extend it before changing the resolver.
|
||||
- `bun run docs:check` — the permission count claim in `CLAUDE.md` moves whenever the registry does.
|
||||
- Any schema phase: verify by query per host, never by `db:push`'s exit code.
|
||||
|
||||
## Open questions
|
||||
|
||||
1. **Does a team have its own capabilities, or only its own levels?** Whether `team_permissions` can grant a
|
||||
capability the account's global role does not have at all. Union says yes; that may be more than wanted.
|
||||
2. **Who may create a team?** Owner only, or any Admin. Bears on whether a team is an administrative object
|
||||
or a self-service one.
|
||||
3. **Does a team own anything?** A team folder, a team dashboard. The answer decides whether teams are
|
||||
purely an authorization grouping or become a storage boundary — and the second is a much larger feature.
|
||||
4. **What happens to a user's team memberships when a role is deleted?** D4 refuses to delete a role anyone
|
||||
holds globally; the same question exists per team and is not answered here.
|
||||
@@ -9,6 +9,27 @@ one.
|
||||
|
||||
---
|
||||
|
||||
## Arming for the first time — there is nothing to rehearse against
|
||||
|
||||
General commander doctrine says to run the action once before arming, so it is known to work unattended.
|
||||
**That does not apply here, and reading it as "replay the last release" is wrong.**
|
||||
|
||||
Every `deploy/*` tag on the remote already has a success record in `data/deploys/`. The claim script will
|
||||
refuse every one of them, and refusing is correct — a claim exists so a release is attempted once, ever.
|
||||
So there is no legitimate trigger to rehearse with, and the only way to manufacture one is to tag a
|
||||
release, which you must never do.
|
||||
|
||||
So: **arm, and say plainly that the action has never executed under you.** The next real release is the
|
||||
first run. That is an honest statement of what is unproven, and it is better than a rehearsal that either
|
||||
fights the claim or invents an occasion.
|
||||
|
||||
Two things follow from it, and they are the reason this is fine rather than a gap:
|
||||
|
||||
- The procedure below is already proven — nine releases, nine reports, and the last four ran unattended.
|
||||
What is unproven is this loop, not the rollout.
|
||||
- Your first real release therefore deserves more narration than later ones, not less. Say what you are
|
||||
about to do before the agent starts, so the owner can stop it while stopping it is still cheap.
|
||||
|
||||
## What wakes you
|
||||
|
||||
You are not a loop. Something fires you, and each time you fire you do exactly one thing: check whether
|
||||
|
||||
@@ -26,29 +26,48 @@ const AREAS = [
|
||||
/**
|
||||
* Areas that have been converted and must stay converted. Add an area here the moment it reaches
|
||||
* zero — enabling every area on day one would just be red for weeks and get ignored.
|
||||
*
|
||||
* 'shared components' landed 2026-08-23 (localization phase 1). It holds three `new Error()` messages
|
||||
* that must NEVER be translated; they are reported but excluded from the check, which is why the
|
||||
* neverTranslate split below has to exist for this entry to be possible at all.
|
||||
*/
|
||||
const ZEROED: string[] = [];
|
||||
const ZEROED: string[] = ['shared components'];
|
||||
|
||||
const SKIP = /node_modules|\.gen\.|[/\\]locales[/\\]|[/\\]vendor[/\\]|\.test\.|\.d\.ts$/;
|
||||
|
||||
// A quoted literal, single or double. Kept as two groups because JS has no branch reset.
|
||||
const Q = String.raw`(?:"([^"\n]{2,120})"|'([^'\n]{2,120})')`;
|
||||
|
||||
const PATTERNS: { kind: string; rx: RegExp }[] = [
|
||||
/**
|
||||
* `neverTranslate` marks a category the doc's "What must never be translated" list forbids converting —
|
||||
* so it is reported (an agent should see it and confirm), but excluded from `--check`.
|
||||
*
|
||||
* Without that split the guard is unusable: an area holding one legitimate `new Error()` can never
|
||||
* reach zero, so it could never be added to ZEROED, so the debt guard could never be switched on for
|
||||
* it. Counting work that must NOT be done against a target of zero makes the target unreachable.
|
||||
*/
|
||||
const PATTERNS: { kind: string; rx: RegExp; neverTranslate?: true }[] = [
|
||||
// >Some visible words<
|
||||
{ kind: 'jsx text', rx: />\s*([A-Z][A-Za-z0-9][^<>{}\n]{2,100}?)\s*</g },
|
||||
// placeholder="..." and friends
|
||||
{
|
||||
kind: 'attributes',
|
||||
rx: new RegExp(String.raw`\b(?:placeholder|title|aria-label|alt|label|description|tooltip|emptyMessage)\s*=\s*` + Q, 'g'),
|
||||
rx: new RegExp(
|
||||
String.raw`\b(?:placeholder|title|aria-label|alt|label|description|tooltip|emptyMessage)\s*=\s*` + Q,
|
||||
'g',
|
||||
),
|
||||
},
|
||||
{ kind: 'toast/alert', rx: new RegExp(String.raw`\b(?:toast(?:\.\w+)?|alert|confirm)\s*\(\s*` + Q, 'g') },
|
||||
// label: 'Status' — nav items, menus, table columns. The largest hidden category.
|
||||
{
|
||||
kind: 'object labels',
|
||||
rx: new RegExp(String.raw`\b(?:label|name|title|heading|placeholder|description|tooltip|emptyText)\s*:\s*` + Q, 'g'),
|
||||
rx: new RegExp(
|
||||
String.raw`\b(?:label|name|title|heading|placeholder|description|tooltip|emptyText)\s*:\s*` + Q,
|
||||
'g',
|
||||
),
|
||||
},
|
||||
{ kind: 'thrown errors', rx: new RegExp(String.raw`new Error\(\s*` + Q, 'g') },
|
||||
// Thrown Errors are for whoever is reading a terminal at 4am. They stay in one language.
|
||||
{ kind: 'thrown errors', rx: new RegExp(String.raw`new Error\(\s*` + Q, 'g'), neverTranslate: true },
|
||||
// `Failed to create ${kind}` — needs interpolation, so it costs more to convert.
|
||||
{ kind: 'template str', rx: /`([A-Z][^`\n]{4,100}\$\{[^`]*)`/g },
|
||||
];
|
||||
@@ -62,9 +81,29 @@ function isHuman(value: string | undefined): value is string {
|
||||
if (/^[a-z0-9\-_./:]+$/.test(s)) return false;
|
||||
if (/^(https?:|\/|#|data:|application\/|image\/)/.test(s)) return false;
|
||||
if (/^[A-Z0-9_]+$/.test(s)) return false;
|
||||
// Tailwind, reached via a `description:`/`label:` key in a classNames object — e.g. sonner's
|
||||
// `description: 'group-[.toast]:text-muted-foreground'`.
|
||||
//
|
||||
// The test is per TOKEN, not on the whole string: every space-separated token must look like a
|
||||
// utility (all lowercase, no sentence punctuation) and at least one must carry a variant colon or
|
||||
// an arbitrary-value bracket. Testing the whole string instead cost a real label — "Application
|
||||
// type:" was filtered because it ends in a colon, which is also how prose introduces a value.
|
||||
const tokens = s.split(/\s+/);
|
||||
const utilityish = (tok: string) => /^[a-z0-9:_\-[\]().#&/%!]+$/.test(tok);
|
||||
if (tokens.every(utilityish) && tokens.some((tok) => /[:[]/.test(tok))) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Commented-out code is not user-facing text. `Header.tsx` carries a disabled dev-console button inside
|
||||
* a `{/* … *\/}` block, and counting it means the area can never reach zero without editing a comment.
|
||||
*
|
||||
* Deliberately only two shapes, both unambiguous: a block comment, and a line whose first non-space
|
||||
* characters are `//`. Stripping every `//` would eat the tail of any string holding a URL.
|
||||
*/
|
||||
const stripComments = (source: string): string =>
|
||||
source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^[ \t]*\/\/.*$/gm, '');
|
||||
|
||||
function* walk(dir: string): Generator<string> {
|
||||
let entries: string[];
|
||||
try {
|
||||
@@ -96,7 +135,7 @@ function scan(root: string): { files: number; hits: Hit[] } {
|
||||
files++;
|
||||
let source: string;
|
||||
try {
|
||||
source = readFileSync(file, 'utf8');
|
||||
source = stripComments(readFileSync(file, 'utf8'));
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
@@ -118,7 +157,9 @@ const results = AREAS.map((area) => ({ ...area, ...scan(area.root) }));
|
||||
const kinds = PATTERNS.map((p) => p.kind);
|
||||
const pad = (s: string | number, n: number) => String(s).padStart(n);
|
||||
|
||||
console.log(`${'area'.padEnd(22)} ${pad('files', 5)} ${kinds.map((k) => pad(k.slice(0, 13), 14)).join('')} ${pad('total', 7)}`);
|
||||
console.log(
|
||||
`${'area'.padEnd(22)} ${pad('files', 5)} ${kinds.map((k) => pad(k.slice(0, 13), 14)).join('')} ${pad('total', 7)}`,
|
||||
);
|
||||
for (const r of results) {
|
||||
const byKind = kinds.map((k) => pad(r.hits.filter((h) => h.kind === k).length, 14)).join('');
|
||||
console.log(`${r.label.padEnd(22)} ${pad(r.files, 5)} ${byKind} ${pad(r.hits.length, 7)}`);
|
||||
@@ -128,9 +169,19 @@ const all = results.flatMap((r) => r.hits);
|
||||
const distinct = new Set(all.map((h) => h.value));
|
||||
const interpolated = new Set(all.filter((h) => h.kind === 'template str').map((h) => h.value));
|
||||
|
||||
console.log(`${'TOTAL'.padEnd(22)} ${pad(results.reduce((n, r) => n + r.files, 0), 5)} ${kinds.map((k) => pad(all.filter((h) => h.kind === k).length, 14)).join('')} ${pad(all.length, 7)}`);
|
||||
const NEVER_KINDS = new Set(PATTERNS.filter((p) => p.neverTranslate).map((p) => p.kind));
|
||||
const convertible = (hits: Hit[]) => hits.filter((h) => !NEVER_KINDS.has(h.kind));
|
||||
|
||||
console.log(
|
||||
`${'TOTAL'.padEnd(22)} ${pad(
|
||||
results.reduce((n, r) => n + r.files, 0),
|
||||
5,
|
||||
)} ${kinds.map((k) => pad(all.filter((h) => h.kind === k).length, 14)).join('')} ${pad(all.length, 7)}`,
|
||||
);
|
||||
console.log();
|
||||
console.log(`occurrences ${all.length}`);
|
||||
console.log(` convertible ${convertible(all).length}`);
|
||||
console.log(` never-translate ${all.length - convertible(all).length} (${[...NEVER_KINDS].join(', ')})`);
|
||||
console.log(`distinct strings ${distinct.size}`);
|
||||
console.log(` needing params ${interpolated.size}`);
|
||||
|
||||
@@ -149,10 +200,16 @@ if (listPrefix) {
|
||||
}
|
||||
|
||||
if (args.has('--check')) {
|
||||
const regressed = results.filter((r) => ZEROED.includes(r.label) && r.hits.length > 0);
|
||||
const regressed = results
|
||||
.filter((r) => ZEROED.includes(r.label))
|
||||
.map((r) => ({ label: r.label, hits: convertible(r.hits) }))
|
||||
.filter((r) => r.hits.length > 0);
|
||||
if (regressed.length > 0) {
|
||||
console.error('\nThese areas were converted and have regressed:');
|
||||
for (const r of regressed) console.error(` ${r.label}: ${r.hits.length} hardcoded strings`);
|
||||
for (const r of regressed) {
|
||||
console.error(` ${r.label}: ${r.hits.length} hardcoded strings`);
|
||||
for (const h of r.hits) console.error(` [${h.kind}] ${h.value} — ${h.file}`);
|
||||
}
|
||||
console.error('\nUse a catalog key. See docs/localization.md.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,32 @@
|
||||
import type { TranslationKeys } from 'i18n';
|
||||
import type { InterpolationParams } from 'i18n';
|
||||
import { useCallback } from 'react';
|
||||
import { useTranslation as useTranslationBase } from 'i18n';
|
||||
import type en from '../locales/en-GB.json';
|
||||
import type uiEn from '@/components/locales/en-GB.json';
|
||||
|
||||
type AppTranslationKey = TranslationKeys<typeof en>;
|
||||
type UiTranslationKey = TranslationKeys<typeof uiEn>;
|
||||
|
||||
// The `app.` prefix is namespace wiring for the merged catalog (D6) — callers never spell it, they
|
||||
// pass the same bare key the JSON file uses.
|
||||
/**
|
||||
* `t` for this workspace's own catalog, `tUi` for the shared-component one.
|
||||
*
|
||||
* Both are here rather than in two imports because "Cancel", "Close" and "Search" recur across the
|
||||
* shell and genuinely belong to `ui.*` — phase 1 put them there so phases 2 and 3 could reach for them
|
||||
* instead of inventing a second key for the same word. Anything feature-specific stays in `app.*`:
|
||||
* per D6 a `ui.*` key may not name a feature.
|
||||
*
|
||||
* Both are memoized. An unstable `t` is unusable in a dependency array, and this repo has already lost
|
||||
* days to exactly that shape — see platform/CLAUDE.md on `useClient()` and the Jellyfin player.
|
||||
*/
|
||||
export const useTranslation = () => {
|
||||
const { t: baseT, locale } = useTranslationBase();
|
||||
const t = (key: AppTranslationKey, params?: InterpolationParams) => baseT(`app.${key}`, params);
|
||||
return { t, locale };
|
||||
|
||||
const t = useCallback((key: AppTranslationKey, params?: InterpolationParams) => baseT(`app.${key}`, params), [baseT]);
|
||||
|
||||
const tUi = useCallback((key: UiTranslationKey, params?: InterpolationParams) => baseT(`ui.${key}`, params), [baseT]);
|
||||
|
||||
return { t, tUi, locale };
|
||||
};
|
||||
|
||||
export type { AppTranslationKey };
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useTranslation } from '@/components/locales';
|
||||
|
||||
type ColorPickerProps = { value?: string; name: string; label?: string };
|
||||
export const ColorPicker = (props: ColorPickerProps) => {
|
||||
const { value = '#000000', name, label } = props;
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div style={{ borderWidth: '1px' }} className="relative w-full rounded">
|
||||
{label && <span className="text-muted-foreground absolute z-10 -mt-2 ml-5 bg-background px-2 text-xs">{label}</span>}
|
||||
{label && (
|
||||
<span className="text-muted-foreground absolute z-10 -mt-2 ms-5 bg-background px-2 text-xs">{label}</span>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="h-12 overflow-hidden rounded-full p-0">
|
||||
<Input
|
||||
@@ -13,14 +17,14 @@ export const ColorPicker = (props: ColorPickerProps) => {
|
||||
className="absolute h-12 w-24 rounded-full border-0 text-lg"
|
||||
type="color"
|
||||
name={name}
|
||||
placeholder="Font Weight"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Input
|
||||
name={name}
|
||||
className="border-0 text-right text-base outline-none focus:outline-none"
|
||||
className="border-0 text-end text-base outline-none focus:outline-none"
|
||||
defaultValue={value}
|
||||
placeholder={t('colorPicker.placeholder')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { Copy, Check, Play } from 'lucide-react';
|
||||
import { copyToClipboard } from 'helpers/clipboard';
|
||||
import { useTranslation } from '@/components/locales';
|
||||
|
||||
type CommandBlockProps = {
|
||||
label?: string;
|
||||
@@ -9,6 +10,7 @@ type CommandBlockProps = {
|
||||
};
|
||||
|
||||
export const CommandBlock = ({ label, command, onRun }: CommandBlockProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const copy = () => {
|
||||
@@ -20,14 +22,14 @@ export const CommandBlock = ({ label, command, onRun }: CommandBlockProps) => {
|
||||
return (
|
||||
<div className="inline-flex flex-col gap-1">
|
||||
{label && <span className="text-xs text-duck-dark/50">{label}</span>}
|
||||
<div className="inline-flex items-center gap-1 bg-[#1a1a2e] rounded-lg pl-3 pr-1 py-1.5">
|
||||
<div className="inline-flex items-center gap-1 bg-[#1a1a2e] rounded-lg ps-3 pe-1 py-1.5">
|
||||
<code className="text-sm text-[#e0e0e0] font-mono whitespace-nowrap">{command}</code>
|
||||
{onRun && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRun(command)}
|
||||
className="shrink-0 p-1.5 rounded hover:bg-white/10 cursor-pointer transition-colors"
|
||||
title="Run in terminal"
|
||||
title={t('commandBlock.run')}
|
||||
>
|
||||
<Play className="h-3.5 w-3.5 text-duck-teal" />
|
||||
</button>
|
||||
@@ -36,7 +38,7 @@ export const CommandBlock = ({ label, command, onRun }: CommandBlockProps) => {
|
||||
type="button"
|
||||
onClick={copy}
|
||||
className="shrink-0 p-1.5 rounded hover:bg-white/10 cursor-pointer transition-colors"
|
||||
title="Copy command"
|
||||
title={t('commandBlock.copy')}
|
||||
>
|
||||
{copied ? <Check className="h-3.5 w-3.5 text-green-400" /> : <Copy className="h-3.5 w-3.5 text-white/40" />}
|
||||
</button>
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { LucideIcon } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Loader2, TriangleAlert } from 'lucide-react';
|
||||
import { cn } from 'helpers/cn';
|
||||
import { useTranslation } from '@/components/locales';
|
||||
|
||||
// Loading, failed, empty. See docs/design-language-interface.md § 6.
|
||||
//
|
||||
@@ -19,12 +20,15 @@ const Shell = ({ children, className }: { children: ReactNode; className?: strin
|
||||
</div>
|
||||
);
|
||||
|
||||
export const LoadingBlock = ({ label = 'Loading…' }: { label?: string }) => (
|
||||
<Shell>
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">{label}</p>
|
||||
</Shell>
|
||||
);
|
||||
export const LoadingBlock = ({ label }: { label?: string }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Shell>
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">{label ?? t('state.loading')}</p>
|
||||
</Shell>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* The real message is shown, not a friendly substitute for it. On a self-hosted platform the person
|
||||
|
||||
@@ -1,7 +1,21 @@
|
||||
import type { Column } from './DataTable';
|
||||
import type { ReactNode } from 'react';
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { render, screen, fireEvent, within } from '@testing-library/react';
|
||||
import { I18nProvider } from 'i18n';
|
||||
import { DataTable, useDataControl } from './DataTable';
|
||||
import enGB from '../locales/en-GB.json';
|
||||
|
||||
/**
|
||||
* The table's own chrome ("Next", "No Data Found.") comes from the `ui.*` catalog now, so these
|
||||
* assertions only hold inside a provider — the same one the app mounts. Asserting the English a user
|
||||
* actually sees, rather than a key, is the point of wrapping rather than loosening the expectations.
|
||||
*/
|
||||
const withI18n = (ui: ReactNode) => (
|
||||
<I18nProvider translations={{ 'en-GB': { ui: enGB } }} locale="en-GB" fallbackLocale="en-GB">
|
||||
{ui}
|
||||
</I18nProvider>
|
||||
);
|
||||
|
||||
type TestItem = {
|
||||
id: number;
|
||||
@@ -55,7 +69,7 @@ const TestWrapper = ({
|
||||
describe('DataTable', () => {
|
||||
describe('rendering', () => {
|
||||
test('renders table with data', () => {
|
||||
render(<TestWrapper />);
|
||||
render(withI18n(<TestWrapper />));
|
||||
|
||||
expect(screen.getByRole('table')).toBeDefined();
|
||||
expect(screen.getByText('Alpha')).toBeDefined();
|
||||
@@ -63,7 +77,7 @@ describe('DataTable', () => {
|
||||
});
|
||||
|
||||
test('renders column headers', () => {
|
||||
render(<TestWrapper />);
|
||||
render(withI18n(<TestWrapper />));
|
||||
|
||||
// Component renders both desktop and mobile versions (hidden via CSS)
|
||||
// so we use getAllByText and verify at least one exists
|
||||
@@ -74,7 +88,7 @@ describe('DataTable', () => {
|
||||
});
|
||||
|
||||
test('renders correct number of rows based on page size', () => {
|
||||
render(<TestWrapper pageSize={3} />);
|
||||
render(withI18n(<TestWrapper pageSize={3} />));
|
||||
|
||||
const rows = screen.getAllByRole('row');
|
||||
// 1 header row + 3 data rows
|
||||
@@ -82,20 +96,20 @@ describe('DataTable', () => {
|
||||
});
|
||||
|
||||
test('shows no data message when data is empty', () => {
|
||||
render(<TestWrapper data={[]} noDataMessage="No items found" />);
|
||||
render(withI18n(<TestWrapper data={[]} noDataMessage="No items found" />));
|
||||
|
||||
expect(screen.getByText('No items found')).toBeDefined();
|
||||
expect(screen.queryByRole('table')).toBeNull();
|
||||
});
|
||||
|
||||
test('shows default no data message', () => {
|
||||
render(<TestWrapper data={[]} />);
|
||||
render(withI18n(<TestWrapper data={[]} />));
|
||||
|
||||
expect(screen.getByText('No Data Found.')).toBeDefined();
|
||||
});
|
||||
|
||||
test('renders noDataCTA when provided', () => {
|
||||
render(<TestWrapper data={[]} noDataCTA={<button>Add Item</button>} />);
|
||||
render(withI18n(<TestWrapper data={[]} noDataCTA={<button>Add Item</button>} />));
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Add Item' })).toBeDefined();
|
||||
});
|
||||
@@ -106,7 +120,7 @@ describe('DataTable', () => {
|
||||
return <DataTable dataController={controller} columns={columns} />;
|
||||
};
|
||||
|
||||
const { container } = render(<NullWrapper />);
|
||||
const { container } = render(withI18n(<NullWrapper />));
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -118,7 +132,7 @@ describe('DataTable', () => {
|
||||
return <DataTable dataController={controller} />;
|
||||
};
|
||||
|
||||
render(<AutoColumnsWrapper />);
|
||||
render(withI18n(<AutoColumnsWrapper />));
|
||||
|
||||
// Should generate columns from object keys (rendered for both mobile/desktop)
|
||||
expect(screen.getAllByText('Id').length).toBeGreaterThan(0);
|
||||
@@ -130,7 +144,7 @@ describe('DataTable', () => {
|
||||
|
||||
describe('sorting', () => {
|
||||
test('clicking sortable column header triggers sort', () => {
|
||||
render(<TestWrapper pageSize={10} />);
|
||||
render(withI18n(<TestWrapper pageSize={10} />));
|
||||
|
||||
// Get the first "Name" element (desktop version)
|
||||
const nameHeaders = screen.getAllByText('Name');
|
||||
@@ -143,7 +157,7 @@ describe('DataTable', () => {
|
||||
});
|
||||
|
||||
test('clicking same column twice reverses sort order', () => {
|
||||
render(<TestWrapper pageSize={10} />);
|
||||
render(withI18n(<TestWrapper pageSize={10} />));
|
||||
|
||||
const nameHeaders = screen.getAllByText('Name');
|
||||
fireEvent.click(nameHeaders[0]!); // asc
|
||||
@@ -156,7 +170,7 @@ describe('DataTable', () => {
|
||||
});
|
||||
|
||||
test('sort icon is displayed for sortable columns', () => {
|
||||
render(<TestWrapper />);
|
||||
render(withI18n(<TestWrapper />));
|
||||
|
||||
// Each sortable column should have the ArrowUpDown icon
|
||||
const headers = screen.getAllByRole('columnheader');
|
||||
@@ -169,19 +183,19 @@ describe('DataTable', () => {
|
||||
|
||||
describe('pagination', () => {
|
||||
test('shows pagination bar when data exceeds page size', () => {
|
||||
render(<TestWrapper pageSize={5} />);
|
||||
render(withI18n(<TestWrapper pageSize={5} />));
|
||||
|
||||
expect(screen.getByText(/Showing page 1 of 2/)).toBeDefined();
|
||||
});
|
||||
|
||||
test('does not show pagination when all data fits on one page', () => {
|
||||
render(<TestWrapper pageSize={10} />);
|
||||
render(withI18n(<TestWrapper pageSize={10} />));
|
||||
|
||||
expect(screen.queryByText(/Showing page/)).toBeNull();
|
||||
});
|
||||
|
||||
test('next button navigates to next page', () => {
|
||||
render(<TestWrapper pageSize={5} />);
|
||||
render(withI18n(<TestWrapper pageSize={5} />));
|
||||
|
||||
const nextButton = screen.getByRole('button', { name: 'Next' });
|
||||
fireEvent.click(nextButton);
|
||||
@@ -192,7 +206,7 @@ describe('DataTable', () => {
|
||||
});
|
||||
|
||||
test('previous button navigates to previous page', () => {
|
||||
render(<TestWrapper pageSize={5} />);
|
||||
render(withI18n(<TestWrapper pageSize={5} />));
|
||||
|
||||
const nextButton = screen.getByRole('button', { name: 'Next' });
|
||||
fireEvent.click(nextButton);
|
||||
@@ -205,7 +219,7 @@ describe('DataTable', () => {
|
||||
});
|
||||
|
||||
test('first button goes to first page', () => {
|
||||
render(<TestWrapper pageSize={5} />);
|
||||
render(withI18n(<TestWrapper pageSize={5} />));
|
||||
|
||||
const nextButton = screen.getByRole('button', { name: 'Next' });
|
||||
fireEvent.click(nextButton);
|
||||
@@ -217,7 +231,7 @@ describe('DataTable', () => {
|
||||
});
|
||||
|
||||
test('last button goes to last page', () => {
|
||||
render(<TestWrapper pageSize={5} />);
|
||||
render(withI18n(<TestWrapper pageSize={5} />));
|
||||
|
||||
const lastButton = screen.getByRole('button', { name: 'Last' });
|
||||
fireEvent.click(lastButton);
|
||||
@@ -226,7 +240,7 @@ describe('DataTable', () => {
|
||||
});
|
||||
|
||||
test('navigation buttons are disabled at boundaries', () => {
|
||||
render(<TestWrapper pageSize={5} />);
|
||||
render(withI18n(<TestWrapper pageSize={5} />));
|
||||
|
||||
const firstBtn = screen.getByRole('button', { name: 'First' });
|
||||
const prevBtn = screen.getByRole('button', { name: 'Prev' });
|
||||
@@ -258,7 +272,7 @@ describe('DataTable', () => {
|
||||
{ field: 'status', label: 'Status', condition: false },
|
||||
];
|
||||
|
||||
render(<TestWrapper cols={conditionalColumns} />);
|
||||
render(withI18n(<TestWrapper cols={conditionalColumns} />));
|
||||
|
||||
expect(screen.getAllByText('ID').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('Name').length).toBeGreaterThan(0);
|
||||
@@ -271,7 +285,7 @@ describe('DataTable', () => {
|
||||
{ field: 'status', label: 'Status', condition: true },
|
||||
];
|
||||
|
||||
render(<TestWrapper cols={conditionalColumns} />);
|
||||
render(withI18n(<TestWrapper cols={conditionalColumns} />));
|
||||
|
||||
expect(screen.getAllByText('Status').length).toBeGreaterThan(0);
|
||||
});
|
||||
@@ -283,11 +297,11 @@ describe('DataTable', () => {
|
||||
{ field: 'status', label: 'Status', condition: () => showStatus },
|
||||
];
|
||||
|
||||
const { rerender } = render(<TestWrapper cols={conditionalColumns} />);
|
||||
const { rerender } = render(withI18n(<TestWrapper cols={conditionalColumns} />));
|
||||
expect(screen.queryByText('Status')).toBeNull();
|
||||
|
||||
showStatus = true;
|
||||
rerender(<TestWrapper cols={conditionalColumns} />);
|
||||
rerender(withI18n(<TestWrapper cols={conditionalColumns} />));
|
||||
expect(screen.getAllByText('Status').length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -303,7 +317,7 @@ describe('DataTable', () => {
|
||||
},
|
||||
];
|
||||
|
||||
render(<TestWrapper cols={formattedColumns} />);
|
||||
render(withI18n(<TestWrapper cols={formattedColumns} />));
|
||||
|
||||
const formattedNames = screen.getAllByTestId('formatted-name');
|
||||
expect(formattedNames.length).toBeGreaterThan(0);
|
||||
@@ -319,7 +333,7 @@ describe('DataTable', () => {
|
||||
},
|
||||
];
|
||||
|
||||
render(<TestWrapper cols={formattedColumns} />);
|
||||
render(withI18n(<TestWrapper cols={formattedColumns} />));
|
||||
|
||||
// Each row should have a unique test ID based on item.id
|
||||
expect(screen.getByTestId('item-1')).toBeDefined();
|
||||
@@ -337,7 +351,7 @@ describe('DataTable', () => {
|
||||
},
|
||||
];
|
||||
|
||||
render(<TestWrapper cols={actionColumns} />);
|
||||
render(withI18n(<TestWrapper cols={actionColumns} />));
|
||||
|
||||
expect(screen.getByTestId('action-1')).toBeDefined();
|
||||
expect(screen.getByText('Edit Alpha')).toBeDefined();
|
||||
@@ -351,7 +365,7 @@ describe('DataTable', () => {
|
||||
{ field: 'name', label: 'Name' },
|
||||
];
|
||||
|
||||
render(<TestWrapper cols={tooltipColumns} />);
|
||||
render(withI18n(<TestWrapper cols={tooltipColumns} />));
|
||||
|
||||
// The tooltip indicator (ⓘ) should be present in the ID column headers
|
||||
const idHeaders = screen.getAllByText(/ID/);
|
||||
@@ -367,7 +381,7 @@ describe('DataTable', () => {
|
||||
{ field: 'name', label: 'Full Name', labelMobile: 'Name' },
|
||||
];
|
||||
|
||||
render(<TestWrapper cols={mobileColumns} />);
|
||||
render(withI18n(<TestWrapper cols={mobileColumns} />));
|
||||
|
||||
// Both desktop and mobile labels should be in the DOM (hidden/shown via CSS)
|
||||
expect(screen.getAllByText('Identifier').length).toBeGreaterThan(0);
|
||||
@@ -396,7 +410,7 @@ describe('DataTable', () => {
|
||||
);
|
||||
};
|
||||
|
||||
render(<SearchableTable />);
|
||||
render(withI18n(<SearchableTable />));
|
||||
|
||||
const searchInput = screen.getByTestId('search-input');
|
||||
fireEvent.change(searchInput, { target: { value: 'Alpha' } });
|
||||
@@ -420,7 +434,7 @@ describe('DataTable', () => {
|
||||
);
|
||||
};
|
||||
|
||||
render(<ExternalSortTable />);
|
||||
render(withI18n(<ExternalSortTable />));
|
||||
|
||||
fireEvent.click(screen.getByTestId('sort-btn'));
|
||||
|
||||
|
||||
@@ -6,8 +6,10 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp
|
||||
import { useDataControl, type UseDataControlReturn } from 'hooks/useDataControl';
|
||||
import { PaginationBar } from './PaginationBar';
|
||||
import { cn } from 'helpers/cn';
|
||||
import { useTranslation } from '@/components/locales';
|
||||
|
||||
export const DataTable = <T,>(props: DataTableProps<T>) => {
|
||||
const { t } = useTranslation();
|
||||
const [columns, setColumns] = useState<Column<T>[]>([]);
|
||||
const { dataController, uniqueKey, noDataCTA, noDataMessage } = props;
|
||||
const theUniqueKey = uniqueKey || 'id';
|
||||
@@ -58,7 +60,7 @@ export const DataTable = <T,>(props: DataTableProps<T>) => {
|
||||
if (rawData.length === 0) {
|
||||
return (
|
||||
<div className="py-12 text-center">
|
||||
<div className="text-muted-foreground">{noDataMessage || 'No Data Found.'}</div>
|
||||
<div className="text-muted-foreground">{noDataMessage || t('dataTable.empty')}</div>
|
||||
{noDataCTA && <>{noDataCTA}</>}
|
||||
</div>
|
||||
);
|
||||
@@ -94,7 +96,7 @@ export const DataTable = <T,>(props: DataTableProps<T>) => {
|
||||
<span className="min-[500px]:hidden">
|
||||
{String(labelMobile || label || field || '')} {tooltip ? <sup>ⓘ</sup> : ''}
|
||||
</span>
|
||||
{sortKey && <ArrowUpDown className={cn('ml-2 h-4 w-4')} />}
|
||||
{sortKey && <ArrowUpDown className={cn('ms-2 h-4 w-4')} />}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
{tooltip && (
|
||||
|
||||
@@ -3,12 +3,14 @@ import type { UseDataControlReturn } from 'hooks/useDataControl';
|
||||
import { useMemo } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { useTranslation } from '@/components/locales';
|
||||
|
||||
type PaginationBarProps<T> = {
|
||||
dataManager: UseDataControlReturn<T>;
|
||||
};
|
||||
|
||||
export const PaginationBar = <T,>(props: PaginationBarProps<T>) => {
|
||||
const { t } = useTranslation();
|
||||
const { currentPage, pageCount, changePage, pageSize, setPageSize } = props.dataManager;
|
||||
|
||||
const pageSizeOptions = useMemo(() => [5, 10, 15, 20], []);
|
||||
@@ -28,10 +30,10 @@ export const PaginationBar = <T,>(props: PaginationBarProps<T>) => {
|
||||
{/* LEFT */}
|
||||
<div className="flex items-center gap-2 min-[500px]:gap-3 text-sm text-muted-foreground whitespace-nowrap">
|
||||
<span className="hidden min-[500px]:inline">
|
||||
Showing page {currentPage} of {pageCount}
|
||||
{t('dataTable.pagination.showingPage', { current: currentPage, total: pageCount })}
|
||||
</span>
|
||||
<span className="min-[500px]:hidden">
|
||||
p {currentPage}/{pageCount}
|
||||
{t('dataTable.pagination.showingPageShort', { current: currentPage, total: pageCount })}
|
||||
</span>
|
||||
|
||||
<span className="bg-border h-5 w-px" aria-hidden="true" />
|
||||
@@ -47,11 +49,11 @@ export const PaginationBar = <T,>(props: PaginationBarProps<T>) => {
|
||||
hover:bg-background
|
||||
data-[state=open]:bg-muted
|
||||
"
|
||||
aria-label="Rows per page"
|
||||
aria-label={t('dataTable.pagination.rowsPerPage')}
|
||||
>
|
||||
<SelectValue className="text-muted-foreground">
|
||||
<span className="hidden min-[500px]:inline">{pageSize} / page</span>
|
||||
<span className="min-[500px]:hidden">{pageSize}/p</span>
|
||||
<span className="hidden min-[500px]:inline">{t('dataTable.pagination.perPage', { size: pageSize })}</span>
|
||||
<span className="min-[500px]:hidden">{t('dataTable.pagination.perPageShort', { size: pageSize })}</span>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
|
||||
@@ -66,8 +68,8 @@ export const PaginationBar = <T,>(props: PaginationBarProps<T>) => {
|
||||
data-[state=checked]:text-muted-foreground
|
||||
"
|
||||
>
|
||||
<span className="hidden min-[500px]:inline">{n} / page</span>
|
||||
<span className="min-[500px]:hidden">{n}/p</span>
|
||||
<span className="hidden min-[500px]:inline">{t('dataTable.pagination.perPage', { size: n })}</span>
|
||||
<span className="min-[500px]:hidden">{t('dataTable.pagination.perPageShort', { size: n })}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -82,7 +84,7 @@ export const PaginationBar = <T,>(props: PaginationBarProps<T>) => {
|
||||
className={buttonClass}
|
||||
onClick={() => changePage(1)}
|
||||
disabled={currentPage === 1}
|
||||
aria-label="First"
|
||||
aria-label={t('dataTable.pagination.first')}
|
||||
>
|
||||
«
|
||||
</Button>
|
||||
@@ -92,7 +94,7 @@ export const PaginationBar = <T,>(props: PaginationBarProps<T>) => {
|
||||
className={buttonClass}
|
||||
onClick={() => changePage(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
aria-label="Prev"
|
||||
aria-label={t('dataTable.pagination.prev')}
|
||||
>
|
||||
‹
|
||||
</Button>
|
||||
@@ -102,7 +104,7 @@ export const PaginationBar = <T,>(props: PaginationBarProps<T>) => {
|
||||
className={buttonClass}
|
||||
onClick={() => changePage(currentPage + 1)}
|
||||
disabled={currentPage === pageCount}
|
||||
aria-label="Next"
|
||||
aria-label={t('dataTable.pagination.next')}
|
||||
>
|
||||
›
|
||||
</Button>
|
||||
@@ -112,7 +114,7 @@ export const PaginationBar = <T,>(props: PaginationBarProps<T>) => {
|
||||
className={buttonClass}
|
||||
onClick={() => changePage(pageCount)}
|
||||
disabled={currentPage === pageCount}
|
||||
aria-label="Last"
|
||||
aria-label={t('dataTable.pagination.last')}
|
||||
>
|
||||
»
|
||||
</Button>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useTranslation } from '@/components/locales';
|
||||
|
||||
type ConfirmDialogProps = {
|
||||
open: boolean;
|
||||
@@ -16,8 +17,9 @@ type ConfirmDialogProps = {
|
||||
|
||||
export const ConfirmDialog = (props: ConfirmDialogProps) => {
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
const { open, disabled = false, title, description, children } = props;
|
||||
const { onConfirm, onCancel, confirmLabel = 'Confirm', confirmVariant = 'default' } = props;
|
||||
const { onConfirm, onCancel, confirmLabel, confirmVariant = 'default' } = props;
|
||||
|
||||
const handleConfirm = async () => {
|
||||
setConfirming(true);
|
||||
@@ -37,16 +39,16 @@ export const ConfirmDialog = (props: ConfirmDialogProps) => {
|
||||
|
||||
<div className="mt-6 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
Cancel
|
||||
{t('button.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={confirmVariant}
|
||||
onClick={handleConfirm}
|
||||
disabled={disabled || confirming}
|
||||
className="sm:ml-2"
|
||||
className="sm:ms-2"
|
||||
>
|
||||
{confirmLabel}
|
||||
{confirmLabel ?? t('button.confirm')}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { ReactNode } from 'react';
|
||||
import { Dialog, DialogOverlay, DialogPortal } from '@/components/ui/dialog';
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { cardStyle } from '@/components/Card';
|
||||
import { useTranslation } from '@/components/locales';
|
||||
|
||||
type PaperDialogProps = {
|
||||
open: boolean;
|
||||
@@ -11,6 +12,8 @@ type PaperDialogProps = {
|
||||
};
|
||||
|
||||
export const PaperDialog = ({ open, onOpenChange, onOpenAutoFocus, children }: PaperDialogProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleOpenAutoFocus = (ev: Event) => {
|
||||
ev.preventDefault();
|
||||
onOpenAutoFocus?.(ev);
|
||||
@@ -25,9 +28,9 @@ export const PaperDialog = ({ open, onOpenChange, onOpenAutoFocus, children }: P
|
||||
className="fixed left-[50%] top-[50%] z-[600] grid w-full max-w-md translate-x-[-50%] translate-y-[-50%] gap-4 border-2 border-duck-dark/30 text-duck-dark p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg"
|
||||
style={cardStyle()}
|
||||
>
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<DialogPrimitive.Close className="absolute end-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<span className="h-4 w-4 text-duck-dark">✕</span>
|
||||
<span className="sr-only">Close</span>
|
||||
<span className="sr-only">{t('button.close')}</span>
|
||||
</DialogPrimitive.Close>
|
||||
{children}
|
||||
</DialogPrimitive.Content>
|
||||
|
||||
@@ -2,9 +2,11 @@ import { Dialog, DialogContent, DialogDescription } from '@/components/ui/dialog
|
||||
import { DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Ban } from 'lucide-react';
|
||||
import { useTranslation } from '@/components/locales';
|
||||
import type { ErrorDialogsProps } from './index';
|
||||
|
||||
export const CustomErrorDialog = ({ apiError, setApiError }: ErrorDialogsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const onOpenChange = (value: boolean) => {
|
||||
if (!value) {
|
||||
setApiError(null);
|
||||
@@ -16,14 +18,15 @@ export const CustomErrorDialog = ({ apiError, setApiError }: ErrorDialogsProps)
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-10">
|
||||
<Ban className="h-12 w-12 text-red-500" />
|
||||
Server Error
|
||||
{t('error.server.title')}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="flex flex-col gap-4 py-5">{apiError?.uuid}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{/* The server's own message, shown verbatim — it is a response, not UI copy (see D1). */}
|
||||
<p>{apiError?.message}</p>
|
||||
<DialogFooter>
|
||||
<Button type="submit" onClick={() => onOpenChange(false)}>
|
||||
Ok
|
||||
{t('button.ok')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -2,9 +2,11 @@ import { Dialog, DialogContent, DialogDescription } from '@/components/ui/dialog
|
||||
import { DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Ban } from 'lucide-react';
|
||||
import { useTranslation } from '@/components/locales';
|
||||
import type { ErrorDialogsProps } from './index';
|
||||
|
||||
export const ForbiddenDialog = ({ setApiError }: ErrorDialogsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const onOpenChange = (value: boolean) => {
|
||||
if (!value) {
|
||||
setApiError(null);
|
||||
@@ -16,16 +18,14 @@ export const ForbiddenDialog = ({ setApiError }: ErrorDialogsProps) => {
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-10">
|
||||
<Ban className="h-12 w-12 text-red-500" />
|
||||
Unauthorized Access
|
||||
{t('error.forbidden.title')}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="flex flex-col gap-4 py-5">
|
||||
Your are not authorized to perform this action.
|
||||
</DialogDescription>
|
||||
<DialogDescription className="flex flex-col gap-4 py-5">{t('error.forbidden.description')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<p>If you think this is an error, please contact your manager.</p>
|
||||
<p>{t('error.forbidden.hint')}</p>
|
||||
<DialogFooter>
|
||||
<Button type="submit" onClick={() => onOpenChange(false)}>
|
||||
Ok
|
||||
{t('button.ok')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -2,9 +2,11 @@ import { Dialog, DialogContent, DialogDescription } from '@/components/ui/dialog
|
||||
import { DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Ban } from 'lucide-react';
|
||||
import { useTranslation } from '@/components/locales';
|
||||
import type { ErrorDialogsProps } from './index';
|
||||
|
||||
export const ServerErrorDialog = ({ apiError, setApiError }: ErrorDialogsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const onOpenChange = (value: boolean) => {
|
||||
if (!value) {
|
||||
setApiError(null);
|
||||
@@ -16,14 +18,15 @@ export const ServerErrorDialog = ({ apiError, setApiError }: ErrorDialogsProps)
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-10">
|
||||
<Ban className="h-12 w-12 text-red-500" />
|
||||
Server Error
|
||||
{t('error.server.title')}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="flex flex-col gap-4 py-5">{apiError?.uuid}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{/* The server's own message, shown verbatim — it is a response, not UI copy (see D1). */}
|
||||
<p>{apiError?.message}</p>
|
||||
<DialogFooter>
|
||||
<Button type="submit" onClick={() => onOpenChange(false)}>
|
||||
Ok
|
||||
{t('button.ok')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -3,10 +3,12 @@ import { DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Ban } from 'lucide-react';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import { useTranslation } from '@/components/locales';
|
||||
import type { ErrorDialogsProps } from './index';
|
||||
|
||||
export const UnauthorizedDialog = ({ setApiError }: ErrorDialogsProps) => {
|
||||
const { signout } = useAuth();
|
||||
const { t } = useTranslation();
|
||||
const onOpenChange = (value: boolean) => {
|
||||
if (!value) {
|
||||
setApiError(null);
|
||||
@@ -20,14 +22,16 @@ export const UnauthorizedDialog = ({ setApiError }: ErrorDialogsProps) => {
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-10">
|
||||
<Ban className="h-12 w-12 text-red-500" />
|
||||
Invalid Credentials
|
||||
{t('error.unauthorized.title')}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="flex flex-col gap-4 py-5">Your access token has expired</DialogDescription>
|
||||
<DialogDescription className="flex flex-col gap-4 py-5">
|
||||
{t('error.unauthorized.description')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<p>Please Signin again</p>
|
||||
<p>{t('error.unauthorized.hint')}</p>
|
||||
<DialogFooter>
|
||||
<Button type="submit" onClick={() => onOpenChange(false)}>
|
||||
Ok
|
||||
{t('button.ok')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import { useTranslation } from '@/components/locales';
|
||||
|
||||
type SvgLogoProps = {
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
};
|
||||
|
||||
export function OfficerIconLogo({ size = 'xl' }: SvgLogoProps) {
|
||||
const { t } = useTranslation();
|
||||
const config = sizeConfig[size];
|
||||
|
||||
return (
|
||||
<img
|
||||
src="/officer-logo.svg"
|
||||
alt="Officer Icon"
|
||||
style={{ width: config.width, height: 'auto' }}
|
||||
/>
|
||||
);
|
||||
return <img src="/officer-logo.svg" alt={t('logo.icon')} style={{ width: config.width, height: 'auto' }} />;
|
||||
}
|
||||
|
||||
const sizeConfig = {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useTranslation } from '@/components/locales';
|
||||
|
||||
type SvgLogoProps = {
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl';
|
||||
square?: boolean;
|
||||
@@ -12,27 +14,17 @@ export function OfficerSvgLogo({ size = 'xl', square = false }: SvgLogoProps) {
|
||||
}
|
||||
|
||||
export function OfficerSvgLogoRegular({ size = 'xl' }: SvgLogoProps) {
|
||||
const { t } = useTranslation();
|
||||
const config = sizeConfig.regular[size];
|
||||
|
||||
return (
|
||||
<img
|
||||
src="/officer-logo.svg"
|
||||
alt="Officer Logo"
|
||||
style={{ width: config.width, height: 'auto' }}
|
||||
/>
|
||||
);
|
||||
return <img src="/officer-logo.svg" alt={t('logo.logo')} style={{ width: config.width, height: 'auto' }} />;
|
||||
}
|
||||
|
||||
export function OfficerSvgLogoSquare({ size = 'xl' }: SvgLogoProps) {
|
||||
const { t } = useTranslation();
|
||||
const config = sizeConfig.square[size];
|
||||
|
||||
return (
|
||||
<img
|
||||
src="/officer-logo.svg"
|
||||
alt="Officer Logo"
|
||||
style={{ width: config.width, height: 'auto' }}
|
||||
/>
|
||||
);
|
||||
return <img src="/officer-logo.svg" alt={t('logo.logo')} style={{ width: config.width, height: 'auto' }} />;
|
||||
}
|
||||
|
||||
const sizeConfig = {
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import md from 'markdown-it';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import rehypeRaw from 'rehype-raw';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Code, Eye } from 'lucide-react';
|
||||
|
||||
type MarkdownEditorProps = {
|
||||
content: string;
|
||||
onChange: (content: string) => void;
|
||||
placeholder?: string;
|
||||
greeting?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
};
|
||||
|
||||
export function MarkdownEditor({
|
||||
content,
|
||||
onChange,
|
||||
placeholder = 'Enter markdown...',
|
||||
greeting,
|
||||
firstName,
|
||||
lastName,
|
||||
}: MarkdownEditorProps) {
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
|
||||
const markdown = md({
|
||||
html: true,
|
||||
breaks: true,
|
||||
linkify: true,
|
||||
});
|
||||
|
||||
const processGreeting = (text: string) => {
|
||||
return text
|
||||
.replace(/\{\{firstName\}\}/g, firstName || '')
|
||||
.replace(/\{\{fullName\}\}/g, `${firstName || ''} ${lastName || ''}`.trim());
|
||||
};
|
||||
|
||||
const processedGreeting = greeting ? processGreeting(greeting) : '';
|
||||
const greetingHtml = processedGreeting ? `<p>${processedGreeting}</p>` : '';
|
||||
const preview = markdown.render(content);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col rounded-lg border border-border bg-background overflow-hidden">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-2 border-b border-border bg-muted px-4 py-2">
|
||||
<Button size="sm" variant={!showPreview ? 'default' : 'outline'} onClick={() => setShowPreview(false)}>
|
||||
<Code className="h-4 w-4 mr-2" />
|
||||
Markdown
|
||||
</Button>
|
||||
<Button size="sm" variant={showPreview ? 'default' : 'outline'} onClick={() => setShowPreview(true)}>
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
Preview
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Content Area */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{!showPreview ? (
|
||||
<textarea
|
||||
rows={15}
|
||||
name="content"
|
||||
value={content}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="w-full h-full resize-none p-4 font-mono text-sm focus:outline-none bg-background text-foreground placeholder:text-muted-foreground overflow-auto"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full overflow-auto bg-slate-50 dark:bg-slate-900 p-4">
|
||||
{/* Email wrapper simulation */}
|
||||
<div className="mx-auto max-w-2xl rounded-lg bg-background p-8 shadow-lg">
|
||||
{/* Logo */}
|
||||
<div className="mb-8 text-center">
|
||||
<img src="https://app.pertento.ai/logo.png" alt="Pertento" className="h-16 mx-auto" />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="space-y-4">
|
||||
{processedGreeting && <p className="text-base leading-relaxed">{processedGreeting}</p>}
|
||||
<style>{`
|
||||
.markdown-preview h1 { font-size: 2rem; font-weight: bold; margin: 1.5rem 0 1rem 0; }
|
||||
.markdown-preview h2 { font-size: 1.5rem; font-weight: bold; margin: 1.25rem 0 0.75rem 0; }
|
||||
.markdown-preview h3 { font-size: 1.25rem; font-weight: bold; margin: 1rem 0 0.5rem 0; }
|
||||
.markdown-preview p { margin: 0.75rem 0; line-height: 1.6; }
|
||||
/* list-style is restored explicitly — tailwind preflight strips it, which leaves an
|
||||
ordered list indented but unnumbered. */
|
||||
.markdown-preview ul, .markdown-preview ol { margin: 1rem 0; padding-left: 2rem; list-style-position: outside; }
|
||||
.markdown-preview ul { list-style-type: disc; }
|
||||
.markdown-preview ol { list-style-type: decimal; }
|
||||
.markdown-preview ul ul { list-style-type: circle; }
|
||||
.markdown-preview ol ol { list-style-type: lower-alpha; }
|
||||
.markdown-preview li { margin: 0.5rem 0; line-height: 1.6; }
|
||||
.markdown-preview code { background-color: rgb(229, 229, 229); color: rgb(31, 41, 55); padding: 0.25rem 0.5rem; border-radius: 0.25rem; font-size: 0.875rem; }
|
||||
.markdown-preview pre { background-color: rgb(17, 24, 39); color: rgb(243, 244, 246); padding: 1rem; border-radius: 0.5rem; overflow-x: auto; margin: 1rem 0; }
|
||||
.markdown-preview pre code { background-color: transparent; color: inherit; padding: 0; }
|
||||
.markdown-preview a { color: rgb(37, 99, 235); text-decoration: underline; }
|
||||
.markdown-preview blockquote { border-left: 4px solid rgb(209, 213, 219); padding-left: 1rem; font-style: italic; margin: 1rem 0; }
|
||||
.dark .markdown-preview code { background-color: rgb(55, 65, 81); color: rgb(243, 244, 246); }
|
||||
.dark .markdown-preview a { color: rgb(96, 165, 250); }
|
||||
.dark .markdown-preview blockquote { border-left-color: rgb(75, 85, 99); }
|
||||
`}</style>
|
||||
<div className="markdown-preview">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Search as SearchIcon, X as ClearIcon } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { cn } from 'helpers/cn';
|
||||
import { useTranslation } from '@/components/locales';
|
||||
|
||||
type SearchInputProps = {
|
||||
className?: string;
|
||||
@@ -12,7 +13,8 @@ type SearchInputProps = {
|
||||
};
|
||||
|
||||
export function SearchInput(props: SearchInputProps) {
|
||||
const { className, handleSearch, value = '', placeholder = 'Search' } = props;
|
||||
const { t } = useTranslation();
|
||||
const { className, handleSearch, value = '', placeholder = t('search.placeholder') } = props;
|
||||
const [isMobileSearchOpen, setIsMobileSearchOpen] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
@@ -22,14 +24,14 @@ export function SearchInput(props: SearchInputProps) {
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
aria-label={isMobileSearchOpen ? 'Close search' : 'Open search'}
|
||||
aria-label={isMobileSearchOpen ? t('search.close') : t('search.open')}
|
||||
onClick={() => {
|
||||
setIsMobileSearchOpen((v) => !v);
|
||||
if (!isMobileSearchOpen) {
|
||||
setTimeout(() => inputRef.current?.focus(), 160);
|
||||
}
|
||||
}}
|
||||
className="ml-auto shrink-0 md:hidden"
|
||||
className="ms-auto shrink-0 md:hidden"
|
||||
>
|
||||
{isMobileSearchOpen ? <ClearIcon className="h-5 w-5" /> : <SearchIcon className="h-5 w-5" />}
|
||||
</Button>
|
||||
@@ -37,7 +39,7 @@ export function SearchInput(props: SearchInputProps) {
|
||||
className={cn(
|
||||
'relative w-fit max-w-lg',
|
||||
isMobileSearchOpen
|
||||
? 'absolute left-0 mt-4 block w-full md:relative md:mt-0 md:w-fit'
|
||||
? 'absolute start-0 mt-4 block w-full md:relative md:mt-0 md:w-fit'
|
||||
: 'hidden w-auto md:block',
|
||||
className,
|
||||
)}
|
||||
@@ -45,14 +47,14 @@ export function SearchInput(props: SearchInputProps) {
|
||||
{!!value ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Clear search"
|
||||
aria-label={t('search.clear')}
|
||||
onClick={() => handleSearch('')}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 z-10 rounded p-1 hover:bg-muted"
|
||||
className="absolute start-3 top-1/2 -translate-y-1/2 z-10 rounded p-1 hover:bg-muted"
|
||||
>
|
||||
<ClearIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
) : (
|
||||
<span className="pointer-events-none absolute left-3 top-1/2 z-10 -translate-y-1/2">
|
||||
<span className="pointer-events-none absolute start-3 top-1/2 z-10 -translate-y-1/2">
|
||||
<SearchIcon className="text-muted-foreground h-4 w-4" />
|
||||
</span>
|
||||
)}
|
||||
@@ -62,7 +64,7 @@ export function SearchInput(props: SearchInputProps) {
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={(ev) => handleSearch(ev.target.value)}
|
||||
className="pl-10 pr-4 text-sm"
|
||||
className="ps-10 pe-4 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Select as ShadSelect, SelectContent, SelectGroup } from '@/components/ui/select';
|
||||
import { SelectItem, SelectLabel, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { cn } from 'helpers/cn';
|
||||
import { useTranslation } from '@/components/locales';
|
||||
|
||||
export const Select = (props: SelectProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { options, value, onChange, onValueChange, label } = props;
|
||||
const { name = '', className, placeholder = 'Select...', update } = props;
|
||||
const { name = '', className, placeholder = t('select.placeholder'), update } = props;
|
||||
|
||||
if (!options) return null;
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useRef } from 'react';
|
||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { useTranslation } from '@/components/locales';
|
||||
|
||||
type TabsProps = {
|
||||
tabList: { value: string; label: string }[];
|
||||
@@ -8,6 +9,7 @@ type TabsProps = {
|
||||
setActiveTab: (tab: string) => void;
|
||||
};
|
||||
const TabsComponent = (props: TabsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { tabList, activeTab, setActiveTab } = props;
|
||||
const tabsListRef = useRef<HTMLDivElement>(null);
|
||||
return (
|
||||
@@ -38,7 +40,7 @@ const TabsComponent = (props: TabsProps) => {
|
||||
<div className="block lg:hidden">
|
||||
<Select value={activeTab} onValueChange={(v) => setActiveTab(v)}>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue placeholder="Filter" />
|
||||
<SelectValue placeholder={t('tabs.filterPlaceholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tabList.map((f) => (
|
||||
|
||||
@@ -1 +1,81 @@
|
||||
{}
|
||||
{
|
||||
"button": {
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm",
|
||||
"ok": "Ok",
|
||||
"close": "Close"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Search",
|
||||
"open": "Open search",
|
||||
"close": "Close search",
|
||||
"clear": "Clear search"
|
||||
},
|
||||
"select": {
|
||||
"placeholder": "Select..."
|
||||
},
|
||||
"tabs": {
|
||||
"filterPlaceholder": "Filter"
|
||||
},
|
||||
"commandBlock": {
|
||||
"run": "Run in terminal",
|
||||
"copy": "Copy command"
|
||||
},
|
||||
"colorPicker": {
|
||||
"placeholder": "Hex colour"
|
||||
},
|
||||
"state": {
|
||||
"loading": "Loading…"
|
||||
},
|
||||
"dataTable": {
|
||||
"empty": "No Data Found.",
|
||||
"pagination": {
|
||||
"showingPage": "Showing page {{current}} of {{total}}",
|
||||
"showingPageShort": "p {{current}}/{{total}}",
|
||||
"rowsPerPage": "Rows per page",
|
||||
"perPage": "{{size}} / page",
|
||||
"perPageShort": "{{size}}/p",
|
||||
"first": "First",
|
||||
"prev": "Prev",
|
||||
"next": "Next",
|
||||
"last": "Last"
|
||||
}
|
||||
},
|
||||
"pagination": {
|
||||
"label": "Pagination",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"morePages": "More pages",
|
||||
"goToPrevious": "Go to previous page",
|
||||
"goToNext": "Go to next page"
|
||||
},
|
||||
"carousel": {
|
||||
"previous": "Previous slide",
|
||||
"next": "Next slide"
|
||||
},
|
||||
"breadcrumb": {
|
||||
"more": "More"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "Toggle color mode"
|
||||
},
|
||||
"error": {
|
||||
"forbidden": {
|
||||
"title": "Unauthorized Access",
|
||||
"description": "You are not authorized to perform this action.",
|
||||
"hint": "If you think this is an error, please contact your manager."
|
||||
},
|
||||
"unauthorized": {
|
||||
"title": "Invalid Credentials",
|
||||
"description": "Your access token has expired",
|
||||
"hint": "Please sign in again"
|
||||
},
|
||||
"server": {
|
||||
"title": "Server Error"
|
||||
}
|
||||
},
|
||||
"logo": {
|
||||
"icon": "Officer Icon",
|
||||
"logo": "Officer Logo"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,81 @@
|
||||
{}
|
||||
{
|
||||
"button": {
|
||||
"cancel": "Cancelar",
|
||||
"confirm": "Confirmar",
|
||||
"ok": "OK",
|
||||
"close": "Fechar"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Pesquisar",
|
||||
"open": "Abrir pesquisa",
|
||||
"close": "Fechar pesquisa",
|
||||
"clear": "Limpar pesquisa"
|
||||
},
|
||||
"select": {
|
||||
"placeholder": "Selecionar..."
|
||||
},
|
||||
"tabs": {
|
||||
"filterPlaceholder": "Filtrar"
|
||||
},
|
||||
"commandBlock": {
|
||||
"run": "Executar no terminal",
|
||||
"copy": "Copiar comando"
|
||||
},
|
||||
"colorPicker": {
|
||||
"placeholder": "Cor hexadecimal"
|
||||
},
|
||||
"state": {
|
||||
"loading": "A carregar…"
|
||||
},
|
||||
"dataTable": {
|
||||
"empty": "Não foram encontrados dados.",
|
||||
"pagination": {
|
||||
"showingPage": "A mostrar página {{current}} de {{total}}",
|
||||
"showingPageShort": "p {{current}}/{{total}}",
|
||||
"rowsPerPage": "Linhas por página",
|
||||
"perPage": "{{size}} / página",
|
||||
"perPageShort": "{{size}}/p",
|
||||
"first": "Primeira",
|
||||
"prev": "Anterior",
|
||||
"next": "Seguinte",
|
||||
"last": "Última"
|
||||
}
|
||||
},
|
||||
"pagination": {
|
||||
"label": "Paginação",
|
||||
"previous": "Anterior",
|
||||
"next": "Seguinte",
|
||||
"morePages": "Mais páginas",
|
||||
"goToPrevious": "Ir para a página anterior",
|
||||
"goToNext": "Ir para a página seguinte"
|
||||
},
|
||||
"carousel": {
|
||||
"previous": "Diapositivo anterior",
|
||||
"next": "Diapositivo seguinte"
|
||||
},
|
||||
"breadcrumb": {
|
||||
"more": "Mais"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "Alternar modo de cor"
|
||||
},
|
||||
"error": {
|
||||
"forbidden": {
|
||||
"title": "Acesso não autorizado",
|
||||
"description": "Não tem autorização para executar esta ação.",
|
||||
"hint": "Se acha que isto é um erro, contacte o seu gestor."
|
||||
},
|
||||
"unauthorized": {
|
||||
"title": "Credenciais inválidas",
|
||||
"description": "O seu token de acesso expirou",
|
||||
"hint": "Inicie sessão novamente"
|
||||
},
|
||||
"server": {
|
||||
"title": "Erro do servidor"
|
||||
}
|
||||
},
|
||||
"logo": {
|
||||
"icon": "Ícone Officer",
|
||||
"logo": "Logótipo Officer"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { InterpolationParams, TranslationKeys } from 'i18n';
|
||||
import { useCallback } from 'react';
|
||||
import { useTranslation as useTranslationBase } from 'i18n';
|
||||
import type en from './en-GB.json';
|
||||
|
||||
@@ -8,6 +9,8 @@ type UiTranslationKey = TranslationKeys<typeof en>;
|
||||
// pass the same bare key the JSON file uses. `ui.*` may not name a feature (see the naming convention).
|
||||
export const useTranslation = () => {
|
||||
const { t: baseT, locale } = useTranslationBase();
|
||||
const t = (key: UiTranslationKey, params?: InterpolationParams) => baseT(`ui.${key}`, params);
|
||||
// Memoized: an unstable `t` is unusable in a dependency array. See platform/CLAUDE.md on the
|
||||
// `useClient()` trap that silently disabled the Jellyfin player's playback reports.
|
||||
const t = useCallback((key: UiTranslationKey, params?: InterpolationParams) => baseT(`ui.${key}`, params), [baseT]);
|
||||
return { t, locale };
|
||||
};
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { Moon, Sun } from 'lucide-react';
|
||||
import { cn } from 'helpers/cn';
|
||||
import { useColorMode } from './ThemeProvider';
|
||||
import { useTranslation } from '@/components/locales';
|
||||
|
||||
type ThemeToggleProps = { colorDark?: boolean; colorLight?: boolean };
|
||||
|
||||
export const ThemeToggle = (props: ThemeToggleProps) => {
|
||||
const { colorDark, colorLight } = props;
|
||||
const { colorMode, setColorMode } = useColorMode();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const toggle = () => {
|
||||
setColorMode(colorMode === 'dark' ? 'light' : 'dark');
|
||||
@@ -26,7 +28,7 @@ export const ThemeToggle = (props: ThemeToggleProps) => {
|
||||
colorDark && 'text-black',
|
||||
)}
|
||||
/>
|
||||
<span className="sr-only">Toggle color mode</span>
|
||||
<span className="sr-only">{t('theme.toggle')}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Slot } from '@radix-ui/react-slot';
|
||||
import { ChevronRight, MoreHorizontal } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from '@/components/locales';
|
||||
|
||||
const Breadcrumb = React.forwardRef<
|
||||
HTMLElement,
|
||||
@@ -66,17 +67,20 @@ const BreadcrumbSeparator = ({ children, className, ...props }: React.ComponentP
|
||||
);
|
||||
BreadcrumbSeparator.displayName = 'BreadcrumbSeparator';
|
||||
|
||||
const BreadcrumbEllipsis = ({ className, ...props }: React.ComponentProps<'span'>) => (
|
||||
<span
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn('flex h-9 w-9 items-center justify-center', className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<span className="sr-only">More</span>
|
||||
</span>
|
||||
);
|
||||
const BreadcrumbEllipsis = ({ className, ...props }: React.ComponentProps<'span'>) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<span
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn('flex h-9 w-9 items-center justify-center', className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<span className="sr-only">{t('breadcrumb.more')}</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
BreadcrumbEllipsis.displayName = 'BreadcrumbElipssis';
|
||||
|
||||
export {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ArrowLeft, ArrowRight } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useTranslation } from '@/components/locales';
|
||||
|
||||
type CarouselApi = UseEmblaCarouselType[1];
|
||||
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
|
||||
@@ -168,6 +169,7 @@ CarouselItem.displayName = 'CarouselItem';
|
||||
const CarouselPrevious = React.forwardRef<HTMLButtonElement, React.ComponentProps<typeof Button>>(
|
||||
({ className, variant = 'outline', size = 'icon', ...props }, ref) => {
|
||||
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Button
|
||||
@@ -177,7 +179,7 @@ const CarouselPrevious = React.forwardRef<HTMLButtonElement, React.ComponentProp
|
||||
className={cn(
|
||||
'absolute h-8 w-8 rounded-full',
|
||||
orientation === 'horizontal'
|
||||
? '-left-12 top-1/2 -translate-y-1/2'
|
||||
? '-start-12 top-1/2 -translate-y-1/2'
|
||||
: '-top-12 left-1/2 -translate-x-1/2 rotate-90',
|
||||
className,
|
||||
)}
|
||||
@@ -186,7 +188,7 @@ const CarouselPrevious = React.forwardRef<HTMLButtonElement, React.ComponentProp
|
||||
{...props}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
<span className="sr-only">Previous slide</span>
|
||||
<span className="sr-only">{t('carousel.previous')}</span>
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
@@ -196,6 +198,7 @@ CarouselPrevious.displayName = 'CarouselPrevious';
|
||||
const CarouselNext = React.forwardRef<HTMLButtonElement, React.ComponentProps<typeof Button>>(
|
||||
({ className, variant = 'outline', size = 'icon', ...props }, ref) => {
|
||||
const { orientation, scrollNext, canScrollNext } = useCarousel();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Button
|
||||
@@ -205,7 +208,7 @@ const CarouselNext = React.forwardRef<HTMLButtonElement, React.ComponentProps<ty
|
||||
className={cn(
|
||||
'absolute h-8 w-8 rounded-full',
|
||||
orientation === 'horizontal'
|
||||
? '-right-12 top-1/2 -translate-y-1/2'
|
||||
? '-end-12 top-1/2 -translate-y-1/2'
|
||||
: '-bottom-12 left-1/2 -translate-x-1/2 rotate-90',
|
||||
className,
|
||||
)}
|
||||
@@ -214,7 +217,7 @@ const CarouselNext = React.forwardRef<HTMLButtonElement, React.ComponentProps<ty
|
||||
{...props}
|
||||
>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
<span className="sr-only">Next slide</span>
|
||||
<span className="sr-only">{t('carousel.next')}</span>
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from '@/components/locales';
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
|
||||
@@ -30,25 +31,28 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute end-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">{t('button.close')}</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
);
|
||||
});
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
|
||||
@@ -3,10 +3,12 @@ import { Moon, Sun } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useColorMode } from '@/components/ui/ThemeProvider';
|
||||
import { cn } from 'helpers/cn';
|
||||
import { useTranslation } from '@/components/locales';
|
||||
|
||||
export const ModeToggle = React.forwardRef<React.ElementRef<typeof Button>, React.ComponentProps<typeof Button>>(
|
||||
({ className, ...props }, ref) => {
|
||||
const { colorMode, setColorMode } = useColorMode();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Button
|
||||
@@ -19,7 +21,7 @@ export const ModeToggle = React.forwardRef<React.ElementRef<typeof Button>, Reac
|
||||
>
|
||||
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||
<span className="sr-only">Toggle color mode</span>
|
||||
<span className="sr-only">{t('theme.toggle')}</span>
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -4,15 +4,19 @@ import { ChevronLeft, ChevronRight, MoreHorizontal } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ButtonProps } from '@/components/ui/button';
|
||||
import { buttonVariants } from '@/components/ui/button';
|
||||
import { useTranslation } from '@/components/locales';
|
||||
|
||||
const Pagination = ({ className, ...props }: React.ComponentProps<'nav'>) => (
|
||||
<nav
|
||||
role="navigation"
|
||||
aria-label="pagination"
|
||||
className={cn('mx-auto flex w-full justify-center', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
const Pagination = ({ className, ...props }: React.ComponentProps<'nav'>) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<nav
|
||||
role="navigation"
|
||||
aria-label={t('pagination.label')}
|
||||
className={cn('mx-auto flex w-full justify-center', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
Pagination.displayName = 'Pagination';
|
||||
|
||||
const PaginationContent = React.forwardRef<HTMLUListElement, React.ComponentProps<'ul'>>(
|
||||
@@ -47,28 +51,47 @@ const PaginationLink = ({ className, isActive, size = 'icon', ...props }: Pagina
|
||||
);
|
||||
PaginationLink.displayName = 'PaginationLink';
|
||||
|
||||
const PaginationPrevious = ({ className, ...props }: React.ComponentProps<typeof PaginationLink>) => (
|
||||
<PaginationLink aria-label="Go to previous page" size="default" className={cn('gap-1 pl-2.5', className)} {...props}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
<span>Previous</span>
|
||||
</PaginationLink>
|
||||
);
|
||||
const PaginationPrevious = ({ className, ...props }: React.ComponentProps<typeof PaginationLink>) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<PaginationLink
|
||||
aria-label={t('pagination.goToPrevious')}
|
||||
size="default"
|
||||
className={cn('gap-1 ps-2.5', className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
<span>{t('pagination.previous')}</span>
|
||||
</PaginationLink>
|
||||
);
|
||||
};
|
||||
PaginationPrevious.displayName = 'PaginationPrevious';
|
||||
|
||||
const PaginationNext = ({ className, ...props }: React.ComponentProps<typeof PaginationLink>) => (
|
||||
<PaginationLink aria-label="Go to next page" size="default" className={cn('gap-1 pr-2.5', className)} {...props}>
|
||||
<span>Next</span>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</PaginationLink>
|
||||
);
|
||||
const PaginationNext = ({ className, ...props }: React.ComponentProps<typeof PaginationLink>) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<PaginationLink
|
||||
aria-label={t('pagination.goToNext')}
|
||||
size="default"
|
||||
className={cn('gap-1 pe-2.5', className)}
|
||||
{...props}
|
||||
>
|
||||
<span>{t('pagination.next')}</span>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</PaginationLink>
|
||||
);
|
||||
};
|
||||
PaginationNext.displayName = 'PaginationNext';
|
||||
|
||||
const PaginationEllipsis = ({ className, ...props }: React.ComponentProps<'span'>) => (
|
||||
<span aria-hidden className={cn('flex h-9 w-9 items-center justify-center', className)} {...props}>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<span className="sr-only">More pages</span>
|
||||
</span>
|
||||
);
|
||||
const PaginationEllipsis = ({ className, ...props }: React.ComponentProps<'span'>) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<span aria-hidden className={cn('flex h-9 w-9 items-center justify-center', className)} {...props}>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<span className="sr-only">{t('pagination.morePages')}</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
PaginationEllipsis.displayName = 'PaginationEllipsis';
|
||||
|
||||
export {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { X } from 'lucide-react';
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from '@/components/locales';
|
||||
|
||||
const Sheet = SheetPrimitive.Root;
|
||||
|
||||
@@ -51,18 +52,21 @@ interface SheetContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>, VariantProps<typeof sheetVariants> {}
|
||||
|
||||
const SheetContent = React.forwardRef<React.ElementRef<typeof SheetPrimitive.Content>, SheetContentProps>(
|
||||
({ side = 'right', className, children, ...props }, ref) => (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
),
|
||||
({ side = 'right', className, children, ...props }, ref) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="absolute end-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">{t('button.close')}</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
);
|
||||
},
|
||||
);
|
||||
SheetContent.displayName = SheetPrimitive.Content.displayName;
|
||||
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Play } from 'lucide-react';
|
||||
|
||||
export default function StartConfettiButton({
|
||||
isDraftLike,
|
||||
onStart,
|
||||
className,
|
||||
label = 'Start',
|
||||
}: {
|
||||
isDraftLike: boolean;
|
||||
onStart?: () => void;
|
||||
className?: string;
|
||||
label?: string;
|
||||
}) {
|
||||
const [showConfetti, setShowConfetti] = useState(false);
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const rafRef = useRef<number | null>(null);
|
||||
|
||||
// ---- Confetti Engine (inline, light) ----
|
||||
const runConfetti = useCallback(() => {
|
||||
const canvas = canvasRef.current!;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
const DPR = Math.max(1, Math.min(2, window.devicePixelRatio || 1));
|
||||
|
||||
// size canvas
|
||||
const resize = () => {
|
||||
const { innerWidth: w, innerHeight: h } = window;
|
||||
canvas.width = Math.floor(w * DPR);
|
||||
canvas.height = Math.floor(h * DPR);
|
||||
canvas.style.width = `${w}px`;
|
||||
canvas.style.height = `${h}px`;
|
||||
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
|
||||
};
|
||||
resize();
|
||||
|
||||
const onResize = () => resize();
|
||||
window.addEventListener('resize', onResize);
|
||||
|
||||
const colors = [
|
||||
'#ef4444', // red-500
|
||||
'#f59e0b', // amber-500
|
||||
'#10b981', // emerald-500
|
||||
'#3b82f6', // blue-500
|
||||
'#8b5cf6', // violet-500
|
||||
'#ec4899', // pink-500
|
||||
'#22c55e', // green-500
|
||||
'#eab308', // yellow-500
|
||||
];
|
||||
|
||||
type Particle = {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
rot: number;
|
||||
rotSpeed: number;
|
||||
tilt: number;
|
||||
tiltSpeed: number;
|
||||
color: string;
|
||||
alpha: number;
|
||||
decay: number;
|
||||
shape: 'rect' | 'triangle';
|
||||
};
|
||||
|
||||
const rand = (min: number, max: number) => Math.random() * (max - min) + min;
|
||||
|
||||
const particles: Particle[] = [];
|
||||
const bursts = 5;
|
||||
const perBurst = 80;
|
||||
const spread = Math.PI / 3;
|
||||
const gravity = 0.25;
|
||||
const air = 0.002;
|
||||
const floorFriction = 0.96;
|
||||
|
||||
for (let b = 0; b < bursts; b++) {
|
||||
const originX = (window.innerWidth / (bursts + 1)) * (b + 1);
|
||||
const originY = window.innerHeight * 0.15;
|
||||
const angleBase = -Math.PI / 2;
|
||||
for (let i = 0; i < perBurst; i++) {
|
||||
const angle = angleBase + rand(-spread, spread);
|
||||
const speed = rand(6, 12);
|
||||
particles.push({
|
||||
x: originX + rand(-8, 8),
|
||||
y: originY + rand(-8, 8),
|
||||
w: rand(4, 8),
|
||||
h: rand(6, 14),
|
||||
vx: Math.cos(angle) * speed,
|
||||
vy: Math.sin(angle) * speed,
|
||||
rot: rand(0, Math.PI * 2),
|
||||
rotSpeed: rand(-0.2, 0.2),
|
||||
tilt: rand(-0.8, 0.8),
|
||||
tiltSpeed: rand(-0.02, 0.02),
|
||||
color: colors[(Math.random() * colors.length) | 0] ?? '#ef4444',
|
||||
alpha: 1,
|
||||
decay: rand(0.004, 0.01),
|
||||
shape: Math.random() < 0.3 ? 'triangle' : 'rect',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const tick = () => {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
for (const p of particles) {
|
||||
// physics
|
||||
p.vy += gravity;
|
||||
p.vx *= 1 - air;
|
||||
p.vy *= 1 - air * 0.5;
|
||||
p.x += p.vx;
|
||||
p.y += p.vy;
|
||||
p.rot += p.rotSpeed;
|
||||
p.tilt += p.tiltSpeed;
|
||||
p.alpha -= p.decay;
|
||||
|
||||
// floor bounce & friction
|
||||
const floor = window.innerHeight - 6;
|
||||
if (p.y > floor) {
|
||||
p.y = floor;
|
||||
p.vy *= -0.45;
|
||||
p.vx *= floorFriction;
|
||||
p.rotSpeed *= floorFriction;
|
||||
p.tiltSpeed *= floorFriction;
|
||||
}
|
||||
|
||||
// draw
|
||||
if (p.alpha > 0) {
|
||||
ctx.globalAlpha = Math.max(0, p.alpha);
|
||||
ctx.save();
|
||||
ctx.translate(p.x, p.y);
|
||||
ctx.rotate(p.rot + p.tilt);
|
||||
ctx.fillStyle = p.color;
|
||||
|
||||
if (p.shape === 'rect') {
|
||||
ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h);
|
||||
} else {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, -p.h / 2);
|
||||
ctx.lineTo(p.w / 2, p.h / 2);
|
||||
ctx.lineTo(-p.w / 2, p.h / 2);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
const alive = particles.some((p) => p.alpha > 0.02);
|
||||
if (alive) {
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
} else {
|
||||
// cleanup
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
window.removeEventListener('resize', onResize);
|
||||
}
|
||||
};
|
||||
|
||||
tick();
|
||||
}, []);
|
||||
|
||||
const handleStart = () => {
|
||||
onStart?.();
|
||||
|
||||
if (isDraftLike) {
|
||||
setShowConfetti(true);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!showConfetti) return;
|
||||
|
||||
runConfetti();
|
||||
|
||||
const t = setTimeout(() => {
|
||||
setShowConfetti(false);
|
||||
}, 2200);
|
||||
|
||||
return () => {
|
||||
clearTimeout(t);
|
||||
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
};
|
||||
}, [showConfetti, runConfetti]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleStart}
|
||||
className={`min-w-24 ${isDraftLike ? 'bg-green-600 text-white hover:bg-green-700' : ''} ${className ?? ''}`}
|
||||
disabled={!isDraftLike}
|
||||
title={isDraftLike ? 'Start the experiment' : 'Only available for Draft/Created'}
|
||||
>
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
{label}
|
||||
</Button>
|
||||
|
||||
{/* Confetti Overlay */}
|
||||
{showConfetti && (
|
||||
<div
|
||||
className="fixed inset-0 z-100 pointer-events-none mask-[radial-gradient(80%_80%_at_50%_40%,#000,transparent)]"
|
||||
aria-hidden
|
||||
>
|
||||
<canvas ref={canvasRef} className="absolute inset-0 w-full h-full" />
|
||||
<div className="absolute inset-x-0 top-0 h-32 bg-linear-to-b from-white/60 to-transparent" />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { resolveKey, interpolate, isPluralForms, localeDirection } from './resolve';
|
||||
import { findMissingKeys } from './parity';
|
||||
import type { TranslationMap } from './types';
|
||||
import type { TranslationKeys, TranslationMap } from './types';
|
||||
|
||||
describe('resolveKey', () => {
|
||||
const translations: TranslationMap = {
|
||||
@@ -100,3 +100,28 @@ describe('findMissingKeys', () => {
|
||||
expect(findMissingKeys(en, { 'pt-PT': ptPT })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Type-level, not behavioural: a plural entry must be addressable by its own key and must NOT
|
||||
* contribute its CLDR categories to the union. Asserted here because nothing else would notice —
|
||||
* the failure mode is `t('files', { count })` refusing to compile while `t('files.one')` compiles.
|
||||
*/
|
||||
describe('TranslationKeys', () => {
|
||||
test('stops at a plural leaf and still walks plain nesting', () => {
|
||||
const catalog = {
|
||||
files: { one: '{{count}} file', other: '{{count}} files' },
|
||||
button: { save: 'Save', nested: { deep: 'Deep' } },
|
||||
};
|
||||
type Key = TranslationKeys<typeof catalog>;
|
||||
|
||||
const assertKey = <K extends Key>(k: K) => k;
|
||||
expect(assertKey('files')).toBe('files');
|
||||
expect(assertKey('button.save')).toBe('button.save');
|
||||
expect(assertKey('button.nested.deep')).toBe('button.nested.deep');
|
||||
|
||||
// @ts-expect-error a plural category is not a key on its own
|
||||
assertKey('files.one');
|
||||
// @ts-expect-error a branch node is not a key
|
||||
assertKey('button');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,10 +13,22 @@ export type I18nContextValue = {
|
||||
locale: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A node that IS a plural entry, so `TranslationKeys` stops at it rather than walking into it.
|
||||
*
|
||||
* Without this, `{ one, other }` contributed `foo.one` and `foo.other` to the key union and `t('foo',
|
||||
* { count })` did not typecheck at all — the plural machinery landed in phase 0 with unit tests, and the
|
||||
* type gap only surfaced in phase 2 when a real catalog first used one. Mirrors `isPluralForms` at
|
||||
* runtime: every key a CLDR category, every value a string.
|
||||
*/
|
||||
type IsPluralLeaf<T> = keyof T extends Intl.LDMLPluralRule ? (T[keyof T] extends string ? true : false) : false;
|
||||
|
||||
export type TranslationKeys<T, Prefix extends string = ''> = T extends string
|
||||
? Prefix
|
||||
: T extends Record<string, unknown>
|
||||
? {
|
||||
[K in keyof T & string]: TranslationKeys<T[K], Prefix extends '' ? K : `${Prefix}.${K}`>;
|
||||
}[keyof T & string]
|
||||
? IsPluralLeaf<T> extends true
|
||||
? Prefix
|
||||
: {
|
||||
[K in keyof T & string]: TranslationKeys<T[K], Prefix extends '' ? K : `${Prefix}.${K}`>;
|
||||
}[keyof T & string]
|
||||
: never;
|
||||
|
||||
@@ -96,6 +96,10 @@ const startBrief = (name: string): string =>
|
||||
'If you CAN — say what you understood, in your own words, and ask me to confirm or correct it.',
|
||||
'If you CANNOT — ask me what you should be watching for here. One question, and wait.',
|
||||
'',
|
||||
'Once you are running, you owe me exactly two messages here per event, and I should never have to ask',
|
||||
'for them: one line when the thing you watch for happens, and a short report when the work is done.',
|
||||
'The job panel is not where I read things. The skill explains how to wake this conversation yourself.',
|
||||
'',
|
||||
'Either way, say it plainly. No headings, no bullet points, no file paths, no commit hashes, no tag',
|
||||
'names. Do not open with "Oriented" and do not tell me you verified anything — I asked you to look,',
|
||||
'so saying you looked is not news. Length is whatever the answer honestly needs and no more.',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { InterpolationParams, TranslationKeys } from 'i18n';
|
||||
import { useCallback } from 'react';
|
||||
import { useTranslation as useTranslationBase } from 'i18n';
|
||||
import type en from './en-GB.json';
|
||||
|
||||
@@ -8,6 +9,11 @@ type PanelTranslationKey = TranslationKeys<typeof en>;
|
||||
// pass the same bare key the JSON file uses.
|
||||
export const useTranslation = () => {
|
||||
const { t: baseT, locale } = useTranslationBase();
|
||||
const t = (key: PanelTranslationKey, params?: InterpolationParams) => baseT(`panel.${key}`, params);
|
||||
// Memoized: an unstable `t` is unusable in a dependency array. See platform/CLAUDE.md on the
|
||||
// `useClient()` trap that silently disabled the Jellyfin player's playback reports.
|
||||
const t = useCallback(
|
||||
(key: PanelTranslationKey, params?: InterpolationParams) => baseT(`panel.${key}`, params),
|
||||
[baseT],
|
||||
);
|
||||
return { t, locale };
|
||||
};
|
||||
|
||||
@@ -49,9 +49,10 @@ handling.
|
||||
- `Dashboards/DashboardPreview`'s `NamePanel` (name + description) has its submit button in a
|
||||
_different_, independently-mounted panel (`CreatePanel`) elsewhere in the workspace layout tree — a
|
||||
`<form>` can't span two panels without restructuring the wizard.
|
||||
- `ColorPicker` and `MarkdownEditor` have zero call sites anywhere in the repo right now; both are
|
||||
shaped to be embedded in a future caller's own form (uncontrolled inputs with `name` attributes, or a
|
||||
plain controlled textarea with no submit of its own), so there's nothing to wrap yet.
|
||||
- `ColorPicker` has zero call sites anywhere in the repo right now; it is shaped to be embedded in a
|
||||
future caller's own form (uncontrolled inputs with `name` attributes), so there's nothing to wrap yet.
|
||||
`MarkdownEditor` was named here too and was deleted on 2026-08-23 during the localization pass — it
|
||||
had no call sites and hardcoded another product's logo URL.
|
||||
- `widgets/Weather`'s `LocationSetup` is a live autocomplete with several result rows and no single
|
||||
obvious Enter target (which of N matches?).
|
||||
|
||||
|
||||
Reference in New Issue
Block a user