Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b0b671895 | ||
|
|
2ec3f11cdd | ||
|
|
5ce1a2928e | ||
|
|
339d31f05c | ||
|
|
a444280cb4 |
@@ -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.
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
<Shell>
|
<Shell>
|
||||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||||
<p className="text-sm text-muted-foreground">{label}</p>
|
<p className="text-sm text-muted-foreground">{label ?? t('state.loading')}</p>
|
||||||
</Shell>
|
</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 = {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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) => (
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,7 +67,9 @@ 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'>) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
<span
|
<span
|
||||||
role="presentation"
|
role="presentation"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
@@ -74,9 +77,10 @@ const BreadcrumbEllipsis = ({ className, ...props }: React.ComponentProps<'span'
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
<span className="sr-only">More</span>
|
<span className="sr-only">{t('breadcrumb.more')}</span>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
BreadcrumbEllipsis.displayName = 'BreadcrumbElipssis';
|
BreadcrumbEllipsis.displayName = 'BreadcrumbElipssis';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,7 +31,9 @@ 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) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
<DialogPortal>
|
<DialogPortal>
|
||||||
<DialogOverlay />
|
<DialogOverlay />
|
||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
@@ -42,13 +45,14 @@ const DialogContent = React.forwardRef<
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{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">
|
<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" />
|
<X className="h-4 w-4" />
|
||||||
<span className="sr-only">Close</span>
|
<span className="sr-only">{t('button.close')}</span>
|
||||||
</DialogPrimitive.Close>
|
</DialogPrimitive.Close>
|
||||||
</DialogPrimitive.Content>
|
</DialogPrimitive.Content>
|
||||||
</DialogPortal>
|
</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,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>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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'>) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
<nav
|
<nav
|
||||||
role="navigation"
|
role="navigation"
|
||||||
aria-label="pagination"
|
aria-label={t('pagination.label')}
|
||||||
className={cn('mx-auto flex w-full justify-center', className)}
|
className={cn('mx-auto flex w-full justify-center', className)}
|
||||||
{...props}
|
{...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();
|
||||||
|
return (
|
||||||
|
<PaginationLink
|
||||||
|
aria-label={t('pagination.goToPrevious')}
|
||||||
|
size="default"
|
||||||
|
className={cn('gap-1 ps-2.5', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
<ChevronLeft className="h-4 w-4" />
|
<ChevronLeft className="h-4 w-4" />
|
||||||
<span>Previous</span>
|
<span>{t('pagination.previous')}</span>
|
||||||
</PaginationLink>
|
</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 (
|
||||||
|
<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" />
|
<ChevronRight className="h-4 w-4" />
|
||||||
</PaginationLink>
|
</PaginationLink>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
PaginationNext.displayName = 'PaginationNext';
|
PaginationNext.displayName = 'PaginationNext';
|
||||||
|
|
||||||
const PaginationEllipsis = ({ className, ...props }: React.ComponentProps<'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}>
|
<span aria-hidden className={cn('flex h-9 w-9 items-center justify-center', className)} {...props}>
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
<span className="sr-only">More pages</span>
|
<span className="sr-only">{t('pagination.morePages')}</span>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
PaginationEllipsis.displayName = 'PaginationEllipsis';
|
PaginationEllipsis.displayName = 'PaginationEllipsis';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
<SheetPortal>
|
<SheetPortal>
|
||||||
<SheetOverlay />
|
<SheetOverlay />
|
||||||
<SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
|
<SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
|
||||||
{children}
|
{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">
|
<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" />
|
<X className="h-4 w-4" />
|
||||||
<span className="sr-only">Close</span>
|
<span className="sr-only">{t('button.close')}</span>
|
||||||
</SheetPrimitive.Close>
|
</SheetPrimitive.Close>
|
||||||
</SheetPrimitive.Content>
|
</SheetPrimitive.Content>
|
||||||
</SheetPortal>
|
</SheetPortal>
|
||||||
),
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
SheetContent.displayName = SheetPrimitive.Content.displayName;
|
SheetContent.displayName = SheetPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user