@ -396,6 +396,16 @@ jobs:
|
|||||||
E2E_BASE_URL=http://localhost:5173 \
|
E2E_BASE_URL=http://localhost:5173 \
|
||||||
pnpm --filter @dorfteich/web exec playwright test e2e/favorites.spec.ts
|
pnpm --filter @dorfteich/web exec playwright test e2e/favorites.spec.ts
|
||||||
|
|
||||||
|
- name: Reset login rate limit before settings-nav pack
|
||||||
|
run: |
|
||||||
|
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
|
||||||
|
pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL"
|
||||||
|
|
||||||
|
- name: Run settings-nav pack
|
||||||
|
run: |
|
||||||
|
E2E_BASE_URL=http://localhost:5173 \
|
||||||
|
pnpm --filter @dorfteich/web exec playwright test e2e/settings-nav.spec.ts
|
||||||
|
|
||||||
- name: Reset login rate limit before create-missing-page pack
|
- name: Reset login rate limit before create-missing-page pack
|
||||||
run: |
|
run: |
|
||||||
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
|
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
|
||||||
|
|||||||
60
apps/web/e2e/settings-nav.spec.ts
Normal file
60
apps/web/e2e/settings-nav.spec.ts
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import { expect, test, type BrowserContext } from '@playwright/test';
|
||||||
|
|
||||||
|
import { contextForUser } from './helpers';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settings section navigation pack (issue #145): every settings page derives
|
||||||
|
* a jump nav from its stacked sections; clicking an entry scrolls the section
|
||||||
|
* into view and marks it active. Language-independent selectors (CSS classes)
|
||||||
|
* throughout.
|
||||||
|
*/
|
||||||
|
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173';
|
||||||
|
|
||||||
|
async function json<T>(context: BrowserContext, url: string, data: unknown): Promise<T> {
|
||||||
|
const response = await context.request.post(url, { data });
|
||||||
|
if (!response.ok()) throw new Error(`post ${url} → ${response.status()}`);
|
||||||
|
return response.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('user settings show the jump nav and clicking scrolls + activates', async ({ browser }) => {
|
||||||
|
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
|
||||||
|
const page = await context.newPage();
|
||||||
|
await page.goto('/settings');
|
||||||
|
|
||||||
|
const nav = page.locator('.settings-nav');
|
||||||
|
await expect(nav).toBeVisible();
|
||||||
|
const links = nav.locator('.settings-nav__link');
|
||||||
|
// Profile, password, sessions, watches, API tokens, data export.
|
||||||
|
await expect(links).toHaveCount(6);
|
||||||
|
|
||||||
|
// Jump to the last section: it scrolls into view and becomes active.
|
||||||
|
const last = links.last();
|
||||||
|
await last.click();
|
||||||
|
const lastSection = page.locator('.settings-layout section[id]').last();
|
||||||
|
await expect(lastSection).toBeInViewport();
|
||||||
|
await expect(last).toHaveClass(/settings-nav__link--active/);
|
||||||
|
|
||||||
|
await context.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pond settings derive the nav from their sections', async ({ browser }) => {
|
||||||
|
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
|
||||||
|
const pond = await json<{ id: string; slug: string }>(context, '/api/v1/ponds', {
|
||||||
|
name: `Nav Pack ${Date.now()}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const page = await context.newPage();
|
||||||
|
await page.goto(`/p/${pond.slug}/settings`);
|
||||||
|
|
||||||
|
const links = page.locator('.settings-nav .settings-nav__link');
|
||||||
|
// The pond owner sees the full section stack — at least members, labels,
|
||||||
|
// missing links, import, files, appearance, plugins, sidebar, comments,
|
||||||
|
// API, export.
|
||||||
|
await expect(links.first()).toBeVisible();
|
||||||
|
expect(await links.count()).toBeGreaterThanOrEqual(8);
|
||||||
|
|
||||||
|
await links.last().click();
|
||||||
|
await expect(page.locator('.settings-layout section[id]').last()).toBeInViewport();
|
||||||
|
|
||||||
|
await context.close();
|
||||||
|
});
|
||||||
137
apps/web/src/components/SettingsLayout.tsx
Normal file
137
apps/web/src/components/SettingsLayout.tsx
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
interface SectionEntry {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A stable, readable anchor id derived from the section heading. */
|
||||||
|
function anchorId(title: string, taken: Set<string>): string {
|
||||||
|
const base =
|
||||||
|
'sec-' +
|
||||||
|
(title
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[̀-ͯ]/g, '')
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '') || 'section');
|
||||||
|
let id = base;
|
||||||
|
let n = 2;
|
||||||
|
while (taken.has(id)) id = `${base}-${n++}`;
|
||||||
|
taken.add(id);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps a settings page's stacked sections and derives a jump navigation from
|
||||||
|
* them (issue #145): every top-level `<section>` with an `<h2>` becomes a nav
|
||||||
|
* entry. The list is read from the DOM (and kept fresh via MutationObserver),
|
||||||
|
* so conditionally rendered and component-owned sections need no wiring. On
|
||||||
|
* wide viewports the nav sits sticky beside the content; on narrow ones it
|
||||||
|
* collapses to a horizontal chip bar above it.
|
||||||
|
*/
|
||||||
|
export function SettingsLayout({ children }: { children: React.ReactNode }): React.JSX.Element {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [sections, setSections] = useState<SectionEntry[]>([]);
|
||||||
|
const [active, setActive] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const container = contentRef.current;
|
||||||
|
if (!container) return undefined;
|
||||||
|
|
||||||
|
const scan = (): void => {
|
||||||
|
const taken = new Set<string>();
|
||||||
|
const found: SectionEntry[] = [];
|
||||||
|
container.querySelectorAll('section').forEach((section) => {
|
||||||
|
// Only top-level sections: a nested <section> belongs to its parent's
|
||||||
|
// entry, not the nav.
|
||||||
|
const parent = section.parentElement?.closest('section');
|
||||||
|
if (parent && container.contains(parent)) return;
|
||||||
|
const title = section.querySelector('h2')?.textContent?.trim();
|
||||||
|
if (!title) return;
|
||||||
|
if (!section.id) section.id = anchorId(title, taken);
|
||||||
|
else taken.add(section.id);
|
||||||
|
found.push({ id: section.id, title });
|
||||||
|
});
|
||||||
|
setSections((prev) =>
|
||||||
|
prev.length === found.length &&
|
||||||
|
prev.every((p, i) => p.id === found[i]!.id && p.title === found[i]!.title)
|
||||||
|
? prev
|
||||||
|
: found,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const scrollParent: HTMLElement | Window = container.closest<HTMLElement>('.main') ?? window;
|
||||||
|
|
||||||
|
const atScrollEnd = (): boolean =>
|
||||||
|
scrollParent instanceof Window
|
||||||
|
? window.innerHeight + window.scrollY >= document.body.scrollHeight - 2
|
||||||
|
: scrollParent.scrollTop + scrollParent.clientHeight >= scrollParent.scrollHeight - 2;
|
||||||
|
|
||||||
|
const updateActive = (): void => {
|
||||||
|
const anchors = Array.from(container.querySelectorAll<HTMLElement>('section[id]'));
|
||||||
|
if (anchors.length === 0) return;
|
||||||
|
// The active section is the last one whose top has passed the reading
|
||||||
|
// line (a bit below the viewport top). At the very bottom the last
|
||||||
|
// section wins even if its top never reaches the line.
|
||||||
|
let current = anchors[0]!.id;
|
||||||
|
for (const section of anchors) {
|
||||||
|
if (section.getBoundingClientRect().top <= 160) current = section.id;
|
||||||
|
}
|
||||||
|
if (atScrollEnd()) current = anchors[anchors.length - 1]!.id;
|
||||||
|
setActive(current);
|
||||||
|
};
|
||||||
|
|
||||||
|
scan();
|
||||||
|
updateActive();
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
scan();
|
||||||
|
updateActive();
|
||||||
|
});
|
||||||
|
observer.observe(container, { childList: true, subtree: true });
|
||||||
|
|
||||||
|
scrollParent.addEventListener('scroll', updateActive, { passive: true });
|
||||||
|
window.addEventListener('resize', updateActive);
|
||||||
|
return () => {
|
||||||
|
observer.disconnect();
|
||||||
|
scrollParent.removeEventListener('scroll', updateActive);
|
||||||
|
window.removeEventListener('resize', updateActive);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const jump = (id: string): void => {
|
||||||
|
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
setActive(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="settings-layout">
|
||||||
|
{sections.length > 1 && (
|
||||||
|
<nav className="settings-nav" aria-label={t('common:settingsNav.label')}>
|
||||||
|
<ul>
|
||||||
|
{sections.map((section) => (
|
||||||
|
<li key={section.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={
|
||||||
|
'settings-nav__link' +
|
||||||
|
(active === section.id ? ' settings-nav__link--active' : '')
|
||||||
|
}
|
||||||
|
aria-current={active === section.id ? 'true' : undefined}
|
||||||
|
onClick={() => jump(section.id)}
|
||||||
|
>
|
||||||
|
{section.title}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
|
<div className="settings-layout__content" ref={contentRef}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
import { Field, FormError, FormSuccess } from '../components/forms';
|
import { Field, FormError, FormSuccess } from '../components/forms';
|
||||||
|
import { SettingsLayout } from '../components/SettingsLayout';
|
||||||
import { apiGet, apiPatch } from '../lib/api';
|
import { apiGet, apiPatch } from '../lib/api';
|
||||||
import { PluginManager } from './PluginManager';
|
import { PluginManager } from './PluginManager';
|
||||||
import { QuotaManager } from './QuotaManager';
|
import { QuotaManager } from './QuotaManager';
|
||||||
@ -63,61 +64,64 @@ export function AdminSettingsPage(): React.JSX.Element {
|
|||||||
<p>
|
<p>
|
||||||
<Link to="/admin/system">{t('system:settingsLink')} →</Link>
|
<Link to="/admin/system">{t('system:settingsLink')} →</Link>
|
||||||
</p>
|
</p>
|
||||||
<section className="settings-section">
|
<SettingsLayout>
|
||||||
<form onSubmit={onSubmit} noValidate>
|
<section className="settings-section">
|
||||||
<FormError error={error} />
|
<h2>{t('settings:admin.general')}</h2>
|
||||||
<FormSuccess message={saved ? t('settings:admin.saved') : null} />
|
<form onSubmit={onSubmit} noValidate>
|
||||||
<Field label={t('settings:admin.instanceName')}>
|
<FormError error={error} />
|
||||||
<input type="text" {...form.register('instance.name')} />
|
<FormSuccess message={saved ? t('settings:admin.saved') : null} />
|
||||||
</Field>
|
<Field label={t('settings:admin.instanceName')}>
|
||||||
<Field label={t('settings:admin.defaultLocale')}>
|
<input type="text" {...form.register('instance.name')} />
|
||||||
<select {...form.register('instance.defaultLocale')}>
|
|
||||||
<option value="de">{t('settings:profile.locales.de')}</option>
|
|
||||||
<option value="en">{t('settings:profile.locales.en')}</option>
|
|
||||||
</select>
|
|
||||||
</Field>
|
|
||||||
<Field label={t('settings:admin.registrationMode')}>
|
|
||||||
<select {...form.register('auth.registrationMode')}>
|
|
||||||
<option value="open">{t('settings:admin.registrationOpen')}</option>
|
|
||||||
<option value="closed">{t('settings:admin.registrationClosed')}</option>
|
|
||||||
</select>
|
|
||||||
</Field>
|
|
||||||
<button type="submit" className="button" disabled={form.formState.isSubmitting}>
|
|
||||||
{t('settings:admin.save')}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="settings-section">
|
|
||||||
<h2>{tQuotas('defaults.title')}</h2>
|
|
||||||
<form onSubmit={onSubmit} noValidate>
|
|
||||||
{(
|
|
||||||
[
|
|
||||||
'quota.editorsPerPond',
|
|
||||||
'quota.readersPerPond',
|
|
||||||
'quota.additionalPonds',
|
|
||||||
'quota.storageBytes',
|
|
||||||
'quota.maxFileBytes',
|
|
||||||
] as const
|
|
||||||
).map((key) => (
|
|
||||||
<Field key={key} label={tQuotas(`defaults.${SETTING_TO_QUOTA_KEY[key]}`)}>
|
|
||||||
<input type="number" min={0} {...form.register(key, { valueAsNumber: true })} />
|
|
||||||
</Field>
|
</Field>
|
||||||
))}
|
<Field label={t('settings:admin.defaultLocale')}>
|
||||||
<button type="submit" className="button" disabled={form.formState.isSubmitting}>
|
<select {...form.register('instance.defaultLocale')}>
|
||||||
{tQuotas('defaults.save')}
|
<option value="de">{t('settings:profile.locales.de')}</option>
|
||||||
</button>
|
<option value="en">{t('settings:profile.locales.en')}</option>
|
||||||
</form>
|
</select>
|
||||||
</section>
|
</Field>
|
||||||
|
<Field label={t('settings:admin.registrationMode')}>
|
||||||
|
<select {...form.register('auth.registrationMode')}>
|
||||||
|
<option value="open">{t('settings:admin.registrationOpen')}</option>
|
||||||
|
<option value="closed">{t('settings:admin.registrationClosed')}</option>
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<button type="submit" className="button" disabled={form.formState.isSubmitting}>
|
||||||
|
{t('settings:admin.save')}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
<UploadSettingsForm settings={settings.data} />
|
<section className="settings-section">
|
||||||
<PublicApiSettingsForm settings={settings.data} />
|
<h2>{tQuotas('defaults.title')}</h2>
|
||||||
<LandingSettingsForm settings={settings.data} />
|
<form onSubmit={onSubmit} noValidate>
|
||||||
<LegalSettingsForm settings={settings.data} />
|
{(
|
||||||
|
[
|
||||||
|
'quota.editorsPerPond',
|
||||||
|
'quota.readersPerPond',
|
||||||
|
'quota.additionalPonds',
|
||||||
|
'quota.storageBytes',
|
||||||
|
'quota.maxFileBytes',
|
||||||
|
] as const
|
||||||
|
).map((key) => (
|
||||||
|
<Field key={key} label={tQuotas(`defaults.${SETTING_TO_QUOTA_KEY[key]}`)}>
|
||||||
|
<input type="number" min={0} {...form.register(key, { valueAsNumber: true })} />
|
||||||
|
</Field>
|
||||||
|
))}
|
||||||
|
<button type="submit" className="button" disabled={form.formState.isSubmitting}>
|
||||||
|
{tQuotas('defaults.save')}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
<PluginManager />
|
<UploadSettingsForm settings={settings.data} />
|
||||||
<QuotaManager />
|
<PublicApiSettingsForm settings={settings.data} />
|
||||||
<UserManager />
|
<LandingSettingsForm settings={settings.data} />
|
||||||
|
<LegalSettingsForm settings={settings.data} />
|
||||||
|
|
||||||
|
<PluginManager />
|
||||||
|
<QuotaManager />
|
||||||
|
<UserManager />
|
||||||
|
</SettingsLayout>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import { useState } from 'react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { SettingsLayout } from '../components/SettingsLayout';
|
||||||
import { formatBytes } from '../files/file-format';
|
import { formatBytes } from '../files/file-format';
|
||||||
import { apiGet, apiPost } from '../lib/api';
|
import { apiGet, apiPost } from '../lib/api';
|
||||||
import { BackupSection } from './AdminBackupSection';
|
import { BackupSection } from './AdminBackupSection';
|
||||||
@ -29,10 +30,12 @@ export function AdminSystemPage(): React.JSX.Element {
|
|||||||
<p>
|
<p>
|
||||||
<Link to="/admin">← {t('backLink')}</Link>
|
<Link to="/admin">← {t('backLink')}</Link>
|
||||||
</p>
|
</p>
|
||||||
<JobsSection />
|
<SettingsLayout>
|
||||||
<BackupSection />
|
<JobsSection />
|
||||||
<AuditViewer />
|
<BackupSection />
|
||||||
<StorageSection />
|
<AuditViewer />
|
||||||
|
<StorageSection />
|
||||||
|
</SettingsLayout>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
|
|
||||||
import { useAuth } from '../auth/auth-context';
|
import { useAuth } from '../auth/auth-context';
|
||||||
|
import { SettingsLayout } from '../components/SettingsLayout';
|
||||||
import { ApiOptInSetting } from '../api-tokens/ApiOptInSetting';
|
import { ApiOptInSetting } from '../api-tokens/ApiOptInSetting';
|
||||||
import { CommentPolicySetting } from '../comments/CommentPolicySetting';
|
import { CommentPolicySetting } from '../comments/CommentPolicySetting';
|
||||||
import { WatchToggle } from '../watches/WatchToggle';
|
import { WatchToggle } from '../watches/WatchToggle';
|
||||||
@ -60,96 +61,98 @@ export function PondSettingsPage(): React.JSX.Element {
|
|||||||
<h1>{pond.data.name}</h1>
|
<h1>{pond.data.name}</h1>
|
||||||
<WatchToggle targetType="pond" targetId={pond.data.id} variant="icon" />
|
<WatchToggle targetType="pond" targetId={pond.data.id} variant="icon" />
|
||||||
</div>
|
</div>
|
||||||
<section>
|
<SettingsLayout>
|
||||||
<h2>{tMembers('title')}</h2>
|
<section>
|
||||||
<MemberManager pondId={pond.data.id} />
|
<h2>{tMembers('title')}</h2>
|
||||||
</section>
|
<MemberManager pondId={pond.data.id} />
|
||||||
<AccessRulesManager pondId={pond.data.id} />
|
</section>
|
||||||
<EffectivePermissionsInspector pondId={pond.data.id} />
|
<AccessRulesManager pondId={pond.data.id} />
|
||||||
<section>
|
<EffectivePermissionsInspector pondId={pond.data.id} />
|
||||||
<h2>{t('settings.title')}</h2>
|
<section>
|
||||||
{canModify ? (
|
<h2>{t('settings.title')}</h2>
|
||||||
<LabelManager pondId={pond.data.id} />
|
{canModify ? (
|
||||||
) : (
|
<LabelManager pondId={pond.data.id} />
|
||||||
<p className="form-banner form-banner--error" role="alert">
|
) : (
|
||||||
{tErrors('forbidden')}
|
<p className="form-banner form-banner--error" role="alert">
|
||||||
</p>
|
{tErrors('forbidden')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
{canModify && (
|
||||||
|
<section>
|
||||||
|
<h2>{tLinks('missing.title')}</h2>
|
||||||
|
<PhantomPagesView pondId={pond.data.id} pondSlug={pondSlug} />
|
||||||
|
</section>
|
||||||
)}
|
)}
|
||||||
</section>
|
{canModify && (
|
||||||
{canModify && (
|
<section>
|
||||||
<section>
|
<h2>{tImport('vault.title')}</h2>
|
||||||
<h2>{tLinks('missing.title')}</h2>
|
<VaultImportSection pondId={pond.data.id} pondSlug={pondSlug} />
|
||||||
<PhantomPagesView pondId={pond.data.id} pondSlug={pondSlug} />
|
</section>
|
||||||
|
)}
|
||||||
|
{canModify && (
|
||||||
|
<section>
|
||||||
|
<h2>{tFiles('manager.title')}</h2>
|
||||||
|
<PondFileManager pondId={pond.data.id} />
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
{canModify && (
|
||||||
|
<section className="appearance-section">
|
||||||
|
<h2>{tFont('heading')}</h2>
|
||||||
|
<AppearanceManager
|
||||||
|
pondId={pond.data.id}
|
||||||
|
pondSlug={pondSlug}
|
||||||
|
fonts={pond.data.settings.fonts}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
{canModify && <PondPluginSettings pondId={pond.data.id} />}
|
||||||
|
{canModify && (
|
||||||
|
<section>
|
||||||
|
<h2>{tCommon('layout.sidebar.view.defaultTitle')}</h2>
|
||||||
|
<SidebarViewSetting
|
||||||
|
pondId={pond.data.id}
|
||||||
|
pondSlug={pondSlug}
|
||||||
|
value={pond.data.settings.sidebarView}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
{canModify && (
|
||||||
|
<section>
|
||||||
|
<h2>{tComments('policy.title')}</h2>
|
||||||
|
<CommentPolicySetting
|
||||||
|
pondId={pond.data.id}
|
||||||
|
pondSlug={pondSlug}
|
||||||
|
value={pond.data.settings.commentPolicy}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
{canModify && (
|
||||||
|
<section>
|
||||||
|
<h2>{tApiTokens('pond.title')}</h2>
|
||||||
|
<ApiOptInSetting
|
||||||
|
pondId={pond.data.id}
|
||||||
|
pondSlug={pondSlug}
|
||||||
|
value={pond.data.settings.apiEnabled}
|
||||||
|
mcpValue={pond.data.settings.mcpEnabled}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
<section className="pond-export">
|
||||||
|
<h2>{tExport('pond.heading')}</h2>
|
||||||
|
<p className="pond-export__hint">{tExport('pond.hint')}</p>
|
||||||
|
<a
|
||||||
|
className="button"
|
||||||
|
href={`/api/v1/ponds/${pond.data.id}/export/markdown`}
|
||||||
|
download={`${pond.data.slug}.zip`}
|
||||||
|
>
|
||||||
|
{tExport('pond.zip')}
|
||||||
|
</a>
|
||||||
</section>
|
</section>
|
||||||
)}
|
{canModify && pond.data.type === 'shared' && (
|
||||||
{canModify && (
|
<DeletePondSection pondId={pond.data.id} pondName={pond.data.name} />
|
||||||
<section>
|
)}
|
||||||
<h2>{tImport('vault.title')}</h2>
|
</SettingsLayout>
|
||||||
<VaultImportSection pondId={pond.data.id} pondSlug={pondSlug} />
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
{canModify && (
|
|
||||||
<section>
|
|
||||||
<h2>{tFiles('manager.title')}</h2>
|
|
||||||
<PondFileManager pondId={pond.data.id} />
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
{canModify && (
|
|
||||||
<section className="appearance-section">
|
|
||||||
<h2>{tFont('heading')}</h2>
|
|
||||||
<AppearanceManager
|
|
||||||
pondId={pond.data.id}
|
|
||||||
pondSlug={pondSlug}
|
|
||||||
fonts={pond.data.settings.fonts}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
{canModify && <PondPluginSettings pondId={pond.data.id} />}
|
|
||||||
{canModify && (
|
|
||||||
<section>
|
|
||||||
<h2>{tCommon('layout.sidebar.view.defaultTitle')}</h2>
|
|
||||||
<SidebarViewSetting
|
|
||||||
pondId={pond.data.id}
|
|
||||||
pondSlug={pondSlug}
|
|
||||||
value={pond.data.settings.sidebarView}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
{canModify && (
|
|
||||||
<section>
|
|
||||||
<h2>{tComments('policy.title')}</h2>
|
|
||||||
<CommentPolicySetting
|
|
||||||
pondId={pond.data.id}
|
|
||||||
pondSlug={pondSlug}
|
|
||||||
value={pond.data.settings.commentPolicy}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
{canModify && (
|
|
||||||
<section>
|
|
||||||
<h2>{tApiTokens('pond.title')}</h2>
|
|
||||||
<ApiOptInSetting
|
|
||||||
pondId={pond.data.id}
|
|
||||||
pondSlug={pondSlug}
|
|
||||||
value={pond.data.settings.apiEnabled}
|
|
||||||
mcpValue={pond.data.settings.mcpEnabled}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
<section className="pond-export">
|
|
||||||
<h2>{tExport('pond.heading')}</h2>
|
|
||||||
<p className="pond-export__hint">{tExport('pond.hint')}</p>
|
|
||||||
<a
|
|
||||||
className="button"
|
|
||||||
href={`/api/v1/ponds/${pond.data.id}/export/markdown`}
|
|
||||||
download={`${pond.data.slug}.zip`}
|
|
||||||
>
|
|
||||||
{tExport('pond.zip')}
|
|
||||||
</a>
|
|
||||||
</section>
|
|
||||||
{canModify && pond.data.type === 'shared' && (
|
|
||||||
<DeletePondSection pondId={pond.data.id} pondName={pond.data.name} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
|
|
||||||
import { useAuth } from '../auth/auth-context';
|
import { useAuth } from '../auth/auth-context';
|
||||||
import { Field, FormError, FormSuccess, applyFieldErrors } from '../components/forms';
|
import { Field, FormError, FormSuccess, applyFieldErrors } from '../components/forms';
|
||||||
|
import { SettingsLayout } from '../components/SettingsLayout';
|
||||||
import { useDataExport } from '../export/use-data-export';
|
import { useDataExport } from '../export/use-data-export';
|
||||||
import { apiDelete, apiGet, apiPatch, apiPost } from '../lib/api';
|
import { apiDelete, apiGet, apiPatch, apiPost } from '../lib/api';
|
||||||
import { ApiTokensSection } from '../api-tokens/ApiTokensSection';
|
import { ApiTokensSection } from '../api-tokens/ApiTokensSection';
|
||||||
@ -25,12 +26,14 @@ export function SettingsPage(): React.JSX.Element {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h1>{t('settings:title')}</h1>
|
<h1>{t('settings:title')}</h1>
|
||||||
<ProfileSection />
|
<SettingsLayout>
|
||||||
<PasswordSection />
|
<ProfileSection />
|
||||||
<SessionsSection />
|
<PasswordSection />
|
||||||
<WatchesSection />
|
<SessionsSection />
|
||||||
<ApiTokensSection />
|
<WatchesSection />
|
||||||
<DataExportSection />
|
<ApiTokensSection />
|
||||||
|
<DataExportSection />
|
||||||
|
</SettingsLayout>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3720,3 +3720,91 @@ ul[data-type='task_list'] li p:last-of-type {
|
|||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Settings section jump navigation (issue #145). Wide viewports: sticky rail
|
||||||
|
beside the content; narrow ones: horizontal chip bar above it. */
|
||||||
|
.settings-layout {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
gap: var(--space-6);
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-layout__content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-layout section[id] {
|
||||||
|
scroll-margin-top: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-nav {
|
||||||
|
position: sticky;
|
||||||
|
top: var(--space-2);
|
||||||
|
width: 13rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-nav ul {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-nav__link {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
padding: var(--space-1) var(--space-2);
|
||||||
|
border: none;
|
||||||
|
border-left: 2px solid var(--color-border);
|
||||||
|
border-radius: 0 6px 6px 0;
|
||||||
|
background: none;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-nav__link:hover {
|
||||||
|
color: var(--color-text);
|
||||||
|
background: var(--color-bg-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-nav__link--active {
|
||||||
|
color: var(--color-accent);
|
||||||
|
border-left-color: var(--color-accent);
|
||||||
|
font-weight: var(--font-weight-heading);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 60rem) {
|
||||||
|
.settings-layout {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-nav {
|
||||||
|
position: static;
|
||||||
|
width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-nav ul {
|
||||||
|
flex-direction: row;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-nav__link {
|
||||||
|
white-space: nowrap;
|
||||||
|
border-left: none;
|
||||||
|
border-bottom: 2px solid var(--color-border);
|
||||||
|
border-radius: 6px 6px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-nav__link--active {
|
||||||
|
border-bottom-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -92,5 +92,8 @@
|
|||||||
"submit": "Teich löschen",
|
"submit": "Teich löschen",
|
||||||
"deleted": "Teich gelöscht."
|
"deleted": "Teich gelöscht."
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"settingsNav": {
|
||||||
|
"label": "Abschnitte"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,10 @@
|
|||||||
"title": "Profil",
|
"title": "Profil",
|
||||||
"displayName": "Anzeigename",
|
"displayName": "Anzeigename",
|
||||||
"locale": "Sprache",
|
"locale": "Sprache",
|
||||||
"locales": { "de": "Deutsch", "en": "English" },
|
"locales": {
|
||||||
|
"de": "Deutsch",
|
||||||
|
"en": "English"
|
||||||
|
},
|
||||||
"save": "Speichern",
|
"save": "Speichern",
|
||||||
"saved": "Gespeichert."
|
"saved": "Gespeichert."
|
||||||
},
|
},
|
||||||
@ -38,6 +41,7 @@
|
|||||||
},
|
},
|
||||||
"admin": {
|
"admin": {
|
||||||
"title": "Administration",
|
"title": "Administration",
|
||||||
|
"general": "Allgemein",
|
||||||
"instanceName": "Name der Instanz",
|
"instanceName": "Name der Instanz",
|
||||||
"defaultLocale": "Standardsprache",
|
"defaultLocale": "Standardsprache",
|
||||||
"registrationMode": "Selbst-Registrierung",
|
"registrationMode": "Selbst-Registrierung",
|
||||||
|
|||||||
@ -92,5 +92,8 @@
|
|||||||
"submit": "Delete pond",
|
"submit": "Delete pond",
|
||||||
"deleted": "Pond deleted."
|
"deleted": "Pond deleted."
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"settingsNav": {
|
||||||
|
"label": "Sections"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,10 @@
|
|||||||
"title": "Profile",
|
"title": "Profile",
|
||||||
"displayName": "Display name",
|
"displayName": "Display name",
|
||||||
"locale": "Language",
|
"locale": "Language",
|
||||||
"locales": { "de": "Deutsch", "en": "English" },
|
"locales": {
|
||||||
|
"de": "Deutsch",
|
||||||
|
"en": "English"
|
||||||
|
},
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"saved": "Saved."
|
"saved": "Saved."
|
||||||
},
|
},
|
||||||
@ -38,6 +41,7 @@
|
|||||||
},
|
},
|
||||||
"admin": {
|
"admin": {
|
||||||
"title": "Administration",
|
"title": "Administration",
|
||||||
|
"general": "General",
|
||||||
"instanceName": "Instance name",
|
"instanceName": "Instance name",
|
||||||
"defaultLocale": "Default language",
|
"defaultLocale": "Default language",
|
||||||
"registrationMode": "Self-registration",
|
"registrationMode": "Self-registration",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user