Compare commits

...
Author SHA1 Message Date
pastilhas 2b0b671895 scanner: separate never-translate hits from convertible ones, and stop counting tailwind as prose
The --check guard compared an area's whole hit count against zero, including
'thrown errors' — which the doc's never-translate list forbids converting. An
area holding one legitimate new Error() could therefore never be added to
ZEROED, so the debt guard could never be switched on for it at all. Categories
now carry neverTranslate, are still reported, and are excluded from --check.

Also drops sonner's 'group-[.toast]:text-muted-foreground', matched as an object
label. The filter tests per token rather than per string: the first attempt
tested the whole string and ate a real label, 'Application type:'.

--check now prints the offending strings and files instead of only a count.
2026-08-23 04:13:21 +00:00
pastilhas 2ec3f11cdd shadcn primitives and logos keyed: dialog, sheet, pagination, carousel, breadcrumb, theme toggles
These are the strings a user meets everywhere, mostly sr-only: the dialog and
sheet close buttons, pagination labels, carousel arrows, the breadcrumb ellipsis
and both colour-mode toggles. nav aria-label='pagination' became a real key too —
a screen reader announces it, so it is user-facing.

Logo alt text keeps the product name and translates only around it
('Logótipo Officer'). Arrow-button insets became logical (-start-12/-end-12).

The three 'useX must be used within <Y>' Errors are deliberately untouched:
never-translate, per the doc's list.
2026-08-23 04:10:39 +00:00
pastilhas 5ce1a2928e inputs and data views keyed: search, select, tabs, state blocks, data table
The DataTable's pagination carries the only interpolated strings in this area
('Showing page {{current}} of {{total}}', '{{size}} / page'). No plural forms:
nothing here is a count-varying noun, so inventing one would only add a key
nobody uses.

DataTable.test.tsx now renders inside an I18nProvider with the real en-GB
catalog, so it still asserts the English a user sees rather than a dot-path.

Left/right utilities became logical (ms-/me-/ps-/pe-/start-) per D3.
2026-08-23 04:10:33 +00:00
pastilhas 339d31f05c ui.* catalog and the dialogs: error dialogs, confirm and paper dialogs keyed
Establishes the ui.* namespace for phases 2 and 3. Both catalogs complete, so the
key-parity test holds. Two English copy defects fixed while keying, because the
Portuguese must not inherit them: 'Your are not authorized' -> 'You are', and
'Please Signin again' -> 'Please sign in again'.

The server's own message in ServerError/CustomError stays verbatim — it is a
response, not UI copy (D1).
2026-08-23 04:10:27 +00:00
pastilhas a444280cb4 deploy report 2026.08.23.03 2026-08-23 04:02:58 +00:00
26 changed files with 488 additions and 183 deletions
+42
View File
@@ -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.
+51 -8
View File
@@ -34,21 +34,36 @@ const SKIP = /node_modules|\.gen\.|[/\\]locales[/\\]|[/\\]vendor[/\\]|\.test\.|\
// A quoted literal, single or double. Kept as two groups because JS has no branch reset. // 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 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< // >Some visible words<
{ kind: 'jsx text', rx: />\s*([A-Z][A-Za-z0-9][^<>{}\n]{2,100}?)\s*</g }, { kind: 'jsx text', rx: />\s*([A-Z][A-Za-z0-9][^<>{}\n]{2,100}?)\s*</g },
// placeholder="..." and friends // placeholder="..." and friends
{ {
kind: 'attributes', 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') }, { 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. // label: 'Status' — nav items, menus, table columns. The largest hidden category.
{ {
kind: 'object labels', 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. // `Failed to create ${kind}` — needs interpolation, so it costs more to convert.
{ kind: 'template str', rx: /`([A-Z][^`\n]{4,100}\$\{[^`]*)`/g }, { kind: 'template str', rx: /`([A-Z][^`\n]{4,100}\$\{[^`]*)`/g },
]; ];
@@ -62,6 +77,16 @@ function isHuman(value: string | undefined): value is string {
if (/^[a-z0-9\-_./:]+$/.test(s)) return false; if (/^[a-z0-9\-_./:]+$/.test(s)) return false;
if (/^(https?:|\/|#|data:|application\/|image\/)/.test(s)) return false; if (/^(https?:|\/|#|data:|application\/|image\/)/.test(s)) return false;
if (/^[A-Z0-9_]+$/.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; return true;
} }
@@ -118,7 +143,9 @@ const results = AREAS.map((area) => ({ ...area, ...scan(area.root) }));
const kinds = PATTERNS.map((p) => p.kind); const kinds = PATTERNS.map((p) => p.kind);
const pad = (s: string | number, n: number) => String(s).padStart(n); 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) { for (const r of results) {
const byKind = kinds.map((k) => pad(r.hits.filter((h) => h.kind === k).length, 14)).join(''); 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)}`); console.log(`${r.label.padEnd(22)} ${pad(r.files, 5)} ${byKind} ${pad(r.hits.length, 7)}`);
@@ -128,9 +155,19 @@ const all = results.flatMap((r) => r.hits);
const distinct = new Set(all.map((h) => h.value)); const distinct = new Set(all.map((h) => h.value));
const interpolated = new Set(all.filter((h) => h.kind === 'template str').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();
console.log(`occurrences ${all.length}`); 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(`distinct strings ${distinct.size}`);
console.log(` needing params ${interpolated.size}`); console.log(` needing params ${interpolated.size}`);
@@ -149,10 +186,16 @@ if (listPrefix) {
} }
if (args.has('--check')) { 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) { if (regressed.length > 0) {
console.error('\nThese areas were converted and have regressed:'); 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.'); console.error('\nUse a catalog key. See docs/localization.md.');
process.exit(1); process.exit(1);
} }
+10 -6
View File
@@ -2,6 +2,7 @@ import type { LucideIcon } from 'lucide-react';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { Loader2, TriangleAlert } from 'lucide-react'; import { Loader2, TriangleAlert } from 'lucide-react';
import { cn } from 'helpers/cn'; import { cn } from 'helpers/cn';
import { useTranslation } from '@/components/locales';
// Loading, failed, empty. See docs/design-language-interface.md § 6. // Loading, failed, empty. See docs/design-language-interface.md § 6.
// //
@@ -19,12 +20,15 @@ const Shell = ({ children, className }: { children: ReactNode; className?: strin
</div> </div>
); );
export const LoadingBlock = ({ label = 'Loading…' }: { label?: string }) => ( export const LoadingBlock = ({ label }: { label?: string }) => {
<Shell> const { t } = useTranslation();
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" /> return (
<p className="text-sm text-muted-foreground">{label}</p> <Shell>
</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 * 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 { Column } from './DataTable';
import type { ReactNode } from 'react';
import { describe, expect, test } from 'bun:test'; import { describe, expect, test } from 'bun:test';
import { render, screen, fireEvent, within } from '@testing-library/react'; import { render, screen, fireEvent, within } from '@testing-library/react';
import { I18nProvider } from 'i18n';
import { DataTable, useDataControl } from './DataTable'; 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 = { type TestItem = {
id: number; id: number;
@@ -55,7 +69,7 @@ const TestWrapper = ({
describe('DataTable', () => { describe('DataTable', () => {
describe('rendering', () => { describe('rendering', () => {
test('renders table with data', () => { test('renders table with data', () => {
render(<TestWrapper />); render(withI18n(<TestWrapper />));
expect(screen.getByRole('table')).toBeDefined(); expect(screen.getByRole('table')).toBeDefined();
expect(screen.getByText('Alpha')).toBeDefined(); expect(screen.getByText('Alpha')).toBeDefined();
@@ -63,7 +77,7 @@ describe('DataTable', () => {
}); });
test('renders column headers', () => { test('renders column headers', () => {
render(<TestWrapper />); render(withI18n(<TestWrapper />));
// Component renders both desktop and mobile versions (hidden via CSS) // Component renders both desktop and mobile versions (hidden via CSS)
// so we use getAllByText and verify at least one exists // 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', () => { test('renders correct number of rows based on page size', () => {
render(<TestWrapper pageSize={3} />); render(withI18n(<TestWrapper pageSize={3} />));
const rows = screen.getAllByRole('row'); const rows = screen.getAllByRole('row');
// 1 header row + 3 data rows // 1 header row + 3 data rows
@@ -82,20 +96,20 @@ describe('DataTable', () => {
}); });
test('shows no data message when data is empty', () => { 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.getByText('No items found')).toBeDefined();
expect(screen.queryByRole('table')).toBeNull(); expect(screen.queryByRole('table')).toBeNull();
}); });
test('shows default no data message', () => { test('shows default no data message', () => {
render(<TestWrapper data={[]} />); render(withI18n(<TestWrapper data={[]} />));
expect(screen.getByText('No Data Found.')).toBeDefined(); expect(screen.getByText('No Data Found.')).toBeDefined();
}); });
test('renders noDataCTA when provided', () => { 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(); expect(screen.getByRole('button', { name: 'Add Item' })).toBeDefined();
}); });
@@ -106,7 +120,7 @@ describe('DataTable', () => {
return <DataTable dataController={controller} columns={columns} />; return <DataTable dataController={controller} columns={columns} />;
}; };
const { container } = render(<NullWrapper />); const { container } = render(withI18n(<NullWrapper />));
expect(container.innerHTML).toBe(''); expect(container.innerHTML).toBe('');
}); });
}); });
@@ -118,7 +132,7 @@ describe('DataTable', () => {
return <DataTable dataController={controller} />; return <DataTable dataController={controller} />;
}; };
render(<AutoColumnsWrapper />); render(withI18n(<AutoColumnsWrapper />));
// Should generate columns from object keys (rendered for both mobile/desktop) // Should generate columns from object keys (rendered for both mobile/desktop)
expect(screen.getAllByText('Id').length).toBeGreaterThan(0); expect(screen.getAllByText('Id').length).toBeGreaterThan(0);
@@ -130,7 +144,7 @@ describe('DataTable', () => {
describe('sorting', () => { describe('sorting', () => {
test('clicking sortable column header triggers sort', () => { test('clicking sortable column header triggers sort', () => {
render(<TestWrapper pageSize={10} />); render(withI18n(<TestWrapper pageSize={10} />));
// Get the first "Name" element (desktop version) // Get the first "Name" element (desktop version)
const nameHeaders = screen.getAllByText('Name'); const nameHeaders = screen.getAllByText('Name');
@@ -143,7 +157,7 @@ describe('DataTable', () => {
}); });
test('clicking same column twice reverses sort order', () => { test('clicking same column twice reverses sort order', () => {
render(<TestWrapper pageSize={10} />); render(withI18n(<TestWrapper pageSize={10} />));
const nameHeaders = screen.getAllByText('Name'); const nameHeaders = screen.getAllByText('Name');
fireEvent.click(nameHeaders[0]!); // asc fireEvent.click(nameHeaders[0]!); // asc
@@ -156,7 +170,7 @@ describe('DataTable', () => {
}); });
test('sort icon is displayed for sortable columns', () => { test('sort icon is displayed for sortable columns', () => {
render(<TestWrapper />); render(withI18n(<TestWrapper />));
// Each sortable column should have the ArrowUpDown icon // Each sortable column should have the ArrowUpDown icon
const headers = screen.getAllByRole('columnheader'); const headers = screen.getAllByRole('columnheader');
@@ -169,19 +183,19 @@ describe('DataTable', () => {
describe('pagination', () => { describe('pagination', () => {
test('shows pagination bar when data exceeds page size', () => { 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(); expect(screen.getByText(/Showing page 1 of 2/)).toBeDefined();
}); });
test('does not show pagination when all data fits on one page', () => { 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(); expect(screen.queryByText(/Showing page/)).toBeNull();
}); });
test('next button navigates to next page', () => { test('next button navigates to next page', () => {
render(<TestWrapper pageSize={5} />); render(withI18n(<TestWrapper pageSize={5} />));
const nextButton = screen.getByRole('button', { name: 'Next' }); const nextButton = screen.getByRole('button', { name: 'Next' });
fireEvent.click(nextButton); fireEvent.click(nextButton);
@@ -192,7 +206,7 @@ describe('DataTable', () => {
}); });
test('previous button navigates to previous page', () => { test('previous button navigates to previous page', () => {
render(<TestWrapper pageSize={5} />); render(withI18n(<TestWrapper pageSize={5} />));
const nextButton = screen.getByRole('button', { name: 'Next' }); const nextButton = screen.getByRole('button', { name: 'Next' });
fireEvent.click(nextButton); fireEvent.click(nextButton);
@@ -205,7 +219,7 @@ describe('DataTable', () => {
}); });
test('first button goes to first page', () => { test('first button goes to first page', () => {
render(<TestWrapper pageSize={5} />); render(withI18n(<TestWrapper pageSize={5} />));
const nextButton = screen.getByRole('button', { name: 'Next' }); const nextButton = screen.getByRole('button', { name: 'Next' });
fireEvent.click(nextButton); fireEvent.click(nextButton);
@@ -217,7 +231,7 @@ describe('DataTable', () => {
}); });
test('last button goes to last page', () => { test('last button goes to last page', () => {
render(<TestWrapper pageSize={5} />); render(withI18n(<TestWrapper pageSize={5} />));
const lastButton = screen.getByRole('button', { name: 'Last' }); const lastButton = screen.getByRole('button', { name: 'Last' });
fireEvent.click(lastButton); fireEvent.click(lastButton);
@@ -226,7 +240,7 @@ describe('DataTable', () => {
}); });
test('navigation buttons are disabled at boundaries', () => { test('navigation buttons are disabled at boundaries', () => {
render(<TestWrapper pageSize={5} />); render(withI18n(<TestWrapper pageSize={5} />));
const firstBtn = screen.getByRole('button', { name: 'First' }); const firstBtn = screen.getByRole('button', { name: 'First' });
const prevBtn = screen.getByRole('button', { name: 'Prev' }); const prevBtn = screen.getByRole('button', { name: 'Prev' });
@@ -258,7 +272,7 @@ describe('DataTable', () => {
{ field: 'status', label: 'Status', condition: false }, { 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('ID').length).toBeGreaterThan(0);
expect(screen.getAllByText('Name').length).toBeGreaterThan(0); expect(screen.getAllByText('Name').length).toBeGreaterThan(0);
@@ -271,7 +285,7 @@ describe('DataTable', () => {
{ field: 'status', label: 'Status', condition: true }, { field: 'status', label: 'Status', condition: true },
]; ];
render(<TestWrapper cols={conditionalColumns} />); render(withI18n(<TestWrapper cols={conditionalColumns} />));
expect(screen.getAllByText('Status').length).toBeGreaterThan(0); expect(screen.getAllByText('Status').length).toBeGreaterThan(0);
}); });
@@ -283,11 +297,11 @@ describe('DataTable', () => {
{ field: 'status', label: 'Status', condition: () => showStatus }, { field: 'status', label: 'Status', condition: () => showStatus },
]; ];
const { rerender } = render(<TestWrapper cols={conditionalColumns} />); const { rerender } = render(withI18n(<TestWrapper cols={conditionalColumns} />));
expect(screen.queryByText('Status')).toBeNull(); expect(screen.queryByText('Status')).toBeNull();
showStatus = true; showStatus = true;
rerender(<TestWrapper cols={conditionalColumns} />); rerender(withI18n(<TestWrapper cols={conditionalColumns} />));
expect(screen.getAllByText('Status').length).toBeGreaterThan(0); 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'); const formattedNames = screen.getAllByTestId('formatted-name');
expect(formattedNames.length).toBeGreaterThan(0); 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 // Each row should have a unique test ID based on item.id
expect(screen.getByTestId('item-1')).toBeDefined(); 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.getByTestId('action-1')).toBeDefined();
expect(screen.getByText('Edit Alpha')).toBeDefined(); expect(screen.getByText('Edit Alpha')).toBeDefined();
@@ -351,7 +365,7 @@ describe('DataTable', () => {
{ field: 'name', label: 'Name' }, { field: 'name', label: 'Name' },
]; ];
render(<TestWrapper cols={tooltipColumns} />); render(withI18n(<TestWrapper cols={tooltipColumns} />));
// The tooltip indicator (ⓘ) should be present in the ID column headers // The tooltip indicator (ⓘ) should be present in the ID column headers
const idHeaders = screen.getAllByText(/ID/); const idHeaders = screen.getAllByText(/ID/);
@@ -367,7 +381,7 @@ describe('DataTable', () => {
{ field: 'name', label: 'Full Name', labelMobile: 'Name' }, { 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) // Both desktop and mobile labels should be in the DOM (hidden/shown via CSS)
expect(screen.getAllByText('Identifier').length).toBeGreaterThan(0); expect(screen.getAllByText('Identifier').length).toBeGreaterThan(0);
@@ -396,7 +410,7 @@ describe('DataTable', () => {
); );
}; };
render(<SearchableTable />); render(withI18n(<SearchableTable />));
const searchInput = screen.getByTestId('search-input'); const searchInput = screen.getByTestId('search-input');
fireEvent.change(searchInput, { target: { value: 'Alpha' } }); fireEvent.change(searchInput, { target: { value: 'Alpha' } });
@@ -420,7 +434,7 @@ describe('DataTable', () => {
); );
}; };
render(<ExternalSortTable />); render(withI18n(<ExternalSortTable />));
fireEvent.click(screen.getByTestId('sort-btn')); 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 { useDataControl, type UseDataControlReturn } from 'hooks/useDataControl';
import { PaginationBar } from './PaginationBar'; import { PaginationBar } from './PaginationBar';
import { cn } from 'helpers/cn'; import { cn } from 'helpers/cn';
import { useTranslation } from '@/components/locales';
export const DataTable = <T,>(props: DataTableProps<T>) => { export const DataTable = <T,>(props: DataTableProps<T>) => {
const { t } = useTranslation();
const [columns, setColumns] = useState<Column<T>[]>([]); const [columns, setColumns] = useState<Column<T>[]>([]);
const { dataController, uniqueKey, noDataCTA, noDataMessage } = props; const { dataController, uniqueKey, noDataCTA, noDataMessage } = props;
const theUniqueKey = uniqueKey || 'id'; const theUniqueKey = uniqueKey || 'id';
@@ -58,7 +60,7 @@ export const DataTable = <T,>(props: DataTableProps<T>) => {
if (rawData.length === 0) { if (rawData.length === 0) {
return ( return (
<div className="py-12 text-center"> <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}</>} {noDataCTA && <>{noDataCTA}</>}
</div> </div>
); );
@@ -94,7 +96,7 @@ export const DataTable = <T,>(props: DataTableProps<T>) => {
<span className="min-[500px]:hidden"> <span className="min-[500px]:hidden">
{String(labelMobile || label || field || '')} {tooltip ? <sup>ⓘ</sup> : ''} {String(labelMobile || label || field || '')} {tooltip ? <sup>ⓘ</sup> : ''}
</span> </span>
{sortKey && <ArrowUpDown className={cn('ml-2 h-4 w-4')} />} {sortKey && <ArrowUpDown className={cn('ms-2 h-4 w-4')} />}
</div> </div>
</TooltipTrigger> </TooltipTrigger>
{tooltip && ( {tooltip && (
@@ -3,12 +3,14 @@ import type { UseDataControlReturn } from 'hooks/useDataControl';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useTranslation } from '@/components/locales';
type PaginationBarProps<T> = { type PaginationBarProps<T> = {
dataManager: UseDataControlReturn<T>; dataManager: UseDataControlReturn<T>;
}; };
export const PaginationBar = <T,>(props: PaginationBarProps<T>) => { export const PaginationBar = <T,>(props: PaginationBarProps<T>) => {
const { t } = useTranslation();
const { currentPage, pageCount, changePage, pageSize, setPageSize } = props.dataManager; const { currentPage, pageCount, changePage, pageSize, setPageSize } = props.dataManager;
const pageSizeOptions = useMemo(() => [5, 10, 15, 20], []); const pageSizeOptions = useMemo(() => [5, 10, 15, 20], []);
@@ -28,10 +30,10 @@ export const PaginationBar = <T,>(props: PaginationBarProps<T>) => {
{/* LEFT */} {/* LEFT */}
<div className="flex items-center gap-2 min-[500px]:gap-3 text-sm text-muted-foreground whitespace-nowrap"> <div className="flex items-center gap-2 min-[500px]:gap-3 text-sm text-muted-foreground whitespace-nowrap">
<span className="hidden min-[500px]:inline"> <span className="hidden min-[500px]:inline">
Showing page {currentPage} of {pageCount} {t('dataTable.pagination.showingPage', { current: currentPage, total: pageCount })}
</span> </span>
<span className="min-[500px]:hidden"> <span className="min-[500px]:hidden">
p {currentPage}/{pageCount} {t('dataTable.pagination.showingPageShort', { current: currentPage, total: pageCount })}
</span> </span>
<span className="bg-border h-5 w-px" aria-hidden="true" /> <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 hover:bg-background
data-[state=open]:bg-muted data-[state=open]:bg-muted
" "
aria-label="Rows per page" aria-label={t('dataTable.pagination.rowsPerPage')}
> >
<SelectValue className="text-muted-foreground"> <SelectValue className="text-muted-foreground">
<span className="hidden min-[500px]:inline">{pageSize} / page</span> <span className="hidden min-[500px]:inline">{t('dataTable.pagination.perPage', { size: pageSize })}</span>
<span className="min-[500px]:hidden">{pageSize}/p</span> <span className="min-[500px]:hidden">{t('dataTable.pagination.perPageShort', { size: pageSize })}</span>
</SelectValue> </SelectValue>
</SelectTrigger> </SelectTrigger>
@@ -66,8 +68,8 @@ export const PaginationBar = <T,>(props: PaginationBarProps<T>) => {
data-[state=checked]:text-muted-foreground data-[state=checked]:text-muted-foreground
" "
> >
<span className="hidden min-[500px]:inline">{n} / page</span> <span className="hidden min-[500px]:inline">{t('dataTable.pagination.perPage', { size: n })}</span>
<span className="min-[500px]:hidden">{n}/p</span> <span className="min-[500px]:hidden">{t('dataTable.pagination.perPageShort', { size: n })}</span>
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -82,7 +84,7 @@ export const PaginationBar = <T,>(props: PaginationBarProps<T>) => {
className={buttonClass} className={buttonClass}
onClick={() => changePage(1)} onClick={() => changePage(1)}
disabled={currentPage === 1} disabled={currentPage === 1}
aria-label="First" aria-label={t('dataTable.pagination.first')}
> >
« «
</Button> </Button>
@@ -92,7 +94,7 @@ export const PaginationBar = <T,>(props: PaginationBarProps<T>) => {
className={buttonClass} className={buttonClass}
onClick={() => changePage(currentPage - 1)} onClick={() => changePage(currentPage - 1)}
disabled={currentPage === 1} disabled={currentPage === 1}
aria-label="Prev" aria-label={t('dataTable.pagination.prev')}
> >
‹ ‹
</Button> </Button>
@@ -102,7 +104,7 @@ export const PaginationBar = <T,>(props: PaginationBarProps<T>) => {
className={buttonClass} className={buttonClass}
onClick={() => changePage(currentPage + 1)} onClick={() => changePage(currentPage + 1)}
disabled={currentPage === pageCount} disabled={currentPage === pageCount}
aria-label="Next" aria-label={t('dataTable.pagination.next')}
> >
› ›
</Button> </Button>
@@ -112,7 +114,7 @@ export const PaginationBar = <T,>(props: PaginationBarProps<T>) => {
className={buttonClass} className={buttonClass}
onClick={() => changePage(pageCount)} onClick={() => changePage(pageCount)}
disabled={currentPage === pageCount} disabled={currentPage === pageCount}
aria-label="Last" aria-label={t('dataTable.pagination.last')}
> >
» »
</Button> </Button>
@@ -1,6 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useTranslation } from '@/components/locales';
type ConfirmDialogProps = { type ConfirmDialogProps = {
open: boolean; open: boolean;
@@ -16,8 +17,9 @@ type ConfirmDialogProps = {
export const ConfirmDialog = (props: ConfirmDialogProps) => { export const ConfirmDialog = (props: ConfirmDialogProps) => {
const [confirming, setConfirming] = useState(false); const [confirming, setConfirming] = useState(false);
const { t } = useTranslation();
const { open, disabled = false, title, description, children } = props; 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 () => { const handleConfirm = async () => {
setConfirming(true); 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"> <div className="mt-6 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button type="button" variant="outline" onClick={onCancel}> <Button type="button" variant="outline" onClick={onCancel}>
Cancel {t('button.cancel')}
</Button> </Button>
<Button <Button
type="button" type="button"
variant={confirmVariant} variant={confirmVariant}
onClick={handleConfirm} onClick={handleConfirm}
disabled={disabled || confirming} disabled={disabled || confirming}
className="sm:ml-2" className="sm:ms-2"
> >
{confirmLabel} {confirmLabel ?? t('button.confirm')}
</Button> </Button>
</div> </div>
</DialogContent> </DialogContent>
@@ -2,6 +2,7 @@ import type { ReactNode } from 'react';
import { Dialog, DialogOverlay, DialogPortal } from '@/components/ui/dialog'; import { Dialog, DialogOverlay, DialogPortal } from '@/components/ui/dialog';
import * as DialogPrimitive from '@radix-ui/react-dialog'; import * as DialogPrimitive from '@radix-ui/react-dialog';
import { cardStyle } from '@/components/Card'; import { cardStyle } from '@/components/Card';
import { useTranslation } from '@/components/locales';
type PaperDialogProps = { type PaperDialogProps = {
open: boolean; open: boolean;
@@ -11,6 +12,8 @@ type PaperDialogProps = {
}; };
export const PaperDialog = ({ open, onOpenChange, onOpenAutoFocus, children }: PaperDialogProps) => { export const PaperDialog = ({ open, onOpenChange, onOpenAutoFocus, children }: PaperDialogProps) => {
const { t } = useTranslation();
const handleOpenAutoFocus = (ev: Event) => { const handleOpenAutoFocus = (ev: Event) => {
ev.preventDefault(); ev.preventDefault();
onOpenAutoFocus?.(ev); 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" 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()} 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="h-4 w-4 text-duck-dark">✕</span>
<span className="sr-only">Close</span> <span className="sr-only">{t('button.close')}</span>
</DialogPrimitive.Close> </DialogPrimitive.Close>
{children} {children}
</DialogPrimitive.Content> </DialogPrimitive.Content>
@@ -2,9 +2,11 @@ import { Dialog, DialogContent, DialogDescription } from '@/components/ui/dialog
import { DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Ban } from 'lucide-react'; import { Ban } from 'lucide-react';
import { useTranslation } from '@/components/locales';
import type { ErrorDialogsProps } from './index'; import type { ErrorDialogsProps } from './index';
export const CustomErrorDialog = ({ apiError, setApiError }: ErrorDialogsProps) => { export const CustomErrorDialog = ({ apiError, setApiError }: ErrorDialogsProps) => {
const { t } = useTranslation();
const onOpenChange = (value: boolean) => { const onOpenChange = (value: boolean) => {
if (!value) { if (!value) {
setApiError(null); setApiError(null);
@@ -16,14 +18,15 @@ export const CustomErrorDialog = ({ apiError, setApiError }: ErrorDialogsProps)
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-10"> <DialogTitle className="flex items-center gap-10">
<Ban className="h-12 w-12 text-red-500" /> <Ban className="h-12 w-12 text-red-500" />
Server Error {t('error.server.title')}
</DialogTitle> </DialogTitle>
<DialogDescription className="flex flex-col gap-4 py-5">{apiError?.uuid}</DialogDescription> <DialogDescription className="flex flex-col gap-4 py-5">{apiError?.uuid}</DialogDescription>
</DialogHeader> </DialogHeader>
{/* The server's own message, shown verbatim — it is a response, not UI copy (see D1). */}
<p>{apiError?.message}</p> <p>{apiError?.message}</p>
<DialogFooter> <DialogFooter>
<Button type="submit" onClick={() => onOpenChange(false)}> <Button type="submit" onClick={() => onOpenChange(false)}>
Ok {t('button.ok')}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -2,9 +2,11 @@ import { Dialog, DialogContent, DialogDescription } from '@/components/ui/dialog
import { DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Ban } from 'lucide-react'; import { Ban } from 'lucide-react';
import { useTranslation } from '@/components/locales';
import type { ErrorDialogsProps } from './index'; import type { ErrorDialogsProps } from './index';
export const ForbiddenDialog = ({ setApiError }: ErrorDialogsProps) => { export const ForbiddenDialog = ({ setApiError }: ErrorDialogsProps) => {
const { t } = useTranslation();
const onOpenChange = (value: boolean) => { const onOpenChange = (value: boolean) => {
if (!value) { if (!value) {
setApiError(null); setApiError(null);
@@ -16,16 +18,14 @@ export const ForbiddenDialog = ({ setApiError }: ErrorDialogsProps) => {
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-10"> <DialogTitle className="flex items-center gap-10">
<Ban className="h-12 w-12 text-red-500" /> <Ban className="h-12 w-12 text-red-500" />
Unauthorized Access {t('error.forbidden.title')}
</DialogTitle> </DialogTitle>
<DialogDescription className="flex flex-col gap-4 py-5"> <DialogDescription className="flex flex-col gap-4 py-5">{t('error.forbidden.description')}</DialogDescription>
Your are not authorized to perform this action.
</DialogDescription>
</DialogHeader> </DialogHeader>
<p>If you think this is an error, please contact your manager.</p> <p>{t('error.forbidden.hint')}</p>
<DialogFooter> <DialogFooter>
<Button type="submit" onClick={() => onOpenChange(false)}> <Button type="submit" onClick={() => onOpenChange(false)}>
Ok {t('button.ok')}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -2,9 +2,11 @@ import { Dialog, DialogContent, DialogDescription } from '@/components/ui/dialog
import { DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Ban } from 'lucide-react'; import { Ban } from 'lucide-react';
import { useTranslation } from '@/components/locales';
import type { ErrorDialogsProps } from './index'; import type { ErrorDialogsProps } from './index';
export const ServerErrorDialog = ({ apiError, setApiError }: ErrorDialogsProps) => { export const ServerErrorDialog = ({ apiError, setApiError }: ErrorDialogsProps) => {
const { t } = useTranslation();
const onOpenChange = (value: boolean) => { const onOpenChange = (value: boolean) => {
if (!value) { if (!value) {
setApiError(null); setApiError(null);
@@ -16,14 +18,15 @@ export const ServerErrorDialog = ({ apiError, setApiError }: ErrorDialogsProps)
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-10"> <DialogTitle className="flex items-center gap-10">
<Ban className="h-12 w-12 text-red-500" /> <Ban className="h-12 w-12 text-red-500" />
Server Error {t('error.server.title')}
</DialogTitle> </DialogTitle>
<DialogDescription className="flex flex-col gap-4 py-5">{apiError?.uuid}</DialogDescription> <DialogDescription className="flex flex-col gap-4 py-5">{apiError?.uuid}</DialogDescription>
</DialogHeader> </DialogHeader>
{/* The server's own message, shown verbatim — it is a response, not UI copy (see D1). */}
<p>{apiError?.message}</p> <p>{apiError?.message}</p>
<DialogFooter> <DialogFooter>
<Button type="submit" onClick={() => onOpenChange(false)}> <Button type="submit" onClick={() => onOpenChange(false)}>
Ok {t('button.ok')}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -3,10 +3,12 @@ import { DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Ban } from 'lucide-react'; import { Ban } from 'lucide-react';
import { useAuth } from 'hooks/useAuth'; import { useAuth } from 'hooks/useAuth';
import { useTranslation } from '@/components/locales';
import type { ErrorDialogsProps } from './index'; import type { ErrorDialogsProps } from './index';
export const UnauthorizedDialog = ({ setApiError }: ErrorDialogsProps) => { export const UnauthorizedDialog = ({ setApiError }: ErrorDialogsProps) => {
const { signout } = useAuth(); const { signout } = useAuth();
const { t } = useTranslation();
const onOpenChange = (value: boolean) => { const onOpenChange = (value: boolean) => {
if (!value) { if (!value) {
setApiError(null); setApiError(null);
@@ -20,14 +22,16 @@ export const UnauthorizedDialog = ({ setApiError }: ErrorDialogsProps) => {
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-10"> <DialogTitle className="flex items-center gap-10">
<Ban className="h-12 w-12 text-red-500" /> <Ban className="h-12 w-12 text-red-500" />
Invalid Credentials {t('error.unauthorized.title')}
</DialogTitle> </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> </DialogHeader>
<p>Please Signin again</p> <p>{t('error.unauthorized.hint')}</p>
<DialogFooter> <DialogFooter>
<Button type="submit" onClick={() => onOpenChange(false)}> <Button type="submit" onClick={() => onOpenChange(false)}>
Ok {t('button.ok')}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -1,17 +1,14 @@
import { useTranslation } from '@/components/locales';
type SvgLogoProps = { type SvgLogoProps = {
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'; size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
}; };
export function OfficerIconLogo({ size = 'xl' }: SvgLogoProps) { export function OfficerIconLogo({ size = 'xl' }: SvgLogoProps) {
const { t } = useTranslation();
const config = sizeConfig[size]; const config = sizeConfig[size];
return ( return <img src="/officer-logo.svg" alt={t('logo.icon')} style={{ width: config.width, height: 'auto' }} />;
<img
src="/officer-logo.svg"
alt="Officer Icon"
style={{ width: config.width, height: 'auto' }}
/>
);
} }
const sizeConfig = { const sizeConfig = {
@@ -1,3 +1,5 @@
import { useTranslation } from '@/components/locales';
type SvgLogoProps = { type SvgLogoProps = {
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'; size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl';
square?: boolean; square?: boolean;
@@ -12,27 +14,17 @@ export function OfficerSvgLogo({ size = 'xl', square = false }: SvgLogoProps) {
} }
export function OfficerSvgLogoRegular({ size = 'xl' }: SvgLogoProps) { export function OfficerSvgLogoRegular({ size = 'xl' }: SvgLogoProps) {
const { t } = useTranslation();
const config = sizeConfig.regular[size]; const config = sizeConfig.regular[size];
return ( return <img src="/officer-logo.svg" alt={t('logo.logo')} style={{ width: config.width, height: 'auto' }} />;
<img
src="/officer-logo.svg"
alt="Officer Logo"
style={{ width: config.width, height: 'auto' }}
/>
);
} }
export function OfficerSvgLogoSquare({ size = 'xl' }: SvgLogoProps) { export function OfficerSvgLogoSquare({ size = 'xl' }: SvgLogoProps) {
const { t } = useTranslation();
const config = sizeConfig.square[size]; const config = sizeConfig.square[size];
return ( return <img src="/officer-logo.svg" alt={t('logo.logo')} style={{ width: config.width, height: 'auto' }} />;
<img
src="/officer-logo.svg"
alt="Officer Logo"
style={{ width: config.width, height: 'auto' }}
/>
);
} }
const sizeConfig = { const sizeConfig = {
+10 -8
View File
@@ -3,6 +3,7 @@ import { Button } from '@/components/ui/button';
import { Search as SearchIcon, X as ClearIcon } from 'lucide-react'; import { Search as SearchIcon, X as ClearIcon } from 'lucide-react';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { cn } from 'helpers/cn'; import { cn } from 'helpers/cn';
import { useTranslation } from '@/components/locales';
type SearchInputProps = { type SearchInputProps = {
className?: string; className?: string;
@@ -12,7 +13,8 @@ type SearchInputProps = {
}; };
export function SearchInput(props: 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 [isMobileSearchOpen, setIsMobileSearchOpen] = useState(false);
const inputRef = useRef<HTMLInputElement | null>(null); const inputRef = useRef<HTMLInputElement | null>(null);
@@ -22,14 +24,14 @@ export function SearchInput(props: SearchInputProps) {
type="button" type="button"
variant="outline" variant="outline"
size="icon" size="icon"
aria-label={isMobileSearchOpen ? 'Close search' : 'Open search'} aria-label={isMobileSearchOpen ? t('search.close') : t('search.open')}
onClick={() => { onClick={() => {
setIsMobileSearchOpen((v) => !v); setIsMobileSearchOpen((v) => !v);
if (!isMobileSearchOpen) { if (!isMobileSearchOpen) {
setTimeout(() => inputRef.current?.focus(), 160); 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" />} {isMobileSearchOpen ? <ClearIcon className="h-5 w-5" /> : <SearchIcon className="h-5 w-5" />}
</Button> </Button>
@@ -37,7 +39,7 @@ export function SearchInput(props: SearchInputProps) {
className={cn( className={cn(
'relative w-fit max-w-lg', 'relative w-fit max-w-lg',
isMobileSearchOpen 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', : 'hidden w-auto md:block',
className, className,
)} )}
@@ -45,14 +47,14 @@ export function SearchInput(props: SearchInputProps) {
{!!value ? ( {!!value ? (
<button <button
type="button" type="button"
aria-label="Clear search" aria-label={t('search.clear')}
onClick={() => handleSearch('')} 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" /> <ClearIcon className="h-4 w-4 text-muted-foreground" />
</button> </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" /> <SearchIcon className="text-muted-foreground h-4 w-4" />
</span> </span>
)} )}
@@ -62,7 +64,7 @@ export function SearchInput(props: SearchInputProps) {
placeholder={placeholder} placeholder={placeholder}
value={value} value={value}
onChange={(ev) => handleSearch(ev.target.value)} onChange={(ev) => handleSearch(ev.target.value)}
className="pl-10 pr-4 text-sm" className="ps-10 pe-4 text-sm"
/> />
</div> </div>
</div> </div>
+3 -1
View File
@@ -1,10 +1,12 @@
import { Select as ShadSelect, SelectContent, SelectGroup } from '@/components/ui/select'; import { Select as ShadSelect, SelectContent, SelectGroup } from '@/components/ui/select';
import { SelectItem, SelectLabel, SelectTrigger, SelectValue } from '@/components/ui/select'; import { SelectItem, SelectLabel, SelectTrigger, SelectValue } from '@/components/ui/select';
import { cn } from 'helpers/cn'; import { cn } from 'helpers/cn';
import { useTranslation } from '@/components/locales';
export const Select = (props: SelectProps) => { export const Select = (props: SelectProps) => {
const { t } = useTranslation();
const { options, value, onChange, onValueChange, label } = props; 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; if (!options) return null;
+3 -1
View File
@@ -1,6 +1,7 @@
import { useRef } from 'react'; import { useRef } from 'react';
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select'; import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useTranslation } from '@/components/locales';
type TabsProps = { type TabsProps = {
tabList: { value: string; label: string }[]; tabList: { value: string; label: string }[];
@@ -8,6 +9,7 @@ type TabsProps = {
setActiveTab: (tab: string) => void; setActiveTab: (tab: string) => void;
}; };
const TabsComponent = (props: TabsProps) => { const TabsComponent = (props: TabsProps) => {
const { t } = useTranslation();
const { tabList, activeTab, setActiveTab } = props; const { tabList, activeTab, setActiveTab } = props;
const tabsListRef = useRef<HTMLDivElement>(null); const tabsListRef = useRef<HTMLDivElement>(null);
return ( return (
@@ -38,7 +40,7 @@ const TabsComponent = (props: TabsProps) => {
<div className="block lg:hidden"> <div className="block lg:hidden">
<Select value={activeTab} onValueChange={(v) => setActiveTab(v)}> <Select value={activeTab} onValueChange={(v) => setActiveTab(v)}>
<SelectTrigger className="w-40"> <SelectTrigger className="w-40">
<SelectValue placeholder="Filter" /> <SelectValue placeholder={t('tabs.filterPlaceholder')} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{tabList.map((f) => ( {tabList.map((f) => (
+74 -1
View File
@@ -1 +1,74 @@
{} {
"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"
},
"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"
}
}
+74 -1
View File
@@ -1 +1,74 @@
{} {
"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"
},
"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"
}
}
+3 -1
View File
@@ -1,12 +1,14 @@
import { Moon, Sun } from 'lucide-react'; import { Moon, Sun } from 'lucide-react';
import { cn } from 'helpers/cn'; import { cn } from 'helpers/cn';
import { useColorMode } from './ThemeProvider'; import { useColorMode } from './ThemeProvider';
import { useTranslation } from '@/components/locales';
type ThemeToggleProps = { colorDark?: boolean; colorLight?: boolean }; type ThemeToggleProps = { colorDark?: boolean; colorLight?: boolean };
export const ThemeToggle = (props: ThemeToggleProps) => { export const ThemeToggle = (props: ThemeToggleProps) => {
const { colorDark, colorLight } = props; const { colorDark, colorLight } = props;
const { colorMode, setColorMode } = useColorMode(); const { colorMode, setColorMode } = useColorMode();
const { t } = useTranslation();
const toggle = () => { const toggle = () => {
setColorMode(colorMode === 'dark' ? 'light' : 'dark'); setColorMode(colorMode === 'dark' ? 'light' : 'dark');
@@ -26,7 +28,7 @@ export const ThemeToggle = (props: ThemeToggleProps) => {
colorDark && 'text-black', colorDark && 'text-black',
)} )}
/> />
<span className="sr-only">Toggle color mode</span> <span className="sr-only">{t('theme.toggle')}</span>
</div> </div>
); );
}; };
+15 -11
View File
@@ -3,6 +3,7 @@ import { Slot } from '@radix-ui/react-slot';
import { ChevronRight, MoreHorizontal } from 'lucide-react'; import { ChevronRight, MoreHorizontal } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useTranslation } from '@/components/locales';
const Breadcrumb = React.forwardRef< const Breadcrumb = React.forwardRef<
HTMLElement, HTMLElement,
@@ -66,17 +67,20 @@ const BreadcrumbSeparator = ({ children, className, ...props }: React.ComponentP
); );
BreadcrumbSeparator.displayName = 'BreadcrumbSeparator'; BreadcrumbSeparator.displayName = 'BreadcrumbSeparator';
const BreadcrumbEllipsis = ({ className, ...props }: React.ComponentProps<'span'>) => ( const BreadcrumbEllipsis = ({ className, ...props }: React.ComponentProps<'span'>) => {
<span const { t } = useTranslation();
role="presentation" return (
aria-hidden="true" <span
className={cn('flex h-9 w-9 items-center justify-center', className)} role="presentation"
{...props} aria-hidden="true"
> className={cn('flex h-9 w-9 items-center justify-center', className)}
<MoreHorizontal className="h-4 w-4" /> {...props}
<span className="sr-only">More</span> >
</span> <MoreHorizontal className="h-4 w-4" />
); <span className="sr-only">{t('breadcrumb.more')}</span>
</span>
);
};
BreadcrumbEllipsis.displayName = 'BreadcrumbElipssis'; BreadcrumbEllipsis.displayName = 'BreadcrumbElipssis';
export { export {
+7 -4
View File
@@ -4,6 +4,7 @@ import { ArrowLeft, ArrowRight } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useTranslation } from '@/components/locales';
type CarouselApi = UseEmblaCarouselType[1]; type CarouselApi = UseEmblaCarouselType[1];
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>; type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
@@ -168,6 +169,7 @@ CarouselItem.displayName = 'CarouselItem';
const CarouselPrevious = React.forwardRef<HTMLButtonElement, React.ComponentProps<typeof Button>>( const CarouselPrevious = React.forwardRef<HTMLButtonElement, React.ComponentProps<typeof Button>>(
({ className, variant = 'outline', size = 'icon', ...props }, ref) => { ({ className, variant = 'outline', size = 'icon', ...props }, ref) => {
const { orientation, scrollPrev, canScrollPrev } = useCarousel(); const { orientation, scrollPrev, canScrollPrev } = useCarousel();
const { t } = useTranslation();
return ( return (
<Button <Button
@@ -177,7 +179,7 @@ const CarouselPrevious = React.forwardRef<HTMLButtonElement, React.ComponentProp
className={cn( className={cn(
'absolute h-8 w-8 rounded-full', 'absolute h-8 w-8 rounded-full',
orientation === 'horizontal' 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', : '-top-12 left-1/2 -translate-x-1/2 rotate-90',
className, className,
)} )}
@@ -186,7 +188,7 @@ const CarouselPrevious = React.forwardRef<HTMLButtonElement, React.ComponentProp
{...props} {...props}
> >
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
<span className="sr-only">Previous slide</span> <span className="sr-only">{t('carousel.previous')}</span>
</Button> </Button>
); );
}, },
@@ -196,6 +198,7 @@ CarouselPrevious.displayName = 'CarouselPrevious';
const CarouselNext = React.forwardRef<HTMLButtonElement, React.ComponentProps<typeof Button>>( const CarouselNext = React.forwardRef<HTMLButtonElement, React.ComponentProps<typeof Button>>(
({ className, variant = 'outline', size = 'icon', ...props }, ref) => { ({ className, variant = 'outline', size = 'icon', ...props }, ref) => {
const { orientation, scrollNext, canScrollNext } = useCarousel(); const { orientation, scrollNext, canScrollNext } = useCarousel();
const { t } = useTranslation();
return ( return (
<Button <Button
@@ -205,7 +208,7 @@ const CarouselNext = React.forwardRef<HTMLButtonElement, React.ComponentProps<ty
className={cn( className={cn(
'absolute h-8 w-8 rounded-full', 'absolute h-8 w-8 rounded-full',
orientation === 'horizontal' 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', : '-bottom-12 left-1/2 -translate-x-1/2 rotate-90',
className, className,
)} )}
@@ -214,7 +217,7 @@ const CarouselNext = React.forwardRef<HTMLButtonElement, React.ComponentProps<ty
{...props} {...props}
> >
<ArrowRight className="h-4 w-4" /> <ArrowRight className="h-4 w-4" />
<span className="sr-only">Next slide</span> <span className="sr-only">{t('carousel.next')}</span>
</Button> </Button>
); );
}, },
+23 -19
View File
@@ -3,6 +3,7 @@ import * as DialogPrimitive from '@radix-ui/react-dialog';
import { X } from 'lucide-react'; import { X } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useTranslation } from '@/components/locales';
const Dialog = DialogPrimitive.Root; const Dialog = DialogPrimitive.Root;
@@ -30,25 +31,28 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef< const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>, React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => ( >(({ className, children, ...props }, ref) => {
<DialogPortal> const { t } = useTranslation();
<DialogOverlay /> return (
<DialogPrimitive.Content <DialogPortal>
ref={ref} <DialogOverlay />
className={cn( <DialogPrimitive.Content
'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', ref={ref}
className, 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',
{...props} className,
> )}
{children} {...props}
<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" /> {children}
<span className="sr-only">Close</span> <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">
</DialogPrimitive.Close> <X className="h-4 w-4" />
</DialogPrimitive.Content> <span className="sr-only">{t('button.close')}</span>
</DialogPortal> </DialogPrimitive.Close>
)); </DialogPrimitive.Content>
</DialogPortal>
);
});
DialogContent.displayName = DialogPrimitive.Content.displayName; DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => ( const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
+3 -1
View File
@@ -3,10 +3,12 @@ import { Moon, Sun } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useColorMode } from '@/components/ui/ThemeProvider'; import { useColorMode } from '@/components/ui/ThemeProvider';
import { cn } from 'helpers/cn'; import { cn } from 'helpers/cn';
import { useTranslation } from '@/components/locales';
export const ModeToggle = React.forwardRef<React.ElementRef<typeof Button>, React.ComponentProps<typeof Button>>( export const ModeToggle = React.forwardRef<React.ElementRef<typeof Button>, React.ComponentProps<typeof Button>>(
({ className, ...props }, ref) => { ({ className, ...props }, ref) => {
const { colorMode, setColorMode } = useColorMode(); const { colorMode, setColorMode } = useColorMode();
const { t } = useTranslation();
return ( return (
<Button <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" /> <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" /> <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> </Button>
); );
}, },
+49 -26
View File
@@ -4,15 +4,19 @@ import { ChevronLeft, ChevronRight, MoreHorizontal } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { ButtonProps } from '@/components/ui/button'; import type { ButtonProps } from '@/components/ui/button';
import { buttonVariants } from '@/components/ui/button'; import { buttonVariants } from '@/components/ui/button';
import { useTranslation } from '@/components/locales';
const Pagination = ({ className, ...props }: React.ComponentProps<'nav'>) => ( const Pagination = ({ className, ...props }: React.ComponentProps<'nav'>) => {
<nav const { t } = useTranslation();
role="navigation" return (
aria-label="pagination" <nav
className={cn('mx-auto flex w-full justify-center', className)} role="navigation"
{...props} aria-label={t('pagination.label')}
/> className={cn('mx-auto flex w-full justify-center', className)}
); {...props}
/>
);
};
Pagination.displayName = 'Pagination'; Pagination.displayName = 'Pagination';
const PaginationContent = React.forwardRef<HTMLUListElement, React.ComponentProps<'ul'>>( const PaginationContent = React.forwardRef<HTMLUListElement, React.ComponentProps<'ul'>>(
@@ -47,28 +51,47 @@ const PaginationLink = ({ className, isActive, size = 'icon', ...props }: Pagina
); );
PaginationLink.displayName = 'PaginationLink'; PaginationLink.displayName = 'PaginationLink';
const PaginationPrevious = ({ className, ...props }: React.ComponentProps<typeof 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}> const { t } = useTranslation();
<ChevronLeft className="h-4 w-4" /> return (
<span>Previous</span> <PaginationLink
</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'; PaginationPrevious.displayName = 'PaginationPrevious';
const PaginationNext = ({ className, ...props }: React.ComponentProps<typeof PaginationLink>) => ( 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}> const { t } = useTranslation();
<span>Next</span> return (
<ChevronRight className="h-4 w-4" /> <PaginationLink
</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'; PaginationNext.displayName = 'PaginationNext';
const PaginationEllipsis = ({ className, ...props }: React.ComponentProps<'span'>) => ( const PaginationEllipsis = ({ className, ...props }: React.ComponentProps<'span'>) => {
<span aria-hidden className={cn('flex h-9 w-9 items-center justify-center', className)} {...props}> const { t } = useTranslation();
<MoreHorizontal className="h-4 w-4" /> return (
<span className="sr-only">More pages</span> <span aria-hidden className={cn('flex h-9 w-9 items-center justify-center', className)} {...props}>
</span> <MoreHorizontal className="h-4 w-4" />
); <span className="sr-only">{t('pagination.morePages')}</span>
</span>
);
};
PaginationEllipsis.displayName = 'PaginationEllipsis'; PaginationEllipsis.displayName = 'PaginationEllipsis';
export { export {
+16 -12
View File
@@ -4,6 +4,7 @@ import { X } from 'lucide-react';
import * as React from 'react'; import * as React from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useTranslation } from '@/components/locales';
const Sheet = SheetPrimitive.Root; const Sheet = SheetPrimitive.Root;
@@ -51,18 +52,21 @@ interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>, VariantProps<typeof sheetVariants> {} extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>, VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<React.ElementRef<typeof SheetPrimitive.Content>, SheetContentProps>( const SheetContent = React.forwardRef<React.ElementRef<typeof SheetPrimitive.Content>, SheetContentProps>(
({ side = 'right', className, children, ...props }, ref) => ( ({ side = 'right', className, children, ...props }, ref) => {
<SheetPortal> const { t } = useTranslation();
<SheetOverlay /> return (
<SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}> <SheetPortal>
{children} <SheetOverlay />
<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"> <SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
<X className="h-4 w-4" /> {children}
<span className="sr-only">Close</span> <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">
</SheetPrimitive.Close> <X className="h-4 w-4" />
</SheetPrimitive.Content> <span className="sr-only">{t('button.close')}</span>
</SheetPortal> </SheetPrimitive.Close>
), </SheetPrimitive.Content>
</SheetPortal>
);
},
); );
SheetContent.displayName = SheetPrimitive.Content.displayName; SheetContent.displayName = SheetPrimitive.Content.displayName;