espial-0.0.40: frontend/src/components/AccountSettings.tsx
import { TimeoutError } from 'ky';
import React from 'react';
import { useTranslation } from 'react-i18next';
import * as api from '../api';
import { editAccountSettings } from '../api';
import type { AccountSettings } from '../types';
import { apiErrorMsg } from '../util';
type SettingsTab = 'account' | 'importexport' | 'api';
// Resolved against the document's `<base href>` (the approot), so the tab URLs keep
// the subpath when the app is served from a non-root APPROOT.
const tabPaths: Record<SettingsTab, string> = {
account: 'Settings',
importexport: 'Settings/importexport',
api: 'Settings/api',
};
function tabHref(value: SettingsTab): string {
return new URL(tabPaths[value], document.baseURI).pathname;
}
function tabFromLocation(): SettingsTab {
const pathname = window.location.pathname;
if (pathname === tabHref('importexport')) return 'importexport';
if (pathname === tabHref('api')) return 'api';
return 'account';
}
/** Displays and edits the current user's account settings, with a sub-nav to import/export. */
export function AccountSettingsView({ initial }: { initial: AccountSettings }) {
const { t } = useTranslation();
const [tab, setTab] = React.useState<SettingsTab>(tabFromLocation);
React.useEffect(() => {
const onPopState = () => {
setTab(tabFromLocation());
};
window.addEventListener('popstate', onPopState);
return () => {
window.removeEventListener('popstate', onPopState);
};
}, []);
function tabLink(value: SettingsTab, label: string) {
return (
<a
href={tabHref(value)}
className={`dib link${tab === value ? ' nav-active' : ' silver hover-alt'}`}
onClick={(e) => {
e.preventDefault();
if (window.location.pathname !== tabHref(value)) {
window.history.pushState(null, '', tabHref(value));
}
setTab(value);
}}
>
{label}
</a>
);
}
return (
<div>
<div className="mb3">
{tabLink('account', t('settings.tabAccount'))}
{' ‧ '}
{tabLink('importexport', t('settings.tabImportExport'))}
{' ‧ '}
{tabLink('api', t('settings.tabApi'))}
</div>
<div className={tab === 'account' ? '' : 'dn'}>
<SettingsView initial={initial} />
</div>
<div className={tab === 'importexport' ? '' : 'dn'}>
<ImportExportView />
</div>
<div className={tab === 'api' ? '' : 'dn'}>
<ApiKeyView initialHasApiKey={initial.hasApiKey} />
</div>
</div>
);
}
function SettingsView({ initial }: { initial: AccountSettings }) {
const { t } = useTranslation();
const [us, setUs] = React.useState<AccountSettings>(initial);
const [apiError, setApiError] = React.useState<string | null>(null);
const archiveDisabled = !us.archiveBackendEnabled;
const archiveDisabledTitle = archiveDisabled ? t('settings.archivingDisabled') : undefined;
async function update(next: AccountSettings): Promise<boolean> {
try {
const res = await editAccountSettings(next);
if (!res.ok) {
setApiError(apiErrorMsg(t, res.status, res.bodyText));
return false;
}
setApiError(null);
setUs(next);
const lockIcon = document.getElementById('privacy-lock-icon');
if (lockIcon) lockIcon.style.display = next.privacyLock ? '' : 'none';
return true;
} catch (err) {
setApiError(
err instanceof TimeoutError ? t('error.requestTimedOut') : t('error.networkError'),
);
return false;
}
}
return (
<div className="settings-form">
<div className="f7 fw7 ttu tracked thm-text-tertiary mb2">{t('settings.groupLanguage')}</div>
<div className="flex items-center mb3">
<select
id="language"
name="language"
className="pointer"
aria-label={t('settings.language')}
value={us.language ?? ''}
onChange={(e) => {
const next = { ...us, language: e.target.value === '' ? null : e.target.value };
void update(next).then((ok) => {
if (ok) window.location.reload();
});
}}
>
<option value="">{t('settings.serverDefault')}</option>
<option value="en">English</option>
<option value="de">Deutsch</option>
<option value="es">Español</option>
<option value="fr">Français</option>
<option value="it">Italiano</option>
<option value="ja">日本語</option>
<option value="ko">한국어</option>
<option value="pl">Polski</option>
<option value="pt-BR">Português (Brasil)</option>
<option value="ru">Русский</option>
<option value="tr">Türkçe</option>
<option value="uk">Українська</option>
<option value="zh-Hans">简体中文</option>
<option value="zh-Hant">繁體中文</option>
</select>
</div>
<div className="f7 fw7 ttu tracked thm-text-tertiary mb2">{t('settings.groupGeneral')}</div>
<div className="flex items-center mb2">
<input
type="checkbox"
className="pointer mr2"
id="suggestTags"
name="suggestTags"
checked={us.suggestTags}
onChange={(e) => void update({ ...us, suggestTags: e.target.checked })}
/>
<label htmlFor="suggestTags" className="lh-copy">
{t('settings.suggestTags')}
</label>
</div>
<div className="flex items-center mb2">
<input
type="checkbox"
className="pointer mr2"
id="suggestTagsUseReturnKey"
name="suggestTagsUseReturnKey"
checked={us.suggestTagsUseReturnKey}
onChange={(e) => void update({ ...us, suggestTagsUseReturnKey: e.target.checked })}
/>
<label htmlFor="suggestTagsUseReturnKey" className="lh-copy">
{t('settings.suggestTagsUseReturnKey')}
</label>
</div>
<div className="flex items-center mb3">
<input
type="checkbox"
className="pointer mr2"
id="previewNotes"
name="previewNotes"
checked={us.previewNotes}
onChange={(e) => void update({ ...us, previewNotes: e.target.checked })}
/>
<label htmlFor="previewNotes" className="lh-copy">
{t('settings.previewNotes')}
</label>
</div>
<div className="f7 fw7 ttu tracked thm-text-tertiary mb2">{t('settings.groupArchive')}</div>
<div
className={`flex items-center mb3${archiveDisabled ? ' o-50' : ''}`}
title={archiveDisabledTitle}
>
<input
type="checkbox"
className={`mr2${archiveDisabled ? '' : ' pointer'}`}
id="archiveDefault"
name="archiveDefault"
checked={us.archiveDefault}
disabled={archiveDisabled}
title={archiveDisabledTitle}
onChange={(e) => void update({ ...us, archiveDefault: e.target.checked })}
/>
<label
htmlFor="archiveDefault"
className={`lh-copy${archiveDisabled ? ' thm-text-tertiary' : ''}`}
title={archiveDisabledTitle}
>
{t('settings.archiveDefault')}
</label>
</div>
<div className="f7 fw7 ttu tracked thm-text-tertiary mb2">{t('settings.groupPrivacy')}</div>
<div className="flex items-center mb2">
<input
type="checkbox"
className="pointer mr2"
id="privateDefault"
name="privateDefault"
checked={us.privateDefault}
onChange={(e) => void update({ ...us, privateDefault: e.target.checked })}
/>
<label htmlFor="privateDefault" className="lh-copy">
{t('settings.privateDefault')}
</label>
</div>
<div className="flex items-center mb2">
<input
type="checkbox"
className="pointer mr2"
id="publicTagCloud"
name="publicTagCloud"
checked={us.publicTagCloud}
onChange={(e) => void update({ ...us, publicTagCloud: e.target.checked })}
/>
<label htmlFor="publicTagCloud" className="lh-copy">
{t('settings.publicTagCloud')}
</label>
</div>
<div className="flex items-center mb2">
<input
type="checkbox"
className="pointer mr2"
id="privacyLock"
name="privacyLock"
checked={us.privacyLock}
onChange={(e) => void update({ ...us, privacyLock: e.target.checked })}
/>
<label htmlFor="privacyLock" className="lh-copy">
{t('settings.privacyLock')}
</label>
</div>
{apiError ? <div className="thm-text-error f7 mt2">{apiError}</div> : null}
</div>
);
}
function ImportExportView() {
const { t } = useTranslation();
const [status, setStatus] = React.useState<string | null>(null);
const [error, setError] = React.useState<string | null>(null);
const [busy, setBusy] = React.useState(false);
async function runImport(
files: FileList | null,
prepare: (texts: string[]) => string,
importFn: (body: string) => Promise<api.ImportResult>,
): Promise<void> {
if (!files || files.length === 0) return;
setError(null);
setStatus(t('settings.importing'));
setBusy(true);
try {
const texts = await Promise.all(Array.from(files).map((f) => f.text()));
let body: string;
try {
body = prepare(texts);
} catch {
setStatus(null);
setError(t('settings.importParseError'));
return;
}
const res = await importFn(body);
if (res.ok) {
setStatus(t('settings.imported', { count: res.imported ?? 0 }));
} else {
setStatus(null);
setError(t('error.error', { status: res.status }));
}
} catch (err) {
setStatus(null);
setError(err instanceof TimeoutError ? t('error.requestTimedOut') : t('error.networkError'));
} finally {
setBusy(false);
}
}
const first = (texts: string[]) => texts[0] ?? '';
const combineNotes = (texts: string[]) => {
const notes: unknown[] = [];
for (const txt of texts) {
const parsed: unknown = JSON.parse(txt);
if (Array.isArray(parsed)) notes.push(...(parsed as unknown[]));
else notes.push(parsed);
}
return JSON.stringify(notes);
};
function importRow(
label: string,
accept: string,
prepare: (texts: string[]) => string,
importFn: (body: string) => Promise<api.ImportResult>,
multiple = false,
) {
return (
<div className="mb2">
<div className="mb1">{label}</div>
<input
type="file"
accept={accept}
multiple={multiple}
disabled={busy}
className="f7 pointer"
onChange={(e) => {
void runImport(e.target.files, prepare, importFn);
e.target.value = '';
}}
/>
</div>
);
}
return (
<div className="settings-form">
<div className="f7 fw7 ttu tracked thm-text-tertiary mb2">{t('settings.export')}</div>
<div className="mb1">
<a className="link" href={api.exportPaths.bookmarksJson} download>
{t('settings.exportBookmarksJson')}
</a>
</div>
<div className="mb1">
<a className="link" href={api.exportPaths.bookmarksHtml} download>
{t('settings.exportBookmarksHtml')}
</a>
</div>
<div className="mb3">
<a className="link" href={api.exportPaths.notesJson} download>
{t('settings.exportNotesJson')}
</a>
</div>
<div className="f7 fw7 ttu tracked thm-text-tertiary mb2">{t('settings.import')}</div>
{importRow(t('settings.importBookmarksJson'), '.json', first, api.importBookmarks)}
{importRow(t('settings.importFirefoxJson'), '.json', first, api.importFirefox)}
{importRow(t('settings.importNetscapeHtml'), '.html,.htm', first, api.importNetscape)}
{importRow(t('settings.importNotesJson'), '.json', combineNotes, api.importNotes, true)}
{status ? <div className="f7 mt2">{status}</div> : null}
{error ? <div className="thm-text-error f7 mt2">{error}</div> : null}
</div>
);
}
function ApiKeyView({ initialHasApiKey }: { initialHasApiKey: boolean }) {
const { t } = useTranslation();
const [hasApiKey, setHasApiKey] = React.useState(initialHasApiKey);
const [newKey, setNewKey] = React.useState<string | null>(null);
const [copied, setCopied] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const [busy, setBusy] = React.useState(false);
async function reset(): Promise<void> {
setError(null);
setCopied(false);
setBusy(true);
try {
const res = await api.resetApiKey();
if (res.ok && res.apiKey) {
setNewKey(res.apiKey);
setHasApiKey(true);
} else {
setError(apiErrorMsg(t, res.status, res.bodyText ?? ''));
}
} catch (err) {
setError(err instanceof TimeoutError ? t('error.requestTimedOut') : t('error.networkError'));
} finally {
setBusy(false);
}
}
async function revoke(): Promise<void> {
setError(null);
setBusy(true);
try {
const res = await api.revokeApiKey();
if (res.ok) {
setHasApiKey(false);
setNewKey(null);
} else {
setError(apiErrorMsg(t, res.status, res.bodyText));
}
} catch (err) {
setError(err instanceof TimeoutError ? t('error.requestTimedOut') : t('error.networkError'));
} finally {
setBusy(false);
}
}
async function copyKey(): Promise<void> {
if (!newKey) return;
try {
await navigator.clipboard.writeText(newKey);
setCopied(true);
} catch {
setCopied(false);
}
}
return (
<div className="settings-form">
<div className="f7 fw7 ttu tracked thm-text-tertiary mb2">{t('settings.apiKeyTitle')}</div>
<div className="mb3 lh-copy">
{hasApiKey ? t('settings.apiKeyHasKey') : t('settings.apiKeyNoKey')}
</div>
{newKey ? (
<div className="mb3">
<div className="mb1 lh-copy">{t('settings.apiKeyShownOnce')}</div>
<div className="flex items-center">
<input
type="text"
readOnly
className="code f7 pa2 ba thm-border-primary bg-transparent thm-text-strong w-100 mw6"
value={newKey}
onFocus={(e) => {
e.target.select();
}}
/>
<a
href="#"
className="link ml2"
onClick={(e) => {
e.preventDefault();
void copyKey();
}}
>
{copied ? t('settings.apiKeyCopied') : t('settings.apiKeyCopy')}
</a>
</div>
</div>
) : null}
<div className="flex items-center">
<button
type="button"
disabled={busy}
className="ph3 pv2 input-reset thm-text-strong ba thm-border-primary bg-transparent pointer f6 dib dim"
onClick={() => void reset()}
>
{hasApiKey ? t('settings.apiKeyReset') : t('settings.apiKeyCreate')}
</button>
{hasApiKey ? (
<button
type="button"
disabled={busy}
className="ml3 input-reset bn bg-transparent thm-text-error pointer f6 dib dim"
onClick={() => void revoke()}
>
{t('settings.apiKeyRevoke')}
</button>
) : null}
</div>
{hasApiKey ? (
<div className="f7 thm-text-tertiary mt2 lh-copy">{t('settings.apiKeyResetHint')}</div>
) : null}
{error ? <div className="thm-text-error f7 mt2">{error}</div> : null}
</div>
);
}