fix(ui): align semantic headings contrast and Settings panels
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
# P6-01 — Shared design-system corrections
|
||||
|
||||
Current owner's P6-01 follows [P6-00 audit](../p6-00/README.md). The earlier README/FINDINGS in this folder retain their historical checkpoint meaning. Baseline application source is `9d6d29d`; all existing dirty company/i18n/toast/browser changes were preserved. Parent execution log owns final commit hashes, checkpoint pushes and remote verification.
|
||||
|
||||
## Bounded changes
|
||||
|
||||
| Audit finding | Existing owners / correction | Protected behavior and focused evidence |
|
||||
|---|---|---|
|
||||
| P6-DC01 | CareerProfilePage and ProfilePage Avatar use existing primary.main/primary.contrastText instead of default grey/white. | Image upload/crop/remove and identity source unchanged. Two initials tests prove rendered default-theme contrast reaches 4.5; real app themes are separately measured by parent's native evidence. |
|
||||
| P6-DC02 | Login root gains main; existing h5-style title explicitly renders h1. | Sign-in/register/provider/verification/2FA/remember-me payloads and routes unchanged. Two mode cases identify title inside main. |
|
||||
| P6-DC03 | Career/Profile/Admin contextual titles/sections get explicit h2/h3/h4; metric values remain styled text. CV Content Sections becomes h3 under existing h2 editing region. Career field-group headings are h4; raw accordion subtitle is span inside existing MUI heading. | Styling/font/spacing remain approved. No global variant mapping or print renderer edit. Career/Admin actual heading sequence, account hierarchy and CV no-write test preserve route-specific context. Existing Admin audit/users cases now require contextual level-two title. |
|
||||
| P6-DC04 | Settings existing tabs link settings-tab-0…3 to settings-panel-0…3. Existing active-only TabPanel gets role, labelledby and translated label fallback. | Inactive panel unmount behavior, Rules session/draft owner, save/discard and mobile selector unchanged. Test traverses all four sections and phone selector. |
|
||||
|
||||
Every changed presentational owner uses MUI/Emotion and existing translations/tokens. No new component framework, font, option, form model, save model, navigation or information architecture was introduced. AppShell is unchanged: the parent's settled native naming proof must determine whether historical transitional flags represent a defect. P5-13 stays withheld/deferred; no model run, AI activation or renderer/pagination modification occurs.
|
||||
|
||||
## Test-first record and current evidence
|
||||
|
||||
Local raw logs and exact Jest JSON remain under `.artifacts/phase6/current/design/`. They are execution artifacts, not portable browser/human qualification.
|
||||
|
||||
- `red.json`: initial eight new cases fail. Initial test setup also used an incorrect CV label and expected DU instead of actual unchanged DD initials; these setup errors are retained rather than called product regressions.
|
||||
- `red-corrected.json` / `red-initials.json`: corrected CV semantics and both Avatar rendered pairs fail for the intended missing feature; initials default pair is 1.878 in the default MUI/JSDOM test environment, distinct from historical real-app ratios.
|
||||
- `red-admin.json`: contextual title assertions fail before Admin source changes.
|
||||
- `red-hierarchy.json`: after actual Career data loads, descendant h6 skips h3; explicit contextual field-group semantics repair it. Earlier wrong Career label and early empty field setup are retained in intermediate logs.
|
||||
- `green.json`: first seven-suite run has 117 passing / 2 failures. One was the corrected Career test setup; the new Admin test also required its own jest-dom matcher import. No product assertion was relaxed.
|
||||
- `green-final.json` and `green-semantic.json`: detect a remaining genuine Admin latest-summary subtitle2=h6 after h4. Its semantic component is corrected to h4.
|
||||
- **`green-race-semantic.json`: 30/30 across complete Profile, AdminSystem and I18n suites, zero failed/skipped/todo.** Other six complete suites in the prior focused run pass their unchanged tests; parent's fresh integrated full-suite run is authoritative final acceptance after the last source edit.
|
||||
- Whitespace check passes. Final type/lint/build, native theme/locale/reflow, complete browser and full regression evidence belong to the parent execution manifest, not an inferred PASS here.
|
||||
|
||||
Added semantic tests: Login 2, Settings 1, meaningful initials 2, Career hierarchy 1, account hierarchy 1, CV hierarchy/no-write 1, AdminSystem hierarchy 1 = **9**; AdminUsers/Audit existing cases were strengthened without changing counts. Existing dirty feedback tests and final full-suite count reconciliation are separately owned by P6-02/final acceptance.
|
||||
|
||||
## P6-02 dependency discovered during independent review
|
||||
|
||||
The existing dirty language-feedback repair could deliver an older failure after a newer successful choice, or keep an earlier Retry that reapplied an obsolete language. Independent review reproduced both cases before fixing them. `red-language-race.json` records two actual failures: obsolete alert delivery and EN reverting to NB through stale Retry.
|
||||
|
||||
I18nProvider now keeps the current choice and latest preference-request sequence at the existing owner. Both failure delivery and Retry check those values; ordinary account save still uses the same endpoint/payload, local choice still changes immediately, server hydration still avoids a write, and storage changes update the current-choice guard. `green-race-semantic.json` includes both passing regressions in the complete six-case I18n suite. **Two additional race tests** are counted in P6-02, separate from nine semantic cases. This is a frontend stale-feedback repair, not a diagnosis or repair of historical database preference500 contention.
|
||||
|
||||
Company failed-save Alert/draft/retry and consistent list/card date locales were independently reviewed as bounded existing-owner corrections. Their final focused/browser results are parent-owned; no duplicate source edits were made here.
|
||||
|
||||
## Preservation and outcome
|
||||
|
||||
Applicable traces: PRES-E02/E06/E07/E08, C06–C09, S01/S03/S04; canonical profile/CV override/save owners and authorization remain untouched. All 57 inherited groups remain contractual; no group is promoted to full PASS from finite semantic tests. P2-D01 must remain ordinary passing in final native regression.
|
||||
|
||||
Bounded corrections are ready for integrated validation. Human screen-reader, physical touch/software keyboard, production-equivalent font/photo, contextual preview main/incomplete axe and operational release evidence remain unverified. Full Phase 7 visual migration and Phase 8 customization were not attempted; Production remains NO-GO.
|
||||
|
||||
## Native error localisation correction
|
||||
|
||||
The first native company error cases actually rendered an Alert and retained the draft, but exposed `Request failed with status code 500` in both languages instead of the caller's translated fallback. The unit API stub had concealed this shared-helper difference. Current source has126 `getApiErrorMessage` call sites; the root is corrected once after existing server-validation/message extraction: ordinary Axios transport failures use the caller's fallback, while structured server explanations, sanitisation, explicit cancellation/session and ordinary application errors are retained. Five real-helper cases cover both locales, HTTP/network/timeout, validation, HTML, cancellation and application errors:2RED/3already passing→5GREEN. Complete-suite/native affected-route validation is repeated after this final helper edit; no assertion is relaxed to accept mixed-language transport text.
|
||||
@@ -0,0 +1,11 @@
|
||||
# Shared correction checkpoint result
|
||||
|
||||
Current source after the final transport helper repair: complete frontend **521/521 in85 suites**, no failed/pending/todo tests. TypeScript, browser-coverage TypeScript, lint and production build pass. The native Settings association/reflow subset passes4/4 (1920/360/1280/390, both themes/languages), and native company/language failure→retained draft/local choice→retry→real save/reload passes4/4. Exact commands/zero retry outcomes are in [semantic evidence](../p6-05/evidence/semantics-results.json) and [feedback evidence](../p6-05/evidence/feedback-results.json); the final aggregate manifest supplements full cross-route regression.
|
||||
|
||||
The authoritative Phase5 unit baseline498 grows by23:9 semantic cases,4 retained feedback cases,2 stale-language races,2 company locale cases,1 toast/fetch baseline case and5 real API-helper transport/protection cases. Existing AdminUsers/Audit cases are strengthened without increasing counts. Two new test files produce83→85 suites. Focused subset cases are not added again to521.
|
||||
|
||||
Independent source/caller review found no remaining application regression. Review findings in the performance diagnostic were corrected before final native execution: wait for every visible CV page, derive actual serving/reuse metadata, and describe count/API-to-DOM parity precisely. No speculative toast refetch optimisation was justified by the passing real-consumer baseline.
|
||||
|
||||
The early full attempt ran while final test/source corrections were still being completed and recorded4 failures (test matcher/fixture and two intended stale-language REDs). Its raw result and every focused RED remain retained; the source-frozen final521 run supersedes it. First native feedback run recorded2 real generic-English500 failures, motivating the shared helper correction; final4/4 passes exact localized messages. No assertion was relaxed, skip introduced or failure reclassified as expected.
|
||||
|
||||
Outcome: **bounded corrections verified; conditional whole-application acceptance remains for P6-05**. Human/device/production and deferred domain gates remain open. Commit/push hash is recorded by the successor in [COMMITS](../COMMITS.md).
|
||||
@@ -27,6 +27,8 @@ test("distinguishes an unavailable audit log from an empty audit log and retries
|
||||
mockedApi.get.mockRejectedValueOnce(new Error("offline"));
|
||||
renderPage();
|
||||
|
||||
expect(screen.getByRole("heading", { name: "Audit log", level: 2 })).toBeInTheDocument();
|
||||
|
||||
expect(await screen.findByText("Unable to load audit events")).toBeInTheDocument();
|
||||
expect(screen.queryByText("No events.")).not.toBeInTheDocument();
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
import AdminSystemPage from './views/AdminSystemPage';
|
||||
@@ -166,6 +167,19 @@ describe('AdminSystemPage', () => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('uses contextual system headings and keeps metric values out of heading navigation', async () => {
|
||||
render(<I18nProvider><AdminSystemPage /></I18nProvider>);
|
||||
await screen.findByText('Production');
|
||||
expect(screen.getByRole('heading', { name: 'System status', level: 2 })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('heading', { name: 'Production' })).not.toBeInTheDocument();
|
||||
const levels = screen.getAllByRole('heading').map(heading => Number(heading.tagName.slice(1)));
|
||||
let previous = 1;
|
||||
for (const level of levels) {
|
||||
expect(level).toBeLessThanOrEqual(previous + 1);
|
||||
previous = level;
|
||||
}
|
||||
});
|
||||
|
||||
it('renders AI service health, latency, and OCR readiness', async () => {
|
||||
render(
|
||||
<I18nProvider>
|
||||
|
||||
@@ -54,6 +54,8 @@ beforeEach(() => {
|
||||
test("requires explicit confirmation before removing your own admin role", async () => {
|
||||
renderPage([{ id: "me", email: "me@example.com", userName: "me", roles: ["Admin"], emailConfirmed: true, isCurrentUser: true, canRemoveAdmin: true }]);
|
||||
|
||||
expect(screen.getByRole("heading", { name: "Users", level: 2 })).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Remove admin" }));
|
||||
expect(await screen.findByText(/you will immediately lose access/i)).toBeInTheDocument();
|
||||
|
||||
|
||||
@@ -40,9 +40,9 @@ interface Props {
|
||||
onThemeModeChange: (v: ThemeModePref) => void;
|
||||
}
|
||||
|
||||
function TabPanel({ value, index, children }: { value: number; index: number; children: React.ReactNode }) {
|
||||
function TabPanel({ value, index, label, children }: { value: number; index: number; label: string; children: React.ReactNode }) {
|
||||
if (value !== index) return null;
|
||||
return <Box sx={{ mt: "var(--app-section-gap)", minWidth: 0 }}>{children}</Box>;
|
||||
return <Box role="tabpanel" id={`settings-panel-${index}`} aria-labelledby={`settings-tab-${index}`} aria-label={label} sx={{ mt: "var(--app-section-gap)", minWidth: 0 }}>{children}</Box>;
|
||||
}
|
||||
|
||||
type NotificationPrefs = {
|
||||
@@ -175,15 +175,15 @@ export default function SettingsView({
|
||||
</FormControl>
|
||||
|
||||
<Tabs aria-label={t("settings")} value={tab} onChange={(_, v) => selectSection(v)} variant="scrollable" scrollButtons="auto" sx={{ display: { xs: "none", sm: "flex" }, mb: 1 }}>
|
||||
<Tab label={t("settingsTabGeneral")} />
|
||||
<Tab label={t("settingsTabFollowUps")} />
|
||||
<Tab label={t("settingsTabNotifications")} />
|
||||
<Tab label={t("settingsTabBackup")} />
|
||||
<Tab id="settings-tab-0" aria-controls="settings-panel-0" label={t("settingsTabGeneral")} />
|
||||
<Tab id="settings-tab-1" aria-controls="settings-panel-1" label={t("settingsTabFollowUps")} />
|
||||
<Tab id="settings-tab-2" aria-controls="settings-panel-2" label={t("settingsTabNotifications")} />
|
||||
<Tab id="settings-tab-3" aria-controls="settings-panel-3" label={t("settingsTabBackup")} />
|
||||
</Tabs>
|
||||
|
||||
{rulesDirty ? <Typography role="status" variant="body2" sx={{ mt: 1, color: "text.secondary" }}>{t("assetsUnsavedChanges")}. {t("rulesDraftScope")}</Typography> : null}
|
||||
|
||||
<TabPanel value={tab} index={0}>
|
||||
<TabPanel value={tab} index={0} label={t("settingsTabGeneral")}>
|
||||
<Box sx={{ display: "grid", gap: "var(--app-section-gap)", minWidth: 0 }}>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "repeat(2, minmax(0, 1fr))" }, gap: "var(--app-section-gap)" }}>
|
||||
<PageSection surface title={t("settingsAppearance")}>
|
||||
@@ -273,7 +273,7 @@ export default function SettingsView({
|
||||
</Box>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value={tab} index={1}>
|
||||
<TabPanel value={tab} index={1} label={t("settingsTabFollowUps")}>
|
||||
<RulesSettingsCard draft={rulesDraft} loadError={rulesLoadError} saveError={rulesSaveError} invalid={invalidRules}
|
||||
saving={savingRules} dirty={rulesDirty} onSave={() => void saveRules()} onRetry={() => void loadRules()} onDiscard={discardRules}
|
||||
onChange={(field, value) => {
|
||||
@@ -282,7 +282,7 @@ export default function SettingsView({
|
||||
}} />
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value={tab} index={2}>
|
||||
<TabPanel value={tab} index={2} label={t("settingsTabNotifications")}>
|
||||
<PageSection surface title={t("settingsNotificationsTitle")} description={t("settingsNotificationsBody")}>
|
||||
{notificationError ? <Alert severity="error" sx={{ mb: 1.5 }}>{notificationError}</Alert> : null}
|
||||
{!notificationPrefs ? <Skeleton variant="rounded" height={70} /> : <Box sx={{ display: "grid", gap: 1 }}>
|
||||
@@ -302,7 +302,7 @@ export default function SettingsView({
|
||||
</PageSection>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value={tab} index={3}>
|
||||
<TabPanel value={tab} index={3} label={t("settingsTabBackup")}>
|
||||
<BackupCard />
|
||||
</TabPanel>
|
||||
</Box>
|
||||
|
||||
@@ -81,6 +81,15 @@ async function openContentTab() {
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Content' }));
|
||||
}
|
||||
|
||||
test('content sections follow the editing-region heading without mutating CV settings', async () => {
|
||||
routeGet(() => Promise.resolve({ data: variant }));
|
||||
renderAt(3);
|
||||
await openContentTab();
|
||||
expect(screen.getByRole('heading', { name: 'CV editing controls', level: 2 })).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: 'Sections', level: 3 })).toBeInTheDocument();
|
||||
expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('section navigation opens and focuses omitted/custom sections without changing their data', async () => {
|
||||
const settings = { ...variant.settings,
|
||||
sections: [{ key: 'skills', hidden: true }, { key: 'custom:sample', hidden: false }],
|
||||
|
||||
@@ -48,6 +48,13 @@ async function click(element: Element) {
|
||||
}
|
||||
|
||||
describe('LoginPage', () => {
|
||||
it.each(['login', 'register'] as const)('provides a main landmark and level-one heading for %s', async (mode) => {
|
||||
renderLoginPage(mode);
|
||||
await screen.findByLabelText(mode === 'login' ? 'Username or email' : 'Email');
|
||||
const title = screen.getByRole('heading', { name: mode === 'login' ? 'Sign in' : 'Create account', level: 1 });
|
||||
expect(screen.getByRole('main')).toContainElement(title);
|
||||
});
|
||||
|
||||
it('renders a dedicated registration screen and explains when registration is disabled', async () => {
|
||||
mockedApi.get.mockResolvedValueOnce({
|
||||
data: { requireAuth: true, googleEnabled: false, microsoftEnabled: false, localEnabled: true, allowRegistration: false, requireEmailVerification: true },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { getContrastRatio } from '@mui/material/styles';
|
||||
import { ToastProvider } from './toast';
|
||||
import { I18nProvider } from './i18n/I18nProvider';
|
||||
import ProfilePage from './views/ProfilePage';
|
||||
@@ -192,6 +193,36 @@ afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test.each([CareerProfilePage, ProfilePage])('meaningful identity initials use a readable foreground/background pair in %p', async (Component) => {
|
||||
window.history.replaceState({}, '', Component === CareerProfilePage ? '/career?section=profile' : '/profile');
|
||||
renderWith(Component);
|
||||
const avatar = await screen.findByText('DD');
|
||||
const style = window.getComputedStyle(avatar);
|
||||
expect(getContrastRatio(style.color, style.backgroundColor)).toBeGreaterThanOrEqual(4.5);
|
||||
});
|
||||
|
||||
test('Career Profile section headings describe the visible hierarchy without changing editing fields', async () => {
|
||||
renderPage();
|
||||
await waitFor(() => expect(screen.getByLabelText(/full name/i)).toHaveValue('Demo User'));
|
||||
expect(screen.getByRole('heading', { name: 'Career profile', level: 2 })).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: 'Career information', level: 3 })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/full name/i)).toHaveValue('Demo User');
|
||||
let previous = 1;
|
||||
for (const heading of screen.getAllByRole('heading')) {
|
||||
const level = Number(heading.tagName.slice(1));
|
||||
expect(level).toBeLessThanOrEqual(previous + 1);
|
||||
previous = level;
|
||||
}
|
||||
});
|
||||
|
||||
test('account headings separate identity from security sections', async () => {
|
||||
renderWith(ProfilePage);
|
||||
await screen.findByText('DD');
|
||||
expect(screen.getByRole('heading', { name: 'Profile', level: 2 })).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: 'Account', level: 3 })).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: 'Change password', level: 3 })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('profile page loads persisted structured cv and can re-parse it', async () => {
|
||||
renderPage();
|
||||
|
||||
|
||||
@@ -102,6 +102,22 @@ afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('each selected Settings section has a named panel linked to its tab and remains selectable on phones', async () => {
|
||||
renderView();
|
||||
await screen.findByText(/AI privacy/i);
|
||||
const tabs = screen.getAllByRole('tab');
|
||||
for (const tab of tabs) {
|
||||
await act(async () => { fireEvent.click(tab); });
|
||||
const panel = screen.getByRole('tabpanel', { name: tab.textContent! });
|
||||
expect(tab.id).not.toBe('');
|
||||
expect(tab).toHaveAttribute('aria-controls', panel.id);
|
||||
expect(panel).toHaveAttribute('aria-labelledby', tab.id);
|
||||
}
|
||||
fireEvent.mouseDown(screen.getByRole('combobox', { name: 'Settings section' }));
|
||||
fireEvent.click(screen.getByRole('option', { name: 'General' }));
|
||||
expect(screen.getByRole('tabpanel', { name: 'General' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('settings view has no accent picker and uses one follow-up section, one notification section', async () => {
|
||||
renderView();
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ export default function AdminAuditPage() {
|
||||
|
||||
return (
|
||||
<Paper sx={{ mt: 0, p: 2 }}>
|
||||
<Typography variant="h5" sx={{ fontWeight: 900, mb: 1 }}>
|
||||
<Typography component="h2" variant="h5" sx={{ fontWeight: 900, mb: 1 }}>
|
||||
{t("adminAuditTitle")}
|
||||
</Typography>
|
||||
<Typography sx={{ color: "text.secondary", mb: 2 }}>
|
||||
|
||||
@@ -203,7 +203,7 @@ function SummaryCard({ title, value, subtitle, tone = "default" }: { title: stri
|
||||
return (
|
||||
<Paper sx={{ p: 2, borderRadius: 3 }}>
|
||||
<Typography variant="overline" sx={{ color: "text.secondary" }}>{title}</Typography>
|
||||
<Typography variant="h5" sx={{ fontWeight: 950, color }}>{value}</Typography>
|
||||
<Typography component="p" variant="h5" sx={{ fontWeight: 950, color }}>{value}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mt: 1 }}>{subtitle || "-"}</Typography>
|
||||
</Paper>
|
||||
);
|
||||
@@ -368,7 +368,7 @@ export default function AdminSystemPage() {
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap" }}>
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 950 }}>{t("adminSystemTitle")}</Typography>
|
||||
<Typography component="h2" variant="h5" sx={{ fontWeight: 950 }}>{t("adminSystemTitle")}</Typography>
|
||||
<Typography sx={{ color: "text.secondary" }}>{t("adminSystemSubtitle")}</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
||||
@@ -439,7 +439,7 @@ export default function AdminSystemPage() {
|
||||
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", lg: "1.2fr 1fr" }, gap: 2 }}>
|
||||
<Paper sx={{ p: 2, borderRadius: 3 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 900, mb: 1 }}>{t("adminSystemDatabaseStorage")}</Typography>
|
||||
<Typography component="h3" variant="h6" sx={{ fontWeight: 900, mb: 1 }}>{t("adminSystemDatabaseStorage")}</Typography>
|
||||
<Stack spacing={0.75}>
|
||||
<DetailRow label={t("adminSystemProvider")} value={status?.database.provider || "-"} />
|
||||
<DetailRow label={t("adminSystemTarget")} value={status?.database.target || "-"} />
|
||||
@@ -457,7 +457,7 @@ export default function AdminSystemPage() {
|
||||
</Paper>
|
||||
|
||||
<Paper sx={{ p: 2, borderRadius: 3 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 900, mb: 1 }}>{t("adminSystemRuntimeAuth")}</Typography>
|
||||
<Typography component="h3" variant="h6" sx={{ fontWeight: 900, mb: 1 }}>{t("adminSystemRuntimeAuth")}</Typography>
|
||||
<Stack spacing={0.75}>
|
||||
<DetailRow label={t("adminSystemFramework")} value={status?.runtime.framework || "-"} />
|
||||
<DetailRow label={t("adminSystemOs")} value={status?.runtime.osDescription || "-"} />
|
||||
@@ -475,7 +475,7 @@ export default function AdminSystemPage() {
|
||||
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", lg: "1fr 1fr" }, gap: 2 }}>
|
||||
<Paper sx={{ p: 2, borderRadius: 3 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 900, mb: 1 }}>{t("adminSystemEmailConfig")}</Typography>
|
||||
<Typography component="h3" variant="h6" sx={{ fontWeight: 900, mb: 1 }}>{t("adminSystemEmailConfig")}</Typography>
|
||||
<Stack spacing={0.75}>
|
||||
<DetailRow label={t("adminSystemEnabled")} value={status?.email.enabled ? t("yes") : t("noWord")} />
|
||||
<DetailRow label={t("adminSystemFrom")} value={status?.email.from || "-"} />
|
||||
@@ -487,7 +487,7 @@ export default function AdminSystemPage() {
|
||||
</Paper>
|
||||
|
||||
<Paper sx={{ p: 2, borderRadius: 3 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 900, mb: 1 }}>{t("adminSystemSummarizerRuntime")}</Typography>
|
||||
<Typography component="h3" variant="h6" sx={{ fontWeight: 900, mb: 1 }}>{t("adminSystemSummarizerRuntime")}</Typography>
|
||||
<Stack spacing={0.75}>
|
||||
<DetailRow label={t("adminSystemModel")} value={status?.ai.model || "-"} />
|
||||
<DetailRow label={t("adminSystemDevice")} value={status?.ai.device || "-"} />
|
||||
@@ -509,16 +509,16 @@ export default function AdminSystemPage() {
|
||||
</Box>
|
||||
|
||||
<Paper sx={{ p: 2, borderRadius: 3 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 900, mb: 1 }}>{t("adminSystemSummarizerTelemetry")}</Typography>
|
||||
<Typography component="h3" variant="h6" sx={{ fontWeight: 900, mb: 1 }}>{t("adminSystemSummarizerTelemetry")}</Typography>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr 1fr", md: "repeat(8, 1fr)" }, gap: 2 }}>
|
||||
<Box><Typography variant="overline" sx={{ color: "text.secondary" }}>{t("adminSystemRequests")}</Typography><Typography variant="h6" sx={{ fontWeight: 900 }}>{status?.ai.requests ?? 0}</Typography></Box>
|
||||
<Box><Typography variant="overline" sx={{ color: "text.secondary" }}>{t("adminSystemCacheHits")}</Typography><Typography variant="h6" sx={{ fontWeight: 900 }}>{status?.ai.cacheHits ?? 0}</Typography></Box>
|
||||
<Box><Typography variant="overline" sx={{ color: "text.secondary" }}>{t("adminSystemCacheMisses")}</Typography><Typography variant="h6" sx={{ fontWeight: 900 }}>{status?.ai.cacheMisses ?? 0}</Typography></Box>
|
||||
<Box><Typography variant="overline" sx={{ color: "text.secondary" }}>{t("adminSystemFailures")}</Typography><Typography variant="h6" sx={{ fontWeight: 900 }}>{status?.ai.failures ?? 0}</Typography></Box>
|
||||
<Box><Typography variant="overline" sx={{ color: "text.secondary" }}>{t("adminSystemProbeFailures")}</Typography><Typography variant="h6" sx={{ fontWeight: 900 }}>{status?.ai.probeFailures ?? 0}</Typography></Box>
|
||||
<Box><Typography variant="overline" sx={{ color: "text.secondary" }}>{t("adminSystemAvgLatency")}</Typography><Typography variant="h6" sx={{ fontWeight: 900 }}>{status?.ai.averageLatencyMs != null ? `${status.ai.averageLatencyMs} ms` : "-"}</Typography></Box>
|
||||
<Box><Typography variant="overline" sx={{ color: "text.secondary" }}>{t("adminSystemOcrRequests")}</Typography><Typography variant="h6" sx={{ fontWeight: 900 }}>{status?.ai.ocrRequests ?? 0}</Typography></Box>
|
||||
<Box><Typography variant="overline" sx={{ color: "text.secondary" }}>{t("adminSystemOcrAvgLatency")}</Typography><Typography variant="h6" sx={{ fontWeight: 900 }}>{status?.ai.averageOcrLatencyMs != null ? `${status.ai.averageOcrLatencyMs} ms` : "-"}</Typography></Box>
|
||||
<Box><Typography variant="overline" sx={{ color: "text.secondary" }}>{t("adminSystemRequests")}</Typography><Typography component="p" variant="h6" sx={{ fontWeight: 900 }}>{status?.ai.requests ?? 0}</Typography></Box>
|
||||
<Box><Typography variant="overline" sx={{ color: "text.secondary" }}>{t("adminSystemCacheHits")}</Typography><Typography component="p" variant="h6" sx={{ fontWeight: 900 }}>{status?.ai.cacheHits ?? 0}</Typography></Box>
|
||||
<Box><Typography variant="overline" sx={{ color: "text.secondary" }}>{t("adminSystemCacheMisses")}</Typography><Typography component="p" variant="h6" sx={{ fontWeight: 900 }}>{status?.ai.cacheMisses ?? 0}</Typography></Box>
|
||||
<Box><Typography variant="overline" sx={{ color: "text.secondary" }}>{t("adminSystemFailures")}</Typography><Typography component="p" variant="h6" sx={{ fontWeight: 900 }}>{status?.ai.failures ?? 0}</Typography></Box>
|
||||
<Box><Typography variant="overline" sx={{ color: "text.secondary" }}>{t("adminSystemProbeFailures")}</Typography><Typography component="p" variant="h6" sx={{ fontWeight: 900 }}>{status?.ai.probeFailures ?? 0}</Typography></Box>
|
||||
<Box><Typography variant="overline" sx={{ color: "text.secondary" }}>{t("adminSystemAvgLatency")}</Typography><Typography component="p" variant="h6" sx={{ fontWeight: 900 }}>{status?.ai.averageLatencyMs != null ? `${status.ai.averageLatencyMs} ms` : "-"}</Typography></Box>
|
||||
<Box><Typography variant="overline" sx={{ color: "text.secondary" }}>{t("adminSystemOcrRequests")}</Typography><Typography component="p" variant="h6" sx={{ fontWeight: 900 }}>{status?.ai.ocrRequests ?? 0}</Typography></Box>
|
||||
<Box><Typography variant="overline" sx={{ color: "text.secondary" }}>{t("adminSystemOcrAvgLatency")}</Typography><Typography component="p" variant="h6" sx={{ fontWeight: 900 }}>{status?.ai.averageOcrLatencyMs != null ? `${status.ai.averageOcrLatencyMs} ms` : "-"}</Typography></Box>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 2 }}>
|
||||
<Chip label={status?.database.canConnect ? t("adminSystemDatabaseConnected") : t("adminSystemDatabaseIssue")} color={status?.database.canConnect ? "success" : "error"} size="small" />
|
||||
@@ -539,7 +539,7 @@ export default function AdminSystemPage() {
|
||||
<Paper sx={{ p: 2, borderRadius: 3 }}>
|
||||
<Box sx={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 2, flexWrap: "wrap", mb: 1 }}>
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ fontWeight: 900 }}>{t("adminBenchmarkTitle")}</Typography>
|
||||
<Typography component="h3" variant="h6" sx={{ fontWeight: 900 }}>{t("adminBenchmarkTitle")}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.25 }}>
|
||||
{cvBenchmarkJob?.description || t("adminBenchmarkRunScript")}
|
||||
</Typography>
|
||||
@@ -579,7 +579,7 @@ export default function AdminSystemPage() {
|
||||
|
||||
<Box sx={{ mt: 2.5, display: "grid", gridTemplateColumns: { xs: "1fr", xl: "1.1fr 0.9fr" }, gap: 2 }}>
|
||||
<Paper variant="outlined" sx={{ p: 1.5, borderRadius: 3 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 900, mb: 1 }}>{t("adminBenchmarkTopFindings")}</Typography>
|
||||
<Typography component="h4" variant="subtitle1" sx={{ fontWeight: 900, mb: 1 }}>{t("adminBenchmarkTopFindings")}</Typography>
|
||||
<Stack spacing={1}>
|
||||
{benchmarkFindings.length > 0 ? benchmarkFindings.map((finding) => (
|
||||
<Box key={`${finding.file}:${finding.issue}`} sx={{ p: 1.25, borderRadius: 2, backgroundColor: "background.default", border: "none", boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)" }}>
|
||||
@@ -591,7 +591,7 @@ export default function AdminSystemPage() {
|
||||
</Paper>
|
||||
|
||||
<Paper variant="outlined" sx={{ p: 1.5, borderRadius: 3 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 900, mb: 1 }}>{t("adminBenchmarkWeakest")}</Typography>
|
||||
<Typography component="h4" variant="subtitle1" sx={{ fontWeight: 900, mb: 1 }}>{t("adminBenchmarkWeakest")}</Typography>
|
||||
<Stack spacing={1}>
|
||||
{weakestEntries.map((entry) => (
|
||||
<Box key={entry.Slug} sx={{ p: 1.25, borderRadius: 2, backgroundColor: "background.default", border: "none", boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)" }}>
|
||||
@@ -609,7 +609,7 @@ export default function AdminSystemPage() {
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mt: 2, p: 1.5, borderRadius: 2, backgroundColor: "background.default", border: "none", boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)", maxHeight: 280, overflow: "auto" }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800, mb: 1 }}>{t("adminBenchmarkLatestSummary")}</Typography>
|
||||
<Typography component="h4" variant="subtitle2" sx={{ fontWeight: 800, mb: 1 }}>{t("adminBenchmarkLatestSummary")}</Typography>
|
||||
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap", fontFamily: "ui-monospace, SFMono-Regular, monospace" }}>
|
||||
{benchmarkStatus?.reportMarkdown || "-"}
|
||||
</Typography>
|
||||
@@ -627,7 +627,7 @@ export default function AdminSystemPage() {
|
||||
) : (
|
||||
<Box sx={{ display: "grid", gap: 2 }}>
|
||||
<Paper sx={{ p: 2, borderRadius: 3 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 900, mb: 1 }}>{t("adminSystemEmailSettingsTitle")}</Typography>
|
||||
<Typography component="h3" variant="h6" sx={{ fontWeight: 900, mb: 1 }}>{t("adminSystemEmailSettingsTitle")}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>{t("adminSystemEmailSettingsBody")}</Typography>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.5 }}>
|
||||
<FormControlLabel
|
||||
@@ -653,7 +653,7 @@ export default function AdminSystemPage() {
|
||||
</Paper>
|
||||
|
||||
<Paper sx={{ p: 2, borderRadius: 3 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 900, mb: 1 }}>{t("adminSystemSmtpTest")}</Typography>
|
||||
<Typography component="h3" variant="h6" sx={{ fontWeight: 900, mb: 1 }}>{t("adminSystemSmtpTest")}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>
|
||||
{t("adminSystemSmtpTestBody")}
|
||||
</Typography>
|
||||
|
||||
@@ -192,7 +192,7 @@ export default function AdminUsersPage() {
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: { xs: 1.5, sm: 2 } }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 950, mb: 0.5 }}>
|
||||
<Typography component="h2" variant="h6" sx={{ fontWeight: 950, mb: 0.5 }}>
|
||||
{t("adminUsersTitle")}
|
||||
</Typography>
|
||||
<Typography sx={{ color: "text.secondary", mb: 2 }}>{t("adminUsersSubtitle")}</Typography>
|
||||
|
||||
@@ -430,7 +430,7 @@ export default function CareerProfilePage() {
|
||||
{workspaceSection === "profile" ? <Paper variant="outlined" sx={{ mb: 2.5, p: { xs: 1.5, sm: 2 }, borderRadius: 3 }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap" }}>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 800 }}>{t("careerProfileImportTitle")}</Typography>
|
||||
<Typography component="h2" variant="subtitle1" sx={{ fontWeight: 800 }}>{t("careerProfileImportTitle")}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">{t("careerProfileImportBody")}</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
@@ -464,7 +464,7 @@ export default function CareerProfilePage() {
|
||||
{workspaceSection === "profile" ? <Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap" }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 2, flexWrap: "wrap" }}>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 1 }}>
|
||||
<Avatar src={me?.avatarImageDataUrl || undefined} sx={{ width: 84, height: 84, fontWeight: 900, fontSize: 28 }}>{initials}</Avatar>
|
||||
<Avatar src={me?.avatarImageDataUrl || undefined} sx={{ width: 84, height: 84, fontWeight: 900, fontSize: 28, bgcolor: "primary.main", color: "primary.contrastText" }}>{initials}</Avatar>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", justifyContent: "center" }}>
|
||||
<input
|
||||
ref={avatarInputRef}
|
||||
@@ -508,7 +508,7 @@ export default function CareerProfilePage() {
|
||||
</Box>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 900 }}>
|
||||
<Typography component="h2" variant="h5" sx={{ fontWeight: 900 }}>
|
||||
{careerOnly ? t("careerWorkspaceProfileTab") : t("profileTitle")}
|
||||
</Typography>
|
||||
<Typography sx={{ color: "text.secondary" }}>{me?.userName || me?.displayName || fullName || me?.email || "-"}</Typography>
|
||||
@@ -538,7 +538,7 @@ export default function CareerProfilePage() {
|
||||
{profileDirty ? <Alert severity="warning" sx={{ mb: 2 }}>{t("profileUnsavedBeforeActions")}</Alert> : null}
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "center", mb: 1.5 }}>
|
||||
<Box>
|
||||
<Typography variant="h6">{t("profileMasterCv")}</Typography>
|
||||
<Typography component="h2" variant="h6">{t("profileMasterCv")}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||
{t("profileMasterCvBody")}
|
||||
</Typography>
|
||||
@@ -611,7 +611,7 @@ export default function CareerProfilePage() {
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1.5, alignItems: "center", width: "100%", pr: 1 }}>
|
||||
<Box>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 800 }}>{t("profileCvRawPanelTitle")}</Typography>
|
||||
<Typography component="span" variant="subtitle1" sx={{ fontWeight: 800 }}>{t("profileCvRawPanelTitle")}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("profileCvRawPanelHelp")}</Typography>
|
||||
</Box>
|
||||
<Chip size="small" label={t("profileCvSectionWordCount", { count: cvWordCount })} />
|
||||
@@ -638,7 +638,7 @@ export default function CareerProfilePage() {
|
||||
<Box sx={{ mt: 2, p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper" }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "center", mb: 1.5 }}>
|
||||
<Box>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 800 }}>{t("profileCvExtractionHistory")}</Typography>
|
||||
<Typography component="h3" variant="subtitle1" sx={{ fontWeight: 800 }}>{t("profileCvExtractionHistory")}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("profileCvExtractionHistoryHelp")}</Typography>
|
||||
</Box>
|
||||
{structuredCv.metadata.profileVersion ? <Chip label={t("profileCvProfileVersion", { count: structuredCv.metadata.profileVersion })} size="small" /> : null}
|
||||
@@ -760,7 +760,7 @@ export default function CareerProfilePage() {
|
||||
<Box sx={{ mt: 2, p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper", display: showAdvancedCvTools ? "block" : "none" }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "center", mb: 1.5 }}>
|
||||
<Box>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 800 }}>{t("profileCvStructureOverview")}</Typography>
|
||||
<Typography component="h3" variant="subtitle1" sx={{ fontWeight: 800 }}>{t("profileCvStructureOverview")}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("profileCvStructureOverviewHelp")}</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
@@ -805,7 +805,7 @@ export default function CareerProfilePage() {
|
||||
</Box>
|
||||
<Box sx={{ mt: workspaceSection === "profile" ? 2 : 0, p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper", display: workspaceSection === "profile" ? "block" : "none" }}>
|
||||
<Box sx={{ mb: 1.5 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 800 }}>{t("profileCvStructuredEditor")}</Typography>
|
||||
<Typography component="h3" variant="subtitle1" sx={{ fontWeight: 800 }}>{t("profileCvStructuredEditor")}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("profileCvStructuredEditorHelp")}</Typography>
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -1047,7 +1047,7 @@ function ContentTab({ settings, update, outline, showPreviewEntry, onFocusPrevie
|
||||
helperText={t("cvEditorHeadlineHelp")} />
|
||||
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800, mb: 0.5 }}>{t("cvEditorSections")}</Typography>
|
||||
<Typography component="h3" variant="subtitle2" sx={{ fontWeight: 800, mb: 0.5 }}>{t("cvEditorSections")}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{t("cvEditorSectionsHelp")}</Typography>
|
||||
<Stack spacing={0.5} sx={{ mt: 1 }}>
|
||||
{sectionRows.map((row, i) => (
|
||||
|
||||
@@ -133,6 +133,7 @@ export default function LoginPage({ initialMode = "login" }: { initialMode?: "lo
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="main"
|
||||
sx={{
|
||||
minHeight: "100vh",
|
||||
display: "flex",
|
||||
@@ -144,7 +145,7 @@ export default function LoginPage({ initialMode = "login" }: { initialMode?: "lo
|
||||
}}
|
||||
>
|
||||
<Paper sx={{ width: "min(520px, 100%)", p: 4, borderRadius: 4, border: "none", boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)" }}>
|
||||
<Typography variant="h5" sx={{ fontWeight: 900, mb: 0.5 }}>
|
||||
<Typography component="h1" variant="h5" sx={{ fontWeight: 900, mb: 0.5 }}>
|
||||
{registerMode ? t("createAccount") : t("signInTitle")}
|
||||
</Typography>
|
||||
<Typography sx={{ color: "text.secondary", mb: 2 }}>
|
||||
|
||||
@@ -171,7 +171,7 @@ export default function ProfilePage() {
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap" }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 2, flexWrap: "wrap" }}>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 1 }}>
|
||||
<Avatar src={me?.avatarImageDataUrl || undefined} sx={{ width: 84, height: 84, fontWeight: 900, fontSize: 28 }}>{initials}</Avatar>
|
||||
<Avatar src={me?.avatarImageDataUrl || undefined} sx={{ width: 84, height: 84, fontWeight: 900, fontSize: 28, bgcolor: "primary.main", color: "primary.contrastText" }}>{initials}</Avatar>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", justifyContent: "center" }}>
|
||||
<input
|
||||
ref={avatarInputRef}
|
||||
@@ -215,7 +215,7 @@ export default function ProfilePage() {
|
||||
</Box>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 900 }}>
|
||||
<Typography component="h2" variant="h5" sx={{ fontWeight: 900 }}>
|
||||
{careerOnly ? "Master career profile" : t("profileTitle")}
|
||||
</Typography>
|
||||
<Typography sx={{ color: "text.secondary" }}>{me?.userName || me?.displayName || fullName || me?.email || "-"}</Typography>
|
||||
@@ -237,7 +237,7 @@ export default function ProfilePage() {
|
||||
|
||||
<Box sx={{ mt: 3, display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
|
||||
{!careerOnly ? <Box sx={{ gridColumn: "1 / -1" }}>
|
||||
<Typography variant="h6">{t("profileAccountSection")}</Typography>
|
||||
<Typography component="h3" variant="h6">{t("profileAccountSection")}</Typography>
|
||||
{!isLocal ? (
|
||||
<Alert severity="info" sx={{ mt: 1 }}>
|
||||
{t("profileReadOnlyInfo")}
|
||||
@@ -332,7 +332,7 @@ export default function ProfilePage() {
|
||||
|
||||
{!careerOnly ? <Box sx={{ gridColumn: "1 / -1", mt: 1 }}>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
<Typography variant="h6">{t("profileChangePassword")}</Typography>
|
||||
<Typography component="h3" variant="h6">{t("profileChangePassword")}</Typography>
|
||||
{!isLocal ? <Typography sx={{ color: "text.secondary" }}>{t("profilePasswordLocalOnly")}</Typography> : null}
|
||||
</Box> : null}
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ export function PersonalInformationSection({
|
||||
<TextField label={t("profileCvContactLinkedIn")} value={value.linkedIn ?? ""} onChange={(e) => set({ linkedIn: e.target.value || undefined })} fullWidth sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }} />
|
||||
<TextField label="GitHub" value={value.gitHub ?? ""} onChange={(e) => set({ gitHub: e.target.value || undefined })} fullWidth sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }} />
|
||||
<Box sx={{ gridColumn: "1 / -1" }}>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 1 }}><Typography variant="subtitle2">{t("profileCvOtherLinks")}</Typography><Button size="small" startIcon={<AddIcon />} onClick={() => set({ links: [...(value.links ?? []), { label: "", url: "" }] })}>{t("profileCvAddLink")}</Button></Stack>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 1 }}><Typography component="h4" variant="subtitle2">{t("profileCvOtherLinks")}</Typography><Button size="small" startIcon={<AddIcon />} onClick={() => set({ links: [...(value.links ?? []), { label: "", url: "" }] })}>{t("profileCvAddLink")}</Button></Stack>
|
||||
<Stack spacing={1}>{(value.links ?? []).map((link, index) => <Stack key={index} direction={{ xs: "column", sm: "row" }} spacing={1} alignItems={{ sm: "center" }}><TextField size="small" label={t("profileCvLinkLabel")} value={link.label ?? ""} onChange={(event) => set({ links: (value.links ?? []).map((item, itemIndex) => itemIndex === index ? { ...item, label: event.target.value || undefined } : item) })} sx={{ flex: "0 1 180px" }} /><TextField size="small" label={t("profileCvLinkUrl")} value={link.url ?? ""} onChange={(event) => set({ links: (value.links ?? []).map((item, itemIndex) => itemIndex === index ? { ...item, url: event.target.value || undefined } : item) })} fullWidth /><IconButton aria-label={t("profileCvDeleteCustomLink", { index: index + 1 })} onClick={() => set({ links: (value.links ?? []).filter((_, itemIndex) => itemIndex !== index) })}><DeleteOutlineIcon fontSize="small" /></IconButton></Stack>)}</Stack>
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -145,7 +145,7 @@ export function LanguagesSection({ value, onChange, getMetadata }: { value: Stru
|
||||
return (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, alignItems: "center", mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{t("profileCvStructuredLanguages")}</Typography>
|
||||
<Typography component="h4" variant="subtitle2" sx={{ fontWeight: 800 }}>{t("profileCvStructuredLanguages")}</Typography>
|
||||
<Button variant="outlined" size="small" onClick={() => onChange([...value, { name: "", level: "", notes: "" }])}>{t("profileCvStructuredAddLanguage")}</Button>
|
||||
</Box>
|
||||
<FieldReviewNote metadata={getMetadata("languages")} />
|
||||
@@ -170,7 +170,7 @@ export function WorkExperienceSection({ value, onChange }: { value: StructuredCv
|
||||
return (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, alignItems: "center", mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{t("profileCvStructuredJobs")}</Typography>
|
||||
<Typography component="h4" variant="subtitle2" sx={{ fontWeight: 800 }}>{t("profileCvStructuredJobs")}</Typography>
|
||||
<Button variant="outlined" size="small" onClick={() => onChange([...value, { title: "", company: "", location: "", start: "", end: "", isCurrent: false, bullets: [], skills: [] }])}>{t("profileCvStructuredAddJob")}</Button>
|
||||
</Box>
|
||||
{value.length === 0 ? <Typography variant="body2" sx={{ color: "text.secondary" }}>{t("profileCvStructuredEmpty")}</Typography> : null}
|
||||
@@ -202,7 +202,7 @@ export function EducationSection({ value, onChange }: { value: StructuredCvEduca
|
||||
return (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, alignItems: "center", mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{t("profileCvStructuredEducation")}</Typography>
|
||||
<Typography component="h4" variant="subtitle2" sx={{ fontWeight: 800 }}>{t("profileCvStructuredEducation")}</Typography>
|
||||
<Button variant="outlined" size="small" onClick={() => onChange([...value, { qualification: "", institution: "", location: "", start: "", end: "", details: [] }])}>{t("profileCvStructuredAddEducation")}</Button>
|
||||
</Box>
|
||||
{value.length === 0 ? <Typography variant="body2" sx={{ color: "text.secondary" }}>{t("profileCvStructuredEmpty")}</Typography> : null}
|
||||
@@ -233,7 +233,7 @@ export function OtherSectionsSection({ value, onChange }: { value: StructuredCvO
|
||||
return (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, alignItems: "center", mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{t("profileCvStructuredOtherSections")}</Typography>
|
||||
<Typography component="h4" variant="subtitle2" sx={{ fontWeight: 800 }}>{t("profileCvStructuredOtherSections")}</Typography>
|
||||
<Button variant="outlined" size="small" onClick={() => onChange([...value, { title: "", items: [] }])}>{t("profileCvStructuredAddOtherSection")}</Button>
|
||||
</Box>
|
||||
{value.length === 0 ? <Typography variant="body2" sx={{ color: "text.secondary" }}>{t("profileCvStructuredEmpty")}</Typography> : null}
|
||||
|
||||
Reference in New Issue
Block a user