portal-mojo 0.2.21 → 0.2.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/ModelTable.md +11 -0
- package/docs/admin-assistant.md +75 -23
- package/docs/admin-dns-domains.md +96 -90
- package/docs/admin-dns-records.md +1 -1
- package/docs/admin-dns-registrar.md +29 -4
- package/docs/admin-dns.md +2 -3
- package/docs/admin-modals.md +21 -0
- package/docs/menus-and-access.md +24 -0
- package/package.json +1 -1
- package/src/admin/assistant/AssistantFeed.tsx +7 -4
- package/src/admin/assistant/AssistantSetupPage.tsx +141 -64
- package/src/admin/assistant/api.ts +9 -7
- package/src/admin/assistant/pages.tsx +125 -109
- package/src/admin/assistant/setup.ts +1 -1
- package/src/admin/assistant/skill-lifecycle.ts +5 -4
- package/src/admin/core/index.ts +10 -2
- package/src/admin/dns/DnsRecordsPanel.tsx +45 -9
- package/src/admin/dns/DomainAssignment.tsx +55 -0
- package/src/admin/dns/DomainDetail.tsx +55 -38
- package/src/admin/dns/DomainOnboardingContent.tsx +9 -4
- package/src/admin/dns/DomainPurchaseDetail.tsx +42 -7
- package/src/admin/dns/DomainPurchaseWizard.tsx +102 -72
- package/src/admin/dns/DomainPurchasesPage.tsx +1 -1
- package/src/admin/dns/DomainRegisterExisting.tsx +21 -6
- package/src/admin/dns/DomainRegistrarSettings.tsx +28 -48
- package/src/admin/dns/DomainRegistrationPanel.tsx +127 -0
- package/src/admin/dns/DomainsPage.tsx +25 -24
- package/src/admin/dns/ProviderCredentialsPage.tsx +32 -18
- package/src/admin/dns/RegistrantContactPage.tsx +73 -27
- package/src/admin/dns/api.ts +26 -25
- package/src/admin/dns/domain-authority.ts +11 -0
- package/src/admin/dns/index.ts +2 -2
- package/src/admin/dns/purchase-data.ts +5 -3
- package/src/admin/dns/registrar-api.ts +57 -11
- package/src/admin/domains/infrastructure.ts +8 -8
- package/src/admin/domains/operations.ts +6 -6
- package/src/admin/edge/VhostsPage.tsx +6 -1
- package/src/admin/jobs/JobDashboardPage.tsx +14 -4
- package/src/client/mock.ts +14 -8
- package/src/client/params.ts +3 -2
- package/src/styles/components/admin-assistant.css +83 -20
- package/src/styles/components/admin-domains.css +67 -11
- package/src/styles/components/admin-edge.css +1 -0
- package/src/styles/components/admin-jobs.css +1 -0
- package/src/ui/FilterBar.tsx +16 -10
- package/src/ui/ModelTable.tsx +7 -3
- package/src/ui/SidebarNav.tsx +10 -1
- package/src/ui/menu-registry.ts +2 -0
- package/src/ui/preset-owned.ts +39 -0
package/src/client/mock.ts
CHANGED
|
@@ -6779,6 +6779,8 @@ let domainRegistrarSaveFault: 'warning' | 'ambiguous' | null = null;
|
|
|
6779
6779
|
let dnsWriteFault: 'reject' | 'ambiguous' | 'reconcile' | null = null;
|
|
6780
6780
|
let dnsFailNextRead = false;
|
|
6781
6781
|
const mockRegistrantScopeState = new Map<string, 'saved' | 'cleared'>();
|
|
6782
|
+
const mockRegistrantConfigured = (group: number) => mockRegistrantScopeState.get(group > 0 ? `group:${group}` : 'house') === 'saved'
|
|
6783
|
+
|| dnsRegistrarMode !== 'contact-missing' && (group === 0 || group % 2 === 1);
|
|
6782
6784
|
|
|
6783
6785
|
export type MockUploadMode = 'raw-put' | 'raw-put-unprefixed' | 'config-put' | 'config-put-bearer' | 'config-post';
|
|
6784
6786
|
export type MockUploadFault = 'initiate-ambiguous' | 'transfer-ambiguous' | 'transfer-failed' | 'transfer-expired' | 'complete-reject' | 'complete-ambiguous' | 'complete-body-malformed';
|
|
@@ -6903,7 +6905,7 @@ export function setMockDnsAcmeMode(value: 'production' | 'staging' | 'unconfigur
|
|
|
6903
6905
|
export function setMockDnsRegistrarMode(value: 'ready' | 'disabled' | 'contact-missing'): void { dnsRegistrarMode = value; }
|
|
6904
6906
|
|
|
6905
6907
|
/** Verifier-only one-shot result after a confirmation token is consumed. */
|
|
6906
|
-
export function armMockRegistrarPurchaseFault(value: 'failed' | 'ambiguous'): void { dnsRegistrarFault = value; }
|
|
6908
|
+
export function armMockRegistrarPurchaseFault(value: 'failed' | 'ambiguous' | null): void { dnsRegistrarFault = value; }
|
|
6907
6909
|
export function armMockDomainRegistrarSaveFault(value: 'warning' | 'ambiguous'): void { domainRegistrarSaveFault = value; }
|
|
6908
6910
|
|
|
6909
6911
|
/** Verifier-only one-shot DNS transport outcomes; all state still lives in db.dnsRecords. */
|
|
@@ -9767,7 +9769,7 @@ async function mockFetchInner(path: string, opts: MockFetchOpts): Promise<unknow
|
|
|
9767
9769
|
const caller = userFromBearer(opts.headers);
|
|
9768
9770
|
if (!caller) return permissionDenied(401);
|
|
9769
9771
|
const assistantGate = hasGlobalPermission(caller, ['view_admin', 'assistant']);
|
|
9770
|
-
const canInspect = (row: MockAssistantConversation) => row.user === caller.id ||
|
|
9772
|
+
const canInspect = (row: MockAssistantConversation) => row.user === caller.id || hasGlobalPermission(caller, ['view_admin']);
|
|
9771
9773
|
const canDelete = (row: MockAssistantConversation) => row.user === caller.id || hasGlobalPermission(caller, ['view_admin']);
|
|
9772
9774
|
const assistantUser = (userId: number) => { const row = db.users.find((user) => user.id === userId); return row ? { id: row.id, display_name: row.display_name } : { id: userId, display_name: `User #${userId}` }; };
|
|
9773
9775
|
const conversationWire = (row: MockAssistantConversation, detail = false) => ({ id: row.id, title: row.title, metadata: { ...row.metadata }, created: row.created, modified: row.modified, user: assistantUser(row.user), ...(detail ? { messages: row.messages.map((message) => ({ ...message, tool_calls: [...message.tool_calls], blocks: message.blocks == null ? null : structuredClone(message.blocks), usage: { ...message.usage } })) } : {}) });
|
|
@@ -9826,14 +9828,17 @@ async function mockFetchInner(path: string, opts: MockFetchOpts): Promise<unknow
|
|
|
9826
9828
|
const id = conversationMatch[1] ? Number(conversationMatch[1]) : null;
|
|
9827
9829
|
if (id != null) { const row = db.assistantConversations.find((candidate) => candidate.id === id); if (!row || !canInspect(row)) return { status: false, error: 'Conversation not found', error_code: 404 }; if (method === 'DELETE') { if (!canDelete(row)) return permissionDenied(); db.assistantConversations = db.assistantConversations.filter((candidate) => candidate.id !== id); return { status: 'deleted' }; } if (method !== 'GET') return { status: false, error: 'Method not allowed', error_code: 405 }; return { status: true, data: conversationWire(row, opts.params?.graph === 'detail'), graph: String(opts.params?.graph ?? 'default') }; }
|
|
9828
9830
|
if (method !== 'GET') return { status: false, error: 'Method not allowed', error_code: 405 };
|
|
9829
|
-
const
|
|
9831
|
+
const params = { ...opts.params, start: Math.max(0, Number(opts.params?.start ?? 0)), size: Math.min(100, Math.max(1, Number(opts.params?.size ?? 100))) };
|
|
9832
|
+
return { ...listRows(db.assistantConversations.filter(canInspect).map(row => conversationWire(row)), params, row => row.title, '-modified'), graph: 'default' };
|
|
9830
9833
|
}
|
|
9831
9834
|
const skillMatch = path.match(/^\/api\/assistant\/skill(?:\/(\d+))?$/);
|
|
9832
9835
|
if (skillMatch) {
|
|
9833
9836
|
if (!assistantGate) return permissionDenied(); const id = skillMatch[1] ? Number(skillMatch[1]) : null;
|
|
9834
9837
|
const skillWire = (row: MockAssistantSkill, detail = false) => ({ id: row.id, tier: row.tier, name: row.name, description: row.description, auto_execute: row.auto_execute, is_active: row.is_active, created: row.created, modified: row.modified, user: row.user == null ? null : assistantUser(row.user), ...(detail ? { triggers: [...row.triggers], steps: structuredClone(row.steps), metadata: { ...row.metadata } } : {}) });
|
|
9835
9838
|
if (id != null) { const row = db.assistantSkills.find((candidate) => candidate.id === id); if (!row) return { status: false, error: 'Skill not found', error_code: 404 }; if (method === 'DELETE') { db.assistantSkills = db.assistantSkills.filter((candidate) => candidate.id !== id); return { status: 'deleted' }; } if (method === 'POST') { if (!hasGlobalPermission(caller, ['view_admin'])) return permissionDenied(); if (typeof opts.body?.is_active !== 'boolean') return { status: false, error: 'is_active is required', error_code: 400 }; row.is_active = opts.body.is_active; row.modified = Math.floor(Date.now() / 1000); } else if (method !== 'GET') return { status: false, error: 'Method not allowed', error_code: 405 }; return { status: true, data: skillWire(row, opts.params?.graph === 'detail'), graph: String(opts.params?.graph ?? 'default') }; }
|
|
9836
|
-
if (method !== 'GET') return { status: false, error: 'Method not allowed', error_code: 405 };
|
|
9839
|
+
if (method !== 'GET') return { status: false, error: 'Method not allowed', error_code: 405 };
|
|
9840
|
+
const params = { ...opts.params, start: Math.max(0, Number(opts.params?.start ?? 0)), size: Math.min(100, Math.max(1, Number(opts.params?.size ?? 100))) };
|
|
9841
|
+
return { ...listRows(db.assistantSkills.map(row => skillWire(row)), params, row => row.name, 'name'), graph: 'default' };
|
|
9837
9842
|
}
|
|
9838
9843
|
const memoryMatch = path.match(/^\/api\/assistant\/memory\/(global|user|group)(?:\/([^/]+))?$/);
|
|
9839
9844
|
if (memoryMatch) {
|
|
@@ -9846,7 +9851,7 @@ async function mockFetchInner(path: string, opts: MockFetchOpts): Promise<unknow
|
|
|
9846
9851
|
}
|
|
9847
9852
|
const store: Record<string, unknown> = tier === 'global' ? db.assistantMemory.global : tier === 'user' ? (db.assistantMemory.users.get(caller.id) ?? {}) : (db.assistantMemory.groups.get(groupId!) ?? {});
|
|
9848
9853
|
if (method === 'GET' && entryKey == null) return { status: true, data: { ...store } };
|
|
9849
|
-
if (method === 'POST' && entryKey == null) { const key = typeof opts.body?.key === 'string' ? opts.body.key : ''; const value = typeof opts.body?.value === 'string' ? opts.body.value : ''; if (!/^[a-z0-9:_-]{
|
|
9854
|
+
if (method === 'POST' && entryKey == null) { const key = typeof opts.body?.key === 'string' ? opts.body.key : ''; const value = typeof opts.body?.value === 'string' ? opts.body.value : ''; if (!/^[a-z0-9][a-z0-9:_-]{0,63}$/.test(key) || value.length < 1 || value.length > 500) return { status: false, error: 'Invalid memory key or value', error_code: 400 }; store[key] = value; if (tier === 'user') db.assistantMemory.users.set(caller.id, store); if (tier === 'group') db.assistantMemory.groups.set(groupId!, store); return { status: true, data: { key, value } }; }
|
|
9850
9855
|
if (method === 'DELETE' && entryKey != null) { delete store[entryKey]; return { status: true, data: { deleted: entryKey } }; }
|
|
9851
9856
|
return { status: false, error: 'Method not allowed', error_code: 405 };
|
|
9852
9857
|
}
|
|
@@ -10339,7 +10344,7 @@ async function mockFetchInner(path: string, opts: MockFetchOpts): Promise<unknow
|
|
|
10339
10344
|
status: true,
|
|
10340
10345
|
data: {
|
|
10341
10346
|
purchase_enabled: dnsRegistrarMode !== 'disabled',
|
|
10342
|
-
registrant_contact_configured:
|
|
10347
|
+
registrant_contact_configured: mockRegistrantConfigured(groupId),
|
|
10343
10348
|
max_domain_price: '100.00', currency: 'USD', quote_ttl_minutes: 15,
|
|
10344
10349
|
allowed_record_types: ['A', 'AAAA', 'CAA', 'CNAME', 'MX', 'NS', 'SRV', 'TXT'],
|
|
10345
10350
|
search_batch_limit: 20, suggestions_enabled: true,
|
|
@@ -10740,7 +10745,7 @@ async function mockFetchInner(path: string, opts: MockFetchOpts): Promise<unknow
|
|
|
10740
10745
|
const group = db.groups.find((candidate) => candidate.id === groupId && candidate.is_active);
|
|
10741
10746
|
if (!group || !body.domain) return missingParams('group', 'domain');
|
|
10742
10747
|
if (dnsRegistrarMode === 'disabled') return { status: false, error: 'Domain purchasing is disabled on this deployment (DNSMAN_PURCHASE_ENABLED is off).', error_code: 400 };
|
|
10743
|
-
if (
|
|
10748
|
+
if (!mockRegistrantConfigured(groupId)) return { status: false, error: 'No registrant contact is configured', error_code: 400 };
|
|
10744
10749
|
const answer = resultRow(String(body.domain));
|
|
10745
10750
|
if (answer.available !== true || !answer.tld_supported) return { status: false, error: 'The domain cannot be quoted', error_code: 400 };
|
|
10746
10751
|
const years = Number(body.years ?? 1);
|
|
@@ -10841,7 +10846,7 @@ async function mockFetchInner(path: string, opts: MockFetchOpts): Promise<unknow
|
|
|
10841
10846
|
const direct = state === 'saved' || (state == null && groupId === 1);
|
|
10842
10847
|
const settingsFile = state == null && groupId === 0;
|
|
10843
10848
|
const contact = direct || settingsFile ? canonical : null;
|
|
10844
|
-
return { status: true, data: { scope: groupId > 0 ? 'group' : 'global', group: groupId || null, contact, source: direct ? 'database' : settingsFile ? 'settings_file' : 'none', inherited: groupId > 0 && !contact && groupId % 2 === 0, effective_configured:
|
|
10849
|
+
return { status: true, data: { scope: groupId > 0 ? 'group' : 'global', group: groupId || null, contact, source: direct ? 'database' : settingsFile ? 'settings_file' : 'none', inherited: groupId > 0 && !contact && groupId % 2 === 0, effective_configured: mockRegistrantConfigured(groupId), problems: [] } };
|
|
10845
10850
|
}
|
|
10846
10851
|
|
|
10847
10852
|
if (path === '/api/dnsman/whois' || path === '/api/dnsman/whois/privacy') {
|
|
@@ -10852,6 +10857,7 @@ async function mockFetchInner(path: string, opts: MockFetchOpts): Promise<unknow
|
|
|
10852
10857
|
if (!domain) return { status: false, error: 'Domain not found', error_code: 404 };
|
|
10853
10858
|
if (domain.provider !== 'route53') return { status: false, error: 'Registrar operations are not available for this provider', error_code: 400 };
|
|
10854
10859
|
if (domain.group == null ? !caller.is_superuser : !dnsMemberCan(caller, domain.group, true)) return permissionDenied();
|
|
10860
|
+
if (domain.registered_on == null) return { status: false, error: 'This hosted zone has no registration in the Route 53 Domains account. Check its registrar.', error_code: 400 };
|
|
10855
10861
|
if (path.endsWith('/privacy')) {
|
|
10856
10862
|
domain.privacy = Boolean(input.enabled); domain.modified = Math.floor(Date.now() / 1000);
|
|
10857
10863
|
return { status: true, data: { name: domain.name, privacy: domain.privacy, operation_id: `op-privacy-${domain.id}` } };
|
package/src/client/params.ts
CHANGED
|
@@ -41,7 +41,8 @@ export interface TableParamsApi extends TableParams {
|
|
|
41
41
|
removeFilter(f: ActiveFilter): void;
|
|
42
42
|
applyPreset(params: Record<string, string>): void;
|
|
43
43
|
presetActive(params: Record<string, string>): boolean;
|
|
44
|
-
|
|
44
|
+
/** Clears search and filters; `keep` survives (the selected preset's own keys). */
|
|
45
|
+
clearFilters(keep?: Readonly<Record<string, string>>): void;
|
|
45
46
|
setPage(page: number): void;
|
|
46
47
|
setSize(size: number): void;
|
|
47
48
|
/**
|
|
@@ -295,7 +296,7 @@ export function useTableParams(
|
|
|
295
296
|
if (keys.length === 0) return Object.keys(filters).length === 0;
|
|
296
297
|
return keys.every((k) => filters[k] === params[k]);
|
|
297
298
|
},
|
|
298
|
-
clearFilters: () => write({ ...state, search: '', page: 1 }, {}),
|
|
299
|
+
clearFilters: (keep) => write({ ...state, search: '', page: 1 }, { ...keep }),
|
|
299
300
|
setPage: (page) => write({ ...state, page }, filters),
|
|
300
301
|
setSize: (size) => write({ ...state, size, page: 1 }, filters),
|
|
301
302
|
applySaved: (saved) => {
|
|
@@ -26,25 +26,88 @@
|
|
|
26
26
|
.assistant-inspect > header, .assistant-skill-detail > header { display: flex; justify-content: space-between; gap: 12px; }.assistant-inspect > .record-feed { flex: 1; min-height: 0; }
|
|
27
27
|
.assistant-memory-controls { display: grid; gap: 12px; margin-bottom: 12px; }.assistant-memory-form { display: grid; grid-template-columns: minmax(180px, .6fr) minmax(260px, 1fr) auto; gap: 10px; align-items: end; }
|
|
28
28
|
@media (max-width: 760px) { .assistant-memory-form { grid-template-columns: 1fr; } }
|
|
29
|
-
.assistant-setup { display: grid;
|
|
30
|
-
.assistant-setup h1
|
|
31
|
-
.assistant-setup
|
|
32
|
-
.assistant-setup-
|
|
33
|
-
.assistant-setup-switch { padding-top: 20px; margin-top: 18px; border-top: 1px solid var(--line); }
|
|
34
|
-
.assistant-setup-switch > span { flex: 1; min-width: 200px; }
|
|
35
|
-
.assistant-setup-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 14px; margin: 18px 0; }
|
|
29
|
+
.assistant-setup { display: grid; min-width: 0; margin: 0 auto; width: 100%; }
|
|
30
|
+
.assistant-setup h1 { font-size: clamp(24px, 3vw, 32px); font-weight: 700; line-height: 1.2; margin: 0; }
|
|
31
|
+
.assistant-setup-header { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 14px; }
|
|
32
|
+
.assistant-setup-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 14px; margin: 18px 0; }
|
|
36
33
|
.assistant-setup-facts dt { color: var(--mute); font-size: 12px; }.assistant-setup-facts dd { margin: 5px 0 0; overflow-wrap: anywhere; }
|
|
37
|
-
.assistant-setup-field { display:
|
|
38
|
-
.assistant-setup-
|
|
39
|
-
|
|
40
|
-
.assistant-setup
|
|
41
|
-
.assistant-setup
|
|
42
|
-
|
|
43
|
-
.assistant-setup
|
|
44
|
-
.assistant-setup
|
|
34
|
+
.assistant-setup-field > span { display: flex; align-items: center; gap: 6px; }
|
|
35
|
+
.assistant-setup-field { display: grid; gap: 8px; margin: 20px 0; }.assistant-setup-field input, .assistant-setup-field select { width: 100%; min-width: 0; }
|
|
36
|
+
/* Setup is a compact overview. Task dialogs own deeper configuration. */
|
|
37
|
+
.assistant-setup { max-width: 960px; gap: 20px; }
|
|
38
|
+
.assistant-setup-header p { margin: 7px 0 0; }
|
|
39
|
+
.assistant-setup .assistant-setup-overview { padding: 24px; }
|
|
40
|
+
.assistant-setup-status, .assistant-setup-status > div { display: flex; align-items: center; gap: 10px; }
|
|
41
|
+
.assistant-setup-status { justify-content: space-between; flex-wrap: wrap; gap: 16px; padding-bottom: 22px; }
|
|
42
|
+
.assistant-setup-status h2 { font-weight: 600; margin: 0; font-size: 20px; }
|
|
43
|
+
.assistant-setup-dot { width: 9px; height: 9px; border-radius: 50%; background: var(--mute); flex: 0 0 auto; }
|
|
44
|
+
.assistant-setup-dot.is-ready { background: var(--ok); }.assistant-setup-dot.is-stopped { background: var(--warn); }
|
|
45
|
+
.assistant-setup-row { display: flex; align-items: center; gap: 18px; padding: 20px 0; border-top: 1px solid var(--line); min-width: 0; }
|
|
46
|
+
.assistant-setup-row > div { flex: 1; min-width: 0; }.assistant-setup-row > .btn { flex-shrink: 0; }
|
|
47
|
+
.assistant-setup-row h3 { font-weight: 600; font-size: 15px; margin: 0; display: flex; align-items: center; gap: 6px; }
|
|
48
|
+
.assistant-setup-row p { color: var(--mute); margin: 5px 0; font-size: 14px; }
|
|
49
|
+
.assistant-setup-row code { font-size: 12px; overflow-wrap: anywhere; }
|
|
50
|
+
.assistant-setup-tools { padding: 0 24px; }.assistant-setup-tools > .assistant-setup-row:first-child { border-top: 0; }
|
|
51
|
+
.assistant-setup-row > i { color: var(--accent); font-size: 19px; flex-shrink: 0; }
|
|
52
|
+
.assistant-setup-help { display: inline-flex; vertical-align: middle; justify-content: center; align-items: center; border: 0; border-radius: 50%; width: 28px; height: 28px; padding: 0; color: var(--mute); background: transparent; cursor: pointer; flex: 0 0 auto; }
|
|
53
|
+
.assistant-setup-help:hover { color: var(--accent); background: var(--surface2); }.assistant-setup-help:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
54
|
+
.assistant-setup-help-content { width: min(340px, calc(100vw - 32px)); max-height: min(500px, 75dvh); overflow: auto; padding: 16px; font-size: 14px; line-height: 1.5; overflow-wrap: anywhere; }
|
|
55
|
+
.assistant-setup-help-content p { margin: 0 0 10px; }.assistant-setup-help-content p:last-child { margin-bottom: 0; }
|
|
56
|
+
.assistant-setup-help-content .assistant-setup-facts { grid-template-columns: 1fr; margin: 0; }
|
|
57
|
+
.assistant-setup-dialog { margin: auto; width: min(680px, calc(100vw - 32px)); max-width: none; max-height: calc(100dvh - 32px); padding: 0; border: 1px solid var(--line); border-radius: 16px; background: var(--surface); color: var(--ink); box-shadow: 0 24px 80px #0004; overflow: hidden; }
|
|
58
|
+
.assistant-setup-dialog[open] { display: flex; flex-direction: column; }.assistant-setup-dialog::backdrop { background: #0007; }
|
|
59
|
+
.assistant-setup-dialog > header { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 22px 24px 18px; border-bottom: 1px solid var(--line); flex-shrink: 0; }
|
|
60
|
+
.assistant-setup-dialog h2 { margin: 0; font-size: 22px; line-height: 1.3; font-weight: 650; }.assistant-setup-dialog h3 { margin: 24px 0 10px; font-size: 16px; font-weight: 650; }.assistant-setup-dialog-content > h3:first-child { margin-top: 0; }
|
|
61
|
+
.assistant-setup-dialog-content { overflow: auto; padding: 24px; min-height: 0; overscroll-behavior: contain; }
|
|
62
|
+
.assistant-setup-dialog-content > :first-child { margin-top: 0; }.assistant-setup-dialog-content > :last-child { margin-bottom: 0; }
|
|
63
|
+
.assistant-setup-dialog p { margin: 0 0 12px; line-height: 1.5; overflow-wrap: anywhere; }.assistant-setup-dialog code { overflow-wrap: anywhere; }
|
|
64
|
+
.assistant-setup-dialog > footer { display: flex; justify-content: flex-end; padding: 16px 24px; border-top: 1px solid var(--line); flex-shrink: 0; }
|
|
65
|
+
.assistant-setup-dialog .assistant-action-row { flex-wrap: wrap; align-items: center; gap: 10px; }
|
|
66
|
+
.assistant-setup-dialog .assistant-setup-row:first-child { border-top: 0; padding-top: 0; }
|
|
67
|
+
.assistant-setup-dialog .assistant-setup-row h3 { margin: 0; }.assistant-setup-dialog .assistant-setup-field { max-width: none; }
|
|
68
|
+
.assistant-setup-dialog .assistant-setup-key-status { padding-top: 6px; }.assistant-setup-key-status p { margin: 6px 0; }
|
|
69
|
+
.assistant-setup-current { display: grid; gap: 8px; background: var(--surface2); padding: 14px; border-radius: 8px; }.assistant-setup-current > span { font-size: 12px; color: var(--mute); }
|
|
70
|
+
.assistant-setup-catalogue { margin-top: 18px; display: flex; gap: 6px; align-items: center; font-size: 13px; }
|
|
71
|
+
.assistant-setup-danger-row { border-top: 1px solid var(--line); margin-top: 24px; padding-top: 18px; }
|
|
72
|
+
.assistant-setup-warning { border: 1px solid var(--warn); border-radius: 8px; padding: 12px; font-size: 14px; line-height: 1.5; }
|
|
73
|
+
.assistant-setup-notice { padding: 12px; background: var(--surface2); border-radius: 8px; font-size: 14px; }
|
|
74
|
+
.assistant-setup-text-button { background: none; color: var(--accent); border: 0; padding: 0; cursor: pointer; font: inherit; text-decoration: underline; }
|
|
75
|
+
.assistant-setup-dialog .assistant-table-scroll { overflow-x: auto; max-width: 100%; margin: 14px 0; }.assistant-setup-dialog table { border-collapse: collapse; width: 100%; font-size: 13px; }.assistant-setup-dialog th, .assistant-setup-dialog td { padding: 10px; text-align: left; border-bottom: 1px solid var(--line); }.assistant-setup-dialog caption { text-align: left; color: var(--mute); margin-bottom: 8px; }
|
|
76
|
+
.assistant-setup-dialog-confirm { width: min(460px, calc(100vw - 32px)); }
|
|
77
|
+
@media (max-width: 600px) {
|
|
78
|
+
.assistant-setup { gap: 16px; }.assistant-setup .assistant-setup-overview { padding: 18px; }.assistant-setup-tools { padding: 0 18px; }
|
|
79
|
+
.assistant-setup-row { flex-wrap: wrap; gap: 12px; }.assistant-setup-row > div { flex-basis: calc(100% - 40px); }.assistant-setup-row > .btn { margin-left: auto; }
|
|
80
|
+
.assistant-setup-status > .btn { width: 100%; }.assistant-setup-dialog > header { padding: 18px; }.assistant-setup-dialog-content { padding: 18px; }.assistant-setup-dialog > footer { padding: 14px 18px; }
|
|
81
|
+
.assistant-setup-dialog .assistant-action-row { flex-direction: column; align-items: stretch; }.assistant-setup-dialog .assistant-setup-facts { grid-template-columns: 1fr; }.assistant-setup-dialog button { white-space: normal; }
|
|
82
|
+
}
|
|
83
|
+
.assistant-setup-area { display: grid; gap: 20px; }
|
|
84
|
+
.assistant-setup-mcp h2 { margin: 0 0 8px; font-size: 20px; font-weight: 650; }.assistant-setup-mcp > h3 { margin: 24px 0 12px; font-size: 16px; font-weight: 600; }
|
|
85
|
+
.assistant-setup-mcp > p { margin: 0 0 16px; }.assistant-setup-mcp .assistant-setup-field { max-width: 680px; }
|
|
86
|
+
.assistant-setup-mcp .assistant-action-row { gap: 12px; }.assistant-setup-mcp .assistant-setup-row:first-of-type { margin-top: 22px; }
|
|
87
|
+
@media (max-width: 600px) { .assistant-setup-mcp.panel-pad { padding: 18px; }.assistant-setup-mcp .assistant-action-row { flex-direction: column; align-items: stretch; } }
|
|
45
88
|
|
|
46
|
-
.assistant-setup-
|
|
47
|
-
.assistant-setup-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
89
|
+
.assistant-setup-address { display: flex; align-items: center; gap: 10px; }.assistant-setup-address input { flex: 1; }.assistant-setup-address button { flex-shrink: 0; }
|
|
90
|
+
@media (max-width: 600px) { .assistant-setup-address { flex-direction: column; align-items: stretch; } }
|
|
91
|
+
|
|
92
|
+
/* Assistant management pages: one task per page, focused record dialogs. */
|
|
93
|
+
.assistant-admin-page { width: 100%; min-width: 0; display: grid; gap: 20px; max-width: 1120px; margin: 0 auto; }
|
|
94
|
+
.assistant-page-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
|
|
95
|
+
.assistant-page-heading .eyebrow { display: block; margin-bottom: 8px; color: var(--mute); font-size: 11px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; }
|
|
96
|
+
.assistant-page-heading h1 { margin: 0; font-size: 28px; line-height: 1.25; font-weight: 700; display: flex; align-items: center; gap: 10px; }
|
|
97
|
+
.assistant-page-actions { display: flex; gap: 8px; align-items: center; flex-shrink: 0; }
|
|
98
|
+
.assistant-page-help { display: inline-flex; align-items: center; justify-content: center; vertical-align: middle; width: 28px; height: 28px; padding: 0; border: 0; background: transparent; color: var(--mute); cursor: pointer; border-radius: 6px; font-size: 15px; flex-shrink: 0; }
|
|
99
|
+
.assistant-page-help:hover { color: var(--ink); background: var(--surface2); }.assistant-page-help:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
100
|
+
.assistant-page-help-content { width: min(340px, calc(100vw - 24px)); padding: 16px; font-size: 13px; line-height: 1.6; overflow-wrap: anywhere; }.assistant-page-help-content p { margin: 0 0 10px; }.assistant-page-help-content p:last-child { margin: 0; }
|
|
101
|
+
.assistant-page-search { position: relative; max-width: 380px; }.assistant-page-search > i { position: absolute; left: 12px; top: 12px; color: var(--mute); }.assistant-page-search .input { padding-left: 36px; width: 100%; }
|
|
102
|
+
.assistant-page-list { overflow: hidden; min-width: 0; }.assistant-page-list table { width: 100%; border-collapse: collapse; table-layout: fixed; }.assistant-page-list th, .assistant-page-list td { padding: 16px 20px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: middle; overflow-wrap: anywhere; }.assistant-page-list th { background: var(--surface2); color: var(--mute); font-size: 11px; text-transform: uppercase; letter-spacing: .07em; font-weight: 700; }.assistant-page-list th:first-child { width: 55%; }.assistant-page-list tr:last-child td { border-bottom: 0; }.assistant-page-list td small { display: block; color: var(--mute); font-size: 12px; margin-top: 5px; line-height: 1.5; }.assistant-page-list .link-button { text-align: left; font-weight: 600; overflow-wrap: anywhere; }.assistant-page-list td .badge { margin-left: 6px; }
|
|
103
|
+
.assistant-page-empty { padding: 32px 24px; margin: 0; text-align: center; color: var(--mute); }.assistant-page-empty p { margin: 0 0 8px; }.assistant-page-empty .btn { margin-top: 12px; }
|
|
104
|
+
.assistant-page-pager { display: flex; justify-content: flex-end; align-items: center; gap: 8px; padding: 12px 16px; border-top: 1px solid var(--line); }.assistant-page-pager span { margin-right: auto; color: var(--mute); font-size: 12px; }
|
|
105
|
+
.assistant-page-dialog { width: min(640px, calc(100vw - 32px)); max-width: none; max-height: calc(100dvh - 40px); padding: 0; margin: auto; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); color: var(--ink); box-shadow: var(--shadow-pop); overflow: hidden; }
|
|
106
|
+
.assistant-page-dialog[open] { display: flex; flex-direction: column; }.assistant-page-dialog::backdrop { background: rgb(0 0 0 / .55); backdrop-filter: blur(3px); }.assistant-page-dialog > header { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 20px 24px; border-bottom: 1px solid var(--line); flex: none; }.assistant-page-dialog h2 { margin: 0; font-size: 20px; font-weight: 700; line-height: 1.4; overflow-wrap: anywhere; }.assistant-page-dialog-body { min-height: 0; overflow-y: auto; padding: 24px; }.assistant-page-dialog-body > .field { margin-top: 20px; }.assistant-page-dialog-body > p:first-child { margin-top: 0; }.assistant-page-dialog > footer { display: flex; justify-content: flex-end; gap: 10px; flex-wrap: wrap; border-top: 1px solid var(--line); padding: 16px 24px; flex: none; }.assistant-page-meta { color: var(--mute); margin: 0 0 18px; font-size: 12px; line-height: 1.6; }.assistant-page-readonly { padding: 10px 12px; background: var(--surface2); color: var(--mute); border-radius: 8px; font-size: 12px; line-height: 1.6; margin: 0 0 16px; }
|
|
107
|
+
.assistant-page-dialog-conversation { width: min(840px, calc(100vw - 32px)); height: min(760px, calc(100dvh - 40px)); }.assistant-page-dialog-conversation .assistant-page-dialog-body { display: flex; flex-direction: column; overflow: hidden; flex: 1; padding: 16px 20px; }.assistant-page-dialog-conversation .record-feed { flex: 1; min-height: 0; }.assistant-page-dialog-conversation .record-feed-scroll { min-height: 0; }
|
|
108
|
+
.assistant-page-chips { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }.assistant-skill-content { display: grid; gap: 22px; }.assistant-skill-description { line-height: 1.6; margin: 0; color: var(--mute); }.assistant-skill-content h3 { margin: 0 0 12px; font-size: 14px; font-weight: 700; }.assistant-skill-triggers { display: flex; flex-wrap: wrap; gap: 8px; list-style: none; padding: 0; margin: 0; }.assistant-skill-triggers li { padding: 5px 10px; border: 1px solid var(--line); border-radius: 6px; font-size: 12px; }.assistant-skill-steps { margin: 0; padding-left: 22px; list-style: decimal; }.assistant-skill-steps li { padding: 10px 0 10px 6px; border-bottom: 1px solid var(--line); line-height: 1.6; }.assistant-skill-steps li:last-child { border: 0; }.assistant-skill-steps small { display: block; color: var(--mute); }.assistant-page-warning { color: var(--warn); font-size: 13px; line-height: 1.6; margin: 0; }.assistant-page-notice { color: var(--ok); font-size: 13px; margin: 0 0 12px; }
|
|
109
|
+
.assistant-memory-area { padding-top: 20px; }.assistant-memory-group { max-width: 380px; margin-bottom: 20px; }.assistant-memory-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 16px; }.assistant-memory-toolbar > span { color: var(--mute); font-size: 13px; }.assistant-memory-list { list-style: none; padding: 0; margin: 0; }.assistant-memory-list > li { display: flex; justify-content: space-between; gap: 20px; padding: 20px; border-bottom: 1px solid var(--line); }.assistant-memory-list > li:last-child { border: 0; }.assistant-memory-list > li > div:first-child { min-width: 0; }.assistant-memory-list strong { font-size: 13px; overflow-wrap: anywhere; }.assistant-memory-list p { color: var(--mute); margin: 8px 0 0; line-height: 1.6; white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
110
|
+
@media (max-width:600px) { .assistant-admin-page { gap: 16px; }.assistant-page-heading h1 { font-size: 24px; }.assistant-page-refresh { min-width: 38px; justify-content: center; }.assistant-page-refresh > span { display: none; }.assistant-page-list th, .assistant-page-list td { padding: 12px; font-size: 12px; }.assistant-page-list th:first-child { width: 44%; }.assistant-page-list th { font-size: 10px; letter-spacing: .04em; padding: 12px 10px; white-space: nowrap; }.assistant-page-list td { padding: 12px 10px; }.assistant-page-list td small { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }.assistant-page-dialog { width: calc(100vw - 20px); max-height: calc(100dvh - 24px); }.assistant-page-dialog > header { padding: 16px; }.assistant-page-dialog-body { padding: 18px 16px; }.assistant-page-dialog > footer { padding: 14px 16px; }.assistant-page-dialog-conversation { height: calc(100dvh - 24px); }.assistant-page-dialog-conversation .assistant-page-dialog-body { padding: 12px; }.assistant-memory-toolbar { align-items: flex-start; flex-direction: column; gap: 10px; }.assistant-memory-toolbar > .assistant-page-actions { width: 100%; justify-content: flex-end; }.assistant-memory-list > li { padding: 16px; gap: 8px; } }
|
|
111
|
+
|
|
112
|
+
.assistant-page-dialog-history { height: fit-content; }.assistant-page-dialog-history .record-feed { flex: initial; height: auto; min-height: 0; max-height: calc(100dvh - 280px); }
|
|
113
|
+
.assistant-page-dialog-conversation .attachment-drop { min-block-size: 36px; inline-size: auto; padding: 7px 10px; border-style: solid; }
|
|
@@ -1,12 +1,68 @@
|
|
|
1
|
-
.domains-
|
|
1
|
+
.domains-page,.domains-page>* { min-width:0; }
|
|
2
|
+
.domains-header,.domains-actions,.domains-overview-actions,.domains-detail-header,.domains-detail-subtitle,.domains-task-help { display:flex;align-items:center;gap:10px; }
|
|
3
|
+
.domains-header { justify-content:space-between;flex-wrap:wrap;margin-bottom:24px;gap:16px; }
|
|
4
|
+
.domains-header h1 { margin:4px 0 8px;font-size:26px;line-height:1.2;font-weight:750;letter-spacing:-.025em;color:var(--ink-strong); }
|
|
5
|
+
.domains-header p { margin:0; }
|
|
6
|
+
.domains-actions,.domains-overview-actions { flex-wrap:wrap; }
|
|
7
|
+
.domains-action-menu { display:grid;gap:4px;padding:6px; }
|
|
8
|
+
.domains-action-menu .btn { justify-content:flex-start; }
|
|
9
|
+
.domains-page .toolbar-heading .panel-title { font-size:19px; }
|
|
10
|
+
.domains-scope,.domains-detail code { overflow-wrap:anywhere; }
|
|
11
|
+
.domains-narrow-scope,.domains-narrow-status { display:none; }
|
|
12
|
+
.domains-detail-header { justify-content:space-between;align-items:flex-start;padding:22px 24px 18px; }
|
|
13
|
+
.domains-detail-header>div { min-width:0; }
|
|
14
|
+
.domains-detail-header h2 { font-size:21px;overflow-wrap:anywhere;margin:0 0 8px; }
|
|
15
|
+
.domains-detail-subtitle { color:var(--mute);flex-wrap:wrap; }
|
|
16
|
+
.domains-detail>.mojo-tabs>.mojo-tab-list { padding:0 24px; }
|
|
17
|
+
.domains-detail-panel { padding:20px 24px;min-width:0; }
|
|
18
|
+
.domains-detail .flat-row { gap:12px; }
|
|
19
|
+
.domains-detail .flat-value { overflow-wrap:anywhere; }
|
|
20
|
+
.domains-overview-actions { margin-top:22px; }
|
|
21
|
+
.domains-assignment,.domains-registrar-settings { display:grid;gap:16px; }
|
|
22
|
+
.domains-assignment>.dim { margin:0; }
|
|
23
|
+
.domains-inline-action { justify-self:start; }
|
|
24
|
+
.domains-demo { display:grid;gap:16px;min-width:0;grid-template-columns:minmax(0,1fr); }
|
|
25
|
+
.domains-demo>* { min-width:0; }
|
|
26
|
+
.domains-demo .seg { flex-wrap:wrap; }
|
|
27
|
+
.domain-onboarding>.dim { margin:4px 0 20px; }
|
|
28
|
+
.domain-onboarding .dns-registrar-grid { gap:18px; }
|
|
29
|
+
.domain-onboarding .dns-onboarding-options { grid-template-columns:1fr;gap:10px;margin-top:20px; }
|
|
30
|
+
.domain-onboarding .dns-onboarding-options>button { grid-template-columns:26px 1fr;column-gap:14px;row-gap:3px;padding:16px; }
|
|
31
|
+
.domain-onboarding .dns-onboarding-options>button>i { grid-row:1/3;font-size:22px;align-self:center; }
|
|
32
|
+
.domain-onboarding .dns-onboarding-options>button>span { grid-column:2; }
|
|
33
|
+
@media(max-width:560px) {
|
|
34
|
+
.domains-header { align-items:flex-start; }
|
|
35
|
+
.domains-actions { width:100%; }
|
|
36
|
+
.domains-actions .btn-primary { margin-left:auto; }
|
|
37
|
+
.domains-detail-header { padding:18px 16px; }
|
|
38
|
+
.domains-detail-header h2 { font-size:19px; }
|
|
39
|
+
.domains-detail>.mojo-tabs>.mojo-tab-list { padding:0 12px; }
|
|
40
|
+
.domains-detail .mojo-tab { padding-left:10px;padding-right:10px; }
|
|
41
|
+
.domains-detail-panel { padding:18px 16px; }
|
|
42
|
+
.domains-detail .flat-row { grid-template-columns:100px minmax(0,1fr);gap:10px; }
|
|
43
|
+
.domains-detail .flat-action:empty { display:none; }
|
|
44
|
+
.domains-detail .flat-value>.btn { margin-top:8px; }
|
|
45
|
+
.domains-detail .dns-record-toolbar { align-items:stretch; }
|
|
46
|
+
.domains-detail .dns-toolbar-actions { flex-wrap:wrap; }
|
|
47
|
+
.domains-narrow-scope { display:inline; }
|
|
48
|
+
.domains-narrow-status { display:block; }
|
|
49
|
+
.domains-page .tbl { table-layout:fixed;width:100%; }
|
|
50
|
+
.domains-page .tbl th:first-child { width:auto; }
|
|
51
|
+
.domains-page .tbl th:last-child { width:128px; }
|
|
52
|
+
.domains-page .tbl td:last-child .btn { white-space:nowrap; }
|
|
53
|
+
.domains-page .tbl th,.domains-page .tbl td { padding-left:12px;padding-right:12px;overflow-wrap:anywhere; }
|
|
54
|
+
}
|
|
55
|
+
.domains-registration .domains-overview-actions:first-child { margin-top:0; }
|
|
56
|
+
.domains-registration .detail-grid,.domains-registration-contact .detail-grid { margin:20px 0;display:grid; }
|
|
57
|
+
.domains-registration .detail-grid>div,.domains-registration-contact .detail-grid>div { display:grid;grid-template-columns:minmax(130px,.45fr) minmax(0,1fr);gap:16px;padding:11px 0;border-bottom:1px solid var(--line); }
|
|
58
|
+
.domains-registration .detail-grid dt,.domains-registration-contact .detail-grid dt { color:var(--mute); }
|
|
59
|
+
.domains-registration .detail-grid dd,.domains-registration-contact .detail-grid dd { margin:0;overflow-wrap:anywhere; }
|
|
60
|
+
.domains-registration-contact>.dim { margin:0 0 20px; }
|
|
61
|
+
.domains-registration-contact .mojo-tab-panel,.dns-registrant-scoped .mojo-tab-panel { padding-top:18px; }
|
|
62
|
+
@media(max-width:560px) {
|
|
63
|
+
.domains-registration .detail-grid>div,.domains-registration-contact .detail-grid>div { grid-template-columns:minmax(100px,.45fr) minmax(0,1fr);gap:10px; }
|
|
64
|
+
.domains-registration-contact .col-6,.dns-registrant-scoped .col-6 { grid-column:1/-1; }
|
|
65
|
+
.dns-registrant-scoped .form-grid>.col-6:nth-child(-n+2) { grid-column:span 6; }
|
|
66
|
+
}
|
|
2
67
|
|
|
3
|
-
.
|
|
4
|
-
.domains-header { align-items: center; margin-bottom: 24px; }
|
|
5
|
-
|
|
6
|
-
.domains-page, .domains-page > * { min-width: 0; }
|
|
7
|
-
.domains-demo { grid-template-columns: minmax(0, 1fr); }
|
|
8
|
-
.domains-demo > * { min-width: 0; }
|
|
9
|
-
@media (max-width: 560px) { .domains-header { align-items: flex-start; } }
|
|
10
|
-
|
|
11
|
-
.domain-onboarding > .dim { margin: 4px 0 20px; }
|
|
12
|
-
.domain-onboarding .dns-registrar-grid { gap: 20px; }
|
|
68
|
+
.dns-registrant-scoped .dns-contact-status { flex-direction:row;align-items:center;justify-content:flex-start; }
|
|
@@ -115,6 +115,7 @@
|
|
|
115
115
|
.edge-vhost-editor .edge-route-row { align-items: end; }
|
|
116
116
|
.edge-hostname-detail .edge-route-actions .btn { white-space: nowrap; }
|
|
117
117
|
|
|
118
|
+
.edge-serving-actions { display:flex;flex-wrap:wrap;gap:8px; }
|
|
118
119
|
.edge-serving-heading.page-header { align-items: flex-start; }
|
|
119
120
|
.edge-serving-heading.page-header > div { min-width: 0; }
|
|
120
121
|
.edge-serving-evidence .edge-row-actions { align-items: center; gap: 6px; }
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
because it names a token rather than a color. */
|
|
4
4
|
|
|
5
5
|
/* ── Page shell ─────────────────────────────────────────────────── */
|
|
6
|
+
.jobs-page-links { display:flex;flex-wrap:wrap;gap:8px; }
|
|
6
7
|
.jobs-dashboard { display: grid; gap: 14px; }
|
|
7
8
|
.jobs-page-head {
|
|
8
9
|
display: flex; align-items: flex-start; justify-content: space-between; gap: 16px;
|
package/src/ui/FilterBar.tsx
CHANGED
|
@@ -2,12 +2,15 @@ import { ComboBox } from './ComboBox';
|
|
|
2
2
|
// FilterBar — web-mojo's filter UX: an "Add filter" menu listing every
|
|
3
3
|
// available filter with a per-type icon and a check on the active ones, each
|
|
4
4
|
// opening a type-appropriate dialog; applied filters become editable pills.
|
|
5
|
-
// Adding a filter to a page is one entry in a `filters` array.
|
|
5
|
+
// Adding a filter to a page is one entry in a `filters` array. Keys owned by
|
|
6
|
+
// the selected preset (`owned`) are the segment's to show: no pill, no count,
|
|
7
|
+
// and Clear all keeps them (see preset-owned.ts).
|
|
6
8
|
import { useEffect, useRef, useState } from 'react';
|
|
7
9
|
import { modal } from './modal';
|
|
8
10
|
import { DateRangePicker } from './date/DateRangePicker';
|
|
9
11
|
import { formatFilterDisplay } from '../client/lookups';
|
|
10
12
|
import type { ActiveFilter, TableParamsApi } from '../client/params';
|
|
13
|
+
import { isPresetOwned } from './preset-owned';
|
|
11
14
|
|
|
12
15
|
export type FilterType = 'text' | 'select' | 'multiselect' | 'number' | 'boolean' | 'date' | 'daterange';
|
|
13
16
|
|
|
@@ -168,7 +171,7 @@ async function openFilterDialog(def: FilterDef, current: ActiveFilter | undefine
|
|
|
168
171
|
}
|
|
169
172
|
|
|
170
173
|
// ── the bar ───────────────────────────────────────────────────────────
|
|
171
|
-
export function FilterBar({ defs, params }: { defs: FilterDef[]; params: TableParamsApi }) {
|
|
174
|
+
export function FilterBar({ defs, params, owned = {} }: { defs: FilterDef[]; params: TableParamsApi; owned?: Readonly<Record<string, string>> }) {
|
|
172
175
|
const [open, setOpen] = useState(false);
|
|
173
176
|
const wrapRef = useRef<HTMLDivElement>(null);
|
|
174
177
|
|
|
@@ -187,11 +190,14 @@ export function FilterBar({ defs, params }: { defs: FilterDef[]; params: TablePa
|
|
|
187
190
|
}, [open]);
|
|
188
191
|
|
|
189
192
|
const active = params.activeFilters;
|
|
193
|
+
// The menu's checks still show owned keys (they ARE filtering, and the
|
|
194
|
+
// dialog edits them); the count and Clear all are the pills' business.
|
|
195
|
+
const clearable = active.filter((f) => !isPresetOwned(f, owned));
|
|
190
196
|
|
|
191
197
|
return (
|
|
192
198
|
<div className="filter-menu-wrap" ref={wrapRef}>
|
|
193
199
|
<button
|
|
194
|
-
className={`btn btn-compact${
|
|
200
|
+
className={`btn btn-compact${clearable.length ? ' btn-has-filters' : ''}`}
|
|
195
201
|
onClick={() => setOpen((v) => !v)}
|
|
196
202
|
aria-expanded={open}
|
|
197
203
|
title="Filter"
|
|
@@ -199,7 +205,7 @@ export function FilterBar({ defs, params }: { defs: FilterDef[]; params: TablePa
|
|
|
199
205
|
{/* Label collapses on normal widths (tool-group icon mode);
|
|
200
206
|
the active-count badge stays visible either way. */}
|
|
201
207
|
<i className="bi bi-funnel" /> <span className="btn-label">Filter</span>
|
|
202
|
-
{
|
|
208
|
+
{clearable.length > 0 && <span className="filter-count">{clearable.length}</span>}
|
|
203
209
|
</button>
|
|
204
210
|
{open && (
|
|
205
211
|
<div className="filter-menu" role="menu">
|
|
@@ -221,12 +227,12 @@ export function FilterBar({ defs, params }: { defs: FilterDef[]; params: TablePa
|
|
|
221
227
|
</button>
|
|
222
228
|
);
|
|
223
229
|
})}
|
|
224
|
-
{
|
|
230
|
+
{clearable.length > 0 && (
|
|
225
231
|
<>
|
|
226
232
|
<div className="filter-menu-sep" />
|
|
227
233
|
<button
|
|
228
234
|
className="filter-menu-item filter-menu-clear"
|
|
229
|
-
onClick={() => { setOpen(false); params.clearFilters(); }}
|
|
235
|
+
onClick={() => { setOpen(false); params.clearFilters(owned); }}
|
|
230
236
|
>
|
|
231
237
|
<i className="bi bi-x-circle" /> Clear all filters
|
|
232
238
|
</button>
|
|
@@ -238,9 +244,9 @@ export function FilterBar({ defs, params }: { defs: FilterDef[]; params: TablePa
|
|
|
238
244
|
);
|
|
239
245
|
}
|
|
240
246
|
|
|
241
|
-
/** Active-filter pills — click to edit, × to remove. */
|
|
242
|
-
export function FilterPills({ defs, params }: { defs: FilterDef[]; params: TableParamsApi }) {
|
|
243
|
-
const active = params.activeFilters;
|
|
247
|
+
/** Active-filter pills — click to edit, × to remove. Preset-owned keys never pill. */
|
|
248
|
+
export function FilterPills({ defs, params, owned = {} }: { defs: FilterDef[]; params: TableParamsApi; owned?: Readonly<Record<string, string>> }) {
|
|
249
|
+
const active = params.activeFilters.filter((f) => !isPresetOwned(f, owned));
|
|
244
250
|
if (active.length === 0) return null;
|
|
245
251
|
return (
|
|
246
252
|
<div className="pill-row">
|
|
@@ -261,7 +267,7 @@ export function FilterPills({ defs, params }: { defs: FilterDef[]; params: Table
|
|
|
261
267
|
</span>
|
|
262
268
|
);
|
|
263
269
|
})}
|
|
264
|
-
<button className="pill-clear" onClick={() => params.clearFilters()}>Clear all</button>
|
|
270
|
+
<button className="pill-clear" onClick={() => params.clearFilters(owned)}>Clear all</button>
|
|
265
271
|
</div>
|
|
266
272
|
);
|
|
267
273
|
}
|
package/src/ui/ModelTable.tsx
CHANGED
|
@@ -30,6 +30,7 @@ import { busyWhile } from './loading';
|
|
|
30
30
|
import { modal } from './modal';
|
|
31
31
|
import { RenderGuard } from './safe-node';
|
|
32
32
|
import { fixedParamKeys, mergeFixedParams } from './fixed-params';
|
|
33
|
+
import { isPresetOwned, presetOwnedFilters } from './preset-owned';
|
|
33
34
|
import { toast } from './toast';
|
|
34
35
|
import * as fmt from './format';
|
|
35
36
|
import { warnOnce } from './warn-once';
|
|
@@ -428,6 +429,9 @@ function ModelTableView<T extends { id: RowId }>(props: ModelTableProps<T> & {
|
|
|
428
429
|
const firstRow = count === 0 ? 0 : (p.page - 1) * p.size + 1;
|
|
429
430
|
const lastRow = Math.min(count, (p.page - 1) * p.size + rows.length);
|
|
430
431
|
const isFiltered = p.activeFilters.length > 0 || (searchable && p.search !== '');
|
|
432
|
+
// The selected segment owns its keys: no pill, and every Clear keeps them.
|
|
433
|
+
const owned = presetOwnedFilters(presets, p.presetActive);
|
|
434
|
+
const clearable = p.activeFilters.some((f) => !isPresetOwned(f, owned)) || (searchable && p.search !== '');
|
|
431
435
|
|
|
432
436
|
// Save the view on every params/visibility change (post-hydration only,
|
|
433
437
|
// so the restore itself never writes back a half-applied state).
|
|
@@ -750,7 +754,7 @@ function ModelTableView<T extends { id: RowId }>(props: ModelTableProps<T> & {
|
|
|
750
754
|
toolbar). Icons + tooltips only; text labels appear
|
|
751
755
|
only on very wide screens via .btn-label. */}
|
|
752
756
|
<div className="tool-group">
|
|
753
|
-
{filters.length > 0 && <FilterBar defs={filters} params={p} />}
|
|
757
|
+
{filters.length > 0 && <FilterBar defs={filters} params={p} owned={owned} />}
|
|
754
758
|
{columnChooser && (
|
|
755
759
|
<MenuDropdown button={(open, toggle) => (
|
|
756
760
|
<button className="btn btn-compact" onClick={toggle} aria-expanded={open} title="Choose columns">
|
|
@@ -838,7 +842,7 @@ function ModelTableView<T extends { id: RowId }>(props: ModelTableProps<T> & {
|
|
|
838
842
|
</div>
|
|
839
843
|
)}
|
|
840
844
|
|
|
841
|
-
<FilterPills defs={filters} params={p} />
|
|
845
|
+
<FilterPills defs={filters} params={p} owned={owned} />
|
|
842
846
|
|
|
843
847
|
{selectable && batchActions.length > 0 && selected.size > 0 && (
|
|
844
848
|
<div className="batch-bar" role="region" aria-label="Batch actions">
|
|
@@ -939,7 +943,7 @@ function ModelTableView<T extends { id: RowId }>(props: ModelTableProps<T> & {
|
|
|
939
943
|
{isFiltered ? (
|
|
940
944
|
<>
|
|
941
945
|
<h3>No results match your filters</h3>
|
|
942
|
-
<button className="btn" onClick={() => p.clearFilters()}>Clear filters</button>
|
|
946
|
+
{clearable && <button className="btn" onClick={() => p.clearFilters(owned)}>Clear filters</button>}
|
|
943
947
|
</>
|
|
944
948
|
) : (
|
|
945
949
|
<h3>No {title.toLowerCase()} yet</h3>
|
package/src/ui/SidebarNav.tsx
CHANGED
|
@@ -115,6 +115,15 @@ function countDestinations(items: MenuItem[]): number {
|
|
|
115
115
|
+ countDestinations(item.children ?? []), 0);
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
+
/** Keep secondary tools searchable without filling the everyday navigation. */
|
|
119
|
+
function primaryTree(item: MenuItem, pathname: string): MenuItem | null {
|
|
120
|
+
if (item.searchOnly && !treeOwnsRoute(item, pathname)) return null;
|
|
121
|
+
if (!item.children) return item;
|
|
122
|
+
const children = item.children.map(child => primaryTree(child, pathname))
|
|
123
|
+
.filter((child): child is MenuItem => child != null);
|
|
124
|
+
return !item.route && children.length === 0 ? null : { ...item, children };
|
|
125
|
+
}
|
|
126
|
+
|
|
118
127
|
function treeOwnsRoute(item: MenuItem, pathname: string): boolean {
|
|
119
128
|
if (item.route && routesMatch(item.route, pathname, item.exact)) return true;
|
|
120
129
|
return (item.children ?? []).some((child) => treeOwnsRoute(child, pathname));
|
|
@@ -299,7 +308,7 @@ export function SidebarNav({ collapsed = false, onRequestExpand }: SidebarNavPro
|
|
|
299
308
|
const normalizedQuery = query.trim().toLocaleLowerCase();
|
|
300
309
|
const filteredItems = useMemo(() => normalizedQuery
|
|
301
310
|
? retainRunDividers(items, items.map((item) => filterTree(item, normalizedQuery)))
|
|
302
|
-
: items, [items, normalizedQuery]);
|
|
311
|
+
: retainRunDividers(items, items.map(item => primaryTree(item, pathname))), [items, normalizedQuery, pathname]);
|
|
303
312
|
const resultCount = countDestinations(filteredItems);
|
|
304
313
|
|
|
305
314
|
if (!menu) return null;
|
package/src/ui/menu-registry.ts
CHANGED
|
@@ -29,6 +29,8 @@ export interface MenuItem {
|
|
|
29
29
|
label?: string;
|
|
30
30
|
/** Extra quick-navigation terms that are never rendered. */
|
|
31
31
|
keywords?: string[];
|
|
32
|
+
/** In accordion menus, show only in search or while this destination is active. */
|
|
33
|
+
searchOnly?: boolean;
|
|
32
34
|
icon?: string;
|
|
33
35
|
/** Absolute route within the hash router, e.g. '/users'. */
|
|
34
36
|
route?: string;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// presetOwnedFilters — the filter keys the selected segment already shows.
|
|
2
|
+
//
|
|
3
|
+
// A preset is a param bundle with DERIVED active state: its keys live in the
|
|
4
|
+
// params store like any filter. Rendering them as pills as well says the
|
|
5
|
+
// same thing twice, and the pill's × (or Clear all) flips the segment behind
|
|
6
|
+
// the operator's back — a default "Staff" preset cleared to "All users", or
|
|
7
|
+
// a raw lookup like `user__player_profiles__group__not` shown as a filter.
|
|
8
|
+
// So the segment is the one control for the keys it owns: no pill, no count
|
|
9
|
+
// on the Filter button, and Clear all keeps them. A filter the operator adds
|
|
10
|
+
// on top still pills; editing an owned key through the Filter menu changes
|
|
11
|
+
// the value, the preset stops matching, and the pill comes back.
|
|
12
|
+
//
|
|
13
|
+
// This module is intentionally NOT exported from ui/index.ts — it is
|
|
14
|
+
// ModelTable plumbing, kept pure so the verify script can pin it without
|
|
15
|
+
// loading ModelTable's router/query graph.
|
|
16
|
+
import type { ActiveFilter } from '../client/params';
|
|
17
|
+
|
|
18
|
+
interface PresetParams { params: Record<string, string> }
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Union of the params of every active preset. Matching is the params
|
|
22
|
+
* store's own `presetActive`, so a segment highlights exactly when it owns
|
|
23
|
+
* its keys. The empty "All" preset owns nothing.
|
|
24
|
+
*/
|
|
25
|
+
export function presetOwnedFilters(
|
|
26
|
+
presets: readonly PresetParams[],
|
|
27
|
+
isActive: (params: Record<string, string>) => boolean,
|
|
28
|
+
): Record<string, string> {
|
|
29
|
+
const owned: Record<string, string> = {};
|
|
30
|
+
for (const { params } of presets) {
|
|
31
|
+
if (Object.keys(params).length > 0 && isActive(params)) Object.assign(owned, params);
|
|
32
|
+
}
|
|
33
|
+
return owned;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** True when a segment owns this filter. A daterange is owned through its `dr_field`. */
|
|
37
|
+
export function isPresetOwned(f: ActiveFilter, owned: Readonly<Record<string, string>>): boolean {
|
|
38
|
+
return f.kind === 'daterange' ? owned.dr_field === f.id : owned[f.id] !== undefined;
|
|
39
|
+
}
|