Desktop D3 UI: studios and store accounts (health checks with fixes, import apps), one card per app across studios (automatic iOS / Android pairing, manual pair / unpair, links, studio moves), store-only apps read-only, ignore list, portfolio search / studio filter / sort / grouping, collapsible studio sidebar, complete Settings, notifications, fixtures with 3 studios and 5 accounts
This commit is contained in:
parent
e058d09683
commit
7b2cc8da0a
24 changed files with 2225 additions and 312 deletions
|
|
@ -1,88 +1,227 @@
|
|||
// Store accounts: the keys live in the system keychain; the app keeps a label only and hands a key to storebound in
|
||||
// memory for one call.
|
||||
import { useState } from 'react';
|
||||
import { type Account, api, errorText } from '../api';
|
||||
import { t } from '../i18n';
|
||||
import { date } from '../lib/format';
|
||||
import { useData } from '../store';
|
||||
// Studios & accounts: studios (name, colour, logo) group store accounts; each account is a Google Play service account
|
||||
// (one JSON key; it can be invited to several Play developer accounts) or an App Store Connect team key (key id,
|
||||
// issuer id, .p8). Keys live in the system keychain; the app keeps a label and the studio only. Per account: a health
|
||||
// check (key works / permission missing / revoked, with the fix) and "Import apps" (every app the key can see).
|
||||
import { type FormEvent, useEffect, useState } from 'react';
|
||||
import { type Account, type AccountCheck, api, errorText, STUDIO_COLORS, type Studio, type StudioColor } from '../api';
|
||||
import { type Key, t } from '../i18n';
|
||||
import { ago } from '../lib/format';
|
||||
import { withIgnored } from '../lib/identity';
|
||||
import { type AccountHealth, useData } from '../store';
|
||||
import { Icon, StoreGlyph } from '../ui/icons';
|
||||
import { Card, Chip, Empty, Notice } from '../ui/kit';
|
||||
import { Card, Chip, Empty, Notice, StudioMark } from '../ui/kit';
|
||||
import type { Tone } from '../lib/model';
|
||||
|
||||
export function Accounts() {
|
||||
const STATUS: Record<AccountCheck['status'], [Tone, Key]> = {
|
||||
ok: ['ok', 'acct.ok'], 'no-apps': ['warn', 'acct.noApps'], permission: ['warn', 'acct.permission'], revoked: ['bad', 'acct.revoked'], 'api-disabled': ['warn', 'acct.apiDisabled'], error: ['bad', 'acct.error'],
|
||||
};
|
||||
|
||||
export function HealthChip({ h }: { h: AccountHealth | undefined }) {
|
||||
if (!h) return <Chip tone="neutral">{t('acct.notTested')}</Chip>;
|
||||
if (h.error || !h.check) return <span title={h.error}><Chip tone="bad">{t('acct.error')}</Chip></span>;
|
||||
const [tone, key] = STATUS[h.check.status];
|
||||
return <span title={h.check.message}><Chip tone={tone}>{t(key)}</Chip></span>;
|
||||
}
|
||||
|
||||
function Swatches({ value, onChange, name }: { value: StudioColor; onChange: (c: StudioColor) => void; name: string }) {
|
||||
return (
|
||||
<div className="swatches" role="radiogroup" aria-label={t('studio.color')}>
|
||||
{STUDIO_COLORS.map(c => (
|
||||
<button key={c} type="button" role="radio" aria-checked={value === c} className={`swatch studio-${c}`} onClick={() => onChange(c)} aria-label={t(`color.${c}` as Key)} title={t(`color.${c}` as Key)}>
|
||||
{value === c && <Icon name="check" />}
|
||||
</button>
|
||||
))}
|
||||
<span className="sr">{name}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AccountRow({ a, busy, setBusy, onError }: { a: Account; busy: string | null; setBusy: (b: string | null) => void; onError: (e: string | null) => void }) {
|
||||
const d = useData();
|
||||
const [edit, setEdit] = useState(false);
|
||||
const [label, setLabel] = useState(a.label);
|
||||
const [studio, setStudio] = useState(a.studio ?? '');
|
||||
const h = d.accountHealth[a.id];
|
||||
const list = d.storeApps[a.id];
|
||||
const ignored = d.overrides.ignored.includes(`account:${a.id}`);
|
||||
const used = d.projects.filter(p => p.playAccount === a.id || p.appleAccount === a.id).length;
|
||||
const run = async (key: string, fn: () => Promise<unknown>) => {
|
||||
setBusy(key); onError(null);
|
||||
try { await fn(); } catch (e) { onError(errorText(e)); } finally { setBusy(null); }
|
||||
};
|
||||
const save = (e: FormEvent) => { e.preventDefault(); run(`ed-${a.id}`, async () => { await api.updateAccount(a.id, label, studio || null); await d.reload(); setEdit(false); }); };
|
||||
const check = h?.check;
|
||||
return (
|
||||
<div className={`acct${ignored ? ' is-ignored' : ''}`}>
|
||||
<div className="acct-row">
|
||||
<span className={`acct-glyph ${a.kind}`}><StoreGlyph store={a.kind === 'play' ? 'android' : 'ios'} /></span>
|
||||
<div className="acct-main">
|
||||
<div className="row" style={{ gap: 8 }}><span className="strong">{a.label}</span><HealthChip h={h} />{ignored && <Chip tone="neutral" icon="eyeOff">{t('acct.ignored')}</Chip>}</div>
|
||||
<div className="small muted">
|
||||
{a.kind === 'play' ? t('acct.kindPlay') : t('acct.kindApple')}
|
||||
{' · '}{list ? t('acct.appsRead', { count: list.value.apps.length, when: ago(list.at) }) : t('acct.notImported')}
|
||||
{used ? ` · ${t('acct.folders', { count: used })}` : ''}
|
||||
{check?.principal ? <> · <span className="mono-id selectable">{check.principal}</span></> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button className="btn btn-sm" onClick={() => run(`ck-${a.id}`, () => d.checkAccount(a.id))} disabled={!!busy}><Icon name="shield" className={busy === `ck-${a.id}` ? 'spin' : undefined} />{t('acct.test')}</button>
|
||||
<button className="btn btn-sm" onClick={() => run(`im-${a.id}`, () => d.importApps(a.id))} disabled={!!busy || d.importBusy}><Icon name="download" />{t('acct.import')}</button>
|
||||
<button className="btn btn-sm btn-ghost btn-icon" onClick={() => setEdit(x => !x)} aria-expanded={edit} aria-label={t('acct.editAria', { label: a.label })} title={t('acct.edit')}><Icon name="edit" /></button>
|
||||
<button className="btn btn-sm btn-ghost btn-icon" onClick={() => d.updateOverrides(o => withIgnored(o, [`account:${a.id}`], !ignored))} aria-label={ignored ? t('acct.unignore') : t('acct.ignore')} title={ignored ? t('acct.unignore') : t('acct.ignoreHint')}><Icon name={ignored ? 'eye' : 'eyeOff'} /></button>
|
||||
<button className="btn btn-sm btn-ghost btn-icon btn-danger" disabled={!!busy} onClick={() => run(`rm-${a.id}`, async () => { await api.removeAccount(a.id); await d.reload(); })} aria-label={t('accounts.removeAria', { label: a.label })} title={t('accounts.remove')}><Icon name="trash" /></button>
|
||||
</div>
|
||||
</div>
|
||||
{edit && (
|
||||
<form className="acct-edit" onSubmit={save}>
|
||||
<div className="field"><label htmlFor={`al-${a.id}`}>{t('accounts.label')}</label><input id={`al-${a.id}`} className="input" value={label} onChange={e => setLabel(e.target.value)} maxLength={60} /></div>
|
||||
<div className="field"><label htmlFor={`as-${a.id}`}>{t('studio.studio')}</label>
|
||||
<select id={`as-${a.id}`} className="input" value={studio} onChange={e => setStudio(e.target.value)}>
|
||||
<option value="">{t('studio.none')}</option>
|
||||
{d.studios.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||
</select></div>
|
||||
<div className="actions"><button className="btn btn-primary btn-sm" type="submit" disabled={!label.trim() || !!busy}><Icon name="check" />{t('common.save')}</button><button type="button" className="btn btn-sm btn-ghost" onClick={() => setEdit(false)}>{t('common.cancel')}</button></div>
|
||||
</form>
|
||||
)}
|
||||
{check && check.status !== 'ok' && check.fix && (
|
||||
<div className="acct-fix">
|
||||
<Notice tone={check.status === 'revoked' ? 'bad' : 'warn'} action={check.docs ? <button className="btn btn-sm" onClick={() => api.openExternal(check.docs!).catch(e => onError(errorText(e)))}><Icon name="external" />{t('acct.openFix')}</button> : undefined}>
|
||||
<b>{check.message}</b><br />{check.fix}
|
||||
</Notice>
|
||||
</div>
|
||||
)}
|
||||
{h?.error && <div className="acct-fix"><Notice tone="bad">{h.error}</Notice></div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StudioCard({ s, accounts, focus }: { s: Studio | null; accounts: Account[]; focus: boolean }) {
|
||||
const d = useData();
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [edit, setEdit] = useState(false);
|
||||
const [name, setName] = useState(s?.name ?? '');
|
||||
const [color, setColor] = useState<StudioColor>(s?.color ?? 'indigo');
|
||||
const apps = d.apps.filter(a => !a.ignored && (a.studio ?? null) === (s?.id ?? null));
|
||||
const storeOnly = apps.filter(a => !a.project).length;
|
||||
const act = async (key: string, fn: () => Promise<unknown>) => { setBusy(key); setError(null); try { await fn(); await d.reload(); } catch (e) { setError(errorText(e)); } finally { setBusy(null); } };
|
||||
const id = s ? `studio-${s.id}` : 'studio-none';
|
||||
useEffect(() => { if (focus) document.getElementById(id)?.scrollIntoView({ block: 'start' }); }, [focus, id]);
|
||||
return (
|
||||
<section className={`card studio-card${focus ? ' focus' : ''}`} id={id} aria-labelledby={`${id}-h`}>
|
||||
<div className="card-head">
|
||||
<div className="row" style={{ gap: 12, minWidth: 0 }}>
|
||||
<StudioMark studio={s} size="lg" />
|
||||
<div className="stack" style={{ gap: 2, minWidth: 0 }}>
|
||||
<h2 id={`${id}-h`}>{s?.name ?? t('studio.none')}</h2>
|
||||
<div className="sub">{s ? t('studio.counts', { count: accounts.length, apps: apps.length, store: storeOnly }) : t('studio.noneSub')}</div>
|
||||
</div>
|
||||
</div>
|
||||
{s && (
|
||||
<div className="actions">
|
||||
<button className="btn btn-sm btn-ghost" onClick={() => setEdit(x => !x)} aria-expanded={edit}><Icon name="edit" />{t('studio.edit')}</button>
|
||||
<button className="btn btn-sm btn-ghost" onClick={() => act('logo', () => api.studioLogo(s.id))} disabled={!!busy}><Icon name="image" />{t('studio.logo')}</button>
|
||||
{s.logo && <button className="btn btn-sm btn-ghost" onClick={() => act('logo', () => api.studioLogo(s.id, true))} disabled={!!busy}>{t('studio.logoClear')}</button>}
|
||||
<button className="btn btn-sm btn-ghost btn-danger" onClick={() => act('rm', () => api.removeStudio(s.id))} disabled={!!busy} title={t('studio.removeHint')}><Icon name="trash" />{t('studio.remove')}</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{s && edit && (
|
||||
<form className="card-body studio-edit" onSubmit={e => { e.preventDefault(); act('save', async () => { await api.saveStudio(s.id, name, color); setEdit(false); }); }}>
|
||||
<div className="field"><label htmlFor={`${id}-n`}>{t('studio.name')}</label><input id={`${id}-n`} className="input" value={name} onChange={e => setName(e.target.value)} maxLength={48} /></div>
|
||||
<div className="field"><span className="legend">{t('studio.color')}</span><Swatches value={color} onChange={setColor} name={name} /></div>
|
||||
<div className="actions"><button className="btn btn-primary btn-sm" type="submit" disabled={!name.trim() || !!busy}><Icon name="check" />{t('common.save')}</button></div>
|
||||
</form>
|
||||
)}
|
||||
{error && <div className="card-body"><Notice tone="bad">{error}</Notice></div>}
|
||||
{accounts.length ? <div className="acct-list">{accounts.map(a => <AccountRow key={a.id} a={a} busy={busy} setBusy={setBusy} onError={setError} />)}</div>
|
||||
: <div className="card-body"><p className="small muted">{s ? t('studio.noAccounts') : t('studio.allAssigned')}</p></div>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function Accounts({ focus }: { focus?: string }) {
|
||||
const d = useData();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [studioName, setStudioName] = useState('');
|
||||
const [studioColor, setStudioColor] = useState<StudioColor>('indigo');
|
||||
const [playLabel, setPlayLabel] = useState('');
|
||||
const [playStudio, setPlayStudio] = useState('');
|
||||
const [appleLabel, setAppleLabel] = useState('');
|
||||
const [appleStudio, setAppleStudio] = useState('');
|
||||
const [keyId, setKeyId] = useState('');
|
||||
const [issuerId, setIssuerId] = useState('');
|
||||
|
||||
const act = async (key: string, fn: () => Promise<Account | null | void>) => {
|
||||
const act = async (key: string, fn: () => Promise<Account | Studio | null | void>, ok?: (r: Account | Studio) => string) => {
|
||||
setBusy(key); setError(null);
|
||||
try {
|
||||
const r = await fn();
|
||||
if (r) d.toast({ kind: 'ok', title: t('accounts.saved', { label: r.label }), detail: t('accounts.savedDetail') });
|
||||
if (r && ok) d.toast({ kind: 'ok', title: ok(r), detail: 'kind' in r ? t('accounts.savedDetail') : undefined });
|
||||
await d.reload();
|
||||
return r;
|
||||
} catch (e) { setError(errorText(e)); } finally { setBusy(null); }
|
||||
};
|
||||
const addPlay = () => act('play', async () => { const a = await api.addPlayAccount(playLabel); if (a) setPlayLabel(''); return a; });
|
||||
const addApple = () => act('apple', async () => { const a = await api.addAppleAccount(appleLabel, keyId, issuerId); if (a) { setAppleLabel(''); setKeyId(''); setIssuerId(''); } return a; });
|
||||
const used = (a: Account) => d.projects.filter(p => p.playAccount === a.id || p.appleAccount === a.id).map(p => p.name);
|
||||
const addStudio = () => act('studio', async () => { const s = await api.saveStudio(null, studioName, studioColor); setStudioName(''); return s; }, r => t('studio.added', { name: (r as Studio).name }));
|
||||
const addPlay = () => act('play', async () => { const a = await api.addPlayAccount(playLabel, playStudio || null); if (a) setPlayLabel(''); return a; }, r => t('accounts.saved', { label: (r as Account).label }));
|
||||
const addApple = () => act('apple', async () => { const a = await api.addAppleAccount(appleLabel, keyId, issuerId, appleStudio || null); if (a) { setAppleLabel(''); setKeyId(''); setIssuerId(''); } return a; }, r => t('accounts.saved', { label: (r as Account).label }));
|
||||
const testAll = async () => { setBusy('all'); await Promise.all(d.accounts.map(a => d.checkAccount(a.id))); setBusy(null); };
|
||||
const unassigned = d.accounts.filter(a => !a.studio || !d.studios.some(s => s.id === a.studio));
|
||||
const studioSelect = (id: string, value: string, set: (v: string) => void) => (
|
||||
<div className="field"><label htmlFor={id}>{t('studio.studio')}</label>
|
||||
<select id={id} className="input" value={value} onChange={e => set(e.target.value)}>
|
||||
<option value="">{t('studio.none')}</option>
|
||||
{d.studios.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||
</select></div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="ph"><div><h1>{t('nav.accounts')}</h1><p>{t('accounts.lead')}</p></div></div>
|
||||
<div className="ph">
|
||||
<div><h1>{t('nav.accounts')}</h1><p>{t('accounts.leadStudios')}</p></div>
|
||||
<div className="actions">
|
||||
<button className="btn" onClick={testAll} disabled={!!busy || !d.accounts.length}><Icon name="shield" className={busy === 'all' ? 'spin' : undefined} />{t('acct.testAll')}</button>
|
||||
<button className="btn btn-primary" onClick={() => d.importApps()} disabled={d.importBusy || !d.accounts.length}><Icon name="download" className={d.importBusy ? 'spin' : undefined} />{t('action.importApps')}</button>
|
||||
</div>
|
||||
</div>
|
||||
{error && <Notice tone="bad">{error}</Notice>}
|
||||
<Card title={t('accounts.list')} icon="key" sub={t('accounts.listSub')} pad={false}>
|
||||
{!d.accounts.length ? <Empty illo="key" title={t('accounts.emptyTitle')}>{t('accounts.emptyText')}</Empty> : (
|
||||
<table className="table">
|
||||
<thead><tr><th>{t('col.name')}</th><th>{t('col.store')}</th><th>{t('col.usedBy')}</th><th>{t('col.added')}</th><th><span className="sr">{t('col.actions')}</span></th></tr></thead>
|
||||
<tbody>
|
||||
{d.accounts.map(a => (
|
||||
<tr key={a.id}>
|
||||
<td className="strong">{a.label}</td>
|
||||
<td><span className="row" style={{ gap: 8 }}><StoreGlyph store={a.kind === 'play' ? 'android' : 'ios'} className="ic14" />{a.kind === 'play' ? 'Google Play' : 'App Store Connect'}</span></td>
|
||||
<td>{used(a).length ? used(a).join(', ') : <Chip tone="neutral">{t('accounts.unused')}</Chip>}</td>
|
||||
<td className="muted">{date(a.created)}</td>
|
||||
<td className="right"><button className="btn btn-sm btn-ghost btn-danger" disabled={!!busy} onClick={() => act(`rm-${a.id}`, () => api.removeAccount(a.id))} aria-label={t('accounts.removeAria', { label: a.label })}><Icon name="trash" />{t('accounts.remove')}</button></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</Card>
|
||||
<div className="grid g2">
|
||||
<Card title="Google Play" icon="key" sub={t('accounts.playSub')}>
|
||||
{!d.accounts.length && !d.studios.length && <Card pad={false}><Empty illo="key" title={t('accounts.emptyTitle')}>{t('accounts.emptyTextStudios')}</Empty></Card>}
|
||||
{d.studios.map(s => <StudioCard key={s.id} s={s} accounts={d.accounts.filter(a => a.studio === s.id)} focus={focus === s.id} />)}
|
||||
{(unassigned.length > 0 || !d.studios.length) && d.accounts.length > 0 && <StudioCard s={null} accounts={unassigned} focus={focus === 'none'} />}
|
||||
|
||||
<div className="grid g3" style={{ alignItems: 'start' }}>
|
||||
<Card title={t('studio.new')} icon="building" sub={t('studio.newSub')}>
|
||||
<form className="form" onSubmit={e => { e.preventDefault(); addStudio(); }}>
|
||||
<div className="field"><label htmlFor="studio-name">{t('studio.name')}</label><input id="studio-name" className="input" value={studioName} onChange={e => setStudioName(e.target.value)} maxLength={48} placeholder={t('studio.namePlaceholder')} autoComplete="off" /></div>
|
||||
<div className="field"><span className="legend">{t('studio.color')}</span><Swatches value={studioColor} onChange={setStudioColor} name={studioName} /></div>
|
||||
<div><button className="btn btn-primary" type="submit" disabled={!studioName.trim() || !!busy}><Icon name="plus" />{t('studio.add')}</button></div>
|
||||
</form>
|
||||
</Card>
|
||||
<Card title="Google Play" icon="key" sub={t('accounts.playSubMany')}>
|
||||
<form className="form" onSubmit={e => { e.preventDefault(); addPlay(); }}>
|
||||
<div className="field">
|
||||
<label htmlFor="play-label">{t('accounts.label')}</label>
|
||||
<input id="play-label" className="input" type="text" value={playLabel} onChange={e => setPlayLabel(e.target.value)} placeholder={t('accounts.playPlaceholder')} maxLength={60} autoComplete="off" />
|
||||
</div>
|
||||
<div className="field"><label htmlFor="play-label">{t('accounts.label')}</label>
|
||||
<input id="play-label" className="input" type="text" value={playLabel} onChange={e => setPlayLabel(e.target.value)} placeholder={t('accounts.playPlaceholder')} maxLength={60} autoComplete="off" /></div>
|
||||
{studioSelect('play-studio', playStudio, setPlayStudio)}
|
||||
<p className="small muted">{t('accounts.playHow')}</p>
|
||||
<div><button className="btn btn-primary" type="submit" disabled={!playLabel.trim() || !!busy}><Icon name="key" />{t('accounts.choosePlay')}</button></div>
|
||||
</form>
|
||||
</Card>
|
||||
<Card title="App Store Connect" icon="key" sub={t('accounts.appleSub')}>
|
||||
<Card title="App Store Connect" icon="key" sub={t('accounts.appleSubMany')}>
|
||||
<form className="form" onSubmit={e => { e.preventDefault(); addApple(); }}>
|
||||
<div className="field">
|
||||
<label htmlFor="apple-label">{t('accounts.label')}</label>
|
||||
<input id="apple-label" className="input" type="text" value={appleLabel} onChange={e => setAppleLabel(e.target.value)} placeholder={t('accounts.applePlaceholder')} maxLength={60} autoComplete="off" />
|
||||
</div>
|
||||
<div className="field"><label htmlFor="apple-label">{t('accounts.label')}</label>
|
||||
<input id="apple-label" className="input" type="text" value={appleLabel} onChange={e => setAppleLabel(e.target.value)} placeholder={t('accounts.applePlaceholder')} maxLength={60} autoComplete="off" /></div>
|
||||
{studioSelect('apple-studio', appleStudio, setAppleStudio)}
|
||||
<div className="grid g2" style={{ gap: 12 }}>
|
||||
<div className="field">
|
||||
<label htmlFor="key-id">{t('accounts.keyId')}</label>
|
||||
<input id="key-id" className="input mono" type="text" value={keyId} onChange={e => setKeyId(e.target.value.toUpperCase())} placeholder="ABC123DEFG" maxLength={10} autoComplete="off" spellCheck={false} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="issuer-id">{t('accounts.issuerId')}</label>
|
||||
<input id="issuer-id" className="input mono" type="text" value={issuerId} onChange={e => setIssuerId(e.target.value)} placeholder="UUID" maxLength={36} autoComplete="off" spellCheck={false} />
|
||||
</div>
|
||||
<div className="field"><label htmlFor="key-id">{t('accounts.keyId')}</label>
|
||||
<input id="key-id" className="input mono" type="text" value={keyId} onChange={e => setKeyId(e.target.value.toUpperCase())} placeholder="ABC123DEFG" maxLength={10} autoComplete="off" spellCheck={false} /></div>
|
||||
<div className="field"><label htmlFor="issuer-id">{t('accounts.issuerId')}</label>
|
||||
<input id="issuer-id" className="input mono" type="text" value={issuerId} onChange={e => setIssuerId(e.target.value)} placeholder="UUID" maxLength={36} autoComplete="off" spellCheck={false} /></div>
|
||||
</div>
|
||||
<div><button className="btn btn-primary" type="submit" disabled={!appleLabel.trim() || keyId.length !== 10 || issuerId.length < 36 || !!busy}><Icon name="key" />{t('accounts.chooseApple')}</button></div>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
<Notice tone="info" icon="shield">{t('accounts.safety')}</Notice>
|
||||
<Notice tone="info" icon="shield">{t('accounts.safetyStudios')}</Notice>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue